From 478dccfec0cb371cc0465593fe95bf833fba92da Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 6 Feb 2026 10:57:09 +0100 Subject: [PATCH 01/65] fix: --- .../chat/browser/tools/languageModelToolsService.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 5536b0defbe..01655ada724 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -206,6 +206,13 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo if (agentModeEnabled !== false) { return true; } + + // Internal tools that cannot be referenced in prompts are always permitted + // since they are infrastructure tools (e.g. inline_chat_exit), not user-facing agent tools + if (!isToolSet(toolOrToolSet) && !toolOrToolSet.canBeReferencedInPrompt && toolOrToolSet.source === ToolDataSource.Internal) { + return true; + } + const permittedInternalToolSetIds = [SpecedToolAliases.read, SpecedToolAliases.search, SpecedToolAliases.web]; if (isToolSet(toolOrToolSet)) { const permitted = toolOrToolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolOrToolSet.referenceName); From 94e4148f68ed1f6691e92d114f0dd09e394bf153 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Mon, 9 Feb 2026 09:35:06 +0100 Subject: [PATCH 02/65] Update src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/tools/languageModelToolsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 01655ada724..3fc83c0f2f0 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -209,7 +209,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo // Internal tools that cannot be referenced in prompts are always permitted // since they are infrastructure tools (e.g. inline_chat_exit), not user-facing agent tools - if (!isToolSet(toolOrToolSet) && !toolOrToolSet.canBeReferencedInPrompt && toolOrToolSet.source === ToolDataSource.Internal) { + if (!isToolSet(toolOrToolSet) && !toolOrToolSet.canBeReferencedInPrompt && toolOrToolSet.source.type === 'internal') { return true; } From 8a10750c10a127156617748f58afaa3afafc4f42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:37:43 +0000 Subject: [PATCH 03/65] Initial plan From 07e38233ff0b2ff650f354de2cd103c085eec92e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:54:43 +0000 Subject: [PATCH 04/65] fix: restrict internal tool bypass to explicitly non-referenceable tools - Change condition from `!canBeReferencedInPrompt` to `canBeReferencedInPrompt === false` to only bypass agent mode checks for tools that explicitly cannot be referenced (like inline_chat_exit) - Add test case for issue #292935 to verify internal tools with canBeReferencedInPrompt=false are permitted when agent mode is disabled - Update comment to clarify "explicitly cannot be referenced" Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../tools/languageModelToolsService.ts | 4 +- .../tools/languageModelToolsService.test.ts | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 3fc83c0f2f0..5ad3e412a11 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -207,9 +207,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return true; } - // Internal tools that cannot be referenced in prompts are always permitted + // Internal tools that explicitly cannot be referenced in prompts are always permitted // since they are infrastructure tools (e.g. inline_chat_exit), not user-facing agent tools - if (!isToolSet(toolOrToolSet) && !toolOrToolSet.canBeReferencedInPrompt && toolOrToolSet.source.type === 'internal') { + if (!isToolSet(toolOrToolSet) && toolOrToolSet.canBeReferencedInPrompt === false && toolOrToolSet.source.type === 'internal') { return true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index b212e9cb255..87abfa833ad 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -3475,6 +3475,52 @@ suite('LanguageModelToolsService', () => { assert.ok(toolIds.includes('multiSetTool'), 'Tool should be permitted if it belongs to at least one permitted toolset'); }); + test('isPermitted allows internal tools with canBeReferencedInPrompt=false when agent mode is disabled (issue #292935)', () => { + // Disable agent mode + configurationService.setUserConfiguration(ChatConfiguration.AgentEnabled, false); + + // Create internal infrastructure tool that explicitly cannot be referenced in prompts + const infrastructureTool: IToolData = { + id: 'infrastructureToolInternal', + toolReferenceName: 'infrastructureToolRef', + modelDescription: 'Infrastructure Tool', + displayName: 'Infrastructure Tool', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: false, + }; + store.add(service.registerToolData(infrastructureTool)); + + // Create internal tool with canBeReferencedInPrompt=true (should be blocked) + const referencableTool: IToolData = { + id: 'referencableTool', + toolReferenceName: 'referencableToolRef', + modelDescription: 'Referencable Tool', + displayName: 'Referencable Tool', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + }; + store.add(service.registerToolData(referencableTool)); + + // Create internal tool with canBeReferencedInPrompt=undefined (should be blocked) + const undefinedTool: IToolData = { + id: 'undefinedTool', + toolReferenceName: 'undefinedToolRef', + modelDescription: 'Undefined Tool', + displayName: 'Undefined Tool', + source: ToolDataSource.Internal, + // canBeReferencedInPrompt is undefined + }; + store.add(service.registerToolData(undefinedTool)); + + // Get tools - only the infrastructure tool should be available + const tools = Array.from(service.getTools(undefined)); + const toolIds = tools.map(t => t.id); + + assert.ok(toolIds.includes('infrastructureToolInternal'), 'Internal infrastructure tool with canBeReferencedInPrompt=false should be permitted when agent mode is disabled'); + assert.ok(!toolIds.includes('referencableTool'), 'Internal tool with canBeReferencedInPrompt=true should NOT be permitted when agent mode is disabled'); + assert.ok(!toolIds.includes('undefinedTool'), 'Internal tool with canBeReferencedInPrompt=undefined should NOT be permitted when agent mode is disabled'); + }); + suite('ToolSet when clause filtering (issue #291154)', () => { test('ToolSet.getTools filters tools by when clause', () => { // Create a context key for testing From 474b6d0eec1ecca29b9fb05094ea6bb5a147e66a Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 11:25:37 -0800 Subject: [PATCH 05/65] Add guidelines and prompt for fixing unhandled errors from VS Code error telemetry --- .github/prompts/fix-error.prompt.md | 17 +++++++ .github/skills/fix-errors/SKILL.md | 71 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 .github/prompts/fix-error.prompt.md create mode 100644 .github/skills/fix-errors/SKILL.md diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md new file mode 100644 index 00000000000..1cdc8fa90b4 --- /dev/null +++ b/.github/prompts/fix-error.prompt.md @@ -0,0 +1,17 @@ +--- +agent: agent +description: 'Fix an unhandled error from the VS Code error telemetry dashboard' +argument-hint: Paste the GitHub issue URL for the error-telemetry issue +tools: ['edit', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'execute/createAndRunTask', 'execute/runTask', 'read/getTaskOutput', 'search/usages', 'read/problems', 'search/changes', 'execute/testFailure', 'todo', 'execute/runTests', 'web/fetch', 'web/githubRepo'] +--- + +The user has given you a GitHub issue URL for an unhandled error from the VS Code error telemetry dashboard. Fetch the issue to retrieve its details (error message, stack trace, hit count, affected users). + +Follow the `fix-errors` skill guidelines to fix this error. Key principles: + +1. **Do NOT fix at the crash site.** Do not add guards, try/catch, or fallback values at the bottom of the stack trace. That only masks the problem. +2. **Trace the data flow upward** through the call stack to find the producer of invalid data. +3. **If the producer is cross-process** (e.g., IPC) and cannot be identified from the stack alone, **enrich the error message** with diagnostic context (data type, truncated value, operation name) so the next telemetry cycle reveals the source. Do NOT silently swallow the error. +4. **If the producer is identifiable**, fix it directly. + +After making changes, check for compilation errors via the build task and run relevant unit tests. diff --git a/.github/skills/fix-errors/SKILL.md b/.github/skills/fix-errors/SKILL.md new file mode 100644 index 00000000000..7a03d11ee0c --- /dev/null +++ b/.github/skills/fix-errors/SKILL.md @@ -0,0 +1,71 @@ +--- +name: fix-errors +description: Guidelines for fixing unhandled errors from the VS Code error telemetry dashboard. Use when investigating error-telemetry issues with stack traces, error messages, and hit/user counts. Covers tracing data flow through call stacks, identifying producers of invalid data vs. consumers that crash, enriching error messages for telemetry diagnosis, and avoiding common anti-patterns like silently swallowing errors. +--- + +When fixing an unhandled error from the telemetry dashboard, the issue typically contains an error message, a stack trace, hit count, and affected user count. + +## Approach + +### 1. Do NOT fix at the crash site + +The error manifests at a specific line in the stack trace, but **the fix almost never belongs there**. Fixing at the crash site (e.g., adding a `typeof` guard in a `revive()` function, swallowing the error with a try/catch, or returning a fallback value) only masks the real problem. The invalid data still flows through the system and will cause failures elsewhere. + +### 2. Trace the data flow upward through the call stack + +Read each frame in the stack trace from bottom to top. For each frame, understand: +- What data is being passed and what is expected +- Where that data originated (IPC message, extension API call, storage, user input, etc.) +- Whether the data could have been corrupted or malformed at that point + +The goal is to find the **producer of invalid data**, not the consumer that crashes on it. + +### 3. When the producer cannot be identified from the stack alone + +Sometimes the stack trace only shows the receiving/consuming side (e.g., an IPC server handler). The sending side is in a different process and not in the stack. In this case: + +- **Enrich the error message** at the consuming site with diagnostic context: the type of the invalid data, a truncated representation of its value, and which operation/command received it. This information flows into the error telemetry dashboard automatically via the unhandled error pipeline. +- **Do NOT silently swallow the error** — let it still throw so it remains visible in telemetry, but with enough context to identify the sender in the next telemetry cycle. +- Consider adding the same enrichment to the low-level validation function that throws (e.g., include the invalid value in the error message) so the telemetry captures it regardless of call site. + +### 4. When the producer IS identifiable + +Fix the producer directly: +- Validate or sanitize data before sending it over IPC / storing it / passing it to APIs +- Ensure serialization/deserialization preserves types correctly (e.g., URI objects should serialize as `UriComponents` objects, not as strings) + +## Example + +Given a stack trace like: +``` +at _validateUri (uri.ts) ← validation throws +at new Uri (uri.ts) ← constructor +at URI.revive (uri.ts) ← revive assumes valid UriComponents +at SomeChannel.call (ipc.ts) ← IPC handler receives arg from another process +``` + +**Wrong fix**: Add a `typeof` guard in `URI.revive` to return `undefined` for non-object input. This silences the error but the caller still expects a valid URI and will fail later. + +**Right fix (when producer is unknown)**: Enrich the error at the IPC handler level and in `_validateUri` itself to include the actual invalid value, so telemetry reveals what data is being sent and from where. Example: +```typescript +// In the IPC handler — validate before revive +function reviveUri(data: UriComponents | URI | undefined | null, context: string): URI { + if (data && typeof data !== 'object') { + throw new Error(`[Channel] Invalid URI data for '${context}': type=${typeof data}, value=${String(data).substring(0, 100)}`); + } + // ... +} + +// In _validateUri — include the scheme value +throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); +``` + +**Right fix (when producer is known)**: Fix the code that sends malformed data. For example, if an authentication provider passes a stringified URI instead of a `UriComponents` object to a logger creation call, fix that call site to pass the proper object. + +## Guidelines + +- Prefer enriching error messages over adding try/catch guards +- Truncate any user-controlled values included in error messages (to avoid PII and keep messages bounded) +- Do not change the behavior of shared utility functions (like `URI.revive`) in ways that affect all callers — fix at the specific call site or producer +- Run the relevant unit tests after making changes +- Check for compilation errors via the build task before declaring work complete From 6d3266f1d0c53ca166a8cf0674491d89957a3872 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 12:05:19 -0800 Subject: [PATCH 06/65] Fix URI scheme error in LoggerChannelClient.deregisterLogger (#293503) LoggerChannelClient.deregisterLogger accepted only URI but the base interface accepts URI | string. Callers passing string logger IDs (e.g. mcpServer) sent raw strings over IPC, causing URI.revive to treat the string as a scheme and fail validation. Fix: accept URI | string and convert via toResource() before IPC, matching the pattern already used by setVisibility. Also enrich the _validateUri error message with the actual scheme value for future telemetry diagnosis. --- src/vs/base/common/uri.ts | 2 +- src/vs/platform/log/common/logIpc.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index ac2ae962d72..a1e4914f7b7 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,7 +22,7 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - throw new Error('[UriError]: Scheme contains illegal characters.'); + throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 diff --git a/src/vs/platform/log/common/logIpc.ts b/src/vs/platform/log/common/logIpc.ts index 4a767070d98..584e349ff66 100644 --- a/src/vs/platform/log/common/logIpc.ts +++ b/src/vs/platform/log/common/logIpc.ts @@ -45,9 +45,9 @@ export class LoggerChannelClient extends AbstractLoggerService implements ILogge this.channel.call('registerLogger', [logger, this.windowId]); } - override deregisterLogger(resource: URI): void { - super.deregisterLogger(resource); - this.channel.call('deregisterLogger', [resource, this.windowId]); + override deregisterLogger(idOrResource: URI | string): void { + super.deregisterLogger(idOrResource); + this.channel.call('deregisterLogger', [this.toResource(idOrResource), this.windowId]); } override setLogLevel(logLevel: LogLevel): void; From 3dac14a31ac5dc3c8f2ccf7f5d85a27f5932f0e6 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 12:31:32 -0800 Subject: [PATCH 07/65] Improve URI scheme error message to specify illegal characters found --- src/vs/base/common/uri.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index a1e4914f7b7..ba1dd71eb5e 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,7 +22,8 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); + const illegalChar = ret.scheme.match(/[^\w\d+.-]/)?.[0] ?? (ret.scheme.length > 0 && !/^\w/.test(ret.scheme) ? ret.scheme[0] : '?'); + throw new Error(`[UriError]: Scheme contains illegal characters. Found '${illegalChar}'`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 From 5c54822b7c534e14ed09f45f29b2eefc3f224565 Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:33:11 -0800 Subject: [PATCH 08/65] Update src/vs/platform/log/common/logIpc.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/platform/log/common/logIpc.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/log/common/logIpc.ts b/src/vs/platform/log/common/logIpc.ts index 584e349ff66..a75622ae2b6 100644 --- a/src/vs/platform/log/common/logIpc.ts +++ b/src/vs/platform/log/common/logIpc.ts @@ -46,8 +46,9 @@ export class LoggerChannelClient extends AbstractLoggerService implements ILogge } override deregisterLogger(idOrResource: URI | string): void { - super.deregisterLogger(idOrResource); - this.channel.call('deregisterLogger', [this.toResource(idOrResource), this.windowId]); + const resource = this.toResource(idOrResource); + super.deregisterLogger(resource); + this.channel.call('deregisterLogger', [resource, this.windowId]); } override setLogLevel(logLevel: LogLevel): void; From ec6a947451c7d3541a2aa09ceaa1712d45cb5f87 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 13:14:06 -0800 Subject: [PATCH 09/65] Enhance URI scheme error message to include length and clarify illegal characters --- src/vs/base/common/uri.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index ba1dd71eb5e..19f876a0300 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,8 +22,8 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - const illegalChar = ret.scheme.match(/[^\w\d+.-]/)?.[0] ?? (ret.scheme.length > 0 && !/^\w/.test(ret.scheme) ? ret.scheme[0] : '?'); - throw new Error(`[UriError]: Scheme contains illegal characters. Found '${illegalChar}'`); + const illegalChar = ret.scheme.match(/[^\w\d+.-]/)?.[0]; + throw new Error(`[UriError]: Scheme contains illegal characters.${illegalChar ? ` Found '${illegalChar}'` : ''} (len:${ret.scheme.length})`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 From a9535a9f9610e2bac70148477204b78989bebf44 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Mon, 9 Feb 2026 13:35:08 -0800 Subject: [PATCH 10/65] Enhance URI scheme validation error message to include index of illegal characters and total count --- src/vs/base/common/uri.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index 19f876a0300..53267ec7b00 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,8 +22,11 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - const illegalChar = ret.scheme.match(/[^\w\d+.-]/)?.[0]; - throw new Error(`[UriError]: Scheme contains illegal characters.${illegalChar ? ` Found '${illegalChar}'` : ''} (len:${ret.scheme.length})`); + const matches = [...ret.scheme.matchAll(/[^\w\d+.-]/gu)]; + const detail = matches.length > 0 + ? ` Found '${matches[0][0]}' at index ${matches[0].index} (${matches.length} total)` + : ''; + throw new Error(`[UriError]: Scheme contains illegal characters.${detail} (len:${ret.scheme.length})`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 From ed67fd6d5e5757306cb9b173ffe49c23d5a7a333 Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Tue, 10 Feb 2026 16:02:25 +0100 Subject: [PATCH 11/65] using the addsmall and removesmall codicons (#294100) --- .../editor/contrib/hover/browser/markdownHoverParticipant.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts index e289a3dbca0..ba261eaa4a4 100644 --- a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts @@ -36,8 +36,8 @@ import { HoverStartSource } from './hoverOperation.js'; import { ScrollEvent } from '../../../../base/common/scrollable.js'; const $ = dom.$; -const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.add, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.')); -const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.remove, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.')); +const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.addSmall, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.')); +const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.removeSmall, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.')); export class MarkdownHover implements IHoverPart { From 02c85ea7ba33085261f97b44a5d5663fae4a8b7b Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 15:09:22 +0000 Subject: [PATCH 12/65] Enhance 2026 theme: update editor sticky scroll background and adjust light theme tab and breadcrumb colors --- extensions/theme-2026/themes/2026-dark.json | 1 + extensions/theme-2026/themes/2026-light.json | 4 +- extensions/theme-2026/themes/styles.css | 54 +++++++------------- 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 40140f3b6ac..76e870eca8b 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -102,6 +102,7 @@ "commandCenter.border": "#2E3031", "editor.background": "#121314", "editor.foreground": "#BBBEBF", + "editorStickyScroll.background": "#121314", "editorLineNumber.foreground": "#858889", "editorLineNumber.activeForeground": "#BBBEBF", "editorCursor.foreground": "#BBBEBF", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index da3849b0028..59fcde38957 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -185,7 +185,7 @@ "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", - "tab.activeBackground": "#FAFAFD", + "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", "tab.inactiveForeground": "#666666", @@ -202,7 +202,7 @@ "editorGroupHeader.tabsBackground": "#FAFAFD", "editorGroupHeader.tabsBorder": "#F2F3F4FF", "breadcrumb.foreground": "#666666", - "breadcrumb.background": "#FAFAFD", + "breadcrumb.background": "#FFFFFF", "breadcrumb.focusForeground": "#202020", "breadcrumb.activeSelectionForeground": "#202020", "breadcrumbPicker.background": "#F0F0F3", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 0141a1a695c..18dd038f6f4 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -131,15 +131,11 @@ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: inset var(--shadow-active-tab); + /* background: var(--vs) */ position: relative; z-index: 5; border-radius: 0; border-top: none !important; - background: linear-gradient( - to bottom, - color-mix(in srgb, var(--vscode-focusBorder) 10%, transparent) 0%, - transparent 100% - ), var(--vscode-tab-activeBackground) !important; } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { @@ -437,6 +433,10 @@ background: color-mix(in srgb, var(--vscode-breadcrumbPicker-background) 60%, transparent) !important; } +.monaco-workbench.vs .breadcrumbs-control { + border-bottom: 1px solid var(--vscode-editorWidget-border); +} + /* Input Boxes */ .monaco-workbench .monaco-inputbox, .monaco-workbench .suggest-input-container { @@ -569,10 +569,14 @@ /* Sticky Scroll */ .monaco-workbench .monaco-editor .sticky-widget { box-shadow: var(--shadow-md) !important; - border-bottom: none !important; - background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; + border-bottom: var(--vscode-editorWidget-border) !important; + background: transparent !important; + backdrop-filter: var(--backdrop-blur-md) !important; + -webkit-backdrop-filter: var(--backdrop-blur-md) !important; +} + +.monaco-workbench .monaco-editor .sticky-widget > * { + background: transparent !important; } .monaco-workbench.vs-dark .monaco-editor .sticky-widget { @@ -580,31 +584,9 @@ } .monaco-workbench .monaco-editor .sticky-widget .sticky-widget-lines { - background-color: transparent !important; - background: transparent !important; -} - -.monaco-workbench.vs-dark .monaco-editor .sticky-widget, -.monaco-workbench .monaco-editor .sticky-widget-focus-preview, -.monaco-workbench .monaco-editor .sticky-scroll-focus-line, -.monaco-workbench .monaco-editor .focused .sticky-widget, -.monaco-workbench .monaco-editor:has(.sticky-widget:focus-within) .sticky-widget { - background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; - box-shadow: var(--shadow-hover) !important; -} - -.monaco-editor .sticky-widget .sticky-line-content, -.monaco-workbench .monaco-editor .sticky-widget .sticky-line-number { - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; - background-color: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent); -} - -.monaco-workbench.vs-dark .monaco-editor .sticky-widget .sticky-line-content, -.monaco-workbench.vs-dark .monaco-editor .sticky-widget .sticky-line-number { - background-color: color-mix(in srgb, var(--vscode-editor-background) 30%, transparent); + background: color-mix(in srgb, var(--vscode-editor-background) 40%, transparent) !important; + backdrop-filter: var(--backdrop-blur-md) !important; + -webkit-backdrop-filter: var(--backdrop-blur-md) !important; } .monaco-editor .rename-box.preview { @@ -616,9 +598,9 @@ /* Notebook */ -/* .monaco-workbench .notebookOverlay.notebook-editor { +.monaco-workbench .notebookOverlay.notebook-editor { z-index: 35 !important; -} */ +} .monaco-workbench .notebookOverlay .monaco-list-row .cell-editor-part:before { box-shadow: inset var(--shadow-sm); From 9b7047a5f04c8add7b8d83870419be86fadc7cfa Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 15:45:58 +0000 Subject: [PATCH 13/65] Update widget border colors and adjust border-radius for consistency in themes --- extensions/theme-2026/themes/2026-light.json | 8 ++--- extensions/theme-2026/themes/styles.css | 32 ++++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index da3849b0028..2d4524f4947 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -49,7 +49,7 @@ "inputValidation.errorForeground": "#202020", "scrollbar.shadow": "#00000000", "widget.shadow": "#00000000", - "widget.border": "#F2F3F4FF", + "widget.border": "#EEEEF1", "editorStickyScroll.shadow": "#00000000", "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", @@ -133,15 +133,15 @@ "editorBracketMatch.background": "#0069CC40", "editorBracketMatch.border": "#F2F3F4FF", "editorWidget.background": "#F0F0F3", - "editorWidget.border": "#F2F3F4FF", + "editorWidget.border": "#EEEEF1", "editorWidget.foreground": "#202020", "editorSuggestWidget.background": "#F0F0F3", - "editorSuggestWidget.border": "#F2F3F4FF", + "editorSuggestWidget.border": "#EEEEF1", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", "editorSuggestWidget.selectedBackground": "#0069CC26", "editorHoverWidget.background": "#F0F0F3", - "editorHoverWidget.border": "#F2F3F4FF", + "editorHoverWidget.border": "#EEEEF1", "peekView.border": "#0069CC", "peekViewEditor.background": "#F0F0F3", "peekViewEditor.matchHighlightBackground": "#0069CC33", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 0141a1a695c..ba7b49f8c63 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -7,7 +7,7 @@ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; - --radius-xl: 12px; + /* --radius-lg: 12px; */ --shadow-xs: 0 0 2px rgba(0, 0, 0, 0.06); --shadow-sm: 0 0 4px rgba(0, 0, 0, 0.08); @@ -189,10 +189,6 @@ border: 1px solid var(--vscode-menu-border) !important; } -.monaco-workbench .quick-input-widget .monaco-list-rows { - background-color: transparent !important; -} - .monaco-workbench .quick-input-widget .quick-input-header, .monaco-workbench .quick-input-widget .quick-input-list, .monaco-workbench .quick-input-widget .quick-input-titlebar, @@ -207,6 +203,10 @@ outline: none !important; } +.monaco-workbench .monaco-editor .suggest-widget .monaco-list { + border-radius: var(--radius-lg); +} + .monaco-workbench .quick-input-widget .monaco-inputbox { box-shadow: none !important; background: transparent !important; @@ -286,7 +286,7 @@ /* Context Menus */ .monaco-workbench .monaco-menu .monaco-action-bar.vertical { box-shadow: var(--shadow-lg); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); } @@ -294,7 +294,7 @@ .monaco-workbench .context-view .monaco-menu { box-shadow: var(--shadow-lg); border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); } .monaco-workbench .action-widget { @@ -310,8 +310,7 @@ /* Suggest Widget */ .monaco-workbench .monaco-editor .suggest-widget { box-shadow: var(--shadow-lg); - border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); background: color-mix(in srgb, var(--vscode-editorSuggestWidget-background) 60%, transparent) !important; @@ -331,10 +330,17 @@ margin-top: 4px !important; } +.monaco-workbench .inline-chat-gutter-menu { + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + backdrop-filter: var(--backdrop-blur-md); + -webkit-backdrop-filter: var(--backdrop-blur-md); +} + /* Dialog */ .monaco-workbench .monaco-dialog-box { box-shadow: var(--shadow-2xl); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-lg); -webkit-backdrop-filter: var(--backdrop-blur-lg); background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; @@ -538,7 +544,7 @@ /* Parameter Hints */ .monaco-workbench .monaco-editor .parameter-hints-widget { box-shadow: var(--shadow-lg); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); } @@ -641,7 +647,7 @@ .monaco-workbench .monaco-editor .inline-chat { box-shadow: var(--shadow-lg); border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); } /* Command Center */ @@ -678,7 +684,7 @@ } .monaco-dialog-modal-block .dialog-shadow { - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); } .monaco-workbench .unified-quick-access-tabs { From 95eaf36777b85502ddffce44f95d3c81e6195cd4 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Tue, 10 Feb 2026 16:52:59 +0100 Subject: [PATCH 14/65] Remove ask mode from picker and add a specific icon for it. --- .../widget/input/modePickerActionItem.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index ca2ba9b331c..071230853fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -45,7 +45,13 @@ export interface IModePickerDelegate { } // TODO: there should be an icon contributed for built-in modes -const builtinDefaultIcon = Codicon.tasklist; +const builtinDefaultIcon = (mode: IChatMode) => { + switch (mode.name.get().toLowerCase()) { + case 'ask': return Codicon.ask; + case 'plan': return Codicon.tasklist; + default: return undefined; + } +}; export class ModePickerActionItem extends ChatInputPickerActionViewItem { constructor( @@ -165,7 +171,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { ...makeAction(mode, currentMode), tooltip: '', hover: { content: mode.description.get() ?? chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip, position: this.pickerOptions.hoverPosition }, - icon: mode.icon.get() ?? (isModeConsideredBuiltIn(mode, this._productService) ? builtinDefaultIcon : undefined), + icon: mode.icon.get() ?? (isModeConsideredBuiltIn(mode, this._productService) ? builtinDefaultIcon(mode) : undefined), category: agentModeDisabledViaPolicy ? policyDisabledCategory : customCategory }; }; @@ -203,7 +209,18 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const shouldHideEditMode = configurationService.getValue(ChatConfiguration.EditModeHidden) && chatAgentService.hasToolsAgent && currentMode.id !== ChatMode.Edit.id; - const otherBuiltinModes = modes.builtin.filter(mode => mode.id !== ChatMode.Agent.id && !(shouldHideEditMode && mode.id === ChatMode.Edit.id)); + const otherBuiltinModes = modes.builtin.filter(mode => { + if (mode.id === ChatMode.Agent.id) { + return false; + } + if (shouldHideEditMode && mode.id === ChatMode.Edit.id) { + return false; + } + if (mode.id === ChatMode.Ask.id) { + return false; + } + return true; + }); // Filter out 'implement' mode from the dropdown - it's available for handoffs but not user-selectable const customModes = groupBy( modes.custom, @@ -267,7 +284,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { // Every built-in mode should have an icon. // TODO: this should be provided by the mode itself if (!icon && isModeConsideredBuiltIn(currentMode, this._productService)) { - icon = builtinDefaultIcon; + icon = builtinDefaultIcon(currentMode); } const labelElements = []; @@ -284,21 +301,6 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } } -/** - * Returns true if the mode is the built-in 'implement' mode from the chat extension. - * This mode is hidden from the mode picker but available for handoffs. - */ -export function isBuiltinImplementMode(mode: IChatMode, productService: IProductService): boolean { - if (mode.name.get().toLowerCase() !== 'implement') { - return false; - } - if (mode.source?.storage !== PromptsStorage.extension) { - return false; - } - const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; - return !!chatExtensionId && mode.source.extensionId.value === chatExtensionId; -} - function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductService): boolean { if (mode.isBuiltin) { return true; From 0d12078d05d3442f99c17df2b4335ce274c3f5cf Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 15:54:58 +0000 Subject: [PATCH 15/65] Update secondary hover background colors for consistency in the 2026 Light theme --- extensions/theme-2026/themes/2026-light.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 2d4524f4947..23f1449887b 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -23,7 +23,7 @@ "button.border": "#F2F3F4FF", "button.secondaryBackground": "#EDEDED", "button.secondaryForeground": "#202020", - "button.secondaryHoverBackground": "#E6E6E6", + "button.secondaryHoverBackground": "#F3F3F3", "checkbox.background": "#EDEDED", "checkbox.border": "#D8D8D8", "checkbox.foreground": "#202020", @@ -64,7 +64,7 @@ "list.activeSelectionForeground": "#202020", "list.inactiveSelectionBackground": "#E0E0E0", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#F7F7F7", + "list.hoverBackground": "#F3F3F3", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", "list.focusBackground": "#0069CC1A", @@ -106,7 +106,7 @@ "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#FAFAFD", - "commandCenter.activeBackground": "#F7F7F7", + "commandCenter.activeBackground": "#F3F3F3", "commandCenter.border": "#D8D8D8", "editor.background": "#FFFFFF", "editor.foreground": "#202020", @@ -127,7 +127,7 @@ "editorLink.activeForeground": "#0069CC", "editorWhitespace.foreground": "#66666640", "editorIndentGuide.background": "#F7F7F740", - "editorIndentGuide.activeBackground": "#F7F7F7", + "editorIndentGuide.activeBackground": "#F3F3F3", "editorRuler.foreground": "#F7F7F7", "editorCodeLens.foreground": "#666666", "editorBracketMatch.background": "#0069CC40", @@ -179,8 +179,8 @@ "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#F0F0F3", "statusBar.noFolderForeground": "#666666", - "statusBarItem.activeBackground": "#E6E6E6", - "statusBarItem.hoverBackground": "#F7F7F7", + "statusBarItem.activeBackground": "#F3F3F3", + "statusBarItem.hoverBackground": "#F3F3F3", "statusBarItem.focusBorder": "#0069CCFF", "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", @@ -193,7 +193,7 @@ "tab.lastPinnedBorder": "#F2F3F4FF", "tab.activeBorder": "#FAFAFD", "tab.activeBorderTop": "#000000", - "tab.hoverBackground": "#F7F7F7", + "tab.hoverBackground": "#F3F3F3", "tab.hoverForeground": "#202020", "tab.unfocusedActiveBackground": "#FAFAFD", "tab.unfocusedActiveForeground": "#666666", From 85e3eef75f0dec998e0af74f3e4663e84beec6e6 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 15:59:02 +0000 Subject: [PATCH 16/65] Add border and box-shadow to notifications center for improved visibility --- extensions/theme-2026/themes/styles.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index ba7b49f8c63..54cafd922ad 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -271,6 +271,8 @@ backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); background: color-mix(in srgb, var(--vscode-notifications-background) 60%, transparent) !important; + border: 1px solid var(--vscode-editorWidget-border) !important; + box-shadow: var(--shadow-lg) !important; } .monaco-workbench.vs-dark .notifications-center { From 48bfa565862a3004f721620cf622937d10edded1 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 10 Feb 2026 16:02:05 +0000 Subject: [PATCH 17/65] Update border-radius to use variable for consistency in chat input styles --- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 952b37142f1..004b2fd6eca 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1239,14 +1239,14 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .interactive-input-part > .chat-todo-list-widget-container .chat-todo-list-widget .monaco-scrollable-element .monaco-list-rows .monaco-list-row { - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-small); } .interactive-session .interactive-input-part.compact .chat-input-container { display: flex; justify-content: space-between; padding-bottom: 0; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-small); } .interactive-session .interactive-input-and-side-toolbar { From ba35bc88a71572aa5839bae94910aaa71cce1a5e Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:23:47 -0800 Subject: [PATCH 18/65] Revert "Merge pull request #294104 from microsoft/tyriar/revert" This reverts commit a8f61833a363b30e231ac4455a0de1d3ab23fb78, reversing changes made to 7e883bb39849880de7e865720ba34e649cb6a0ca. --- package-lock.json | 96 +++++++++---------- package.json | 20 ++-- remote/package-lock.json | 96 +++++++++---------- remote/package.json | 20 ++-- remote/web/package-lock.json | 88 ++++++++--------- remote/web/package.json | 18 ++-- .../terminal/browser/xterm/xtermTerminal.ts | 6 +- 7 files changed, 173 insertions(+), 171 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49beda3da1a..7c6806f2b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,16 +30,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -3895,30 +3895,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -3928,7 +3928,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -3950,63 +3950,63 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", - "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", + "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index d7794f4e979..e79cfc22656 100644 --- a/package.json +++ b/package.json @@ -92,16 +92,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", diff --git a/remote/package-lock.json b/remote/package-lock.json index a6c0504563b..de6d200c162 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -577,30 +577,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -610,67 +610,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", - "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", + "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index e90da38f129..603dc5559ba 100644 --- a/remote/package.json +++ b/remote/package.json @@ -17,16 +17,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index a5d7be69eff..e83d2319329 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/xterm": "^6.1.0-beta.152", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -99,30 +99,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -132,58 +132,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 4bf2586bc81..f738d5554fa 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/xterm": "^6.1.0-beta.152", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 96098673241..d66f1c39a38 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -244,9 +244,11 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach scrollSensitivity: config.mouseWheelScrollSensitivity, scrollOnEraseInDisplay: true, wordSeparator: config.wordSeparators, - overviewRuler: options.disableOverviewRuler ? { width: 0 } : { + scrollbar: options.disableOverviewRuler ? undefined : { width: 14, - showTopBorder: true, + overviewRuler: { + showTopBorder: true, + }, }, ignoreBracketedPasteMode: config.ignoreBracketedPasteMode, rescaleOverlappingGlyphs: config.rescaleOverlappingGlyphs, From 03bd51cfa3584a8116b523a9e9fa852ca4ae2553 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 10 Feb 2026 16:26:10 +0000 Subject: [PATCH 19/65] Move PreToolUse to extension (#294042) * Move PreToolUse to extension * Migrate PostToolUse --- .../common/extensionsApiProposals.ts | 2 +- .../api/common/extHostLanguageModelTools.ts | 1 + .../tools/languageModelToolsService.ts | 160 +---- .../chat/common/hooks/hooksCommandTypes.ts | 60 -- .../common/hooks/hooksExecutionService.ts | 199 +---- .../contrib/chat/common/hooks/hooksTypes.ts | 90 +-- .../common/tools/languageModelToolsService.ts | 12 + .../tools/languageModelToolsService.test.ts | 303 ++------ .../test/common/hooksExecutionService.test.ts | 678 ------------------ ...scode.proposed.chatParticipantPrivate.d.ts | 14 +- 10 files changed, 127 insertions(+), 1392 deletions(-) diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 8769eef63c2..fa429fbf424 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -56,7 +56,7 @@ const _allApiProposals = { }, chatParticipantPrivate: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts', - version: 12 + version: 13 }, chatPromptFiles: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts', diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 68b95881b32..6095aca4be1 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -127,6 +127,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, + preToolUseResult: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.preToolUseResult : undefined, }, token); const dto: Dto = result instanceof SerializableObjectWithBuffers ? result.value : result; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index aba6e221b02..4bc871cae89 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -20,6 +20,7 @@ import { derived, derivedOpts, IObservable, IReader, observableFromEventOpts, Ob import Severity from '../../../../../base/common/severity.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; @@ -35,8 +36,6 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; -import { IPostToolUseCallerInput, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../common/hooks/hooksTypes.js'; -import { HookAbortError, IHooksExecutionService } from '../../common/hooks/hooksExecutionService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../common/chatModes.js'; @@ -45,12 +44,11 @@ import { ChatConfiguration } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; -import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, isToolSet, ToolDataSource, toolContentToA11yString, toolMatchesModel, ToolSet, VSCodeToolReference, IToolSet, ToolSetForModel, IToolInvokedEvent } from '../../common/tools/languageModelToolsService.js'; -import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; -import { URI } from '../../../../../base/common/uri.js'; import { chatSessionResourceToId } from '../../common/model/chatUri.js'; import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; +import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; +import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; const jsonSchemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); @@ -126,7 +124,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IStorageService private readonly _storageService: IStorageService, @ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService, - @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, @ICommandService private readonly _commandService: ICommandService, ) { super(); @@ -367,74 +364,35 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - /** - * Execute the preToolUse hook and handle denial. - * Returns an object containing: - * - denialResult: A tool result if the hook denied execution (caller should return early) - * - hookResult: The full hook result for use in auto-approval logic (allow/ask decisions) - * @param pendingInvocation If there's an existing streaming invocation from beginToolCall, pass it here to cancel it instead of creating a new one. - */ - private async _executePreToolUseHook( + private _handlePreToolUseDenial( dto: IToolInvocation, + hookResult: IExternalPreToolUseHookResult, toolData: IToolData | undefined, - request: IChatRequestModel | undefined, pendingInvocation: ChatToolInvocation | undefined, - token: CancellationToken - ): Promise<{ denialResult?: IToolResult; hookResult?: IPreToolUseHookResult }> { - // Skip hook if no session context or tool doesn't exist - if (!dto.context?.sessionResource || !toolData) { - return {}; + request: IChatRequestModel | undefined, + ): IToolResult { + const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); + const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason); + this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); + + if (toolData) { + if (pendingInvocation) { + pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); + } else if (request) { + const cancelledInvocation = ChatToolInvocation.createCancelled( + { toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId }, + dto.parameters, + ToolConfirmKind.Denied, + reason + ); + this._chatService.appendProgress(request, cancelledInvocation); + } } - const hookInput: IPreToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - toolCallId: dto.callId, + return { + content: [{ kind: 'text', value: `Tool execution denied: ${hookReason}` }], + toolResultError: hookReason, }; - let hookResult: IPreToolUseHookResult | undefined; - try { - hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token); - } catch (e) { - if (e instanceof HookAbortError) { - this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} aborted by preToolUse hook: ${e.stopReason}`); - throw new CancellationError(); - } - throw e; - } - - if (hookResult?.permissionDecision === 'deny') { - const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); - const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason); - this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); - - // Handle the tool invocation in cancelled state - if (toolData) { - if (pendingInvocation) { - // If there's an existing streaming invocation, cancel it - pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); - } else if (request) { - // Otherwise create a new cancelled invocation and add it to the chat model - const toolInvocation = ChatToolInvocation.createCancelled( - { toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId }, - dto.parameters, - ToolConfirmKind.Denied, - reason - ); - this._chatService.appendProgress(request, toolInvocation); - } - } - - const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason); - return { - denialResult: { - content: [{ kind: 'text', value: denialMessage }], - toolResultError: hookReason, - }, - hookResult, - }; - } - - return { hookResult }; } /** @@ -469,53 +427,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - /** - * Execute the postToolUse hook after tool completion. - * If the hook returns a "block" decision, additional context is appended to the tool result - * as feedback for the agent indicating the block and reason. The tool has already run, - * so blocking only provides feedback. - */ - private async _executePostToolUseHook( - dto: IToolInvocation, - toolResult: IToolResult, - token: CancellationToken - ): Promise { - if (!dto.context?.sessionResource) { - return; - } - - const hookInput: IPostToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - getToolResponseText: () => toolContentToA11yString(toolResult.content), - toolCallId: dto.callId, - }; - let hookResult; - try { - hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token); - } catch (e) { - if (e instanceof HookAbortError) { - this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook aborted for tool ${dto.toolId}: ${e.stopReason}`); - throw new CancellationError(); - } - throw e; - } - - if (hookResult?.decision === 'block') { - const hookReason = hookResult.reason ?? localize('postToolUseHookBlockedNoReason', "Hook blocked tool result"); - this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook blocked for tool ${dto.toolId}: ${hookReason}`); - const blockMessage = localize('postToolUseHookBlockedContext', "The PostToolUse hook blocked this tool result. Reason: {0}", hookReason); - toolResult.content.push({ kind: 'text', value: '\n\n' + blockMessage + '\n' }); - } - - if (hookResult?.additionalContext) { - // Append additional context from all hooks to the tool result content - for (const context of hookResult.additionalContext) { - toolResult.content.push({ kind: 'text', value: '\n\n' + context + '\n' }); - } - } - } - async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); @@ -563,14 +474,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo token = source.token; } - // Execute preToolUse hook - returns early if hook denies execution - const { denialResult: hookDenialResult, hookResult: preToolUseHookResult } = await this._executePreToolUseHook(dto, toolData, request, toolInvocation, token); - if (hookDenialResult) { - // Clean up pending tool call if it exists + // Handle preToolUse hook denial + const preToolUseHookResult = dto.preToolUseResult; + if (preToolUseHookResult?.permissionDecision === 'deny') { + const denialResult = this._handlePreToolUseDenial(dto, preToolUseHookResult, toolData, toolInvocation, request); if (pendingToolCallKey) { this._pendingToolCalls.delete(pendingToolCallKey); } - return hookDenialResult; + return denialResult; } // Apply updatedInput from preToolUse hook if provided, after validating against the tool's input schema @@ -730,9 +641,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - // Execute postToolUse hook after successful tool execution - await this._executePostToolUseHook(dto, toolResult, token); - this._telemetryService.publicLog2( 'languageModelToolInvoked', { @@ -777,7 +685,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IPreToolUseHookResult | undefined, token: CancellationToken): Promise { + private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IExternalPreToolUseHookResult | undefined, token: CancellationToken): Promise { let forceConfirmationReason: string | undefined; if (hookResult?.permissionDecision === 'ask') { const hookMessage = localize('preToolUseHookRequiredConfirmation', "{0} required confirmation", HookType.PreToolUse); @@ -798,7 +706,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo * since when the hook returns 'ask' and preparedInvocation was undefined, we create one. */ private async resolveAutoConfirmFromHook( - hookResult: IPreToolUseHookResult | undefined, + hookResult: IExternalPreToolUseHookResult | undefined, tool: IToolEntry, dto: IToolInvocation, preparedInvocation: IPreparedToolInvocation | undefined, diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts index b939a402040..1b31c0fafd5 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts @@ -9,10 +9,6 @@ * "External" means these types define the contract between VS Code and the external hook * command process. * - * Examples: - * - IPreToolUseCommandInput: sent TO the spawned command via stdin - * - IPreToolUseCommandOutput: received FROM the spawned command via stdout - * * Internal types (in hooksTypes.ts) are used within VS Code. */ @@ -69,59 +65,3 @@ export interface IHookCommandResult { } //#endregion - -//#region PreToolUse Hook Types - -/** - * Tool-specific command input fields for preToolUse hook. - * These are mixed with IHookCommandInput at runtime. - */ -export interface IPreToolUseCommandInput { - readonly tool_name: string; - readonly tool_input: unknown; - readonly tool_use_id: string; -} - -/** - * External command output for preToolUse hook. - * Extends common output with hookSpecificOutput wrapper. - */ -export interface IPreToolUseCommandOutput extends IHookCommandOutput { - readonly hookSpecificOutput?: { - readonly hookEventName?: string; - readonly permissionDecision?: 'allow' | 'deny'; - readonly permissionDecisionReason?: string; - readonly updatedInput?: object; - readonly additionalContext?: string; - }; -} - -//#endregion - -//#region PostToolUse Hook Types - -/** - * Tool-specific command input fields for postToolUse hook. - * These are mixed with IHookCommandInput at runtime. - */ -export interface IPostToolUseCommandInput { - readonly tool_name: string; - readonly tool_input: unknown; - readonly tool_response: string; - readonly tool_use_id: string; -} - -/** - * External command output for postToolUse hook. - * Extends common output with decision control fields. - */ -export interface IPostToolUseCommandOutput extends IHookCommandOutput { - readonly decision?: 'block'; - readonly reason?: string; - readonly hookSpecificOutput?: { - readonly hookEventName?: string; - readonly additionalContext?: string; - }; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts index 266ce042a49..5e6f02e05b4 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts @@ -13,24 +13,15 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { ILogService } from '../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; -import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; +import { HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; import { HookCommandResultKind, IHookCommandInput, IHookCommandResult, - IPostToolUseCommandInput, - IPreToolUseCommandInput } from './hooksCommandTypes.js'; import { commonHookOutputValidator, IHookResult, - IPostToolUseCallerInput, - IPostToolUseHookResult, - IPreToolUseCallerInput, - IPreToolUseHookResult, - postToolUseOutputValidator, - PreToolUsePermissionDecision, - preToolUseOutputValidator } from './hooksTypes.js'; export const hooksOutputChannelId = 'hooksExecution'; @@ -101,21 +92,6 @@ export interface IHooksExecutionService { * Execute hooks of the given type for the given session */ executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; - - /** - * Execute preToolUse hooks with typed input and validated output. - * The execution service builds the full hook input from the caller input plus session context. - * Returns a combined result with common fields and permission decision. - */ - executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise; - - /** - * Execute postToolUse hooks with typed input and validated output. - * Called after a tool completes successfully. The execution service builds the full hook input - * from the caller input plus session context. - * Returns a combined result with decision and additional context. - */ - executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise; } /** @@ -439,6 +415,15 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } } + // Emit aggregated warnings for any hook results that had warning messages + this._emitAggregatedWarnings(hookType, sessionResource, results); + + // If any hook set stopReason, emit progress so it's visible to the user + const stoppedResult = results.find(r => r.stopReason !== undefined); + if (stoppedResult?.stopReason) { + this._emitHookProgress(hookType, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); + } + return results; } finally { this._onDidExecuteHook.fire({ @@ -451,170 +436,6 @@ export class HooksExecutionService extends Disposable implements IHooksExecution } } - async executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise { - const toolSpecificInput: IPreToolUseCommandInput = { - tool_name: input.toolName, - tool_input: input.toolInput, - tool_use_id: input.toolCallId, - }; - - const results = await this.executeHook(HookType.PreToolUse, sessionResource, { - input: toolSpecificInput, - token: token ?? CancellationToken.None, - }); - - // Run all hooks and collapse results. Most restrictive decision wins: deny > ask > allow. - // Collect all additionalContext strings from every hook. - const allAdditionalContext: string[] = []; - let mostRestrictiveDecision: PreToolUsePermissionDecision | undefined; - let winningResult: IHookResult | undefined; - let winningReason: string | undefined; - let lastUpdatedInput: object | undefined; - - for (const result of results) { - if (result.resultKind === 'success' && typeof result.output === 'object' && result.output !== null) { - const validationResult = preToolUseOutputValidator.validate(result.output); - if (!validationResult.error) { - const hookSpecificOutput = validationResult.content.hookSpecificOutput; - if (hookSpecificOutput) { - // Validate hookEventName if present - must match the hook type - if (hookSpecificOutput.hookEventName !== undefined && hookSpecificOutput.hookEventName !== HookType.PreToolUse) { - this._logService.warn(`[HooksExecutionService] preToolUse hook returned invalid hookEventName '${hookSpecificOutput.hookEventName}', expected '${HookType.PreToolUse}'`); - continue; - } - - // Collect additionalContext from every hook - if (hookSpecificOutput.additionalContext) { - allAdditionalContext.push(hookSpecificOutput.additionalContext); - } - - // Track the last updatedInput (later hooks override earlier ones) - if (hookSpecificOutput.updatedInput) { - lastUpdatedInput = hookSpecificOutput.updatedInput; - } - - // Track the most restrictive decision: deny > ask > allow - const decision = hookSpecificOutput.permissionDecision; - if (decision && this._isMoreRestrictive(decision, mostRestrictiveDecision)) { - mostRestrictiveDecision = decision; - winningResult = result; - winningReason = hookSpecificOutput.permissionDecisionReason; - } - } - } else { - this._logService.warn(`[HooksExecutionService] preToolUse hook output validation failed: ${validationResult.error.message}`); - } - } - } - - const baseResult = winningResult ?? results[0]; - - // Emit hook progress for warning messages after all hooks have completed - this._emitAggregatedWarnings(HookType.PreToolUse, sessionResource, results); - - // If any hook set stopReason, throw HookAbortError after processing - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason !== undefined) { - this._emitHookProgress(HookType.PreToolUse, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - throw new HookAbortError(HookType.PreToolUse, stoppedResult.stopReason ?? 'Unknown error'); - } - - return { - ...baseResult, - permissionDecision: mostRestrictiveDecision, - permissionDecisionReason: winningReason, - updatedInput: lastUpdatedInput, - additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, - }; - } - - /** - * Returns true if `candidate` is more restrictive than `current`. - * Restriction order: deny > ask > allow. - */ - private _isMoreRestrictive(candidate: PreToolUsePermissionDecision, current: PreToolUsePermissionDecision | undefined): boolean { - const order: Record = { 'deny': 2, 'ask': 1, 'allow': 0 }; - return current === undefined || order[candidate] > order[current]; - } - - async executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise { - // Check if there are PostToolUse hooks registered before doing any work stringifying tool results - const hooks = this.getHooksForSession(sessionResource); - const hookCommands = hooks?.[HookType.PostToolUse]; - if (!hookCommands || hookCommands.length === 0) { - return undefined; - } - - // Lazily render tool response text only when hooks are registered - const toolResponseText = input.getToolResponseText(); - - const toolSpecificInput: IPostToolUseCommandInput = { - tool_name: input.toolName, - tool_input: input.toolInput, - tool_response: toolResponseText, - tool_use_id: input.toolCallId, - }; - - const results = await this.executeHook(HookType.PostToolUse, sessionResource, { - input: toolSpecificInput, - token: token ?? CancellationToken.None, - }); - - // Run all hooks and collapse results. Block is the most restrictive decision. - // Collect all additionalContext strings from every hook. - const allAdditionalContext: string[] = []; - let hasBlock = false; - let blockReason: string | undefined; - let blockResult: IHookResult | undefined; - - for (const result of results) { - if (result.resultKind === 'success' && typeof result.output === 'object' && result.output !== null) { - const validationResult = postToolUseOutputValidator.validate(result.output); - if (!validationResult.error) { - const validated = validationResult.content; - - // Validate hookEventName if present - if (validated.hookSpecificOutput?.hookEventName !== undefined && validated.hookSpecificOutput.hookEventName !== HookType.PostToolUse) { - this._logService.warn(`[HooksExecutionService] postToolUse hook returned invalid hookEventName '${validated.hookSpecificOutput.hookEventName}', expected '${HookType.PostToolUse}'`); - continue; - } - - // Collect additionalContext from every hook - if (validated.hookSpecificOutput?.additionalContext) { - allAdditionalContext.push(validated.hookSpecificOutput.additionalContext); - } - - // Track the first block decision (most restrictive) - if (validated.decision === 'block' && !hasBlock) { - hasBlock = true; - blockReason = validated.reason; - blockResult = result; - } - } else { - this._logService.warn(`[HooksExecutionService] postToolUse hook output validation failed: ${validationResult.error.message}`); - } - } - } - - const baseResult = blockResult ?? results[0]; - - // Emit hook progress for warning messages after all hooks have completed - this._emitAggregatedWarnings(HookType.PostToolUse, sessionResource, results); - - // If any hook set stopReason, throw HookAbortError after processing - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason !== undefined) { - this._emitHookProgress(HookType.PostToolUse, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - throw new HookAbortError(HookType.PostToolUse, stoppedResult.stopReason ?? 'Unknown error'); - } - - return { - ...baseResult, - decision: hasBlock ? 'block' : undefined, - reason: blockReason, - additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, - }; - } } /** diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts index f35308d8935..1c4bceb5314 100644 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts @@ -9,14 +9,10 @@ * "Internal" means these types are used by VS Code code only - they never cross the * process boundary to external hook commands. They use camelCase for field names. * - * Examples: - * - IPreToolUseCallerInput: provided by VS Code callers (e.g., LanguageModelToolsService) - * - IPreToolUseHookResult: returned TO VS Code callers after processing command output - * * External types (in hooksCommandTypes.ts) define the contract with spawned commands. */ -import { vBoolean, vEnum, vObj, vObjAny, vOptionalProp, vString } from '../../../../../base/common/validation.js'; +import { vBoolean, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js'; //#region Common Hook Types @@ -58,87 +54,3 @@ export const commonHookOutputValidator = vObj({ }); //#endregion - -//#region PreToolUse Hook Types - -/** - * Input provided by VS Code callers when invoking the preToolUse hook. - */ -export interface IPreToolUseCallerInput { - readonly toolName: string; - readonly toolInput: unknown; - readonly toolCallId: string; -} - -export const preToolUseOutputValidator = vObj({ - hookSpecificOutput: vOptionalProp(vObj({ - hookEventName: vOptionalProp(vString()), - permissionDecision: vOptionalProp(vEnum('allow', 'deny', 'ask')), - permissionDecisionReason: vOptionalProp(vString()), - updatedInput: vOptionalProp(vObjAny()), - additionalContext: vOptionalProp(vString()), - })), -}); - -/** - * Valid permission decisions for preToolUse hooks. - * - 'allow': Auto-approve the tool execution (skip user confirmation) - * - 'deny': Deny the tool execution - * - 'ask': Always require user confirmation (never auto-approve) - */ -export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; - -/** - * Result from preToolUse hooks with permission decision fields. - * Returned to VS Code callers. Represents the collapsed result of all hooks. - */ -export interface IPreToolUseHookResult extends IHookResult { - readonly permissionDecision?: PreToolUsePermissionDecision; - readonly permissionDecisionReason?: string; - /** - * Modified tool input parameters from the hook. - * When set, replaces the original tool input before execution. - * Combine with 'allow' to auto-approve, or 'ask' to show modified input to the user. - */ - readonly updatedInput?: object; - readonly additionalContext?: string[]; -} - -//#endregion - -//#region PostToolUse Hook Types - -/** - * Input provided by VS Code callers when invoking the postToolUse hook. - * The toolResponse is a lazy getter that renders the tool result content to a string. - * It is only called if there are PostToolUse hooks registered. - */ -export interface IPostToolUseCallerInput { - readonly toolName: string; - readonly toolInput: unknown; - readonly getToolResponseText: () => string; - readonly toolCallId: string; -} - -export const postToolUseOutputValidator = vObj({ - decision: vOptionalProp(vEnum('block')), - reason: vOptionalProp(vString()), - hookSpecificOutput: vOptionalProp(vObj({ - hookEventName: vOptionalProp(vString()), - additionalContext: vOptionalProp(vString()), - })), -}); - -export type PostToolUseDecision = 'block'; - -/** - * Result from postToolUse hooks with decision fields. - * Returned to VS Code callers. Represents the collapsed result of all hooks. - */ -export interface IPostToolUseHookResult extends IHookResult { - readonly decision?: PostToolUseDecision; - readonly reason?: string; - readonly additionalContext?: string[]; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 05a5d1d1752..9153ccc9fac 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -162,6 +162,16 @@ export namespace ToolDataSource { } } +/** + * Pre-tool-use hook result passed from the extension when the hook was executed externally. + */ +export interface IExternalPreToolUseHookResult { + permissionDecision?: 'allow' | 'deny' | 'ask'; + permissionDecisionReason?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updatedInput?: Record; +} + export interface IToolInvocation { callId: string; toolId: string; @@ -184,6 +194,8 @@ export interface IToolInvocation { userSelectedTools?: UserSelectedTools; /** The label of the custom button selected by the user during confirmation, if custom buttons were used. */ selectedCustomButton?: string; + /** Pre-tool-use hook result passed from the extension, if the hook was already executed externally. */ + preToolUseResult?: IExternalPreToolUseHookResult; } export interface IToolInvocationContext { diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 3913649070d..a5b61a26879 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -34,7 +34,7 @@ import { ILanguageModelToolsConfirmationService } from '../../../common/tools/la import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; -import { IHookResult, IPostToolUseCallerInput, IPostToolUseHookResult, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../../common/hooks/hooksTypes.js'; +import { IHookResult } from '../../../common/hooks/hooksTypes.js'; import { IHooksExecutionService, IHooksExecutionOptions, IHooksExecutionProxy } from '../../../common/hooks/hooksExecutionService.js'; import { HookTypeValue, IChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -69,10 +69,6 @@ class MockHooksExecutionService implements IHooksExecutionService { readonly _serviceBrand: undefined; readonly onDidExecuteHook = Event.None; readonly onDidHookProgress = Event.None; - public preToolUseHookResult: IPreToolUseHookResult | undefined = undefined; - public postToolUseHookResult: IPostToolUseHookResult | undefined = undefined; - public lastPreToolUseInput: IPreToolUseCallerInput | undefined = undefined; - public lastPostToolUseInput: IPostToolUseCallerInput | undefined = undefined; setProxy(_proxy: IHooksExecutionProxy): void { } registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; } @@ -80,14 +76,6 @@ class MockHooksExecutionService implements IHooksExecutionService { executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise { return Promise.resolve([]); } - async executePreToolUseHook(_sessionResource: URI, input: IPreToolUseCallerInput, _token?: CancellationToken): Promise { - this.lastPreToolUseInput = input; - return this.preToolUseHookResult; - } - async executePostToolUseHook(_sessionResource: URI, input: IPostToolUseCallerInput, _token?: CancellationToken): Promise { - this.lastPostToolUseInput = input; - return this.postToolUseHookResult; - } } function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial) { @@ -3825,13 +3813,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook denies, tool returns error and creates cancelled invocation', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'deny', - permissionDecisionReason: 'Destructive operations require approval', - }; - const tool = registerToolForTest(hookService, store, 'hookDenyTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'should not run' }] }) }); @@ -3839,8 +3820,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test' }); + dto.preToolUseResult = { + permissionDecision: 'deny', + permissionDecisionReason: 'Destructive operations require approval', + }; + const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test' }), + dto, async () => 0, CancellationToken.None ); @@ -3863,12 +3850,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook allows, tool executes normally', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - }; - const tool = registerToolForTest(hookService, store, 'hookAllowTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) }); @@ -3876,8 +3857,13 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-allow', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-allow' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + }; + const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-allow' }), + dto, async () => 0, CancellationToken.None ); @@ -3888,8 +3874,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns undefined, tool executes normally', async () => { - mockHooksService.preToolUseHookResult = undefined; - const tool = registerToolForTest(hookService, store, 'hookUndefinedTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) }); @@ -3906,38 +3890,7 @@ suite('LanguageModelToolsService', () => { assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'success'); }); - test('hook receives correct input parameters', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - }; - - const tool = registerToolForTest(hookService, store, 'hookInputTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) - }); - - stubGetSession(hookChatService, 'hook-test-input', { requestId: 'req1' }); - - await hookService.invokeTool( - tool.makeDto({ param1: 'value1', param2: 42 }, { sessionId: 'hook-test-input' }), - async () => 0, - CancellationToken.None - ); - - assert.ok(mockHooksService.lastPreToolUseInput); - assert.strictEqual(mockHooksService.lastPreToolUseInput.toolName, 'hookInputTool'); - assert.deepStrictEqual(mockHooksService.lastPreToolUseInput.toolInput, { param1: 'value1', param2: 42 }); - }); - test('when hook denies, tool invoke is never called', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'deny', - permissionDecisionReason: 'Operation not allowed', - }; - let invokeCalled = false; const tool = registerToolForTest(hookService, store, 'hookNeverInvokeTool', { invoke: async () => { @@ -3949,8 +3902,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: unknown } = {}; stubGetSession(hookChatService, 'hook-test-no-invoke', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-no-invoke' }); + dto.preToolUseResult = { + permissionDecision: 'deny', + permissionDecisionReason: 'Operation not allowed', + }; + await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-no-invoke' }), + dto, async () => 0, CancellationToken.None ); @@ -3959,13 +3918,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns ask, tool is not auto-approved', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'ask', - permissionDecisionReason: 'Requires user confirmation', - }; - let invokeCompleted = false; const tool = registerToolForTest(hookService, store, 'hookAskTool', { invoke: async () => { @@ -3984,9 +3936,15 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-ask', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-ask' }); + dto.preToolUseResult = { + permissionDecision: 'ask', + permissionDecisionReason: 'Requires user confirmation', + }; + // Start invocation - it should wait for confirmation const invokePromise = hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-ask' }), + dto, async () => 0, CancellationToken.None ); @@ -4007,12 +3965,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns allow, tool is auto-approved', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - }; - let invokeCompleted = false; const tool = registerToolForTest(hookService, store, 'hookAutoApproveTool', { invoke: async () => { @@ -4031,9 +3983,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-auto-approve', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-auto-approve' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + }; + // Invoke the tool - it should auto-approve due to hook const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-auto-approve' }), + dto, async () => 0, CancellationToken.None ); @@ -4046,12 +4003,6 @@ suite('LanguageModelToolsService', () => { test('when hook returns updatedInput, tool is invoked with replaced parameters', async () => { let receivedParameters: Record | undefined; - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { safeCommand: 'echo hello' }, - }; const tool = registerToolForTest(hookService, store, 'hookUpdatedInputTool', { invoke: async (dto) => { @@ -4069,8 +4020,14 @@ suite('LanguageModelToolsService', () => { stubGetSession(hookChatService, 'hook-test-updated-input', { requestId: 'req1' }); + const dto = tool.makeDto({ originalCommand: 'rm -rf /' }, { sessionId: 'hook-test-updated-input' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { safeCommand: 'echo hello' }, + }; + await hookService.invokeTool( - tool.makeDto({ originalCommand: 'rm -rf /' }, { sessionId: 'hook-test-updated-input' }), + dto, async () => 0, CancellationToken.None ); @@ -4095,12 +4052,6 @@ suite('LanguageModelToolsService', () => { }); let receivedParameters: Record | undefined; - mockHooks.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { invalidField: 'wrong' }, - }; const tool = registerToolForTest(setup.service, store, 'hookValidationFailTool', { invoke: async (dto) => { @@ -4124,8 +4075,14 @@ suite('LanguageModelToolsService', () => { stubGetSession(setup.chatService, 'hook-test-validation-fail', { requestId: 'req1' }); + const dto = tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-fail' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { invalidField: 'wrong' }, + }; + await setup.service.invokeTool( - tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-fail' }), + dto, async () => 0, CancellationToken.None ); @@ -4151,12 +4108,6 @@ suite('LanguageModelToolsService', () => { }); let receivedParameters: Record | undefined; - mockHooks.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { command: 'safe-command' }, - }; const tool = registerToolForTest(setup.service, store, 'hookValidationPassTool', { invoke: async (dto) => { @@ -4180,8 +4131,14 @@ suite('LanguageModelToolsService', () => { stubGetSession(setup.chatService, 'hook-test-validation-pass', { requestId: 'req1' }); + const dto = tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-pass' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { command: 'safe-command' }, + }; + await setup.service.invokeTool( - tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-pass' }), + dto, async () => 0, CancellationToken.None ); @@ -4190,154 +4147,4 @@ suite('LanguageModelToolsService', () => { assert.deepStrictEqual(receivedParameters, { command: 'safe-command' }); }); }); - - suite('postToolUse hooks', () => { - let mockHooksService: MockHooksExecutionService; - let hookService: LanguageModelToolsService; - let hookChatService: MockChatService; - - setup(() => { - mockHooksService = new MockHooksExecutionService(); - const setup = createTestToolsService(store, { - hooksExecutionService: mockHooksService - }); - hookService = setup.service; - hookChatService = setup.chatService; - }); - - test('when hook blocks, block context is appended to tool result', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - resultKind: 'success', - decision: 'block', - reason: 'Lint errors detected', - }; - - const tool = registerToolForTest(hookService, store, 'postHookBlockTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-block', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-block' }), - async () => 0, - CancellationToken.None - ); - - // Original content should still be present - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - - // Block context should be appended wrapped in XML tags - assert.ok(result.content.length >= 2, 'Block context should be appended'); - const blockPart = result.content[1] as IToolResultTextPart; - assert.strictEqual(blockPart.kind, 'text'); - assert.ok(blockPart.value.includes(''), 'Block text should have opening tag'); - assert.ok(blockPart.value.includes(''), 'Block text should have closing tag'); - assert.ok(blockPart.value.includes('Lint errors detected'), 'Block text should include the reason'); - - // Should NOT set toolResultError - assert.strictEqual(result.toolResultError, undefined); - }); - - test('when hook returns additionalContext, it is appended to tool result', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - resultKind: 'success', - additionalContext: ['Consider running tests after this change'], - }; - - const tool = registerToolForTest(hookService, store, 'postHookContextTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-context', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-context' }), - async () => 0, - CancellationToken.None - ); - - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - - assert.ok(result.content.length >= 2, 'Additional context should be appended'); - const contextPart = result.content[1] as IToolResultTextPart; - assert.strictEqual(contextPart.kind, 'text'); - assert.ok(contextPart.value.includes(''), 'Context text should have opening tag'); - assert.ok(contextPart.value.includes(''), 'Context text should have closing tag'); - assert.ok(contextPart.value.includes('Consider running tests after this change')); - }); - - test('when hook returns undefined, tool result is unchanged', async () => { - mockHooksService.postToolUseHookResult = undefined; - - const tool = registerToolForTest(hookService, store, 'postHookNoopTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-noop', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-noop' }), - async () => 0, - CancellationToken.None - ); - - assert.strictEqual(result.content.length, 1); - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - }); - - test('hook receives correct input including tool response text', async () => { - mockHooksService.postToolUseHookResult = undefined; - - const tool = registerToolForTest(hookService, store, 'postHookInputTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'file contents here' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-input', { requestId: 'req1' }); - - await hookService.invokeTool( - tool.makeDto({ param1: 'value1' }, { sessionId: 'post-hook-input' }), - async () => 0, - CancellationToken.None - ); - - assert.ok(mockHooksService.lastPostToolUseInput); - assert.strictEqual(mockHooksService.lastPostToolUseInput.toolName, 'postHookInputTool'); - assert.deepStrictEqual(mockHooksService.lastPostToolUseInput.toolInput, { param1: 'value1' }); - assert.strictEqual(typeof mockHooksService.lastPostToolUseInput.getToolResponseText, 'function'); - }); - - test('when hook blocks with both decision and additionalContext, both are appended', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - resultKind: 'success', - decision: 'block', - reason: 'Security issue found', - additionalContext: ['Please review the file permissions'], - }; - - const tool = registerToolForTest(hookService, store, 'postHookBlockContextTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-block-ctx', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-block-ctx' }), - async () => 0, - CancellationToken.None - ); - - // Original + block message + additional context = 3 parts - assert.ok(result.content.length >= 3, 'Should have original, block message, and additional context'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original'); - assert.ok((result.content[1] as IToolResultTextPart).value.includes('Security issue found')); - assert.ok((result.content[2] as IToolResultTextPart).value.includes('Please review the file permissions')); - }); - }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts index 43f2f82410d..1493f35a29a 100644 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts @@ -389,684 +389,6 @@ suite('HooksExecutionService', () => { }); }); - suite('executePreToolUseHook', () => { - test('returns allow result when hook allows', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - permissionDecisionReason: 'Tool is safe' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - assert.strictEqual(result.permissionDecisionReason, 'Tool is safe'); - }); - - test('returns ask result when hook requires confirmation', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'ask', - permissionDecisionReason: 'Requires user approval' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Requires user approval'); - }); - - test('deny takes priority over ask and allow', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - // First hook returns allow, second returns ask, third returns deny - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow' } } - }; - } else if (callCount === 2) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'ask' } } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'Blocked' } } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2'), cmd('hook3')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'deny'); - assert.strictEqual(result.permissionDecisionReason, 'Blocked'); - }); - - test('ask takes priority over allow', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - // First hook returns allow, second returns ask - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow' } } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Need confirmation' } } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Need confirmation'); - }); - - test('ignores results with wrong hookEventName', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - // First hook returns allow but with wrong hookEventName - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', // Wrong hook type - permissionDecision: 'deny' - } - } - }; - } else { - // Second hook returns allow with correct hookEventName - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'allow' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - // The deny with wrong hookEventName should be ignored - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - }); - - test('allows results without hookEventName (optional field)', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - // No hookEventName - should be accepted - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - }); - - test('returns updatedInput when hook provides it', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - updatedInput: { path: '/safe/path.ts' } - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: { path: '/original/path.ts' }, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - assert.deepStrictEqual(result.updatedInput, { path: '/safe/path.ts' }); - }); - - test('later hook updatedInput overrides earlier one', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'first' } } } - }; - } - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'second' } } } - }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.updatedInput, { value: 'second' }); - }); - - test('returns result with updatedInput even without permission decision', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - updatedInput: { modified: true } - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.updatedInput, { modified: true }); - assert.strictEqual(result.permissionDecision, undefined); - }); - - test('updatedInput combined with ask shows modified input to user', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'ask', - permissionDecisionReason: 'Modified input needs review', - updatedInput: { command: 'echo safe' } - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: { command: 'rm -rf /' }, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Modified input needs review'); - assert.deepStrictEqual(result.updatedInput, { command: 'echo safe' }); - }); - }); - - suite('executePostToolUseHook', () => { - test('returns undefined when no hooks configured', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.strictEqual(result, undefined); - }); - - test('returns block decision when hook blocks', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Lint errors found' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.decision, 'block'); - assert.strictEqual(result.reason, 'Lint errors found'); - }); - - test('returns additionalContext from hookSpecificOutput', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'File was modified successfully' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.additionalContext, ['File was modified successfully']); - assert.strictEqual(result.decision, undefined); - }); - - test('block takes priority and collects all additionalContext', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Tests failed' - } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - additionalContext: 'Extra context from second hook' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.decision, 'block'); - assert.strictEqual(result.reason, 'Tests failed'); - assert.deepStrictEqual(result.additionalContext, ['Extra context from second hook']); - }); - - test('ignores results with wrong hookEventName', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: 'Should be ignored' - } - } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'Correct context' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.additionalContext, ['Correct context']); - }); - - test('passes tool response text as string to external command', async () => { - let receivedInput: unknown; - const proxy = createMockProxy((_cmd, input) => { - receivedInput = input; - return { kind: HookCommandResultKind.Success, result: {} }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - await service.executePostToolUseHook( - sessionUri, - { toolName: 'my-tool', toolInput: { arg: 'val' }, getToolResponseText: () => 'file contents here', toolCallId: 'call-42' } - ); - - assert.ok(typeof receivedInput === 'object' && receivedInput !== null); - const input = receivedInput as Record; - assert.strictEqual(input['tool_name'], 'my-tool'); - assert.deepStrictEqual(input['tool_input'], { arg: 'val' }); - assert.strictEqual(input['tool_response'], 'file contents here'); - assert.strictEqual(input['tool_use_id'], 'call-42'); - assert.strictEqual(input['hookEventName'], HookType.PostToolUse); - }); - - test('does not call getter when no PostToolUse hooks registered', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - // Register hooks only for PreToolUse, not PostToolUse - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - let getterCalled = false; - const result = await service.executePostToolUseHook( - sessionUri, - { - toolName: 'test-tool', - toolInput: {}, - getToolResponseText: () => { getterCalled = true; return ''; }, - toolCallId: 'call-1' - } - ); - - assert.strictEqual(result, undefined); - assert.strictEqual(getterCalled, false); - }); - }); - - suite('preToolUse smoke tests — input → output', () => { - test('single hook: allow', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - permissionDecisionReason: 'Trusted tool', - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('lint-check')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'readFile', toolInput: { path: '/src/index.ts' }, toolCallId: 'call-1' }; - const result = await service.executePreToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason, additionalContext: result?.additionalContext }), - JSON.stringify({ permissionDecision: 'allow', permissionDecisionReason: 'Trusted tool', additionalContext: undefined }) - ); - }); - - test('single hook: deny', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'deny', - permissionDecisionReason: 'Path is outside workspace', - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('path-guard')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'writeFile', toolInput: { path: '/etc/passwd' }, toolCallId: 'call-2' }; - const result = await service.executePreToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'Path is outside workspace' }) - ); - }); - - test('multiple hooks: deny wins over allow and ask', async () => { - // Three hooks return allow, ask, deny (in that order). - // deny must win regardless of ordering. - let callCount = 0; - const decisions = ['allow', 'ask', 'deny'] as const; - const proxy = createMockProxy(() => { - const decision = decisions[callCount++]; - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `hook-${callCount}` } } - }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2'), cmd('h3')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'runCommand', toolInput: { cmd: 'rm -rf /' }, toolCallId: 'call-3' } - ); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'hook-3' }) - ); - }); - - test('multiple hooks: ask wins over allow', async () => { - let callCount = 0; - const decisions = ['allow', 'ask'] as const; - const proxy = createMockProxy(() => { - const decision = decisions[callCount++]; - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `reason-${decision}` } } - }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'exec', toolInput: {}, toolCallId: 'call-4' } - ); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'ask', permissionDecisionReason: 'reason-ask' }) - ); - }); - }); - - suite('postToolUse smoke tests — input → output', () => { - test('single hook: block', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Lint errors found' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('lint')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'writeFile', toolInput: { path: 'foo.ts' }, getToolResponseText: () => 'wrote 42 bytes', toolCallId: 'call-5' }; - const result = await service.executePostToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: 'block', reason: 'Lint errors found', additionalContext: undefined }) - ); - }); - - test('single hook: additionalContext only', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - additionalContext: 'Tests still pass after this edit' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('test-runner')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'editFile', toolInput: {}, getToolResponseText: () => 'ok', toolCallId: 'call-6' }; - const result = await service.executePostToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: undefined, reason: undefined, additionalContext: ['Tests still pass after this edit'] }) - ); - }); - - test('multiple hooks: block wins and all hooks run', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { kind: HookCommandResultKind.Success, result: { decision: 'block', reason: 'Tests failed' } }; - } - return { kind: HookCommandResultKind.Success, result: { hookSpecificOutput: { additionalContext: 'context from second hook' } } }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('test'), cmd('lint')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'writeFile', toolInput: {}, getToolResponseText: () => 'data', toolCallId: 'call-7' } - ); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: 'block', reason: 'Tests failed', additionalContext: ['context from second hook'] }) - ); - }); - - test('no hooks registered → undefined (getter never called)', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - // Register PreToolUse only — no PostToolUse - store.add(service.registerHooks(sessionUri, { [HookType.PreToolUse]: [cmd('h')] })); - - let getterCalled = false; - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 't', toolInput: {}, getToolResponseText: () => { getterCalled = true; return ''; }, toolCallId: 'c' } - ); - - assert.strictEqual(result, undefined); - assert.strictEqual(getterCalled, false); - }); - }); - function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy { return { runHookCommand: async (hookCommand, input, token) => { diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 86dcf0337e4..ad37a1404ce 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 12 +// version: 13 declare module 'vscode' { @@ -258,6 +258,8 @@ declare module 'vscode' { provideFileIgnored(uri: Uri, token: CancellationToken): ProviderResult; } + export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; + export interface LanguageModelToolInvocationOptions { chatRequestId?: string; /** @deprecated Use {@link chatSessionResource} instead */ @@ -269,6 +271,16 @@ declare module 'vscode' { * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ subAgentInvocationId?: string; + /** + * Pre-tool-use hook result, if the hook was already executed by the caller. + * When provided, the tools service will skip executing its own preToolUse hook + * and use this result for permission decisions and input modifications instead. + */ + preToolUseResult?: { + permissionDecision?: PreToolUsePermissionDecision; + permissionDecisionReason?: string; + updatedInput?: object; + }; } export interface LanguageModelToolInvocationPrepareOptions { From 7393b0ec6071aca9ba95635a36588affb09db549 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:23:46 +0000 Subject: [PATCH 20/65] Fix chat session loss on workspace ID change (#289779) * Initial plan * Add migration logic to ChatSessionStore for workspace ID changes Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Improve workspace migration logic to handle empty window transitions Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Add tests for workspace migration in ChatSessionStore Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Address code review feedback: extract duplicate logic and improve tests Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Refactor to use onDidEnterWorkspace event for migration Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * Fix test to use proper workspace identifier with uri property Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * Address code review feedback: improve log consistency and remove unnecessary type assertion Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * Clarify log message to indicate files are copied (not moved) Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * Fix chatService tests by stubbing IWorkspaceEditingService Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> * Fix ChatEditingService test by stubbing IWorkspaceEditingService Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> Co-authored-by: Benjamin Pasero Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> Co-authored-by: Rob Lourens --- .../chat/common/model/chatSessionStore.ts | 97 ++++++++++++++++++- .../chatEditing/chatEditingService.test.ts | 5 + .../common/chatService/chatService.test.ts | 2 + .../common/model/chatSessionStore.test.ts | 90 ++++++++++++++++- 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 3bb3abaaafe..cecb075bd83 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -19,9 +19,10 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUserDataProfilesService } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IAnyWorkspaceIdentifier, isEmptyWorkspaceIdentifier, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; +import { IWorkspaceEditingService } from '../../../../services/workspaces/common/workspaceEditing.js'; import { awaitStatsForSession } from '../chat.js'; import { IChatSessionStats, IChatSessionTiming, ResponseModelState } from '../chatService/chatService.js'; import { ChatAgentLocation } from '../constants.js'; @@ -36,7 +37,7 @@ const ChatIndexStorageKey = 'chat.ChatSessionStore.index'; const ChatTransferIndexStorageKey = 'ChatSessionStore.transferIndex'; export class ChatSessionStore extends Disposable { - private readonly storageRoot: URI; + private storageRoot: URI; private readonly previousEmptyWindowStorageRoot: URI | undefined; private readonly transferredSessionStorageRoot: URI; @@ -55,6 +56,7 @@ export class ChatSessionStore extends Disposable { @ILifecycleService private readonly lifecycleService: ILifecycleService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, ) { super(); @@ -71,6 +73,12 @@ export class ChatSessionStore extends Disposable { this.transferredSessionStorageRoot = joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'transferredChatSessions'); + // Listen to workspace transitions to migrate chat sessions + this._register(this.workspaceEditingService.onDidEnterWorkspace(event => { + const transitionPromise = this.storeQueue.queue(() => this.handleWorkspaceTransition(event.oldWorkspace, event.newWorkspace)); + event.join(transitionPromise); + })); + this._register(this.lifecycleService.onWillShutdown(e => { this.shuttingDown = true; if (!this.storeTask) { @@ -84,6 +92,91 @@ export class ChatSessionStore extends Disposable { })); } + private async handleWorkspaceTransition(oldWorkspace: IAnyWorkspaceIdentifier, newWorkspace: IAnyWorkspaceIdentifier): Promise { + const wasEmptyWindow = isEmptyWorkspaceIdentifier(oldWorkspace); + const isNewWorkspaceEmpty = isEmptyWorkspaceIdentifier(newWorkspace); + const oldWorkspaceId = oldWorkspace.id; + const newWorkspaceId = newWorkspace.id; + + this.logService.info(`ChatSessionStore: Workspace transition from ${oldWorkspaceId} to ${newWorkspaceId}`); + + // Determine the old storage location based on the old workspace + const oldStorageRoot = wasEmptyWindow ? + joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') : + joinPath(this.environmentService.workspaceStorageHome, oldWorkspaceId, 'chatSessions'); + + // Determine the new storage location based on the new workspace + const newStorageRoot = isNewWorkspaceEmpty ? + joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') : + joinPath(this.environmentService.workspaceStorageHome, newWorkspaceId, 'chatSessions'); + + // If the storage roots are identical, there is nothing to migrate + if (oldStorageRoot.toString() === newStorageRoot.toString()) { + this.storageRoot = newStorageRoot; + return; + } + + // Update storage root for the new workspace + this.storageRoot = newStorageRoot; + + // Migrate session files from old to new location + await this.migrateSessionsToNewWorkspace(oldStorageRoot, wasEmptyWindow, isNewWorkspaceEmpty); + } + + private async migrateSessionsToNewWorkspace(oldStorageRoot: URI, wasEmptyWindow: boolean, isNewWorkspaceEmpty: boolean): Promise { + try { + // Check if old storage location exists + const oldStorageExists = await this.fileService.exists(oldStorageRoot); + if (!oldStorageExists) { + this.logService.info(`ChatSessionStore: Old storage location does not exist, skipping migration`); + return; + } + + // Read all session files from old location + const oldDirectory = await this.fileService.resolve(oldStorageRoot); + if (!oldDirectory.children) { + this.logService.info(`ChatSessionStore: No children in old storage location, skipping migration`); + return; + } + + this.logService.info(`ChatSessionStore: Found ${oldDirectory.children.length} files in old storage location`); + + // Copy each file to the new location + let migratedCount = 0; + for (const child of oldDirectory.children) { + if (!child.isDirectory && (child.name.endsWith('.json') || child.name.endsWith('.jsonl'))) { + const oldFilePath = child.resource; + const newFilePath = joinPath(this.storageRoot, child.name); + + try { + await this.fileService.copy(oldFilePath, newFilePath, false); + migratedCount++; + } catch (e) { + if (toFileOperationResult(e) === FileOperationResult.FILE_MOVE_CONFLICT) { + // File already exists at target - skip as a no-op + this.logService.trace(`ChatSessionStore: Session file ${child.name} already exists at target, skipping`); + } else { + this.reportError('sessionMigration', `Error migrating chat session file ${child.name}`, e); + } + } + } + } + + this.logService.info(`ChatSessionStore: Copied ${migratedCount} chat session files from ${wasEmptyWindow ? 'empty window' : oldStorageRoot.toString()} to ${isNewWorkspaceEmpty ? 'empty window' : this.storageRoot.toString()} (originals preserved at old location)`); + + // Clear the index cache and flush it to the new storage scope + this.indexCache = undefined; + try { + await this.flushIndex(); + } catch (e) { + this.reportError('migrateWorkspace', 'Error flushing chat session index after workspace migration', e); + } + + } catch (e) { + this.reportError('migrateWorkspace', 'Error migrating chat sessions to new workspace', e); + } + } + async storeSessions(sessions: ChatModel[]): Promise { if (this.shuttingDown) { // Don't start this task if we missed the chance to block shutdown diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index 5f57af4bf8d..ee806bff9f4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { waitForState } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; @@ -25,6 +26,7 @@ import { IWorkbenchAssignmentService } from '../../../../../services/assignment/ import { NullWorkbenchAssignmentService } from '../../../../../services/assignment/test/common/nullAssignmentService.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { TestWorkerService } from '../../../../inlineChat/test/browser/testWorkerService.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; @@ -96,6 +98,9 @@ suite('ChatEditingService', function () { return Disposable.None; } }); + collection.set(IWorkspaceEditingService, new class extends mock() { + override readonly onDidEnterWorkspace = Event.None; + }); collection.set(INotebookService, new class extends mock() { override getNotebookTextModel(_uri: URI): NotebookTextModel | undefined { return undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 6ea83f12ce5..601e1389741 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 @@ -32,6 +32,7 @@ import { NullWorkbenchAssignmentService } from '../../../../../services/assignme import { IExtensionService, nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { InMemoryTestFileService, mock, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; @@ -174,6 +175,7 @@ suite('ChatService', () => { instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); + instantiationService.stub(IWorkspaceEditingService, { onDidEnterWorkspace: Event.None }); instantiationService.stub(IChatEditingService, new class extends mock() { override startOrContinueGlobalEditingSession(): IChatEditingSession { return { diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts index cce617aabe1..081a235b906 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; @@ -15,9 +17,10 @@ import { IStorageService } from '../../../../../../platform/storage/common/stora import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IUserDataProfilesService, toUserDataProfile } from '../../../../../../platform/userDataProfile/common/userDataProfile.js'; -import { IWorkspaceContextService, WorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IAnyWorkspaceIdentifier, IWorkspaceContextService, WorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { TestWorkspace, Workspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; +import { IDidEnterWorkspaceEvent, IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { InMemoryTestFileService, TestContextService, TestLifecycleService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { ChatModel, ISerializableChatData3 } from '../../../common/model/chatModel.js'; import { ChatSessionStore, IChatTransfer } from '../../../common/model/chatSessionStore.js'; @@ -40,10 +43,27 @@ function createMockChatModel(sessionResource: URI, options?: { customTitle?: str return model as unknown as ChatModel; } +class MockWorkspaceEditingService extends Disposable implements Partial { + private readonly _onDidEnterWorkspace = this._register(new Emitter()); + readonly onDidEnterWorkspace = this._onDidEnterWorkspace.event; + + fireWorkspaceTransition(oldWorkspace: IAnyWorkspaceIdentifier, newWorkspace: IAnyWorkspaceIdentifier): Promise { + const promises: Promise[] = []; + const event: IDidEnterWorkspaceEvent = { + oldWorkspace, + newWorkspace, + join: (promise: Promise) => promises.push(promise) + }; + this._onDidEnterWorkspace.fire(event); + return Promise.all(promises).then(() => { }); + } +} + suite('ChatSessionStore', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; + let mockWorkspaceEditingService: MockWorkspaceEditingService; function createChatSessionStore(isEmptyWindow: boolean = false): ChatSessionStore { const workspace = isEmptyWindow ? new Workspace('empty-window-id', []) : TestWorkspace; @@ -61,6 +81,8 @@ suite('ChatSessionStore', () => { instantiationService.stub(ILifecycleService, testDisposables.add(new TestLifecycleService())); instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); instantiationService.stub(IConfigurationService, new TestConfigurationService()); + mockWorkspaceEditingService = testDisposables.add(new MockWorkspaceEditingService()); + instantiationService.stub(IWorkspaceEditingService, mockWorkspaceEditingService as unknown as IWorkspaceEditingService); }); test('hasSessions returns false when no sessions exist', () => { @@ -374,4 +396,70 @@ suite('ChatSessionStore', () => { assert.strictEqual(result.toString(), session2Resource.toString()); }); }); + + suite('workspace migration', () => { + test('migration is triggered when onDidEnterWorkspace fires', async () => { + const fileService = instantiationService.get(IFileService) as InMemoryTestFileService; + + // Create store with empty window + const store = createChatSessionStore(true); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); + + // Store a session in empty window + await store.storeSessions([model]); + assert.strictEqual(store.hasSessions(), true); + + // Get the file path for the session in empty window storage + const emptyWindowStorageRoot = store.getChatStorageFolder(); + const sessionFile = URI.joinPath(emptyWindowStorageRoot, 'session-1.json'); + const fileExists = await fileService.exists(sessionFile); + assert.strictEqual(fileExists, true, 'Session file should exist in empty window storage'); + + // Simulate workspace transition via the onDidEnterWorkspace event + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'empty-window-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: TestWorkspace.id, uri: URI.file('/test/folder') }; + + // Fire the workspace transition event - migration happens synchronously via join() + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + // Verify file was copied to new location + const newStorageRoot = store.getChatStorageFolder(); + const migratedSessionFile = URI.joinPath(newStorageRoot, 'session-1.json'); + const migratedFileExists = await fileService.exists(migratedSessionFile); + assert.strictEqual(migratedFileExists, true, 'Session file should be migrated to workspace storage'); + }); + + test('migration handles non-existent old storage location gracefully', async () => { + // Create store with a workspace + const store = createChatSessionStore(false); + + // Simulate workspace transition from a non-existent workspace + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'non-existent-workspace-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: 'new-workspace-id' }; + + // Fire the workspace transition event - should not crash + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + // Store should work normally + assert.strictEqual(store.hasSessions(), false); + }); + + test('storage root is updated after workspace transition', async () => { + // Create store with empty window + const store = createChatSessionStore(true); + + const initialStorageRoot = store.getChatStorageFolder(); + assert.ok(initialStorageRoot.path.includes('emptyWindowChatSessions'), 'Initial storage should be empty window location'); + + // Simulate workspace transition - use proper identifier types + // Empty workspace only has 'id', single folder has 'uri' property too + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'empty-window-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: 'new-workspace-id', uri: URI.file('/test/folder') }; + + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + const newStorageRoot = store.getChatStorageFolder(); + assert.ok(newStorageRoot.path.includes('new-workspace-id'), 'Storage root should be updated to new workspace location'); + }); + }); }); From 936dee9d943a558ec0742ebae25d027b54ca6f10 Mon Sep 17 00:00:00 2001 From: digitarald Date: Tue, 10 Feb 2026 09:42:24 -0800 Subject: [PATCH 21/65] Moved planAgent model config to core for picker UI --- .../contrib/chat/browser/chat.contribution.ts | 9 ++ .../chat/browser/planAgentDefaultModel.ts | 107 ++++++++++++++++++ .../contrib/chat/common/constants.ts | 1 + 3 files changed, 117 insertions(+) create mode 100644 src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 1c71ce192a5..91706e2e422 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -143,6 +143,7 @@ import { ChatRepoInfoContribution } from './chatRepoInfo.js'; import { VALID_PROMPT_FOLDER_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; import { ChatTipService, IChatTipService } from './chatTipService.js'; import { ChatQueuePickerRendering } from './widget/input/chatQueuePickerActionItem.js'; +import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; @@ -614,6 +615,14 @@ configurationRegistry.registerConfiguration({ } } }, + [ChatConfiguration.PlanAgentDefaultModel]: { + type: 'string', + description: nls.localize('chat.planAgent.defaultModel.description', "Select the default language model to use for the Plan agent from the available providers."), + default: '', + enum: PlanAgentDefaultModel.modelIds, + enumItemLabels: PlanAgentDefaultModel.modelLabels, + markdownEnumDescriptions: PlanAgentDefaultModel.modelDescriptions + }, [ChatConfiguration.RequestQueueingEnabled]: { type: 'boolean', description: nls.localize('chat.requestQueuing.enabled.description', "When enabled, allows queuing additional messages while a request is in progress and steering the current request with a new message."), diff --git a/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts b/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts new file mode 100644 index 00000000000..37c49f0774f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { ChatConfiguration } from '../common/constants.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/languageModels.js'; +import { DEFAULT_MODEL_PICKER_CATEGORY } from '../common/widget/input/modelPickerWidget.js'; + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + +export class PlanAgentDefaultModel extends Disposable { + static readonly ID = 'workbench.contrib.planAgentDefaultModel'; + static readonly configName = ChatConfiguration.PlanAgentDefaultModel; + + static modelIds: string[] = ['']; + static modelLabels: string[] = [localize('defaultModel', 'Auto (Vendor Default)')]; + static modelDescriptions: string[] = [localize('defaultModelDescription', "Use the vendor's default model")]; + + constructor( + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this._register(languageModelsService.onDidChangeLanguageModels(() => this._updateModelValues())); + this._updateModelValues(); + } + + private _updateModelValues(): void { + try { + // Clear arrays + PlanAgentDefaultModel.modelIds.length = 0; + PlanAgentDefaultModel.modelLabels.length = 0; + PlanAgentDefaultModel.modelDescriptions.length = 0; + + // Add default/empty option + PlanAgentDefaultModel.modelIds.push(''); + PlanAgentDefaultModel.modelLabels.push(localize('defaultModel', 'Auto (Vendor Default)')); + PlanAgentDefaultModel.modelDescriptions.push(localize('defaultModelDescription', "Use the vendor's default model")); + + const models: { identifier: string; metadata: ILanguageModelChatMetadata }[] = []; + const modelIds = this.languageModelsService.getLanguageModelIds(); + + for (const modelId of modelIds) { + try { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + if (metadata) { + models.push({ identifier: modelId, metadata }); + } else { + this.logService.warn(`[PlanAgentDefaultModel] No metadata found for model ID: ${modelId}`); + } + } catch (e) { + this.logService.error(`[PlanAgentDefaultModel] Error looking up model ${modelId}:`, e); + } + } + + const supportedModels = models.filter(model => { + if (!model.metadata?.isUserSelectable) { + return false; + } + if (!model.metadata.capabilities?.toolCalling) { + return false; + } + return true; + }); + + supportedModels.sort((a, b) => { + const aCategory = a.metadata.modelPickerCategory ?? DEFAULT_MODEL_PICKER_CATEGORY; + const bCategory = b.metadata.modelPickerCategory ?? DEFAULT_MODEL_PICKER_CATEGORY; + + if (aCategory.order !== bCategory.order) { + return aCategory.order - bCategory.order; + } + + return a.metadata.name.localeCompare(b.metadata.name); + }); + + for (const model of supportedModels) { + try { + const qualifiedName = `${model.metadata.name} (${model.metadata.vendor})`; + PlanAgentDefaultModel.modelIds.push(qualifiedName); + PlanAgentDefaultModel.modelLabels.push(model.metadata.name); + PlanAgentDefaultModel.modelDescriptions.push(model.metadata.tooltip ?? model.metadata.detail ?? ''); + } catch (e) { + this.logService.error(`[PlanAgentDefaultModel] Error adding model ${model.metadata.name}:`, e); + } + } + + configurationRegistry.notifyConfigurationSchemaUpdated({ + id: 'chatSidebar', + properties: { + [ChatConfiguration.PlanAgentDefaultModel]: {} + } + }); + } catch (e) { + this.logService.error('[PlanAgentDefaultModel] Error updating model values:', e); + } + } +} + +registerWorkbenchContribution2(PlanAgentDefaultModel.ID, PlanAgentDefaultModel, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8270d7cf9e4..a5eec8a6ad8 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -11,6 +11,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', AgentEnabled = 'chat.agent.enabled', + PlanAgentDefaultModel = 'chat.planAgent.defaultModel', RequestQueueingEnabled = 'chat.requestQueuing.enabled', RequestQueueingDefaultAction = 'chat.requestQueuing.defaultAction', AgentStatusEnabled = 'chat.agentsControl.enabled', From 97c52349b904f1d73d2be58ce4033f79ddd75e39 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Tue, 10 Feb 2026 09:47:27 -0800 Subject: [PATCH 22/65] new session menu entry (#294164) --- .../services/actions/common/menusExtensionPoint.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 0b7630309b9..ad7aa09f9ea 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -514,6 +514,12 @@ const apiMenus: IAPIMenu[] = [ description: localize('menus.chatContextUsageActions', "Actions in the chat context usage details popup."), proposed: 'chatParticipantAdditions' }, + { + key: 'chat/newSession', + id: MenuId.ChatNewMenu, + description: localize('menus.chatNewSession', "The Chat new session menu."), + proposed: 'chatSessionsProvider' + }, ]; namespace schema { From 7aa320fdb15cefd514ea2326c27953693dcd7ada Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:48:43 -0800 Subject: [PATCH 23/65] send images in folders as attachments (#294023) * send images in folders as attachments * fix tests * Update src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address some comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chatAttachmentResolveService.ts | 40 ++++ .../contrib/chat/browser/widget/chatWidget.ts | 28 ++- .../chat/common/chatService/chatService.ts | 1 + .../common/chatService/chatServiceImpl.ts | 6 + .../chatAttachmentResolveService.test.ts | 202 ++++++++++++++++++ 5 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts index 60e6559ffc3..e5fb7e719a9 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts @@ -47,6 +47,7 @@ export interface IChatAttachmentResolveService { resolveSymbolsAttachContext(symbols: DocumentSymbolTransferData[]): ISymbolVariableEntry[]; resolveNotebookOutputAttachContext(data: NotebookCellOutputTransferData): IChatRequestVariableEntry[]; resolveSourceControlHistoryItemAttachContext(data: SCMHistoryItemTransferData[]): ISCMHistoryItemVariableEntry[]; + resolveDirectoryImages(directoryUri: URI): Promise; } export class ChatAttachmentResolveService implements IChatAttachmentResolveService { @@ -277,6 +278,45 @@ export class ChatAttachmentResolveService implements IChatAttachmentResolveServi return []; } + // --- DIRECTORIES --- + + public async resolveDirectoryImages(directoryUri: URI): Promise { + const imageEntries: IChatRequestVariableEntry[] = []; + await this._collectDirectoryImages(directoryUri, imageEntries); + return imageEntries; + } + + private async _collectDirectoryImages(directoryUri: URI, results: IChatRequestVariableEntry[]): Promise { + let stat; + try { + stat = await this.fileService.resolve(directoryUri); + } catch { + return; + } + + if (!stat.children) { + return; + } + + const childPromises: Promise[] = []; + + for (const child of stat.children) { + if (child.isDirectory && !child.isSymbolicLink) { + childPromises.push(this._collectDirectoryImages(child.resource, results)); + } else if (child.isFile && !child.isSymbolicLink && SUPPORTED_IMAGE_EXTENSIONS_REGEX.test(child.resource.path)) { + childPromises.push( + this.resolveImageEditorAttachContext(child.resource).then(entry => { + if (entry) { + results.push(entry); + } + }).catch(() => { /* skip unreadable images */ }) + ); + } + } + + await Promise.all(childPromises); + } + // --- SOURCE CONTROL --- public resolveSourceControlHistoryItemAttachContext(data: SCMHistoryItemTransferData[]): ISCMHistoryItemVariableEntry[] { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 85111d9787d..8fa0fbb402f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -71,6 +71,7 @@ import { IPromptsService } from '../../common/promptSyntax/service/promptsServic import { handleModeSwitch } from '../actions/chatActions.js'; import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewModelChangeEvent, IChatWidgetViewOptions, isIChatResourceViewContext, isIChatViewViewContext } from '../chat.js'; import { ChatAttachmentModel } from '../attachments/chatAttachmentModel.js'; +import { IChatAttachmentResolveService } from '../attachments/chatAttachmentResolveService.js'; import { ChatSuggestNextWidget } from './chatContentParts/chatSuggestNextWidget.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from './input/chatInputPart.js'; import { IChatListItemTemplate } from './chatListRenderer.js'; @@ -365,7 +366,8 @@ export class ChatWidget extends Disposable implements IChatWidget { @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatTodoListService private readonly chatTodoListService: IChatTodoListService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, - @ILifecycleService private readonly lifecycleService: ILifecycleService + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService, ) { super(); @@ -2210,6 +2212,9 @@ export class ChatWidget extends Disposable implements IChatWidget { } } } + // Expand directory attachments: extract images as binary entries + const resolvedImageVariables = await this._resolveDirectoryImageAttachments(requestInputs.attachedContext.asArray()); + if (this.viewModel.sessionResource && !options.queue) { // todo@connor4312: move chatAccessibilityService.acceptRequest to a refcount model to handle queue messages this.chatAccessibilityService.acceptRequest(this._viewModel!.sessionResource); @@ -2221,6 +2226,7 @@ export class ChatWidget extends Disposable implements IChatWidget { locationData: this._location.resolveData?.(), parserContext: { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind }, attachedContext: requestInputs.attachedContext.asArray(), + resolvedVariables: resolvedImageVariables, noCommandDetection: options?.noCommandDetection, ...this.getModeRequestOptions(), modeInfo: this.input.currentModeInfo, @@ -2268,6 +2274,26 @@ export class ChatWidget extends Disposable implements IChatWidget { return sent.data.responseCreatedPromise; } + // Resolve images from directory attachments to send as additional variables. + private async _resolveDirectoryImageAttachments(attachments: IChatRequestVariableEntry[]): Promise { + const imagePromises: Promise[] = []; + + for (const attachment of attachments) { + if (attachment.kind === 'directory' && URI.isUri(attachment.value)) { + imagePromises.push( + this.chatAttachmentResolveService.resolveDirectoryImages(attachment.value) + ); + } + } + + if (imagePromises.length === 0) { + return []; + } + + const resolved = await Promise.all(imagePromises); + return resolved.flat(); + } + private async confirmPendingRequestsBeforeSend(model: IChatModel, options: IChatAcceptInputOptions): Promise { if (options.queue) { return true; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 70c534c3f07..657c6d6ac1e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1300,6 +1300,7 @@ export interface IChatSendRequestOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any rejectedConfirmationData?: any[]; attachedContext?: IChatRequestVariableEntry[]; + resolvedVariables?: IChatRequestVariableEntry[]; /** The target agent ID can be specified with this property instead of using @ in 'message' */ agentId?: string; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 69054bd688a..838008c642b 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -952,6 +952,12 @@ export class ChatService extends Disposable implements IChatService { variableData = { variables: this.prepareContext(request.attachedContext) }; model.updateRequest(request, variableData); + // Merge resolved variables (e.g. images from directories) for the + // agent request only - they are not stored on the request model. + if (options?.resolvedVariables?.length) { + variableData = { variables: [...variableData.variables, ...options.resolvedVariables] }; + } + const promptTextResult = getPromptText(request.message); variableData = updateRanges(variableData, promptTextResult.diff); // TODO bit of a hack message = promptTextResult.message; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts new file mode 100644 index 00000000000..0105ff47128 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { ChatAttachmentResolveService } from '../../browser/attachments/chatAttachmentResolveService.js'; +import { createFileStat } from '../../../../test/common/workbenchTestServices.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +suite('ChatAttachmentResolveService', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let service: ChatAttachmentResolveService; + + /** + * Map from directory URI string to children, simulating a file tree. + * Populated per-test to control the mock directory structure. + */ + let directoryTree: Map; + + /** + * Set of file URI strings that should be treated as valid images + * by the mocked resolveImageEditorAttachContext. + */ + let imageFileUris: Set; + + setup(() => { + instantiationService = testDisposables.add(new TestInstantiationService()); + directoryTree = new Map(); + imageFileUris = new Set(); + + // Stub IFileService with resolve() that uses the directoryTree map + instantiationService.stub(IFileService, { + resolve: async (resource: URI): Promise => { + const children = directoryTree.get(resource.toString()); + if (children !== undefined) { + return createFileStat(resource, false, false, true, false, children); + } + // Treat as a file + return createFileStat(resource, false, true, false); + } + }); + + instantiationService.stub(IEditorService, {}); + instantiationService.stub(ITextModelService, {}); + instantiationService.stub(IExtensionService, {}); + instantiationService.stub(IDialogService, {}); + + service = instantiationService.createInstance(ChatAttachmentResolveService); + + // Override resolveImageEditorAttachContext to avoid DOM dependencies (canvas, Image, etc.) + // and return a predictable image entry for files in the imageFileUris set. + service.resolveImageEditorAttachContext = async (resource: URI): Promise => { + if (imageFileUris.has(resource.toString())) { + return { + id: resource.toString(), + name: resource.path.split('/').pop()!, + value: new Uint8Array([1, 2, 3]), + kind: 'image', + }; + } + return undefined; + }; + }); + + test('returns empty array for empty directory', async () => { + const dirUri = URI.file('/test/empty-dir'); + directoryTree.set(dirUri.toString(), []); + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('returns image entries for image files in directory', async () => { + const dirUri = URI.file('/test/images-dir'); + const pngUri = URI.file('/test/images-dir/photo.png'); + const jpgUri = URI.file('/test/images-dir/photo.jpg'); + const txtUri = URI.file('/test/images-dir/readme.txt'); + + directoryTree.set(dirUri.toString(), [ + { resource: pngUri, isFile: true, isDirectory: false }, + { resource: jpgUri, isFile: true, isDirectory: false }, + { resource: txtUri, isFile: true, isDirectory: false }, + ]); + imageFileUris.add(pngUri.toString()); + imageFileUris.add(jpgUri.toString()); + + const result = await service.resolveDirectoryImages(dirUri); + assert.strictEqual(result.length, 2); + assert.ok(result.every(e => e.kind === 'image')); + const names = result.map(e => e.name).sort(); + assert.deepStrictEqual(names, ['photo.jpg', 'photo.png']); + }); + + test('ignores non-image files', async () => { + const dirUri = URI.file('/test/text-dir'); + const txtUri = URI.file('/test/text-dir/file.txt'); + const tsUri = URI.file('/test/text-dir/index.ts'); + + directoryTree.set(dirUri.toString(), [ + { resource: txtUri, isFile: true, isDirectory: false }, + { resource: tsUri, isFile: true, isDirectory: false }, + ]); + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('recursively discovers images in subdirectories', async () => { + const rootUri = URI.file('/test/root'); + const subDirUri = URI.file('/test/root/subdir'); + const deepDirUri = URI.file('/test/root/subdir/deep'); + + const rootPng = URI.file('/test/root/logo.png'); + const subPng = URI.file('/test/root/subdir/banner.webp'); + const deepJpg = URI.file('/test/root/subdir/deep/photo.jpeg'); + const deepTxt = URI.file('/test/root/subdir/deep/notes.txt'); + + directoryTree.set(rootUri.toString(), [ + { resource: rootPng, isFile: true, isDirectory: false }, + { resource: subDirUri, isFile: false, isDirectory: true }, + ]); + directoryTree.set(subDirUri.toString(), [ + { resource: subPng, isFile: true, isDirectory: false }, + { resource: deepDirUri, isFile: false, isDirectory: true }, + ]); + directoryTree.set(deepDirUri.toString(), [ + { resource: deepJpg, isFile: true, isDirectory: false }, + { resource: deepTxt, isFile: true, isDirectory: false }, + ]); + + imageFileUris.add(rootPng.toString()); + imageFileUris.add(subPng.toString()); + imageFileUris.add(deepJpg.toString()); + + const result = await service.resolveDirectoryImages(rootUri); + assert.strictEqual(result.length, 3); + assert.ok(result.every(e => e.kind === 'image')); + const names = result.map(e => e.name).sort(); + assert.deepStrictEqual(names, ['banner.webp', 'logo.png', 'photo.jpeg']); + }); + + test('handles unreadable directory gracefully', async () => { + const dirUri = URI.file('/test/unreadable'); + // Override resolve to throw for this URI + instantiationService.stub(IFileService, { + resolve: async (resource: URI): Promise => { + if (resource.toString() === dirUri.toString()) { + throw new Error('Permission denied'); + } + return createFileStat(resource, false, true, false); + } + }); + // Re-create service with the new stub + service = instantiationService.createInstance(ChatAttachmentResolveService); + service.resolveImageEditorAttachContext = async (resource: URI): Promise => { + if (imageFileUris.has(resource.toString())) { + return { + id: resource.toString(), + name: resource.path.split('/').pop()!, + value: new Uint8Array([1, 2, 3]), + kind: 'image', + }; + } + return undefined; + }; + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('handles mixed directory with images and non-images', async () => { + const dirUri = URI.file('/test/mixed'); + const gifUri = URI.file('/test/mixed/animation.gif'); + const jsUri = URI.file('/test/mixed/script.js'); + const bmpUri = URI.file('/test/mixed/icon.bmp'); + + directoryTree.set(dirUri.toString(), [ + { resource: gifUri, isFile: true, isDirectory: false }, + { resource: jsUri, isFile: true, isDirectory: false }, + { resource: bmpUri, isFile: true, isDirectory: false }, + ]); + imageFileUris.add(gifUri.toString()); + imageFileUris.add(bmpUri.toString()); + // bmp is NOT in CHAT_ATTACHABLE_IMAGE_MIME_TYPES (only png, jpg, jpeg, gif, webp) + // so it should be skipped by the regex even though it would resolve successfully + + const result = await service.resolveDirectoryImages(dirUri); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'animation.gif'); + }); +}); From fe159c64465123374dd9c029ee5878e237ccfecc Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:50:05 -0800 Subject: [PATCH 24/65] Fix local sessions not refreshing properly --- .../api/browser/mainThreadChatSessions.ts | 12 +- .../api/common/extHostChatSessions.ts | 12 +- .../agentSessions.contribution.ts | 2 +- ...der.ts => localAgentSessionsController.ts} | 20 ++-- ...s => localAgentSessionsController.test.ts} | 112 +++++++++--------- 5 files changed, 76 insertions(+), 82 deletions(-) rename src/vs/workbench/contrib/chat/browser/agentSessions/{localAgentSessionsProvider.ts => localAgentSessionsController.ts} (93%) rename src/vs/workbench/contrib/chat/test/browser/agentSessions/{localAgentSessionsProvider.test.ts => localAgentSessionsController.test.ts} (89%) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 797db7cff68..ff9ea448f4a 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -5,7 +5,7 @@ import { raceCancellationError } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../base/common/event.js'; +import { Emitter } from '../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; @@ -326,7 +326,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes private readonly _handle: number; private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); - readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; constructor( proxy: ExtHostChatSessionsShape, @@ -410,17 +410,15 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } $registerChatSessionItemController(handle: number, chatSessionType: string): void { - // Register the controller handle - items will be pushed via $setChatSessionItems const disposables = new DisposableStore(); - const controller = new MainThreadChatSessionItemController(this._proxy, handle); - disposables.add(controller); + const controller = disposables.add(new MainThreadChatSessionItemController(this._proxy, handle)); disposables.add(this._chatSessionsService.registerChatSessionItemController(chatSessionType, controller)); this._itemControllerRegistrations.set(handle, { - dispose: () => disposables.dispose(), chatSessionType, controller, + dispose: () => disposables.dispose(), }); disposables.add(this._chatSessionsService.registerChatModelChangeListeners( @@ -437,7 +435,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat async $setChatSessionItems(handle: number, items: Dto[]): Promise { const registration = this._itemControllerRegistrations.get(handle); if (!registration) { - this._logService.warn(`No controller registered for handle ${handle}`); + this._logService.warn(`No chat session controller registered for handle ${handle}`); return; } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 14b71d20dbb..46a7230a092 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -324,7 +324,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); - // Helper to fetch and push items to main thread + const collection = new ChatSessionItemCollectionImpl(() => { + // Noop for providers + }); + + // Helper to push items to main thread const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { collection.replace(items); const convertedItems: IChatSessionItem[] = []; @@ -335,15 +339,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio void this._proxy.$setChatSessionItems(handle, convertedItems); }; - const collection = new ChatSessionItemCollectionImpl(() => { - // Noop for providers - }); - const controller: vscode.ChatSessionItemController = { id: chatSessionType, items: collection, createChatSessionItem: (_resource: vscode.Uri, _label: string) => { - throw new Error('not implemented'); + throw new Error('Not implemented for providers'); }, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, dispose: () => { 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 3824399027b..95a563c19f5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -13,7 +13,7 @@ import { Extensions as QuickAccessExtensions, IQuickAccessRegistry } from '../.. import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from './agentSessions.js'; import { IAgentSessionsService, AgentSessionsService } from './agentSessionsService.js'; -import { LocalAgentsSessionsController } from './localAgentSessionsProvider.js'; +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 } from './agentSessionsActions.js'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts similarity index 93% rename from src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts rename to src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index bbbb67e2cdb..18d0bcb1c29 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; export class LocalAgentsSessionsController extends Disposable implements IChatSessionItemController, IWorkbenchContribution { - static readonly ID = 'workbench.contrib.localAgentsSessionsProvider'; + static readonly ID = 'workbench.contrib.localAgentsSessionsController'; readonly chatSessionType = localChatSessionType; @@ -51,22 +51,18 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe } private registerListeners(): void { - this._register(this.chatSessionsService.registerChatModelChangeListeners( - this.chatService, - Schemas.vscodeLocalChatSession, - () => this._onDidChangeChatSessionItems.fire() - )); + const refreshItems = async () => { + this._onDidChangeChatSessionItems.fire(); + await this.refresh(CancellationToken.None); + this._onDidChangeChatSessionItems.fire(); + }; - this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => { - if (chatSessionType === this.chatSessionType) { - this._onDidChange.fire(); - } - })); + this._register(this.chatSessionsService.registerChatModelChangeListeners(this.chatService, Schemas.vscodeLocalChatSession, refreshItems)); this._register(this.chatService.onDidDisposeSession(e => { const session = e.sessionResource.filter(resource => getChatSessionType(resource) === this.chatSessionType); if (session.length > 0) { - this._onDidChangeChatSessionItems.fire(); + refreshItems(); } })); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts similarity index 89% rename from src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts rename to src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts index 7bc70307a08..3cbc1cccf03 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts @@ -14,7 +14,7 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { LocalAgentsSessionsController } from '../../../browser/agentSessions/localAgentSessionsProvider.js'; +import { LocalAgentsSessionsController } from '../../../browser/agentSessions/localAgentSessionsController.js'; import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ChatRequestQueueKind, IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; @@ -300,39 +300,39 @@ suite('LocalAgentsSessionsController', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createProvider(): LocalAgentsSessionsController { + function createController(): LocalAgentsSessionsController { return disposables.add(instantiationService.createInstance(LocalAgentsSessionsController)); } test('should have correct session type', () => { - const provider = createProvider(); - assert.strictEqual(provider.chatSessionType, localChatSessionType); + const controller = createController(); + assert.strictEqual(controller.chatSessionType, localChatSessionType); }); test('should register itself with chat sessions service', async () => { - const provider = createProvider(); + const controller = createController(); - const providerResults = await mockChatSessionsService.getChatSessionItems(undefined, CancellationToken.None); - assert.strictEqual(providerResults.length, 1); - assert.strictEqual(providerResults[0].chatSessionType, provider.chatSessionType); + const controllerResults = await mockChatSessionsService.getChatSessionItems(undefined, CancellationToken.None); + assert.strictEqual(controllerResults.length, 1); + assert.strictEqual(controllerResults[0].chatSessionType, controller.chatSessionType); }); test('should provide empty sessions when no live or history sessions', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); mockChatService.setLiveSessionItems([]); mockChatService.setHistorySessionItems([]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 0); }); }); test('should provide live session items', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('test-session'); const mockModel = createMockChatModel({ @@ -351,8 +351,8 @@ suite('LocalAgentsSessionsController', () => { lastResponseState: ResponseModelState.Complete }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'Test Session'); assert.strictEqual(sessions[0].resource.toString(), sessionResource.toString()); @@ -361,7 +361,7 @@ suite('LocalAgentsSessionsController', () => { test('should provide history session items', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('history-session'); @@ -375,8 +375,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'History Session'); }); @@ -384,7 +384,7 @@ suite('LocalAgentsSessionsController', () => { test('should not duplicate sessions in history and live', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('duplicate-session'); const mockModel = createMockChatModel({ @@ -410,8 +410,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'Live Session'); }); @@ -420,7 +420,7 @@ suite('LocalAgentsSessionsController', () => { suite('Session Status', () => { test('should return InProgress status when request in progress', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('in-progress-session'); const mockModel = createMockChatModel({ @@ -439,8 +439,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.InProgress); }); @@ -448,7 +448,7 @@ suite('LocalAgentsSessionsController', () => { test('should return Completed status when last response is complete', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('completed-session'); const mockModel = createMockChatModel({ @@ -470,8 +470,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); }); @@ -479,7 +479,7 @@ suite('LocalAgentsSessionsController', () => { test('should return Success status when last response was canceled', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('canceled-session'); const mockModel = createMockChatModel({ @@ -500,8 +500,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); }); @@ -509,7 +509,7 @@ suite('LocalAgentsSessionsController', () => { test('should return Failed status when last response has error', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('error-session'); const mockModel = createMockChatModel({ @@ -530,8 +530,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Failed); }); @@ -541,7 +541,7 @@ suite('LocalAgentsSessionsController', () => { suite('Session Statistics', () => { test('should return statistics for sessions with modified entries', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('stats-session'); const mockModel = createMockChatModel({ @@ -580,8 +580,8 @@ suite('LocalAgentsSessionsController', () => { } }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.ok(sessions[0].changes); const changes = sessions[0].changes as { files: number; insertions: number; deletions: number }; @@ -593,7 +593,7 @@ suite('LocalAgentsSessionsController', () => { test('should not return statistics for sessions without modified entries', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('no-stats-session'); const mockModel = createMockChatModel({ @@ -621,8 +621,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].changes, undefined); }); @@ -632,7 +632,7 @@ suite('LocalAgentsSessionsController', () => { suite('Session Timing', () => { test('should use model timestamp for created when model exists', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('timing-session'); const modelTimestamp = Date.now() - 5000; @@ -652,8 +652,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ created: modelTimestamp }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); @@ -661,7 +661,7 @@ suite('LocalAgentsSessionsController', () => { test('should use lastMessageDate for created when model does not exist', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('history-timing'); const lastMessageDate = Date.now() - 10000; @@ -676,8 +676,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ created: lastMessageDate }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); @@ -685,7 +685,7 @@ suite('LocalAgentsSessionsController', () => { test('should set lastRequestEnded from last response completedAt', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('endtime-session'); const completedAt = Date.now() - 1000; @@ -706,8 +706,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ lastRequestEnded: completedAt }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); @@ -717,7 +717,7 @@ suite('LocalAgentsSessionsController', () => { suite('Session Icon', () => { test('should use Codicon.chatSparkle as icon', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('icon-session'); const mockModel = createMockChatModel({ @@ -735,8 +735,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].iconPath, Codicon.chatSparkle); }); @@ -746,7 +746,7 @@ suite('LocalAgentsSessionsController', () => { suite('Events', () => { test('should fire onDidChangeChatSessionItems when model progress changes', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('progress-session'); const mockModel = createMockChatModel({ @@ -759,7 +759,7 @@ suite('LocalAgentsSessionsController', () => { mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); @@ -772,7 +772,7 @@ suite('LocalAgentsSessionsController', () => { test('should fire onDidChangeChatSessionItems when model request status changes', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('status-change-session'); const mockModel = createMockChatModel({ @@ -785,7 +785,7 @@ suite('LocalAgentsSessionsController', () => { mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); @@ -798,7 +798,7 @@ suite('LocalAgentsSessionsController', () => { test('should clean up model listeners when model is removed via chatModels observable', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('cleanup-session'); const mockModel = createMockChatModel({ @@ -816,7 +816,7 @@ suite('LocalAgentsSessionsController', () => { // The onDidChangeChatSessionItems from registerModelListeners cleanup should fire once // but after that, title changes should NOT fire onDidChangeChatSessionItems let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); From eb0e8808c5ea5a3d328a8c28af25484dd63b53a9 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 11:52:41 -0600 Subject: [PATCH 25/65] make auto run task notification more specific (#294161) --- src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts index 2b029d727aa..ef940211d69 100644 --- a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts +++ b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts @@ -180,12 +180,12 @@ export class RunAutomaticTasks extends Disposable implements IWorkbenchContribut private _showPrompt(notificationService: INotificationService, storageService: IStorageService, openerService: IOpenerService, configurationService: IConfigurationService, taskNames: string[], locations: Map): Promise { return new Promise(resolve => { notificationService.prompt(Severity.Info, nls.localize('tasks.run.allowAutomatic', - "This workspace has tasks ({0}) defined ({1}) that run automatically when you open this workspace. Do you allow automatic tasks to run when you open this workspace?", + "This workspace has tasks ({0}) defined ({1}) that run automatically when you open this workspace. Do you want to allow automatic tasks to run in all trusted workspaces?", taskNames.join(', '), Array.from(locations.keys()).join(', ') ), [{ - label: nls.localize('allow', "Allow and Run"), + label: nls.localize('allow', "Allow"), run: () => { resolve(true); configurationService.updateValue(ALLOW_AUTOMATIC_TASKS, 'on', ConfigurationTarget.USER); From d4ee4a532a2663a0efa23dfdbc0eef2e3aa7ae24 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 10 Feb 2026 19:07:56 +0100 Subject: [PATCH 26/65] fix when to use last fetched data (#294174) --- .../workbench/services/accounts/browser/defaultAccount.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 66b782d7c91..868cfac8c79 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -469,11 +469,11 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], donotUseLastFetchedData: boolean): Promise { try { const accountId = sessions[0].account.id; - const accountPolicyData = !donotUseLastFetchedData && this._policyData?.accountId === accountId ? this._policyData : undefined; + const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; const [entitlementsData, tokenEntitlementsData] = await Promise.all([ this.getEntitlements(sessions), - this.getTokenEntitlements(sessions, accountPolicyData), + this.getTokenEntitlements(sessions, donotUseLastFetchedData ? undefined : accountPolicyData), ]); let isTokenEntitlementsDataFetched = false; @@ -486,7 +486,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid policyData.chat_preview_features_enabled = tokenEntitlementsData.chat_preview_features_enabled; policyData.mcp = tokenEntitlementsData.mcp; if (policyData.mcp) { - const mcpRegistryProvider = await this.getMcpRegistryProvider(sessions, accountPolicyData); + const mcpRegistryProvider = await this.getMcpRegistryProvider(sessions, donotUseLastFetchedData ? undefined : accountPolicyData); if (!isUndefined(mcpRegistryProvider)) { isMcpRegistryDataFetched = true; policyData.mcpRegistryUrl = mcpRegistryProvider?.url; From 0ad4e82a024aa97d085e520d3a81710d5ed9b014 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:22:04 -0800 Subject: [PATCH 27/65] Redact keys in trace logs Fixes #294158 --- src/vs/platform/terminal/node/ptyService.ts | 23 ++++- .../terminal/node/terminalEnvironment.ts | 53 +++++++++++ .../platform/terminal/node/terminalProcess.ts | 5 +- .../test/node/terminalEnvironment.test.ts | 90 ++++++++++++++++++- 4 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index a600f7b27d1..826b09f47c8 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -18,7 +18,7 @@ import { escapeNonWindowsPath } from '../common/terminalEnvironment.js'; import type { ISerializeOptions, SerializeAddon as XtermSerializeAddon } from '@xterm/addon-serialize'; import type { Unicode11Addon as XtermUnicode11Addon } from '@xterm/addon-unicode11'; import { IGetTerminalLayoutInfoArgs, IProcessDetails, ISetTerminalLayoutInfoArgs, ITerminalTabLayoutInfoDto } from '../common/terminalProcess.js'; -import { getWindowsBuildNumber } from './terminalEnvironment.js'; +import { getWindowsBuildNumber, sanitizeEnvForLogging } from './terminalEnvironment.js'; import { TerminalProcess } from './terminalProcess.js'; import { localize } from '../../../nls.js'; import { ignoreProcessNames } from './childProcessMonitor.js'; @@ -37,6 +37,24 @@ import { hasKey, isFunction, isNumber, isString } from '../../../base/common/typ type XtermTerminal = pkg.Terminal; const { Terminal: XtermTerminal } = pkg; +/** + * Sanitizes arguments for logging, specifically handling env objects in createProcess calls. + */ +function sanitizeArgsForLogging(fnName: string, args: unknown[]): unknown[] { + // createProcess signature: shellLaunchConfig, cwd, cols, rows, unicodeVersion, env (index 5), executableEnv (index 6), ... + if (fnName === 'createProcess' && args.length > 5) { + const sanitizedArgs = [...args]; + if (args[5] && typeof args[5] === 'object') { + sanitizedArgs[5] = sanitizeEnvForLogging(args[5] as IProcessEnvironment); + } + if (args[6] && typeof args[6] === 'object') { + sanitizedArgs[6] = sanitizeEnvForLogging(args[6] as IProcessEnvironment); + } + return sanitizedArgs; + } + return args; +} + interface ITraceRpcArgs { logService: ILogService; simulatedLatency: number; @@ -50,7 +68,8 @@ export function traceRpc(_target: Object, key: string, descriptor: PropertyDescr const fn = descriptor.value; descriptor[fnKey] = async function (this: TThis, ...args: unknown[]) { if (this.traceRpcArgs.logService.getLevel() === LogLevel.Trace) { - this.traceRpcArgs.logService.trace(`[RPC Request] PtyService#${fn.name}(${args.map(e => JSON.stringify(e)).join(', ')})`); + const sanitizedArgs = sanitizeArgsForLogging(fn.name, args); + this.traceRpcArgs.logService.trace(`[RPC Request] PtyService#${fn.name}(${sanitizedArgs.map(e => JSON.stringify(e)).join(', ')})`); } if (this.traceRpcArgs.simulatedLatency) { await timeout(this.traceRpcArgs.simulatedLatency); diff --git a/src/vs/platform/terminal/node/terminalEnvironment.ts b/src/vs/platform/terminal/node/terminalEnvironment.ts index 1d150cd6b8e..62a0c53bb88 100644 --- a/src/vs/platform/terminal/node/terminalEnvironment.ts +++ b/src/vs/platform/terminal/node/terminalEnvironment.ts @@ -375,3 +375,56 @@ function areZshBashFishLoginArgs(originalArgs: SingleOrMany): boolean { return isString(originalArgs) && shLoginArgs.includes(originalArgs.toLowerCase()) || !isString(originalArgs) && originalArgs.length === 1 && shLoginArgs.includes(originalArgs[0].toLowerCase()); } + +/** + * Patterns that indicate sensitive environment variable names. + */ +const sensitiveEnvVarNames = /^(?:.*_)?(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|AUTH|PRIVATE_?KEY|ACCESS_?KEY|CLIENT_?SECRET|APIKEY)(?:_.*)?$/i; + +/** + * Patterns for detecting secret values in environment variables. + */ +const secretValuePatterns = [ + // JWT tokens + /^eyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/, + // GitHub tokens + /^gh[psuro]_[a-zA-Z0-9]{36}$/, + /^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$/, + // Google API keys + /^AIza[A-Za-z0-9_\-]{35}$/, + // Slack tokens + /^xox[pbar]\-[A-Za-z0-9\-]+$/, + // Azure/MS tokens (common patterns) + /^[a-zA-Z0-9]{32,}$/, +]; + +/** + * Sanitizes environment variables for logging by redacting sensitive values. + */ +export function sanitizeEnvForLogging(env: IProcessEnvironment | undefined): IProcessEnvironment | undefined { + if (!env) { + return env; + } + const sanitized: IProcessEnvironment = {}; + for (const key of Object.keys(env)) { + const value = env[key]; + if (value === undefined) { + continue; + } + // Check if the key name suggests a sensitive value + if (sensitiveEnvVarNames.test(key)) { + sanitized[key] = ''; + continue; + } + // Check if the value matches known secret patterns + let isSecret = false; + for (const pattern of secretValuePatterns) { + if (pattern.test(value)) { + isSecret = true; + break; + } + } + sanitized[key] = isSecret ? '' : value; + } + return sanitized; +} diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index aba2699e665..968009e20e6 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -17,7 +17,7 @@ import { ILogService, LogLevel } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { FlowControlConstants, IShellLaunchConfig, ITerminalChildProcess, ITerminalLaunchError, IProcessProperty, IProcessPropertyMap, ProcessPropertyType, TerminalShellType, IProcessReadyEvent, ITerminalProcessOptions, PosixShellType, IProcessReadyWindowsPty, GeneralShellType, ITerminalLaunchResult } from '../common/terminal.js'; import { ChildProcessMonitor } from './childProcessMonitor.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection } from './terminalEnvironment.js'; +import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, sanitizeEnvForLogging } from './terminalEnvironment.js'; import { WindowsShellHelper } from './windowsShellHelper.js'; import { IPty, IPtyForkOptions, IWindowsPtyForkOptions, spawn } from 'node-pty'; import { isNumber } from '../../../base/common/types.js'; @@ -301,7 +301,8 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess ): Promise { const args = shellIntegrationInjection?.newArgs || shellLaunchConfig.args || []; await this._throttleKillSpawn(); - this._logService.trace('node-pty.IPty#spawn', shellLaunchConfig.executable, args, options); + const sanitizedOptions = { ...options, env: sanitizeEnvForLogging(options.env as IProcessEnvironment | undefined) }; + this._logService.trace('node-pty.IPty#spawn', shellLaunchConfig.executable, args, sanitizedOptions); const ptyProcess = spawn(shellLaunchConfig.executable!, args, options); this._ptyProcess = ptyProcess; this._childProcessMonitor = this._register(new ChildProcessMonitor(ptyProcess.pid, this._logService)); diff --git a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts index a4d17386a22..bb03a05958b 100644 --- a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts +++ b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITerminalProcessOptions } from '../../common/terminal.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure } from '../../node/terminalEnvironment.js'; +import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure, sanitizeEnvForLogging } from '../../node/terminalEnvironment.js'; const enabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: true, suggestEnabled: false, nonce: '' }, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined, isScreenReaderOptimized: false }; const disabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: false, suggestEnabled: false, nonce: '' }, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined, isScreenReaderOptimized: false }; @@ -257,4 +257,92 @@ suite('platform - terminalEnvironment', async () => { }); }); }); + + suite('sanitizeEnvForLogging', () => { + test('should return undefined for undefined input', () => { + strictEqual(sanitizeEnvForLogging(undefined), undefined); + }); + + test('should return empty object for empty input', () => { + deepStrictEqual(sanitizeEnvForLogging({}), {}); + }); + + test('should pass through non-sensitive values', () => { + deepStrictEqual(sanitizeEnvForLogging({ + PATH: '/usr/bin', + HOME: '/home/user', + TERM: 'xterm-256color' + }), { + PATH: '/usr/bin', + HOME: '/home/user', + TERM: 'xterm-256color' + }); + }); + + test('should redact sensitive env var names', () => { + deepStrictEqual(sanitizeEnvForLogging({ + API_KEY: 'secret123', + GITHUB_TOKEN: 'ghp_xxxx', + MY_SECRET: 'hidden', + PASSWORD: 'pass123', + AWS_ACCESS_KEY: 'AKIA...', + DATABASE_PASSWORD: 'dbpass', + CLIENT_SECRET: 'client_secret_value', + AUTH_TOKEN: 'auth_value', + PRIVATE_KEY: 'private_key_value' + }), { + API_KEY: '', + GITHUB_TOKEN: '', + MY_SECRET: '', + PASSWORD: '', + AWS_ACCESS_KEY: '', + DATABASE_PASSWORD: '', + CLIENT_SECRET: '', + AUTH_TOKEN: '', + PRIVATE_KEY: '' + }); + }); + + test('should redact JWT tokens by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + SOME_VAR: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U' + }), { + SOME_VAR: '' + }); + }); + + test('should redact GitHub tokens by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + MY_GH: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + }), { + MY_GH: '' + }); + }); + + test('should redact Google API keys by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + GOOGLE_KEY: 'AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe' + }), { + GOOGLE_KEY: '' + }); + }); + + test('should redact long alphanumeric strings (potential secrets)', () => { + deepStrictEqual(sanitizeEnvForLogging({ + LONG_VALUE: 'abcdefghijklmnopqrstuvwxyz123456' + }), { + LONG_VALUE: '' + }); + }); + + test('should skip undefined values', () => { + const env: { [key: string]: string | undefined } = { + DEFINED: 'value', + UNDEFINED: undefined + }; + deepStrictEqual(sanitizeEnvForLogging(env), { + DEFINED: 'value' + }); + }); + }); }); From e955f18c756ae4361ff89f1914f2b610f5d7a73a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:23:42 -0800 Subject: [PATCH 28/65] Add logs around early terminal input --- .../contrib/terminal/browser/terminalProcessManager.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 02b12985084..251a94fd3e6 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -370,6 +370,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } this._processListeners = [ newProcess.onProcessReady((e: IProcessReadyEvent) => { + this._logService.debug('onProcessReady', e); this._processTraits = e; this.shellProcessId = e.pid; this._initialCwd = e.cwd; @@ -379,6 +380,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce if (this._preLaunchInputQueue.length > 0 && this._process) { // Send any queued data that's waiting + this._logService.debug('sending prelaunch input queue', this._preLaunchInputQueue); newProcess.input(this._preLaunchInputQueue.join('')); this._preLaunchInputQueue.length = 0; } @@ -632,6 +634,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } } else { // If the pty is not ready, queue the data received to send later + this._logService.debug('queueing data in prelaunch input queue', data); this._preLaunchInputQueue.push(data); } } From 52457c5f1fc4a69546935e4f41bcee2f704ab04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Tue, 10 Feb 2026 20:05:45 +0100 Subject: [PATCH 29/65] Chat - implement session picker in the chat panel title (#293426) * refactor layout and layout2d into base common * support anchored quick pick * wip: use anchored quick pick in scm * almost there * undo scm history view pane * Chat - implement session picker in the chat panel title * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Port changes again, and clean things up * Pull request feedback * missing changes * cleanup * fix bad merge * reduce complexity * polish * update title * Adopt the anchor * Fix compilation error * Fix monaco editor check * Enhance drag-and-drop functionality in QuickInput: add cursor style for no-drag state and enable/disable control * Fix positioning bug * fix change of behavior of layout2d * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Benjamin Pasero Co-authored-by: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --- .../browser/ui/contextview/contextview.ts | 186 ++++-------------- src/vs/base/browser/ui/menu/menu.ts | 6 +- src/vs/base/common/layout.ts | 166 ++++++++++++++++ .../layout.test.ts} | 31 ++- .../quickinput/browser/media/quickInput.css | 5 + .../browser/quickInputController.ts | 69 ++++++- .../platform/quickinput/common/quickInput.ts | 10 + .../agentSessions/agentSessionsActions.ts | 2 +- .../agentSessions/agentSessionsPicker.ts | 2 + .../viewPane/chatViewTitleControl.ts | 4 +- 10 files changed, 310 insertions(+), 171 deletions(-) create mode 100644 src/vs/base/common/layout.ts rename src/vs/base/test/{browser/ui/contextview/contextview.test.ts => common/layout.test.ts} (60%) diff --git a/src/vs/base/browser/ui/contextview/contextview.ts b/src/vs/base/browser/ui/contextview/contextview.ts index 44c3c080e24..b3bfc63cb79 100644 --- a/src/vs/base/browser/ui/contextview/contextview.ts +++ b/src/vs/base/browser/ui/contextview/contextview.ts @@ -7,11 +7,13 @@ import { BrowserFeatures } from '../../canIUse.js'; import * as DOM from '../../dom.js'; import { StandardMouseEvent } from '../../mouseEvent.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../common/lifecycle.js'; +import { AnchorAlignment, AnchorAxisAlignment, AnchorPosition, IRect, layout2d } from '../../../common/layout.js'; import * as platform from '../../../common/platform.js'; -import { Range } from '../../../common/range.js'; import { OmitOptional } from '../../../common/types.js'; import './contextview.css'; +export { AnchorAlignment, AnchorAxisAlignment, AnchorPosition } from '../../../common/layout.js'; + export const enum ContextViewDOMPosition { ABSOLUTE = 1, FIXED, @@ -31,18 +33,6 @@ export function isAnchor(obj: unknown): obj is IAnchor | OmitOptional { return !!anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number'; } -export const enum AnchorAlignment { - LEFT, RIGHT -} - -export const enum AnchorPosition { - BELOW, ABOVE -} - -export const enum AnchorAxisAlignment { - VERTICAL, HORIZONTAL -} - export interface IDelegate { /** * The anchor where to position the context view. @@ -73,66 +63,40 @@ export interface IContextViewProvider { layout(): void; } -export interface IPosition { - top: number; - left: number; -} +export function getAnchorRect(anchor: HTMLElement | StandardMouseEvent | IAnchor): IRect { + // Get the element's position and size (to anchor the view) + if (DOM.isHTMLElement(anchor)) { + const elementPosition = DOM.getDomNodePagePosition(anchor); -export interface ISize { - width: number; - height: number; -} + // In areas where zoom is applied to the element or its ancestors, we need to adjust the size of the element + // e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level. + // Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Size Multiplier: 1.5 + const zoom = DOM.getDomNodeZoomLevel(anchor); -export interface IView extends IPosition, ISize { } - -export const enum LayoutAnchorPosition { - Before, - After -} - -export enum LayoutAnchorMode { - AVOID, - ALIGN -} - -export interface ILayoutAnchor { - offset: number; - size: number; - mode?: LayoutAnchorMode; // default: AVOID - position: LayoutAnchorPosition; -} - -/** - * Lays out a one dimensional view next to an anchor in a viewport. - * - * @returns The view offset within the viewport. - */ -export function layout(viewportSize: number, viewSize: number, anchor: ILayoutAnchor): number { - const layoutAfterAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset : anchor.offset + anchor.size; - const layoutBeforeAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset + anchor.size : anchor.offset; - - if (anchor.position === LayoutAnchorPosition.Before) { - if (viewSize <= viewportSize - layoutAfterAnchorBoundary) { - return layoutAfterAnchorBoundary; // happy case, lay it out after the anchor - } - - if (viewSize <= layoutBeforeAnchorBoundary) { - return layoutBeforeAnchorBoundary - viewSize; // ok case, lay it out before the anchor - } - - return Math.max(viewportSize - viewSize, 0); // sad case, lay it over the anchor + return { + top: elementPosition.top * zoom, + left: elementPosition.left * zoom, + width: elementPosition.width * zoom, + height: elementPosition.height * zoom + }; + } else if (isAnchor(anchor)) { + return { + top: anchor.y, + left: anchor.x, + width: anchor.width || 1, + height: anchor.height || 2 + }; } else { - if (viewSize <= layoutBeforeAnchorBoundary) { - return layoutBeforeAnchorBoundary - viewSize; // happy case, lay it out before the anchor - } - - - if (viewSize <= viewportSize - layoutAfterAnchorBoundary && layoutBeforeAnchorBoundary < viewSize / 2) { - return layoutAfterAnchorBoundary; // ok case, lay it out after the anchor - } - - - return 0; // sad case, lay it over the anchor + return { + top: anchor.posy, + left: anchor.posx, + // We are about to position the context view where the mouse + // cursor is. To prevent the view being exactly under the mouse + // when showing and thus potentially triggering an action within, + // we treat the mouse location like a small sized block element. + width: 2, + height: 2 + }; } } @@ -270,82 +234,14 @@ export class ContextView extends Disposable { } // Get anchor - const anchor = this.delegate!.getAnchor(); - - // Compute around - let around: IView; - - // Get the element's position and size (to anchor the view) - if (DOM.isHTMLElement(anchor)) { - const elementPosition = DOM.getDomNodePagePosition(anchor); - - // In areas where zoom is applied to the element or its ancestors, we need to adjust the size of the element - // e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level. - // Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Size Multiplier: 1.5 - const zoom = DOM.getDomNodeZoomLevel(anchor); - - around = { - top: elementPosition.top * zoom, - left: elementPosition.left * zoom, - width: elementPosition.width * zoom, - height: elementPosition.height * zoom - }; - } else if (isAnchor(anchor)) { - around = { - top: anchor.y, - left: anchor.x, - width: anchor.width || 1, - height: anchor.height || 2 - }; - } else { - around = { - top: anchor.posy, - left: anchor.posx, - // We are about to position the context view where the mouse - // cursor is. To prevent the view being exactly under the mouse - // when showing and thus potentially triggering an action within, - // we treat the mouse location like a small sized block element. - width: 2, - height: 2 - }; - } - - const viewSizeWidth = DOM.getTotalWidth(this.view); - const viewSizeHeight = DOM.getTotalHeight(this.view); - - const anchorPosition = this.delegate!.anchorPosition ?? AnchorPosition.BELOW; - const anchorAlignment = this.delegate!.anchorAlignment ?? AnchorAlignment.LEFT; - const anchorAxisAlignment = this.delegate!.anchorAxisAlignment ?? AnchorAxisAlignment.VERTICAL; - - let top: number; - let left: number; - + const anchor = getAnchorRect(this.delegate!.getAnchor()); const activeWindow = DOM.getActiveWindow(); - if (anchorAxisAlignment === AnchorAxisAlignment.VERTICAL) { - const verticalAnchor: ILayoutAnchor = { offset: around.top - activeWindow.pageYOffset, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; - const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; - - top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset; - - // if view intersects vertically with anchor, we must avoid the anchor - if (Range.intersects({ start: top, end: top + viewSizeHeight }, { start: verticalAnchor.offset, end: verticalAnchor.offset + verticalAnchor.size })) { - horizontalAnchor.mode = LayoutAnchorMode.AVOID; - } - - left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor); - } else { - const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; - const verticalAnchor: ILayoutAnchor = { offset: around.top, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; - - left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor); - - // if view intersects horizontally with anchor, we must avoid the anchor - if (Range.intersects({ start: left, end: left + viewSizeWidth }, { start: horizontalAnchor.offset, end: horizontalAnchor.offset + horizontalAnchor.size })) { - verticalAnchor.mode = LayoutAnchorMode.AVOID; - } - - top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset; - } + const viewport = { top: activeWindow.pageYOffset, left: activeWindow.pageXOffset, width: activeWindow.innerWidth, height: activeWindow.innerHeight }; + const view = { width: DOM.getTotalWidth(this.view), height: DOM.getTotalHeight(this.view) }; + const anchorPosition = this.delegate!.anchorPosition; + const anchorAlignment = this.delegate!.anchorAlignment; + const anchorAxisAlignment = this.delegate!.anchorAxisAlignment; + const { top, left } = layout2d(viewport, view, anchor, { anchorAlignment, anchorPosition, anchorAxisAlignment }); this.view.classList.remove('top', 'bottom', 'left', 'right'); this.view.classList.add(anchorPosition === AnchorPosition.BELOW ? 'bottom' : 'top'); diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index f48c4488073..c747ea1cd87 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -11,7 +11,6 @@ import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { StandardMouseEvent } from '../../mouseEvent.js'; import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js'; import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../actionbar/actionViewItems.js'; -import { AnchorAlignment, layout, LayoutAnchorPosition } from '../contextview/contextview.js'; import { DomScrollableElement } from '../scrollbar/scrollableElement.js'; import { EmptySubmenuAction, IAction, IActionRunner, Separator, SubmenuAction } from '../../../common/actions.js'; import { RunOnceScheduler } from '../../../common/async.js'; @@ -26,6 +25,7 @@ import { DisposableStore } from '../../../common/lifecycle.js'; import { isLinux, isMacintosh } from '../../../common/platform.js'; import { ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js'; import * as strings from '../../../common/strings.js'; +import { AnchorAlignment, layout, LayoutAnchorPosition } from '../../../common/layout.js'; export const MENU_MNEMONIC_REGEX = /\(&([^\s&])\)|(^|[^&])&([^\s&])/; export const MENU_ESCAPED_MNEMONIC_REGEX = /(&)?(&)([^\s&])/g; @@ -859,7 +859,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { const ret = { top: 0, left: 0 }; // Start with horizontal - ret.left = layout(windowDimensions.width, submenu.width, { position: expandDirection.horizontal === HorizontalDirection.Right ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, offset: entry.left, size: entry.width }); + ret.left = layout(windowDimensions.width, submenu.width, { position: expandDirection.horizontal === HorizontalDirection.Right ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, offset: entry.left, size: entry.width }).position; // We don't have enough room to layout the menu fully, so we are overlapping the menu if (ret.left >= entry.left && ret.left < entry.left + entry.width) { @@ -872,7 +872,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { } // Now that we have a horizontal position, try layout vertically - ret.top = layout(windowDimensions.height, submenu.height, { position: LayoutAnchorPosition.Before, offset: entry.top, size: 0 }); + ret.top = layout(windowDimensions.height, submenu.height, { position: LayoutAnchorPosition.Before, offset: entry.top, size: 0 }).position; // We didn't have enough room below, but we did above, so we shift down to align the menu if (ret.top + submenu.height === entry.top && ret.top + entry.height + submenu.height <= windowDimensions.height) { diff --git a/src/vs/base/common/layout.ts b/src/vs/base/common/layout.ts new file mode 100644 index 00000000000..b3ca8f372b1 --- /dev/null +++ b/src/vs/base/common/layout.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from './range.js'; + +export interface IAnchor { + x: number; + y: number; + width?: number; + height?: number; +} + +export const enum AnchorAlignment { + LEFT, RIGHT +} + +export const enum AnchorPosition { + BELOW, ABOVE +} + +export const enum AnchorAxisAlignment { + VERTICAL, HORIZONTAL +} + +interface IPosition { + readonly top: number; + readonly left: number; +} + +interface ISize { + readonly width: number; + readonly height: number; +} + +export interface IRect extends IPosition, ISize { } + +export const enum LayoutAnchorPosition { + Before, + After +} + +export enum LayoutAnchorMode { + AVOID, + ALIGN +} + +export interface ILayoutAnchor { + offset: number; + size: number; + mode?: LayoutAnchorMode; // default: AVOID + position: LayoutAnchorPosition; +} + +export interface ILayoutResult { + position: number; + result: 'ok' | 'flipped' | 'overlap'; +} + +/** + * Lays out a one dimensional view next to an anchor in a viewport. + * + * @returns The view offset within the viewport. + */ +export function layout(viewportSize: number, viewSize: number, anchor: ILayoutAnchor): ILayoutResult { + const layoutAfterAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset : anchor.offset + anchor.size; + const layoutBeforeAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset + anchor.size : anchor.offset; + + if (anchor.position === LayoutAnchorPosition.Before) { + if (viewSize <= viewportSize - layoutAfterAnchorBoundary) { + return { position: layoutAfterAnchorBoundary, result: 'ok' }; // happy case, lay it out after the anchor + } + + if (viewSize <= layoutBeforeAnchorBoundary) { + return { position: layoutBeforeAnchorBoundary - viewSize, result: 'flipped' }; // ok case, lay it out before the anchor + } + + return { position: Math.max(viewportSize - viewSize, 0), result: 'overlap' }; // sad case, lay it over the anchor + } else { + if (viewSize <= layoutBeforeAnchorBoundary) { + return { position: layoutBeforeAnchorBoundary - viewSize, result: 'ok' }; // happy case, lay it out before the anchor + } + + if (viewSize <= viewportSize - layoutAfterAnchorBoundary && layoutBeforeAnchorBoundary < viewSize / 2) { + return { position: layoutAfterAnchorBoundary, result: 'flipped' }; // ok case, lay it out after the anchor + } + + return { position: 0, result: 'overlap' }; // sad case, lay it over the anchor + } +} + +interface ILayout2DOptions { + readonly anchorAlignment?: AnchorAlignment; // default: left + readonly anchorPosition?: AnchorPosition; // default: above + readonly anchorAxisAlignment?: AnchorAxisAlignment; // default: vertical +} + +export interface ILayout2DResult { + top: number; + left: number; + bottom: number; + right: number; + anchorAlignment: AnchorAlignment; + anchorPosition: AnchorPosition; +} + +export function layout2d(viewport: IRect, view: ISize, anchor: IRect, options?: ILayout2DOptions): ILayout2DResult { + let anchorAlignment = options?.anchorAlignment ?? AnchorAlignment.LEFT; + let anchorPosition = options?.anchorPosition ?? AnchorPosition.BELOW; + const anchorAxisAlignment = options?.anchorAxisAlignment ?? AnchorAxisAlignment.VERTICAL; + + let top: number; + let left: number; + + if (anchorAxisAlignment === AnchorAxisAlignment.VERTICAL) { + const verticalAnchor: ILayoutAnchor = { offset: anchor.top - viewport.top, size: anchor.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; + const horizontalAnchor: ILayoutAnchor = { offset: anchor.left, size: anchor.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; + + const verticalLayoutResult = layout(viewport.height, view.height, verticalAnchor); + top = verticalLayoutResult.position + viewport.top; + + if (verticalLayoutResult.result === 'flipped') { + anchorPosition = anchorPosition === AnchorPosition.BELOW ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } + + // if view intersects vertically with anchor, we must avoid the anchor + if (Range.intersects({ start: top, end: top + view.height }, { start: verticalAnchor.offset, end: verticalAnchor.offset + verticalAnchor.size })) { + horizontalAnchor.mode = LayoutAnchorMode.AVOID; + } + + const horizontalLayoutResult = layout(viewport.width, view.width, horizontalAnchor); + left = horizontalLayoutResult.position; + + if (horizontalLayoutResult.result === 'flipped') { + anchorAlignment = anchorAlignment === AnchorAlignment.LEFT ? AnchorAlignment.RIGHT : AnchorAlignment.LEFT; + } + } else { + const horizontalAnchor: ILayoutAnchor = { offset: anchor.left, size: anchor.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; + const verticalAnchor: ILayoutAnchor = { offset: anchor.top, size: anchor.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; + + const horizontalLayoutResult = layout(viewport.width, view.width, horizontalAnchor); + left = horizontalLayoutResult.position; + + if (horizontalLayoutResult.result === 'flipped') { + anchorAlignment = anchorAlignment === AnchorAlignment.LEFT ? AnchorAlignment.RIGHT : AnchorAlignment.LEFT; + } + + // if view intersects horizontally with anchor, we must avoid the anchor + if (Range.intersects({ start: left, end: left + view.width }, { start: horizontalAnchor.offset, end: horizontalAnchor.offset + horizontalAnchor.size })) { + verticalAnchor.mode = LayoutAnchorMode.AVOID; + } + + const verticalLayoutResult = layout(viewport.height, view.height, verticalAnchor); + top = verticalLayoutResult.position + viewport.top; + + if (verticalLayoutResult.result === 'flipped') { + anchorPosition = anchorPosition === AnchorPosition.BELOW ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } + } + + const right = viewport.width - (left + view.width); + const bottom = viewport.height - (top + view.height); + + return { top, left, bottom, right, anchorAlignment, anchorPosition }; +} diff --git a/src/vs/base/test/browser/ui/contextview/contextview.test.ts b/src/vs/base/test/common/layout.test.ts similarity index 60% rename from src/vs/base/test/browser/ui/contextview/contextview.test.ts rename to src/vs/base/test/common/layout.test.ts index 4058d33f4a9..a6be1ea8ed2 100644 --- a/src/vs/base/test/browser/ui/contextview/contextview.test.ts +++ b/src/vs/base/test/common/layout.test.ts @@ -4,27 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { layout, LayoutAnchorPosition } from '../../../../browser/ui/contextview/contextview.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; +import { layout, LayoutAnchorPosition } from '../../common/layout.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; -suite('Contextview', function () { +suite('Layout', function () { test('layout', () => { - assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.Before }), 0); - assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.Before }), 50); - assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.Before }), 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.Before }).position, 0); + assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.Before }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.Before }).position, 180); - assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.After }), 0); - assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.After }), 30); - assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.After }), 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.After }).position, 0); + assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.After }).position, 30); + assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.After }).position, 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.Before }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.Before }).position, 100); + assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.Before }).position, 130); - assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.Before }), 50); - assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.Before }), 100); - assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.Before }), 130); - - assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.After }), 50); - assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.After }), 30); - assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.After }), 130); + assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.After }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.After }).position, 30); + assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.After }).position, 130); }); ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 0636687742d..bd509719a3c 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -20,6 +20,11 @@ border-top-left-radius: 5px; } +.quick-input-widget.no-drag .quick-input-titlebar, +.quick-input-widget.no-drag .quick-input-title, +.quick-input-widget.no-drag .quick-input-header { + cursor: default; +} .quick-input-widget .monaco-inputbox .monaco-action-bar { top: 0; } diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index fae2f7ffb45..8e5283ef9ad 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -37,6 +37,8 @@ import { TriStateCheckbox, createToggleActionViewItemProvider } from '../../../b import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { QuickInputTreeController } from './tree/quickInputTreeController.js'; import { QuickTree } from './tree/quickTree.js'; +import { AnchorAlignment, AnchorPosition, layout2d } from '../../../base/common/layout.js'; +import { getAnchorRect } from '../../../base/browser/ui/contextview/contextview.js'; const $ = dom.$; @@ -541,6 +543,7 @@ export class QuickInputController extends Disposable { input.quickNavigate = options.quickNavigate; input.hideInput = !!options.hideInput; input.contextKey = options.contextKey; + input.anchor = options.anchor; input.busy = true; Promise.all([picks, options.activeItem]) .then(([items, _activeItem]) => { @@ -710,6 +713,7 @@ export class QuickInputController extends Disposable { ui.container.style.display = ''; this.updateLayout(); + this.dndController?.setEnabled(!controller.anchor); this.dndController?.layoutContainer(); ui.inputBox.setFocus(); this.quickInputTypeContext.set(controller.type); @@ -861,16 +865,52 @@ export class QuickInputController extends Disposable { private updateLayout() { if (this.ui && this.isVisible()) { const style = this.ui.container.style; - const width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); + let width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); style.width = width + 'px'; + let listHeight = this.dimension && this.dimension.height * 0.4; + // Position - style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; - style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`; + if (this.controller?.anchor) { + const container = this.layoutService.getContainer(dom.getActiveWindow()).getBoundingClientRect(); + const anchor = getAnchorRect(this.controller.anchor); + width = 380; + listHeight = this.dimension ? Math.min(this.dimension.height * 0.2, 200) : 200; + + // Beware: + // We need to add some extra pixels to the height to account for the input and padding. + const containerHeight = Math.floor(listHeight) + 6 + 26 + 16; + const { top, left, right, bottom, anchorAlignment, anchorPosition } = layout2d(container, { width, height: containerHeight }, anchor, { anchorPosition: AnchorPosition.ABOVE }); + + if (anchorAlignment === AnchorAlignment.RIGHT) { + style.right = `${right}px`; + style.left = 'initial'; + } else { + style.left = `${left}px`; + style.right = 'initial'; + } + + if (anchorPosition === AnchorPosition.ABOVE) { + style.bottom = `${bottom}px`; + style.top = 'initial'; + } else { + style.top = `${top}px`; + style.bottom = 'initial'; + } + + style.width = `${width}px`; + style.height = ''; + } else { + style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; + style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`; + style.right = ''; + style.bottom = ''; + style.height = ''; + } this.ui.inputBox.layout(); - this.ui.list.layout(this.dimension && this.dimension.height * 0.4); - this.ui.tree.layout(this.dimension && this.dimension.height * 0.4); + this.ui.list.layout(listHeight); + this.ui.tree.layout(listHeight); } } @@ -965,6 +1005,8 @@ export interface IQuickInputControllerHost extends ILayoutService { } class QuickInputDragAndDropController extends Disposable { readonly dndViewState = observableValue<{ top?: number; left?: number; done: boolean } | undefined>(this, undefined); + private _enabled = true; + private readonly _snapThreshold = 20; private readonly _snapLineHorizontalRatio = 0.25; @@ -1000,6 +1042,10 @@ class QuickInputDragAndDropController extends Disposable { } layoutContainer(dimension = this._layoutService.activeContainerDimension): void { + if (!this._enabled) { + return; + } + const state = this.dndViewState.get(); const dragAreaRect = this._quickInputContainer.getBoundingClientRect(); if (state?.top && state?.left) { @@ -1011,6 +1057,11 @@ class QuickInputDragAndDropController extends Disposable { } } + setEnabled(enabled: boolean): void { + this._enabled = enabled; + this._quickInputContainer.classList.toggle('no-drag', !enabled); + } + setAlignment(alignment: 'top' | 'center' | { top: number; left: number }, done = true): void { if (alignment === 'top') { this.dndViewState.set({ @@ -1041,6 +1092,10 @@ class QuickInputDragAndDropController extends Disposable { // Double click this._register(dom.addDisposableGenericMouseUpListener(dragArea, (event: MouseEvent) => { + if (!this._enabled) { + return; + } + const originEvent = new StandardMouseEvent(dom.getWindow(dragArea), event); if (originEvent.detail !== 2) { return; @@ -1057,6 +1112,10 @@ class QuickInputDragAndDropController extends Disposable { // Mouse down this._register(dom.addDisposableGenericMouseDownListener(dragArea, (e: MouseEvent) => { + if (!this._enabled) { + return; + } + const activeWindow = dom.getWindow(this._layoutService.activeContainer); const originEvent = new StandardMouseEvent(activeWindow, e); diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 9ff9d71fe6c..9426be48e2f 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -197,6 +197,11 @@ export interface IPickOptions { */ activeItem?: Promise | T; + /** + * an optional anchor for the picker + */ + anchor?: HTMLElement | { x: number; y: number }; + onKeyMods?: (keyMods: IKeyMods) => void; onDidFocus?: (entry: T) => void; onDidTriggerItemButton?: (context: IQuickPickItemButtonContext) => void; @@ -353,6 +358,11 @@ export interface IQuickInput extends IDisposable { */ ignoreFocusOut: boolean; + /** + * An optional anchor for the quick input. + */ + anchor?: HTMLElement | { x: number; y: number }; + /** * Shows the quick input. */ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 0af4544f3e9..c07c6e5111a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -149,7 +149,7 @@ export class PickAgentSessionAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const instantiationService = accessor.get(IInstantiationService); - const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker); + const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, undefined); await agentSessionsPicker.pickAgentSession(); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index 9251a44e9fe..27fe44366cb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -67,6 +67,7 @@ export class AgentSessionsPicker { private readonly sorter = new AgentSessionsSorter(); constructor( + private readonly anchor: HTMLElement | undefined, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -78,6 +79,7 @@ export class AgentSessionsPicker { const picker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); const filter = disposables.add(this.instantiationService.createInstance(AgentSessionsFilter, {})); + picker.anchor = this.anchor; picker.items = this.createPickerItems(filter); picker.canAcceptInBackground = true; picker.placeholder = localize('chatAgentPickerPlaceholder', "Search agent sessions by name"); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts index 53769e1c43c..0cbc75d1883 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts @@ -59,6 +59,8 @@ export class ChatViewTitleControl extends Disposable { } private registerActions(): void { + const that = this; + this._register(registerAction2(class extends Action2 { constructor() { super({ @@ -76,7 +78,7 @@ export class ChatViewTitleControl extends Disposable { async run(accessor: ServicesAccessor): Promise { const instantiationService = accessor.get(IInstantiationService); - const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker); + const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, that.titleLabel.value?.element); await agentSessionsPicker.pickAgentSession(); } })); From 891dbff6f2a46690545d06b95cd02b9435988504 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 13:11:23 -0600 Subject: [PATCH 30/65] tips cleanup, improvements (#294177) --- .../contrib/chat/browser/chatTipService.ts | 37 ++++---- .../chatContentParts/chatTipContentPart.ts | 10 +-- .../chat/browser/widget/chatListRenderer.ts | 10 +-- .../chat/test/browser/chatTipService.test.ts | 84 +++++++++++++++---- 4 files changed, 96 insertions(+), 45 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 309d5b366f2..a26d065d8a9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../common/constants.js'; @@ -14,9 +14,11 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { AgentFileType, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -57,7 +59,7 @@ export interface IChatTipService { /** * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. */ - disableTips(): void; + disableTips(): Promise; } export interface ITipDefinition { @@ -153,9 +155,9 @@ export class TipEligibilityTracker extends Disposable { constructor( tips: readonly ITipDefinition[], - commandService: ICommandService, - private readonly _storageService: IStorageService, - promptsService: IPromptsService, + @ICommandService commandService: ICommandService, + @IStorageService private readonly _storageService: IStorageService, + @IPromptsService promptsService: IPromptsService, ) { super(); @@ -262,8 +264,12 @@ export class TipEligibilityTracker extends Disposable { private async _checkForInstructionFiles(promptsService: IPromptsService): Promise { try { - const files = await promptsService.listAgentInstructions(CancellationToken.None); - this._hasInstructionFiles = files.length > 0; + const [agentInstructions, instructionFiles] = await Promise.all([ + promptsService.listAgentInstructions(CancellationToken.None), + promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), + ]); + const hasCopilotInstructions = agentInstructions.some(f => f.type === AgentFileType.copilotInstructionsMd); + this._hasInstructionFiles = hasCopilotInstructions || instructionFiles.length > 0; } catch { this._hasInstructionFiles = true; } @@ -312,14 +318,11 @@ export class ChatTipService extends Disposable implements IChatTipService { @IProductService private readonly _productService: IProductService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IStorageService private readonly _storageService: IStorageService, - @ICommandService commandService: ICommandService, - @IStorageService storageService: IStorageService, - @IPromptsService promptsService: IPromptsService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, ) { super(); - this._tracker = this._register(new TipEligibilityTracker( - TIP_CATALOG, commandService, storageService, promptsService, - )); + this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); } dismissTip(): void { @@ -341,17 +344,18 @@ export class ChatTipService extends Disposable implements IChatTipService { } try { const parsed = JSON.parse(raw); + this._logService.debug('#ChatTips dismissed:', parsed); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } - disableTips(): void { + async disableTips(): Promise { this._hasShownTip = false; this._shownTip = undefined; this._tipRequestId = undefined; - this._configurationService.updateValue('chat.tips.enabled', false); + await this._configurationService.updateValue('chat.tips.enabled', false); this._onDidDisableTips.fire(); } @@ -406,6 +410,7 @@ export class ChatTipService extends Disposable implements IChatTipService { private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean { if (tip.when && !contextKeyService.contextMatchesRules(tip.when)) { + this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize()); return false; } return !this._tracker.isExcluded(tip); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 0e71dfec5c1..df83eb5d194 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -32,11 +32,11 @@ export class ChatTipContentPart extends Disposable { constructor( tip: IChatTip, private readonly _renderer: IMarkdownRenderer, - private readonly _chatTipService: IChatTipService, - private readonly _contextMenuService: IContextMenuService, - private readonly _menuService: IMenuService, - private readonly _contextKeyService: IContextKeyService, private readonly _getNextTip: () => IChatTip | undefined, + @IChatTipService private readonly _chatTipService: IChatTipService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IMenuService private readonly _menuService: IMenuService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, ) { super(); @@ -114,7 +114,7 @@ registerAction2(class DisableTipsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - accessor.get(IChatTipService).disableTips(); + await accessor.get(IChatTipService).disableTips(); } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 0d812eb533f..38d1242b302 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -34,7 +34,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IMenuEntryActionViewItemOptions, createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; -import { MenuId, MenuItemAction, IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -254,8 +254,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), ); templateData.value.appendChild(tipPart.domNode); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 5da31f55818..0c9f6dc9c7e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -12,12 +12,15 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService, InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; -import { IPromptsService, IResolvedAgentFile } from '../../common/promptSyntax/service/promptsService.js'; +import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../../common/constants.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { override contextMatchesRules(): boolean { @@ -34,6 +37,7 @@ suite('ChatTipService', () => { let commandExecutedEmitter: Emitter; let storageService: InMemoryStorageService; let mockInstructionFiles: IResolvedAgentFile[]; + let mockPromptInstructionFiles: IPromptPath[]; function createProductService(hasCopilot: boolean): IProductService { return { @@ -55,15 +59,18 @@ suite('ChatTipService', () => { commandExecutedEmitter = testDisposables.add(new Emitter()); storageService = testDisposables.add(new InMemoryStorageService()); mockInstructionFiles = []; + mockPromptInstructionFiles = []; instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IStorageService, storageService); + instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(ICommandService, { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: testDisposables.add(new Emitter()).event, } as Partial as ICommandService); instantiationService.stub(IPromptsService, { listAgentInstructions: async () => mockInstructionFiles, + listPromptFiles: async () => mockPromptInstructionFiles, } as Partial as IPromptsService); }); @@ -190,7 +197,7 @@ suite('ChatTipService', () => { assert.ok(fired, 'onDidDismissTip should fire'); }); - test('disableTips fires onDidDisableTips event', () => { + test('disableTips fires onDidDisableTips event', async () => { const service = createService(); const now = Date.now(); @@ -198,12 +205,12 @@ suite('ChatTipService', () => { let fired = false; testDisposables.add(service.onDidDisableTips(() => { fired = true; })); - service.disableTips(); + await service.disableTips(); assert.ok(fired, 'onDidDisableTips should fire'); }); - test('disableTips resets state so re-enabling works', () => { + test('disableTips resets state so re-enabling works', async () => { const service = createService(); const now = Date.now(); @@ -212,7 +219,7 @@ suite('ChatTipService', () => { assert.ok(tip1); // Disable tips - service.disableTips(); + await service.disableTips(); // Re-enable tips configurationService.setUserConfiguration('chat.tips.enabled', true); @@ -222,6 +229,13 @@ suite('ChatTipService', () => { assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); }); + function createMockPromptsService(agentInstructions: IResolvedAgentFile[] = [], promptInstructions: IPromptPath[] = []): Partial { + return { + listAgentInstructions: async () => agentInstructions, + listPromptFiles: async (_type: PromptsType) => promptInstructions, + }; + } + test('excludes tip.undoChanges when restore checkpoint command has been executed', () => { const tip: ITipDefinition = { id: 'tip.undoChanges', @@ -233,7 +247,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before command is executed'); @@ -243,7 +257,7 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after command is executed'); }); - test('excludes tip.customInstructions when instruction files exist in workspace', async () => { + test('excludes tip.customInstructions when copilot-instructions.md exists in workspace', async () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', @@ -253,13 +267,51 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [{ uri: { path: '/.github/copilot-instructions.md' } } as IResolvedAgentFile] } as Partial as IPromptsService, + createMockPromptsService([{ uri: { path: '/.github/copilot-instructions.md' }, realPath: undefined, type: AgentFileType.copilotInstructionsMd } as IResolvedAgentFile]) as IPromptsService, )); // Wait for the async file check to complete await new Promise(r => setTimeout(r, 0)); - assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when instruction files exist'); + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when copilot-instructions.md exists'); + }); + + test('does not exclude tip.customInstructions when only AGENTS.md exists', async () => { + const tip: ITipDefinition = { + id: 'tip.customInstructions', + message: 'test', + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([{ uri: { path: '/AGENTS.md' }, realPath: undefined, type: AgentFileType.agentsMd } as IResolvedAgentFile]) as IPromptsService, + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when only AGENTS.md exists'); + }); + + test('excludes tip.customInstructions when .instructions.md files exist in workspace', async () => { + const tip: ITipDefinition = { + id: 'tip.customInstructions', + message: 'test', + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [{ uri: URI.file('/.github/instructions/coding.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }]) as IPromptsService, + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when .instructions.md files exist'); }); test('does not exclude tip.customInstructions when no instruction files exist', async () => { @@ -272,7 +324,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); // Wait for the async file check to complete @@ -295,7 +347,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -319,7 +371,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -340,7 +392,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); @@ -351,7 +403,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted exclusion from workspace storage'); @@ -371,7 +423,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); tracker1.recordCurrentMode(contextKeyService); @@ -382,7 +434,7 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); From 97f7c15f5dafd1248897ef223b2f0a29882c0179 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 13:27:14 -0600 Subject: [PATCH 31/65] do not use same kb for question carousel and confirmation dialog (#294182) fix #294039 --- src/vs/workbench/contrib/chat/browser/actions/chatActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index fefa2b156dd..24b02b3d54a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -793,7 +793,7 @@ export function registerChatActions() { precondition: ChatContextKeys.inChatSession, keybinding: [{ weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyH, when: ChatContextKeys.inChatSession, }] }); From 2050e3c2c3cb4da31cd32bb7e0a40012920e5992 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 13:55:00 -0600 Subject: [PATCH 32/65] Terminal: update auto replies configuration (#294207) fix #294200 --- .../autoReplies/common/terminalAutoRepliesConfiguration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts index 4979520e356..dc20533be3f 100644 --- a/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts @@ -26,6 +26,7 @@ export const terminalAutoRepliesConfiguration: IStringDictionary Date: Tue, 10 Feb 2026 12:20:41 -0800 Subject: [PATCH 33/65] feat: force chat setting to be in subcategory (#294212) --- .../contrib/preferences/browser/settingsLayout.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index 30b1f1f9524..dc0bd76f057 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -174,7 +174,7 @@ export const tocData: ITOCEntry = { { id: 'chat', label: localize('chat', "Chat"), - settings: ['chat.*'], + settings: [], children: [ { id: 'chat/agent', @@ -187,7 +187,8 @@ export const tocData: ITOCEntry = { 'chat.undoRequests.*', 'chat.customAgentInSubagent.*', 'chat.editing.autoAcceptDelay', - 'chat.editing.confirmEditRequest*' + 'chat.editing.confirmEditRequest*', + 'chat.planAgent.defaultModel' ] }, { @@ -209,7 +210,8 @@ export const tocData: ITOCEntry = { 'chat.notifyWindow*', 'chat.statusWidget.*', 'chat.tips.*', - 'chat.unifiedAgentsBar.*' + 'chat.unifiedAgentsBar.*', + 'chat.confettiOnThumbsUp' ] }, { @@ -259,7 +261,8 @@ export const tocData: ITOCEntry = { 'chat.useChatHooks', 'chat.includeApplyingInstructions', 'chat.includeReferencedInstructions', - 'chat.sendElementsToChat.*' + 'chat.sendElementsToChat.*', + 'chat.useClaudeMdFile' ] }, { From 8efd494dc82a8e8b322609df3dc91baa4beb37c7 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:21:40 -0800 Subject: [PATCH 34/65] Allow contributed configurations to define additional "search terms" --- .../configuration/common/configurationRegistry.ts | 5 +++++ .../workbench/api/common/configurationExtensionPoint.ts | 7 +++++++ .../contrib/preferences/browser/preferencesSearch.ts | 9 ++++++--- .../workbench/services/preferences/common/preferences.ts | 1 + .../services/preferences/common/preferencesModels.ts | 1 + 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index f12e732db79..c26fc939188 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -200,6 +200,11 @@ export interface IConfigurationPropertySchema extends IJSONSchema { */ enumItemLabels?: string[]; + /** + * Optional terms used for search purposes. + */ + searchTerms?: string[]; + /** * When specified, controls the presentation format of string settings. * Otherwise, the presentation format defaults to `singleline`. diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 4a2cf457445..6f777af406a 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -116,6 +116,13 @@ const configurationEntrySchema: IJSONSchema = { type: 'boolean', description: nls.localize('scope.ignoreSync', 'When enabled, Settings Sync will not sync the user value of this configuration by default.') }, + searchTerms: { + type: 'array', + items: { + type: 'string' + }, + description: nls.localize('scope.searchTerms', 'A list of additional search terms that help users find this setting in the Settings editor. These are not shown to the user.') + }, tags: { type: 'array', items: { diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts index 3721b873139..0cfcac7318d 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts @@ -242,10 +242,13 @@ export class SettingMatches { // Search the description if we found non-contiguous key matches at best. const hasContiguousKeyMatchTypes = this.matchType >= SettingMatchType.ContiguousWordsInSettingsLabel; if (this.searchDescription && !hasContiguousKeyMatchTypes) { + // Search the description lines and any additional search terms. + const searchableLines = setting.searchTerms?.length + ? [...setting.description, setting.searchTerms.join(' ')] + : setting.description; for (const word of queryWords) { - // Search the description lines. - for (let lineIndex = 0; lineIndex < setting.description.length; lineIndex++) { - const descriptionMatches = matchesBaseContiguousSubString(word, setting.description[lineIndex]); + for (let lineIndex = 0; lineIndex < searchableLines.length; lineIndex++) { + const descriptionMatches = matchesBaseContiguousSubString(word, searchableLines[lineIndex]); if (descriptionMatches?.length) { descriptionMatchingWords.set(word, descriptionMatches.map(match => this.toDescriptionRange(setting, match, lineIndex))); } diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 4fc67f48798..08cb72355db 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -64,6 +64,7 @@ export interface ISetting { value: any; valueRange: IRange; description: string[]; + searchTerms?: string[]; descriptionIsMarkdown?: boolean; descriptionRanges: IRange[]; overrides?: ISetting[]; diff --git a/src/vs/workbench/services/preferences/common/preferencesModels.ts b/src/vs/workbench/services/preferences/common/preferencesModels.ts index 3dc1900f41a..79fc7bc33c5 100644 --- a/src/vs/workbench/services/preferences/common/preferencesModels.ts +++ b/src/vs/workbench/services/preferences/common/preferencesModels.ts @@ -723,6 +723,7 @@ export class DefaultSettings extends Disposable { value, description: descriptionLines, descriptionIsMarkdown: !!prop.markdownDescription, + searchTerms: prop.searchTerms, range: nullRange, keyRange: nullRange, valueRange: nullRange, From d434a65945c2f2519bc2d64e42352d8dc91b302a Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:32:40 -0800 Subject: [PATCH 35/65] Use esbuild to pack the markdown extension for desktop and web (#294208) * Try using esbuild to bundle our built-in extensions Test switching to esbuild instead of webpack to bundle our buildin extensions. Setup so we can do this incrementally and starting with the markdown extension as a test * Fix build ext media * Fix .ts script name check * Update comment * Use ts for all scripts --- build/gulpfile.extensions.ts | 30 +++++++- build/lib/extensions.ts | 75 ++++++++++++++++-- eslint.config.js | 4 +- extensions/esbuild-extension-common.ts | 77 +++++++++++++++++++ .../esbuild-browser.ts | 40 ++++++++++ .../markdown-language-features/esbuild.ts | 27 +++++++ .../extension-browser.webpack.config.js | 27 ------- .../extension.webpack.config.js | 28 ------- 8 files changed, 241 insertions(+), 67 deletions(-) create mode 100644 extensions/esbuild-extension-common.ts create mode 100644 extensions/markdown-language-features/esbuild-browser.ts create mode 100644 extensions/markdown-language-features/esbuild.ts delete mode 100644 extensions/markdown-language-features/extension-browser.webpack.config.js delete mode 100644 extensions/markdown-language-features/extension.webpack.config.js diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index a310fbbe548..f48738be53a 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -273,11 +273,33 @@ gulp.task(compileWebExtensionsTask); export const watchWebExtensionsTask = task.define('watch-web', () => buildWebExtensions(true)); gulp.task(watchWebExtensionsTask); -async function buildWebExtensions(isWatch: boolean) { +async function buildWebExtensions(isWatch: boolean): Promise { const extensionsPath = path.join(root, 'extensions'); - const webpackConfigLocations = await nodeUtil.promisify(glob)( - path.join(extensionsPath, '**', 'extension-browser.webpack.config.js'), + + // Find all esbuild-browser.ts files + const esbuildConfigLocations = await nodeUtil.promisify(glob)( + path.join(extensionsPath, '**', 'esbuild-browser.ts'), { ignore: ['**/node_modules'] } ); - return ext.webpackExtensions('packaging web extension', isWatch, webpackConfigLocations.map(configPath => ({ configPath }))); + + // Find all webpack configs, excluding those that will be esbuilt + const esbuildExtensionDirs = new Set(esbuildConfigLocations.map(p => path.dirname(p))); + const webpackConfigLocations = (await nodeUtil.promisify(glob)( + path.join(extensionsPath, '**', 'extension-browser.webpack.config.js'), + { ignore: ['**/node_modules'] } + )).filter(configPath => !esbuildExtensionDirs.has(path.dirname(configPath))); + + const promises: Promise[] = []; + + // Esbuild for extensions + if (esbuildConfigLocations.length > 0) { + promises.push(ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script })))); + } + + // Run webpack for remaining extensions + if (webpackConfigLocations.length > 0) { + promises.push(ext.webpackExtensions('packaging web extension', isWatch, webpackConfigLocations.map(configPath => ({ configPath })))); + } + + await Promise.all(promises); } diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index e06f1510a66..cea54bff8b9 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -66,16 +66,31 @@ function updateExtensionPackageJSON(input: Stream, update: (data: any) => any): function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolean): Stream { + const esbuildConfigFileName = forWeb + ? 'esbuild-browser.ts' + : 'esbuild.ts'; + const webpackConfigFileName = forWeb ? `extension-browser.webpack.config.js` : `extension.webpack.config.js`; + const hasEsbuild = fs.existsSync(path.join(extensionPath, esbuildConfigFileName)); const isWebPacked = fs.existsSync(path.join(extensionPath, webpackConfigFileName)); - let input = isWebPacked - ? fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle) - : fromLocalNormal(extensionPath); - if (isWebPacked) { + let input: Stream; + let isBundled = false; + + if (hasEsbuild) { + input = fromLocalEsbuild(extensionPath, esbuildConfigFileName); + isBundled = true; + } else if (isWebPacked) { + input = fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle); + isBundled = true; + } else { + input = fromLocalNormal(extensionPath); + } + + if (isBundled) { input = updateExtensionPackageJSON(input, (data: any) => { delete data.scripts; delete data.dependencies; @@ -240,6 +255,51 @@ function fromLocalNormal(extensionPath: string): Stream { return result.pipe(createStatsStream(path.basename(extensionPath))); } +function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): Stream { + const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); + const result = es.through(); + + const esbuildScript = path.join(extensionPath, esbuildConfigFileName); + + // Run esbuild, then collect the files + new Promise((resolve, reject) => { + const proc = cp.execFile(process.argv[0], [esbuildScript], {}, (error, _stdout, stderr) => { + if (error) { + return reject(error); + } + const matches = (stderr || '').match(/\> (.+): error: (.+)?/g); + fancyLog(`Bundled extension: ${ansiColors.yellow(path.join(path.basename(extensionPath), esbuildConfigFileName))} with ${matches ? matches.length : 0} errors.`); + for (const match of matches || []) { + fancyLog.error(match); + } + return resolve(); + }); + + proc.stdout!.on('data', (data) => { + fancyLog(`${ansiColors.green('esbuilding')}: ${data.toString('utf8')}`); + }); + }).then(() => { + // After esbuild completes, collect all files using vsce + return vsce.listFiles({ cwd: extensionPath, packageManager: vsce.PackageManager.None }); + }).then(fileNames => { + const files = fileNames + .map(fileName => path.join(extensionPath, fileName)) + .map(filePath => new File({ + path: filePath, + stat: fs.statSync(filePath), + base: extensionPath, + contents: fs.createReadStream(filePath) + })); + + es.readArray(files).pipe(result); + }).catch(err => { + console.error(extensionPath); + result.emit('error', err); + }); + + return result.pipe(createStatsStream(path.basename(extensionPath))); +} + const userAgent = 'VSCode Build'; const baseHeaders = { 'X-Market-Client-Id': 'VSCode Build', @@ -647,7 +707,7 @@ export async function webpackExtensions(taskName: string, isWatch: boolean, webp }); } -async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { script: string; outputRoot?: string }[]) { +export async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { script: string; outputRoot?: string }[]): Promise { function reporter(stdError: string, script: string) { const matches = (stdError || '').match(/\> (.+): error: (.+)?/g); fancyLog(`Finished ${ansiColors.green(taskName)} ${script} with ${matches ? matches.length : 0} errors.`); @@ -678,10 +738,11 @@ async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { }); }); }); - return Promise.all(tasks); + + await Promise.all(tasks); } -export async function buildExtensionMedia(isWatch: boolean, outputRoot?: string) { +export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Promise { return esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts.map(p => ({ script: path.join(extensionsPath, p), outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(p)) : undefined diff --git a/eslint.config.js b/eslint.config.js index 96e1232427b..fa55c74032c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2064,7 +2064,9 @@ export default tseslint.config( // Additional extension strictness rules { files: [ - 'extensions/markdown-language-features/**/*.ts', + 'extensions/markdown-language-features/src/**/*.ts', + 'extensions/markdown-language-features/notebook/**/*.ts', + 'extensions/markdown-language-features/preview-src/**/*.ts', 'extensions/mermaid-chat-features/**/*.ts', 'extensions/media-preview/**/*.ts', 'extensions/simple-browser/**/*.ts', diff --git a/extensions/esbuild-extension-common.ts b/extensions/esbuild-extension-common.ts new file mode 100644 index 00000000000..513656ae89f --- /dev/null +++ b/extensions/esbuild-extension-common.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/** + * @fileoverview Common build script for extensions. + */ +import path from 'node:path'; +import esbuild from 'esbuild'; + +type BuildOptions = Partial & { + outdir: string; +}; + +/** + * Build the source code once using esbuild. + */ +async function build(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { + await esbuild.build({ + bundle: true, + minify: true, + sourcemap: false, + format: 'cjs', + platform: 'node', + target: ['es2024'], + external: ['vscode'], + ...options, + }); + + await didBuild?.(options.outdir); +} + +/** + * Build the source code once using esbuild, logging errors instead of throwing. + */ +async function tryBuild(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { + try { + await build(options, didBuild); + } catch (err) { + console.error(err); + } +} + +interface RunConfig { + srcDir: string; + outdir: string; + entryPoints: string[] | Record | { in: string; out: string }[]; + additionalOptions?: Partial; +} + +export async function run(config: RunConfig, args: string[], didBuild?: (outDir: string) => unknown): Promise { + let outdir = config.outdir; + const outputRootIndex = args.indexOf('--outputRoot'); + if (outputRootIndex >= 0) { + const outputRoot = args[outputRootIndex + 1]; + const outputDirName = path.basename(outdir); + outdir = path.join(outputRoot, outputDirName); + } + + const resolvedOptions: BuildOptions = { + entryPoints: config.entryPoints, + outdir, + logOverride: { + 'import-is-undefined': 'error', + }, + ...(config.additionalOptions || {}), + }; + + const isWatch = args.indexOf('--watch') >= 0; + if (isWatch) { + await tryBuild(resolvedOptions, didBuild); + const watcher = await import('@parcel/watcher'); + watcher.subscribe(config.srcDir, () => tryBuild(resolvedOptions, didBuild)); + } else { + return build(resolvedOptions, didBuild).catch(() => process.exit(1)); + } +} diff --git a/extensions/markdown-language-features/esbuild-browser.ts b/extensions/markdown-language-features/esbuild-browser.ts new file mode 100644 index 00000000000..2c46e390c06 --- /dev/null +++ b/extensions/markdown-language-features/esbuild-browser.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +/** + * Copy the language server worker main file to the output directory. + */ +async function copyServerWorkerMain(outDir: string): Promise { + const srcPath = path.join(import.meta.dirname, 'node_modules', 'vscode-markdown-languageserver', 'dist', 'browser', 'workerMain.js'); + const destPath = path.join(outDir, 'serverWorkerMain.js'); + await fs.promises.copyFile(srcPath, destPath); +} + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.browser.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + alias: { + 'path': 'path-browserify', + }, + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/esbuild.ts b/extensions/markdown-language-features/esbuild.ts new file mode 100644 index 00000000000..67835c9a1d7 --- /dev/null +++ b/extensions/markdown-language-features/esbuild.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +/** + * Copy the language server worker main file to the output directory. + */ +async function copyServerWorkerMain(outDir: string): Promise { + const srcPath = path.join(import.meta.dirname, 'node_modules', 'vscode-markdown-languageserver', 'dist', 'node', 'workerMain.js'); + const destPath = path.join(outDir, 'serverWorkerMain.js'); + await fs.promises.copyFile(srcPath, destPath); +} + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/extension-browser.webpack.config.js b/extensions/markdown-language-features/extension-browser.webpack.config.js deleted file mode 100644 index 5471319a4c4..00000000000 --- a/extensions/markdown-language-features/extension-browser.webpack.config.js +++ /dev/null @@ -1,27 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import CopyPlugin from 'copy-webpack-plugin'; -import { browser, browserPlugins } from '../shared.webpack.config.mjs'; - -export default browser({ - context: import.meta.dirname, - entry: { - extension: './src/extension.browser.ts' - }, - plugins: [ - ...browserPlugins(import.meta.dirname), // add plugins, don't replace inherited - new CopyPlugin({ - patterns: [ - { - from: './node_modules/vscode-markdown-languageserver/dist/browser/workerMain.js', - to: 'serverWorkerMain.js', - } - ], - }), - ], -}, { - configFile: 'tsconfig.browser.json' -}); diff --git a/extensions/markdown-language-features/extension.webpack.config.js b/extensions/markdown-language-features/extension.webpack.config.js deleted file mode 100644 index 51c9912f9af..00000000000 --- a/extensions/markdown-language-features/extension.webpack.config.js +++ /dev/null @@ -1,28 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import CopyPlugin from 'copy-webpack-plugin'; -import withDefaults, { nodePlugins } from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - }, - plugins: [ - ...nodePlugins(import.meta.dirname), // add plugins, don't replace inherited - new CopyPlugin({ - patterns: [ - { - from: './node_modules/vscode-markdown-languageserver/dist/node/workerMain.js', - to: 'serverWorkerMain.js', - } - ], - }), - ], -}); From efa1a41a18af79e592104d837b665465df86c359 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 10 Feb 2026 21:51:20 +0100 Subject: [PATCH 36/65] modal editor - allow to maximise (#294185) * modal editor - allow to maximise * feedback * feedback --- .../browser/parts/editor/editorCommands.ts | 38 +++++++++++++- .../parts/editor/media/modalEditorPart.css | 4 +- .../browser/parts/editor/modalEditorPart.ts | 50 ++++++++++++++++-- src/vs/workbench/common/contextkeys.ts | 2 + .../editor/common/editorGroupsService.ts | 15 ++++++ .../test/browser/modalEditorGroup.test.ts | 51 +++++++++++++++++++ 6 files changed, 152 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index fd15522971e..693918dc2ca 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -27,7 +27,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { ActiveGroupEditorsByMostRecentlyUsedQuickAccess } from './editorQuickAccess.js'; import { SideBySideEditor } from './sideBySideEditor.js'; import { TextDiffEditor } from './textDiffEditor.js'; -import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; +import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; @@ -107,6 +107,7 @@ export const NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID = 'workbench.action.newEmptyEdit export const CLOSE_MODAL_EDITOR_COMMAND_ID = 'workbench.action.closeModalEditor'; export const MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID = 'workbench.action.moveModalEditorToMain'; +export const TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID = 'workbench.action.toggleModalEditorMaximized'; export const API_OPEN_EDITOR_COMMAND_ID = '_workbench.open'; export const API_OPEN_DIFF_EDITOR_COMMAND_ID = '_workbench.diff'; @@ -1435,6 +1436,39 @@ function registerModalEditorCommands(): void { } }); + registerAction2(class extends Action2 { + constructor() { + super({ + id: TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID, + title: localize2('toggleModalEditorMaximized', 'Maximize Modal Editor'), + category: Categories.View, + f1: true, + precondition: EditorPartModalContext, + icon: Codicon.screenFull, + toggled: { + condition: EditorPartModalMaximizedContext, + icon: Codicon.screenNormal, + title: localize('restoreModalEditorSize', "Restore Modal Editor") + }, + menu: { + id: MenuId.ModalEditorTitle, + group: 'navigation', + order: 1 + } + }); + } + run(accessor: ServicesAccessor): void { + const editorGroupsService = accessor.get(IEditorGroupsService); + + for (const part of editorGroupsService.parts) { + if (isModalEditorPart(part)) { + part.toggleMaximized(); + break; + } + } + } + }); + registerAction2(class extends Action2 { constructor() { super({ @@ -1475,6 +1509,8 @@ function isModalEditorPart(obj: unknown): obj is IModalEditorPart { return !!part && typeof part.close === 'function' && typeof part.onWillClose === 'function' + && typeof part.toggleMaximized === 'function' + && typeof part.maximized === 'boolean' && part.windowId === mainWindow.vscodeWindowId; } diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index de3bc1e1f33..19b64fc8c85 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -6,15 +6,15 @@ /** Modal Editor Part: Modal Block */ .monaco-modal-editor-block { position: fixed; - height: 100%; width: 100%; left: 0; - top: 0; /* z-index for modal editors: below dialogs, quick input, context views, hovers but above other things */ z-index: 2000; display: flex; justify-content: center; align-items: center; + /* Never allow content to escape above the title bar */ + overflow: hidden; } .monaco-modal-editor-block.dimmed { diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index f4b26051c5b..6e65299f400 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -22,7 +22,7 @@ import { IEditorGroupView, IEditorPartsView } from './editor.js'; import { EditorPart } from './editorPart.js'; import { GroupDirection, GroupsOrder, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { EditorPartModalContext } from '../../../common/contextkeys.js'; +import { EditorPartModalContext, EditorPartModalMaximizedContext } from '../../../common/contextkeys.js'; import { Verbosity } from '../../../common/editor.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; @@ -145,10 +145,32 @@ export class ModalEditorPart { })); // Layout the modal editor part - disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, () => { + const layoutModal = () => { const containerDimension = this.layoutService.mainContainerDimension; - const width = Math.min(containerDimension.width * 0.8, 1200); - const height = Math.min(containerDimension.height * 0.8, 800); + const titleBarOffset = this.layoutService.mainContainerOffset.top; + const availableHeight = Math.max(containerDimension.height - titleBarOffset, 0); + + let width: number; + let height: number; + + if (editorPart.maximized) { + const padding = 16; // Keep a small margin around all edges + width = Math.max(containerDimension.width - padding, 0); + height = Math.max(availableHeight - padding, 0); + } else { + const maxWidth = 1200; + const maxHeight = 800; + const targetWidth = containerDimension.width * 0.8; + const targetHeight = availableHeight * 0.8; + width = Math.min(targetWidth, maxWidth, containerDimension.width); + height = Math.min(targetHeight, maxHeight, availableHeight); + } + + height = Math.min(height, availableHeight); // Ensure the modal never exceeds available height (below the title bar) + + // Shift the modal block below the title bar + modalElement.style.top = `${titleBarOffset}px`; + modalElement.style.height = `calc(100% - ${titleBarOffset}px)`; editorPartContainer.style.width = `${width}px`; editorPartContainer.style.height = `${height}px`; @@ -156,7 +178,9 @@ export class ModalEditorPart { const borderSize = 2; // Account for 1px border on all sides and modal header height const headerHeight = 32 + 1 /* border bottom */; editorPart.layout(width - borderSize, height - borderSize - headerHeight, 0, 0); - })); + }; + disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, layoutModal)); + disposables.add(editorPart.onDidChangeMaximized(() => layoutModal())); // Focus the modal editorPartContainer.focus(); @@ -176,6 +200,12 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private readonly _onWillClose = this._register(new Emitter()); readonly onWillClose = this._onWillClose.event; + private readonly _onDidChangeMaximized = this._register(new Emitter()); + readonly onDidChangeMaximized = this._onDidChangeMaximized.event; + + private _maximized = false; + get maximized(): boolean { return this._maximized; } + private readonly optionsDisposable = this._register(new MutableDisposable()); constructor( @@ -212,10 +242,20 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { this.enforceModalPartOptions(); } + toggleMaximized(): void { + this._maximized = !this._maximized; + + this._onDidChangeMaximized.fire(this._maximized); + } + protected override handleContextKeys(): void { const isModalEditorPartContext = EditorPartModalContext.bindTo(this.scopedContextKeyService); isModalEditorPartContext.set(true); + const isMaximizedContext = EditorPartModalMaximizedContext.bindTo(this.scopedContextKeyService); + isMaximizedContext.set(this._maximized); + this._register(this.onDidChangeMaximized(maximized => isMaximizedContext.set(maximized))); + super.handleContextKeys(); } diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 9d00d682730..20b3b1f804d 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -95,7 +95,9 @@ export const SelectedEditorsInGroupFileOrUntitledResourceContextKey = new RawCon export const EditorPartMultipleEditorGroupsContext = new RawContextKey('editorPartMultipleEditorGroups', false, localize('editorPartMultipleEditorGroups', "Whether there are multiple editor groups opened in an editor part")); export const EditorPartSingleEditorGroupsContext = EditorPartMultipleEditorGroupsContext.toNegated(); export const EditorPartMaximizedEditorGroupContext = new RawContextKey('editorPartMaximizedEditorGroup', false, localize('editorPartEditorGroupMaximized', "Editor Part has a maximized group")); + export const EditorPartModalContext = new RawContextKey('editorPartModal', false, localize('editorPartModal', "Whether focus is in a modal editor part")); +export const EditorPartModalMaximizedContext = new RawContextKey('editorPartModalMaximized', false, localize('editorPartModalMaximized', "Whether the modal editor part is maximized")); // Editor Layout Context Keys export const EditorsVisibleContext = new RawContextKey('editorIsOpen', false, localize('editorIsOpen', "Whether an editor is open")); diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index a67469b8609..221b30801c0 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -510,6 +510,21 @@ export interface IModalEditorPart extends IEditorPart { */ readonly onWillClose: Event; + /** + * Whether the modal editor part is currently maximized. + */ + readonly maximized: boolean; + + /** + * Fired when the maximized state changes. + */ + readonly onDidChangeMaximized: Event; + + /** + * Toggle between default and maximized size. + */ + toggleMaximized(): void; + /** * Close this modal editor part after moving all * editors of all groups back to the main editor part diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index c20d6b95d32..fdad2e694ad 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -372,5 +372,56 @@ suite('Modal Editor Group', () => { assert.strictEqual(removedGroupId, modalGroupId); }); + test('modal editor part starts not maximized', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + assert.strictEqual(modalPart.maximized, false); + + modalPart.close(); + }); + + test('modal editor part toggleMaximized toggles state', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + assert.strictEqual(modalPart.maximized, false); + + modalPart.toggleMaximized(); + assert.strictEqual(modalPart.maximized, true); + + modalPart.toggleMaximized(); + assert.strictEqual(modalPart.maximized, false); + + modalPart.close(); + }); + + test('modal editor part fires onDidChangeMaximized', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const events: boolean[] = []; + disposables.add(modalPart.onDidChangeMaximized(maximized => events.push(maximized))); + + modalPart.toggleMaximized(); + modalPart.toggleMaximized(); + + assert.deepStrictEqual(events, [true, false]); + + modalPart.close(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); From fa527f4c80910b5b710aae3d1c8bd95d1b25aaa3 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 10 Feb 2026 13:35:40 -0800 Subject: [PATCH 37/65] Update handling of claude hooks config (#294187) --- .../chat/browser/promptSyntax/hookActions.ts | 38 ++++--- .../common/promptSyntax/hookCompatibility.ts | 13 +++ .../browser/promptSyntax/hookUtils.test.ts | 70 ++++++++++++ .../promptSyntax/hookClaudeCompat.test.ts | 100 ++++++++++++++++++ 4 files changed, 208 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index c3cab3b104c..babfb20a486 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -22,6 +22,8 @@ import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQui import { IFileService } from '../../../../../platform/files/common/files.js'; import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js'; +import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../common/promptSyntax/hookCompatibility.js'; +import { getClaudeHookTypeName, resolveClaudeHookType } from '../../common/promptSyntax/hookClaudeCompat.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; @@ -116,15 +118,23 @@ async function addHookToFile( hooksContent = { hooks: {} }; } + // Detect source format from file URI + const sourceFormat = getHookSourceFormat(hookFileUri); + const isClaude = sourceFormat === HookSourceFormat.Claude; + // Detect naming convention from existing keys - const useCopilotCliNamingConvention = usesCopilotCliNaming(hooksContent.hooks); - const hookTypeKeyName = getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention); + const useCopilotCliNamingConvention = !isClaude && usesCopilotCliNaming(hooksContent.hooks); + const hookTypeKeyName = isClaude + ? (getClaudeHookTypeName(hookTypeId) ?? hookTypeId) + : getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention); // Also check if there's an existing key for this hook type (with either naming) // Find existing key that resolves to the same hook type let existingKeyForType: string | undefined; for (const key of Object.keys(hooksContent.hooks)) { - const resolvedType = resolveCopilotCliHookType(key); + const resolvedType = isClaude + ? resolveClaudeHookType(key) + : resolveCopilotCliHookType(key); if (resolvedType === hookTypeId || key === hookTypeId) { existingKeyForType = key; break; @@ -135,10 +145,7 @@ async function addHookToFile( const keyToUse = existingKeyForType ?? hookTypeKeyName; // Add the new hook entry (append if hook type already exists) - const newHookEntry = { - type: 'command', - command: '' - }; + const newHookEntry = buildNewHookEntry(sourceFormat); let newHookIndex: number; if (!hooksContent.hooks[keyToUse]) { hooksContent.hooks[keyToUse] = [newHookEntry]; @@ -668,14 +675,19 @@ export async function showConfigureHooksQuickPick( return; } + // Detect if new file is a Claude hooks file based on its path + const newFileFormat = getHookSourceFormat(hookFileUri); + const isClaudeNewFile = newFileFormat === HookSourceFormat.Claude; + const hookTypeKey = isClaudeNewFile + ? (getClaudeHookTypeName(selectedHookType!.hookType.id as HookType) ?? selectedHookType!.hookType.id) + : selectedHookType!.hookType.id; + const newFileHookEntry = buildNewHookEntry(newFileFormat); + // Create new hook file with the selected hook type const hooksContent = { hooks: { - [selectedHookType!.hookType.id]: [ - { - type: 'command', - command: '' - } + [hookTypeKey]: [ + newFileHookEntry ] } }; @@ -684,7 +696,7 @@ export async function showConfigureHooksQuickPick( await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, selectedHookType!.hookType.id, 0, 'command'); + const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command'); // Open editor with selection store.dispose(); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index e3c483d3811..1525bbb59e8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -148,3 +148,16 @@ export function getHookSourceFormatLabel(format: HookSourceFormat): string { return 'GitHub Copilot'; } } + +/** + * Builds a new hook entry object in the appropriate format for the given source format. + * - Copilot format: `{ type: 'command', command: '' }` + * - Claude format: `{ matcher: '', hooks: [{ type: 'command', command: '' }] }` + */ +export function buildNewHookEntry(format: HookSourceFormat): Record { + const commandEntry = { type: 'command', command: '' }; + if (format === HookSourceFormat.Claude) { + return { matcher: '', hooks: [commandEntry] }; + } + return commandEntry; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts index 617a4ae433c..d75ce8adbb8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js'; +import { buildNewHookEntry, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; /** * Helper to extract the selected text from content using a selection range. @@ -652,4 +653,73 @@ suite('hookUtils', () => { }); }); }); + + suite('findHookCommandSelection with buildNewHookEntry', () => { + + test('finds command in Copilot-format generated JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const content = JSON.stringify({ hooks: { SessionStart: [entry] } }, null, '\t'); + const result = findHookCommandSelection(content, 'SessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + }); + + test('finds command in Claude-format generated JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { PreToolUse: [entry] } }, null, '\t'); + const result = findHookCommandSelection(content, 'PreToolUse', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + }); + + test('finds command when appending Claude entry to existing hooks', () => { + const entry1 = buildNewHookEntry(HookSourceFormat.Claude); + const entry2 = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { PreToolUse: [entry1, entry2] } }, null, '\t'); + + const result0 = findHookCommandSelection(content, 'PreToolUse', 0, 'command'); + const result1 = findHookCommandSelection(content, 'PreToolUse', 1, 'command'); + assert.ok(result0); + assert.ok(result1); + assert.strictEqual(getSelectedText(content, result0), ''); + assert.strictEqual(getSelectedText(content, result1), ''); + // Second entry should be on a later line + assert.ok(result1.startLineNumber > result0.startLineNumber); + }); + + test('Claude format JSON has correct structure', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { SubagentStart: [entry] } }, null, '\t'); + const parsed = JSON.parse(content); + assert.deepStrictEqual(parsed, { + hooks: { + SubagentStart: [ + { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + } + ] + } + }); + }); + + test('Copilot format JSON has correct structure', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const content = JSON.stringify({ hooks: { SubagentStart: [entry] } }, null, '\t'); + const parsed = JSON.parse(content); + assert.deepStrictEqual(parsed, { + hooks: { + SubagentStart: [ + { + type: 'command', + command: '' + } + ] + } + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 54a2760c28e..8321a90cf69 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { HookType } from '../../../common/promptSyntax/hookSchema.js'; import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName } from '../../../common/promptSyntax/hookClaudeCompat.js'; +import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; suite('HookClaudeCompat', () => { @@ -341,3 +342,102 @@ suite('HookClaudeCompat', () => { }); }); }); + +suite('HookSourceFormat', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getHookSourceFormat', () => { + test('detects Claude format for .claude/settings.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.claude/settings.json')), HookSourceFormat.Claude); + }); + + test('detects Claude format for .claude/settings.local.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.claude/settings.local.json')), HookSourceFormat.Claude); + }); + + test('detects Claude format for ~/.claude/settings.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/home/user/.claude/settings.json')), HookSourceFormat.Claude); + }); + + test('returns Copilot format for .github/hooks/hooks.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.github/hooks/hooks.json')), HookSourceFormat.Copilot); + }); + + test('returns Copilot format for arbitrary .json file', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.github/hooks/my-hooks.json')), HookSourceFormat.Copilot); + }); + + test('returns Copilot format for settings.json not inside .claude', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.vscode/settings.json')), HookSourceFormat.Copilot); + }); + }); + + suite('buildNewHookEntry', () => { + test('builds Copilot format entry', () => { + assert.deepStrictEqual(buildNewHookEntry(HookSourceFormat.Copilot), { + type: 'command', + command: '' + }); + }); + + test('builds Claude format entry with matcher wrapper', () => { + assert.deepStrictEqual(buildNewHookEntry(HookSourceFormat.Claude), { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + }); + }); + + test('Claude format entry serializes correctly in JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const hooksContent = { + hooks: { + SubagentStart: [entry] + } + }; + const json = JSON.stringify(hooksContent, null, '\t'); + const parsed = JSON.parse(json); + assert.deepStrictEqual(parsed.hooks.SubagentStart[0], { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + }); + }); + + test('Copilot format entry serializes correctly in JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const hooksContent = { + hooks: { + SubagentStart: [entry] + } + }; + const json = JSON.stringify(hooksContent, null, '\t'); + const parsed = JSON.parse(json); + assert.deepStrictEqual(parsed.hooks.SubagentStart[0], { + type: 'command', + command: '' + }); + }); + + test('Claude format round-trips through parseClaudeHooks', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const hooksContent = { + hooks: { + PreToolUse: [entry] + } + }; + + const result = parseClaudeHooks(hooksContent, URI.file('/workspace'), '/home/user'); + assert.strictEqual(result.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + const hooks = result.get(HookType.PreToolUse)!; + assert.strictEqual(hooks.hooks.length, 1); + // Empty command string is falsy and gets omitted by resolveHookCommand + assert.strictEqual(hooks.hooks[0].command, undefined); + }); + }); +}); From 4448228afb3e64516f4cf29a3b2000beb05b02c3 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 10 Feb 2026 22:36:22 +0100 Subject: [PATCH 38/65] Escape closes modal too eagerly when in text editor (fix #294179) (#294195) * Escape closes modal too eagerly when in text editor (fix #294179) * feedback --- .../browser/parts/editor/editorParts.ts | 1 + .../editor/common/editorGroupFinder.ts | 31 ++++++- .../editor/common/editorGroupsService.ts | 5 ++ .../test/browser/modalEditorGroup.test.ts | 81 +++++++++++++++++++ .../test/browser/workbenchTestServices.ts | 2 + 5 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 71864ffb0c2..61703e493fd 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -155,6 +155,7 @@ export class EditorParts extends MultiWindowParts { diff --git a/src/vs/workbench/services/editor/common/editorGroupFinder.ts b/src/vs/workbench/services/editor/common/editorGroupFinder.ts index 1b93908f2f7..825061a3663 100644 --- a/src/vs/workbench/services/editor/common/editorGroupFinder.ts +++ b/src/vs/workbench/services/editor/common/editorGroupFinder.ts @@ -8,7 +8,7 @@ import { EditorActivation } from '../../../../platform/editor/common/editor.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { EditorInputWithOptions, isEditorInputWithOptions, IUntypedEditorInput, isEditorInput, EditorInputCapabilities } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService } from './editorGroupsService.js'; +import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService, IModalEditorPart } from './editorGroupsService.js'; import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, MODAL_GROUP, MODAL_GROUP_TYPE, PreferredGroup, SIDE_GROUP } from './editorService.js'; /** @@ -29,12 +29,39 @@ export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOpt const group = doFindGroup(editor, preferredGroup, editorGroupService, configurationService); if (group instanceof Promise) { - return group.then(group => handleGroupActivation(group, editor, preferredGroup, editorGroupService)); + return group.then(group => handleGroupResult(group, editor, preferredGroup, editorGroupService)); + } + + return handleGroupResult(group, editor, preferredGroup, editorGroupService); +} + +function handleGroupResult(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined, editorGroupService: IEditorGroupsService): [IEditorGroup, EditorActivation | undefined] { + const modalEditorPart = editorGroupService.activeModalEditorPart; + if (modalEditorPart && preferredGroup !== MODAL_GROUP) { + // Only allow to open in modal group if MODAL_GROUP is explicitly requested + group = handleModalEditorPart(group, editor, modalEditorPart, editorGroupService); } return handleGroupActivation(group, editor, preferredGroup, editorGroupService); } +function handleModalEditorPart(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, modalEditorPart: IModalEditorPart, editorGroupService: IEditorGroupsService): IEditorGroup { + const options = editor.options; + + // If the resolved group is part of the modal, redirect + // to the main window active group instead + if (modalEditorPart.groups.some(modalGroup => modalGroup.id === group.id)) { + group = editorGroupService.mainPart.activeGroup; + } + + // Try to close the modal editor part unless preserveFocus is set + if (!options?.preserveFocus) { + modalEditorPart.close(); + } + + return group; +} + function handleGroupActivation(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined, editorGroupService: IEditorGroupsService): [IEditorGroup, EditorActivation | undefined] { // Resolve editor activation strategy diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 221b30801c0..d7a24923b9e 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -610,6 +610,11 @@ export interface IEditorGroupsService extends IEditorGroupsContainer { */ createModalEditorPart(): Promise; + /** + * The currently active modal editor part, if any. + */ + readonly activeModalEditorPart: IModalEditorPart | undefined; + /** * Returns the instantiation service that is scoped to the * provided editor part. Use this method when building UI diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index fdad2e694ad..f33d165e59f 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -15,6 +15,7 @@ import { SideBySideEditorInput } from '../../../../common/editor/sideBySideEdito import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; +import { findGroup } from '../../common/editorGroupFinder.js'; suite('Modal Editor Group', () => { @@ -372,6 +373,86 @@ suite('Modal Editor Group', () => { assert.strictEqual(removedGroupId, modalGroupId); }); + test('activeModalEditorPart is set when modal is created and cleared on close', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // No modal initially + assert.strictEqual(parts.activeModalEditorPart, undefined); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + assert.strictEqual(parts.activeModalEditorPart, modalPart); + + // Close modal + modalPart.close(); + assert.strictEqual(parts.activeModalEditorPart, undefined); + }); + + test('findGroup returns main part group when modal is active and preferredGroup is not MODAL_GROUP', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const mainGroup = parts.mainPart.activeGroup; + + // Create modal and open an editor in it + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // findGroup without MODAL_GROUP should return main part group, not modal group + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + const [group] = instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource }, undefined)); + + assert.strictEqual(group.id, mainGroup.id); + }); + + test('findGroup closes modal when preferredGroup is not MODAL_GROUP and preserveFocus is not set', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + assert.ok(parts.activeModalEditorPart); + + // findGroup without MODAL_GROUP and without preserveFocus should close the modal + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource }, undefined)); + + assert.strictEqual(parts.activeModalEditorPart, undefined); + }); + + test('findGroup keeps modal open when preserveFocus is true', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + assert.ok(parts.activeModalEditorPart); + + // findGroup with preserveFocus should keep the modal open + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource, options: { preserveFocus: true } }, undefined)); + + assert.strictEqual(parts.activeModalEditorPart, modalPart); + + modalPart.close(); + }); + test('modal editor part starts not maximized', async () => { const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index a2b918d627a..ba91f4f6ac8 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -924,6 +924,7 @@ export class TestEditorGroupsService implements IEditorGroupsService { enforcePartOptions(options: IEditorPartOptions): IDisposable { return Disposable.None; } readonly mainPart = this; + readonly activeModalEditorPart: IModalEditorPart | undefined = undefined; registerEditorPart(part: any): IDisposable { return Disposable.None; } createAuxiliaryEditorPart(): Promise { throw new Error('Method not implemented.'); } createModalEditorPart(): Promise { throw new Error('Method not implemented.'); } @@ -1639,6 +1640,7 @@ export class TestEditorPart extends MainEditorPart implements IEditorGroupsServi readonly mainPart = this; readonly parts: readonly IEditorPart[] = [this]; + readonly activeModalEditorPart: IModalEditorPart | undefined = undefined; readonly onDidCreateAuxiliaryEditorPart: Event = Event.None; From d99eacc2c1e37731362c16728c80967c2bdc8b6d Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:04:52 -0800 Subject: [PATCH 39/65] fix: apply Copilot feedback (#294235) --- src/vs/workbench/contrib/preferences/browser/settingsLayout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index dc0bd76f057..bc3a037c246 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -174,7 +174,6 @@ export const tocData: ITOCEntry = { { id: 'chat', label: localize('chat', "Chat"), - settings: [], children: [ { id: 'chat/agent', @@ -246,6 +245,7 @@ export const tocData: ITOCEntry = { label: localize('chatContext', "Context"), settings: [ 'chat.detectParticipant.*', + 'chat.experimental.detectParticipant.*', 'chat.implicitContext.*', 'chat.promptFilesLocations', 'chat.instructionsFilesLocations', From 0991a42347e19d171576596e475ef3abd15ec730 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 10 Feb 2026 14:10:55 -0800 Subject: [PATCH 40/65] Add more configuration slash commands (#294231) --- .../contrib/chat/browser/chat.contribution.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 91706e2e422..10e81458395 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1430,6 +1430,46 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { }, async () => { await instantiationService.invokeFunction(showConfigureHooksQuickPick); })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'agents', + detail: nls.localize('agents', "Configure custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.customagents'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'skills', + detail: nls.localize('skills', "Configure skills"), + sortText: 'z3_skills', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.skills'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'instructions', + detail: nls.localize('instructions', "Configure instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.instructions'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'prompts', + detail: nls.localize('prompts', "Configure prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.prompts'); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'help', detail: '', From 9f6e41537951d91c27a0aea79afc4b4c9bf51184 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 16:20:11 -0600 Subject: [PATCH 41/65] add more tips, make all of them contextual (#294237) fixes #290019 --- .../chat/browser/actions/chatActions.ts | 18 ++ .../contrib/chat/browser/chatTipService.ts | 214 ++++++++++++++++-- .../browser/widget/input/chatInputPart.ts | 8 + .../viewPane/chatContextUsageWidget.ts | 87 ++++--- .../chat/common/actions/chatContextKeys.ts | 2 + .../chat/test/browser/chatTipService.test.ts | 178 ++++++++++++++- 6 files changed, 460 insertions(+), 47 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 24b02b3d54a..980ba420f12 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -809,6 +809,24 @@ export function registerChatActions() { } }); + registerAction2(class ShowContextUsageAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.showContextUsage', + title: localize2('interactiveSession.showContextUsage.label', "Show Context Window Usage"), + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.enabled, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget ?? (await widgetService.revealWidget()); + widget?.input.showContextUsageDetails(); + } + }); + const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.provider.enterprise.id)); registerAction2(class extends Action2 { constructor() { diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index a26d065d8a9..5d673cca996 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -19,6 +19,7 @@ import { PromptsType } from '../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -85,6 +86,21 @@ export interface ITipDefinition { * Matches against both mode kind (e.g. 'agent') and mode name (e.g. 'Plan'). */ readonly excludeWhenModesUsed?: string[]; + /** + * Tool IDs that, if ever invoked in this workspace, make this tip ineligible. + * The tip won't be shown if the tool it describes has already been used. + */ + readonly excludeWhenToolsInvoked?: string[]; + /** + * If set, exclude this tip when prompt files of the specified type exist in the workspace. + */ + readonly excludeWhenPromptFilesExist?: { + readonly promptType: PromptsType; + /** Also check for this specific agent instruction file type. */ + readonly agentFileType?: AgentFileType; + /** If true, exclude the tip until the async file check completes. Default: false. */ + readonly excludeUntilChecked?: boolean; + }; } /** @@ -108,10 +124,12 @@ const TIP_CATALOG: ITipDefinition[] = [ { id: 'tip.attachFiles', message: localize('tip.attachFiles', "Tip: Attach files or folders with # to give Copilot more context."), + excludeWhenCommandsExecuted: ['workbench.action.chat.attachContext', 'workbench.action.chat.attachFile', 'workbench.action.chat.attachFolder', 'workbench.action.chat.attachSelection'], }, { id: 'tip.codeActions', message: localize('tip.codeActions', "Tip: Select code and right-click for Copilot actions in the context menu."), + excludeWhenCommandsExecuted: ['inlineChat.start'], }, { id: 'tip.undoChanges', @@ -126,7 +144,78 @@ const TIP_CATALOG: ITipDefinition[] = [ id: 'tip.customInstructions', message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) so Copilot always has the context it needs when starting a task."), enabledCommands: ['workbench.action.chat.generateInstructions'], - } + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, + }, + { + id: 'tip.customAgent', + message: localize('tip.customAgent', "Tip: [Create a custom agent](command:workbench.command.new.agent) to define reusable personas with tailored instructions and tools for your workflow."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + enabledCommands: ['workbench.command.new.agent'], + excludeWhenCommandsExecuted: ['workbench.command.new.agent'], + excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, + }, + { + id: 'tip.skill', + message: localize('tip.skill', "Tip: [Create a skill](command:workbench.command.new.skill) so agents can perform domain-specific tasks with reusable prompts and tools."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + enabledCommands: ['workbench.command.new.skill'], + excludeWhenCommandsExecuted: ['workbench.command.new.skill'], + excludeWhenPromptFilesExist: { promptType: PromptsType.skill, excludeUntilChecked: true }, + }, + { + id: 'tip.messageQueueing', + message: localize('tip.messageQueueing', "Tip: You can send follow-up messages while the agent is working. They'll be queued and processed in order."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], + }, + { + id: 'tip.yoloMode', + message: localize('tip.yoloMode', "Tip: Enable [auto-approve mode](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), + ), + enabledCommands: ['workbench.action.openSettings'], + }, + { + id: 'tip.mermaid', + message: localize('tip.mermaid', "Tip: Ask the agent to visualize architectures and flows; it can render Mermaid diagrams directly in chat."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenToolsInvoked: ['renderMermaidDiagram'], + }, + { + id: 'tip.githubRepo', + message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (e.g. @owner/repo) in your prompt so the agent can query code and issues across that repo."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), + ), + excludeWhenToolsInvoked: ['github-pull-request_doSearch', 'github-pull-request_issue_fetch', 'github-pull-request_formSearchQuery'], + }, + { + id: 'tip.subagents', + message: localize('tip.subagents', "Tip: Ask the agent to implement a plan in parallel; it can delegate work across subagents for faster results."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenToolsInvoked: ['runSubagent'], + }, + { + id: 'tip.contextUsage', + message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are being spent and what's consuming them."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ChatContextKeys.contextUsageHasBeenOpened.negate(), + ChatContextKeys.chatSessionIsEmpty.negate(), + ), + enabledCommands: ['workbench.action.chat.showContextUsage'], + excludeWhenCommandsExecuted: ['workbench.action.chat.showContextUsage'], + }, + { + id: 'tip.sendToNewChat', + message: localize('tip.sendToNewChat', "Tip: Use [Send to New Chat](command:workbench.action.chat.sendToNewChat) to start a fresh conversation with a clean context window."), + when: ChatContextKeys.chatSessionIsEmpty.negate(), + enabledCommands: ['workbench.action.chat.sendToNewChat'], + excludeWhenCommandsExecuted: ['workbench.action.chat.sendToNewChat'], + }, ]; /** @@ -138,26 +227,38 @@ export class TipEligibilityTracker extends Disposable { private static readonly _COMMANDS_STORAGE_KEY = 'chat.tips.executedCommands'; private static readonly _MODES_STORAGE_KEY = 'chat.tips.usedModes'; + private static readonly _TOOLS_STORAGE_KEY = 'chat.tips.invokedTools'; private readonly _executedCommands: Set; private readonly _usedModes: Set; + private readonly _invokedTools: Set; private readonly _pendingCommands: Set; private readonly _pendingModes: Set; + private readonly _pendingTools: Set; private readonly _commandListener = this._register(new MutableDisposable()); + private readonly _toolListener = this._register(new MutableDisposable()); /** - * Whether agent instruction files exist in the workspace. - * Defaults to `true` (hide the tip) until the async check completes. + * Tip IDs excluded because prompt files of the required type exist in the workspace. + * Tips with `excludeUntilChecked` are pre-added and removed if no files are found. */ - private _hasInstructionFiles = true; + private readonly _excludedByFiles = new Set(); + + /** Tips that have file-based exclusions, kept for re-checks. */ + private readonly _tipsWithFileExclusions: readonly ITipDefinition[]; + + /** Generation counter per tip ID to discard stale async file-check results. */ + private readonly _fileCheckGeneration = new Map(); constructor( tips: readonly ITipDefinition[], @ICommandService commandService: ICommandService, @IStorageService private readonly _storageService: IStorageService, - @IPromptsService promptsService: IPromptsService, + @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -169,6 +270,9 @@ export class TipEligibilityTracker extends Disposable { const storedModes = this._storageService.get(TipEligibilityTracker._MODES_STORAGE_KEY, StorageScope.WORKSPACE); this._usedModes = new Set(storedModes ? JSON.parse(storedModes) : []); + const storedTools = this._storageService.get(TipEligibilityTracker._TOOLS_STORAGE_KEY, StorageScope.WORKSPACE); + this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); + // --- Derive what still needs tracking ---------------------------------- this._pendingCommands = new Set(); @@ -189,6 +293,15 @@ export class TipEligibilityTracker extends Disposable { } } + this._pendingTools = new Set(); + for (const tip of tips) { + for (const toolId of tip.excludeWhenToolsInvoked ?? []) { + if (!this._invokedTools.has(toolId)) { + this._pendingTools.add(toolId); + } + } + } + // --- Set up command listener (auto-disposes when all seen) -------------- if (this._pendingCommands.size > 0) { @@ -205,9 +318,40 @@ export class TipEligibilityTracker extends Disposable { }); } - // --- Async file check -------------------------------------------------- + // --- Set up tool listener (auto-disposes when all seen) ----------------- - this._checkForInstructionFiles(promptsService); + if (this._pendingTools.size > 0) { + this._toolListener.value = languageModelToolsService.onDidInvokeTool(e => { + if (this._pendingTools.has(e.toolId)) { + this._invokedTools.add(e.toolId); + this._persistSet(TipEligibilityTracker._TOOLS_STORAGE_KEY, this._invokedTools); + this._pendingTools.delete(e.toolId); + + if (this._pendingTools.size === 0) { + this._toolListener.clear(); + } + } + }); + } + + // --- Async file checks ------------------------------------------------- + + this._tipsWithFileExclusions = tips.filter(t => t.excludeWhenPromptFilesExist); + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } + this._checkForPromptFiles(tip); + } + + // Re-check agent file exclusions when custom agents change (covers late discovery) + this._register(this._promptsService.onDidChangeCustomAgents(() => { + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.promptType === PromptsType.agent) { + this._checkForPromptFiles(tip); + } + } + })); } /** @@ -245,6 +389,7 @@ export class TipEligibilityTracker extends Disposable { if (tip.excludeWhenCommandsExecuted) { for (const cmd of tip.excludeWhenCommandsExecuted) { if (this._executedCommands.has(cmd)) { + this._logService.debug('#ChatTips: tip excluded because command was executed', tip.id, cmd); return true; } } @@ -252,26 +397,59 @@ export class TipEligibilityTracker extends Disposable { if (tip.excludeWhenModesUsed) { for (const mode of tip.excludeWhenModesUsed) { if (this._usedModes.has(mode)) { + this._logService.debug('#ChatTips: tip excluded because mode was used', tip.id, mode); return true; } } } - if (tip.id === 'tip.customInstructions' && this._hasInstructionFiles) { + if (tip.excludeWhenToolsInvoked) { + for (const toolId of tip.excludeWhenToolsInvoked) { + if (this._invokedTools.has(toolId)) { + this._logService.debug('#ChatTips: tip excluded because tool was invoked', tip.id, toolId); + return true; + } + } + } + if (tip.excludeWhenPromptFilesExist && this._excludedByFiles.has(tip.id)) { + this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); return true; } return false; } - private async _checkForInstructionFiles(promptsService: IPromptsService): Promise { + private async _checkForPromptFiles(tip: ITipDefinition): Promise { + const config = tip.excludeWhenPromptFilesExist!; + const generation = (this._fileCheckGeneration.get(tip.id) ?? 0) + 1; + this._fileCheckGeneration.set(tip.id, generation); + try { - const [agentInstructions, instructionFiles] = await Promise.all([ - promptsService.listAgentInstructions(CancellationToken.None), - promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), + const [promptFiles, agentInstructions] = await Promise.all([ + this._promptsService.listPromptFiles(config.promptType, CancellationToken.None), + config.agentFileType ? this._promptsService.listAgentInstructions(CancellationToken.None) : Promise.resolve([]), ]); - const hasCopilotInstructions = agentInstructions.some(f => f.type === AgentFileType.copilotInstructionsMd); - this._hasInstructionFiles = hasCopilotInstructions || instructionFiles.length > 0; + + // Discard stale result if a newer check was started while we were awaiting + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + + const hasPromptFiles = promptFiles.length > 0; + const hasAgentFile = config.agentFileType + ? agentInstructions.some(f => f.type === config.agentFileType) + : false; + + if (hasPromptFiles || hasAgentFile) { + this._excludedByFiles.add(tip.id); + } else { + this._excludedByFiles.delete(tip.id); + } } catch { - this._hasInstructionFiles = true; + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + if (config.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } } } @@ -413,7 +591,11 @@ export class ChatTipService extends Disposable implements IChatTipService { this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize()); return false; } - return !this._tracker.isExcluded(tip); + if (this._tracker.isExcluded(tip)) { + return false; + } + this._logService.debug('#ChatTips: tip is eligible', tip.id); + return true; } private _isCopilotEnabled(): boolean { 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 00b4229ebb0..24ab3f42c01 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1698,6 +1698,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + /** + * Shows the context usage details popup and focuses it. + * @returns Whether the details were successfully shown. + */ + showContextUsageDetails(): boolean { + return this.contextUsageWidget?.showDetails() ?? false; + } + /** * Updates the context usage widget based on the current model. */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 05f0f2ab499..422e3a3e905 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -13,6 +13,9 @@ import { IObservable, observableValue } from '../../../../../../base/common/obse import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatContextUsageDetails, IChatContextUsageData } from './chatContextUsageDetails.js'; @@ -113,10 +116,16 @@ export class ChatContextUsageWidget extends Disposable { private currentData: IChatContextUsageData | undefined; + private static readonly _OPENED_STORAGE_KEY = 'chat.contextUsage.hasBeenOpened'; + + private readonly _contextUsageOpenedKey: IContextKey; + constructor( @IHoverService private readonly hoverService: IHoverService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -131,49 +140,69 @@ export class ChatContextUsageWidget extends Disposable { this.progressIndicator = new CircularProgressIndicator(); iconContainer.appendChild(this.progressIndicator.domNode); + // Track context usage opened state + this._contextUsageOpenedKey = ChatContextKeys.contextUsageHasBeenOpened.bindTo(this.contextKeyService); + + // Restore persisted state + if (this.storageService.getBoolean(ChatContextUsageWidget._OPENED_STORAGE_KEY, StorageScope.WORKSPACE, false)) { + this._contextUsageOpenedKey.set(true); + } + // Set up hover - will be configured when data is available this.setupHover(); } + /** + * Shows the sticky context usage details hover and records that the user + * has opened it. Returns `true` if the details were shown. + */ + showDetails(): boolean { + const details = this._createDetails(); + if (!details) { + return false; + } + this.hoverService.showInstantHover( + { ...this._hoverOptions, content: details.domNode, target: this.domNode, persistence: { hideOnHover: false, sticky: true } }, + true + ); + this._markOpened(); + return true; + } + + private readonly _hoverOptions: Omit = { + appearance: { showPointer: true, compact: true }, + persistence: { hideOnHover: false }, + trapFocus: true + }; + + private _createDetails(): ChatContextUsageDetails | undefined { + if (!this._isVisible.get() || !this.currentData) { + return undefined; + } + this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); + this._contextUsageDetails.value.update(this.currentData); + return this._contextUsageDetails.value; + } + + private _markOpened(): void { + this._contextUsageOpenedKey.set(true); + this.storageService.store(ChatContextUsageWidget._OPENED_STORAGE_KEY, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + private setupHover(): void { this._hoverDisposable.clear(); const store = new DisposableStore(); this._hoverDisposable.value = store; - const createDetails = (): ChatContextUsageDetails | undefined => { - if (!this._isVisible.get() || !this.currentData) { - return undefined; - } - this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); - this._contextUsageDetails.value.update(this.currentData); - return this._contextUsageDetails.value; - }; - - const hoverOptions: Omit = { - appearance: { showPointer: true, compact: true }, - persistence: { hideOnHover: false }, - trapFocus: true - }; - store.add(this.hoverService.setupDelayedHover(this.domNode, () => ({ - ...hoverOptions, - content: createDetails()?.domNode ?? '' + ...this._hoverOptions, + content: this._createDetails()?.domNode ?? '' }))); - const showStickyHover = () => { - const details = createDetails(); - if (details) { - this.hoverService.showInstantHover( - { ...hoverOptions, content: details.domNode, target: this.domNode, persistence: { hideOnHover: false, sticky: true } }, - true - ); - } - }; - // Show sticky + focused hover on click store.add(addDisposableListener(this.domNode, EventType.CLICK, e => { e.stopPropagation(); - showStickyHover(); + this.showDetails(); })); // Show sticky + focused hover on keyboard activation (Space/Enter) @@ -181,7 +210,7 @@ export class ChatContextUsageWidget extends Disposable { const evt = new StandardKeyboardEvent(e); if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) { e.preventDefault(); - showStickyHover(); + this.showDetails(); } })); } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index a9b4206c5e5..d2d47e5842f 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -120,6 +120,8 @@ export namespace ChatContextKeys { export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); + + export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); } export namespace ChatContextKeyExprs { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 0c9f6dc9c7e..3ee8fdae9f6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -21,6 +21,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; +import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { override contextMatchesRules(): boolean { @@ -71,7 +73,9 @@ suite('ChatTipService', () => { instantiationService.stub(IPromptsService, { listAgentInstructions: async () => mockInstructionFiles, listPromptFiles: async () => mockPromptInstructionFiles, + onDidChangeCustomAgents: Event.None, } as Partial as IPromptsService); + instantiationService.stub(ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())); }); test('returns a tip for new requests with timestamp after service creation', () => { @@ -229,13 +233,22 @@ suite('ChatTipService', () => { assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); }); - function createMockPromptsService(agentInstructions: IResolvedAgentFile[] = [], promptInstructions: IPromptPath[] = []): Partial { + function createMockPromptsService( + agentInstructions: IResolvedAgentFile[] = [], + promptInstructions: IPromptPath[] = [], + options?: { onDidChangeCustomAgents?: Event; listPromptFiles?: (_type: PromptsType) => Promise }, + ): Partial { return { listAgentInstructions: async () => agentInstructions, - listPromptFiles: async (_type: PromptsType) => promptInstructions, + listPromptFiles: options?.listPromptFiles ?? (async (_type: PromptsType) => promptInstructions), + onDidChangeCustomAgents: options?.onDidChangeCustomAgents ?? Event.None, }; } + function createMockToolsService(): MockLanguageModelToolsService { + return testDisposables.add(new MockLanguageModelToolsService()); + } + test('excludes tip.undoChanges when restore checkpoint command has been executed', () => { const tip: ITipDefinition = { id: 'tip.undoChanges', @@ -248,6 +261,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before command is executed'); @@ -261,6 +276,7 @@ suite('ChatTipService', () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }; const tracker = testDisposables.add(new TipEligibilityTracker( @@ -268,6 +284,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService([{ uri: { path: '/.github/copilot-instructions.md' }, realPath: undefined, type: AgentFileType.copilotInstructionsMd } as IResolvedAgentFile]) as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -280,6 +298,7 @@ suite('ChatTipService', () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }; const tracker = testDisposables.add(new TipEligibilityTracker( @@ -287,6 +306,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService([{ uri: { path: '/AGENTS.md' }, realPath: undefined, type: AgentFileType.agentsMd } as IResolvedAgentFile]) as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -299,6 +320,7 @@ suite('ChatTipService', () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }; const tracker = testDisposables.add(new TipEligibilityTracker( @@ -306,6 +328,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService([], [{ uri: URI.file('/.github/instructions/coding.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }]) as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -318,6 +342,7 @@ suite('ChatTipService', () => { const tip: ITipDefinition = { id: 'tip.customInstructions', message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, }; const tracker = testDisposables.add(new TipEligibilityTracker( @@ -325,6 +350,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -348,6 +375,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -372,6 +401,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -393,6 +424,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); @@ -404,6 +437,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted exclusion from workspace storage'); @@ -424,6 +459,8 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); tracker1.recordCurrentMode(contextKeyService); @@ -435,8 +472,145 @@ suite('ChatTipService', () => { { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); }); + + test('excludes tip when tracked tool has been invoked', () => { + const mockToolsService = createMockToolsService(); + const tip: ITipDefinition = { + id: 'tip.mermaid', + message: 'test', + excludeWhenToolsInvoked: ['renderMermaidDiagram'], + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + mockToolsService, + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before tool is invoked'); + + mockToolsService.fireOnDidInvokeTool({ toolId: 'renderMermaidDiagram', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after tool is invoked'); + }); + + test('persists tool exclusions to workspace storage across tracker instances', () => { + const mockToolsService = createMockToolsService(); + const tip: ITipDefinition = { + id: 'tip.subagents', + message: 'test', + excludeWhenToolsInvoked: ['runSubagent'], + }; + + const tracker1 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + mockToolsService, + new NullLogService(), + )); + + mockToolsService.fireOnDidInvokeTool({ toolId: 'runSubagent', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); + assert.strictEqual(tracker1.isExcluded(tip), true); + + // Second tracker reads from storage — should be excluded immediately + const tracker2 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted tool exclusion from workspace storage'); + }); + + test('excludes tip.skill when skill files exist in workspace', async () => { + const tip: ITipDefinition = { + id: 'tip.skill', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.skill }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [{ uri: URI.file('/.github/skills/my-skill.skill.md'), storage: PromptsStorage.local, type: PromptsType.skill }]) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when skill files exist'); + }); + + test('does not exclude tip.skill when no skill files exist', async () => { + const tip: ITipDefinition = { + id: 'tip.skill', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.skill }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); + }); + + test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { + const agentChangeEmitter = testDisposables.add(new Emitter()); + let agentFiles: IPromptPath[] = []; + + const tip: ITipDefinition = { + id: 'tip.customAgent', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [], { + onDidChangeCustomAgents: agentChangeEmitter.event, + listPromptFiles: async () => agentFiles, + }) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Initial check: no agent files, but excludeUntilChecked means excluded first + await new Promise(r => setTimeout(r, 0)); + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded after initial check finds no files'); + + // Simulate agent files appearing + agentFiles = [{ uri: URI.file('/.github/agents/my-agent.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent }]; + agentChangeEmitter.fire(); + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after onDidChangeCustomAgents fires and agent files exist'); + }); }); From b3c0c092b332eb8f43fd1a31e2eeb4d4cc15ce62 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:24:13 -0800 Subject: [PATCH 42/65] searchTerms -> keywords --- .../platform/configuration/common/configurationRegistry.ts | 4 ++-- src/vs/workbench/api/common/configurationExtensionPoint.ts | 4 ++-- .../contrib/preferences/browser/preferencesSearch.ts | 6 +++--- src/vs/workbench/services/preferences/common/preferences.ts | 2 +- .../services/preferences/common/preferencesModels.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index c26fc939188..f035cdec47c 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -201,9 +201,9 @@ export interface IConfigurationPropertySchema extends IJSONSchema { enumItemLabels?: string[]; /** - * Optional terms used for search purposes. + * Optional keywords used for search purposes. */ - searchTerms?: string[]; + keywords?: string[]; /** * When specified, controls the presentation format of string settings. diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 6f777af406a..46304274ef0 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -116,12 +116,12 @@ const configurationEntrySchema: IJSONSchema = { type: 'boolean', description: nls.localize('scope.ignoreSync', 'When enabled, Settings Sync will not sync the user value of this configuration by default.') }, - searchTerms: { + keywords: { type: 'array', items: { type: 'string' }, - description: nls.localize('scope.searchTerms', 'A list of additional search terms that help users find this setting in the Settings editor. These are not shown to the user.') + description: nls.localize('scope.keywords', 'A list of keywords that help users find this setting in the Settings editor. These are not shown to the user.') }, tags: { type: 'array', diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts index 0cfcac7318d..99fd9647231 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts @@ -242,9 +242,9 @@ export class SettingMatches { // Search the description if we found non-contiguous key matches at best. const hasContiguousKeyMatchTypes = this.matchType >= SettingMatchType.ContiguousWordsInSettingsLabel; if (this.searchDescription && !hasContiguousKeyMatchTypes) { - // Search the description lines and any additional search terms. - const searchableLines = setting.searchTerms?.length - ? [...setting.description, setting.searchTerms.join(' ')] + // Search the description lines and any additional keywords. + const searchableLines = setting.keywords?.length + ? [...setting.description, setting.keywords.join(' ')] : setting.description; for (const word of queryWords) { for (let lineIndex = 0; lineIndex < searchableLines.length; lineIndex++) { diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 08cb72355db..106103bdd9f 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -64,7 +64,7 @@ export interface ISetting { value: any; valueRange: IRange; description: string[]; - searchTerms?: string[]; + keywords?: string[]; descriptionIsMarkdown?: boolean; descriptionRanges: IRange[]; overrides?: ISetting[]; diff --git a/src/vs/workbench/services/preferences/common/preferencesModels.ts b/src/vs/workbench/services/preferences/common/preferencesModels.ts index 79fc7bc33c5..a6e78cc1fa0 100644 --- a/src/vs/workbench/services/preferences/common/preferencesModels.ts +++ b/src/vs/workbench/services/preferences/common/preferencesModels.ts @@ -723,7 +723,7 @@ export class DefaultSettings extends Disposable { value, description: descriptionLines, descriptionIsMarkdown: !!prop.markdownDescription, - searchTerms: prop.searchTerms, + keywords: prop.keywords, range: nullRange, keyRange: nullRange, valueRange: nullRange, From 11bbecdc9c8ddb2b67e42da1b360f86b73e1e80d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 10 Feb 2026 22:59:11 +0000 Subject: [PATCH 43/65] Move hook execution to extension (#294215) * Refactor hook execution * Fix compilation: add IExtHostHooks import, remove unused IHookResult, inline ChatRequestHooks type * Move hooks property to chatHooks proposal, sync DTS * cleanup * Remove dead hook execution code: proxy, RPC, output channel, progress events All hook execution now happens in the extension via NodeHookExecutor. HooksExecutionService is now a pure registry (registerHooks/getHooksForSession). Removed: - executeHook, setProxy, onDidHookProgress from service - IHooksExecutionProxy, IHookProgressEvent, HookAbortError, formatHookErrorMessage - hooksCommandTypes.ts, hooksTypes.ts (dead type files) - mainThreadHooks proxy setup - extHostHooksNode., extHostHooksWorker. - ExtHostHooksShape. protocol - IExtHostHooks DI registrations - ChatHooksProgressContribution - All associated test files * Remove HooksExecutionService entirely The service was only a registry for session hooks, but hooks are already passed directly on the chat request DTO. The registerHooks/getHooksForSession pattern was redundant. * Restore modelName support in chatSubagentContentPart that was accidentally removed during merge * Revert unrelated tabIndex change on chatSubagentContentPart * Remove empty hooks ext host infrastructure Delete IExtHostHooks, NodeExtHostHooks, WorkerExtHostHooks, MainThreadHooks, ExtHostHooksShape, MainThreadHooksShape - all were empty stubs after hook execution moved to extension. * Remove mainThreadHooks import from extensionHost.contribution * Fix DTS comments: env and timeoutSec are values, not implementation promises --- .../common/extensionsApiProposals.ts | 2 +- .../api/browser/extensionHost.contribution.ts | 1 - .../workbench/api/browser/mainThreadHooks.ts | 43 -- .../workbench/api/common/extHost.api.impl.ts | 7 - .../workbench/api/common/extHost.protocol.ts | 15 - src/vs/workbench/api/common/extHostHooks.ts | 21 - .../api/common/extHostTypeConverters.ts | 44 +- .../api/node/extHost.node.services.ts | 3 - src/vs/workbench/api/node/extHostHooksNode.ts | 196 -------- .../api/test/node/extHostHooks.test.ts | 137 ------ .../api/worker/extHost.worker.services.ts | 3 - .../api/worker/extHostHooksWorker.ts | 50 -- .../contrib/chat/browser/chat.contribution.ts | 34 -- .../common/chatService/chatServiceImpl.ts | 6 - .../chat/common/hooks/hooksCommandTypes.ts | 67 --- .../common/hooks/hooksExecutionService.ts | 465 ------------------ .../contrib/chat/common/hooks/hooksTypes.ts | 56 --- .../chatEditing/chatEditingService.test.ts | 4 - .../tools/languageModelToolsService.test.ts | 37 +- .../test/common/hooksExecutionService.test.ts | 402 --------------- src/vscode-dts/vscode.proposed.chatHooks.d.ts | 46 +- 21 files changed, 67 insertions(+), 1572 deletions(-) delete mode 100644 src/vs/workbench/api/browser/mainThreadHooks.ts delete mode 100644 src/vs/workbench/api/common/extHostHooks.ts delete mode 100644 src/vs/workbench/api/node/extHostHooksNode.ts delete mode 100644 src/vs/workbench/api/test/node/extHostHooks.test.ts delete mode 100644 src/vs/workbench/api/worker/extHostHooksWorker.ts delete mode 100644 src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts delete mode 100644 src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts delete mode 100644 src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts delete mode 100644 src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index fa429fbf424..abc94186d7f 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', - version: 3 + version: 5 }, chatOutputRenderer: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 20eab0ab271..ac03a724943 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -98,7 +98,6 @@ import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; import './mainThreadMeteredConnection.js'; -import './mainThreadHooks.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadHooks.ts b/src/vs/workbench/api/browser/mainThreadHooks.ts deleted file mode 100644 index d76cbc2a46c..00000000000 --- a/src/vs/workbench/api/browser/mainThreadHooks.ts +++ /dev/null @@ -1,43 +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 { URI, UriComponents } from '../../../base/common/uri.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHooksExecutionProxy, IHooksExecutionService } from '../../contrib/chat/common/hooks/hooksExecutionService.js'; -import { HookTypeValue, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; - -@extHostNamedCustomer(MainContext.MainThreadHooks) -export class MainThreadHooks extends Disposable implements MainThreadHooksShape { - - constructor( - extHostContext: IExtHostContext, - @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, - ) { - super(); - const extHostProxy = extHostContext.getProxy(ExtHostContext.ExtHostHooks); - - const proxy: IHooksExecutionProxy = { - runHookCommand: async (hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise => { - const result = await extHostProxy.$runHookCommand(hookCommand, input, token); - return { - kind: result.kind as HookCommandResultKind, - result: result.result - }; - } - }; - - this._hooksExecutionService.setProxy(proxy); - } - - async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise { - const uri = URI.revive(sessionResource); - return this._hooksExecutionService.executeHook(hookType as HookTypeValue, uri, { input, token }); - } -} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ef3f6a731dd..d41aab9b5d6 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -65,7 +65,6 @@ import { IExtHostConsumerFileSystem } from './extHostFileSystemConsumer.js'; import { ExtHostFileSystemEventService, FileSystemWatcherCreateOptions } from './extHostFileSystemEventService.js'; import { IExtHostFileSystemInfo } from './extHostFileSystemInfo.js'; import { IExtHostInitDataService } from './extHostInitDataService.js'; -import { IExtHostHooks } from './extHostHooks.js'; import { ExtHostInteractive } from './extHostInteractive.js'; import { ExtHostLabelService } from './extHostLabelService.js'; import { ExtHostLanguageFeatures } from './extHostLanguageFeatures.js'; @@ -245,7 +244,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); - rpcProtocol.set(ExtHostContext.ExtHostHooks, accessor.get(IExtHostHooks)); // Check that no named customers are missing const expected = Object.values>(ExtHostContext); @@ -257,7 +255,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService); const extHostDialogs = new ExtHostDialogs(rpcProtocol); const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); - const extHostHooks = accessor.get(IExtHostHooks); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -1661,10 +1658,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, - async executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Promise { - checkProposedApiEnabled(extension, 'chatHooks'); - return extHostHooks.executeHook(hookType, options, token); - }, }; // namespace: lm diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index fff9a8d8845..c1a9e98276b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -99,9 +99,6 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; export type IconPathDto = | UriComponents @@ -3234,12 +3231,6 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } -export type IHookCommandDto = Dto; - -export interface ExtHostHooksShape { - $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostMcpShape { $substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise; $resolveMcpLaunch(collectionId: string, label: string): Promise; @@ -3295,10 +3286,6 @@ export interface MainThreadMcpShape { export interface MainThreadDataChannelsShape extends IDisposable { } -export interface MainThreadHooksShape extends IDisposable { - $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostDataChannelsShape { $onDidReceiveData(channelId: string, data: unknown): void; } @@ -3535,7 +3522,6 @@ export const MainContext = { MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), - MainThreadHooks: createProxyIdentifier('MainThreadHooks'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), @@ -3615,7 +3601,6 @@ export const ExtHostContext = { ExtHostMeteredConnection: createProxyIdentifier('ExtHostMeteredConnection'), ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), ExtHostMcp: createProxyIdentifier('ExtHostMcp'), - ExtHostHooks: createProxyIdentifier('ExtHostHooks'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), }; diff --git a/src/vs/workbench/api/common/extHostHooks.ts b/src/vs/workbench/api/common/extHostHooks.ts deleted file mode 100644 index d03d803c47c..00000000000 --- a/src/vs/workbench/api/common/extHostHooks.ts +++ /dev/null @@ -1,21 +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 type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { ExtHostHooksShape } from './extHost.protocol.js'; - -export const IExtHostHooks = createDecorator('IExtHostHooks'); - -export interface IChatHookExecutionOptions { - readonly input?: unknown; - readonly toolInvocationToken: unknown; -} - -export interface IExtHostHooks extends ExtHostHooksShape { - executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; -} diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 885302dcb9b..3bddf605a30 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -16,6 +16,7 @@ import { parse, revive } from '../../../base/common/marshalling.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { Mimes } from '../../../base/common/mime.js'; import { cloneAndChange } from '../../../base/common/objects.js'; +import { OS } from '../../../base/common/platform.js'; import { IPrefixTreeNode, WellDefinedPrefixTree } from '../../../base/common/prefixTree.js'; import { basename } from '../../../base/common/resources.js'; import { ThemeIcon } from '../../../base/common/themables.js'; @@ -45,7 +46,7 @@ import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCom import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; +import { IChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; @@ -3437,6 +3438,7 @@ export namespace ChatAgentRequest { subAgentName: request.subAgentName, parentRequestId: request.parentRequestId, hasHooksEnabled: request.hasHooksEnabled ?? false, + hooks: request.hooks ? ChatRequestHooksConverter.to(request.hooks) : undefined, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { @@ -3464,6 +3466,8 @@ export namespace ChatAgentRequest { delete (requestWithAllProps as any).parentRequestId; // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).hasHooksEnabled; + // eslint-disable-next-line local/code-no-any-casts + delete (requestWithAllProps as any).hooks; } if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { @@ -4082,13 +4086,39 @@ export namespace SourceControlInputBoxValidationType { } } -export namespace ChatHookResult { - export function to(result: IHookResult): vscode.ChatHookResult { +export namespace ChatRequestHooksConverter { + export function to(hooks: IChatRequestHooks): vscode.ChatRequestHooks { + const result: Record = {}; + for (const [hookType, commands] of Object.entries(hooks)) { + if (!commands || commands.length === 0) { + continue; + } + const converted: vscode.ChatHookCommand[] = []; + for (const cmd of commands) { + const resolved = ChatHookCommand.to(cmd); + if (resolved) { + converted.push(resolved); + } + } + if (converted.length > 0) { + result[hookType] = converted; + } + } + return result; + } +} + +export namespace ChatHookCommand { + export function to(hook: IHookCommand): vscode.ChatHookCommand | undefined { + const command = resolveEffectiveCommand(hook, OS); + if (!command) { + return undefined; + } return { - resultKind: result.resultKind, - stopReason: result.stopReason, - warningMessage: result.warningMessage, - output: result.output, + command, + cwd: hook.cwd, + env: hook.env, + timeoutSec: hook.timeoutSec, }; } } diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index 5f52766f40a..55acd8bd9c1 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -31,8 +31,6 @@ import { IExtHostMpcService } from '../common/extHostMcp.js'; import { NodeExtHostMpcService } from './extHostMcpNode.js'; import { IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { NodeExtHostAuthentication } from './extHostAuthentication.js'; -import { IExtHostHooks } from '../common/extHostHooks.js'; -import { NodeExtHostHooks } from './extHostHooksNode.js'; // ######################################################################### // ### ### @@ -55,4 +53,3 @@ registerSingleton(IExtHostTerminalService, ExtHostTerminalService, Instantiation registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager); registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager); -registerSingleton(IExtHostHooks, NodeExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/node/extHostHooksNode.ts b/src/vs/workbench/api/node/extHostHooksNode.ts deleted file mode 100644 index 1b00ae2a271..00000000000 --- a/src/vs/workbench/api/node/extHostHooksNode.ts +++ /dev/null @@ -1,196 +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 type * as vscode from 'vscode'; -import { spawn } from 'child_process'; -import { homedir } from 'os'; -import * as nls from '../../../nls.js'; -import { disposableTimeout } from '../../../base/common/async.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { OS } from '../../../base/common/platform.js'; -import { URI, isUriComponents } from '../../../base/common/uri.js'; -import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue, getEffectiveCommandSource, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; -import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import * as typeConverters from '../common/extHostTypeConverters.js'; - -const SIGKILL_DELAY_MS = 5000; - -export class NodeExtHostHooks implements IExtHostHooks { - - private readonly _mainThreadProxy: MainThreadHooksShape; - - constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService, - @ILogService private readonly _logService: ILogService - ) { - this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); - } - - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { - this._logService.error('[NodeExtHostHooks] Invalid or missing tool invocation token'); - return []; - } - - const context = options.toolInvocationToken as IToolInvocationContext; - - const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); - return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult)); - } - - async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise { - this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`); - - try { - return await this._executeCommand(hookCommand, input, token); - } catch (err) { - return { - kind: HookCommandResultKind.Error, - result: err instanceof Error ? err.message : String(err) - }; - } - } - - private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise { - const home = homedir(); - const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined; - const cwd = cwdUri ? cwdUri.fsPath : home; - - // Resolve the effective command for the current platform - // This applies windows/linux/osx overrides and falls back to command - const effectiveCommand = resolveEffectiveCommand(hook as Parameters[0], OS); - if (!effectiveCommand) { - return Promise.resolve({ - kind: HookCommandResultKind.NonBlockingError, - result: nls.localize('noCommandForPlatform', "No command specified for the current platform") - }); - } - - // Execute the command, preserving legacy behavior for explicit shell types: - // - powershell source: run through PowerShell so PowerShell-specific commands work - // - bash source: run through bash so bash-specific commands work - // - otherwise: use default shell via spawn with shell: true - const commandSource = getEffectiveCommandSource(hook as Parameters[0], OS); - let shellExecutable: string | undefined; - let shellArgs: string[] | undefined; - - if (commandSource === 'powershell') { - shellExecutable = 'powershell.exe'; - shellArgs = ['-Command', effectiveCommand]; - } else if (commandSource === 'bash') { - shellExecutable = 'bash'; - shellArgs = ['-c', effectiveCommand]; - } - - const child = shellExecutable && shellArgs - ? spawn(shellExecutable, shellArgs, { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - }) - : spawn(effectiveCommand, [], { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - shell: true, - }); - - return new Promise((resolve, reject) => { - const stdout: string[] = []; - const stderr: string[] = []; - let exitCode: number | null = null; - let exited = false; - - const disposables = new DisposableStore(); - const sigkillTimeout = disposables.add(new MutableDisposable()); - - const killWithEscalation = () => { - if (exited) { - return; - } - child.kill('SIGTERM'); - sigkillTimeout.value = disposableTimeout(() => { - if (!exited) { - child.kill('SIGKILL'); - } - }, SIGKILL_DELAY_MS); - }; - - const cleanup = () => { - exited = true; - disposables.dispose(); - }; - - // Collect output - child.stdout.on('data', data => stdout.push(data.toString())); - child.stderr.on('data', data => stderr.push(data.toString())); - - // Set up timeout (default 30 seconds) - disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000)); - - // Set up cancellation - if (token) { - disposables.add(token.onCancellationRequested(killWithEscalation)); - } - - // Write input to stdin - if (input !== undefined && input !== null) { - try { - // Use a replacer to convert URI values to filesystem paths. - // URIs arrive as UriComponents objects via the RPC boundary. - child.stdin.write(JSON.stringify(input, (_key, value) => { - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - })); - } catch { - // Ignore stdin write errors - } - } - child.stdin.end(); - - // Capture exit code - child.on('exit', code => { exitCode = code; }); - - // Resolve on close (after streams flush) - child.on('close', () => { - cleanup(); - const code = exitCode ?? 1; - const stdoutStr = stdout.join(''); - const stderrStr = stderr.join(''); - - if (code === 0) { - // Success - try to parse stdout as JSON, otherwise return as string - let result: string | object = stdoutStr; - try { - result = JSON.parse(stdoutStr); - } catch { - // Keep as string if not valid JSON - } - resolve({ kind: HookCommandResultKind.Success, result }); - } else if (code === 2) { - // Blocking error - show stderr to model and stop processing - resolve({ kind: HookCommandResultKind.Error, result: stderrStr }); - } else { - // Non-blocking error - show stderr to user only - resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr }); - } - }); - - child.on('error', err => { - cleanup(); - reject(err); - }); - }); - } -} diff --git a/src/vs/workbench/api/test/node/extHostHooks.test.ts b/src/vs/workbench/api/test/node/extHostHooks.test.ts deleted file mode 100644 index f398cffd3f5..00000000000 --- a/src/vs/workbench/api/test/node/extHostHooks.test.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 assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../platform/log/common/log.js'; -import { NodeExtHostHooks } from '../../node/extHostHooksNode.js'; -import { IHookCommandDto, MainThreadHooksShape } from '../../common/extHost.protocol.js'; -import { HookCommandResultKind } from '../../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../../contrib/chat/common/hooks/hooksTypes.js'; -import { IExtHostRpcService } from '../../common/extHostRpcService.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; - -function createHookCommandDto(command: string, options?: Partial>): IHookCommandDto { - return { - type: 'command', - command, - ...options, - }; -} - -function createMockExtHostRpcService(mainThreadProxy: MainThreadHooksShape): IExtHostRpcService { - return { - _serviceBrand: undefined, - getProxy(): T { - return mainThreadProxy as unknown as T; - }, - set(_identifier: unknown, instance: R): R { - return instance; - }, - dispose(): void { }, - assertRegistered(): void { }, - drain(): Promise { return Promise.resolve(); }, - } as IExtHostRpcService; -} - -suite.skip('ExtHostHooks', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - let hooksService: NodeExtHostHooks; - - setup(() => { - const mockMainThreadProxy: MainThreadHooksShape = { - $executeHook: async (): Promise => { - return []; - }, - dispose: () => { } - }; - - const mockRpcService = createMockExtHostRpcService(mockMainThreadProxy); - hooksService = new NodeExtHostHooks(mockRpcService, new NullLogService()); - }); - - test('$runHookCommand runs command and returns success result', async () => { - const hookCommand = createHookCommandDto('echo "hello world"'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.strictEqual((result.result as string).trim(), 'hello world'); - }); - - test('$runHookCommand parses JSON output', async () => { - const hookCommand = createHookCommandDto('echo \'{"key": "value"}\''); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.deepStrictEqual(result.result, { key: 'value' }); - }); - - test('$runHookCommand returns non-blocking error for exit code 1', async () => { - const hookCommand = createHookCommandDto('exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - }); - - test('$runHookCommand returns blocking error for exit code 2', async () => { - const hookCommand = createHookCommandDto('exit 2'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - }); - - test('$runHookCommand captures stderr on non-blocking error', async () => { - const hookCommand = createHookCommandDto('echo "error message" >&2 && exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - assert.strictEqual((result.result as string).trim(), 'error message'); - }); - - test('$runHookCommand captures stderr on blocking error', async () => { - const hookCommand = createHookCommandDto('echo "blocking error" >&2 && exit 2'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - assert.strictEqual((result.result as string).trim(), 'blocking error'); - }); - - test('$runHookCommand passes input to stdin as JSON', async () => { - const hookCommand = createHookCommandDto('cat'); - const input = { tool: 'bash', args: { command: 'ls' } }; - const result = await hooksService.$runHookCommand(hookCommand, input, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.deepStrictEqual(result.result, input); - }); - - test('$runHookCommand returns non-blocking error for invalid command', async () => { - const hookCommand = createHookCommandDto('/nonexistent/command/that/does/not/exist'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - // Invalid commands typically return non-zero exit codes (127 for command not found) - // which are treated as non-blocking errors unless it's exit code 2 - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - }); - - test('$runHookCommand uses custom environment variables', async () => { - const hookCommand = createHookCommandDto('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } }); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.strictEqual((result.result as string).trim(), 'custom_value'); - }); - - test('$runHookCommand uses custom cwd', async () => { - const hookCommand = createHookCommandDto('pwd', { cwd: URI.file('/tmp') }); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - // The result should contain /tmp or /private/tmp (macOS symlink) - assert.ok((result.result as string).includes('tmp')); - }); -}); diff --git a/src/vs/workbench/api/worker/extHost.worker.services.ts b/src/vs/workbench/api/worker/extHost.worker.services.ts index 14ec71b80e9..d6055bcf0f6 100644 --- a/src/vs/workbench/api/worker/extHost.worker.services.ts +++ b/src/vs/workbench/api/worker/extHost.worker.services.ts @@ -8,12 +8,10 @@ import { InstantiationType, registerSingleton } from '../../../platform/instanti import { ILogService } from '../../../platform/log/common/log.js'; import { ExtHostAuthentication, IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { IExtHostExtensionService } from '../common/extHostExtensionService.js'; -import { IExtHostHooks } from '../common/extHostHooks.js'; import { ExtHostLogService } from '../common/extHostLogService.js'; import { ExtensionStoragePaths, IExtensionStoragePaths } from '../common/extHostStoragePaths.js'; import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js'; import { ExtHostExtensionService } from './extHostExtensionService.js'; -import { WorkerExtHostHooks } from './extHostHooksWorker.js'; // ######################################################################### // ### ### @@ -26,4 +24,3 @@ registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationTy registerSingleton(IExtHostExtensionService, ExtHostExtensionService, InstantiationType.Eager); registerSingleton(IExtensionStoragePaths, ExtensionStoragePaths, InstantiationType.Eager); registerSingleton(IExtHostTelemetry, new SyncDescriptor(ExtHostTelemetry, [true], true)); -registerSingleton(IExtHostHooks, WorkerExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/worker/extHostHooksWorker.ts b/src/vs/workbench/api/worker/extHostHooksWorker.ts deleted file mode 100644 index 3bd7fcf6edf..00000000000 --- a/src/vs/workbench/api/worker/extHostHooksWorker.ts +++ /dev/null @@ -1,50 +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 type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; -import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import * as typeConverters from '../common/extHostTypeConverters.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; - -export class WorkerExtHostHooks implements IExtHostHooks { - - private readonly _mainThreadProxy: MainThreadHooksShape; - - constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService, - @ILogService private readonly _logService: ILogService - ) { - this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); - } - - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { - this._logService.error('[WorkerExtHostHooks] Invalid or missing tool invocation token'); - return []; - } - - const context = options.toolInvocationToken as IToolInvocationContext; - - const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); - return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult)); - } - - async $runHookCommand(_hookCommand: IHookCommandDto, _input: unknown, _token: CancellationToken): Promise { - this._logService.debug('[WorkerExtHostHooks] Hook commands are not supported in web worker context'); - - // Web worker cannot run shell commands - return an error - return { - kind: HookCommandResultKind.Error, - result: 'Hook commands are not supported in web worker context' - }; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 10e81458395..f5d3a047e54 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -52,7 +52,6 @@ import { ILanguageModelsService, LanguageModelsService } from '../common/languag import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; -import { HooksExecutionService, IHooksExecutionService } from '../common/hooks/hooksExecutionService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; @@ -1191,37 +1190,6 @@ class ChatResolverContribution extends Disposable { } } -class ChatHooksProgressContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.chatHooksProgress'; - - constructor( - @IChatService private readonly chatService: IChatService, - @IHooksExecutionService hooksExecutionService: IHooksExecutionService, - ) { - super(); - - this._register(hooksExecutionService.onDidHookProgress(event => { - const model = this.chatService.getSession(event.sessionResource); - if (!model) { - return; - } - - const request = model.getRequests().at(-1); - if (!request) { - return; - } - - this.chatService.appendProgress(request, { - kind: 'hook', - hookType: event.hookType, - stopReason: event.stopReason, - systemMessage: event.systemMessage, - }); - })); - } -} - class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatAgentSetting'; @@ -1528,7 +1496,6 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(ChatHooksProgressContribution.ID, ChatHooksProgressContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually); @@ -1609,7 +1576,6 @@ registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delay registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed); registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed); registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); -registerSingleton(IHooksExecutionService, HooksExecutionService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 838008c642b..b55b824cd7d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -51,7 +51,6 @@ import { ILanguageModelToolsService } from '../tools/languageModelToolsService.j import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; -import { IHooksExecutionService } from '../hooks/hooksExecutionService.js'; const serializedChatKey = 'interactive.sessions'; @@ -156,7 +155,6 @@ export class ChatService extends Disposable implements IChatService { @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, - @IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService, ) { super(); @@ -911,10 +909,6 @@ export class ChatService extends Disposable implements IChatService { this.logService.warn('[ChatService] Failed to collect hooks:', error); } - if (collectedHooks) { - store.add(this.hooksExecutionService.registerHooks(model.sessionResource, collectedHooks)); - } - const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts deleted file mode 100644 index 1b31c0fafd5..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * External hook types - types that cross the process boundary to/from spawned hook commands. - * - * "External" means these types define the contract between VS Code and the external hook - * command process. - * - * Internal types (in hooksTypes.ts) are used within VS Code. - */ - -import { URI } from '../../../../../base/common/uri.js'; - -//#region Common Hook Types - -/** - * Common properties added to all hook command inputs. - */ -export interface IHookCommandInput { - readonly timestamp: string; - readonly cwd: URI; - readonly sessionId: string; - readonly hookEventName: string; - readonly transcript_path?: URI; -} - -/** - * Common output fields that can be present in any hook command result. - * These fields control execution flow and user feedback. - */ -export interface IHookCommandOutput { - /** - * If set, stops processing entirely after this hook. - * The message is shown to the user but not to the agent. - */ - readonly stopReason?: string; - /** - * Message shown to the user. - */ - readonly systemMessage?: string; -} - -export const enum HookCommandResultKind { - Success = 1, - /** Blocking error - shown to model */ - Error = 2, - /** Non-blocking error - shown to user only */ - NonBlockingError = 3 -} - -/** - * Raw result from spawning a hook command. - * This is the low-level result before semantic processing. - */ -export interface IHookCommandResult { - readonly kind: HookCommandResultKind; - /** - * For success, this is stdout (parsed as JSON if valid, otherwise string). - * For errors, this is stderr. - */ - readonly result: string | object; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts deleted file mode 100644 index 5e6f02e05b4..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ /dev/null @@ -1,465 +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 { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { StopWatch } from '../../../../../base/common/stopwatch.js'; -import { URI, isUriComponents } from '../../../../../base/common/uri.js'; -import { localize } from '../../../../../nls.js'; -import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; -import { HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; -import { - HookCommandResultKind, - IHookCommandInput, - IHookCommandResult, -} from './hooksCommandTypes.js'; -import { - commonHookOutputValidator, - IHookResult, -} from './hooksTypes.js'; - -export const hooksOutputChannelId = 'hooksExecution'; -const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks"); - -export interface IHooksExecutionOptions { - readonly input?: unknown; - readonly token?: CancellationToken; -} - -export interface IHookExecutedEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly input: unknown; - readonly results: readonly IHookResult[]; - readonly durationMs: number; -} - -/** - * Event fired when a hook produces progress that should be shown to the user. - */ -export interface IHookProgressEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly stopReason?: string; - readonly systemMessage?: string; -} - -/** - * Callback interface for hook execution proxies. - * MainThreadHooks implements this to forward calls to the extension host. - */ -export interface IHooksExecutionProxy { - runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise; -} - -export const IHooksExecutionService = createDecorator('hooksExecutionService'); - -export interface IHooksExecutionService { - _serviceBrand: undefined; - - /** - * Fires when a hook has finished executing. - */ - readonly onDidExecuteHook: Event; - - /** - * Fires when a hook produces progress (warning or stop) that should be shown to the user. - */ - readonly onDidHookProgress: Event; - - /** - * Called by mainThreadHooks when extension host is ready - */ - setProxy(proxy: IHooksExecutionProxy): void; - - /** - * Register hooks for a session. Returns a disposable that unregisters them. - */ - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable; - - /** - * Get hooks registered for a session. - */ - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined; - - /** - * Execute hooks of the given type for the given session - */ - executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; -} - -/** - * Keys that should be redacted when logging hook input. - */ -const redactedInputKeys = ['toolArgs']; - -export class HooksExecutionService extends Disposable implements IHooksExecutionService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidExecuteHook = this._register(new Emitter()); - readonly onDidExecuteHook: Event = this._onDidExecuteHook.event; - - private readonly _onDidHookProgress = this._register(new Emitter()); - readonly onDidHookProgress: Event = this._onDidHookProgress.event; - - private _proxy: IHooksExecutionProxy | undefined; - private readonly _sessionHooks = new Map(); - /** Stored transcript path per session (keyed by session URI string). */ - private readonly _sessionTranscriptPaths = new Map(); - private _channelRegistered = false; - private _requestCounter = 0; - - constructor( - @ILogService private readonly _logService: ILogService, - @IOutputService private readonly _outputService: IOutputService, - ) { - super(); - } - - setProxy(proxy: IHooksExecutionProxy): void { - this._proxy = proxy; - } - - private _ensureOutputChannel(): void { - if (this._channelRegistered) { - return; - } - Registry.as(Extensions.OutputChannels).registerChannel({ - id: hooksOutputChannelId, - label: hooksOutputChannelLabel, - log: false - }); - this._channelRegistered = true; - } - - private _log(requestId: number, hookType: HookTypeValue, message: string): void { - this._ensureOutputChannel(); - const channel = this._outputService.getChannel(hooksOutputChannelId); - if (channel) { - channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`); - } - } - - private _redactForLogging(input: object): object { - const result: Record = { ...input }; - for (const key of redactedInputKeys) { - if (Object.hasOwn(result, key)) { - result[key] = '...'; - } - } - return result; - } - - /** - * JSON.stringify replacer that converts URI / UriComponents values to their string form. - */ - private readonly _uriReplacer = (_key: string, value: unknown): unknown => { - if (URI.isUri(value)) { - return value.fsPath; - } - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - }; - - private async _runSingleHook( - requestId: number, - hookType: HookTypeValue, - hookCommand: IHookCommand, - sessionResource: URI, - callerInput: unknown, - transcriptPath: URI | undefined, - token: CancellationToken - ): Promise { - // Build the common hook input properties. - // URI values are kept as URI objects through the RPC boundary, and converted - // to filesystem paths on the extension host side during JSON serialization. - const commonInput: IHookCommandInput = { - timestamp: new Date().toISOString(), - cwd: hookCommand.cwd ?? URI.file(''), - sessionId: sessionResource.toString(), - hookEventName: hookType, - ...(transcriptPath ? { transcript_path: transcriptPath } : undefined), - }; - - // Merge common properties with caller-specific input - const fullInput = !!callerInput && typeof callerInput === 'object' - ? { ...commonInput, ...callerInput } - : commonInput; - - const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer); - this._log(requestId, hookType, `Running: ${hookCommandJson}`); - const inputForLog = this._redactForLogging(fullInput); - this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`); - - const sw = StopWatch.create(); - try { - const commandResult = await this._proxy!.runHookCommand(hookCommand, fullInput, token); - const result = this._toInternalResult(commandResult); - this._logCommandResult(requestId, hookType, commandResult, Math.round(sw.elapsed())); - return result; - } catch (err) { - const errMessage = err instanceof Error ? err.message : String(err); - this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`); - // Proxy errors (e.g., process spawn failure) are treated as warnings - return { - resultKind: 'warning', - output: undefined, - warningMessage: errMessage, - }; - } - } - - private _toInternalResult(commandResult: IHookCommandResult): IHookResult { - switch (commandResult.kind) { - case HookCommandResultKind.Error: { - // Exit code 2 - stop processing with message shown to user (not model) - // Equivalent to continue=false with stopReason=stderr - const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); - return { - resultKind: 'error', - stopReason: message, - output: undefined, - }; - } - case HookCommandResultKind.NonBlockingError: { - // Non-blocking error - shown to user only as warning - const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); - return { - resultKind: 'warning', - output: undefined, - warningMessage: errorMessage, - }; - } - case HookCommandResultKind.Success: { - // For string results, no common fields to extract - if (typeof commandResult.result !== 'object') { - return { - resultKind: 'success', - output: commandResult.result, - }; - } - - // Extract and validate common fields - const validationResult = commonHookOutputValidator.validate(commandResult.result); - const commonFields = validationResult.error ? {} : validationResult.content; - - // Extract only known hook-specific fields for output - const resultObj = commandResult.result as Record; - const hookOutput = this._extractHookSpecificOutput(resultObj); - - // Handle continue field: when false, stopReason is effective - // stopReason takes precedence if both are set - let stopReason = commonFields.stopReason; - if (commonFields.continue === false && !stopReason) { - stopReason = ''; // Empty string signals stop without a specific reason - } - - return { - resultKind: 'success', - stopReason, - warningMessage: commonFields.systemMessage, - output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined, - }; - } - default: { - // Unexpected result kind - treat as warning - return { - resultKind: 'warning', - warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`, - output: undefined, - }; - } - } - } - - /** - * Extract hook-specific output fields, excluding common fields. - */ - private _extractHookSpecificOutput(result: Record): Record { - const commonFields = new Set(['continue', 'stopReason', 'systemMessage']); - const output: Record = {}; - for (const [key, value] of Object.entries(result)) { - if (value !== undefined && !commonFields.has(key)) { - output[key] = value; - } - } - - return output; - } - - private _logCommandResult(requestId: number, hookType: HookTypeValue, result: IHookCommandResult, elapsed: number): void { - const resultKindStr = result.kind === HookCommandResultKind.Success ? 'Success' - : result.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError' - : 'Error'; - const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); - const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]'; - if (hasOutput) { - this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`); - this._log(requestId, hookType, `Output: ${resultStr}`); - } else { - this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`); - } - } - - /** - * Extract `transcript_path` from hook input if present. - * The caller (e.g. SessionStart) may include it as a URI in the input object. - */ - private _extractTranscriptPath(input: unknown): URI | undefined { - if (typeof input !== 'object' || input === null) { - return undefined; - } - const transcriptPath = (input as Record)['transcriptPath']; - if (URI.isUri(transcriptPath)) { - return transcriptPath; - } - if (isUriComponents(transcriptPath)) { - return URI.revive(transcriptPath); - } - return undefined; - } - - /** - * Emit a hook progress event to show warnings or stop reasons to the user. - */ - private _emitHookProgress(hookType: HookTypeValue, sessionResource: URI, stopReason?: string, systemMessage?: string): void { - this._onDidHookProgress.fire({ - hookType, - sessionResource, - stopReason, - systemMessage, - }); - } - - /** - * Collect all warning messages from hook results and emit them as a single aggregated progress event. - * Uses numbered list formatting when there are multiple warnings. - */ - private _emitAggregatedWarnings(hookType: HookTypeValue, sessionResource: URI, results: readonly IHookResult[]): void { - const warnings = results - .filter(r => r.warningMessage !== undefined) - .map(r => r.warningMessage!); - - if (warnings.length > 0) { - const message = warnings.length === 1 - ? warnings[0] - : warnings.map((w, i) => `${i + 1}. ${w}`).join('\n'); - this._emitHookProgress(hookType, sessionResource, undefined, message); - } - } - - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable { - const key = sessionResource.toString(); - this._sessionHooks.set(key, hooks); - return toDisposable(() => { - this._sessionHooks.delete(key); - this._sessionTranscriptPaths.delete(key); - }); - } - - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined { - return this._sessionHooks.get(sessionResource.toString()); - } - - async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise { - const sw = StopWatch.create(); - const results: IHookResult[] = []; - - try { - if (!this._proxy) { - return results; - } - - const sessionKey = sessionResource.toString(); - - // Extract and store transcript_path from input when present (e.g. SessionStart) - const inputTranscriptPath = this._extractTranscriptPath(options?.input); - if (inputTranscriptPath) { - this._sessionTranscriptPaths.set(sessionKey, inputTranscriptPath); - } - - const hooks = this.getHooksForSession(sessionResource); - if (!hooks) { - return results; - } - - const hookCommands = hooks[hookType]; - if (!hookCommands || hookCommands.length === 0) { - return results; - } - - const transcriptPath = this._sessionTranscriptPaths.get(sessionKey); - - const requestId = this._requestCounter++; - const token = options?.token ?? CancellationToken.None; - - this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`); - this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`); - - for (const hookCommand of hookCommands) { - const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, transcriptPath, token); - results.push(result); - - // If stopReason is set, stop processing remaining hooks - if (result.stopReason) { - this._log(requestId, hookType, `Stopping: ${result.stopReason}`); - break; - } - } - - // Emit aggregated warnings for any hook results that had warning messages - this._emitAggregatedWarnings(hookType, sessionResource, results); - - // If any hook set stopReason, emit progress so it's visible to the user - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason) { - this._emitHookProgress(hookType, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - } - - return results; - } finally { - this._onDidExecuteHook.fire({ - hookType, - sessionResource, - input: options?.input, - results, - durationMs: Math.round(sw.elapsed()), - }); - } - } - -} - -/** - * Error thrown when a hook requests the agent to abort processing. - * The message should be shown to the user. - */ -export class HookAbortError extends Error { - constructor( - public readonly hookType: string, - public readonly stopReason: string - ) { - super(`Hook ${hookType} aborted: ${stopReason}`); - this.name = 'HookAbortError'; - } -} - -/** - * Formats a localized error message for a failed hook. - * @param errorMessage The error message from the hook - * @returns A localized error message string - */ -export function formatHookErrorMessage(errorMessage: string): string { - if (errorMessage) { - return localize('hookFatalErrorWithMessage', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details. Error message: {0}', errorMessage); - } - return localize('hookFatalError', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details.'); -} diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts deleted file mode 100644 index 1c4bceb5314..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Internal hook types - types used within VS Code's hooks execution service. - * - * "Internal" means these types are used by VS Code code only - they never cross the - * process boundary to external hook commands. They use camelCase for field names. - * - * External types (in hooksCommandTypes.ts) define the contract with spawned commands. - */ - -import { vBoolean, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js'; - -//#region Common Hook Types - -/** - * The kind of result from executing a hook command. - */ -export type HookResultKind = 'success' | 'error' | 'warning'; - -/** - * Semantic hook result with common fields extracted and defaults applied. - * This is what callers receive from executeHook. - */ -export interface IHookResult { - /** - * The kind of result from executing the hook. - */ - readonly resultKind: HookResultKind; - /** - * If set, the agent should stop processing entirely after this hook. - * The message is shown to the user but not to the agent. - */ - readonly stopReason?: string; - /** - * Warning message shown to the user. - * (Mapped from `systemMessage` in command output, or stderr for non-blocking errors.) - */ - readonly warningMessage?: string; - /** - * The hook's output (hook-specific fields only). - * For errors, this is the error message string. - */ - readonly output: unknown; -} - -export const commonHookOutputValidator = vObj({ - continue: vOptionalProp(vBoolean()), - stopReason: vOptionalProp(vString()), - systemMessage: vOptionalProp(vString()), -}); - -//#endregion diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index ee806bff9f4..0243dcfdd0d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -47,7 +47,6 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; -import { IHooksExecutionService } from '../../../common/hooks/hooksExecutionService.js'; import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; @@ -90,9 +89,6 @@ suite('ChatEditingService', function () { collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); - collection.set(IHooksExecutionService, new class extends mock() { - override registerHooks() { return Disposable.None; } - }); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index a5b61a26879..3b53ed241ba 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -8,7 +8,6 @@ import { Barrier } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError } from '../../../../../../base/common/errors.js'; -import { Event } from '../../../../../../base/common/event.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; @@ -34,10 +33,6 @@ import { ILanguageModelToolsConfirmationService } from '../../../common/tools/la import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; -import { IHookResult } from '../../../common/hooks/hooksTypes.js'; -import { IHooksExecutionService, IHooksExecutionOptions, IHooksExecutionProxy } from '../../../common/hooks/hooksExecutionService.js'; -import { HookTypeValue, IChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; // --- Test helpers to reduce repetition and improve readability --- @@ -65,19 +60,6 @@ class TestTelemetryService implements Partial { } } -class MockHooksExecutionService implements IHooksExecutionService { - readonly _serviceBrand: undefined; - readonly onDidExecuteHook = Event.None; - readonly onDidHookProgress = Event.None; - - setProxy(_proxy: IHooksExecutionProxy): void { } - registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; } - getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { return undefined; } - executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise { - return Promise.resolve([]); - } -} - function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial) { const toolData: IToolData = { id, @@ -136,7 +118,6 @@ interface TestToolsServiceOptions { accessibilityService?: IAccessibilityService; accessibilitySignalService?: Partial; telemetryService?: Partial; - hooksExecutionService?: MockHooksExecutionService; commandService?: Partial; /** Called after configurationService is created but before the service is instantiated */ configureServices?: (config: TestConfigurationService) => void; @@ -161,7 +142,6 @@ function createTestToolsService(store: ReturnType { instaService1.stub(IAccessibilityService, testAccessibilityService1); instaService1.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService1.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService1.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService1 = store.add(instaService1.createInstance(LanguageModelToolsService)); const tool1 = registerToolForTest(testService1, store, 'soundOnlyTool', { @@ -1774,7 +1753,6 @@ suite('LanguageModelToolsService', () => { instaService2.stub(IAccessibilityService, testAccessibilityService2); instaService2.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService2.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService2.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService2 = store.add(instaService2.createInstance(LanguageModelToolsService)); const tool2 = registerToolForTest(testService2, store, 'autoScreenReaderTool', { @@ -1817,7 +1795,6 @@ suite('LanguageModelToolsService', () => { instaService3.stub(IAccessibilityService, testAccessibilityService3); instaService3.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService3.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService3.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService3 = store.add(instaService3.createInstance(LanguageModelToolsService)); const tool3 = registerToolForTest(testService3, store, 'offTool', { @@ -2587,7 +2564,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); const tool = registerToolForTest(testService, store, 'gitCommitTool', { @@ -2626,7 +2602,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2666,7 +2641,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2709,7 +2683,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -3799,15 +3772,11 @@ suite('LanguageModelToolsService', () => { }); suite('preToolUse hooks', () => { - let mockHooksService: MockHooksExecutionService; let hookService: LanguageModelToolsService; let hookChatService: MockChatService; setup(() => { - mockHooksService = new MockHooksExecutionService(); - const setup = createTestToolsService(store, { - hooksExecutionService: mockHooksService - }); + const setup = createTestToolsService(store); hookService = setup.service; hookChatService = setup.chatService; }); @@ -4045,9 +4014,7 @@ suite('LanguageModelToolsService', () => { } }; - const mockHooks = new MockHooksExecutionService(); const setup = createTestToolsService(store, { - hooksExecutionService: mockHooks, commandService: mockCommandService as ICommandService, }); @@ -4101,9 +4068,7 @@ suite('LanguageModelToolsService', () => { } }; - const mockHooks = new MockHooksExecutionService(); const setup = createTestToolsService(store, { - hooksExecutionService: mockHooks, commandService: mockCommandService as ICommandService, }); diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts deleted file mode 100644 index 1493f35a29a..00000000000 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ /dev/null @@ -1,402 +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 assert from 'assert'; -import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../common/hooks/hooksCommandTypes.js'; -import { HooksExecutionService, IHooksExecutionProxy } from '../../common/hooks/hooksExecutionService.js'; -import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; -import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js'; - -function cmd(command: string): IHookCommand { - return { type: 'command', command, cwd: URI.file('/') }; -} - -function createMockOutputService(): IOutputService { - const mockChannel: Partial = { - append: () => { }, - }; - return { - _serviceBrand: undefined, - getChannel: () => mockChannel as IOutputChannel, - } as unknown as IOutputService; -} - -suite('HooksExecutionService', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - let service: HooksExecutionService; - const sessionUri = URI.file('/test/session'); - - setup(() => { - service = store.add(new HooksExecutionService(new NullLogService(), createMockOutputService())); - }); - - suite('registerHooks', () => { - test('registers hooks for a session', () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - assert.strictEqual(service.getHooksForSession(sessionUri), hooks); - }); - - test('returns disposable that unregisters hooks', () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - const disposable = service.registerHooks(sessionUri, hooks); - - assert.strictEqual(service.getHooksForSession(sessionUri), hooks); - - disposable.dispose(); - - assert.strictEqual(service.getHooksForSession(sessionUri), undefined); - }); - - test('different sessions have independent hooks', () => { - const session1 = URI.file('/test/session1'); - const session2 = URI.file('/test/session2'); - const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] }; - const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] }; - - store.add(service.registerHooks(session1, hooks1)); - store.add(service.registerHooks(session2, hooks2)); - - assert.strictEqual(service.getHooksForSession(session1), hooks1); - assert.strictEqual(service.getHooksForSession(session2), hooks2); - }); - }); - - suite('getHooksForSession', () => { - test('returns undefined for unregistered session', () => { - assert.strictEqual(service.getHooksForSession(sessionUri), undefined); - }); - }); - - suite('executeHook', () => { - test('returns empty array when no proxy set', async () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('returns empty array when no hooks registered for session', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('returns empty array when no hooks of requested type', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PostToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('executes hook commands via proxy and returns semantic results', async () => { - const proxy = createMockProxy((cmd) => ({ - kind: HookCommandResultKind.Success, - result: `executed: ${cmd.command}` - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' }); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, undefined); - assert.strictEqual(results[0].output, 'executed: echo test'); - }); - - test('executes multiple hook commands in order', async () => { - const executedCommands: string[] = []; - const proxy = createMockProxy((cmd) => { - executedCommands.push(cmd.command ?? ''); - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { - [HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')] - }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 3); - assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']); - }); - - test('wraps proxy errors in warning result', async () => { - const proxy = createMockProxy(() => { - throw new Error('proxy failed'); - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('fail')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - // Proxy errors are now treated as warnings (non-blocking) - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].warningMessage, 'proxy failed'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes cancellation token to proxy', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const cts = store.add(new CancellationTokenSource()); - await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token }); - - assert.strictEqual(receivedToken, cts.token); - }); - - test('uses CancellationToken.None when no token provided', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(receivedToken, CancellationToken.None); - }); - - test('extracts common fields from successful result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - stopReason: 'User requested stop', - systemMessage: 'Warning: hook triggered', - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, 'User requested stop'); - assert.strictEqual(results[0].warningMessage, 'Warning: hook triggered'); - // Hook-specific fields are in output with wrapper - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles continue false by setting stopReason', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - continue: false, - systemMessage: 'User requested to stop' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - // continue:false without explicit stopReason sets stopReason to empty string - assert.strictEqual(results[0].stopReason, ''); - assert.strictEqual(results[0].warningMessage, 'User requested to stop'); - }); - - test('stopReason takes precedence over continue false', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - continue: false, - stopReason: 'Explicit stop reason' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, 'Explicit stop reason'); - }); - - test('uses defaults when no common fields present', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].stopReason, undefined); - assert.strictEqual(results[0].warningMessage, undefined); - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles error results from command (exit code 2) as stop with stopReason', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Error, - result: 'command failed with error' - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - // Exit code 2 produces error with stopReason - assert.strictEqual(results[0].resultKind, 'error'); - assert.strictEqual(results[0].stopReason, 'command failed with error'); - assert.strictEqual(results[0].output, undefined); - }); - - test('handles non-blocking error results from command', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.NonBlockingError, - result: 'non-blocking warning message' - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].warningMessage, 'non-blocking warning message'); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('handles non-blocking error with object result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.NonBlockingError, - result: { code: 'WARN_001', message: 'Something went wrong' } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].warningMessage, '{"code":"WARN_001","message":"Something went wrong"}'); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes through hook-specific output fields for non-preToolUse hooks', async () => { - // Stop hooks return different fields (decision, reason) than preToolUse hooks - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Please run the tests' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.Stop]: [cmd('check-stop')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.Stop, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'success'); - // Hook-specific fields should be in output, not undefined - assert.deepStrictEqual(results[0].output, { - decision: 'block', - reason: 'Please run the tests' - }); - }); - - test('passes input to proxy', async () => { - let receivedInput: unknown; - const proxy = createMockProxy((_cmd, input) => { - receivedInput = input; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const testInput = { foo: 'bar', nested: { value: 123 } }; - await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput }); - - // Input includes caller properties merged with common hook properties - assert.ok(typeof receivedInput === 'object' && receivedInput !== null); - const input = receivedInput as Record; - assert.strictEqual(input['foo'], 'bar'); - assert.deepStrictEqual(input['nested'], { value: 123 }); - // Common properties are also present - assert.strictEqual(typeof input['timestamp'], 'string'); - assert.strictEqual(input['hookEventName'], HookType.PreToolUse); - }); - }); - - function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy { - return { - runHookCommand: async (hookCommand, input, token) => { - if (handler) { - return handler(hookCommand, input, token); - } - return { kind: HookCommandResultKind.Success, result: 'mock result' }; - } - }; - } -}); diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index 39064b88952..1540fea982c 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 5 declare module 'vscode' { @@ -13,18 +13,33 @@ declare module 'vscode' { export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop'; /** - * Options for executing a hook command. + * A resolved hook command ready for execution. + * The command has already been resolved for the current platform. */ - export interface ChatHookExecutionOptions { + export interface ChatHookCommand { /** - * Input data to pass to the hook via stdin (will be JSON-serialized). + * The shell command to execute, already resolved for the current platform. */ - readonly input?: unknown; + readonly command: string; /** - * The tool invocation token from the chat request context, - * used to associate the hook execution with the current chat session. + * Working directory for the command. */ - readonly toolInvocationToken: ChatParticipantToolToken; + readonly cwd?: Uri; + /** + * Additional environment variables for the command. + */ + readonly env?: Record; + /** + * Maximum execution time in seconds. + */ + readonly timeoutSec?: number; + } + + /** + * Collected hooks for a chat request, organized by hook type. + */ + export interface ChatRequestHooks { + readonly [hookType: string]: readonly ChatHookCommand[]; } /** @@ -60,20 +75,15 @@ declare module 'vscode' { readonly output: unknown; } - export namespace chat { + export interface ChatRequest { /** - * Execute all hooks of the specified type for the current chat session. - * Hooks are configured in hooks .json files in the workspace. - * - * @param hookType The type of hook to execute. - * @param options Hook execution options including the input data. - * @param token Optional cancellation token. - * @returns A promise that resolves to an array of hook execution results. + * Resolved hook commands for this request, organized by hook type. + * The commands have already been resolved for the current platform. + * Only present when hooks are enabled. */ - export function executeHook(hookType: ChatHookType, options: ChatHookExecutionOptions, token?: CancellationToken): Thenable; + readonly hooks?: ChatRequestHooks; } - /** * A progress part representing the execution result of a hook. * Hooks are user-configured scripts that run at specific points during chat processing. From ea12208c7f9dc50b2114e78204b249d13102733e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 11 Feb 2026 00:08:52 +0100 Subject: [PATCH 44/65] set expiration time for data (#294240) --- .../accounts/browser/defaultAccount.ts | 116 ++++++++++-------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 868cfac8c79..bc2ada6a3d9 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -7,7 +7,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../authentication/common/authentication.js'; -import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { asJson, IRequestService, isClientError, isSuccess } from '../../../../platform/request/common/request.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -189,11 +189,12 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount interface IAccountPolicyData { readonly accountId: string; readonly policyData: IPolicyData; - readonly isTokenEntitlementsDataFetched: boolean; - readonly isMcpRegistryDataFetched: boolean; + readonly tokenEntitlementsFetchedAt?: number; + readonly mcpRegistryDataFetchedAt?: number; } interface IDefaultAccountData { + accountId: string; defaultAccount: IDefaultAccount; policyData: IAccountPolicyData | null; } @@ -261,7 +262,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const { accountId, policyData } = JSON.parse(cached); if (accountId && policyData) { this.logService.debug('[DefaultAccount] Initializing with cached policy data'); - return { accountId, policyData, isTokenEntitlementsDataFetched: false, isMcpRegistryDataFetched: false }; + return { accountId, policyData }; } } catch (error) { this.logService.error('[DefaultAccount] Failed to parse cached policy data', getErrorMessage(error)); @@ -284,7 +285,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Starting initialization'); - await this.doUpdateDefaultAccount(false); + await this.doUpdateDefaultAccount(); this.logService.debug('[DefaultAccount] Initialization complete'); this._register(this.onDidChangeDefaultAccount(account => { @@ -332,11 +333,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid })); this._register(this.hostService.onDidChangeFocus(focused => { - // Refresh default account when window gets focused and policy data is not fully fetched, to ensure we have the latest policy data. - if (focused && this._policyData && (!this._policyData.isMcpRegistryDataFetched || !this._policyData.isTokenEntitlementsDataFetched)) { - this.accountDataPollScheduler.cancel(); - this.logService.debug('[DefaultAccount] Window focused, updating default account'); - this.refresh(); + if (focused) { + this.refetchDefaultAccount(true); } })); } @@ -348,27 +346,31 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Refreshing default account'); + await this.updateDefaultAccount(); return this.defaultAccount; } - private async refetchDefaultAccount(): Promise { - if (!this.hostService.hasFocus) { + private async refetchDefaultAccount(useExistingEntitlements?: boolean): Promise { + if (this.accountDataPollScheduler.isScheduled()) { + this.accountDataPollScheduler.cancel(); + } + if (!this.hostService.hasFocus || !this._defaultAccount) { this.scheduleAccountDataPoll(); - this.logService.debug('[DefaultAccount] Skipping refetching default account because window is not focused'); + this.logService.debug('[DefaultAccount] Skipping refetching default account. Host is not focused or default account is not set'); return; } this.logService.debug('[DefaultAccount] Refetching default account'); - await this.updateDefaultAccount(true); + await this.updateDefaultAccount(useExistingEntitlements); } - private async updateDefaultAccount(donotUseLastFetchedData: boolean = false): Promise { - await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(donotUseLastFetchedData)); + private async updateDefaultAccount(useExistingEntitlements?: boolean): Promise { + await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(useExistingEntitlements)); } - private async doUpdateDefaultAccount(donotUseLastFetchedData: boolean): Promise { + private async doUpdateDefaultAccount(useExistingEntitlements: boolean = false): Promise { try { - const defaultAccount = await this.fetchDefaultAccount(donotUseLastFetchedData); + const defaultAccount = await this.fetchDefaultAccount(useExistingEntitlements); this.setDefaultAccount(defaultAccount); this.scheduleAccountDataPoll(); } catch (error) { @@ -376,7 +378,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async fetchDefaultAccount(donotUseLastFetchedData: boolean): Promise { + private async fetchDefaultAccount(useExistingEntitlements: boolean): Promise { const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); @@ -386,7 +388,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, donotUseLastFetchedData); + return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, useExistingEntitlements); } private setDefaultAccount(account: IDefaultAccountData | null): void { @@ -449,7 +451,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return result; } - private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, donotUseLastFetchedData: boolean): Promise { + private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, useExistingEntitlements: boolean): Promise { try { this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authenticationProvider.id); const sessions = await this.findMatchingProviderSession(authenticationProvider.id, this.defaultAccountConfig.authenticationProvider.scopes); @@ -459,39 +461,39 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, donotUseLastFetchedData); + return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, useExistingEntitlements); } catch (error) { this.logService.error('[DefaultAccount] Failed to get default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; } } - private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], donotUseLastFetchedData: boolean): Promise { + private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], useExistingEntitlements: boolean): Promise { try { const accountId = sessions[0].account.id; + const existingEntitlementsData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; - const [entitlementsData, tokenEntitlementsData] = await Promise.all([ - this.getEntitlements(sessions), - this.getTokenEntitlements(sessions, donotUseLastFetchedData ? undefined : accountPolicyData), + const [entitlementsData, tokenEntitlementsResult] = await Promise.all([ + useExistingEntitlements && existingEntitlementsData ? existingEntitlementsData : this.getEntitlements(sessions), + this.getTokenEntitlements(sessions, accountPolicyData), ]); - let isTokenEntitlementsDataFetched = false; - let isMcpRegistryDataFetched = false; + let tokenEntitlementsFetchedAt: number | undefined; + let mcpRegistryDataFetchedAt: number | undefined; let policyData: Mutable | undefined = accountPolicyData?.policyData ? { ...accountPolicyData.policyData } : undefined; - if (tokenEntitlementsData) { - isTokenEntitlementsDataFetched = true; + if (tokenEntitlementsResult) { + tokenEntitlementsFetchedAt = tokenEntitlementsResult.fetchedAt; + const tokenEntitlementsData = tokenEntitlementsResult.data; policyData = policyData ?? {}; policyData.chat_agent_enabled = tokenEntitlementsData.chat_agent_enabled; policyData.chat_preview_features_enabled = tokenEntitlementsData.chat_preview_features_enabled; policyData.mcp = tokenEntitlementsData.mcp; if (policyData.mcp) { - const mcpRegistryProvider = await this.getMcpRegistryProvider(sessions, donotUseLastFetchedData ? undefined : accountPolicyData); - if (!isUndefined(mcpRegistryProvider)) { - isMcpRegistryDataFetched = true; - policyData.mcpRegistryUrl = mcpRegistryProvider?.url; - policyData.mcpAccess = mcpRegistryProvider?.registry_access; - } + const mcpRegistryResult = await this.getMcpRegistryProvider(sessions, accountPolicyData); + mcpRegistryDataFetchedAt = mcpRegistryResult?.fetchedAt; + policyData.mcpRegistryUrl = mcpRegistryResult?.data?.url; + policyData.mcpAccess = mcpRegistryResult?.data?.registry_access; } else { policyData.mcpRegistryUrl = undefined; policyData.mcpAccess = undefined; @@ -505,7 +507,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid entitlementsData, }; this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authenticationProvider.id); - return { defaultAccount, policyData: policyData ? { accountId, policyData, isTokenEntitlementsDataFetched, isMcpRegistryDataFetched } : null }; + return { defaultAccount, accountId, policyData: policyData ? { accountId, policyData, tokenEntitlementsFetchedAt, mcpRegistryDataFetchedAt } : null }; } catch (error) { this.logService.error('[DefaultAccount] Failed to create default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; @@ -560,12 +562,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return expectedScopes.every(scope => scopes.includes(scope)); } - private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise | undefined> { - if (accountPolicyData?.isTokenEntitlementsDataFetched) { + private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: Partial; fetchedAt: number } | undefined> { + if (accountPolicyData?.tokenEntitlementsFetchedAt && !this.isDataStale(accountPolicyData.tokenEntitlementsFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched token entitlements data'); - return accountPolicyData.policyData; + return { data: accountPolicyData.policyData, fetchedAt: accountPolicyData.tokenEntitlementsFetchedAt }; } - return await this.requestTokenEntitlements(sessions); + const data = await this.requestTokenEntitlements(sessions); + return data ? { data, fetchedAt: Date.now() } : undefined; } private async requestTokenEntitlements(sessions: AuthenticationSession[]): Promise | undefined> { @@ -639,12 +642,14 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise { - if (accountPolicyData?.isMcpRegistryDataFetched) { + private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: IMcpRegistryProvider | null; fetchedAt: number } | undefined> { + if (accountPolicyData?.mcpRegistryDataFetchedAt && !this.isDataStale(accountPolicyData.mcpRegistryDataFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched MCP registry data'); - return accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + const data = accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + return { data, fetchedAt: accountPolicyData.mcpRegistryDataFetchedAt }; } - return await this.requestMcpRegistryProvider(sessions); + const data = await this.requestMcpRegistryProvider(sessions); + return !isUndefined(data) ? { data, fetchedAt: Date.now() } : undefined; } private async requestMcpRegistryProvider(sessions: AuthenticationSession[]): Promise { @@ -660,11 +665,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - if (response.res.statusCode && response.res.statusCode !== 200) { - this.logService.trace(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); - return response.res.statusCode === 404 /* mcp not configured */ - ? null - : undefined; + if (!isSuccess(response)) { + if (isClientError(response)) { + this.logService.debug(`[DefaultAccount] Received ${response.res.statusCode} for MCP registry data, treating as no registry available.`); + return null; + } + this.logService.debug(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); + return undefined; } try { @@ -703,7 +710,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid }, token); const status = response.res.statusCode; - if (status && status !== 200) { + if (status === 401 || status === 404) { + this.logService.debug(`[DefaultAccount] Received ${status} for URL ${url} with session ${session.id}, likely due to expired/revoked token or insufficient permissions.`, 'Trying next session if available.'); lastResponse = response; continue; // try next session } @@ -711,7 +719,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return response; } catch (error) { if (!token.isCancellationRequested) { - this.logService.error(`[chat entitlement] request: error ${error}`); + this.logService.error(`[DefaultAccount] request: error ${error}`, url); } } } @@ -724,6 +732,10 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return lastResponse; } + private isDataStale(fetchedAt: number): boolean { + return (Date.now() - fetchedAt) >= ACCOUNT_DATA_POLL_INTERVAL_MS; + } + private getEntitlementUrl(): string | undefined { if (this.getDefaultAccountAuthenticationProvider().enterprise) { try { From 5075f3e01463ec577ef82e5e1347e370e912c136 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 17:12:48 -0600 Subject: [PATCH 45/65] Tweak tips (#294248) * Tweak tips * add steering --- .../workbench/contrib/chat/browser/chatTipService.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 5d673cca996..5c55c8159ad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -164,13 +164,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.messageQueueing', - message: localize('tip.messageQueueing', "Tip: You can send follow-up messages while the agent is working. They'll be queued and processed in order."), + message: localize('tip.messageQueueing', "Tip: You can send follow-up messages and steering while the agent is working. They'll be queued and processed in order."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], }, { id: 'tip.yoloMode', - message: localize('tip.yoloMode', "Tip: Enable [auto-approve mode](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), + message: localize('tip.yoloMode', "Tip: Enable [auto approval](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), @@ -179,13 +179,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.mermaid', - message: localize('tip.mermaid', "Tip: Ask the agent to visualize architectures and flows; it can render Mermaid diagrams directly in chat."), + message: localize('tip.mermaid', "Tip: Ask the agent to draw an architectural diagram or flow chart; it can render Mermaid diagrams directly in chat."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['renderMermaidDiagram'], }, { id: 'tip.githubRepo', - message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (e.g. @owner/repo) in your prompt so the agent can query code and issues across that repo."), + message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (e.g. @owner/repo) in your prompt to let the agent search code, browse issues, and explore pull requests from that repo."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), @@ -194,13 +194,13 @@ const TIP_CATALOG: ITipDefinition[] = [ }, { id: 'tip.subagents', - message: localize('tip.subagents', "Tip: Ask the agent to implement a plan in parallel; it can delegate work across subagents for faster results."), + message: localize('tip.subagents', "Tip: For large tasks, ask the agent to work in parallel. It can split the work across subagents to finish faster."), when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['runSubagent'], }, { id: 'tip.contextUsage', - message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are being spent and what's consuming them."), + message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are being used and what's consuming them."), when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ChatContextKeys.contextUsageHasBeenOpened.negate(), From f7f99edcd0c2e8465ea274b17391136fdeea2f12 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:19:21 -0800 Subject: [PATCH 46/65] Store sessionResource as view state instead of id For #274403 Should let us support non-local sessions. Keeping around id support for backcompat --- .../browser/widgetHosts/viewPane/chatViewPane.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 6c7ae754e48..c11c625bc9c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -65,7 +65,9 @@ import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; interface IChatViewPaneState extends Partial { + /** @deprecated */ sessionId?: string; + sessionResource?: URI; sessionsSidebarWidth?: number; } @@ -128,7 +130,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { lifecycleService.startupKind !== StartupKind.ReloadedWindow && this.configurationService.getValue(ChatConfiguration.RestoreLastPanelSession) === false ) { - this.viewState.sessionId = undefined; // clear persisted session on fresh start + // clear persisted session on fresh start + this.viewState.sessionId = undefined; + this.viewState.sessionResource = undefined; } this.sessionsViewerVisible = false; // will be updated from layout code this.sessionsViewerSidebarWidth = Math.max(ChatViewPane.SESSIONS_SIDEBAR_MIN_WIDTH, this.viewState.sessionsSidebarWidth ?? ChatViewPane.SESSIONS_SIDEBAR_DEFAULT_WIDTH); @@ -267,6 +271,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return this.chatService.transferredSessionResource; } + if (this.viewState.sessionResource) { + return this.viewState.sessionResource; + } + return this.viewState.sessionId ? LocalChatSessionUri.forSession(this.viewState.sessionId) : undefined; } @@ -675,7 +683,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { if (model) { await this.updateWidgetLockState(model.sessionResource); // Update widget lock state based on session type - this.viewState.sessionId = model.sessionId; // remember as model to restore in view state + // remember as model to restore in view state + this.viewState.sessionId = model.sessionId; + this.viewState.sessionResource = model.sessionResource; } this._widget.setModel(model); From c177d564a63b364c9deb4ac580dae79d2998c48e Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Tue, 10 Feb 2026 15:38:31 -0800 Subject: [PATCH 47/65] experiment background agent display name (#294218) * experiment background agent display name * update session type picker to handle background agent display name changes --- .../chat/browser/agentSessions/agentSessions.ts | 9 ++++++++- .../contrib/chat/browser/chat.contribution.ts | 10 ++++++++++ .../chatSessions/chatSessions.contribution.ts | 3 ++- .../widget/input/sessionTargetPickerActionItem.ts | 14 ++++++++++++-- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 8270198d110..8ac2ab653fd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -7,6 +7,7 @@ import { localize } from '../../../../../nls.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { IChatSessionTiming } from '../../common/chatService/chatService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -40,12 +41,18 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes } } +/** + * Observable holding the display name for the background agent session provider. + * Updated via experiment treatment to allow A/B testing of the display name. + */ +export const backgroundAgentDisplayName = observableValue('backgroundAgentDisplayName', localize('chat.session.providerLabel.background', "Background")); + export function getAgentSessionProviderName(provider: AgentSessionProviders): string { switch (provider) { case AgentSessionProviders.Local: return localize('chat.session.providerLabel.local', "Local"); case AgentSessionProviders.Background: - return localize('chat.session.providerLabel.background', "Background"); + return backgroundAgentDisplayName.get(); case AgentSessionProviders.Cloud: return localize('chat.session.providerLabel.cloud', "Cloud"); case AgentSessionProviders.Claude: diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f5d3a047e54..db362272166 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -89,6 +89,7 @@ import { registerChatToolActions } from './actions/chatToolActions.js'; import { ChatTransferContribution } from './actions/chatTransfer.js'; import { registerChatCustomizationDiagnosticsAction } from './actions/chatCustomizationDiagnosticsAction.js'; import './agentSessions/agentSessions.contribution.js'; +import { backgroundAgentDisplayName } from './agentSessions/agentSessions.js'; import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService } from './chat.js'; import { ChatAccessibilityService } from './accessibility/chatAccessibilityService.js'; @@ -1200,6 +1201,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr ) { super(); this.registerMaxRequestsSetting(); + this.registerBackgroundAgentDisplayName(); } @@ -1230,6 +1232,14 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr }; this._register(Event.runAndSubscribe(Event.debounce(this.entitlementService.onDidChangeEntitlement, () => { }, 1000), () => registerMaxRequestsSetting())); } + + private registerBackgroundAgentDisplayName(): void { + this.experimentService.getTreatment('backgroundAgentDisplayName').then((value) => { + if (value) { + backgroundAgentDisplayName.set(value, undefined); + } + }); + } } 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 9076add61bd..d75bdc57ac6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -44,7 +44,7 @@ import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; -import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; +import { AgentSessionProviders, backgroundAgentDisplayName, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; @@ -332,6 +332,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ ).recomputeInitiallyAndOnChange(this._store); this._register(autorun(reader => { + backgroundAgentDisplayName.read(reader); const activatedProviders = [...builtinSessionProviders, ...contributedSessionProviders.read(reader)]; for (const provider of Object.values(AgentSessionProviders)) { if (activatedProviders.includes(provider)) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 725f52f8092..c5ab3aae3f3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -18,10 +18,11 @@ import { IKeybindingService } from '../../../../../../platform/keybinding/common import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; -import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { AgentSessionProviders, backgroundAgentDisplayName, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; +import { autorun } from '../../../../../../base/common/observable.js'; export interface ISessionTypeItem { type: AgentSessionProviders; @@ -100,10 +101,19 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { super(action, sessionTargetPickerOptions, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); - this._updateAgentSessionItems(); this._register(this.chatSessionsService.onDidChangeAvailability(() => { this._updateAgentSessionItems(); })); + + // Re-render when the background agent display name changes via experiment + // Note: autorun runs immediately, so this also handles initial population + this._register(autorun(reader => { + backgroundAgentDisplayName.read(reader); + this._updateAgentSessionItems(); + if (this.element) { + this.renderLabel(this.element); + } + })); } protected _run(sessionTypeItem: ISessionTypeItem): void { From c78a202c54368e717f47c193b4fcbc8f72edc4be Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 10 Feb 2026 15:57:10 -0800 Subject: [PATCH 48/65] Fix selection of string literals when clicking around quotes (#294120) --- src/vs/editor/browser/view/viewController.ts | 28 ++- .../test/browser/view/viewController.test.ts | 230 ++++++++++++++++++ 2 files changed, 252 insertions(+), 6 deletions(-) diff --git a/src/vs/editor/browser/view/viewController.ts b/src/vs/editor/browser/view/viewController.ts index cd312c96271..9f94a1b4f82 100644 --- a/src/vs/editor/browser/view/viewController.ts +++ b/src/vs/editor/browser/view/viewController.ts @@ -16,6 +16,7 @@ import { EditorOption } from '../../common/config/editorOptions.js'; import * as platform from '../../../base/common/platform.js'; import { StandardTokenType } from '../../common/encodedTokenAttributes.js'; import { ITextModel } from '../../common/model.js'; +import { containsRTL } from '../../../base/common/strings.js'; export interface IMouseDispatchData { position: Position; @@ -181,15 +182,30 @@ export class ViewController { return undefined; } - // Get 1-based boundaries of the string content (excluding quotes). - const start = lineTokens.getStartOffset(index) + 2; - const end = lineTokens.getEndOffset(index); - - if (column !== start && column !== end) { + // Verify the click is after starting or before closing quote. + const tokenStart = lineTokens.getStartOffset(index); + const tokenEnd = lineTokens.getEndOffset(index); + if (column !== tokenStart + 2 && column !== tokenEnd) { return undefined; } - return new Selection(lineNumber, start, lineNumber, end); + // Verify the token looks like a complete quoted string (quote ... quote). + const lineContent = model.getLineContent(lineNumber); + const firstChar = lineContent.charAt(tokenStart); + if (firstChar !== '"' && firstChar !== '\'' && firstChar !== '`') { + return undefined; + } + if (lineContent.charAt(tokenEnd - 1) !== firstChar) { + return undefined; + } + + // Skip if string contains RTL characters. + const content = lineContent.substring(tokenStart + 1, tokenEnd - 1); + if (containsRTL(content)) { + return undefined; + } + + return new Selection(lineNumber, tokenStart + 2, lineNumber, tokenEnd); } public dispatchMouse(data: IMouseDispatchData): void { diff --git a/src/vs/editor/test/browser/view/viewController.test.ts b/src/vs/editor/test/browser/view/viewController.test.ts index a3b26791ce8..95d44801cb7 100644 --- a/src/vs/editor/test/browser/view/viewController.test.ts +++ b/src/vs/editor/test/browser/view/viewController.test.ts @@ -12,8 +12,11 @@ import { NavigationCommandRevealType } from '../../../browser/coreCommands.js'; import { ViewController } from '../../../browser/view/viewController.js'; import { ViewUserInputEvents } from '../../../browser/view/viewUserInputEvents.js'; import { Position } from '../../../common/core/position.js'; +import { MetadataConsts, StandardTokenType } from '../../../common/encodedTokenAttributes.js'; +import { EncodedTokenizationResult, ITokenizationSupport, TokenizationRegistry } from '../../../common/languages.js'; import { ILanguageService } from '../../../common/languages/language.js'; import { ILanguageConfigurationService } from '../../../common/languages/languageConfigurationRegistry.js'; +import { NullState } from '../../../common/languages/nullTokenize.js'; import { MonospaceLineBreaksComputerFactory } from '../../../common/viewModel/monospaceLineBreaksComputer.js'; import { ViewModel } from '../../../common/viewModel/viewModelImpl.js'; import { instantiateTextModel } from '../../../test/common/testTextModel.js'; @@ -145,3 +148,230 @@ suite('ViewController - Bracket content selection', () => { testBracketSelection('var x = {};', new Position(1, 10), ''); }); }); + +interface TokenSpan { + startIndex: number; + type: StandardTokenType; +} + +suite('ViewController - String content selection', () => { + let disposables: DisposableStore; + let instantiationService: TestInstantiationService; + let languageConfigurationService: ILanguageConfigurationService; + let languageService: ILanguageService; + let viewModel: ViewModel | undefined; + + setup(() => { + disposables = new DisposableStore(); + instantiationService = createCodeEditorServices(disposables); + languageConfigurationService = instantiationService.get(ILanguageConfigurationService); + languageService = instantiationService.get(ILanguageService); + viewModel = undefined; + }); + + teardown(() => { + viewModel?.dispose(); + viewModel = undefined; + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createViewControllerWithTokens(text: string, lineTokens: TokenSpan[]): ViewController { + const languageId = 'stringTestMode'; + disposables.add(languageService.registerLanguage({ id: languageId })); + disposables.add(languageConfigurationService.register(languageId, { + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ] + })); + + const encodedLanguageId = languageService.languageIdCodec.encodeLanguageId(languageId); + const makeMetadata = (type: StandardTokenType) => ( + (encodedLanguageId << MetadataConsts.LANGUAGEID_OFFSET) + | (type << MetadataConsts.TOKEN_TYPE_OFFSET) + ) >>> 0; + + const tokenizationSupport: ITokenizationSupport = { + getInitialState: () => NullState, + tokenize: undefined!, + tokenizeEncoded: (_line, _hasEOL, state) => { + const arr = new Uint32Array(lineTokens.length * 2); + for (let i = 0; i < lineTokens.length; i++) { + arr[i * 2] = lineTokens[i].startIndex; + arr[i * 2 + 1] = makeMetadata(lineTokens[i].type); + } + return new EncodedTokenizationResult(arr, [], state); + } + }; + + disposables.add(TokenizationRegistry.register(languageId, tokenizationSupport)); + + const configuration = disposables.add(new TestConfiguration({})); + const monospaceLineBreaksComputerFactory = MonospaceLineBreaksComputerFactory.create(configuration.options); + const model = disposables.add(instantiateTextModel(instantiationService, text, languageId)); + + model.tokenization.forceTokenization(1); + + viewModel = new ViewModel( + 1, + configuration, + model, + monospaceLineBreaksComputerFactory, + monospaceLineBreaksComputerFactory, + null!, + disposables.add(new TestLanguageConfigurationService()), + new TestThemeService(), + { setVisibleLines() { } }, + { batchChanges: (cb: any) => cb() } + ); + + return new ViewController( + configuration, + viewModel, + new ViewUserInputEvents(viewModel.coordinatesConverter), + { + paste: () => { }, + type: () => { }, + compositionType: () => { }, + startComposition: () => { }, + endComposition: () => { }, + cut: () => { } + } + ); + } + + function doubleClickAt(controller: ViewController, position: Position): string { + controller.dispatchMouse({ + position, + mouseColumn: position.column, + startedOnLineNumbers: false, + revealType: NavigationCommandRevealType.Minimal, + mouseDownCount: 2, + inSelectionMode: false, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + leftButton: true, + middleButton: false, + onInjectedText: false + }); + const selections = viewModel!.getSelections(); + return viewModel!.model.getValueInRange(selections[0]); + } + + // -- Happy-path: whole string as a single token including quotes -- + + test('Select string content clicking right after opening double quote', () => { + // 0123456789... + const text = 'var x = "hello";'; + // Token layout: [0..8) Other [8..15) String("hello") [15..16) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + // Column right after opening quote: offset 9 → column 10 + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + test('Select string content clicking at closing double quote', () => { + const text = 'var x = "hello";'; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + // Column at closing quote: offset 14 → column 15 + assert.strictEqual(doubleClickAt(controller, new Position(1, 15)), 'hello'); + }); + + test('Select string content with single quotes', () => { + const text = `var x = 'hello';`; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + test('Select string content with backtick quotes', () => { + const text = 'var x = `hello`;'; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + // -- Click in middle of string should NOT select the whole string -- + + test('Click in middle of string does not select whole string', () => { + // 0123456789012345678901 + const text = 'var x = "hello world";'; + // Token layout: [0..8) Other [8..21) String("hello world") [21..22) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 21, type: StandardTokenType.Other }, + ]); + // Click on 'w' in "world" — word select should pick 'world', not 'hello world' + assert.strictEqual(doubleClickAt(controller, new Position(1, 16)), 'world'); + }); + + // -- Bail-out: quotes as separate tokens (theme issue #292784) -- + + test('Separate quote tokens fall back to word select', () => { + // 0 1 2 + // 0123456789012345678901234 + const text = 'var x = "hello world";'; + // Theme tokenizes quotes as separate Other tokens: + // [0..8) Other [8..9) Other(") [9..20) String(hello world) [20..21) Other(") [21..22) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.Other }, // opening " + { startIndex: 9, type: StandardTokenType.String }, // hello world + { startIndex: 20, type: StandardTokenType.Other }, // closing " + { startIndex: 21, type: StandardTokenType.Other }, + ]); + // The String token "hello world" doesn't start with a quote char → should bail out. + // Click right after opening quote (column 10) → word select picks just 'hello'. + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + // -- Bail-out: RTL content in string (#293384) -- + + test('RTL content in string falls back to word select', () => { + const text = 'var x = "שלום עולם";'; + // Token layout: [0..8) Other [8..19) String("שלום עולם") [19..20) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 19, type: StandardTokenType.Other }, + ]); + // Should bail out due to RTL content → word select picks first word + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'שלום'); + }); + + // -- Bail-out: mismatched quotes (#293203 — string split at braces) -- + + test('String token without matching closing quote falls back to word select', () => { + // 0123456789012345 + const text = 'var x = "a {} b";'; + // Hypothetical tokenizer splits: [0..8) Other [8..11) String("a ) [11..13) Other({}) [13..17) String( b") [17..18) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, // `"a ` — starts with " but doesn't end with " + { startIndex: 11, type: StandardTokenType.Other }, // `{}` + { startIndex: 13, type: StandardTokenType.String }, // ` b"` — ends with " but doesn't start with " + { startIndex: 16, type: StandardTokenType.Other }, + ]); + // First String token starts with " but ends with space → bail out → word select picks 'a' + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'a'); + }); +}); From 0493189e1898523d115cd62f8e0362c2b61667dc Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:14:49 -0800 Subject: [PATCH 49/65] Start adopting unified js/ts config for code lenses For #292934 Testing this with a self contained area first: code lenses. Will keep support for the old setting values too to avoid breaking existing settings --- .../typescript-language-features/package.json | 121 +++++++++++++----- .../package.nls.json | 17 ++- .../codeLens/implementationsCodeLens.ts | 36 ++++-- .../codeLens/referencesCodeLens.ts | 27 +++- .../util/dependentRegistration.ts | 16 +++ .../src/utils/configuration.ts | 74 +++++++++++ 6 files changed, 240 insertions(+), 51 deletions(-) create mode 100644 extensions/typescript-language-features/src/utils/configuration.ts diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 667dc4e9ea7..4cd0263a1a0 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -173,36 +173,6 @@ "description": "%typescript.enablePromptUseWorkspaceTsdk%", "scope": "window" }, - "javascript.referencesCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%javascript.referencesCodeLens.enabled%", - "scope": "window" - }, - "javascript.referencesCodeLens.showOnAllFunctions": { - "type": "boolean", - "default": false, - "description": "%javascript.referencesCodeLens.showOnAllFunctions%", - "scope": "window" - }, - "typescript.referencesCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%typescript.referencesCodeLens.enabled%", - "scope": "window" - }, - "typescript.referencesCodeLens.showOnAllFunctions": { - "type": "boolean", - "default": false, - "description": "%typescript.referencesCodeLens.showOnAllFunctions%", - "scope": "window" - }, - "typescript.implementationsCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%typescript.implementationsCodeLens.enabled%", - "scope": "window" - }, "typescript.experimental.useTsgo": { "type": "boolean", "default": false, @@ -212,16 +182,103 @@ "experimental" ] }, + "js/ts.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "javascript.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.enabled.unifiedDeprecationMessage%", + "scope": "window" + }, + "typescript.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.enabled.unifiedDeprecationMessage%", + "scope": "window" + }, + "js/ts.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "javascript.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage%", + "scope": "window" + }, + "typescript.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage%", + "scope": "window" + }, + "js/ts.implementationsCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "typescript.implementationsCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.enabled.unifiedDeprecationMessage%", + "scope": "window" + }, + "js/ts.implementationsCodeLens.showOnInterfaceMethods": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "typescript.implementationsCodeLens.showOnInterfaceMethods": { "type": "boolean", "default": false, - "description": "%typescript.implementationsCodeLens.showOnInterfaceMethods%", + "description": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.showOnInterfaceMethods.unifiedDeprecationMessage%", "scope": "window" }, + "js/ts.implementationsCodeLens.showOnAllClassMethods": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.showOnAllClassMethods%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "typescript.implementationsCodeLens.showOnAllClassMethods": { "type": "boolean", "default": false, - "description": "%typescript.implementationsCodeLens.showOnAllClassMethods%", + "description": "%configuration.implementationsCodeLens.showOnAllClassMethods%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.showOnAllClassMethods.unifiedDeprecationMessage%", "scope": "window" }, "typescript.reportStyleChecksAsWarnings": { diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 43f62e918f3..ed640ef85f0 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -49,13 +49,16 @@ "javascript.validate.enable": "Enable/disable JavaScript validation.", "javascript.goToProjectConfig.title": "Go to Project Configuration (jsconfig / tsconfig)", "typescript.goToProjectConfig.title": "Go to Project Configuration (tsconfig)", - "javascript.referencesCodeLens.enabled": "Enable/disable references CodeLens in JavaScript files.", - "javascript.referencesCodeLens.showOnAllFunctions": "Enable/disable references CodeLens on all functions in JavaScript files.", - "typescript.referencesCodeLens.enabled": "Enable/disable references CodeLens in TypeScript files.", - "typescript.referencesCodeLens.showOnAllFunctions": "Enable/disable references CodeLens on all functions in TypeScript files.", - "typescript.implementationsCodeLens.enabled": "Enable/disable implementations CodeLens. This CodeLens shows the implementers of an interface.", - "typescript.implementationsCodeLens.showOnInterfaceMethods": "Enable/disable implementations CodeLens on interface methods.", - "typescript.implementationsCodeLens.showOnAllClassMethods": "Enable/disable showing implementations CodeLens above all class methods instead of only on abstract methods.", + "configuration.referencesCodeLens.enabled": "Enable/disable references CodeLens in JavaScript and TypeScript files. This CodeLens shows the number of references for classes and exported functions and allows you to peek or navigate to them.", + "configuration.referencesCodeLens.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.referencesCodeLens.enabled#` instead.", + "configuration.referencesCodeLens.showOnAllFunctions": "Enable/disable the references CodeLens on all functions in JavaScript and TypeScript files.", + "configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.referencesCodeLens.showOnAllFunctions#` instead.", + "configuration.implementationsCodeLens.enabled": "Enable/disable implementations CodeLens in TypeScript files. This CodeLens shows the implementers of a TypeScript interface.", + "configuration.implementationsCodeLens.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.enabled#` instead.", + "configuration.implementationsCodeLens.showOnInterfaceMethods": "Enable/disable implementations CodeLens on TypeScript interface methods.", + "configuration.implementationsCodeLens.showOnInterfaceMethods.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.showOnInterfaceMethods#` instead.", + "configuration.implementationsCodeLens.showOnAllClassMethods": "Enable/disable showing implementations CodeLens above all TypeScript class methods instead of only on abstract methods.", + "configuration.implementationsCodeLens.showOnAllClassMethods.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.showOnAllClassMethods#` instead.", "typescript.openTsServerLog.title": "Open TS Server log", "typescript.restartTsServer": "Restart TS Server", "typescript.selectTypeScriptVersion.title": "Select TypeScript Version...", diff --git a/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts b/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts index d012a6ab9d9..d32cfa129f8 100644 --- a/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts +++ b/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts @@ -11,10 +11,16 @@ import type * as Proto from '../../tsServer/protocol/protocol'; import * as PConst from '../../tsServer/protocol/protocol.const'; import * as typeConverters from '../../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; -import { conditionalRegistration, requireGlobalConfiguration, requireSomeCapability } from '../util/dependentRegistration'; +import { readUnifiedConfig, unifiedConfigSection } from '../../utils/configuration'; +import { conditionalRegistration, requireHasModifiedUnifiedConfig, requireSomeCapability } from '../util/dependentRegistration'; import { ReferencesCodeLens, TypeScriptBaseCodeLensProvider, getSymbolRange } from './baseCodeLensProvider'; import { ExecutionTarget } from '../../tsServer/server'; +const Config = Object.freeze({ + enabled: 'implementationsCodeLens.enabled', + showOnInterfaceMethods: 'implementationsCodeLens.showOnInterfaceMethods', + showOnAllClassMethods: 'implementationsCodeLens.showOnAllClassMethods', +}); export default class TypeScriptImplementationsCodeLensProvider extends TypeScriptBaseCodeLensProvider { public constructor( @@ -25,14 +31,30 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip super(client, _cachedResponse); this._register( vscode.workspace.onDidChangeConfiguration(evt => { - if (evt.affectsConfiguration(`${language.id}.implementationsCodeLens.showOnInterfaceMethods`) || - evt.affectsConfiguration(`${language.id}.implementationsCodeLens.showOnAllClassMethods`)) { + if ( + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.enabled}`) || + evt.affectsConfiguration(`${language.id}.${Config.enabled}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnInterfaceMethods}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnInterfaceMethods}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnAllClassMethods}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnAllClassMethods}`) + ) { this.changeEmitter.fire(); } }) ); } + + override async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + const enabled = readUnifiedConfig(Config.enabled, false, { scope: document, fallbackSection: this.language.id }); + if (!enabled) { + return []; + } + + return super.provideCodeLenses(document, token); + } + public async resolveCodeLens( codeLens: ReferencesCodeLens, token: vscode.CancellationToken, @@ -88,8 +110,6 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip item: Proto.NavigationTree, parent: Proto.NavigationTree | undefined ): vscode.Range | undefined { - const cfg = vscode.workspace.getConfiguration(this.language.id); - // Always show on interfaces if (item.kind === PConst.Kind.interface) { return getSymbolRange(document, item); @@ -111,7 +131,7 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip if ( item.kind === PConst.Kind.method && parent?.kind === PConst.Kind.interface && - cfg.get('implementationsCodeLens.showOnInterfaceMethods', false) + readUnifiedConfig('implementationsCodeLens.showOnInterfaceMethods', false, { scope: document, fallbackSection: this.language.id }) ) { return getSymbolRange(document, item); } @@ -121,7 +141,7 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip if ( item.kind === PConst.Kind.method && parent?.kind === PConst.Kind.class && - cfg.get('implementationsCodeLens.showOnAllClassMethods', false) + readUnifiedConfig('implementationsCodeLens.showOnAllClassMethods', false, { scope: document, fallbackSection: this.language.id }) ) { // But not private ones as these can never be overridden if (/\bprivate\b/.test(item.kindModifiers ?? '')) { @@ -141,7 +161,7 @@ export function register( cachedResponse: CachedResponse, ) { return conditionalRegistration([ - requireGlobalConfiguration(language.id, 'implementationsCodeLens.enabled'), + requireHasModifiedUnifiedConfig(Config.enabled, language.id), requireSomeCapability(client, ClientCapability.Semantic), ], () => { return vscode.languages.registerCodeLensProvider(selector.semantic, diff --git a/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts b/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts index de7d1f6d900..0942f7f8f3a 100644 --- a/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts +++ b/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts @@ -12,9 +12,14 @@ import * as PConst from '../../tsServer/protocol/protocol.const'; import { ExecutionTarget } from '../../tsServer/server'; import * as typeConverters from '../../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; -import { conditionalRegistration, requireGlobalConfiguration, requireSomeCapability } from '../util/dependentRegistration'; +import { readUnifiedConfig, unifiedConfigSection } from '../../utils/configuration'; +import { conditionalRegistration, requireHasModifiedUnifiedConfig, requireSomeCapability } from '../util/dependentRegistration'; import { ReferencesCodeLens, TypeScriptBaseCodeLensProvider, getSymbolRange } from './baseCodeLensProvider'; +const Config = Object.freeze({ + enabled: 'referencesCodeLens.enabled', + showOnAllFunctions: 'referencesCodeLens.showOnAllFunctions', +}); export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLensProvider { public constructor( @@ -25,13 +30,27 @@ export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLens super(client, _cachedResponse); this._register( vscode.workspace.onDidChangeConfiguration(evt => { - if (evt.affectsConfiguration(`${language.id}.referencesCodeLens.showOnAllFunctions`)) { + if ( + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.enabled}`) || + evt.affectsConfiguration(`${language.id}.${Config.enabled}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnAllFunctions}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnAllFunctions}`) + ) { this.changeEmitter.fire(); } }) ); } + override async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + const enabled = readUnifiedConfig(Config.enabled, false, { scope: document, fallbackSection: this.language.id }); + if (!enabled) { + return []; + } + + return super.provideCodeLenses(document, token); + } + public async resolveCodeLens(codeLens: ReferencesCodeLens, token: vscode.CancellationToken): Promise { const args = typeConverters.Position.toFileLocationRequestArgs(codeLens.file, codeLens.range.start); const response = await this.client.execute('references', args, token, { @@ -76,7 +95,7 @@ export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLens switch (item.kind) { case PConst.Kind.function: { - const showOnAllFunctions = vscode.workspace.getConfiguration(this.language.id).get('referencesCodeLens.showOnAllFunctions'); + const showOnAllFunctions = readUnifiedConfig(Config.showOnAllFunctions, false, { scope: document, fallbackSection: this.language.id }); if (showOnAllFunctions && item.nameSpan) { return getSymbolRange(document, item); } @@ -137,7 +156,7 @@ export function register( cachedResponse: CachedResponse, ) { return conditionalRegistration([ - requireGlobalConfiguration(language.id, 'referencesCodeLens.enabled'), + requireHasModifiedUnifiedConfig(Config.enabled, language.id), requireSomeCapability(client, ClientCapability.Semantic), ], () => { return vscode.languages.registerCodeLensProvider(selector.semantic, diff --git a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts index 8371b470997..916bfd8f3ae 100644 --- a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts +++ b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { API } from '../../tsServer/api'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; +import { hasModifiedUnifiedConfig } from '../../utils/configuration'; import { Disposable } from '../../utils/dispose'; export class Condition extends Disposable { @@ -102,6 +103,21 @@ export function requireGlobalConfiguration( ); } +/** + * Requires that a configuration value has been modified from its default value in either the global or workspace scope + * + * Does not check the value, only that it has been modified from the default. + */ +export function requireHasModifiedUnifiedConfig( + configValue: string, + fallbackSection: string, +) { + return new Condition( + () => hasModifiedUnifiedConfig(configValue, { fallbackSection }), + vscode.workspace.onDidChangeConfiguration + ); +} + export function requireSomeCapability( client: ITypeScriptServiceClient, ...capabilities: readonly ClientCapability[] diff --git a/extensions/typescript-language-features/src/utils/configuration.ts b/extensions/typescript-language-features/src/utils/configuration.ts new file mode 100644 index 00000000000..b10a70fd27a --- /dev/null +++ b/extensions/typescript-language-features/src/utils/configuration.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +type ConfigurationScope = vscode.ConfigurationScope | null | undefined; + +export const unifiedConfigSection = 'js/ts'; + +/** + * Gets a configuration value, checking the unified `js/ts` setting first, + * then falling back to the language-specific setting. + */ +export function readUnifiedConfig( + subSectionName: string, + defaultValue: T, + options: { + readonly scope?: ConfigurationScope; + readonly fallbackSection: string; + } +): T { + // Check unified setting first + const unifiedConfig = vscode.workspace.getConfiguration(unifiedConfigSection, options.scope); + const unifiedInspect = unifiedConfig.inspect(subSectionName); + if (hasModifiedValue(unifiedInspect)) { + return unifiedConfig.get(subSectionName, defaultValue); + } + + // Fall back to language-specific setting + const languageConfig = vscode.workspace.getConfiguration(options.fallbackSection, options.scope); + return languageConfig.get(subSectionName, defaultValue); +} + +/** + * Checks if an inspected configuration value has any user-defined values set. + */ +function hasModifiedValue(inspect: ReturnType): boolean { + if (!inspect) { + return false; + } + + return ( + typeof inspect.globalValue !== 'undefined' + || typeof inspect.workspaceValue !== 'undefined' + || typeof inspect.workspaceFolderValue !== 'undefined' + || typeof inspect.globalLanguageValue !== 'undefined' + || typeof inspect.workspaceLanguageValue !== 'undefined' + || typeof inspect.workspaceFolderLanguageValue !== 'undefined' + || ((inspect.languageIds?.length ?? 0) > 0) + ); +} + +/** + * Checks if a unified configuration value has been modified from its default value. + */ +export function hasModifiedUnifiedConfig( + subSectionName: string, + options: { + readonly scope?: ConfigurationScope; + readonly fallbackSection: string; + } +): boolean { + // Check unified setting + const unifiedConfig = vscode.workspace.getConfiguration(unifiedConfigSection, options.scope); + if (hasModifiedValue(unifiedConfig.inspect(subSectionName))) { + return true; + } + + // Check language-specific setting + const languageConfig = vscode.workspace.getConfiguration(options.fallbackSection, options.scope); + return hasModifiedValue(languageConfig.inspect(subSectionName)); +} From 90c1e412533fecfee583be72e6c2c2f8062226bb Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:16:39 -0600 Subject: [PATCH 50/65] Add chatSessions `isReadOnly` (#294255) * PROTOTYPE: Add growth agent (https://github.com/microsoft/vscode-copilot-chat/pull/3460) * support vscode.ChatSessionStatus.NeedsInput in chatSessions ext api ref https://github.com/microsoft/vscode/issues/292430 * Add isReadOnly flag to chat sessions contributions Read-only session types (e.g., Growth) are passive/informational and should not be registered as agents, appear in session target pickers, or be delegation targets. Commands are still registered to support openSessionWithPrompt. * Collapse isReadOnly and canDelegate branches in _enableContribution Both need agent and command registration; picker filtering handles keeping isReadOnly sessions out of the UI separately. the alternative (and probably ideal) UI is to 'grey out'/'disable' the chat input for isReadOnly sessions. That way we don't have this problem at all of a non-functional chatInput * fix description * redundant doc * update test --- .../api/common/extHostChatSessions.ts | 3 +- src/vs/workbench/api/common/extHostTypes.ts | 3 +- .../browser/agentSessions/agentSessions.ts | 17 +++++++++- .../chatSessions/chatSessions.contribution.ts | 9 +++++- .../chatContentParts/chatSuggestNextWidget.ts | 2 +- .../delegationSessionPickerActionItem.ts | 3 +- .../input/sessionTargetPickerActionItem.ts | 4 +++ .../chat/common/chatSessionsService.ts | 1 + .../agentSessionViewModel.test.ts | 31 ++++++++++++++++++- .../vscode.proposed.chatSessionsProvider.d.ts | 7 ++++- 10 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 46a7230a092..70d972b1d5f 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -478,7 +478,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return ChatSessionStatus.Completed; case 2: // vscode.ChatSessionStatus.InProgress return ChatSessionStatus.InProgress; - // Need to support NeedsInput status if we ever export it to the extension API + case 3: // vscode.ChatSessionStatus.NeedsInput + return ChatSessionStatus.NeedsInput; default: return undefined; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 9e47fb165dc..d7d1c805b87 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3552,7 +3552,8 @@ export enum ChatLocation { export enum ChatSessionStatus { Failed = 0, Completed = 1, - InProgress = 2 + InProgress = 2, + NeedsInput = 3 } export class ChatSessionChangedFile { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 8ac2ab653fd..ff81e1273fa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -9,6 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { IChatSessionTiming } from '../../common/chatService/chatService.js'; +import { IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -18,6 +19,7 @@ export enum AgentSessionProviders { Cloud = 'copilot-cloud-agent', Claude = 'claude-code', Codex = 'openai-codex', + Growth = 'copilot-growth', } export function isBuiltInAgentSessionProvider(provider: string): boolean { @@ -35,6 +37,7 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes case AgentSessionProviders.Cloud: case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return type; default: return undefined; @@ -59,6 +62,8 @@ export function getAgentSessionProviderName(provider: AgentSessionProviders): st return 'Claude'; case AgentSessionProviders.Codex: return 'Codex'; + case AgentSessionProviders.Growth: + return 'Growth'; } } @@ -74,6 +79,8 @@ export function getAgentSessionProviderIcon(provider: AgentSessionProviders): Th return Codicon.openai; case AgentSessionProviders.Claude: return Codicon.claude; + case AgentSessionProviders.Growth: + return Codicon.lightbulb; } } @@ -85,11 +92,16 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders return true; case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return false; } } -export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean { +export function getAgentCanContinueIn(provider: AgentSessionProviders, contribution?: IChatSessionsExtensionPoint): boolean { + // Read-only sessions (e.g., Growth) are passive/informational and cannot be delegation targets + if (contribution?.isReadOnly) { + return false; + } switch (provider) { case AgentSessionProviders.Local: case AgentSessionProviders.Background: @@ -97,6 +109,7 @@ export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean return true; case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return false; } } @@ -113,6 +126,8 @@ export function getAgentSessionProviderDescription(provider: AgentSessionProvide return localize('chat.session.providerDescription.claude', "Delegate tasks to the Claude Agent SDK using the Claude models included in your GitHub Copilot subscription. The agent iterates via chat and works interactively to implement changes on your main workspace."); case AgentSessionProviders.Codex: return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view."); + case AgentSessionProviders.Growth: + return localize('chat.session.providerDescription.growth', "Educational messages to help you learn Copilot features."); } } 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 d75bdc57ac6..344bc51d962 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -200,6 +200,11 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint 0) { 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 9e5218f2afb..37a075a3970 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -54,7 +54,8 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return true; // Always show active session type } - return getAgentCanContinueIn(type); + const contribution = this.chatSessionsService.getChatSessionContribution(type); + return getAgentCanContinueIn(type, contribution); } protected override _getSessionCategory(sessionTypeItem: ISessionTypeItem) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index c5ab3aae3f3..c14a5416cda 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -163,6 +163,10 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const contributions = this.chatSessionsService.getAllChatSessionContributions(); for (const contribution of contributions) { + if (contribution.isReadOnly) { + continue; // Read-only sessions are not interactive and should not appear in session target picker + } + const agentSessionType = getAgentSessionProvider(contribution.type); if (!agentSessionType) { continue; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 9f280a98fb8..839510819e7 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -85,6 +85,7 @@ export interface IChatSessionsExtensionPoint { readonly capabilities?: IChatAgentAttachmentCapabilities; readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; + readonly isReadOnly?: boolean; /** * When set, the chat session will show a filtered mode picker with custom agents * that have a matching `target` property. This enables contributed chat sessions diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 77685cafa97..f7fb12e4c80 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -22,7 +22,7 @@ import { MenuId } from '../../../../../../platform/actions/common/actions.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; -import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentCanContinueIn, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js'; suite('AgentSessions', () => { @@ -1949,6 +1949,16 @@ suite('AgentSessions', () => { assert.strictEqual(icon.id, Codicon.cloud.id); }); + test('should return correct name for Growth provider', () => { + const name = getAgentSessionProviderName(AgentSessionProviders.Growth); + assert.strictEqual(name, 'Growth'); + }); + + test('should return correct icon for Growth provider', () => { + const icon = getAgentSessionProviderIcon(AgentSessionProviders.Growth); + assert.strictEqual(icon.id, Codicon.lightbulb.id); + }); + test('should handle Local provider type in model', async () => { return runWithFakedTimers({}, async () => { const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); @@ -2087,6 +2097,25 @@ suite('AgentSessions', () => { }); }); + suite('AgentSessionsViewModel - getAgentCanContinueIn', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return false when contribution.isReadOnly is true', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: true }); + assert.strictEqual(result, false); + }); + + test('should return true for Cloud when contribution is not read-only', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: false }); + assert.strictEqual(result, true); + }); + + test('should return false for Growth provider', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Growth); + assert.strictEqual(result, false); + }); + }); + suite('AgentSessionsViewModel - Cancellation and Lifecycle', () => { const disposables = new DisposableStore(); let mockChatSessionsService: MockChatSessionsService; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index a2dc921d69f..df078abc002 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -23,7 +23,12 @@ declare module 'vscode' { /** * The chat session is currently in progress. */ - InProgress = 2 + InProgress = 2, + + /** + * The chat session needs user input (e.g. an unresolved confirmation). + */ + NeedsInput = 3 } export namespace chat { From 9dbaef0209cfd64f4a85a525d3932272800774ca Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:03:53 -0800 Subject: [PATCH 51/65] Add optimized way to update a single chat session item Reduce amount of data we're passing over to ext host each time an update happens --- .../api/browser/mainThreadChatSessions.ts | 92 ++++++++++++------- .../workbench/api/common/extHost.protocol.ts | 1 + .../api/common/extHostChatSessions.ts | 62 ++----------- .../api/common/extHostTypeConverters.ts | 48 ++++++++++ 4 files changed, 116 insertions(+), 87 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index ff9ea448f4a..da9a664e813 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -337,20 +337,32 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes this._handle = handle; } - private _items: IChatSessionItem[] = []; + private readonly _items = new ResourceMap(); get items(): IChatSessionItem[] { - return this._items; + return Array.from(this._items.values()); } refresh(token: CancellationToken): Promise { return this._proxy.$refreshChatSessionItems(this._handle, token); } - setItems(items: IChatSessionItem[]): void { - this._items = items; + setItems(items: readonly IChatSessionItem[]): void { + this._items.clear(); + for (const item of items) { + this._items.set(item.resource, item); + } this._onDidChangeChatSessionItems.fire(); } + updateItem(item: IChatSessionItem): void { + if (this._items.has(item.resource)) { + this._items.set(item.resource, item); + this._onDidChangeChatSessionItems.fire(); + } else { + console.warn(`Item with resource ${item.resource.toString()} does not exist. Skipping update.`); + } + } + fireOnDidChangeChatSessionItems(): void { this._onDidChangeChatSessionItems.fire(); } @@ -432,6 +444,36 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._itemControllerRegistrations.get(handle)?.controller.fireOnDidChangeChatSessionItems(); } + private async _resolveSessionItem(item: Dto): Promise { + const uri = URI.revive(item.resource); + const model = this._chatService.getSession(uri); + if (model) { + item = await this.handleSessionModelOverrides(model, item); + } + + // We can still get stats if there is no model or if fetching from model failed + if (!item.changes || !model) { + const stats = (await this._chatService.getMetadataForSession(uri))?.stats; + const diffs: IAgentSession['changes'] = { + files: stats?.fileCount || 0, + insertions: stats?.added || 0, + deletions: stats?.removed || 0 + }; + if (hasValidDiff(diffs)) { + item.changes = diffs; + } + } + + return { + ...item, + changes: revive(item.changes), + resource: uri, + iconPath: item.iconPath, + tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, + archived: item.archived, + }; + } + async $setChatSessionItems(handle: number, items: Dto[]): Promise { const registration = this._itemControllerRegistrations.get(handle); if (!registration) { @@ -439,39 +481,21 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat return; } - const resolvedItems = await Promise.all(items.map(async item => { - const uri = URI.revive(item.resource); - const model = this._chatService.getSession(uri); - if (model) { - item = await this.handleSessionModelOverrides(model, item); - } - - // We can still get stats if there is no model or if fetching from model failed - if (!item.changes || !model) { - const stats = (await this._chatService.getMetadataForSession(uri))?.stats; - const diffs: IAgentSession['changes'] = { - files: stats?.fileCount || 0, - insertions: stats?.added || 0, - deletions: stats?.removed || 0 - }; - if (hasValidDiff(diffs)) { - item.changes = diffs; - } - } - - return { - ...item, - changes: revive(item.changes), - resource: uri, - iconPath: item.iconPath, - tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, - archived: item.archived, - } satisfies IChatSessionItem; - })); - + const resolvedItems = await Promise.all(items.map(item => this._resolveSessionItem(item))); registration.controller.setItems(resolvedItems); } + async $updateChatSessionItem(controllerHandle: number, item: Dto): Promise { + const registration = this._itemControllerRegistrations.get(controllerHandle); + if (!registration) { + this._logService.warn(`No chat session controller registered for handle ${controllerHandle}`); + return; + } + + const resolvedItem = await this._resolveSessionItem(item); + registration.controller.updateItem(resolvedItem); + } + $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { const sessionResource = URI.revive(sessionResourceComponents); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c1a9e98276b..422aa1b5827 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3418,6 +3418,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { $registerChatSessionItemController(handle: number, chatSessionType: string): void; $unregisterChatSessionItemController(handle: number): void; $setChatSessionItems(handle: number, items: Dto[]): Promise; + $updateChatSessionItem(handle: number, item: Dto): Promise; $onDidChangeChatSessionItems(handle: number): void; $onDidCommitChatSessionItem(handle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 70d972b1d5f..febaa3a413d 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -19,10 +19,10 @@ import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, IPromptFileVariableEntry, ISymbolVariableEntry, PromptFileVariableKind } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionItem, 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 { Dto, Proxied } from '../../services/extensions/common/proxyIdentifier.js'; import { ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; @@ -331,10 +331,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio // Helper to push items to main thread const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { collection.replace(items); - const convertedItems: IChatSessionItem[] = []; + const convertedItems: Array> = []; for (const sessionContent of items) { this._sessionItems.set(sessionContent.resource, sessionContent); - convertedItems.push(this.convertChatSessionItem(sessionContent)); + convertedItems.push(typeConvert.ChatSessionItem.from(sessionContent)); } void this._proxy.$setChatSessionItems(handle, convertedItems); }; @@ -389,10 +389,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); const onItemsChanged = () => { - const items: IChatSessionItem[] = []; + const items: Array> = []; for (const [_, item] of collection) { this._sessionItems.set(item.resource, item); - items.push(this.convertChatSessionItem(item)); + items.push(typeConvert.ChatSessionItem.from(item)); } void this._proxy.$setChatSessionItems(controllerHandle, items); }; @@ -416,10 +416,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new Error('ChatSessionItemController has been disposed'); } - return new ChatSessionItemImpl(resource, label, () => { - // TODO: Optimize to only update the specific item - onItemsChanged(); + const item = new ChatSessionItemImpl(resource, label, () => { + void this._proxy.$updateChatSessionItem(controllerHandle, typeConvert.ChatSessionItem.from(item)); }); + return item; }, dispose: () => { isDisposed = true; @@ -466,50 +466,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - private convertChatSessionStatus(status: vscode.ChatSessionStatus | undefined): ChatSessionStatus | undefined { - if (status === undefined) { - return undefined; - } - - switch (status) { - case 0: // vscode.ChatSessionStatus.Failed - return ChatSessionStatus.Failed; - case 1: // vscode.ChatSessionStatus.Completed - return ChatSessionStatus.Completed; - case 2: // vscode.ChatSessionStatus.InProgress - return ChatSessionStatus.InProgress; - case 3: // vscode.ChatSessionStatus.NeedsInput - return ChatSessionStatus.NeedsInput; - default: - return undefined; - } - } - - private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { - // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties - const timing = sessionContent.timing; - const created = timing?.created ?? timing?.startTime ?? 0; - const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; - const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; - - return { - resource: sessionContent.resource, - label: sessionContent.label, - description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, - badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, - status: this.convertChatSessionStatus(sessionContent.status), - archived: sessionContent.archived, - tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), - timing: { - created, - lastRequestStarted, - lastRequestEnded, - }, - changes: sessionContent.changes instanceof Array ? sessionContent.changes : undefined, - metadata: sessionContent.metadata, - }; - } - async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 3bddf605a30..1de10e9526f 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -45,6 +45,7 @@ import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/ch import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; @@ -4122,3 +4123,50 @@ export namespace ChatHookCommand { }; } } + +export namespace ChatSessionItem { + + function convertStatus(status: vscode.ChatSessionStatus | undefined): ChatSessionStatus | undefined { + if (status === undefined) { + return undefined; + } + + switch (status) { + case 0: // vscode.ChatSessionStatus.Failed + return ChatSessionStatus.Failed; + case 1: // vscode.ChatSessionStatus.Completed + return ChatSessionStatus.Completed; + case 2: // vscode.ChatSessionStatus.InProgress + return ChatSessionStatus.InProgress; + case 3: // vscode.ChatSessionStatus.NeedsInput + return ChatSessionStatus.NeedsInput; + default: + return undefined; + } + } + + export function from(sessionContent: vscode.ChatSessionItem): Dto { + // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties + const timing = sessionContent.timing; + const created = timing?.created ?? timing?.startTime ?? 0; + const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; + const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; + + return { + resource: sessionContent.resource, + label: sessionContent.label, + description: sessionContent.description ? MarkdownString.from(sessionContent.description) : undefined, + badge: sessionContent.badge ? MarkdownString.from(sessionContent.badge) : undefined, + status: convertStatus(sessionContent.status), + archived: sessionContent.archived, + tooltip: MarkdownString.fromStrict(sessionContent.tooltip), + timing: { + created, + lastRequestStarted, + lastRequestEnded, + }, + changes: sessionContent.changes instanceof Array ? sessionContent.changes : undefined, + metadata: sessionContent.metadata, + }; + } +} From c4909f8a5c108971b473950c3c1027c4ad7bfc17 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:14:07 -0800 Subject: [PATCH 52/65] fix: chat settings feature filter regression (#294265) --- .../preferences/browser/settingsEditor2.ts | 1 + .../preferences/browser/settingsTreeModels.ts | 27 +++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index fc210a06396..9a33bfea060 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -148,6 +148,7 @@ export class SettingsEditor2 extends EditorPane { `@${FEATURE_SETTING_TAG}remote`, `@${FEATURE_SETTING_TAG}timeline`, `@${FEATURE_SETTING_TAG}notebook`, + `@${FEATURE_SETTING_TAG}chat`, `@${POLICY_SETTING_TAG}` ]; diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index 186468f57ad..9e2704a79b6 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -471,14 +471,31 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { return true; } - const features = tocData.children!.find(child => child.id === 'features'); + // Restrict to core settings + if (this.setting.extensionInfo) { + return false; + } + // Chat settings are now in their own top-level category + if (featureFilters.has('chat')) { + const chatFeatures = tocData.children!.find(child => child.id === 'chat'); + if (chatFeatures?.children) { + const patterns = chatFeatures.children + .flatMap(feature => feature.settings ?? []) + .map(setting => createSettingMatchRegExp(setting)); + if (patterns.some(pattern => pattern.test(this.setting.key))) { + return true; + } + } + } + + const features = tocData.children!.find(child => child.id === 'features'); return Array.from(featureFilters).some(filter => { - if (features && features.children) { + if (features?.children) { const feature = features.children.find(feature => 'features/' + filter === feature.id); - if (feature) { - const patterns = feature.settings?.map(setting => createSettingMatchRegExp(setting)); - return patterns && !this.setting.extensionInfo && patterns.some(pattern => pattern.test(this.setting.key.toLowerCase())); + if (feature?.settings) { + const patterns = feature.settings.map(setting => createSettingMatchRegExp(setting)); + return patterns.some(pattern => pattern.test(this.setting.key)); } else { return false; } From ce62dd2d1369b23f9dc1c84f380e4df07bdbc264 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 10 Feb 2026 17:17:12 -0800 Subject: [PATCH 53/65] Update hook timeout format (#294266) --- .../extensions/common/extensionsApiProposals.ts | 2 +- .../workbench/api/common/extHostTypeConverters.ts | 2 +- .../chat/common/promptSyntax/hookSchema.ts | 15 ++++++++------- .../common/promptSyntax/hookClaudeCompat.test.ts | 6 +++--- .../test/common/promptSyntax/hookSchema.test.ts | 14 +++++++------- src/vscode-dts/vscode.proposed.chatHooks.d.ts | 4 ++-- 6 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index abc94186d7f..b6a71de582b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', - version: 5 + version: 6 }, chatOutputRenderer: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 3bddf605a30..309c1799844 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -4118,7 +4118,7 @@ export namespace ChatHookCommand { command, cwd: hook.cwd, env: hook.env, - timeoutSec: hook.timeoutSec, + timeout: hook.timeout, }; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 297a0535e13..ccf368ed54a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -92,7 +92,7 @@ export interface IHookCommand { /** Resolved working directory URI. */ readonly cwd?: URI; readonly env?: Record; - readonly timeoutSec?: number; + readonly timeout?: number; /** Original JSON field name that provided the windows command. */ readonly windowsSource?: 'windows' | 'powershell'; /** Original JSON field name that provided the linux command. */ @@ -164,10 +164,10 @@ const hookCommandSchema: IJSONSchema = { additionalProperties: { type: 'string' }, description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') }, - timeoutSec: { + timeout: { type: 'number', default: 30, - description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 30).') + description: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).') } } }; @@ -240,7 +240,7 @@ export const hookFileSchema: IJSONSchema = { { type: 'command', command: '${2:./scripts/validate.sh}', - timeoutSec: 15 + timeout: 15 } ] } @@ -278,7 +278,7 @@ export function toHookType(rawHookTypeId: string): HookType | undefined { * - powershell -> windows * This is an internal helper - use resolveHookCommand for the full resolution. */ -function normalizeHookCommand(raw: Record): { command?: string; windows?: string; linux?: string; osx?: string; windowsSource?: 'windows' | 'powershell'; linuxSource?: 'linux' | 'bash'; osxSource?: 'osx' | 'bash'; cwd?: string; env?: Record; timeoutSec?: number } | undefined { +function normalizeHookCommand(raw: Record): { command?: string; windows?: string; linux?: string; osx?: string; windowsSource?: 'windows' | 'powershell'; linuxSource?: 'linux' | 'bash'; osxSource?: 'osx' | 'bash'; cwd?: string; env?: Record; timeout?: number } | undefined { if (raw.type !== 'command') { return undefined; } @@ -313,7 +313,8 @@ function normalizeHookCommand(raw: Record): { command?: string; ...(osxSource && { osxSource }), ...(typeof raw.cwd === 'string' && { cwd: raw.cwd }), ...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record }), - ...(typeof raw.timeoutSec === 'number' && { timeoutSec: raw.timeoutSec }), + ...(typeof raw.timeout !== 'number' && typeof raw.timeoutSec === 'number' && { timeout: raw.timeoutSec }), + ...(typeof raw.timeout === 'number' && { timeout: raw.timeout }), }; } @@ -456,6 +457,6 @@ export function resolveHookCommand(raw: Record, workspaceRootUr ...(normalized.osxSource && { osxSource: normalized.osxSource }), ...(cwdUri && { cwd: cwdUri }), ...(normalized.env && { env: normalized.env }), - ...(normalized.timeoutSec !== undefined && { timeoutSec: normalized.timeoutSec }), + ...(normalized.timeout !== undefined && { timeout: normalized.timeout }), }; } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 8321a90cf69..84aa27f5722 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -325,11 +325,11 @@ suite('HookClaudeCompat', () => { assert.deepStrictEqual(entry.hooks[0].env, { NODE_ENV: 'production' }); }); - test('preserves timeoutSec', () => { + test('preserves timeout', () => { const json = { hooks: { PreToolUse: [ - { type: 'command', command: 'echo "test"', timeoutSec: 60 } + { type: 'command', command: 'echo "test"', timeout: 60 } ] } }; @@ -337,7 +337,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); const entry = result.get(HookType.PreToolUse)!; - assert.strictEqual(entry.hooks[0].timeoutSec, 60); + assert.strictEqual(entry.hooks[0].timeout, 60); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts index 7bf407a0523..63bcf59c004 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -35,14 +35,14 @@ suite('HookSchema', () => { command: './scripts/validate.sh', cwd: 'src', env: { NODE_ENV: 'test' }, - timeoutSec: 60 + timeout: 60 }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', command: './scripts/validate.sh', cwd: URI.file('/workspace/src'), env: { NODE_ENV: 'test' }, - timeoutSec: 60 + timeout: 60 }); }); @@ -118,18 +118,18 @@ suite('HookSchema', () => { }); }); - test('powershell with timeoutSec', () => { + test('powershell with timeout', () => { const result = resolveHookCommand({ type: 'command', powershell: 'Get-Process', - timeoutSec: 30 + timeout: 30 }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', windows: 'Get-Process', windowsSource: 'powershell', cwd: workspaceRoot, - timeoutSec: 30 + timeout: 30 }); }); @@ -277,11 +277,11 @@ suite('HookSchema', () => { }); }); - test('ignores non-number timeoutSec', () => { + test('ignores non-number timeout', () => { const result = resolveHookCommand({ type: 'command', command: 'echo hello', - timeoutSec: '30' + timeout: '30' }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index 1540fea982c..eec28002b77 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 5 +// version: 6 declare module 'vscode' { @@ -32,7 +32,7 @@ declare module 'vscode' { /** * Maximum execution time in seconds. */ - readonly timeoutSec?: number; + readonly timeout?: number; } /** From 52d25405c691d1da39c1edd3d8e147c447214121 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 10 Feb 2026 19:21:24 -0600 Subject: [PATCH 54/65] fix alt buffer opening (#294251) --- .../chatAgentTools/browser/toolTerminalCreator.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts index c87a59ab3b3..2e42fbe4ba1 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts @@ -148,6 +148,12 @@ export class ToolTerminalCreator { const env: Record = { // Avoid making `git diff` interactive when called from copilot GIT_PAGER: 'cat', + // Prevent git from opening an editor for merge commits + GIT_MERGE_AUTOEDIT: 'no', + // Prevent git from opening an editor (e.g. for commit --amend, rebase -i). + // `:` is a POSIX shell built-in no-op (returns 0), works cross-platform + // since git always invokes the editor via `sh -c`. + GIT_EDITOR: ':', }; const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true; From 720ab1577359f505de8e2c8e823a35a119efce8f Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:09:54 -0600 Subject: [PATCH 55/65] honor when clause for chatSession contibution in agent sessions filter (#294283) --- .../agentSessions/agentSessionsFilter.ts | 20 +++++++++++-------- .../chatSessions/chatSessions.contribution.ts | 4 +--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 209ea059bcc..f83e4d138ac 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -11,7 +11,7 @@ import { registerAction2, Action2, MenuId } from '../../../../../platform/action import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; -import { AgentSessionProviders, getAgentSessionProviderName } from './agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderName } from './agentSessions.js'; import { AgentSessionStatus, IAgentSession } from './agentSessionsModel.js'; import { IAgentSessionsFilter, IAgentSessionsFilterExcludes } from './agentSessionsViewer.js'; @@ -127,17 +127,21 @@ export class AgentSessionsFilter extends Disposable implements Required ({ - id: provider, - label: getAgentSessionProviderName(provider) - })); + const providers: { id: string; label: string }[] = [{ + id: AgentSessionProviders.Local, + label: getAgentSessionProviderName(AgentSessionProviders.Local) + }]; - for (const provider of this.chatSessionsService.getAllChatSessionContributions()) { - if (providers.find(p => p.id === provider.type)) { + for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { + if (providers.find(p => p.id === contribution.type)) { continue; // already added } - providers.push({ id: provider.type, label: provider.name }); + const knownProvider = getAgentSessionProvider(contribution.type); + providers.push({ + id: contribution.type, + label: knownProvider ? getAgentSessionProviderName(knownProvider) : contribution.displayName + }); } const that = this; 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 344bc51d962..8a6372af31d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -333,7 +333,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const builtinSessionProviders = [AgentSessionProviders.Local]; const contributedSessionProviders = observableFromEvent( this.onDidChangeAvailability, - () => Array.from(this._contributions.keys()).filter(isAgentSessionProviderType) as AgentSessionProviders[], + () => Array.from(this._contributions.keys()).filter(key => this._contributionDisposables.has(key) && isAgentSessionProviderType(key)) as AgentSessionProviders[], ).recomputeInitiallyAndOnChange(this._store); this._register(autorun(reader => { @@ -371,8 +371,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ displayName = localize('chat.session.inProgress.background', "Background Agent"); } else if (chatSessionType === AgentSessionProviders.Cloud) { displayName = localize('chat.session.inProgress.cloud', "Cloud Agent"); - } else if (chatSessionType === AgentSessionProviders.Growth) { - displayName = localize('chat.session.inProgress.growth', "Growth"); } else { displayName = this._contributions.get(chatSessionType)?.contribution.displayName; } From 8e9f58e01a8773cf5fe70a8e610c93bcd24f1463 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 11 Feb 2026 02:12:52 +0000 Subject: [PATCH 56/65] Add 'view as tree' to chat edited files list (#294284) --- .../browser/chatEditing/chatEditingActions.ts | 69 ++ .../chatReferencesContentPart.ts | 4 +- .../browser/widget/input/chatEditsTree.ts | 636 ++++++++++++++++++ .../browser/widget/input/chatInputPart.ts | 119 ++-- .../chat/browser/widget/media/chat.css | 6 + .../chat/common/actions/chatContextKeys.ts | 2 + .../widget/input/chatEditsTree.test.ts | 275 ++++++++ 7 files changed, 1038 insertions(+), 73 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 388edd3f00c..b25f186b1e4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -25,6 +25,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IEditorPane } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; @@ -895,3 +896,71 @@ CommandsRegistry.registerCommand('_chat.editSessions.accept', async (accessor: S await editingSession.accept(...uris); } }); + +//#region View as Tree / View as List toggle + +export const CHAT_EDITS_VIEW_MODE_STORAGE_KEY = 'chat.editsViewMode'; +export const ChatEditsViewAsTreeActionId = 'chatEditing.viewAsTree'; +export const ChatEditsViewAsListActionId = 'chatEditing.viewAsList'; + +registerAction2(class ChatEditsViewAsTreeAction extends Action2 { + constructor() { + super({ + id: ChatEditsViewAsTreeActionId, + title: localize2('chatEditing.viewAsTree', "View as Tree"), + icon: Codicon.listFlat, + category: CHAT_CATEGORY, + menu: [ + { + id: MenuId.ChatEditingWidgetToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView.negate()), + }, + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView.negate()), + }, + ], + }); + } + + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + } +}); + +registerAction2(class ChatEditsViewAsListAction extends Action2 { + constructor() { + super({ + id: ChatEditsViewAsListActionId, + title: localize2('chatEditing.viewAsList', "View as List"), + icon: Codicon.listTree, + category: CHAT_CATEGORY, + menu: [ + { + id: MenuId.ChatEditingWidgetToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView), + }, + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView), + }, + ], + }); + } + + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'list', StorageScope.PROFILE, StorageTarget.USER); + } +}); + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts index d62abbb822c..f94a9c168cd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts @@ -299,7 +299,7 @@ class CollapsibleListDelegate implements IListVirtualDelegate { +export class CollapsibleListRenderer implements IListRenderer { static TEMPLATE_ID = 'chatCollapsibleListRenderer'; readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts new file mode 100644 index 00000000000..8917b4edfa8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts @@ -0,0 +1,636 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { addDisposableListener } from '../../../../../../base/browser/dom.js'; +import { ITreeRenderer, ITreeNode, IObjectTreeElement, ObjectTreeElementCollapseState } from '../../../../../../base/browser/ui/tree/tree.js'; +import { IIdentityProvider, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { comparePaths } from '../../../../../../base/common/comparers.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { matchesSomeScheme, Schemas } from '../../../../../../base/common/network.js'; +import { basename } from '../../../../../../base/common/path.js'; +import { basenameOrAuthority, dirname, isEqual, isEqualAuthority, isEqualOrParent } from '../../../../../../base/common/resources.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../../platform/actions/common/actions.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { FileKind } from '../../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IOpenEvent, WorkbenchList, WorkbenchObjectTree } from '../../../../../../platform/list/browser/listService.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; +import { isDark } from '../../../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; +import { IResourceLabel, ResourceLabels } from '../../../../../browser/labels.js'; +import { SETTINGS_AUTHORITY } from '../../../../../services/preferences/common/preferences.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../../common/chatService/chatService.js'; +import { chatEditingWidgetFileStateContextKey, IChatEditingSession } from '../../../common/editing/chatEditingService.js'; +import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../chatEditing/chatEditingActions.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../files/browser/views/explorerView.js'; +import { CollapsibleListPool, IChatCollapsibleListItem, ICollapsibleListTemplate } from '../chatContentParts/chatReferencesContentPart.js'; +import { IDisposableReference } from '../chatContentParts/chatCollections.js'; + +const $ = dom.$; + +/** + * Represents a folder node in the tree view. + */ +export interface IChatEditsFolderElement { + readonly kind: 'folder'; + readonly uri: URI; + readonly children: IChatCollapsibleListItem[]; +} + +/** + * Union type for elements in the chat edits tree. + */ +export type IChatEditsTreeElement = IChatCollapsibleListItem | IChatEditsFolderElement; + +/** + * Find the common ancestor directory among a set of URIs. + * Returns undefined if the URIs have no common ancestor (different schemes/authorities). + */ +function findCommonAncestorUri(uris: readonly URI[]): URI | undefined { + if (uris.length === 0) { + return undefined; + } + let common = uris[0]; + for (let i = 1; i < uris.length; i++) { + while (!isEqualOrParent(uris[i], common)) { + const parent = dirname(common); + if (isEqual(parent, common)) { + return undefined; // reached filesystem root + } + common = parent; + } + } + return common; +} + +/** + * Convert a flat list of chat edits items into a tree grouped by directory. + * Files at the common ancestor directory are shown at the root level without a folder row. + */ +export function buildEditsTree(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement[] { + // Group files by their directory + const folderMap = new Map(); + const itemsWithoutUri: IChatCollapsibleListItem[] = []; + + for (const item of items) { + if (item.kind === 'reference' && URI.isUri(item.reference)) { + const folderUri = dirname(item.reference); + const key = folderUri.toString(); + let group = folderMap.get(key); + if (!group) { + group = { uri: folderUri, items: [] }; + folderMap.set(key, group); + } + group.items.push(item); + } else { + itemsWithoutUri.push(item); + } + } + + const result: IObjectTreeElement[] = []; + + // Add items without URIs as top-level items (e.g., warnings) + for (const item of itemsWithoutUri) { + result.push({ element: item }); + } + + if (folderMap.size === 0) { + return result; + } + + // Find common ancestor so we can flatten files at the root level + const folderUris = [...folderMap.values()].map(f => f.uri); + const commonAncestor = findCommonAncestorUri(folderUris); + + // Sort folders by path + const sortedFolders = [...folderMap.values()].sort((a, b) => + comparePaths(a.uri.fsPath, b.uri.fsPath) + ); + + // Emit folders first, then root-level files (matching search tree behavior) + const rootFiles: IObjectTreeElement[] = []; + for (const folder of sortedFolders) { + const isAtCommonAncestor = commonAncestor && isEqual(folder.uri, commonAncestor); + if (isAtCommonAncestor) { + // Files at the common ancestor go at the root level, after all folders + for (const item of folder.items) { + rootFiles.push({ element: item }); + } + } else { + const folderElement: IChatEditsFolderElement = { + kind: 'folder', + uri: folder.uri, + children: folder.items, + }; + result.push({ + element: folderElement, + children: folder.items.map(item => ({ element: item as IChatEditsTreeElement })), + collapsible: true, + collapsed: ObjectTreeElementCollapseState.PreserveOrExpanded, + }); + } + } + + // Root-level files come after folders + result.push(...rootFiles); + + return result; +} + +/** + * Convert a flat list into tree elements without grouping (list mode). + */ +export function buildEditsList(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement[] { + return items.map(item => ({ element: item as IChatEditsTreeElement })); +} + +/** + * Delegate for the chat edits tree that returns element heights and template IDs. + */ +export class ChatEditsTreeDelegate implements IListVirtualDelegate { + getHeight(_element: IChatEditsTreeElement): number { + return 22; + } + + getTemplateId(element: IChatEditsTreeElement): string { + if (element.kind === 'folder') { + return ChatEditsFolderRenderer.TEMPLATE_ID; + } + return ChatEditsFileTreeRenderer.TEMPLATE_ID; + } +} + +/** + * Identity provider for the chat edits tree. + * Provides stable string IDs so the tree can preserve collapse/selection state across updates. + */ +export class ChatEditsTreeIdentityProvider implements IIdentityProvider { + getId(element: IChatEditsTreeElement): string { + if (element.kind === 'folder') { + return `folder:${element.uri.toString()}`; + } + if (element.kind === 'warning') { + return `warning:${element.content.value}`; + } + const ref = element.reference; + if (typeof ref === 'string') { + return `ref:${ref}`; + } else if (URI.isUri(ref)) { + return `file:${ref.toString()}`; + } else { + // eslint-disable-next-line local/code-no-in-operator + return `file:${'uri' in ref ? ref.uri.toString() : String(ref)}`; + } + } +} + +interface IChatEditsFolderTemplate { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; +} + +/** + * Renderer for folder elements in the chat edits tree. + */ +export class ChatEditsFolderRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'chatEditsFolderRenderer'; + readonly templateId = ChatEditsFolderRenderer.TEMPLATE_ID; + + constructor( + private readonly labels: ResourceLabels, + private readonly labelService: ILabelService, + ) { } + + renderTemplate(container: HTMLElement): IChatEditsFolderTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + return { label, templateDisposables }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IChatEditsFolderTemplate): void { + const element = node.element; + if (element.kind !== 'folder') { + return; + } + const relativeLabel = this.labelService.getUriLabel(element.uri, { relative: true }); + templateData.label.setResource( + { resource: element.uri, name: relativeLabel || basename(element.uri.path) }, + { fileKind: FileKind.FOLDER, fileDecorations: undefined } + ); + } + + disposeTemplate(templateData: IChatEditsFolderTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * Tree renderer for file elements in the chat edits tree. + * Adapted from CollapsibleListRenderer to work with ITreeNode. + */ +export class ChatEditsFileTreeRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'chatEditsFileRenderer'; + readonly templateId = ChatEditsFileTreeRenderer.TEMPLATE_ID; + + constructor( + private readonly labels: ResourceLabels, + private readonly menuId: MenuId | undefined, + @IThemeService private readonly themeService: IThemeService, + @IProductService private readonly productService: IProductService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { } + + renderTemplate(container: HTMLElement): ICollapsibleListTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + + const fileDiffsContainer = $('.working-set-line-counts'); + const addedSpan = dom.$('.working-set-lines-added'); + const removedSpan = dom.$('.working-set-lines-removed'); + fileDiffsContainer.appendChild(addedSpan); + fileDiffsContainer.appendChild(removedSpan); + label.element.appendChild(fileDiffsContainer); + + let toolbar; + let actionBarContainer; + let contextKeyService; + if (this.menuId) { + actionBarContainer = $('.chat-collapsible-list-action-bar'); + contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); + const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } })); + label.element.appendChild(actionBarContainer); + } + + return { templateDisposables, label, toolbar, actionBarContainer, contextKeyService, fileDiffsContainer, addedSpan, removedSpan }; + } + + private getReferenceIcon(data: IChatContentReference): URI | ThemeIcon | undefined { + if (ThemeIcon.isThemeIcon(data.iconPath)) { + return data.iconPath; + } else { + return isDark(this.themeService.getColorTheme().type) && data.iconPath?.dark + ? data.iconPath?.dark + : data.iconPath?.light; + } + } + + renderElement(node: ITreeNode, _index: number, templateData: ICollapsibleListTemplate): void { + const data = node.element; + if (data.kind === 'folder') { + return; + } + + if (data.kind === 'warning') { + templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning }); + return; + } + + const reference = data.reference; + const icon = this.getReferenceIcon(data); + templateData.label.element.style.display = 'flex'; + let arg: URI | undefined; + // eslint-disable-next-line local/code-no-in-operator + if (typeof reference === 'object' && 'variableName' in reference) { + if (reference.value) { + const uri = URI.isUri(reference.value) ? reference.value : reference.value.uri; + templateData.label.setResource( + { + resource: uri, + name: basenameOrAuthority(uri), + description: `#${reference.variableName}`, + // eslint-disable-next-line local/code-no-in-operator + range: 'range' in reference.value ? reference.value.range : undefined, + }, { icon, title: data.options?.status?.description ?? data.title }); + } else if (reference.variableName.startsWith('kernelVariable')) { + const variable = reference.variableName.split(':')[1]; + const asVariableName = `${variable}`; + const label = `Kernel variable`; + templateData.label.setLabel(label, asVariableName, { title: data.options?.status?.description }); + } else { + templateData.label.setLabel('Unknown variable type: ' + reference.variableName); + } + } else if (typeof reference === 'string') { + templateData.label.setLabel(reference, undefined, { iconPath: URI.isUri(icon) ? icon : undefined, title: data.options?.status?.description ?? data.title }); + } else { + // eslint-disable-next-line local/code-no-in-operator + const uri = 'uri' in reference ? reference.uri : reference; + arg = uri; + if (uri.scheme === 'https' && isEqualAuthority(uri.authority, 'github.com') && uri.path.includes('/tree/')) { + templateData.label.setResource({ resource: uri, name: basename(uri.path) }, { icon: Codicon.github, title: data.title }); + } else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) { + const settingId = uri.path.substring(1); + templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId) }); + } else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) { + templateData.label.setResource({ resource: uri, name: uri.toString(true) }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(true) }); + } else { + templateData.label.setFile(uri, { + fileKind: FileKind.FILE, + fileDecorations: undefined, + // eslint-disable-next-line local/code-no-in-operator + range: 'range' in reference ? reference.range : undefined, + title: data.options?.status?.description ?? data.title, + }); + } + } + + for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) { + // eslint-disable-next-line no-restricted-syntax + const element = templateData.label.element.querySelector(selector); + if (element) { + if (data.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted || data.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial) { + element.classList.add('warning'); + } else { + element.classList.remove('warning'); + } + } + } + + if (data.state !== undefined) { + if (templateData.actionBarContainer) { + const diffMeta = data?.options?.diffMeta; + if (diffMeta) { + if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) { + return; + } + templateData.addedSpan.textContent = `+${diffMeta.added}`; + templateData.removedSpan.textContent = `-${diffMeta.removed}`; + templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed)); + } + // eslint-disable-next-line no-restricted-syntax + templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified'); + } + if (templateData.toolbar) { + templateData.toolbar.context = arg; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state); + } + } + } + + disposeTemplate(templateData: ICollapsibleListTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * Widget that renders the chat edits file list, supporting both flat list and tree views. + * Manages the lifecycle of the underlying tree or list widget, and handles toggling between modes. + */ +export class ChatEditsListWidget extends Disposable { + private readonly _onDidFocus = this._register(new Emitter()); + readonly onDidFocus: Event = this._onDidFocus.event; + + private readonly _onDidOpen = this._register(new Emitter>()); + readonly onDidOpen: Event> = this._onDidOpen.event; + + private _tree: WorkbenchObjectTree | undefined; + private _list: IDisposableReference> | undefined; + + private readonly _listPool: CollapsibleListPool; + private readonly _widgetDisposables = this._register(new DisposableStore()); + private readonly _chatEditsInTreeView: IContextKey; + + private _currentContainer: HTMLElement | undefined; + private _currentSession: IChatEditingSession | null = null; + private _lastEntries: readonly IChatCollapsibleListItem[] = []; + + get currentSession(): IChatEditingSession | null { + return this._currentSession; + } + + get selectedElements(): URI[] { + const edits: URI[] = []; + if (this._tree) { + for (const element of this._tree.getSelection()) { + if (element && element.kind === 'reference' && URI.isUri(element.reference)) { + edits.push(element.reference); + } + } + } else if (this._list) { + for (const element of this._list.object.getSelectedElements()) { + if (element.kind === 'reference' && URI.isUri(element.reference)) { + edits.push(element.reference); + } + } + } + return edits; + } + + constructor( + private readonly onDidChangeVisibility: Event, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, + @IThemeService private readonly themeService: IThemeService, + @ILabelService private readonly labelService: ILabelService, + ) { + super(); + + this._listPool = this._register(this.instantiationService.createInstance( + CollapsibleListPool, + this.onDidChangeVisibility, + MenuId.ChatEditingWidgetModifiedFilesToolbar, + { verticalScrollMode: ScrollbarVisibility.Visible }, + )); + + this._chatEditsInTreeView = ChatContextKeys.chatEditsInTreeView.bindTo(contextKeyService); + this._chatEditsInTreeView.set(this._isTreeMode); + + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_EDITS_VIEW_MODE_STORAGE_KEY, this._store)(() => { + const isTree = this._isTreeMode; + this._chatEditsInTreeView.set(isTree); + if (this._currentContainer) { + this.create(this._currentContainer, this._currentSession); + this.setEntries(this._lastEntries); + } + })); + } + + private get _isTreeMode(): boolean { + return this.storageService.get(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, StorageScope.PROFILE, 'list') === 'tree'; + } + + /** + * Creates the appropriate widget (tree or list) inside the given container. + * Must be called before {@link setEntries}. + */ + create(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this._currentContainer = container; + this._currentSession = chatEditingSession; + this.clear(); + dom.clearNode(container); + + if (this._isTreeMode) { + this._createTree(container, chatEditingSession); + } else { + this._createList(container, chatEditingSession); + } + } + + /** + * Rebuild the widget (e.g. after a view mode toggle). + */ + rebuild(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this.create(container, chatEditingSession); + } + + /** + * Whether the current view mode has changed since the widget was last created. + */ + get needsRebuild(): boolean { + if (this._isTreeMode) { + return !this._tree; + } + return !this._list; + } + + /** + * Update the displayed entries. + */ + setEntries(entries: readonly IChatCollapsibleListItem[]): void { + this._lastEntries = entries; + if (this._tree) { + const treeElements = this._isTreeMode + ? buildEditsTree(entries) + : buildEditsList(entries); + + // Use the file entry count for height, not the tree-expanded count, + // so height stays consistent when toggling between tree and list modes + const maxItemsShown = 6; + const itemsShown = Math.min(entries.length, maxItemsShown); + const height = itemsShown * 22; + this._tree.layout(height); + this._tree.getHTMLElement().style.height = `${height}px`; + this._tree.setChildren(null, treeElements); + } else if (this._list) { + const maxItemsShown = 6; + const itemsShown = Math.min(entries.length, maxItemsShown); + const height = itemsShown * 22; + const list = this._list.object; + list.layout(height); + list.getHTMLElement().style.height = `${height}px`; + list.splice(0, list.length, entries); + } + } + + /** + * Dispose the current tree or list widget without disposing the outer widget. + */ + clear(): void { + this._widgetDisposables.clear(); + this._tree = undefined; + this._list = undefined; + } + + private _createTree(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + const resourceLabels = this._widgetDisposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeVisibility })); + const treeContainer = dom.$('.chat-used-context-list'); + this._widgetDisposables.add(createFileIconThemableTreeContainerScope(treeContainer, this.themeService)); + + const tree = this._widgetDisposables.add(this.instantiationService.createInstance( + WorkbenchObjectTree, + 'ChatEditsTree', + treeContainer, + new ChatEditsTreeDelegate(), + [ + new ChatEditsFolderRenderer(resourceLabels, this.labelService), + this.instantiationService.createInstance(ChatEditsFileTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar), + ], + { + alwaysConsumeMouseWheel: false, + accessibilityProvider: { + getAriaLabel: (element: IChatEditsTreeElement) => { + if (element.kind === 'folder') { + return this.labelService.getUriLabel(element.uri, { relative: true }); + } + if (element.kind === 'warning') { + return element.content.value; + } + const reference = element.reference; + if (typeof reference === 'string') { + return reference; + } else if (URI.isUri(reference)) { + return this.labelService.getUriBasenameLabel(reference); + // eslint-disable-next-line local/code-no-in-operator + } else if ('uri' in reference) { + return this.labelService.getUriBasenameLabel(reference.uri); + } else { + return ''; + } + }, + getWidgetAriaLabel: () => localize('chatEditsTree', "Changed Files"), + }, + identityProvider: new ChatEditsTreeIdentityProvider(), + verticalScrollMode: ScrollbarVisibility.Visible, + hideTwistiesOfChildlessElements: true, + } + )); + + tree.updateOptions({ enableStickyScroll: false }); + + this._tree = tree; + + this._widgetDisposables.add(tree.onDidChangeFocus(() => { + this._onDidFocus.fire(); + })); + + this._widgetDisposables.add(tree.onDidOpen(e => { + this._onDidOpen.fire(e); + })); + + this._widgetDisposables.add(addDisposableListener(tree.getHTMLElement(), 'click', () => { + this._onDidFocus.fire(); + }, true)); + + dom.append(container, tree.getHTMLElement()); + } + + private _createList(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this._list = this._listPool.get(); + const list = this._list.object; + this._widgetDisposables.add(this._list); + + this._widgetDisposables.add(list.onDidFocus(() => { + this._onDidFocus.fire(); + })); + + this._widgetDisposables.add(list.onDidOpen(async (e) => { + if (e.element) { + this._onDidOpen.fire({ + element: e.element as IChatEditsTreeElement, + editorOptions: e.editorOptions, + sideBySide: e.sideBySide, + browserEvent: e.browserEvent, + }); + } + })); + + this._widgetDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', () => { + this._onDidFocus.fire(); + }, true)); + + dom.append(container, list.getHTMLElement()); + } + + override dispose(): void { + this.clear(); + super.dispose(); + } +} 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 24ab3f42c01..b0fd053396d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -31,7 +31,6 @@ import { mixin } from '../../../../../../base/common/objects.js'; import { autorun, derived, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; import { isEqual } from '../../../../../../base/common/resources.js'; -import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { assertType } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../../../editor/browser/config/editorConfiguration.js'; @@ -63,7 +62,6 @@ import { registerAndCreateHistoryNavigationContext } from '../../../../../../pla import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; -import { WorkbenchList } from '../../../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; @@ -104,17 +102,17 @@ import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmen import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; -import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; +import { ChatEditingShowChangesAction, ChatEditsViewAsListActionId, ChatEditsViewAsTreeActionId, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { IChatContextService } from '../../contextContrib/chatContextService.js'; -import { IDisposableReference } from '../chatContentParts/chatCollections.js'; import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatContentParts/chatQuestionCarouselPart.js'; import { IChatContentPartRenderContext } from '../chatContentParts/chatContentParts.js'; -import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; +import { IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; +import { ChatEditsListWidget } from './chatEditsTree.js'; import { ChatFollowups } from './chatFollowups.js'; import { ChatInputPartWidgetController } from './chatInputPartWidgets.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -420,21 +418,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _workingSetLinesRemovedSpan = new Lazy(() => dom.$('.working-set-lines-removed')); private readonly _chatEditsActionsDisposables: DisposableStore = this._register(new DisposableStore()); - private readonly _chatEditsDisposables: DisposableStore = this._register(new DisposableStore()); private readonly _renderingChatEdits = this._register(new MutableDisposable()); - private _chatEditsListPool: CollapsibleListPool; - private _chatEditList: IDisposableReference> | undefined; + private readonly _chatEditsListWidget = this._register(new MutableDisposable()); get selectedElements(): URI[] { - const edits = []; - const editsList = this._chatEditList?.object; - const selectedElements = editsList?.getSelectedElements() ?? []; - for (const element of selectedElements) { - if (element.kind === 'reference' && URI.isUri(element.reference)) { - edits.push(element.reference); - } - } - return edits; + return this._chatEditsListWidget.value?.selectedElements ?? []; } private _attemptedWorkingSetEntriesCount: number = 0; @@ -590,8 +578,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditor.updateOptions(newOptions); })); - this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.ChatEditingWidgetModifiedFilesToolbar, { verticalScrollMode: ScrollbarVisibility.Visible })); - this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService); this.initSelectedModel(); @@ -2583,8 +2569,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ); } else { dom.clearNode(this.chatEditingSessionWidgetContainer); - this._chatEditsDisposables.clear(); - this._chatEditList = undefined; + this._chatEditsListWidget.value?.clear(); } }); } @@ -2677,7 +2662,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }) : undefined, disableWhileRunning: isSessionMenu, buttonConfigProvider: (action) => { - if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID) { + if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID + || action.id === ChatEditsViewAsTreeActionId || action.id === ChatEditsViewAsListActionId) { return { showIcon: true, showLabel: false, isSecondary: true }; } return undefined; @@ -2728,54 +2714,51 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge workingSetContainer.classList.toggle('collapsed', collapsed); })); - if (!this._chatEditList) { - this._chatEditList = this._chatEditsListPool.get(); - const list = this._chatEditList.object; - this._chatEditsDisposables.add(this._chatEditList); - this._chatEditsDisposables.add(list.onDidFocus(() => { - this._onDidFocus.fire(); - })); - this._chatEditsDisposables.add(list.onDidOpen(async (e) => { - if (e.element?.kind === 'reference' && URI.isUri(e.element.reference)) { - const modifiedFileUri = e.element.reference; - const originalUri = e.element.options?.originalUri; - - if (e.element.options?.isDeletion && originalUri) { - await this.editorService.openEditor({ - resource: originalUri, // instead of modified, because modified will not exist - options: e.editorOptions - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + if (!this._chatEditsListWidget.value || this._chatEditsListWidget.value.needsRebuild) { + if (!this._chatEditsListWidget.value) { + const widget = this.instantiationService.createInstance(ChatEditsListWidget, this._onDidChangeVisibility.event); + this._chatEditsListWidget.value = widget; + this._register(widget.onDidFocus(() => this._onDidFocus.fire())); + this._register(widget.onDidOpen(async (e) => { + const element = e.element; + if (!element || element.kind === 'folder' || element.kind === 'warning') { return; } + if (element.kind === 'reference' && URI.isUri(element.reference)) { + const modifiedFileUri = element.reference; + const originalUri = element.options?.originalUri; - // If there's a originalUri, open as diff editor - if (originalUri) { - await this.editorService.openEditor({ - original: { resource: originalUri }, - modified: { resource: modifiedFileUri }, + if (element.options?.isDeletion && originalUri) { + await this.editorService.openEditor({ + resource: originalUri, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + if (originalUri) { + await this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedFileUri }, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + // Use the widget's current session, not a stale closure + const entry = widget.currentSession?.getEntry(modifiedFileUri); + const pane = await this.editorService.openEditor({ + resource: modifiedFileUri, options: e.editorOptions }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - return; + + if (pane) { + entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); + } } - - const entry = chatEditingSession?.getEntry(modifiedFileUri); - - const pane = await this.editorService.openEditor({ - resource: modifiedFileUri, - options: e.editorOptions - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - - if (pane) { - entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); - } - } - })); - this._chatEditsDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', e => { - if (!this.hasFocus()) { - this._onDidFocus.fire(); - } - }, true)); - dom.append(workingSetContainer, list.getHTMLElement()); + })); + } + this._chatEditsListWidget.value.rebuild(workingSetContainer, chatEditingSession); dom.append(innerContainer, workingSetContainer); } @@ -2788,13 +2771,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // entries, while background chat sessions use session file changes. const allEntries = editEntries.concat(sessionFileEntries); - const maxItemsShown = 6; - const itemsShown = Math.min(allEntries.length, maxItemsShown); - const height = itemsShown * 22; - const list = this._chatEditList!.object; - list.layout(height); - list.getHTMLElement().style.height = `${height}px`; - list.splice(0, list.length, allEntries); + this._chatEditsListWidget.value?.setEntries(allEntries); })); } 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 004b2fd6eca..38fa30b7113 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2101,6 +2101,12 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } +/* Tree view: remove twistie indent for leaf (non-collapsible) file rows */ +.interactive-session .chat-editing-session-list .monaco-tl-twistie:not(.collapsible) { + width: 0; + padding-right: 0; +} + .interactive-session .chat-summary-list .monaco-list .monaco-list-row { border-radius: 4px; } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index d2d47e5842f..cb507daf273 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -119,6 +119,8 @@ export namespace ChatContextKeys { export const hasMultipleAgentSessionsSelected = new RawContextKey('agentSessionHasMultipleSelected', false, { type: 'boolean', description: localize('agentSessionHasMultipleSelected', "True when multiple agent sessions are selected.") }); export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); + export const chatEditsInTreeView = new RawContextKey('chatEditsInTreeView', false, { type: 'boolean', description: localize('chatEditsInTreeView', "True when the chat edits working set is displayed as a tree.") }); + export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts new file mode 100644 index 00000000000..d9f9a38a76e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../../../platform/contextkey/browser/contextKeyService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { IChatCollapsibleListItem } from '../../../../browser/widget/chatContentParts/chatReferencesContentPart.js'; +import { buildEditsList, buildEditsTree, ChatEditsListWidget, ChatEditsTreeIdentityProvider, IChatEditsFolderElement } from '../../../../browser/widget/input/chatEditsTree.js'; +import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../../../browser/chatEditing/chatEditingActions.js'; +import { ModifiedFileEntryState, IChatEditingSession } from '../../../../common/editing/chatEditingService.js'; +import { Event } from '../../../../../../../base/common/event.js'; + +function makeFileItem(path: string, added = 0, removed = 0): IChatCollapsibleListItem { + return { + reference: URI.file(path), + state: ModifiedFileEntryState.Modified, + kind: 'reference', + options: { + status: undefined, + diffMeta: { added, removed }, + } + }; +} + +suite('ChatEditsTree', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('buildEditsList', () => { + test('wraps items as flat tree elements', () => { + const items = [ + makeFileItem('/src/a.ts'), + makeFileItem('/src/b.ts'), + ]; + const result = buildEditsList(items); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].children, undefined); + assert.strictEqual(result[1].children, undefined); + }); + + test('returns empty array for empty input', () => { + assert.deepStrictEqual(buildEditsList([]), []); + }); + }); + + suite('buildEditsTree', () => { + test('groups files by directory', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/src/b.ts'), + makeFileItem('/project/lib/c.ts'), + ]; + const result = buildEditsTree(items); + + // Should have 2 folder elements + assert.strictEqual(result.length, 2); + + const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder'); + assert.strictEqual(folders.length, 2); + + // Each folder should have children + for (const r of result) { + assert.ok(r.children); + assert.ok(r.collapsible); + } + }); + + test('skips folder grouping for single file in single folder', () => { + const items = [makeFileItem('/project/src/a.ts')]; + const result = buildEditsTree(items); + + // Single file should not be wrapped in a folder + assert.strictEqual(result.length, 1); + assert.notStrictEqual(result[0].element.kind, 'folder'); + }); + + test('still groups when there are multiple folders even with single files', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/lib/b.ts'), + ]; + const result = buildEditsTree(items); + + assert.strictEqual(result.length, 2); + const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder'); + assert.strictEqual(folders.length, 2); + }); + + test('handles items without URIs as top-level elements', () => { + const warning: IChatCollapsibleListItem = { + kind: 'warning', + content: { value: 'Something went wrong' }, + }; + const items: IChatCollapsibleListItem[] = [ + warning, + makeFileItem('/src/a.ts'), + ]; + const result = buildEditsTree(items); + + // Warning at top level + single file at root (common ancestor is /src/) + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].element.kind, 'warning'); + assert.strictEqual(result[1].element.kind, 'reference'); + }); + + test('flattens files at common ancestor and shows subfolders', () => { + const items = [ + makeFileItem('/project/root/hello.py'), + makeFileItem('/project/root/README.md'), + makeFileItem('/project/root/test.py'), + makeFileItem('/project/root/js/test2.js'), + ]; + const result = buildEditsTree(items); + + // Common ancestor is /project/root/ — files there go to root level, + // js/ becomes a folder node + const rootFiles = result.filter(r => r.element.kind === 'reference'); + const folders = result.filter(r => r.element.kind === 'folder'); + assert.strictEqual(rootFiles.length, 3, 'three files at root level'); + assert.strictEqual(folders.length, 1, 'one subfolder'); + assert.strictEqual((folders[0].element as IChatEditsFolderElement).children.length, 1); + + // Folders should come before files (like search) + const firstFolderIndex = result.findIndex(r => r.element.kind === 'folder'); + const firstFileIndex = result.findIndex(r => r.element.kind === 'reference'); + assert.ok(firstFolderIndex < firstFileIndex, 'folders should appear before files'); + }); + + test('all files in same directory produces no folder row', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/src/b.ts'), + makeFileItem('/project/src/c.ts'), + ]; + const result = buildEditsTree(items); + + // All files in the same directory — common ancestor is /project/src/ + // No folder row needed + assert.strictEqual(result.length, 3); + assert.ok(result.every(r => r.element.kind === 'reference')); + }); + }); + + suite('ChatEditsTreeIdentityProvider', () => { + test('provides stable IDs for folders', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const folder: IChatEditsFolderElement = { + kind: 'folder', + uri: URI.file('/src'), + children: [], + }; + const id = provider.getId(folder); + assert.strictEqual(id, `folder:${URI.file('/src').toString()}`); + }); + + test('provides stable IDs for file references', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item = makeFileItem('/src/a.ts'); + const id = provider.getId(item); + assert.strictEqual(id, `file:${URI.file('/src/a.ts').toString()}`); + }); + + test('same element produces same ID', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item1 = makeFileItem('/src/a.ts'); + const item2 = makeFileItem('/src/a.ts'); + assert.strictEqual(provider.getId(item1), provider.getId(item2)); + }); + + test('different elements produce different IDs', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item1 = makeFileItem('/src/a.ts'); + const item2 = makeFileItem('/src/b.ts'); + assert.notStrictEqual(provider.getId(item1), provider.getId(item2)); + }); + }); + + suite('ChatEditsListWidget lifecycle', () => { + let store: DisposableStore; + let storageService: IStorageService; + let widget: ChatEditsListWidget; + + setup(() => { + store = new DisposableStore(); + const instaService = workbenchInstantiationService({ + contextKeyService: () => store.add(new ContextKeyService(new TestConfigurationService)), + }, store); + store.add(instaService); + + storageService = instaService.get(IStorageService); + widget = store.add(instaService.createInstance(ChatEditsListWidget, Event.None)); + }); + + teardown(() => { + store.dispose(); + }); + + test('storage listener fires after clear', () => { + // Stub create to avoid DOM/widget side effects in tests + let createCallCount = 0; + const origCreate = widget.create.bind(widget); + widget.create = (c, s) => { + createCallCount++; + // Update stored refs without actually building widgets + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + assert.strictEqual(createCallCount, 1); + + // Simulate session switch + widget.clear(); + + // Toggle view mode — storage listener must still fire after clear() + createCallCount = 0; + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + assert.strictEqual(createCallCount, 1, 'storage listener should trigger create after clear()'); + + widget.create = origCreate; + }); + + test('currentSession is updated on rebuild', () => { + // Stub create + widget.create = (c, s) => { + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + assert.strictEqual(widget.currentSession, null); + + const mockSession = {} as IChatEditingSession; + widget.rebuild(container, mockSession); + assert.strictEqual(widget.currentSession, mockSession); + }); + + test('setEntries replays after view mode toggle', () => { + // Stub create and track setEntries calls + widget.create = (c, s) => { + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + + const entries = [makeFileItem('/src/a.ts'), makeFileItem('/src/b.ts')]; + widget.setEntries(entries); + + const setEntriesCalls: readonly IChatCollapsibleListItem[][] = []; + const origSetEntries = widget.setEntries.bind(widget); + widget.setEntries = (e) => { + (setEntriesCalls as IChatCollapsibleListItem[][]).push([...e]); + origSetEntries(e); + }; + + // Toggle to tree mode — should replay entries + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + assert.strictEqual(setEntriesCalls.length, 1, 'setEntries should have been replayed'); + assert.strictEqual(setEntriesCalls[0].length, 2, 'should have replayed the 2 entries'); + + widget.setEntries = origSetEntries; + }); + }); +}); From 4efca9779489ef11520f0ff48a3446db2b3b3485 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:24:15 -0800 Subject: [PATCH 57/65] Improve rendered `li` elements in chat markdown (#294268) Add vertical spacing to rendered markdown list items in chat --- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 4 ++++ 1 file changed, 4 insertions(+) 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 38fa30b7113..1bd12e53aca 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -609,6 +609,10 @@ padding-inline-start: 28px; } +.interactive-item-container .value .rendered-markdown li { + margin: 4px 0; +} + /* NOTE- We want to dedent codeblocks in lists specifically to give them the full width. No more elegant way to do this, these values have to be updated for changes to the rules above, or to support more deeply nested lists. */ .interactive-item-container .value .rendered-markdown ul .interactive-result-code-block { From 46b0829be41ac211d02d76cd4fa57dfa83d62919 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:24:36 -0800 Subject: [PATCH 58/65] Use font-weight 600 for bold in rendered markdown (#294274) Use font-weight 600 for bold text in rendered markdown --- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 4 ++++ 1 file changed, 4 insertions(+) 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 1bd12e53aca..06d2cfab752 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -300,6 +300,10 @@ border-color: var(--vscode-textBlockQuote-border); } +.interactive-item-container .value .rendered-markdown strong { + font-weight: 600; +} + .interactive-item-container .value .rendered-markdown table { width: 100%; text-align: left; From 50a163589ec0956ded62021335f55fccc715f5fb Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:24:46 -0800 Subject: [PATCH 59/65] Apply consistent border-radius to tables, code blocks, and code block toolbars in chat (#294275) Improve rendered markdown table styling in chat --- .../chatContentParts/media/codeBlockPart.css | 5 ++--- .../chat/browser/widget/media/chat.css | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css index 9a70eec7939..7a9b9f736b7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css @@ -21,6 +21,7 @@ line-height: 26px; background-color: var(--vscode-interactive-result-editor-background-color, var(--vscode-editor-background)); border: 1px solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-medium); z-index: 100; max-width: 70%; text-overflow: ellipsis; @@ -32,7 +33,6 @@ } .interactive-result-code-block .interactive-result-code-block-toolbar > .monaco-toolbar { - border-radius: 3px; right: 10px; } @@ -50,7 +50,6 @@ .interactive-result-code-block .interactive-result-code-block-toolbar:focus-within, .interactive-result-code-block.focused .interactive-result-code-block-toolbar { opacity: 1; - border-radius: 2px; pointer-events: auto; } @@ -79,7 +78,7 @@ .interactive-result-code-block, .interactive-result-code-block .monaco-editor, .interactive-result-code-block .monaco-editor .overflow-guard { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-medium); } .interactive-result-code-block .interactive-result-vulns { 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 06d2cfab752..4de68fd4633 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -308,16 +308,31 @@ width: 100%; text-align: left; margin-bottom: 16px; + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--vscode-chat-requestBorder); } -.interactive-item-container .value .rendered-markdown table, .interactive-item-container .value .rendered-markdown table td, .interactive-item-container .value .rendered-markdown table th { border: 1px solid var(--vscode-chat-requestBorder); - border-collapse: collapse; + border-top: none; + border-left: none; padding: 4px 6px; } +.interactive-item-container .value .rendered-markdown table td:last-child, +.interactive-item-container .value .rendered-markdown table th:last-child { + border-right: none; +} + +.interactive-item-container .value .rendered-markdown table tr:last-child td, +.interactive-item-container .value .rendered-markdown table tr:last-child th { + border-bottom: none; +} + .interactive-item-container .value .rendered-markdown a, .interactive-item-container .value .interactive-session-followups, .interactive-item-container .value .rendered-markdown a code { From 272ea031366f6f4dab754d31d274f704d8fdfab7 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:32:00 -0800 Subject: [PATCH 60/65] Set textPreformat.background in dark 2026 theme (#294303) Tweak code backgrounds in markdown --- extensions/theme-2026/themes/2026-dark.json | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 76e870eca8b..2aca9467744 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -15,6 +15,7 @@ "textCodeBlock.background": "#242526", "textLink.foreground": "#48A0C7", "textLink.activeForeground": "#53A5CA", + "textPreformat.background": "#262626", "textPreformat.foreground": "#888888", "textSeparator.foreground": "#2a2a2aFF", "button.background": "#3994BCF2", From cc808c1a4efa182971f1e7e6954e17c0d20821c4 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 11 Feb 2026 03:13:40 +0000 Subject: [PATCH 61/65] Mention "agent" in chat.tools.edits.autoApprove setting (#294327) This is just more clear and returns search results for "agent" --- src/vs/workbench/contrib/chat/browser/chat.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index db362272166..12ed1879857 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -356,7 +356,7 @@ configurationRegistry.registerConfiguration({ '**/*.lock': false, // yarn.lock, bun.lock, etc. '**/*-lock.{yaml,json}': false, // pnpm-lock.yaml, package-lock.json }, - markdownDescription: nls.localize('chat.tools.autoApprove.edits', "Controls whether edits made by chat are automatically approved. The default is to approve all edits except those made to certain files which have the potential to cause immediate unintended side-effects, such as `**/.vscode/*.json`.\n\nSet to `true` to automatically approve edits to matching files, `false` to always require explicit approval. The last pattern matching a given file will determine whether the edit is automatically approved."), + markdownDescription: nls.localize('chat.tools.autoApprove.edits', "Controls whether edits made by the agent are automatically approved. The default is to approve all edits except those made to certain files which have the potential to cause immediate unintended side-effects, such as `**/.vscode/*.json`.\n\nSet to `true` to automatically approve edits to matching files, `false` to always require explicit approval. The last pattern matching a given file will determine whether the edit is automatically approved."), type: 'object', additionalProperties: { type: 'boolean', From a97da020eea0d161405a3e69131232a9d6b72338 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 11 Feb 2026 04:10:37 +0000 Subject: [PATCH 62/65] Add /debug (#294346) --- .../contrib/chat/browser/chat.contribution.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 12ed1879857..f51de3f44f1 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1408,6 +1408,16 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { }, async () => { await instantiationService.invokeFunction(showConfigureHooksQuickPick); })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'debug', + detail: nls.localize('debug', "Show Chat Debug View"), + sortText: 'z3_debug', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('github.copilot.debug.showChatLogView'); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'agents', detail: nls.localize('agents', "Configure custom agents"), From 6ee76b61dea9398190a8f73c4d74aaeda78b16d9 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 11 Feb 2026 07:19:35 +0100 Subject: [PATCH 63/65] update code notifications (#294198) --- .github/CODENOTIFY | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index 9b07c27526f..7aba51a470b 100644 --- a/.github/CODENOTIFY +++ b/.github/CODENOTIFY @@ -65,7 +65,6 @@ src/vs/code/** @bpasero @deepak1556 src/vs/workbench/services/activity/** @bpasero src/vs/workbench/services/authentication/** @TylerLeonhardt src/vs/workbench/services/auxiliaryWindow/** @bpasero -src/vs/workbench/services/chat/** @bpasero src/vs/workbench/services/contextmenu/** @bpasero src/vs/workbench/services/dialogs/** @alexr00 @bpasero src/vs/workbench/services/editor/** @bpasero @@ -100,15 +99,6 @@ src/vs/workbench/electron-browser/** @bpasero src/vs/workbench/contrib/authentication/** @TylerLeonhardt src/vs/workbench/contrib/files/** @bpasero src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @roblourens -src/vs/workbench/contrib/chat/browser/chatSetup/** @bpasero -src/vs/workbench/contrib/chat/browser/chatStatus/** @bpasero -src/vs/workbench/contrib/chat/browser/chatViewPane.ts @bpasero -src/vs/workbench/contrib/chat/browser/media/chatViewPane.css @bpasero -src/vs/workbench/contrib/chat/browser/chatViewTitleControl.ts @bpasero -src/vs/workbench/contrib/chat/browser/media/chatViewTitleControl.css @bpasero -src/vs/workbench/contrib/chat/browser/chatManagement/chatUsageWidget.ts @bpasero -src/vs/workbench/contrib/chat/browser/chatManagement/media/chatUsageWidget.css @bpasero -src/vs/workbench/contrib/chat/browser/agentSessions/** @bpasero src/vs/workbench/contrib/localization/** @TylerLeonhardt src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts @TylerLeonhardt src/vs/workbench/contrib/scm/** @lszomoru From cacbbe30b90c56e1017dafc52cc5f5fbf421ce2c Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 10 Feb 2026 23:24:45 -0800 Subject: [PATCH 64/65] Add support for disabling all hooks and use JSONC (#294234) --- extensions/json/package.json | 3 + .../chatCustomizationDiagnosticsAction.ts | 26 +++- .../chat/browser/promptSyntax/hookUtils.ts | 55 ++++++- .../common/promptSyntax/hookClaudeCompat.ts | 34 ++++- .../common/promptSyntax/hookCompatibility.ts | 59 +++++++- .../chat/common/promptSyntax/promptTypes.ts | 6 +- .../promptSyntax/service/promptsService.ts | 3 +- .../service/promptsServiceImpl.ts | 40 ++++- .../promptSyntax/hookClaudeCompat.test.ts | 142 ++++++++++++------ .../promptSyntax/hookCompatibility.test.ts | 131 ++++++++++++++++ 10 files changed, 424 insertions(+), 75 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts diff --git a/extensions/json/package.json b/extensions/json/package.json index 73265dc5f23..1bc6fa85e53 100644 --- a/extensions/json/package.json +++ b/extensions/json/package.json @@ -70,6 +70,9 @@ ".ember-cli", "typedoc.json" ], + "filenamePatterns": [ + "**/.github/hooks/*.json" + ], "configuration": "./language-configuration.json" }, { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts index e4eb46e3aab..bb72913ae98 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts @@ -325,8 +325,13 @@ async function collectHooksStatus( const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token); const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus); + // Collect URIs of files skipped due to disableAllHooks so we can show their hidden hooks + const disabledFileUris = discoveryInfo.files + .filter(f => f.status === 'skipped' && f.skipReason === 'all-hooks-disabled') + .map(f => f.uri); + // Parse hook files to extract individual hooks grouped by lifecycle - const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token); + const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token, disabledFileUris); return { type, paths, files, enabled, parsedHooks }; } @@ -341,7 +346,8 @@ async function parseHookFiles( pathService: IPathService, workspaceContextService: IWorkspaceContextService, remoteAgentService: IRemoteAgentService, - token: CancellationToken + token: CancellationToken, + additionalDisabledFileUris?: URI[] ): Promise { // Get workspace root and user home for path resolution const workspaceFolder = workspaceContextService.getWorkspace().folders[0]; @@ -354,7 +360,7 @@ async function parseHookFiles( const targetOS = remoteEnv?.os ?? OS; // Use the shared helper - return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, targetOS, token); + return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, targetOS, token, { additionalDisabledFileUris }); } /** @@ -442,6 +448,8 @@ function getSkipReasonMessage(skipReason: PromptFileSkipReason | undefined, erro return errorMessage ?? nls.localize('status.parseError', 'Parse error'); case 'disabled': return nls.localize('status.typeDisabled', 'Disabled'); + case 'all-hooks-disabled': + return nls.localize('status.allHooksDisabled', 'All hooks disabled via disableAllHooks'); default: return errorMessage ?? nls.localize('status.unknownError', 'Unknown error'); } @@ -735,16 +743,22 @@ export function formatStatusOutput( const fileHooks = hooksByFile.get(fileKey)!; const firstHook = fileHooks[0]; const filePath = getRelativePath(firstHook.fileUri, workspaceFolders); + const fileDisabled = fileHooks[0].disabled; - // File as clickable link - lines.push(`[${firstHook.filePath}](${filePath})
`); + // File as clickable link, with note if hooks are disabled via flag + if (fileDisabled) { + lines.push(`[${firstHook.filePath}](${filePath}) - *${nls.localize('status.allHooksDisabledLabel', 'all hooks disabled via disableAllHooks')}*
`); + } else { + lines.push(`[${firstHook.filePath}](${filePath})
`); + } // Flatten hooks with their lifecycle label for (let i = 0; i < fileHooks.length; i++) { const hook = fileHooks[i]; const isLast = i === fileHooks.length - 1; const prefix = isLast ? TREE_END : TREE_BRANCH; - lines.push(`${prefix} ${hook.hookTypeLabel}: \`${hook.commandLabel}\`
`); + const disabledPrefix = hook.disabled ? `${ICON_ERROR} ` : ''; + lines.push(`${prefix} ${disabledPrefix}${hook.hookTypeLabel}: \`${hook.commandLabel}\`
`); } } hasContent = true; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts index 87acdace945..e6dd6668f35 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { findNodeAtLocation, Node, parseTree } from '../../../../../base/common/json.js'; +import { findNodeAtLocation, Node, parse as parseJSONC, parseTree } from '../../../../../base/common/json.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; import { URI } from '../../../../../base/common/uri.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; @@ -11,7 +11,7 @@ import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { formatHookCommandLabel, HOOK_TYPES, HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; -import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { parseHooksFromFile, parseHooksIgnoringDisableAll } from '../../common/promptSyntax/hookCompatibility.js'; import * as nls from '../../../../../nls.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { OperatingSystem } from '../../../../../base/common/platform.js'; @@ -126,6 +126,13 @@ export interface IParsedHook { index: number; /** The original hook type ID as it appears in the JSON file */ originalHookTypeId: string; + /** If true, this hook is disabled via `disableAllHooks: true` in its file */ + disabled?: boolean; +} + +export interface IParseAllHookFilesOptions { + /** Additional file URIs to parse (e.g., files skipped due to disableAllHooks) */ + additionalDisabledFileUris?: readonly URI[]; } /** @@ -139,7 +146,8 @@ export async function parseAllHookFiles( workspaceRootUri: URI | undefined, userHome: string, os: OperatingSystem, - token: CancellationToken + token: CancellationToken, + options?: IParseAllHookFilesOptions ): Promise { const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, token); const parsedHooks: IParsedHook[] = []; @@ -147,7 +155,7 @@ export async function parseAllHookFiles( for (const hookFile of hookFiles) { try { const content = await fileService.readFile(hookFile.uri); - const json = JSON.parse(content.value.toString()); + const json = parseJSONC(content.value.toString()); // Use format-aware parsing const { hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); @@ -179,5 +187,44 @@ export async function parseAllHookFiles( } } + // Parse additional disabled files (e.g., files with disableAllHooks: true) + // These are parsed ignoring the disableAllHooks flag so we can show their hooks as disabled + if (options?.additionalDisabledFileUris) { + for (const uri of options.additionalDisabledFileUris) { + try { + const content = await fileService.readFile(uri); + const json = parseJSONC(content.value.toString()); + + // Parse hooks ignoring disableAllHooks - use the underlying format parsers directly + const { hooks } = parseHooksIgnoringDisableAll(uri, json, workspaceRootUri, userHome); + + for (const [hookType, { hooks: commands, originalId }] of hooks) { + const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + if (!hookTypeMeta) { + continue; + } + + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)'); + parsedHooks.push({ + hookType, + hookTypeLabel: hookTypeMeta.label, + command, + commandLabel, + fileUri: uri, + filePath: labelService.getUriLabel(uri, { relative: true }), + index: i, + originalHookTypeId: originalId, + disabled: true + }); + } + } + } catch (error) { + console.error('Failed to read or parse disabled hook file', uri.toString(), error); + } + } + } + return parsedHooks; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index 5f2079ea401..c159acfa4c3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -53,6 +53,20 @@ export function getClaudeHookTypeName(hookType: HookType): string | undefined { return getHookTypeToClaudeNameMap().get(hookType); } +/** + * Result of parsing Claude hooks file. + */ +export interface IParseClaudeHooksResult { + /** + * The parsed hooks by type. + */ + readonly hooks: Map; + /** + * Whether all hooks from this file were disabled via `disableAllHooks: true`. + */ + readonly disabledAllHooks: boolean; +} + /** * Parses hooks from a Claude settings.json file. * Claude format: @@ -70,23 +84,31 @@ export function getClaudeHookTypeName(hookType: HookType): string | undefined { * "PreToolUse": [{ "type": "command", "command": "..." }] * } * } + * + * If the file has `disableAllHooks: true` at the top level, all hooks are filtered out. */ export function parseClaudeHooks( json: unknown, workspaceRootUri: URI | undefined, userHome: string -): Map { +): IParseClaudeHooksResult { const result = new Map(); if (!json || typeof json !== 'object') { - return result; + return { hooks: result, disabledAllHooks: false }; } const root = json as Record; + + // Check for disableAllHooks property at the top level + if (root.disableAllHooks === true) { + return { hooks: result, disabledAllHooks: true }; + } + const hooks = root.hooks; if (!hooks || typeof hooks !== 'object') { - return result; + return { hooks: result, disabledAllHooks: false }; } const hooksObj = hooks as Record; @@ -140,7 +162,7 @@ export function parseClaudeHooks( } } - return result; + return { hooks: result, disabledAllHooks: false }; } /** @@ -158,7 +180,5 @@ function resolveClaudeCommand( return undefined; } - // Add type if missing for resolveHookCommand - const normalized = { ...raw, type: 'command' }; - return resolveHookCommand(normalized, workspaceRootUri, userHome); + return resolveHookCommand(raw, workspaceRootUri, userHome); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index 1525bbb59e8..6bdf4afdc89 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -111,6 +111,18 @@ export function parseCopilotHooks( return result; } +/** + * Result of parsing hooks from a file. + */ +export interface IParseHooksFromFileResult { + readonly format: HookSourceFormat; + readonly hooks: Map; + /** + * Whether all hooks from this file were disabled via `disableAllHooks: true`. + */ + readonly disabledAllHooks: boolean; +} + /** * Parses hooks from any supported format, auto-detecting the format from the file URI. */ @@ -119,22 +131,61 @@ export function parseHooksFromFile( json: unknown, workspaceRootUri: URI | undefined, userHome: string -): { format: HookSourceFormat; hooks: Map } { +): IParseHooksFromFileResult { const format = getHookSourceFormat(fileUri); let hooks: Map; + let disabledAllHooks = false; switch (format) { - case HookSourceFormat.Claude: - hooks = parseClaudeHooks(json, workspaceRootUri, userHome); + case HookSourceFormat.Claude: { + const result = parseClaudeHooks(json, workspaceRootUri, userHome); + hooks = result.hooks; + disabledAllHooks = result.disabledAllHooks; break; + } case HookSourceFormat.Copilot: default: hooks = parseCopilotHooks(json, workspaceRootUri, userHome); break; } - return { format, hooks }; + return { format, hooks, disabledAllHooks }; +} + +/** + * Parses hooks from a file, ignoring the `disableAllHooks` flag. + * Used by diagnostics to show which hooks are hidden when `disableAllHooks: true` is set. + */ +export function parseHooksIgnoringDisableAll( + fileUri: URI, + json: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): IParseHooksFromFileResult { + const format = getHookSourceFormat(fileUri); + + let hooks: Map; + + switch (format) { + case HookSourceFormat.Claude: { + // Strip `disableAllHooks` before parsing so the hooks are still extracted + if (json && typeof json === 'object') { + const { disableAllHooks: _, ...rest } = json as Record; + const result = parseClaudeHooks(rest, workspaceRootUri, userHome); + hooks = result.hooks; + } else { + hooks = new Map(); + } + break; + } + case HookSourceFormat.Copilot: + default: + hooks = parseCopilotHooks(json, workspaceRootUri, userHome); + break; + } + + return { format, hooks, disabledAllHooks: true }; } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index 4d588cc1cfd..8c4d0cbc58a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -54,8 +54,8 @@ export function getLanguageIdForPromptsType(type: PromptsType): string { case PromptsType.skill: return SKILL_LANGUAGE_ID; case PromptsType.hook: - // Hooks use JSON syntax with schema validation - return 'json'; + // Hooks use JSONC syntax with schema validation + return 'jsonc'; default: throw new Error(`Unknown prompt type: ${type}`); } @@ -71,7 +71,7 @@ export function getPromptsTypeForLanguageId(languageId: string): PromptsType | u return PromptsType.agent; case SKILL_LANGUAGE_ID: return PromptsType.skill; - // Note: hook uses 'json' language ID which is shared, so we don't map it here + // Note: hook uses 'jsonc' language ID which is shared, so we don't map it here default: return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 47b3e64e180..f59d436d4fd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -273,7 +273,8 @@ export type PromptFileSkipReason = | 'name-mismatch' | 'duplicate-name' | 'parse-error' - | 'disabled'; + | 'disabled' + | 'all-hooks-disabled'; /** * Result of discovering a single prompt file. 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 c8196999288..7d4cb5cc352 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { parse as parseJSONC } from '../../../../../../base/common/json.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { basename, dirname, isEqual, joinPath } from '../../../../../../base/common/resources.js'; @@ -1030,10 +1031,16 @@ export class PromptsService extends Disposable implements IPromptsService { for (const hookFile of hookFiles) { try { const content = await this.fileService.readFile(hookFile.uri); - const json = JSON.parse(content.value.toString()); + const json = parseJSONC(content.value.toString()); - // Use format-aware parsing that handles Copilot, Claude, and Cursor formats - const { format, hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + // Use format-aware parsing that handles Copilot and Claude formats + const { format, hooks, disabledAllHooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + + // Skip files that have all hooks disabled + if (disabledAllHooks) { + this.logger.trace(`[PromptsService] Skipping hook file with disableAllHooks: ${hookFile.uri}`); + continue; + } for (const [hookType, { hooks: commands }] of hooks) { for (const command of commands) { @@ -1304,6 +1311,14 @@ export class PromptsService extends Disposable implements IPromptsService { private async getHookDiscoveryInfo(token: CancellationToken): Promise { const files: IPromptFileDiscoveryResult[] = []; + // Get user home for tilde expansion + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + // Get workspace root for resolving relative cwd paths + const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + const hookFiles = await this.listPromptFiles(PromptsType.hook, token); for (const promptPath of hookFiles) { const uri = promptPath.uri; @@ -1312,9 +1327,9 @@ export class PromptsService extends Disposable implements IPromptsService { const name = basename(uri); try { - // Try to parse the JSON to validate it + // Try to parse the JSON to validate it (supports JSONC with comments) const content = await this.fileService.readFile(uri); - const json = JSON.parse(content.value.toString()); + const json = parseJSONC(content.value.toString()); // Validate it's an object if (!json || typeof json !== 'object') { @@ -1330,6 +1345,21 @@ export class PromptsService extends Disposable implements IPromptsService { continue; } + // Use format-aware parsing to check for disabledAllHooks + const { disabledAllHooks } = parseHooksFromFile(uri, json, workspaceRootUri, userHome); + + if (disabledAllHooks) { + files.push({ + uri, + storage, + status: 'skipped', + skipReason: 'all-hooks-disabled', + name, + extensionId + }); + continue; + } + // File is valid files.push({ uri, storage, status: 'loaded', name, extensionId }); } catch (e) { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 84aa27f5722..6f852ed7285 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -57,9 +57,10 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - assert.strictEqual(result.size, 1); - assert.ok(result.has(HookType.PreToolUse)); - const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + assert.ok(result.hooks.has(HookType.PreToolUse)); + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.originalId, 'PreToolUse'); assert.strictEqual(entry.hooks.length, 1); assert.strictEqual(entry.hooks[0].command, 'echo "pre-tool"'); @@ -75,9 +76,9 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - assert.strictEqual(result.size, 2); - assert.ok(result.has(HookType.SessionStart)); - assert.ok(result.has(HookType.Stop)); + assert.strictEqual(result.hooks.size, 2); + assert.ok(result.hooks.has(HookType.SessionStart)); + assert.ok(result.hooks.has(HookType.Stop)); }); test('parses multiple commands for same hook type', () => { @@ -92,13 +93,62 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 2); assert.strictEqual(entry.hooks[0].command, 'echo "first"'); assert.strictEqual(entry.hooks[1].command, 'echo "second"'); }); }); + suite('disableAllHooks', () => { + test('returns empty hooks and disabledAllHooks=true when disableAllHooks is true', () => { + const json = { + disableAllHooks: true, + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "should be ignored"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.disabledAllHooks, true); + assert.strictEqual(result.hooks.size, 0); + }); + + test('parses hooks normally when disableAllHooks is false', () => { + const json = { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "should be parsed"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + }); + + test('parses hooks normally when disableAllHooks is not present', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "should be parsed"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + }); + }); + suite('nested hooks with matchers', () => { test('parses nested hooks with matcher', () => { const json = { @@ -116,7 +166,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 1); assert.strictEqual(entry.hooks[0].command, 'echo "bash hook"'); }); @@ -138,7 +188,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 2); }); @@ -160,7 +210,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 2); assert.strictEqual(entry.hooks[0].command, 'echo "bash"'); assert.strictEqual(entry.hooks[1].command, 'echo "write"'); @@ -181,55 +231,42 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 2); assert.strictEqual(entry.hooks[0].command, 'echo "direct"'); assert.strictEqual(entry.hooks[1].command, 'echo "nested"'); }); }); - suite('command without type field', () => { - test('parses command without explicit type field', () => { - const json = { - hooks: { - PreToolUse: [ - { command: 'echo "no type"' } - ] - } - }; - - const result = parseClaudeHooks(json, workspaceRoot, userHome); - - const entry = result.get(HookType.PreToolUse)!; - assert.strictEqual(entry.hooks.length, 1); - assert.strictEqual(entry.hooks[0].command, 'echo "no type"'); - }); - }); - suite('invalid inputs', () => { test('returns empty map for null json', () => { const result = parseClaudeHooks(null, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('returns empty map for undefined json', () => { const result = parseClaudeHooks(undefined, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('returns empty map for non-object json', () => { const result = parseClaudeHooks('string', workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('returns empty map for missing hooks property', () => { const result = parseClaudeHooks({}, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('returns empty map for non-object hooks property', () => { const result = parseClaudeHooks({ hooks: 'invalid' }, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('skips unknown hook types', () => { @@ -242,8 +279,8 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - assert.strictEqual(result.size, 1); - assert.ok(result.has(HookType.PreToolUse)); + assert.strictEqual(result.hooks.size, 1); + assert.ok(result.hooks.has(HookType.PreToolUse)); }); test('skips non-array hook entries', () => { @@ -255,7 +292,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); }); test('skips invalid command entries', () => { @@ -271,7 +308,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 1); assert.strictEqual(entry.hooks[0].command, 'valid'); }); @@ -288,7 +325,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 1); assert.strictEqual(entry.hooks[0].command, 'valid'); }); @@ -306,7 +343,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.deepStrictEqual(entry.hooks[0].cwd, URI.file('/workspace/src')); }); @@ -321,7 +358,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.deepStrictEqual(entry.hooks[0].env, { NODE_ENV: 'production' }); }); @@ -336,9 +373,24 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks[0].timeout, 60); }); + + test('supports Claude timeout alias', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"', timeout: 1 } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.hooks.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks[0].timeout, 1); + }); }); }); }); @@ -432,9 +484,9 @@ suite('HookSourceFormat', () => { }; const result = parseClaudeHooks(hooksContent, URI.file('/workspace'), '/home/user'); - assert.strictEqual(result.size, 1); - assert.ok(result.has(HookType.PreToolUse)); - const hooks = result.get(HookType.PreToolUse)!; + assert.strictEqual(result.hooks.size, 1); + assert.ok(result.hooks.has(HookType.PreToolUse)); + const hooks = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(hooks.hooks.length, 1); // Empty command string is falsy and gets omitted by resolveHookCommand assert.strictEqual(hooks.hooks[0].command, undefined); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts new file mode 100644 index 00000000000..7d4ba6ffe51 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.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 assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { HookType } from '../../../common/promptSyntax/hookSchema.js'; +import { parseCopilotHooks, parseHooksFromFile, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; +import { URI } from '../../../../../../base/common/uri.js'; + +suite('HookCompatibility', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('parseCopilotHooks', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + suite('basic parsing', () => { + test('parses simple hook with command', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "pre-tool"' } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "pre-tool"'); + }); + }); + + suite('invalid inputs', () => { + test('returns empty result for null json', () => { + const result = parseCopilotHooks(null, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty result for undefined json', () => { + const result = parseCopilotHooks(undefined, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty result for missing hooks property', () => { + const result = parseCopilotHooks({}, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + }); + }); + + suite('parseHooksFromFile', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + test('uses Copilot format for .github/hooks/*.json files', () => { + const fileUri = URI.file('/workspace/.github/hooks/my-hooks.json'); + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"' } + ] + } + }; + + const result = parseHooksFromFile(fileUri, json, workspaceRoot, userHome); + + assert.strictEqual(result.format, HookSourceFormat.Copilot); + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + }); + + test('uses Claude format for .claude/settings.json files', () => { + const fileUri = URI.file('/workspace/.claude/settings.json'); + const json = { + disableAllHooks: true, + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"' } + ] + } + }; + + const result = parseHooksFromFile(fileUri, json, workspaceRoot, userHome); + + assert.strictEqual(result.format, HookSourceFormat.Claude); + assert.strictEqual(result.disabledAllHooks, true); + assert.strictEqual(result.hooks.size, 0); + }); + + test('disableAllHooks is ignored for Copilot format', () => { + const fileUri = URI.file('/workspace/.github/hooks/hooks.json'); + const json = { + disableAllHooks: true, + hooks: { + SessionStart: [ + { type: 'command', command: 'echo "start"' } + ] + } + }; + + const result = parseHooksFromFile(fileUri, json, workspaceRoot, userHome); + + // Copilot format does not support disableAllHooks + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + }); + + test('disabledAllHooks works for Claude format', () => { + const fileUri = URI.file('/workspace/.claude/settings.local.json'); + const json = { + disableAllHooks: true, + hooks: { + SessionStart: [ + { type: 'command', command: 'echo "start"' } + ] + } + }; + + const result = parseHooksFromFile(fileUri, json, workspaceRoot, userHome); + + assert.strictEqual(result.disabledAllHooks, true); + assert.strictEqual(result.hooks.size, 0); + }); + }); +}); From 456a429c0a1e7f38ce39c9a6d869eddd7720a73e Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:35:35 -0800 Subject: [PATCH 65/65] fix for moving hooks to extension (#294441) * fix for moving hooks to extension * fix hygiene * address comments --- .../tools/languageModelToolsService.ts | 40 +++++++++- .../chatContentParts/chatHookContentPart.ts | 23 ++++-- .../chatSubagentContentPart.ts | 76 ++++++++++++++++++- .../chatThinkingContentPart.ts | 18 ++++- .../media/chatHookContentPart.css | 25 +++++- .../chat/browser/widget/chatListRenderer.ts | 64 ++++++++++++++-- .../chat/common/chatService/chatService.ts | 4 + .../tools/builtinTools/runSubagentTool.ts | 27 +++++-- 8 files changed, 251 insertions(+), 26 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 4bc871cae89..ce54a6de5f7 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -47,7 +47,7 @@ import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToo import { chatSessionResourceToId } from '../../common/model/chatUri.js'; import { HookType } from '../../common/promptSyntax/hookSchema.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; +import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolInvocationPresentation, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; const jsonSchemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); @@ -377,6 +377,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo if (toolData) { if (pendingInvocation) { + pendingInvocation.presentation = ToolInvocationPresentation.Hidden; pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); } else if (request) { const cancelledInvocation = ChatToolInvocation.createCancelled( @@ -385,6 +386,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo ToolConfirmKind.Denied, reason ); + cancelledInvocation.presentation = ToolInvocationPresentation.Hidden; this._chatService.appendProgress(request, cancelledInvocation); } } @@ -726,17 +728,49 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } const fullReferenceName = getToolFullReferenceName(tool.data); const hookReason = hookResult.permissionDecisionReason; - const baseMessage = localize('hookRequiresConfirmation.message', "{0} hook confirmation required", HookType.PreToolUse); + const hookNote = hookReason + ? localize('hookRequiresConfirmation.messageWithReason', "{0} hook required confirmation: {1}", HookType.PreToolUse, hookReason) + : localize('hookRequiresConfirmation.message', "{0} hook required confirmation", HookType.PreToolUse); preparedInvocation.confirmationMessages = { ...preparedInvocation.confirmationMessages, title: localize('hookRequiresConfirmation.title', "Use the '{0}' tool?", fullReferenceName), - message: new MarkdownString(hookReason ? `${baseMessage}\n\n${hookReason}` : baseMessage), + message: new MarkdownString(`_${hookNote}_`), allowAutoConfirm: false, }; preparedInvocation.toolSpecificData = { kind: 'input', rawInput: dto.parameters, }; + } else { + // Tool already has its own confirmation - prepend hook note + const hookReason = hookResult.permissionDecisionReason; + const hookNote = hookReason + ? localize('hookRequiresConfirmation.note', "{0} hook required confirmation: {1}", HookType.PreToolUse, hookReason) + : localize('hookRequiresConfirmation.noteNoReason', "{0} hook required confirmation", HookType.PreToolUse); + + const existing = preparedInvocation.confirmationMessages!; + if (preparedInvocation.toolSpecificData?.kind === 'terminal') { + // Terminal tools render message as hover only; use disclaimer for visible text + const existingDisclaimerText = existing.disclaimer + ? (typeof existing.disclaimer === 'string' ? existing.disclaimer : existing.disclaimer.value) + : undefined; + const combinedDisclaimer = existingDisclaimerText + ? `${hookNote}\n\n${existingDisclaimerText}` + : hookNote; + preparedInvocation.confirmationMessages = { + ...existing, + disclaimer: combinedDisclaimer, + allowAutoConfirm: false, + }; + } else { + // Edit/other tools: prepend hook note to the message body + const msgText = typeof existing.message === 'string' ? existing.message : existing.message?.value ?? ''; + preparedInvocation.confirmationMessages = { + ...existing, + message: new MarkdownString(`_${hookNote}_\n\n${msgText}`), + allowAutoConfirm: false, + }; + } } return { autoConfirmed: undefined, preparedInvocation }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts index 464f0791383..a9f8451881a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts @@ -9,7 +9,7 @@ import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IChatHookPart } from '../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; -import { HOOK_TYPES, HookTypeValue } from '../../../common/promptSyntax/hookSchema.js'; +import { HookType, HOOK_TYPES, HookTypeValue } from '../../../common/promptSyntax/hookSchema.js'; import { ChatTreeItem } from '../../chat.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; @@ -29,16 +29,23 @@ export class ChatHookContentPart extends ChatCollapsibleContentPart implements I const hookTypeLabel = getHookTypeLabel(hookPart.hookType); const isStopped = !!hookPart.stopReason; const isWarning = !!hookPart.systemMessage; + const toolName = hookPart.toolDisplayName; const title = isStopped - ? localize('hook.title.stopped', "Blocked by {0} hook", hookTypeLabel) - : localize('hook.title.warning', "Warning from {0} hook", hookTypeLabel); + ? (toolName + ? localize('hook.title.stoppedWithTool', "Blocked {0} - {1} hook", toolName, hookTypeLabel) + : localize('hook.title.stopped', "Blocked by {0} hook", hookTypeLabel)) + : (toolName + ? localize('hook.title.warningWithTool', "Warning for {0} - {1} hook", toolName, hookTypeLabel) + : localize('hook.title.warning', "Warning from {0} hook", hookTypeLabel)); super(title, context, undefined, hoverService); - this.icon = isStopped ? Codicon.circleSlash : isWarning ? Codicon.warning : Codicon.check; + this.icon = isStopped ? Codicon.error : isWarning ? Codicon.warning : Codicon.check; if (isStopped) { this.domNode.classList.add('chat-hook-outcome-blocked'); + } else if (isWarning) { + this.domNode.classList.add('chat-hook-outcome-warning'); } this.setExpanded(false); @@ -50,7 +57,10 @@ export class ChatHookContentPart extends ChatCollapsibleContentPart implements I if (this.hookPart.stopReason) { const reasonElement = $('.chat-hook-reason', undefined, this.hookPart.stopReason); content.appendChild(reasonElement); - } else if (this.hookPart.systemMessage) { + } + + const isToolHook = this.hookPart.hookType === HookType.PreToolUse || this.hookPart.hookType === HookType.PostToolUse; + if (this.hookPart.systemMessage && (isToolHook || !this.hookPart.stopReason)) { const messageElement = $('.chat-hook-message', undefined, this.hookPart.systemMessage); content.appendChild(messageElement); } @@ -64,6 +74,7 @@ export class ChatHookContentPart extends ChatCollapsibleContentPart implements I } return other.hookType === this.hookPart.hookType && other.stopReason === this.hookPart.stopReason && - other.systemMessage === this.hookPart.systemMessage; + other.systemMessage === this.hookPart.systemMessage && + other.toolDisplayName === this.hookPart.toolDisplayName; } } 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 86a60cfacfd..5e03ac8c247 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -16,7 +16,7 @@ import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import { IChatHookPart, IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; import { IRunSubagentToolInputParams } from '../../../common/tools/builtinTools/runSubagentTool.js'; import { CodeBlockModelCollection } from '../../../common/widget/codeBlockModelCollection.js'; @@ -51,7 +51,16 @@ interface ILazyMarkdownItem { lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; } -type ILazyItem = ILazyToolItem | ILazyMarkdownItem; +/** + * Represents a lazy hook item (blocked/warning) that will be rendered when expanded. + */ +interface ILazyHookItem { + kind: 'hook'; + lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; + hookPart: IChatHookPart; +} + +type ILazyItem = ILazyToolItem | ILazyMarkdownItem | ILazyHookItem; /** * This is generally copied from ChatThinkingContentPart. We are still experimenting with both UIs so I'm not @@ -587,6 +596,58 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } } + /** + * Appends a hook item (blocked/warning) to the subagent content part. + */ + public appendHookItem( + factory: () => { domNode: HTMLElement; disposable?: IDisposable }, + hookPart: IChatHookPart + ): void { + if (this.isExpanded() || this.hasExpandedOnce) { + const result = factory(); + this.appendHookItemToDOM(result.domNode, hookPart); + if (result.disposable) { + this._register(result.disposable); + } + } else { + const item: ILazyHookItem = { + kind: 'hook', + lazy: new Lazy(factory), + hookPart, + }; + this.lazyItems.push(item); + } + } + + /** + * Appends a hook item's DOM node to the wrapper. + */ + private appendHookItemToDOM(domNode: HTMLElement, hookPart: IChatHookPart): void { + const itemWrapper = $('.chat-thinking-tool-wrapper'); + const icon = hookPart.stopReason ? Codicon.error : Codicon.warning; + const iconElement = createThinkingIcon(icon); + itemWrapper.appendChild(iconElement); + itemWrapper.appendChild(domNode); + + // Treat hook items as tool items for visibility purposes + if (!this.hasToolItems) { + this.hasToolItems = true; + if (this.wrapper) { + this.wrapper.style.display = ''; + } + } + + if (this.wrapper) { + if (this.resultContainer) { + this.wrapper.insertBefore(itemWrapper, this.resultContainer); + } else { + this.wrapper.appendChild(itemWrapper); + } + } + this.lastItemWrapper = itemWrapper; + this.layoutScheduler.schedule(); + } + /** * Appends a markdown item's DOM node to the wrapper. */ @@ -705,6 +766,12 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen if (result.disposable) { this._register(result.disposable); } + } else if (item.kind === 'hook') { + const result = item.lazy.value; + this.appendHookItemToDOM(result.domNode, item.hookPart); + if (result.disposable) { + this._register(result.disposable); + } } } @@ -759,6 +826,11 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen return true; } + // Match hook parts with the same subAgentInvocationId to keep them grouped in the subagent dropdown + if (other.kind === 'hook' && other.subAgentInvocationId) { + return this.subAgentInvocationId === other.subAgentInvocationId; + } + // Match subagent tool invocations with the same subAgentInvocationId to keep them grouped if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || ChatSubagentContentPart.isParentSubagentTool(other))) { // For parent subagent tool, use toolCallId as the effective ID 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 ce9c70c41d7..4f926a701d4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -765,6 +765,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen - For reasoning/thinking: "Considered", "Planned", "Analyzed", "Reviewed", "Evaluated" - Choose the synonym that best fits the context + PRIORITY RULE - BLOCKED/DENIED CONTENT: + - If any item mentions being "blocked" (e.g. "Tried to use X, but was blocked"), it MUST be reflected in the title + - Blocked content takes priority over all other tool calls + - Use natural phrasing like "Tried to , but was blocked" or "Attempted but was denied" + - If there are both blocked items AND normal tool calls, mention both: e.g. "Tried to run terminal but was blocked, edited file.ts" + RULES FOR TOOL CALLS: 1. If the SAME file was both edited AND read: Use a combined phrase like "Reviewed and updated " 2. If exactly ONE file was edited: Start with an edit synonym + "" (include actual filename) @@ -804,6 +810,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen - "Edited Button.tsx, Edited Button.css, Edited index.ts" → "Modified 3 files" - "Searched codebase for error handling" → "Looked up error handling" + EXAMPLES WITH BLOCKED CONTENT: + - "Tried to use Run in Terminal, but was blocked" → "Tried to run command, but was blocked" + - "Tried to use Run in Terminal, but was blocked, Edited config.ts" → "Tried to run command but was blocked, edited config.ts" + - "Tried to use Edit File, but was blocked, Tried to use Run in Terminal, but was blocked" → "Tried to use 2 tools, but was blocked" + - "Used Read File, but received a warning, Edited utils.ts" → "Read file with a warning, edited utils.ts" + EXAMPLES WITH REASONING HEADERS (no tools): - "Analyzing component architecture" → "Considered component architecture" - "Planning refactor strategy" → "Planned refactor strategy" @@ -1160,7 +1172,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen toolCallLabel = localize('chat.thinking.editingFile', 'Edited file'); } } else { - toolCallLabel = `Invoked \`${toolInvocationId}\``; + toolCallLabel = toolInvocationId; } // Add tool call to extracted titles for LLM title generation @@ -1207,6 +1219,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number } }; const exitCode = terminalData?.terminalCommandState?.exitCode; icon = exitCode !== undefined && exitCode !== 0 ? Codicon.error : 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; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css index c06e49192d5..3a30dc1e68a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css @@ -7,6 +7,17 @@ color: var(--vscode-notificationsWarningIcon-foreground) !important; } +.chat-thinking-box .chat-used-context.chat-hook-outcome-blocked, +.chat-thinking-box .chat-used-context.chat-hook-outcome-warning { + padding: 4px 12px 4px 22px; + margin-bottom: 0; +} + +.chat-thinking-box .chat-used-context.chat-hook-outcome-blocked > .chat-used-context-label .codicon, +.chat-thinking-box .chat-used-context.chat-hook-outcome-warning > .chat-used-context-label .codicon { + display: none; +} + .chat-hook-details { display: flex; flex-direction: column; @@ -14,8 +25,20 @@ padding: 8px 12px; } -.chat-hook-message, .chat-hook-reason { +.chat-hook-reason { + font-size: var(--vscode-chat-font-size-body-s); + padding: 4px 10px; +} + +.chat-hook-message { font-size: var(--vscode-chat-font-size-body-s); padding: 4px 10px; color: var(--vscode-descriptionForeground); } + +/* When both reason and message are shown, add a subtle separator */ +.chat-hook-reason + .chat-hook-message { + border-top: 1px solid var(--vscode-chat-requestBorder, var(--vscode-editorWidget-border)); + margin-top: 2px; + padding-top: 6px; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 38d1242b302..57267238df8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -108,6 +108,7 @@ import { IChatTipService } from '../chatTipService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; +import { HookType } from '../../common/promptSyntax/hookSchema.js'; const $ = dom.$; @@ -1226,7 +1227,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinking.collapsedTools'); @@ -1428,6 +1430,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer other.kind === 'hook' && other.hookType === hookPart.hookType); } - return this.renderNoContent(other => other.kind === 'hook' && other.hookType === hookPart.hookType); + + if (hookPart.subAgentInvocationId) { + const subagentPart = this.getSubagentPart(templateData.renderedParts, hookPart.subAgentInvocationId); + if (subagentPart) { + subagentPart.appendHookItem(() => { + const part = this.instantiationService.createInstance(ChatHookContentPart, hookPart, context); + return { domNode: part.domNode, disposable: part }; + }, hookPart); + return this.renderNoContent(other => other.kind === 'hook' && other.hookType === hookPart.hookType && other.subAgentInvocationId === hookPart.subAgentInvocationId); + } + } + + // Only pin preTool/postTool hooks into the thinking part + const shouldPinToThinking = hookPart.hookType === HookType.PreToolUse || hookPart.hookType === HookType.PostToolUse; + if (shouldPinToThinking) { + const hookTitle = hookPart.stopReason + ? (hookPart.toolDisplayName + ? localize('hook.thinking.blocked', "Blocked {0}", hookPart.toolDisplayName) + : localize('hook.thinking.blockedGeneric', "Blocked by hook")) + : (hookPart.toolDisplayName + ? localize('hook.thinking.warning', "Used {0}, but received a warning", hookPart.toolDisplayName) + : localize('hook.thinking.warningGeneric', "Tool call received a warning")); + + let thinkingPart = this.getLastThinkingPart(templateData.renderedParts); + if (!thinkingPart) { + // Create a thinking part if one doesn't exist yet (e.g. hook arrives before/with its tool in the same turn) + const newThinking = this.renderThinkingPart({ kind: 'thinking' }, context, templateData); + if (newThinking instanceof ChatThinkingContentPart) { + thinkingPart = newThinking; + } + } + + if (thinkingPart) { + thinkingPart.appendItem(() => { + const part = this.instantiationService.createInstance(ChatHookContentPart, hookPart, context); + return { domNode: part.domNode, disposable: part }; + }, hookTitle, undefined, templateData.value); + return thinkingPart; + } + } + + const part = this.instantiationService.createInstance(ChatHookContentPart, hookPart, context); + return part; } private renderPullRequestContent(pullRequestContent: IChatPullRequestContent, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): IChatContentPart | undefined { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 657c6d6ac1e..47c0fc70084 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -434,7 +434,11 @@ export interface IChatHookPart { stopReason?: string; /** Warning/system message from the hook, shown to the user */ systemMessage?: string; + /** Display name of the tool that was affected by the hook */ + toolDisplayName?: string; metadata?: { readonly [key: string]: unknown }; + /** If set, this hook was executed within a subagent invocation and should be grouped with it. */ + subAgentInvocationId?: string; } export interface IChatTerminalToolInvocationData { 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 d1f19a0f152..d8784ef6dd7 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -7,38 +7,39 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { generateUuid } from '../../../../../../base/common/uuid.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../nls.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; -import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; -import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js'; +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 { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; +import { IChatRequestHooks } from '../../promptSyntax/hookSchema.js'; +import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, + isToolSet, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, - isToolSet, ToolDataSource, ToolProgress, VSCodeToolReference, } from '../languageModelToolsService.js'; -import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ManageTodoListToolToolId } from './manageTodoListTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; -import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. @@ -222,6 +223,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } else { model.acceptResponseProgress(request, part); } + } else if (part.kind === 'hook') { + model.acceptResponseProgress(request, { ...part, subAgentInvocationId }); } else if (part.kind === 'markdownContent') { if (inEdit) { model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n') }); @@ -244,6 +247,14 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined); // agents can not call subagents await computer.collect(variableSet, token); + // Collect hooks from hook .json files + let collectedHooks: IChatRequestHooks | undefined; + try { + collectedHooks = await this.promptsService.getHooks(token); + } catch (error) { + this.logService.warn('[ChatService] Failed to collect hooks:', error); + } + // Build the agent request const agentRequest: IChatAgentRequest = { sessionResource: invocation.context.sessionResource, @@ -258,6 +269,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { userSelectedTools: modeTools, modeInstructions, parentRequestId: invocation.chatRequestId, + hooks: collectedHooks, + hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), }; // Subscribe to tool invocations to clear markdown parts when a tool is invoked