diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c876d2a3782be..56cd6e6ba2eb4 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -212,7 +212,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -223,7 +223,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -232,7 +232,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index df6ab20e586b5..7922ec107f90a 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -258,7 +258,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -269,7 +269,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -278,7 +278,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index bd4a62d42fa26..2bde317b4806a 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -249,7 +249,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -260,7 +260,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -269,7 +269,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index decfcf2a6f84d..9c91702bccb89 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -73,14 +73,14 @@ jobs: fi - name: Upload explorer artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: component-explorer path: /tmp/explorer-artifact/ - name: Upload screenshot report if: steps.compare.outcome == 'failure' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: screenshot-diff path: | diff --git a/build/package-lock.json b/build/package-lock.json index ec46db00b08cd..3dd620ab59eff 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -2318,9 +2318,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -4526,9 +4526,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/extensions/CONTRIBUTING.md b/extensions/CONTRIBUTING.md new file mode 100644 index 0000000000000..cfaf6b0ca8dc1 --- /dev/null +++ b/extensions/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to Built-In Extensions + +This directory contains built-in extensions that ship with VS Code. + +## Basic Structure + +A typical TypeScript-based built-in extension has the following structure: + +- `package.json`: extension manifest. +- `src/`: Main directory for TypeScript source code. +- `tsconfig.json`: primary TypeScript config. This should inherit from `tsconfig.base.json`. +- `esbuild.mts`: esbuild build script used for production builds. +- `.vscodeignore`: Ignore file list. You can copy this from an existing extension. + +TypeScript-based extensions have the following output structure: + +- `out`: Output directory for development builds +- `dist`: Output directory for production builds. + + +## Enabling an Extension in the Browser + +By default extensions will only target desktop. To enable an extension in browsers as well: + +- Add a `"browser"` entry in `package.json` pointing to the browser bundle (for example `"./dist/browser/extension"`). +- Add `tsconfig.browser.json` that typechecks only browser-safe sources. +- Add an `esbuild.browser.mts` file. This should set `platform: 'browser'`. + +Make sure the browser build of the extension only uses browser-safe APIs. If an extension needs different behavior between desktop and web, you can create distinct entrypoints for each target: + +- `src/extension.ts`: Desktop entrypoint. +- `src/extension.browser.ts`: Browser entrypoint. Make sure `esbuild.browser.mts` builds this and that `tsconfig.browser.json` targets it. diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index f1b217f5cca39..032cdd12cf34a 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -53,7 +53,7 @@ "badge.background": "#3994BCF0", "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", - "list.activeSelectionBackground": "#3994BC26", + "list.activeSelectionBackground": "#262728", "list.activeSelectionForeground": "#ededed", "list.inactiveSelectionBackground": "#2C2D2E", "list.inactiveSelectionForeground": "#ededed", @@ -153,8 +153,10 @@ "editorGutter.background": "#121314", "editorGutter.addedBackground": "#72C892", "editorGutter.deletedBackground": "#F28772", - "diffEditor.insertedTextBackground": "#72C89233", - "diffEditor.removedTextBackground": "#F2877233", + "diffEditor.insertedLineBackground": "#347d3926", + "diffEditor.insertedTextBackground": "#57ab5a4d", + "diffEditor.removedLineBackground": "#c93c3726", + "diffEditor.removedTextBackground": "#f470674d", "editorOverviewRuler.border": "#2A2B2CFF", "editorOverviewRuler.findMatchForeground": "#3a94bc99", "editorOverviewRuler.modifiedForeground": "#6ab890", @@ -268,369 +270,419 @@ "tokenColors": [ { "scope": [ - "comment" + "comment", + "punctuation.definition.comment", + "string.comment" ], "settings": { - "foreground": "#6F9B60" + "foreground": "#768390" } }, { "scope": [ - "keyword", - "storage.modifier", - "storage.type", - "keyword.operator.new", - "keyword.operator.expression", - "keyword.operator.cast", - "keyword.operator.sizeof", - "keyword.operator.instanceof" + "constant.other.placeholder", + "constant.character" ], "settings": { - "foreground": "#4F8FDD" + "foreground": "#F47067" } }, { "scope": [ - "string" + "constant", + "entity.name.constant", + "variable.other.constant", + "variable.other.enummember", + "variable.language", + "entity" ], "settings": { - "foreground": "#C48081" + "foreground": "#6CB6FF" } }, { - "name": "Language constants", "scope": [ - "constant.language" + "entity.name", + "meta.export.default", + "meta.definition.variable" ], "settings": { - "foreground": "#4F8FDD" + "foreground": "#F69D50" } }, { - "name": "HTML/XML tags", "scope": [ - "entity.name.tag", - "meta.tag.sgml", - "markup.deleted.git_gutter" + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.object.member", + "meta.embedded.expression" ], "settings": { - "foreground": "#4F9BDD" + "foreground": "#ADBAC7" } }, { - "name": "HTML/XML tag punctuation", - "scope": [ - "punctuation.definition.tag.html", - "punctuation.definition.tag.begin.html", - "punctuation.definition.tag.end.html" - ], + "scope": "entity.name.function", "settings": { - "foreground": "#7A828B" + "foreground": "#DCBDFB" } }, { - "name": "HTML/XML attribute names", "scope": [ - "entity.other.attribute-name" + "entity.name.tag", + "support.class.component" ], "settings": { - "foreground": "#90D5FF" + "foreground": "#8DDB8C" } }, { - "name": "Operators", - "scope": [ - "keyword.operator" - ], + "scope": "keyword", "settings": { - "foreground": "#C5CCD6" + "foreground": "#F47067" } }, { - "name": "Function declarations", "scope": [ - "entity.name.function", - "support.function", - "support.constant.handlebars", - "source.powershell variable.other.member", - "entity.name.operator.custom-literal" + "storage", + "storage.type" ], "settings": { - "foreground": "#D1D6AE" + "foreground": "#F47067" } }, { - "name": "Types declaration and references", "scope": [ - "support.class", - "support.type", - "entity.name.type", - "entity.name.namespace", - "entity.other.attribute", - "entity.name.scope-resolution", - "entity.name.class", - "storage.type.numeric.go", - "storage.type.byte.go", - "storage.type.boolean.go", - "storage.type.string.go", - "storage.type.uintptr.go", - "storage.type.error.go", - "storage.type.rune.go", - "storage.type.cs", - "storage.type.generic.cs", - "storage.type.modifier.cs", - "storage.type.variable.cs", - "storage.type.annotation.java", - "storage.type.generic.java", - "storage.type.java", - "storage.type.object.array.java", - "storage.type.primitive.array.java", - "storage.type.primitive.java", - "storage.type.token.java", - "storage.type.groovy", - "storage.type.annotation.groovy", - "storage.type.parameters.groovy", - "storage.type.generic.groovy", - "storage.type.object.array.groovy", - "storage.type.primitive.array.groovy", - "storage.type.primitive.groovy" + "storage.modifier.package", + "storage.modifier.import", + "storage.type.java" ], "settings": { - "foreground": "#48C9C4" + "foreground": "#ADBAC7" } }, { - "name": "Types declaration and references, TS grammar specific", "scope": [ - "meta.type.cast.expr", - "meta.type.new.expr", - "support.constant.math", - "support.constant.dom", - "support.constant.json", - "entity.other.inherited-class", - "punctuation.separator.namespace.ruby" + "string", + "string punctuation.section.embedded source" ], "settings": { - "foreground": "#48C9B9" + "foreground": "#96D0FF" } }, { - "name": "Control flow / Special keywords", - "scope": [ - "keyword.control", - "source.cpp keyword.operator.new", - "keyword.operator.delete", - "keyword.other.using", - "keyword.other.directive.using", - "keyword.other.operator", - "entity.name.operator" - ], + "scope": "support", "settings": { - "foreground": "#C184C6" + "foreground": "#6CB6FF" } }, { - "name": "Variable and parameter name", - "scope": [ - "variable", - "meta.definition.variable.name", - "support.variable", - "entity.name.variable", - "constant.other.placeholder" - ], + "scope": "meta.property-name", "settings": { - "foreground": "#90D5FF" + "foreground": "#6CB6FF" } }, { - "name": "Constants and enums", - "scope": [ - "variable.other.constant", - "variable.other.enummember" - ], + "scope": "variable", + "settings": { + "foreground": "#F69D50" + } + }, + { + "scope": "variable.other", + "settings": { + "foreground": "#ADBAC7" + } + }, + { + "scope": "invalid.broken", + "settings": { + "foreground": "#FF938A", + "fontStyle": "italic" + } + }, + { + "scope": "invalid.deprecated", "settings": { - "foreground": "#4CBDFF" + "foreground": "#FF938A", + "fontStyle": "italic" + } + }, + { + "scope": "invalid.illegal", + "settings": { + "foreground": "#FF938A", + "fontStyle": "italic" + } + }, + { + "scope": "invalid.unimplemented", + "settings": { + "foreground": "#FF938A", + "fontStyle": "italic" + } + }, + { + "scope": "carriage-return", + "settings": { + "foreground": "#CDD9E5", + "background": "#F47067", + "fontStyle": "italic underline", + "content": "^M" + } + }, + { + "scope": "message.error", + "settings": { + "foreground": "#FF938A" + } + }, + { + "scope": "string variable", + "settings": { + "foreground": "#6CB6FF" } }, { - "name": "Object keys, TS grammar specific", "scope": [ - "meta.object-literal.key" + "source.regexp", + "string.regexp" ], "settings": { - "foreground": "#90D5FF" + "foreground": "#96D0FF" } }, { - "name": "CSS property value", "scope": [ - "support.constant.property-value", - "support.constant.font-name", - "support.constant.media-type", - "support.constant.media", - "constant.other.color.rgb-value", - "constant.other.rgb-value", - "support.constant.color" + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition" ], "settings": { - "foreground": "#C48F80" + "foreground": "#96D0FF" + } + }, + { + "scope": "string.regexp constant.character.escape", + "settings": { + "foreground": "#8DDB8C", + "fontStyle": "bold" + } + }, + { + "scope": "support.constant", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": "support.variable", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": "support.type.property-name.json", + "settings": { + "foreground": "#8DDB8C" + } + }, + { + "scope": "meta.module-reference", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": "punctuation.definition.list.begin.markdown", + "settings": { + "foreground": "#F69D50" } }, { - "name": "Regular expression groups", "scope": [ - "punctuation.definition.group.regexp", - "punctuation.definition.group.assertion.regexp", - "punctuation.definition.character-class.regexp", - "punctuation.character.set.begin.regexp", - "punctuation.character.set.end.regexp", - "keyword.operator.negation.regexp", - "support.other.parenthesis.regexp" + "markup.heading", + "markup.heading entity.name" ], "settings": { - "foreground": "#C49580" + "foreground": "#6CB6FF", + "fontStyle": "bold" + } + }, + { + "scope": "markup.quote", + "settings": { + "foreground": "#8DDB8C" + } + }, + { + "scope": "markup.italic", + "settings": { + "foreground": "#ADBAC7", + "fontStyle": "italic" + } + }, + { + "scope": "markup.bold", + "settings": { + "foreground": "#ADBAC7", + "fontStyle": "bold" } }, { "scope": [ - "constant.character.character-class.regexp", - "constant.other.character-class.set.regexp", - "constant.other.character-class.regexp", - "constant.character.set.regexp" + "markup.underline" ], "settings": { - "foreground": "#C86971" + "fontStyle": "underline" } }, { "scope": [ - "keyword.operator.or.regexp", - "keyword.control.anchor.regexp" + "markup.strikethrough" ], "settings": { - "foreground": "#CBD6AE" + "fontStyle": "strikethrough" + } + }, + { + "scope": "markup.inline.raw", + "settings": { + "foreground": "#6CB6FF" } }, { - "scope": "keyword.operator.quantifier.regexp", + "scope": [ + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted" + ], "settings": { - "foreground": "#CCBD84" + "foreground": "#FF938A", + "background": "#5D0F12" } }, { "scope": [ - "constant.character", - "constant.other.option" + "punctuation.section.embedded" ], "settings": { - "foreground": "#4F9BDD" + "foreground": "#F47067" } }, { - "scope": "constant.character.escape", + "scope": [ + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted" + ], "settings": { - "foreground": "#CCB784" + "foreground": "#8DDB8C", + "background": "#113417" } }, { - "scope": "entity.name.label", + "scope": [ + "markup.changed", + "punctuation.definition.changed" + ], "settings": { - "foreground": "#BAC2CC" + "foreground": "#F69D50", + "background": "#682D0F" } }, { - "name": "Numbers", "scope": [ - "constant.numeric" + "markup.ignored", + "markup.untracked" ], "settings": { - "foreground": "#A8CAAD" + "foreground": "#2D333B", + "background": "#6CB6FF" } }, { - "name": "Markup Heading", - "scope": "markup.heading", + "scope": "meta.diff.range", "settings": { - "foreground": "#64b0df", + "foreground": "#DCBDFB", "fontStyle": "bold" } }, { - "name": "Markup Bold", - "scope": "markup.bold", + "scope": "meta.diff.header", "settings": { - "foreground": "#C48081", + "foreground": "#6CB6FF" + } + }, + { + "scope": "meta.separator", + "settings": { + "foreground": "#6CB6FF", "fontStyle": "bold" } }, { - "name": "Markup Italic", - "scope": "markup.italic", + "scope": "meta.output", "settings": { - "fontStyle": "italic" + "foreground": "#6CB6FF" } }, { - "name": "Markup Strikethrough", - "scope": "markup.strikethrough", + "scope": [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote" + ], "settings": { - "fontStyle": "strikethrough" + "foreground": "#768390" } }, { - "name": "Markup Underline", - "scope": "markup.underline", + "scope": "brackethighlighter.unmatched", "settings": { - "fontStyle": "underline" + "foreground": "#FF938A" } }, { - "name": "Markup Quote", - "scope": "markup.quote", + "scope": [ + "constant.other.reference.link", + "string.other.link" + ], "settings": { - "foreground": "#C184C6" + "foreground": "#96D0FF" } }, { - "name": "Markup List", - "scope": "markup.list", + "scope": "token.info-token", "settings": { - "foreground": "#48C9C4" + "foreground": "#6796E6" } }, { - "name": "Markup Inline Raw", - "scope": "markup.inline.raw", + "scope": "token.warn-token", "settings": { - "foreground": "#D1D6AE" + "foreground": "#CD9731" } }, { - "name": "Markup Raw/Fenced Code Block", - "scope": [ - "markup.raw", - "markup.fenced_code" - ], + "scope": "token.error-token", "settings": { - "foreground": "#8C8C8C" + "foreground": "#F44747" } }, { - "name": "Markup Link", - "scope": [ - "meta.link", - "markup.underline.link" - ], + "scope": "token.debug-token", "settings": { - "foreground": "#48A0C7" + "foreground": "#B267E6" } } ], - "semanticHighlighting": true, - "semanticTokenColors": { - "newOperator": "#C586C0", - "stringLiteral": "#ce9178", - "customLiteral": "#DCDCAA", - "numberLiteral": "#b5cea8" - } + "semanticHighlighting": true } diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 082b53b39bd55..13dcdef5c3e6a 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -275,369 +275,395 @@ "tokenColors": [ { "scope": [ - "comment" + "comment", + "punctuation.definition.comment", + "string.comment" ], "settings": { - "foreground": "#60984D" + "foreground": "#6e7781" } }, { "scope": [ - "keyword", - "storage.modifier", - "storage.type", - "keyword.operator.new", - "keyword.operator.expression", - "keyword.operator.cast", - "keyword.operator.sizeof", - "keyword.operator.instanceof" + "constant.other.placeholder", + "constant.character" ], "settings": { - "foreground": "#5460C1" + "foreground": "#cf222e" } }, { "scope": [ - "string" + "constant", + "entity.name.constant", + "variable.other.constant", + "variable.other.enummember", + "variable.language", + "entity" ], "settings": { - "foreground": "#B86855" + "foreground": "#0550ae" } }, { - "name": "Language constants", "scope": [ - "constant.language" + "entity.name", + "meta.export.default", + "meta.definition.variable" ], "settings": { - "foreground": "#5460C1" + "foreground": "#953800" } }, { - "name": "HTML/XML tags", "scope": [ - "entity.name.tag", - "meta.tag.sgml", - "markup.deleted.git_gutter" + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.object.member", + "meta.embedded.expression" ], "settings": { - "foreground": "#5751DE" + "foreground": "#1f2328" } }, { - "name": "HTML/XML tag punctuation", - "scope": [ - "punctuation.definition.tag.html", - "punctuation.definition.tag.begin.html", - "punctuation.definition.tag.end.html" - ], + "scope": "entity.name.function", "settings": { - "foreground": "#93201A" + "foreground": "#8250df" } }, { - "name": "HTML/XML attribute names", "scope": [ - "entity.other.attribute-name" + "entity.name.tag", + "support.class.component" ], "settings": { - "foreground": "#E75854" + "foreground": "#116329" } }, { - "name": "Operators", - "scope": [ - "keyword.operator" - ], + "scope": "keyword", "settings": { - "foreground": "#573F35" + "foreground": "#cf222e" } }, { - "name": "Function declarations", "scope": [ - "entity.name.function", - "support.function", - "support.constant.handlebars", - "source.powershell variable.other.member", - "entity.name.operator.custom-literal" + "storage", + "storage.type" ], "settings": { - "foreground": "#98863B" + "foreground": "#cf222e" } }, { - "name": "Types declaration and references", "scope": [ - "support.class", - "support.type", - "entity.name.type", - "entity.name.namespace", - "entity.other.attribute", - "entity.name.scope-resolution", - "entity.name.class", - "storage.type.numeric.go", - "storage.type.byte.go", - "storage.type.boolean.go", - "storage.type.string.go", - "storage.type.uintptr.go", - "storage.type.error.go", - "storage.type.rune.go", - "storage.type.cs", - "storage.type.generic.cs", - "storage.type.modifier.cs", - "storage.type.variable.cs", - "storage.type.annotation.java", - "storage.type.generic.java", - "storage.type.java", - "storage.type.object.array.java", - "storage.type.primitive.array.java", - "storage.type.primitive.java", - "storage.type.token.java", - "storage.type.groovy", - "storage.type.annotation.groovy", - "storage.type.parameters.groovy", - "storage.type.generic.groovy", - "storage.type.object.array.groovy", - "storage.type.primitive.array.groovy", - "storage.type.primitive.groovy" + "storage.modifier.package", + "storage.modifier.import", + "storage.type.java" ], "settings": { - "foreground": "#46969A" + "foreground": "#1f2328" } }, { - "name": "Types declaration and references, TS grammar specific", "scope": [ - "meta.type.cast.expr", - "meta.type.new.expr", - "support.constant.math", - "support.constant.dom", - "support.constant.json", - "entity.other.inherited-class", - "punctuation.separator.namespace.ruby" + "string", + "string punctuation.section.embedded source" ], "settings": { - "foreground": "#419BB3" + "foreground": "#0a3069" } }, { - "name": "Control flow / Special keywords", - "scope": [ - "keyword.control", - "source.cpp keyword.operator.new", - "source.cpp keyword.operator.delete", - "keyword.other.using", - "keyword.other.directive.using", - "keyword.other.operator", - "entity.name.operator" - ], + "scope": "support", "settings": { - "foreground": "#8F41AD" + "foreground": "#0550ae" } }, { - "name": "Variable and parameter name", - "scope": [ - "variable", - "meta.definition.variable.name", - "support.variable", - "entity.name.variable", - "constant.other.placeholder" - ], + "scope": "meta.property-name", "settings": { - "foreground": "#282D85" + "foreground": "#0550ae" } }, { - "name": "Constants and enums", - "scope": [ - "variable.other.constant", - "variable.other.enummember" - ], + "scope": "variable", "settings": { - "foreground": "#3086C5" + "foreground": "#953800" } }, { - "name": "Object keys, TS grammar specific", - "scope": [ - "meta.object-literal.key" - ], + "scope": "variable.other", "settings": { - "foreground": "#282D85" + "foreground": "#1f2328" } }, { - "name": "CSS property value", - "scope": [ - "support.constant.property-value", - "support.constant.font-name", - "support.constant.media-type", - "support.constant.media", - "constant.other.color.rgb-value", - "constant.other.rgb-value", - "support.constant.color" - ], + "scope": "invalid.broken", + "settings": { + "fontStyle": "italic", + "foreground": "#82071e" + } + }, + { + "scope": "invalid.deprecated", + "settings": { + "fontStyle": "italic", + "foreground": "#82071e" + } + }, + { + "scope": "invalid.illegal", "settings": { - "foreground": "#2D6AAE" + "fontStyle": "italic", + "foreground": "#82071e" + } + }, + { + "scope": "invalid.unimplemented", + "settings": { + "fontStyle": "italic", + "foreground": "#82071e" + } + }, + { + "scope": "carriage-return", + "settings": { + "fontStyle": "italic underline", + "background": "#cf222e", + "foreground": "#f6f8fa", + "content": "^M" + } + }, + { + "scope": "message.error", + "settings": { + "foreground": "#82071e" + } + }, + { + "scope": "string variable", + "settings": { + "foreground": "#0550ae" } }, { - "name": "Regular expression groups", "scope": [ - "punctuation.definition.group.regexp", - "punctuation.definition.group.assertion.regexp", - "punctuation.definition.character-class.regexp", - "punctuation.character.set.begin.regexp", - "punctuation.character.set.end.regexp", - "keyword.operator.negation.regexp", - "support.other.parenthesis.regexp" + "source.regexp", + "string.regexp" ], "settings": { - "foreground": "#D68490" + "foreground": "#0a3069" } }, { "scope": [ - "constant.character.character-class.regexp", - "constant.other.character-class.set.regexp", - "constant.other.character-class.regexp", - "constant.character.set.regexp" + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition" ], "settings": { - "foreground": "#A63350" + "foreground": "#0a3069" + } + }, + { + "scope": "string.regexp constant.character.escape", + "settings": { + "fontStyle": "bold", + "foreground": "#116329" } }, { - "scope": "keyword.operator.quantifier.regexp", + "scope": "support.constant", "settings": { - "foreground": "#573F35" + "foreground": "#0550ae" + } + }, + { + "scope": "support.variable", + "settings": { + "foreground": "#0550ae" + } + }, + { + "scope": "support.type.property-name.json", + "settings": { + "foreground": "#116329" + } + }, + { + "scope": "meta.module-reference", + "settings": { + "foreground": "#0550ae" + } + }, + { + "scope": "punctuation.definition.list.begin.markdown", + "settings": { + "foreground": "#953800" } }, { "scope": [ - "keyword.operator.or.regexp", - "keyword.control.anchor.regexp" + "markup.heading", + "markup.heading entity.name" ], "settings": { - "foreground": "#C54C5B" + "fontStyle": "bold", + "foreground": "#0550ae" + } + }, + { + "scope": "markup.quote", + "settings": { + "foreground": "#116329" + } + }, + { + "scope": "markup.italic", + "settings": { + "fontStyle": "italic", + "foreground": "#1f2328" + } + }, + { + "scope": "markup.bold", + "settings": { + "fontStyle": "bold", + "foreground": "#1f2328" } }, { "scope": [ - "constant.character", - "constant.other.option" + "markup.underline" ], "settings": { - "foreground": "#5751DE" + "fontStyle": "underline" } }, { - "scope": "constant.character.escape", + "scope": [ + "markup.strikethrough" + ], "settings": { - "foreground": "#E14A46" + "fontStyle": "strikethrough" } }, { - "scope": "entity.name.label", + "scope": "markup.inline.raw", "settings": { - "foreground": "#5C3923" + "foreground": "#0550ae" } }, { - "name": "Numbers", "scope": [ - "constant.numeric" + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted" ], "settings": { - "foreground": "#2B9A69" + "background": "#ffebe9", + "foreground": "#82071e" } }, { - "name": "Markup Heading", - "scope": "markup.heading", + "scope": [ + "punctuation.section.embedded" + ], "settings": { - "foreground": "#5460C1", - "fontStyle": "bold" + "foreground": "#cf222e" } }, { - "name": "Markup Bold", - "scope": "markup.bold", + "scope": [ + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted" + ], "settings": { - "foreground": "#B86855", - "fontStyle": "bold" + "background": "#dafbe1", + "foreground": "#116329" } }, { - "name": "Markup Italic", - "scope": "markup.italic", + "scope": [ + "markup.changed", + "punctuation.definition.changed" + ], "settings": { - "fontStyle": "italic" + "background": "#ffd8b5", + "foreground": "#953800" } }, { - "name": "Markup Strikethrough", - "scope": "markup.strikethrough", + "scope": [ + "markup.ignored", + "markup.untracked" + ], "settings": { - "fontStyle": "strikethrough" + "foreground": "#eaeef2", + "background": "#0550ae" } }, { - "name": "Markup Underline", - "scope": "markup.underline", + "scope": "meta.diff.range", "settings": { - "fontStyle": "underline" + "foreground": "#8250df", + "fontStyle": "bold" } }, { - "name": "Markup Quote", - "scope": "markup.quote", + "scope": "meta.diff.header", "settings": { - "foreground": "#8F41AD" + "foreground": "#0550ae" } }, { - "name": "Markup List", - "scope": "markup.list", + "scope": "meta.separator", "settings": { - "foreground": "#46969A" + "fontStyle": "bold", + "foreground": "#0550ae" } }, { - "name": "Markup Inline Raw", - "scope": "markup.inline.raw", + "scope": "meta.output", "settings": { - "foreground": "#98863B" + "foreground": "#0550ae" } }, { - "name": "Markup Raw/Fenced Code Block", "scope": [ - "markup.raw", - "markup.fenced_code" + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote" ], "settings": { - "foreground": "#606060" + "foreground": "#57606a" + } + }, + { + "scope": "brackethighlighter.unmatched", + "settings": { + "foreground": "#82071e" } }, { - "name": "Markup Link", "scope": [ - "meta.link", - "markup.underline.link" + "constant.other.reference.link", + "string.other.link" ], "settings": { - "foreground": "#0069CC" + "foreground": "#0a3069" } } ], - "semanticHighlighting": true, - "semanticTokenColors": { - "newOperator": "#AF00DB", - "stringLiteral": "#a31515", - "customLiteral": "#795E26", - "numberLiteral": "#098658" - } + "semanticHighlighting": true } diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index af8bc21803273..050f706aa1e79 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -81,6 +81,22 @@ /* Chat Widget */ +.monaco-workbench .interactive-session .chat-question-carousel-container { + border-radius: var(--radius-lg); +} + +.monaco-workbench .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor, +.monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +.monaco-workbench .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container { + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} + +.monaco-workbench.vs .interactive-session .chat-input-container { + box-shadow: inset var(--shadow-sm); +} .monaco-workbench .part.panel .interactive-session, .monaco-workbench .part.auxiliarybar .interactive-session { position: relative; diff --git a/package-lock.json b/package-lock.json index f6af524389bcb..03648e4b2cc0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4191,9 +4191,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -4235,10 +4235,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4268,15 +4269,16 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -12403,10 +12405,11 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -15757,15 +15760,16 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -18532,9 +18536,9 @@ "dev": true }, "node_modules/webpack": { - "version": "5.105.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", - "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "version": "5.105.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.3.tgz", + "integrity": "sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg==", "dev": true, "license": "MIT", "dependencies": { @@ -18544,7 +18548,7 @@ "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", @@ -18562,7 +18566,7 @@ "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", - "webpack-sources": "^3.3.3" + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -18669,9 +18673,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, "license": "MIT", "engines": { diff --git a/src/vs/base/browser/ui/dialog/dialog.css b/src/vs/base/browser/ui/dialog/dialog.css index 0dd289c9f9ee1..d1d37dcff678e 100644 --- a/src/vs/base/browser/ui/dialog/dialog.css +++ b/src/vs/base/browser/ui/dialog/dialog.css @@ -27,6 +27,7 @@ width: min-content; min-width: 500px; max-width: 90vw; + max-height: 90vh; min-height: 75px; padding: 10px; transform: translate3d(0px, 0px, 0px); @@ -55,12 +56,18 @@ flex-grow: 1; align-items: center; padding: 0 10px; + min-height: 0; /* allow flex item to shrink below content size */ + overflow: hidden; } .monaco-dialog-box.align-vertical .dialog-message-row { flex-direction: column; } +.monaco-dialog-box.align-vertical .dialog-message-row .dialog-message-container { + min-height: 0; /* allow flex item to shrink below content size in column layout */ +} + .monaco-dialog-box .dialog-message-row > .dialog-icon.codicon { flex: 0 0 48px; height: 48px; @@ -77,12 +84,17 @@ align-self: baseline; } +.monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { + align-self: stretch; /* fill row height so overflow-y scrolling works */ +} + /** Dialog: Message/Footer Container */ .monaco-dialog-box .dialog-message-row .dialog-message-container, .monaco-dialog-box .dialog-footer-row { display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; text-overflow: ellipsis; user-select: text; -webkit-user-select: text; diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 78641318c5f10..ab9fb26c1d208 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -1288,7 +1288,7 @@ export class CodeApplication extends Disposable { // MCP const mcpDiscoveryChannel = ProxyChannel.fromService(accessor.get(INativeMcpDiscoveryHelperService), disposables); mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, mcpDiscoveryChannel); - const mcpGatewayChannel = this._register(new McpGatewayChannel(mainProcessElectronServer, accessor.get(IMcpGatewayService))); + const mcpGatewayChannel = this._register(new McpGatewayChannel(mainProcessElectronServer, accessor.get(IMcpGatewayService), accessor.get(ILoggerMainService))); mainProcessElectronServer.registerChannel(McpGatewayChannelName, mcpGatewayChannel); // Logger diff --git a/src/vs/platform/mcp/common/modelContextProtocolApps.ts b/src/vs/platform/mcp/common/modelContextProtocolApps.ts index 4569e8f25ac8d..86b891514e213 100644 --- a/src/vs/platform/mcp/common/modelContextProtocolApps.ts +++ b/src/vs/platform/mcp/common/modelContextProtocolApps.ts @@ -17,6 +17,7 @@ export namespace McpApps { | MCP.ReadResourceRequest | MCP.PingRequest | (McpUiOpenLinkRequest & MCP.JSONRPCRequest) + | (McpUiDownloadFileRequest & MCP.JSONRPCRequest) | (McpUiUpdateModelContextRequest & MCP.JSONRPCRequest) | (McpUiMessageRequest & MCP.JSONRPCRequest) | (McpUiRequestDisplayModeRequest & MCP.JSONRPCRequest) @@ -37,6 +38,7 @@ export namespace McpApps { | McpApps.McpUiInitializeResult | McpUiMessageResult | McpUiOpenLinkResult + | McpUiDownloadFileResult | McpUiRequestDisplayModeResult; export type HostNotification = @@ -223,6 +225,33 @@ export namespace McpApps { [key: string]: unknown; } + /** + * @description Request to download one or more files through the host. + * Uses standard MCP resource types: EmbeddedResource for inline content + * and ResourceLink for references the host resolves via resources/read. + */ + export interface McpUiDownloadFileRequest { + method: "ui/download-file"; + params: { + /** @description Resources to download, either inline or as links for the host to resolve. */ + contents: (MCP.EmbeddedResource | MCP.ResourceLink)[]; + }; + } + + /** + * @description Result from a download file request. + * @see {@link McpUiDownloadFileRequest} + */ + export interface McpUiDownloadFileResult { + /** @description True if the host rejected or failed to process the download. */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; + } + /** * @description Request to send a message to the host's chat interface. * @see {@link app.App.sendMessage} for the method that sends this request @@ -528,6 +557,8 @@ export namespace McpApps { updateModelContext?: McpUiSupportedContentBlockModalities; /** @description Host supports receiving content messages (ui/message) from the View. */ message?: McpUiSupportedContentBlockModalities; + /** @description Host supports file downloads (ui/download-file) from the View. */ + downloadFile?: {}; } /** @@ -734,4 +765,6 @@ export namespace McpApps { "ui/request-display-mode"; export const UPDATE_MODEL_CONTEXT_METHOD: McpUiUpdateModelContextRequest["method"] = "ui/update-model-context"; + export const DOWNLOAD_FILE_METHOD: McpUiDownloadFileRequest["method"] = + "ui/download-file"; } diff --git a/src/vs/platform/mcp/node/mcpGatewayChannel.ts b/src/vs/platform/mcp/node/mcpGatewayChannel.ts index 0b0ce1edb0ac8..78d0e93a3f0d2 100644 --- a/src/vs/platform/mcp/node/mcpGatewayChannel.ts +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -6,6 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { ILoggerService } from '../../log/common/log.js'; import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; @@ -19,10 +20,14 @@ export class McpGatewayChannel extends Disposable implements IServerCh constructor( private readonly _ipcServer: IPCServer, - @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService + @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService, + @ILoggerService private readonly _loggerService: ILoggerService, ) { super(); - this._register(_ipcServer.onDidRemoveConnection(c => mcpGatewayService.disposeGatewaysForClient(c.ctx))); + this._register(_ipcServer.onDidRemoveConnection(c => { + this._loggerService.getLogger('mcpGateway')?.info(`[McpGateway][Channel] Client disconnected: ${c.ctx}, cleaning up gateways`); + mcpGatewayService.disposeGatewaysForClient(c.ctx); + })); } listen(_ctx: TContext, _event: string): Event { @@ -30,6 +35,9 @@ export class McpGatewayChannel extends Disposable implements IServerCh } async call(ctx: TContext, command: string, args?: unknown): Promise { + const logger = this._loggerService.getLogger('mcpGateway'); + logger?.debug(`[McpGateway][Channel] IPC call: ${command} from client ${ctx}`); + switch (command) { case 'createGateway': { const brokerChannel = ipcChannelForContext(this._ipcServer, ctx); @@ -42,9 +50,11 @@ export class McpGatewayChannel extends Disposable implements IServerCh readResource: (serverIndex, uri) => brokerChannel.call('readResource', { serverIndex, uri }), listResourceTemplates: () => brokerChannel.call('listResourceTemplates'), }); + logger?.info(`[McpGateway][Channel] Gateway created: ${result.gatewayId} for client ${ctx}`); return result as T; } case 'disposeGateway': { + logger?.info(`[McpGateway][Channel] Disposing gateway: ${args as string} for client ${ctx}`); await this.mcpGatewayService.disposeGateway(args as string); return undefined as T; } diff --git a/src/vs/platform/mcp/node/mcpGatewayService.ts b/src/vs/platform/mcp/node/mcpGatewayService.ts index c86b23f21dbe8..6131c7677dab6 100644 --- a/src/vs/platform/mcp/node/mcpGatewayService.ts +++ b/src/vs/platform/mcp/node/mcpGatewayService.ts @@ -56,6 +56,7 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService const gateway = new McpGatewayRoute(gatewayId, this._logger, toolInvoker); this._gateways.set(gatewayId, gateway); + this._logger.info(`[McpGatewayService] Active gateways: ${this._gateways.size}`); // Track client ownership if clientId provided (for cleanup on disconnect) if (clientId) { @@ -83,7 +84,7 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService gateway.dispose(); this._gateways.delete(gatewayId); this._gatewayToClient.delete(gatewayId); - this._logger.info(`[McpGatewayService] Disposed gateway: ${gatewayId}`); + this._logger.info(`[McpGatewayService] Disposed gateway: ${gatewayId} (remaining: ${this._gateways.size})`); // If no more gateways, shut down the server if (this._gateways.size === 0) { @@ -101,7 +102,7 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } if (gatewaysToDispose.length > 0) { - this._logger.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}`); + this._logger.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}: [${gatewaysToDispose.join(', ')}]`); for (const gatewayId of gatewaysToDispose) { this._gateways.get(gatewayId)?.dispose(); @@ -204,6 +205,8 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService const url = new URL(req.url!, `http://${req.headers.host}`); const pathParts = url.pathname.split('/').filter(Boolean); + this._logger.debug(`[McpGatewayService] ${req.method} ${url.pathname} (active gateways: ${this._gateways.size})`); + // Expected path: /gateway/{gatewayId} if (pathParts.length >= 2 && pathParts[0] === 'gateway') { const gatewayId = pathParts[1]; @@ -216,11 +219,13 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } // Not found + this._logger.warn(`[McpGatewayService] ${req.method} ${url.pathname}: gateway not found`); res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Gateway not found' })); } override dispose(): void { + this._logger.info(`[McpGatewayService] Disposing service (gateways: ${this._gateways.size})`); this._stopServer(); for (const gateway of this._gateways.values()) { gateway.dispose(); @@ -247,6 +252,8 @@ class McpGatewayRoute extends Disposable { } handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] ${req.method} request (sessions: ${this._sessions.size})`); + if (req.method === 'POST') { void this._handlePost(req, res); return; @@ -266,6 +273,7 @@ class McpGatewayRoute extends Disposable { } public override dispose(): void { + this._logger.info(`[McpGateway][route ${this.gatewayId}] Disposing route (sessions: ${this._sessions.size})`); for (const session of this._sessions.values()) { session.dispose(); } @@ -286,6 +294,7 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.info(`[McpGateway][route ${this.gatewayId}] Deleting session ${sessionId}`); session.dispose(); this._sessions.delete(sessionId); res.writeHead(204); @@ -305,6 +314,7 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.info(`[McpGateway][route ${this.gatewayId}] SSE connection requested for session ${sessionId}`); session.attachSseClient(req, res); } @@ -315,10 +325,13 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.debug(`[McpGateway][route ${this.gatewayId}] Handling POST`); + let message: JsonRpcMessage | JsonRpcMessage[]; try { message = JSON.parse(body) as JsonRpcMessage | JsonRpcMessage[]; } catch (error) { + this._logger.warn(`[McpGateway][route ${this.gatewayId}] JSON parse error: ${error instanceof Error ? error.message : String(error)}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(JsonRpcProtocol.createParseError('Parse error', error instanceof Error ? error.message : String(error)))); return; @@ -339,13 +352,16 @@ class McpGatewayRoute extends Disposable { }; if (responses.length === 0) { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] POST response: 202 (no content)`); res.writeHead(202, headers); res.end(); return; } + const responseBody = JSON.stringify(Array.isArray(message) ? responses : responses[0]); + this._logger.debug(`[McpGateway][route ${this.gatewayId}] POST response: 200, body: ${responseBody}`); res.writeHead(200, headers); - res.end(JSON.stringify(Array.isArray(message) ? responses : responses[0])); + res.end(responseBody); } catch (error) { this._logger.error('[McpGatewayService] Failed handling gateway request', error); this._respondHttpError(res, 500, 'Internal server error'); @@ -356,6 +372,7 @@ class McpGatewayRoute extends Disposable { if (headerSessionId) { const existing = this._sessions.get(headerSessionId); if (!existing) { + this._logger.warn(`[McpGateway][route ${this.gatewayId}] Session not found: ${headerSessionId}`); this._respondHttpError(res, 404, 'Session not found'); return undefined; } @@ -369,6 +386,7 @@ class McpGatewayRoute extends Disposable { } const sessionId = generateUuid(); + this._logger.info(`[McpGateway][route ${this.gatewayId}] Creating new session ${sessionId}`); const session = new McpGatewaySession(sessionId, this._logger, () => { this._sessions.delete(sessionId); }, this._toolInvoker); @@ -377,6 +395,7 @@ class McpGatewayRoute extends Disposable { } private _respondHttpError(res: http.ServerResponse, statusCode: number, error: string): void { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] HTTP error response: ${statusCode} ${error}`); res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: statusCode, message: error } } satisfies JsonRpcMessage)); } diff --git a/src/vs/platform/mcp/node/mcpGatewaySession.ts b/src/vs/platform/mcp/node/mcpGatewaySession.ts index f35223a15a376..579b018449502 100644 --- a/src/vs/platform/mcp/node/mcpGatewaySession.ts +++ b/src/vs/platform/mcp/node/mcpGatewaySession.ts @@ -105,6 +105,7 @@ export class McpGatewaySession extends Disposable { return; } + this._logService.info(`[McpGateway][session ${this.id}] Tools changed, notifying client`); this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); })); @@ -113,6 +114,7 @@ export class McpGatewaySession extends Disposable { return; } + this._logService.info(`[McpGateway][session ${this.id}] Resources changed, notifying client`); this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); })); } @@ -126,9 +128,11 @@ export class McpGatewaySession extends Disposable { res.write(': connected\n\n'); this._sseClients.add(res); + this._logService.info(`[McpGateway][session ${this.id}] SSE client attached (total: ${this._sseClients.size})`); res.on('close', () => { this._sseClients.delete(res); + this._logService.info(`[McpGateway][session ${this.id}] SSE client detached (total: ${this._sseClients.size})`); }); } @@ -145,6 +149,7 @@ export class McpGatewaySession extends Disposable { } public override dispose(): void { + this._logService.info(`[McpGateway][session ${this.id}] Disposing session (SSE clients: ${this._sseClients.size})`); for (const client of this._sseClients) { if (!client.destroyed) { client.end(); @@ -160,10 +165,12 @@ export class McpGatewaySession extends Disposable { if (this._isCollectingPostResponses) { this._pendingResponses.push(message); } + this._logService.debug(`[McpGateway][session ${this.id}] --> response: ${JSON.stringify(message)}`); return; } if (isJsonRpcNotification(message)) { + this._logService.debug(`[McpGateway][session ${this.id}] --> notification: ${(message as IJsonRpcNotification).method}`); this._broadcastSse(message); return; } @@ -173,11 +180,13 @@ export class McpGatewaySession extends Disposable { private _broadcastSse(message: JsonRpcMessage): void { if (this._sseClients.size === 0) { + this._logService.debug(`[McpGateway][session ${this.id}] No SSE clients to broadcast to, dropping message`); return; } const payload = JSON.stringify(message); const eventId = String(++this._lastEventId); + this._logService.debug(`[McpGateway][session ${this.id}] Broadcasting SSE event id=${eventId} to ${this._sseClients.size}`); const lines = payload.split(/\r?\n/g); const data = [ `id: ${eventId}`, @@ -198,11 +207,14 @@ export class McpGatewaySession extends Disposable { } private async _handleRequest(request: IJsonRpcRequest): Promise { + this._logService.debug(`[McpGateway][session ${this.id}] <-- request: ${request.method} (id=${String(request.id)})`); + if (request.method === 'initialize') { return this._handleInitialize(request); } if (!this._isInitialized) { + this._logService.warn(`[McpGateway][session ${this.id}] Rejected request '${request.method}': session not initialized`); throw new JsonRpcError(MCP_INVALID_REQUEST, 'Session is not initialized'); } @@ -220,13 +232,17 @@ export class McpGatewaySession extends Disposable { case 'resources/templates/list': return this._handleListResourceTemplates(); default: + this._logService.warn(`[McpGateway][session ${this.id}] Unknown method: ${request.method}`); throw new JsonRpcError(MCP_METHOD_NOT_FOUND, `Method not found: ${request.method}`); } } private _handleNotification(notification: IJsonRpcNotification): void { + this._logService.debug(`[McpGateway][session ${this.id}] <-- notification: ${notification.method}`); + if (notification.method === 'notifications/initialized') { this._isInitialized = true; + this._logService.info(`[McpGateway][session ${this.id}] Session initialized`); this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); } @@ -276,21 +292,27 @@ export class McpGatewaySession extends Disposable { ? params.arguments as Record : {}; + this._logService.debug(`[McpGateway][session ${this.id}] Calling tool '${params.name}' with args: ${JSON.stringify(argumentsValue)}`); + try { const { result, serverIndex } = await this._toolInvoker.callTool(params.name, argumentsValue); + this._logService.debug(`[McpGateway][session ${this.id}] Tool '${params.name}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); return { ...result, content: encodeResourceUrisInContent(result.content, serverIndex), }; } catch (error) { - this._logService.error('[McpGatewayService] Tool call invocation failed', error); + this._logService.error(`[McpGateway][session ${this.id}] Tool '${params.name}' invocation failed`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } private _handleListTools(): unknown { return this._toolInvoker.listTools() - .then(tools => ({ tools })); + .then(tools => { + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${tools.length} tool(s): [${tools.map(t => t.name).join(', ')}]`); + return { tools }; + }); } private async _handleListResources(): Promise { @@ -304,6 +326,7 @@ export class McpGatewaySession extends Disposable { }); } } + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${allResources.length} resource(s) from ${serverResults.length} server(s)`); return { resources: allResources }; } @@ -314,8 +337,10 @@ export class McpGatewaySession extends Disposable { } const { serverIndex, originalUri } = decodeGatewayResourceUri(params.uri); + this._logService.debug(`[McpGateway][session ${this.id}] Reading resource '${originalUri}' from server ${serverIndex}`); try { const result = await this._toolInvoker.readResource(serverIndex, originalUri); + this._logService.debug(`[McpGateway][session ${this.id}] Resource read returned ${result.contents.length} content(s)`); return { contents: result.contents.map(content => ({ ...content, @@ -323,7 +348,7 @@ export class McpGatewaySession extends Disposable { })), }; } catch (error) { - this._logService.error('[McpGatewayService] Resource read failed', error); + this._logService.error(`[McpGateway][session ${this.id}] Resource read failed for '${originalUri}'`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } @@ -339,6 +364,7 @@ export class McpGatewaySession extends Disposable { }); } } + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${allTemplates.length} resource template(s) from ${serverResults.length} server(s)`); return { resourceTemplates: allTemplates }; } } diff --git a/src/vs/platform/quickinput/browser/tree/quickTree.ts b/src/vs/platform/quickinput/browser/tree/quickTree.ts index e506021a05892..3c9a614694866 100644 --- a/src/vs/platform/quickinput/browser/tree/quickTree.ts +++ b/src/vs/platform/quickinput/browser/tree/quickTree.ts @@ -104,6 +104,11 @@ export class QuickTree extends QuickInput implements I this.ui.inputBox.setFocus(); } + reveal(element: T): void { + this.ui.tree.tree.reveal(element); + this.ui.tree.tree.setFocus([element]); + } + override show() { if (!this.visible) { const visibilities: Visibilities = { diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 9426be48e2f4a..04d91e66aa488 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -1172,6 +1172,12 @@ export interface IQuickTree extends IQuickInput { */ focusOnInput(): void; + /** + * Reveals and focuses a specific item in the tree. + * @param element The item to reveal and focus. + */ + reveal(element: T): void; + /** * Focus a particular item in the list. Used internally for keyboard navigation. * @param focus The focus behavior. diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 7455376e3f45c..07551319546ce 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { getMarks, mark } from '../../../base/common/performance.js'; -import { isTahoeOrNewer, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { isTahoeOrNewer, isLinux, isMacintosh, isWindows, INodeProcess } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { release } from 'os'; @@ -702,11 +702,16 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { this.windowState = state; this.logService.trace('window#ctor: using window state', state); - const options = instantiationService.invokeFunction(defaultBrowserWindowOptions, this.windowState, undefined, { + const webPreferences: electron.WebPreferences = { preload: FileAccess.asFileUri('vs/base/parts/sandbox/electron-browser/preload.js').fsPath, additionalArguments: [`--vscode-window-config=${this.configObjectUrl.resource.toString()}`], - v8CacheOptions: this.environmentMainService.useCodeCache ? 'bypassHeatCheck' : 'none', - }); + v8CacheOptions: this.environmentMainService.useCodeCache ? 'bypassHeatCheck' : 'none' + }; + if ((process as INodeProcess).isEmbeddedApp) { + webPreferences.backgroundThrottling = false; // disable for sub-app + } + + const options = instantiationService.invokeFunction(defaultBrowserWindowOptions, this.windowState, undefined, webPreferences); // Create the browser window mark('code/willCreateCodeBrowserWindow'); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 7f5708a85d8d0..7ff02612cc13f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -5,7 +5,6 @@ import './agentFeedbackEditorInputContribution.js'; import './agentFeedbackEditorWidgetContribution.js'; -import './agentFeedbackLineDecorationContribution.js'; import './agentFeedbackOverviewRulerContribution.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts deleted file mode 100644 index 119bbad2fc0ae..0000000000000 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts +++ /dev/null @@ -1,169 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/agentFeedbackLineDecoration.css'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js'; -import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; -import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; -import { TrackedRangeStickiness } from '../../../../editor/common/model.js'; -import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IAgentFeedbackService } from './agentFeedbackService.js'; -import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { getSessionForResource } from './agentFeedbackEditorUtils.js'; -import { Selection } from '../../../../editor/common/core/selection.js'; - -const addFeedbackHintDecoration = ModelDecorationOptions.register({ - description: 'agent-feedback-add-hint', - linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`, - stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, -}); - -export class AgentFeedbackLineDecorationContribution extends Disposable implements IEditorContribution { - - static readonly ID = 'agentFeedback.lineDecorationContribution'; - - private _hintDecorationId: string | null = null; - private _hintLine = -1; - private _sessionResource: URI | undefined; - private _feedbackLines = new Set(); - - constructor( - private readonly _editor: ICodeEditor, - @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, - @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, - ) { - super(); - - this._store.add(this._agentFeedbackService.onDidChangeFeedback(() => this._updateFeedbackLines())); - this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged())); - this._store.add(this._editor.onMouseMove((e: IEditorMouseEvent) => this._onMouseMove(e))); - this._store.add(this._editor.onMouseLeave(() => this._updateHintDecoration(-1))); - this._store.add(this._editor.onMouseDown((e: IEditorMouseEvent) => this._onMouseDown(e))); - - this._resolveSession(); - this._updateFeedbackLines(); - } - - private _onModelChanged(): void { - this._updateHintDecoration(-1); - this._resolveSession(); - this._updateFeedbackLines(); - } - - private _resolveSession(): void { - const model = this._editor.getModel(); - if (!model) { - this._sessionResource = undefined; - return; - } - this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); - } - - private _updateFeedbackLines(): void { - if (!this._sessionResource) { - this._feedbackLines.clear(); - return; - } - - const feedbackItems = this._agentFeedbackService.getFeedback(this._sessionResource); - const lines = new Set(); - - for (const item of feedbackItems) { - const model = this._editor.getModel(); - if (!model || item.resourceUri.toString() !== model.uri.toString()) { - continue; - } - - lines.add(item.range.startLineNumber); - } - - this._feedbackLines = lines; - } - - private _onMouseMove(e: IEditorMouseEvent): void { - if (!this._sessionResource) { - this._updateHintDecoration(-1); - return; - } - - const isLineDecoration = e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS && !e.target.detail.isAfterLines; - const isContentArea = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY; - if (e.target.position - && (isLineDecoration || isContentArea) - && !this._feedbackLines.has(e.target.position.lineNumber) - ) { - this._updateHintDecoration(e.target.position.lineNumber); - } else { - this._updateHintDecoration(-1); - } - } - - private _updateHintDecoration(line: number): void { - if (line === this._hintLine) { - return; - } - - this._hintLine = line; - this._editor.changeDecorations(accessor => { - if (this._hintDecorationId) { - accessor.removeDecoration(this._hintDecorationId); - this._hintDecorationId = null; - } - if (line !== -1) { - this._hintDecorationId = accessor.addDecoration( - new Range(line, 1, line, 1), - addFeedbackHintDecoration, - ); - } - }); - } - - private _onMouseDown(e: IEditorMouseEvent): void { - if (!e.target.position - || e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS - || e.target.detail.isAfterLines - || !this._sessionResource - ) { - return; - } - - const lineNumber = e.target.position.lineNumber; - - // Lines with existing feedback - do nothing - if (this._feedbackLines.has(lineNumber)) { - return; - } - - // Select the line content and focus the editor - const model = this._editor.getModel(); - if (!model) { - return; - } - - const startColumn = model.getLineFirstNonWhitespaceColumn(lineNumber); - const endColumn = model.getLineLastNonWhitespaceColumn(lineNumber); - if (startColumn === 0 || endColumn === 0) { - // Empty line - select the whole line range - this._editor.setSelection(new Selection(lineNumber, model.getLineMaxColumn(lineNumber), lineNumber, 1)); - } else { - this._editor.setSelection(new Selection(lineNumber, endColumn, lineNumber, startColumn)); - } - this._editor.focus(); - } - - override dispose(): void { - this._updateHintDecoration(-1); - super.dispose(); - } -} - -registerEditorContribution(AgentFeedbackLineDecorationContribution.ID, AgentFeedbackLineDecorationContribution, EditorContributionInstantiation.Eventually); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css deleted file mode 100644 index 6f503b0143fbb..0000000000000 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-editor .agent-feedback-line-decoration, -.monaco-editor .agent-feedback-add-hint { - border-radius: 3px; - display: flex !important; - align-items: center; - justify-content: center; - background-color: var(--vscode-editorHoverWidget-background); - cursor: pointer; - border: 1px solid var(--vscode-editorHoverWidget-border); - box-sizing: border-box; -} - -.monaco-editor .agent-feedback-line-decoration:hover, -.monaco-editor .agent-feedback-add-hint:hover { - background-color: var(--vscode-editorHoverWidget-border); -} - -.monaco-editor .agent-feedback-add-hint { - opacity: 0.7; -} - -.monaco-editor .agent-feedback-add-hint:hover { - opacity: 1; -} diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index aad4b93413754..25c9f5cb9145d 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -15,6 +15,7 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'chat.implicitContext.suggestedContext': false, 'chat.implicitContext.enabled': { 'panel': 'never' }, 'chat.tools.terminal.enableAutoApprove': true, + 'github.copilot.chat.githubMcpServer.enabled': true, 'breadcrumbs.enabled': false, @@ -46,6 +47,8 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'workbench.layoutControl.type': 'toggles', 'workbench.editor.useModal': 'all', 'workbench.panel.showLabels': false, + 'workbench.colorTheme': 'Experimental Dark', + 'search.quickOpen.includeHistory': false, 'window.menuStyle': 'custom', 'window.dialogStyle': 'custom', diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 06553d2cb0fad..7c5acd5cf8868 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -22,7 +22,7 @@ import { ILogService } from '../../../platform/log/common/log.js'; import { isChatViewTitleActionContext } from '../../contrib/chat/common/actions/chatActions.js'; import { IChatAgentRequest, IChatAgentResult, IChatAgentResultTimings, UserSelectedTools } from '../../contrib/chat/common/participants/chatAgents.js'; import { ChatAgentVoteDirection, IChatContentReference, IChatFollowup, IChatResponseErrorDetails, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService/chatService.js'; -import { IChatRequestHooks } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { ChatRequestHooks } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -456,7 +456,7 @@ interface InFlightChatRequest { requestId: string; extRequest: vscode.ChatRequest; extension: IRelaxedExtensionDescription; - hooks?: IChatRequestHooks; + hooks?: ChatRequestHooks; yieldRequested: boolean; } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 841a35e48d293..61a6ce830c289 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -47,7 +47,7 @@ import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js' import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { ChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; @@ -4098,7 +4098,7 @@ export namespace SourceControlInputBoxValidationType { } export namespace ChatRequestHooksConverter { - export function to(hooks: IChatRequestHooks): vscode.ChatRequestHooks { + export function to(hooks: ChatRequestHooks): vscode.ChatRequestHooks { const result: Record = {}; for (const [hookType, commands] of Object.entries(hooks)) { if (!commands || commands.length === 0) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 56f751321425b..bd97ee3ffbb4f 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -30,7 +30,7 @@ import { SnippetString } from './extHostTypes/snippetString.js'; import { SymbolKind, SymbolTag } from './extHostTypes/symbolInformation.js'; import { TextEdit } from './extHostTypes/textEdit.js'; import { WorkspaceEdit } from './extHostTypes/workspaceEdit.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookTypes.js'; export { CodeActionKind } from './extHostTypes/codeActionKind.js'; export { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 29fb309c4db48..4d7b9f7fa8f18 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -185,15 +185,8 @@ const requestInProgressOrPendingToolCall = ContextKeyExpr.or( ChatContextKeys.Editing.hasToolConfirmation, ChatContextKeys.Editing.hasQuestionCarousel, ); -const requestInProgressWithoutInput = ContextKeyExpr.and( - ChatContextKeys.requestInProgress, - ChatContextKeys.inputHasText.negate(), -); -const pendingToolCall = ContextKeyExpr.or( - ChatContextKeys.Editing.hasToolConfirmation, - ChatContextKeys.Editing.hasQuestionCarousel, -); const whenNotInProgress = ChatContextKeys.requestInProgress.negate(); +const whenNoRequestOrPendingToolCall = requestInProgressOrPendingToolCall!.negate(); export class ChatSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.chat.submit'; @@ -202,7 +195,7 @@ export class ChatSubmitAction extends SubmitAction { const menuCondition = ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Ask); const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, - whenNotInProgress, + whenNoRequestOrPendingToolCall, ChatContextKeys.chatSessionOptionsValid, ); @@ -231,7 +224,7 @@ export class ChatSubmitAction extends SubmitAction { id: MenuId.ChatExecute, order: 4, when: ContextKeyExpr.and( - whenNotInProgress, + whenNoRequestOrPendingToolCall, menuCondition, ChatContextKeys.withinEditSessionDiff.negate(), ), @@ -247,7 +240,7 @@ export class ChatSubmitAction extends SubmitAction { order: 4, when: ContextKeyExpr.and( ContextKeyExpr.or(ctxHasEditorModification.negate(), ChatContextKeys.inputHasText), - whenNotInProgress, + whenNoRequestOrPendingToolCall, ChatContextKeys.requestInProgress.negate(), menuCondition ), @@ -664,7 +657,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { constructor() { const notInProgressOrEditing = ContextKeyExpr.and( - ContextKeyExpr.or(whenNotInProgress, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.Sent)), + ContextKeyExpr.or(whenNoRequestOrPendingToolCall, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.Sent)), ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.QueueOrSteer) ); @@ -846,7 +839,8 @@ export class CancelAction extends Action2 { menu: [{ id: MenuId.ChatExecute, when: ContextKeyExpr.and( - ContextKeyExpr.or(requestInProgressWithoutInput, pendingToolCall), + requestInProgressOrPendingToolCall, + ChatContextKeys.inputHasText.negate(), ChatContextKeys.remoteJobCreating.negate(), ChatContextKeys.currentlyEditing.negate(), ), diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index 6606748d653f4..701177fb30d4e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -18,7 +18,12 @@ import { IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; const queuingActionsPresent = ContextKeyExpr.and( - ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.QueueOrSteer)), + ContextKeyExpr.or( + ChatContextKeys.requestInProgress, + ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.QueueOrSteer), + ChatContextKeys.Editing.hasQuestionCarousel, + ChatContextKeys.Editing.hasToolConfirmation, + ), ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.Sent), ); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 91e60f599dee2..7d8e0512eafb1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -22,6 +22,7 @@ import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpServerCacheState, McpServerEditorTab } from '../../../mcp/common/mcpTypes.js'; import { startServerAndWaitForLiveTools } from '../../../mcp/common/mcpTypesUtils.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; +import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; import { ConfigureToolSets } from '../tools/toolSetsContribution.js'; @@ -31,7 +32,7 @@ const enum BucketOrdinal { User, BuiltIn, Mcp, Extension } type BucketPick = IQuickPickItem & { picked: boolean; ordinal: BucketOrdinal; status?: string; toolset?: ToolSet; children: (ToolPick | ToolSetPick)[] }; type ToolSetPick = IQuickPickItem & { picked: boolean; toolset: ToolSet; parent: BucketPick }; type ToolPick = IQuickPickItem & { picked: boolean; tool: IToolData; parent: BucketPick }; -type ActionableButton = IQuickInputButton & { action: () => void }; +type ActionableButton = IQuickInputButton & { action: () => void; keepOpen?: boolean }; // New QuickTree types for tree-based implementation @@ -77,6 +78,7 @@ interface IToolSetTreeItem extends IToolTreeItem { interface IToolTreeItemData extends IToolTreeItem { readonly itemType: 'tool'; readonly tool: IToolData; + buttons?: ActionableButton[]; checked: boolean; } @@ -205,6 +207,7 @@ export async function showToolsPicker( const editorService = accessor.get(IEditorService); const mcpWorkbenchService = accessor.get(IMcpWorkbenchService); const toolsService = accessor.get(ILanguageModelToolsService); + const confirmationService = accessor.get(ILanguageModelToolsConfirmationService); const telemetryService = accessor.get(ITelemetryService); const mcpServerByTool = new Map(); @@ -451,6 +454,38 @@ export async function showToolsPicker( } } } + // Add approval management buttons to tool items that support confirmation + for (const bucket of sortedBuckets) { + const isMcpBucket = bucket.ordinal === BucketOrdinal.Mcp; + const addConfirmationButton = (toolItem: IToolTreeItemData) => { + if (!confirmationService.toolCanManageConfirmation(toolItem.tool)) { + return; + } + const tool = toolItem.tool; + const manageTools = isMcpBucket ? bucket.children.flatMap(c => isToolTreeItem(c) ? [c.tool] : isToolSetTreeItem(c) && c.children ? c.children.filter(isToolTreeItem).map(gc => gc.tool) : []) : [tool]; + const buttons: ActionableButton[] = toolItem.buttons ? [...toolItem.buttons] : []; + buttons.push({ + iconClass: ThemeIcon.asClassName(Codicon.pass), + tooltip: localize('manageToolApproval', "Manage Approval"), + keepOpen: true, + action: () => confirmationService.manageConfirmationPreferences(manageTools, { focusToolId: tool.id }) + }); + toolItem.buttons = buttons; + }; + + for (const child of bucket.children) { + if (isToolTreeItem(child)) { + addConfirmationButton(child); + } else if (isToolSetTreeItem(child) && child.children) { + for (const grandchild of child.children) { + if (isToolTreeItem(grandchild)) { + addConfirmationButton(grandchild); + } + } + } + } + } + if (treeItems.length === 0) { treePicker.placeholder = localize('noTools', "Add tools to chat"); } else { @@ -474,7 +509,8 @@ export async function showToolsPicker( // Handle button triggers store.add(treePicker.onDidTriggerItemButton(e => { if (e.button && typeof (e.button as ActionableButton).action === 'function') { - (e.button as ActionableButton).action(); + const actionableButton = e.button as ActionableButton; + actionableButton.action(); store.dispose(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 21c59e68f7691..129cbad928f6d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../base/browser/dom.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; import { IPagedRenderer } from '../../../../base/browser/ui/list/listPaging.js'; import { Action, IAction, Separator } from '../../../../base/common/actions.js'; @@ -12,7 +13,8 @@ import { RunOnceScheduler } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, disposeIfDisposable, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { autorun } from '../../../../base/common/observable.js'; import { IPagedModel, PagedModel } from '../../../../base/common/paging.js'; import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; @@ -38,6 +40,7 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IViewDescriptorService, IViewsRegistry, Extensions as ViewExtensions } from '../../../common/views.js'; import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution.js'; +import { manageExtensionIcon } from '../../extensions/browser/extensionsIcons.js'; import { AbstractExtensionsListView } from '../../extensions/browser/extensionsViews.js'; import { DefaultViewsContext, extensionsFilterSubMenu, IExtensionsWorkbenchService, SearchAgentPluginsContext } from '../../extensions/common/extensions.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; @@ -180,6 +183,71 @@ class OpenPluginReadmeAction extends Action { } } +function getInstalledPluginContextMenuActionGroups(plugin: IAgentPlugin, instantiationService: IInstantiationService): IAction[][] { + const groups: IAction[][] = []; + if (plugin.enabled.get()) { + groups.push([instantiationService.createInstance(DisablePluginAction, plugin)]); + } else { + groups.push([instantiationService.createInstance(EnablePluginAction, plugin)]); + } + groups.push([ + instantiationService.createInstance(OpenPluginFolderAction, plugin), + instantiationService.createInstance(OpenPluginReadmeAction, joinPath(plugin.uri, 'README.md')), + ]); + groups.push([instantiationService.createInstance(UninstallPluginAction, plugin)]); + return groups; +} + +class ManagePluginAction extends Action { + static readonly ID = 'agentPlugin.manage'; + static readonly CLASS = `extension-action icon manage ${ThemeIcon.asClassName(manageExtensionIcon)}`; + + private _actionViewItem: DropDownActionViewItem | null = null; + + constructor( + private readonly getActionGroups: () => IAction[][], + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(ManagePluginAction.ID, '', ManagePluginAction.CLASS, true); + this.tooltip = localize('manage', "Manage"); + } + + createActionViewItem(options: IActionViewItemOptions): DropDownActionViewItem { + this._actionViewItem = this.instantiationService.createInstance(DropDownActionViewItem, this, options); + return this._actionViewItem; + } + + override async run(): Promise { + this._actionViewItem?.showMenu(this.getActionGroups()); + } +} + +class DropDownActionViewItem extends ActionViewItem { + constructor( + action: IAction, + options: IActionViewItemOptions, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + ) { + super(null, action, { ...options, icon: true, label: false }); + } + + showMenu(actionGroups: IAction[][]): void { + if (!this.element) { + return; + } + const actions = actionGroups.flatMap(group => [...group, new Separator()]); + if (actions.length > 0) { + actions.pop(); + } + const { left, top, height } = dom.getDomNodePagePosition(this.element); + this.contextMenuService.showContextMenu({ + getAnchor: () => ({ x: left, y: top + height + 10 }), + getActions: () => actions, + onHide: () => disposeIfDisposable(actions), + }); + } +} + //#endregion //#region Renderer @@ -213,7 +281,15 @@ class AgentPluginRenderer implements IPagedRenderer { + if (action instanceof ManagePluginAction) { + return action.createActionViewItem(options); + } + return undefined; + } + }); actionbar.setFocusable(false); return { root, name, description, detail, actionbar, disposables: [actionbar], elementDisposables: [] }; } @@ -244,6 +320,10 @@ class AgentPluginRenderer implements IPagedRenderer getInstalledPluginContextMenuActionGroups(element.plugin, this.instantiationService)); + data.elementDisposables.push(manageAction); + data.actionbar.push([manageAction], { icon: true, label: false }); } } @@ -383,20 +463,15 @@ export class AgentPluginsListView extends AbstractExtensionsListView [...group, new Separator()]); + if (actions.length > 0) { + actions.pop(); } - - actions.push(new Separator()); - actions.push(this.instantiationService.createInstance(OpenPluginFolderAction, item.plugin)); - actions.push(this.instantiationService.createInstance(OpenPluginReadmeAction, joinPath(item.plugin.uri, 'README.md'))); - actions.push(new Separator()); - actions.push(this.instantiationService.createInstance(UninstallPluginAction, item.plugin)); } else { + actions = []; if (item.readmeUri) { actions.push(this.instantiationService.createInstance(OpenPluginReadmeAction, item.readmeUri)); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 36d5ff8263c3a..727d81d2f94d5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -8,7 +8,10 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; -import { $, append, EventHelper } from '../../../../../base/browser/dom.js'; +import { $, append, EventHelper, addDisposableListener, EventType, hide, setVisibility } from '../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { localize } from '../../../../../nls.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter, IAgentSessionsSorterOptions } from './agentSessionsViewer.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; @@ -71,6 +74,8 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private sessionsContainer: HTMLElement | undefined; get element(): HTMLElement | undefined { return this.sessionsContainer; } + private emptyFilterMessage: HTMLElement | undefined; + private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; private sessionsListFindIsOpen = false; @@ -106,7 +111,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.focusedAgentSessionTypeContextKey = ChatContextKeys.agentSessionType.bindTo(this.contextKeyService); this.hasMultipleAgentSessionsSelectedContextKey = ChatContextKeys.hasMultipleAgentSessionsSelected.bindTo(this.contextKeyService); - this.createList(this.container); + this.create(this.container); this.registerListeners(); } @@ -140,9 +145,35 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } } - private createList(container: HTMLElement): void { + private create(container: HTMLElement): void { this.sessionsContainer = append(container, $('.agent-sessions-viewer')); + this.createEmptyFilterMessage(this.sessionsContainer); + this.createList(this.sessionsContainer); + } + + private createEmptyFilterMessage(container: HTMLElement): void { + this.emptyFilterMessage = append(container, $('.agent-sessions-empty-filter-message')); + hide(this.emptyFilterMessage); + + const span = append(this.emptyFilterMessage, $('span')); + span.textContent = localize('agentSessions.noFilterResults', "No matching sessions."); + + const link = append(this.emptyFilterMessage, $('span.reset-filter-link')); + link.textContent = localize('agentSessions.clearFilters', "Clear Filter"); + link.tabIndex = 0; + link.setAttribute('role', 'button'); + this._register(addDisposableListener(link, EventType.CLICK, () => this.options.filter.reset())); + this._register(addDisposableListener(link, EventType.KEY_DOWN, (e) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) { + EventHelper.stop(e, true); + this.options.filter.reset(); + } + })); + } + + private createList(container: HTMLElement): void { const collapseByDefault = (element: unknown) => { if (isAgentSessionSection(element)) { if (element.section === AgentSessionSection.More && !this.options.filter.getExcludes().read) { @@ -168,16 +199,17 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo const sorter = new AgentSessionsSorter(this.options); const approvalModel = this.options.enableApprovalRow ? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)) : undefined; const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, this.options, approvalModel)); + const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', - this.sessionsContainer, + container, new AgentSessionsListDelegate(approvalModel), new AgentSessionsCompressionDelegate(), [ sessionRenderer, this.instantiationService.createInstance(AgentSessionSectionRenderer), ], - new AgentSessionsDataSource(this.options.filter, sorter), + sessionFilter, { accessibilityProvider: new AgentSessionsAccessibilityProvider(), dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), @@ -202,6 +234,10 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); + this._register(sessionFilter.onDidGetChildren(count => { + this.updateEmptyFilterMessage(count); + })); + const model = this.agentSessionsService.model; this._register(this.options.filter.onDidChange(async () => { @@ -251,6 +287,20 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo })); } + private updateEmptyFilterMessage(visibleChildren: number): void { + if (!this.emptyFilterMessage || !this.sessionsList) { + return; + } + + const model = this.agentSessionsService.model; + const hasSessionsInModel = model.sessions.length > 0; + const hasVisibleChildren = visibleChildren > 0; + const isFilterActive = !this.options.filter.isDefault(); + + const showMessage = hasSessionsInModel && !hasVisibleChildren && isFilterActive; + setVisibility(showMessage, this.emptyFilterMessage); + } + private hasTodaySessions(): boolean { const startOfToday = new Date().setHours(0, 0, 0, 0); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 65bfc186494c9..c6cabc14a0d72 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -287,7 +287,7 @@ export class AgentSessionsFilter extends Disposable implements Required { +export class AgentSessionsDataSource extends Disposable implements IAsyncDataSource { private static readonly CAPPED_SESSIONS_LIMIT = 3; + private readonly _onDidGetChildren = this._register(new Emitter()); + readonly onDidGetChildren: Event = this._onDidGetChildren.event; + constructor( private readonly filter: IAgentSessionsFilter | undefined, private readonly sorter: ITreeSorter, - ) { } + ) { + super(); + } hasChildren(element: IAgentSessionsModel | AgentSessionListItem): boolean { @@ -741,6 +756,7 @@ export class AgentSessionsDataSource implements IAsyncDataSource 0) { parsedHooks = true; for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_TYPES.find(h => h.id === hookType); + const hookMeta = HOOK_METADATA[hookType]; for (let i = 0; i < entry.hooks.length; i++) { const hook = entry.hooks[i]; const cmdLabel = formatHookCommandLabel(hook, OS); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 7b3d437b0efe3..e3b8f65df506b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -26,7 +26,7 @@ import { IListVirtualDelegate, IListRenderer } from '../../../../../base/browser import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { basename, isEqual, joinPath } from '../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { registerColor } from '../../../../../platform/theme/common/colorRegistry.js'; import { PANEL_BORDER } from '../../../../common/theme.js'; @@ -47,7 +47,7 @@ import { } from './aiCustomizationManagement.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon } from './aiCustomizationIcons.js'; import { ChatModelsWidget } from '../chatManagement/chatModelsWidget.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { INewPromptOptions, NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../promptSyntax/newPromptFileActions.js'; import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; @@ -60,12 +60,8 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; -import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { HOOKS_SOURCE_FOLDER } from '../../common/promptSyntax/config/promptFileLocations.js'; -import { COPILOT_CLI_HOOK_TYPE_MAP } from '../../common/promptSyntax/hookSchema.js'; import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js'; import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -194,7 +190,6 @@ export class AICustomizationManagementEditor extends EditorPane { @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, @ITextFileService private readonly textFileService: ITextFileService, - @IFileService private readonly fileService: IFileService, @IFileDialogService private readonly fileDialogService: IFileDialogService, @IHoverService private readonly hoverService: IHoverService, ) { @@ -583,15 +578,21 @@ export class AICustomizationManagementEditor extends EditorPane { if (type === PromptsType.hook) { if (this.workspaceService.isSessionsWindow) { - // Sessions: directly create a Copilot CLI format hooks file - await this.createCopilotCliHookFile(); - } else { - // Core: show the configure hooks quick pick + // Sessions: show hooks filtered to Copilot CLI (GitHub Copilot) hook types await this.instantiationService.invokeFunction(showConfigureHooksQuickPick, { openEditor: async (resource) => { await this.showEmbeddedEditor(resource, basename(resource), true); return; }, + target: Target.GitHubCopilot, + }); + } else { + // Core: use the default core behaviour + await this.instantiationService.invokeFunction(showConfigureHooksQuickPick, { + openEditor: async (resource) => { + await this.showEmbeddedEditor(resource, basename(resource), true); + return; + } }); } return; @@ -624,36 +625,6 @@ export class AICustomizationManagementEditor extends EditorPane { void this.listWidget.refresh(); } - /** - * Ensures a Copilot CLI format hooks file exists (.github/hooks/hooks.json), - * then opens the configure hooks quick pick. - */ - private async createCopilotCliHookFile(): Promise { - const projectRoot = this.workspaceService.getActiveProjectRoot(); - if (!projectRoot) { - return; - } - - const hookFileUri = joinPath(projectRoot, HOOKS_SOURCE_FOLDER, 'hooks.json'); - - // Create the file with all hook events if it doesn't exist - try { - await this.fileService.stat(hookFileUri); - } catch { - // Derive hook event names from the schema so new events are automatically included - const hooks: Record = {}; - for (const eventName of Object.keys(COPILOT_CLI_HOOK_TYPE_MAP)) { - hooks[eventName] = [{ type: 'command', bash: '' }]; - } - const hooksContent = { version: 1, hooks }; - const jsonContent = JSON.stringify(hooksContent, null, '\t'); - await this.fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); - } - - await this.showEmbeddedEditor(hookFileUri, basename(hookFileUri), true); - void this.listWidget.refresh(); - } - override updateStyles(): void { const borderColor = this.theme.getColor(aiCustomizationManagementSashBorder); if (borderColor) { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index c1cb465e781e4..954b313b84a88 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -37,7 +37,7 @@ import '../common/widget/chatColors.js'; import { IChatEditingService } from '../common/editing/chatEditingService.js'; import { IChatLayoutService } from '../common/widget/chatLayoutService.js'; import { ChatModeService, IChatMode, IChatModeService } from '../common/chatModes.js'; -import { ChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; +import { ChatResponseResourceFileSystemProvider, IChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; import { IChatService } from '../common/chatService/chatService.js'; import { ChatService } from '../common/chatService/chatServiceImpl.js'; import { IChatSessionsService } from '../common/chatSessionsService.js'; @@ -653,12 +653,11 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['preview'], }, - [ChatConfiguration.PluginPaths]: { + [ChatConfiguration.PluginLocations]: { type: 'object', additionalProperties: { type: 'boolean' }, restricted: true, - markdownDescription: nls.localize('chat.plugins.paths', "Plugin directories to discover. Each key is a path that points directly to a plugin folder, and the value enables (`true`) or disables (`false`) it. Paths can be absolute or relative to the workspace root."), - default: {}, + markdownDescription: nls.localize('chat.pluginLocations', "Plugin directories to discover. Each key is a path that points directly to a plugin folder, and the value enables (`true`) or disables (`false`) it. Paths can be absolute, relative to the workspace root, or start with `~/` for the user's home directory."), scope: ConfigurationScope.MACHINE, tags: ['experimental'], }, @@ -1323,6 +1322,13 @@ Registry.as(Extensions.ConfigurationMigration). return []; } }, + { + key: 'chat.plugins.paths', + migrateFn: (value: unknown, _accessor) => ([ + ['chat.plugins.paths', { value: undefined }], + [ChatConfiguration.PluginLocations, { value }] + ]) + }, ]); class ChatResolverContribution extends Disposable { @@ -1735,7 +1741,7 @@ registerWorkbenchContribution2(SimpleBrowserOverlay.ID, SimpleBrowserOverlay, Wo registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatContextContributions.ID, ChatContextContributions, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatResponseResourceFileSystemProvider.ID, ChatResponseResourceFileSystemProvider, WorkbenchPhase.AfterRestored); +registerSingleton(IChatResponseResourceFileSystemProvider, ChatResponseResourceFileSystemProvider, InstantiationType.Eager); registerWorkbenchContribution2(PromptUrlHandler.ID, PromptUrlHandler, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContributions, WorkbenchPhase.Eventually); 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 4f53329ca1b79..10dc9a1739356 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -50,7 +50,7 @@ import { IEditorGroupsService } from '../../../../services/editor/common/editorG import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { Target } from '../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../common/promptSyntax/promptTypes.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 9b3796035309f..d1cc0bba474e0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -32,7 +32,7 @@ import { globalAutoApproveDescription } from './tools/languageModelToolsService.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; -import { Target } from '../common/promptSyntax/service/promptsService.js'; +import { Target } from '../common/promptSyntax/promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; export class ChatSlashCommandsContribution extends Disposable { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index 00255f745086a..db3d6f6acc2b7 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -19,11 +19,12 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { Codicon } from '../../../../../base/common/codicons.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; -import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; +import { HOOK_METADATA, HOOKS_BY_TARGET, HookType, IHookTypeMeta } from '../../common/promptSyntax/hookTypes.js'; +import { 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'; @@ -38,7 +39,7 @@ import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browse import { Range } from '../../../../../editor/common/core/range.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; -import { OS } from '../../../../../base/common/platform.js'; +import { OperatingSystem, OS } from '../../../../../base/common/platform.js'; /** * Action ID for the `Configure Hooks` action. @@ -46,7 +47,8 @@ import { OS } from '../../../../../base/common/platform.js'; const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks'; interface IHookTypeQuickPickItem extends IQuickPickItem { - readonly hookType: typeof HOOK_TYPES[number]; + readonly hookType: HookType; + readonly hookTypeMeta: IHookTypeMeta; } interface IHookQuickPickItem extends IQuickPickItem { @@ -296,15 +298,17 @@ const enum Step { } /** - * Optional callbacks for customizing the hook creation and opening behaviour. + * Optional callbacks and settings for customizing the hook creation and opening behaviour. * The agentic editor passes these to open hooks in the embedded editor and * track worktree files for auto-commit. */ -export interface IHookQuickPickCallbacks { +export interface IHookQuickPickOptions { /** Override how the hook file is opened. If not provided, uses editorService.openEditor. */ readonly openEditor?: (resource: URI, options?: { selection?: ITextEditorSelection }) => Promise; /** Called after a new hook file is created on disk. */ readonly onHookFileCreated?: (uri: URI) => void; + /** Filter the displayed hook types to those supported by the given target. */ + readonly target?: Target; } /** @@ -313,7 +317,7 @@ export interface IHookQuickPickCallbacks { */ export async function showConfigureHooksQuickPick( accessor: ServicesAccessor, - callbacks?: IHookQuickPickCallbacks, + options?: IHookQuickPickOptions, ): Promise { const promptsService = accessor.get(IPromptsService); const quickInputService = accessor.get(IQuickInputService); @@ -379,18 +383,52 @@ export async function showConfigureHooksQuickPick( while (true) { switch (step) { case Step.SelectHookType: { - // Step 1: Show all lifecycle events with hook counts - const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => { - const count = hookCountByType.get(hookType.id) ?? 0; + // Step 1: Show lifecycle events with hook counts, filtered by target + const makeItem = ([hookType, meta]: [HookType, IHookTypeMeta]): IHookTypeQuickPickItem => { + const count = hookCountByType.get(hookType) ?? 0; const countLabel = count > 0 ? ` (${count})` : ''; return { - label: `${hookType.label}${countLabel}`, - description: hookType.description, - hookType + label: `${meta.label}${countLabel}`, + description: meta.description, + hookType, + hookTypeMeta: meta }; - }); + }; + + let pickerItems: (IHookTypeQuickPickItem | IQuickPickSeparator)[]; + + if (options?.target) { + // Filtered to a specific target + const targetHookTypes = new Set(Object.values(HOOKS_BY_TARGET[options.target])); + pickerItems = (Object.entries(HOOK_METADATA) as [HookType, IHookTypeMeta][]) + .filter(([hookType]) => targetHookTypes.has(hookType)) + .map(makeItem); + } else { + // No target: group into Default (shared), VS Code Only, Copilot CLI Only + const vscodeTypes = new Set(Object.values(HOOKS_BY_TARGET[Target.VSCode])); + const copilotTypes = new Set(Object.values(HOOKS_BY_TARGET[Target.GitHubCopilot])); + const allEntries = Object.entries(HOOK_METADATA) as [HookType, IHookTypeMeta][]; + + const shared = allEntries.filter(([h]) => vscodeTypes.has(h) && copilotTypes.has(h)); + const vscodeOnly = allEntries.filter(([h]) => vscodeTypes.has(h) && !copilotTypes.has(h)); + const copilotOnly = allEntries.filter(([h]) => !vscodeTypes.has(h) && copilotTypes.has(h)); + + pickerItems = []; + if (shared.length > 0) { + pickerItems.push({ type: 'separator', label: localize('hookSection.default', "Local/Copilot CLI Agents") }); + pickerItems.push(...shared.map(makeItem)); + } + if (vscodeOnly.length > 0) { + pickerItems.push({ type: 'separator', label: localize('hookSection.vscodeOnly', "Local Agents") }); + pickerItems.push(...vscodeOnly.map(makeItem)); + } + if (copilotOnly.length > 0) { + pickerItems.push({ type: 'separator', label: localize('hookSection.copilotCliOnly', "Copilot CLI Agents") }); + pickerItems.push(...copilotOnly.map(makeItem)); + } + } - picker.items = hookTypeItems; + picker.items = pickerItems; picker.value = ''; picker.placeholder = localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'); picker.title = localize('commands.hooks.title', 'Hooks'); @@ -411,7 +449,7 @@ export async function showConfigureHooksQuickPick( case Step.SelectHook: { // Filter hooks by the selected type - const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType.id); + const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType); // Step 2: Show "Add new hook" + existing hooks of this type const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; @@ -447,7 +485,7 @@ export async function showConfigureHooksQuickPick( picker.items = hookItems; picker.value = ''; picker.placeholder = localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'); - picker.title = selectedHookType!.hookType.label; + picker.title = selectedHookType!.hookTypeMeta.label; picker.buttons = [backButton]; const result = await awaitPick(picker, backButton); @@ -488,8 +526,8 @@ export async function showConfigureHooksQuickPick( } picker.hide(); - if (callbacks?.openEditor) { - await callbacks.openEditor(entry.fileUri, { selection }); + if (options?.openEditor) { + await options.openEditor(entry.fileUri, { selection }); } else { await editorService.openEditor({ resource: entry.fileUri, @@ -566,12 +604,12 @@ export async function showConfigureHooksQuickPick( picker.hide(); await addHookToFile( selectedFile.fileUri, - selectedHookType!.hookType.id as HookType, + selectedHookType!.hookType, fileService, editorService, notificationService, bulkEditService, - callbacks?.openEditor, + options?.openEditor, ); return; } @@ -698,12 +736,12 @@ export async function showConfigureHooksQuickPick( store.dispose(); await addHookToFile( hookFileUri, - selectedHookType!.hookType.id as HookType, + selectedHookType!.hookType, fileService, editorService, notificationService, bulkEditService, - callbacks?.openEditor, + options?.openEditor, ); return; } @@ -711,13 +749,24 @@ export async function showConfigureHooksQuickPick( // Detect if new file is a Claude hooks file based on its path const newFileFormat = getHookSourceFormat(hookFileUri); const isClaudeNewFile = newFileFormat === HookSourceFormat.Claude; + const isCopilotCliOnly = !isClaudeNewFile + && !new Set(Object.values(HOOKS_BY_TARGET[Target.VSCode])).has(selectedHookType!.hookType) + && new Set(Object.values(HOOKS_BY_TARGET[Target.GitHubCopilot])).has(selectedHookType!.hookType); const hookTypeKey = isClaudeNewFile - ? (getClaudeHookTypeName(selectedHookType!.hookType.id as HookType) ?? selectedHookType!.hookType.id) - : selectedHookType!.hookType.id; - const newFileHookEntry = buildNewHookEntry(newFileFormat); + ? (getClaudeHookTypeName(selectedHookType!.hookType) ?? selectedHookType!.hookType) + : isCopilotCliOnly + ? (getCopilotCliHookTypeName(selectedHookType!.hookType) ?? selectedHookType!.hookType) + : selectedHookType!.hookType; + const newFileHookEntry = isCopilotCliOnly + ? { type: 'command', [targetOS === OperatingSystem.Windows ? 'powershell' : 'bash']: '' } + : buildNewHookEntry(newFileFormat); + const commandFieldKey = isCopilotCliOnly + ? (targetOS === OperatingSystem.Windows ? 'powershell' : 'bash') + : 'command'; // Create new hook file with the selected hook type - const hooksContent = { + const hooksContent: Record = { + ...(isCopilotCliOnly ? { version: 1 } : {}), hooks: { [hookTypeKey]: [ newFileHookEntry @@ -728,15 +777,15 @@ export async function showConfigureHooksQuickPick( const jsonContent = JSON.stringify(hooksContent, null, '\t'); await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); - callbacks?.onHookFileCreated?.(hookFileUri); + options?.onHookFileCreated?.(hookFileUri); // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command'); + const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, commandFieldKey); // Open editor with selection store.dispose(); - if (callbacks?.openEditor) { - await callbacks.openEditor(hookFileUri, { selection }); + if (options?.openEditor) { + await options.openEditor(hookFileUri, { selection }); } else { await editorService.openEditor({ resource: hookFileUri, diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts index e6dd6668f35ef..e5eac07cdb7a4 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -10,7 +10,8 @@ import { IPromptsService } from '../../common/promptSyntax/service/promptsServic import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { formatHookCommandLabel, HOOK_TYPES, HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; +import { formatHookCommandLabel, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; +import { HOOK_METADATA, HookType } from '../../common/promptSyntax/hookTypes.js'; import { parseHooksFromFile, parseHooksIgnoringDisableAll } from '../../common/promptSyntax/hookCompatibility.js'; import * as nls from '../../../../../nls.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -161,7 +162,7 @@ export async function parseAllHookFiles( const { hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); for (const [hookType, { hooks: commands, originalId }] of hooks) { - const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + const hookTypeMeta = HOOK_METADATA[hookType]; if (!hookTypeMeta) { continue; } @@ -199,7 +200,7 @@ export async function parseAllHookFiles( const { hooks } = parseHooksIgnoringDisableAll(uri, json, workspaceRootUri, userHome); for (const [hookType, { hooks: commands, originalId }] of hooks) { - const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + const hookTypeMeta = HOOK_METADATA[hookType]; if (!hookTypeMeta) { continue; } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index fc1d34ba0996f..54a379abecc8b 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -16,7 +16,7 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, NeverShowAgainScope, Severity } from '../../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { getLanguageIdForPromptsType, PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { getLanguageIdForPromptsType, PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { IUserDataSyncEnablementService, SyncResource } from '../../../../../platform/userDataSync/common/userDataSync.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { CONFIGURE_SYNC_COMMAND_ID } from '../../../../services/userDataSync/common/userDataSync.js'; @@ -26,7 +26,7 @@ import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; -import { Target, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { getTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; /** diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index ac323f512e4e3..e231b63d6d7f8 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -14,8 +14,8 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { showToolsPicker } from '../actions/chatToolPicker.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; -import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptsService, Target } from '../../common/promptSyntax/service/promptsService.js'; +import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { registerEditorFeature } from '../../../../../editor/common/editorFeatures.js'; import { PromptFileRewriter } from './promptFileRewriter.js'; import { Range } from '../../../../../editor/common/core/range.js'; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts index 50a3495c4f845..3661f5a27c379 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts @@ -424,7 +424,15 @@ export class LanguageModelToolsConfirmationService extends Disposable implements }; } - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void { + toolCanManageConfirmation(tool: IToolData): boolean { + return !!tool.canRequestPreApproval + || !!tool.canRequestPostApproval + || this._contributions.has(tool.id) + || !!this._preExecutionToolConfirmStore.checkAutoConfirmation(tool.id) + || !!this._postExecutionToolConfirmStore.checkAutoConfirmation(tool.id); + } + + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void { interface IToolTreeItem extends IQuickTreeItem { type: 'tool' | 'server' | 'tool-pre' | 'tool-post' | 'server-pre' | 'server-post' | 'manage'; toolId?: string; @@ -690,7 +698,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements description, checked, pickable, - collapsed: true, + collapsed: tools.length > 1, children: toolChildren.length > 0 ? toolChildren : undefined }); } @@ -773,12 +781,12 @@ export class LanguageModelToolsConfirmationService extends Disposable implements } })); - disposables.add(quickTree.onDidAccept(() => { - for (const item of quickTree.activeItems) { - if (item.type === 'manage') { - (item as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidOpen?.(); - quickTree.hide(); - } + disposables.add(quickTree.onDidAccept(async () => { + const manageItem = quickTree.activeItems.find(i => i.type === 'manage'); + if (manageItem) { + quickTree.hide(); + await (manageItem as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidOpen?.(); + this.manageConfirmationPreferences(tools, options); } })); @@ -787,6 +795,23 @@ export class LanguageModelToolsConfirmationService extends Disposable implements })); quickTree.show(); + + // If a focus tool was specified, expand its parent and set it as active. + // Must happen after show() since the tree data is applied via autorun on visibility. + if (options?.focusToolId) { + const focusToolId = options.focusToolId; + for (const serverItem of quickTree.itemTree) { + const serverItemTyped = serverItem as IToolTreeItem; + if (serverItemTyped.children) { + const toolItem = (serverItemTyped.children as IToolTreeItem[]).find(c => c.type === 'tool' && c.toolId === focusToolId); + if (toolItem) { + quickTree.expand(serverItem); + quickTree.reveal(toolItem); + break; + } + } + } + } } public resetToolAutoConfirmation(): void { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 41ef02090b8fd..13eb89e07cbaf 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -45,7 +45,7 @@ import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; import { chatSessionResourceToId } from '../../common/model/chatUri.js'; -import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolInvocationPresentation, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; 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 2ed104c1de8be..e7f8800ecee97 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts @@ -10,14 +10,14 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IChatHookPart } from '../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; -import { HookType, HOOK_TYPES, HookTypeValue } from '../../../common/promptSyntax/hookSchema.js'; +import { HookType, HOOK_METADATA, HookTypeValue } from '../../../common/promptSyntax/hookTypes.js'; import { ChatTreeItem } from '../../chat.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import './media/chatHookContentPart.css'; function getHookTypeLabel(hookType: HookTypeValue): string { - return HOOK_TYPES.find(hook => hook.id === hookType)?.label ?? hookType; + return HOOK_METADATA[hookType as HookType]?.label ?? hookType; } export class ChatHookContentPart extends ChatCollapsibleContentPart implements IChatContentPart { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 5ce3306ae08fa..996b73a63f377 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -10,6 +10,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { isMacintosh } from '../../../../../../base/common/platform.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; @@ -27,12 +28,9 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import './media/chatQuestionCarousel.css'; -const PREVIOUS_QUESTION_ACTION_ID = 'workbench.action.chat.previousQuestion'; -const NEXT_QUESTION_ACTION_ID = 'workbench.action.chat.nextQuestion'; export interface IChatQuestionCarouselOptions { onSubmit: (answers: Map | undefined) => void; shouldAutoFocus?: boolean; @@ -46,17 +44,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _currentIndex = 0; private readonly _answers = new Map(); + private readonly _explicitlyAnsweredQuestionIds = new Set(); private _questionContainer: HTMLElement | undefined; private _closeButtonContainer: HTMLElement | undefined; + private _tabBar: HTMLElement | undefined; + private _tabItems: HTMLElement[] = []; + private readonly _questionTabIndicators = new Map(); + private _reviewIndex = -1; private _footerRow: HTMLElement | undefined; - private _stepIndicator: HTMLElement | undefined; - private _navigationButtons: HTMLElement | undefined; - private _prevButton: Button | undefined; - private _nextButton: Button | undefined; - private readonly _nextButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); - private _submitButton: Button | undefined; - private readonly _submitButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); private _skipAllButton: Button | undefined; private _isSkipped = false; @@ -83,7 +79,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent @IHoverService private readonly _hoverService: IHoverService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, - @IKeybindingService private readonly _keybindingService: IKeybindingService, ) { super(); @@ -135,7 +130,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._interactiveUIStore.value = interactiveStore; // Question container + const questionPanelId = `question-panel-${this.carousel.questions[0]?.id ?? 'default'}`; this._questionContainer = dom.$('.chat-question-carousel-content'); + this._questionContainer.setAttribute('role', 'tabpanel'); + this._questionContainer.id = questionPanelId; this.domNode.append(this._questionContainer); // Close/skip button (X) - placed in header row, only shown when allowSkip is true @@ -150,49 +148,75 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._skipAllButton = skipAllButton; } - // Footer row with step indicator and navigation buttons - this._footerRow = dom.$('.chat-question-footer-row'); - - // Step indicator (e.g., "2/4") on the left - this._stepIndicator = dom.$('.chat-question-step-indicator'); - this._footerRow.appendChild(this._stepIndicator); - - // Navigation controls (< >) - placed in footer row - this._navigationButtons = dom.$('.chat-question-carousel-nav'); - this._navigationButtons.setAttribute('role', 'navigation'); - this._navigationButtons.setAttribute('aria-label', localize('chat.questionCarousel.navigation', 'Question navigation')); - - // Group prev/next buttons together - const arrowsContainer = dom.$('.chat-question-nav-arrows'); - - const previousLabel = localize('previous', 'Previous'); - const previousLabelWithKeybinding = this.getLabelWithKeybinding(previousLabel, PREVIOUS_QUESTION_ACTION_ID); - const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev'); - prevButton.label = `$(${Codicon.chevronLeft.id})`; - prevButton.element.setAttribute('aria-label', previousLabelWithKeybinding); - interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabelWithKeybinding })); - this._prevButton = prevButton; + const isSingleQuestion = this.carousel.questions.length === 1; - const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next'); - nextButton.label = `$(${Codicon.chevronRight.id})`; - this._nextButton = nextButton; + if (!isSingleQuestion) { + this._reviewIndex = this.carousel.questions.length; + + // Multi-question: Create tab bar with question tabs and Review tab + this._tabBar = dom.$('.chat-question-tab-bar'); + const tabList = dom.$('.chat-question-tabs'); + tabList.setAttribute('role', 'tablist'); + tabList.setAttribute('aria-label', localize('chat.questionCarousel.tabBarLabel', 'Questions')); + this._tabBar.appendChild(tabList); + + this.carousel.questions.forEach((question, index) => { + const tab = dom.$('.chat-question-tab'); + tab.setAttribute('role', 'tab'); + tab.setAttribute('aria-selected', index === 0 ? 'true' : 'false'); + tab.tabIndex = index === 0 ? 0 : -1; + tab.id = `question-tab-${question.id}-${index}`; + tab.setAttribute('aria-controls', questionPanelId); + + const displayTitle = this.getQuestionText(question.title); + const tabIndicator = dom.$('.chat-question-tab-indicator.codicon'); + const tabLabel = dom.$('span.chat-question-tab-label'); + tabLabel.textContent = displayTitle; + tab.append(tabIndicator, tabLabel); + tab.setAttribute('aria-label', displayTitle); + this._questionTabIndicators.set(question.id, tabIndicator); + + interactiveStore.add(dom.addDisposableListener(tab, dom.EventType.CLICK, () => { + this.saveCurrentAnswer(); + this._currentIndex = index; + this.renderCurrentQuestion(true); + tab.focus(); + })); - const submitButton = interactiveStore.add(new Button(this._navigationButtons, { ...defaultButtonStyles })); - submitButton.element.classList.add('chat-question-submit-button'); - submitButton.label = localize('submit', 'Submit'); - this._submitButton = submitButton; + tabList.appendChild(tab); + this._tabItems.push(tab); + }); - this._navigationButtons.appendChild(arrowsContainer); - this._footerRow.appendChild(this._navigationButtons); - this.domNode.append(this._footerRow); + // Review tab + const reviewTab = dom.$('.chat-question-tab.no-icon'); + reviewTab.setAttribute('role', 'tab'); + reviewTab.setAttribute('aria-selected', 'false'); + reviewTab.tabIndex = -1; + reviewTab.id = 'question-tab-review'; + reviewTab.setAttribute('aria-controls', questionPanelId); + const reviewLabel = localize('chat.questionCarousel.review', 'Review'); + reviewTab.textContent = reviewLabel; + reviewTab.setAttribute('aria-label', reviewLabel); + interactiveStore.add(dom.addDisposableListener(reviewTab, dom.EventType.CLICK, () => { + this.saveCurrentAnswer(); + this._currentIndex = this._reviewIndex; + this.renderCurrentQuestion(true); + reviewTab.focus(); + })); + tabList.appendChild(reviewTab); + this._tabItems.push(reviewTab); + + // Controls container for close button only + if (this._closeButtonContainer) { + const controlsContainer = dom.$('.chat-question-tab-controls'); + controlsContainer.appendChild(this._closeButtonContainer); + this._tabBar.appendChild(controlsContainer); + } + this.domNode.insertBefore(this._tabBar, this._questionContainer!); + } // Register event listeners - interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1))); - interactiveStore.add(nextButton.onDidClick(() => this.navigate(1))); - interactiveStore.add(submitButton.onDidClick(() => this.submit())); if (this._skipAllButton) { interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore())); } @@ -204,6 +228,37 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent e.preventDefault(); e.stopPropagation(); this.ignore(); + } else if (!isSingleQuestion && (event.keyCode === KeyCode.RightArrow || event.keyCode === KeyCode.LeftArrow)) { + // Arrow L/R navigates tabs from anywhere in the carousel, + // except when focus is in a text input or textarea (where arrows move cursor) + const target = e.target as HTMLElement; + const isTextInput = target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'text'; + const isTextarea = target.tagName === 'TEXTAREA'; + if (!isTextInput && !isTextarea) { + e.preventDefault(); + e.stopPropagation(); + const totalTabs = this._tabItems.length; // includes Review tab + if (event.keyCode === KeyCode.RightArrow) { + if (this._currentIndex < totalTabs - 1) { + this.saveCurrentAnswer(); + this._currentIndex++; + this.renderCurrentQuestion(true); + this._tabItems[this._currentIndex]?.focus(); + } + } else { + if (this._currentIndex > 0) { + this.saveCurrentAnswer(); + this._currentIndex--; + this.renderCurrentQuestion(true); + this._tabItems[this._currentIndex]?.focus(); + } + } + } + } else if (event.keyCode === KeyCode.Enter && (event.metaKey || event.ctrlKey)) { + // Cmd/Ctrl+Enter submits immediately from anywhere + e.preventDefault(); + e.stopPropagation(); + this.submit(); } else if (event.keyCode === KeyCode.Enter && !event.shiftKey) { // Handle Enter key for text inputs and freeform textareas, not radio/checkbox or buttons // Buttons have their own Enter/Space handling via Button class @@ -229,6 +284,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent */ private saveCurrentAnswer(): void { const currentQuestion = this.carousel.questions[this._currentIndex]; + if (!currentQuestion) { + return; // Review tab or out of bounds + } const answer = this.getCurrentAnswer(); if (answer !== undefined) { this._answers.set(currentQuestion.id, answer); @@ -268,14 +326,24 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent */ private handleNextOrSubmit(): void { this.saveCurrentAnswer(); + const currentQuestion = this.carousel.questions[this._currentIndex]; + if (currentQuestion && this.getCurrentAnswer() !== undefined) { + this._explicitlyAnsweredQuestionIds.add(currentQuestion.id); + this.updateQuestionTabIndicators(); + } if (this._currentIndex < this.carousel.questions.length - 1) { // Move to next question this._currentIndex++; this.persistDraftState(); this.renderCurrentQuestion(true); + } else if (this.carousel.questions.length > 1) { + // Multi-question: navigate to Review tab + this._currentIndex = this._reviewIndex; + this.renderCurrentQuestion(true); + this._tabItems[this._currentIndex]?.focus(); } else { - // Submit + // Single question: submit directly this._options.onSubmit(this._answers); this.hideAndShowSummary(); } @@ -286,6 +354,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent */ private submit(): void { this.saveCurrentAnswer(); + const currentQuestion = this.carousel.questions[this._currentIndex]; + if (currentQuestion) { + this._explicitlyAnsweredQuestionIds.add(currentQuestion.id); + } this._options.onSubmit(this._answers); this.hideAndShowSummary(); } @@ -336,19 +408,17 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._singleSelectItems.clear(); this._multiSelectCheckboxes.clear(); this._freeformTextareas.clear(); - this._nextButtonHover.value = undefined; - this._submitButtonHover.value = undefined; // Clear references to disposed elements - this._prevButton = undefined; - this._nextButton = undefined; - this._submitButton = undefined; this._skipAllButton = undefined; this._questionContainer = undefined; - this._navigationButtons = undefined; this._closeButtonContainer = undefined; + this._tabBar = undefined; + this._tabItems = []; + this._questionTabIndicators.clear(); + this._reviewIndex = -1; this._footerRow = undefined; - this._stepIndicator = undefined; + this._explicitlyAnsweredQuestionIds.clear(); } /** @@ -512,7 +582,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } private renderCurrentQuestion(focusContainerForScreenReader: boolean = false): void { - if (!this._questionContainer || !this._prevButton || !this._nextButton || !this._submitButton) { + if (!this._questionContainer) { return; } @@ -526,60 +596,102 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._multiSelectCheckboxes.clear(); this._freeformTextareas.clear(); + // Remove footer if it exists from a previous Review render + if (this._footerRow) { + this._footerRow.remove(); + this._footerRow = undefined; + } + // Clear previous content dom.clearNode(this._questionContainer); + const isSingleQuestion = this.carousel.questions.length === 1; + const isReview = !isSingleQuestion && this._currentIndex === this._reviewIndex; + + // Update tab bar active state for multi-question carousels + if (!isSingleQuestion) { + this._tabItems.forEach((tab, index) => { + const isActive = index === this._currentIndex; + tab.classList.toggle('active', isActive); + tab.setAttribute('aria-selected', String(isActive)); + tab.tabIndex = isActive ? 0 : -1; + }); + // Link the panel to the active tab for screen readers + const activeTab = this._tabItems[this._currentIndex]; + if (activeTab) { + this._questionContainer.setAttribute('aria-labelledby', activeTab.id); + } + this.updateQuestionTabIndicators(); + } + + if (isReview) { + this.renderReviewPanel(questionRenderStore); + } else { + this.renderQuestionPanel(questionRenderStore, isSingleQuestion); + } + + // Update aria-label to reflect the current question + this._updateAriaLabel(); + + // In screen reader mode, focus the container and announce the question + if (focusContainerForScreenReader && this._accessibilityService.isScreenReaderOptimized()) { + this._focusContainerAndAnnounce(); + } + + this._onDidChangeHeight.fire(); + } + + /** + * Renders a question panel (title, message, input) inside the question container. + */ + private renderQuestionPanel(questionRenderStore: DisposableStore, isSingleQuestion: boolean): void { const question = this.carousel.questions[this._currentIndex]; - if (!question) { + if (!question || !this._questionContainer) { return; } - // Render question header row with title and close button - const headerRow = dom.$('.chat-question-header-row'); - const titleRow = dom.$('.chat-question-title-row'); + // Render question header row with title and close button (single question only) + if (isSingleQuestion) { + const headerRow = dom.$('.chat-question-header-row'); + const titleRow = dom.$('.chat-question-title-row'); - // Render question title (short header) in the header bar as plain text - if (question.title) { - const title = dom.$('.chat-question-title'); - const questionText = question.title; - const messageContent = this.getQuestionText(questionText); + if (question.title) { + const title = dom.$('.chat-question-title'); + const questionText = question.title; + const messageContent = this.getQuestionText(questionText); - title.setAttribute('aria-label', messageContent); + title.setAttribute('aria-label', messageContent); - if (question.message !== undefined) { - const messageMd = isMarkdownString(questionText) ? MarkdownString.lift(questionText) : new MarkdownString(questionText); - const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(messageMd)); - title.appendChild(renderedTitle.element); - } else { - // Check for subtitle in parentheses at the end - const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/); - if (parenMatch) { - // Main title (bold) - const mainTitle = dom.$('span.chat-question-title-main'); - mainTitle.textContent = parenMatch[1]; - title.appendChild(mainTitle); - - // Subtitle in parentheses (normal weight) - const subtitle = dom.$('span.chat-question-title-subtitle'); - subtitle.textContent = ' ' + parenMatch[2]; - title.appendChild(subtitle); + if (question.message !== undefined) { + const messageMd = isMarkdownString(questionText) ? MarkdownString.lift(questionText) : new MarkdownString(questionText); + const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(messageMd)); + title.appendChild(renderedTitle.element); } else { - title.textContent = messageContent; + const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/); + if (parenMatch) { + const mainTitle = dom.$('span.chat-question-title-main'); + mainTitle.textContent = parenMatch[1]; + title.appendChild(mainTitle); + + const subtitle = dom.$('span.chat-question-title-subtitle'); + subtitle.textContent = ' ' + parenMatch[2]; + title.appendChild(subtitle); + } else { + title.textContent = messageContent; + } } + titleRow.appendChild(title); } - titleRow.appendChild(title); - } - - // Add close button to header row (if allowSkip is enabled) - if (this._closeButtonContainer) { - titleRow.appendChild(this._closeButtonContainer); - } - headerRow.appendChild(titleRow); + if (this._closeButtonContainer) { + titleRow.appendChild(this._closeButtonContainer); + } - this._questionContainer.appendChild(headerRow); + headerRow.appendChild(titleRow); + this._questionContainer.appendChild(headerRow); + } - // Render full question text below the header row (supports multi-line and markdown) + // Render full question text below the header row if (question.message) { const messageEl = dom.$('.chat-question-message'); if (isMarkdownString(question.message)) { @@ -591,56 +703,79 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._questionContainer.appendChild(messageEl); } - const isSingleQuestion = this.carousel.questions.length === 1; - // Update step indicator in footer - if (this._stepIndicator) { - this._stepIndicator.textContent = `${this._currentIndex + 1}/${this.carousel.questions.length}`; - this._stepIndicator.style.display = isSingleQuestion ? 'none' : ''; - } - // Render input based on question type const inputContainer = dom.$('.chat-question-input-container'); this.renderInput(inputContainer, question); this._questionContainer.appendChild(inputContainer); + } - // Update navigation button states (prevButton and nextButton are guaranteed non-null from guard above) - this._prevButton!.enabled = this._currentIndex > 0; - this._prevButton!.element.style.display = isSingleQuestion ? 'none' : ''; - - // Keep navigation arrows stable and disable next on the last question - const isLastQuestion = this._currentIndex === this.carousel.questions.length - 1; - const submitLabel = localize('submit', 'Submit'); - const nextLabel = localize('next', 'Next'); - const nextLabelWithKeybinding = this.getLabelWithKeybinding(nextLabel, NEXT_QUESTION_ACTION_ID); - this._nextButton!.label = `$(${Codicon.chevronRight.id})`; - this._nextButton!.enabled = !isLastQuestion; - this._nextButton!.element.setAttribute('aria-label', nextLabelWithKeybinding); - this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabelWithKeybinding }); - - this._submitButton!.enabled = isLastQuestion; - this._submitButton!.element.style.display = isLastQuestion ? '' : 'none'; - this._submitButton!.element.setAttribute('aria-label', submitLabel); - this._submitButtonHover.value = isLastQuestion - ? this._hoverService.setupDelayedHover(this._submitButton!.element, { content: submitLabel }) - : undefined; + /** + * Renders the review panel with a summary of all answers and a submit footer. + */ + private renderReviewPanel(questionRenderStore: DisposableStore): void { + if (!this._questionContainer) { + return; + } - // Update aria-label to reflect the current question - this._updateAriaLabel(); + // Render inline review summary. + // If no explicit answers exist yet, show a single empty-state label. + // If some explicit answers exist, show all questions and mark missing ones as not answered yet. + const summaryContainer = dom.$('.chat-question-carousel-summary'); + const answeredCount = this.carousel.questions.filter(q => this._explicitlyAnsweredQuestionIds.has(q.id)).length; - // In screen reader mode, focus the container and announce the question - // This must happen after all render calls to avoid focus being stolen - if (focusContainerForScreenReader && this._accessibilityService.isScreenReaderOptimized()) { - this._focusContainerAndAnnounce(); + if (answeredCount === 0) { + const emptyLabel = dom.$('div.chat-question-summary-empty'); + emptyLabel.textContent = localize('chat.questionCarousel.noQuestionsAnsweredYet', 'No questions answered yet'); + summaryContainer.appendChild(emptyLabel); + this._questionContainer.appendChild(summaryContainer); + } else { + for (const question of this.carousel.questions) { + const summaryItem = dom.$('.chat-question-summary-item'); + + const questionRow = dom.$('div.chat-question-summary-label'); + const questionText = question.message ?? question.title; + let labelText = typeof questionText === 'string' ? questionText : questionText.value; + labelText = labelText.replace(/[:\s]+$/, ''); + questionRow.textContent = localize('chat.questionCarousel.summaryQuestion', 'Q: {0}', labelText); + summaryItem.appendChild(questionRow); + + const hasExplicitAnswer = this._explicitlyAnsweredQuestionIds.has(question.id); + const answer = this._answers.get(question.id); + + if (hasExplicitAnswer && answer !== undefined) { + const formattedAnswer = this.formatAnswerForSummary(question, answer); + const answerRow = dom.$('div.chat-question-summary-answer'); + answerRow.textContent = localize('chat.questionCarousel.summaryAnswer', 'A: {0}', formattedAnswer); + summaryItem.appendChild(answerRow); + } else { + const unanswered = dom.$('div.chat-question-summary-unanswered'); + unanswered.textContent = localize('chat.questionCarousel.notAnsweredYet', 'Not answered yet'); + summaryItem.appendChild(unanswered); + } + + summaryContainer.appendChild(summaryItem); + } + + this._questionContainer.appendChild(summaryContainer); } - this._onDidChangeHeight.fire(); - } + // Footer with Submit/Cancel appears only once at least one question is answered. + if (answeredCount > 0) { + this._footerRow = dom.$('.chat-question-footer-row'); - private getLabelWithKeybinding(label: string, actionId: string): string { - const keybindingLabel = this._keybindingService.lookupKeybinding(actionId, this._contextKeyService)?.getLabel(); - return keybindingLabel - ? localize('chat.questionCarousel.labelWithKeybinding', '{0} ({1})', label, keybindingLabel) - : label; + const hint = dom.$('span.chat-question-submit-hint'); + hint.textContent = isMacintosh + ? localize('chat.questionCarousel.submitHintMac', '\u2318\u23CE to submit') + : localize('chat.questionCarousel.submitHintOther', 'Ctrl+Enter to submit'); + this._footerRow.appendChild(hint); + + const submitButton = questionRenderStore.add(new Button(this._footerRow, { ...defaultButtonStyles })); + submitButton.element.classList.add('chat-question-submit-button'); + submitButton.label = localize('submit', 'Submit'); + questionRenderStore.add(submitButton.onDidClick(() => this.submit())); + + this.domNode.append(this._footerRow); + } } private renderInput(container: HTMLElement, question: IChatQuestion): void { @@ -726,7 +861,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const listItems: HTMLElement[] = []; const indicators: HTMLElement[] = []; - const updateSelection = (newIndex: number) => { + const updateSelection = (newIndex: number, isUserInitiated: boolean = false) => { // Update visual state listItems.forEach((item, i) => { const isSelected = i === newIndex; @@ -745,6 +880,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (data) { data.selectedIndex = newIndex; } + if (isUserInitiated) { + this.updateQuestionTabIndicators(); + } this.saveCurrentAnswer(); }; @@ -773,12 +911,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const label = dom.$('.chat-question-list-label'); const separatorIndex = option.label.indexOf(' - '); if (separatorIndex !== -1) { - const titleSpan = dom.$('span.chat-question-list-label-title'); + const titleSpan = dom.$('div.chat-question-list-label-title'); titleSpan.textContent = option.label.substring(0, separatorIndex); label.appendChild(titleSpan); - const descSpan = dom.$('span.chat-question-list-label-desc'); - descSpan.textContent = ': ' + option.label.substring(separatorIndex + 3); + const descSpan = dom.$('div.chat-question-list-label-desc'); + descSpan.textContent = option.label.substring(separatorIndex + 3); label.appendChild(descSpan); } else { label.textContent = option.label; @@ -794,7 +932,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._inputBoxes.add(dom.addDisposableListener(listItem, dom.EventType.CLICK, (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - updateSelection(index); + updateSelection(index, true); const freeform = this._freeformTextareas.get(question.id); if (freeform) { freeform.value = ''; @@ -840,9 +978,17 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // clear when we start typing in freeform this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => { if (freeformTextarea.value.length > 0) { - updateSelection(-1); - } else { - this.saveCurrentAnswer(); + updateSelection(-1, true); + } + })); + + this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.UpArrow && freeformTextarea.selectionStart === 0 && freeformTextarea.selectionEnd === 0 && listItems.length) { + e.preventDefault(); + const lastIndex = listItems.length - 1; + updateSelection(lastIndex, true); + listItems[lastIndex].focus(); } })); @@ -861,6 +1007,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (event.keyCode === KeyCode.DownArrow) { e.preventDefault(); + if (data.selectedIndex >= listItems.length - 1) { + updateSelection(-1); + freeformTextarea.focus(); + return; + } newIndex = Math.min(data.selectedIndex + 1, listItems.length - 1); } else if (event.keyCode === KeyCode.UpArrow) { e.preventDefault(); @@ -876,17 +1027,17 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const numberIndex = event.keyCode - KeyCode.Digit1; if (numberIndex < listItems.length) { e.preventDefault(); - updateSelection(numberIndex); + updateSelection(numberIndex, true); } else if (numberIndex === listItems.length) { e.preventDefault(); - updateSelection(-1); + updateSelection(-1, true); freeformTextarea.focus(); } return; } if (newIndex !== data.selectedIndex && newIndex >= 0) { - updateSelection(newIndex); + updateSelection(newIndex, true); } })); @@ -973,12 +1124,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const label = dom.$('.chat-question-list-label'); const separatorIndex = option.label.indexOf(' - '); if (separatorIndex !== -1) { - const titleSpan = dom.$('span.chat-question-list-label-title'); + const titleSpan = dom.$('div.chat-question-list-label-title'); titleSpan.textContent = option.label.substring(0, separatorIndex); label.appendChild(titleSpan); - const descSpan = dom.$('span.chat-question-list-label-desc'); - descSpan.textContent = ': ' + option.label.substring(separatorIndex + 3); + const descSpan = dom.$('div.chat-question-list-label-desc'); + descSpan.textContent = option.label.substring(separatorIndex + 3); label.appendChild(descSpan); } else { label.textContent = option.label; @@ -996,6 +1147,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._inputBoxes.add(checkbox.onChange(() => { listItem.classList.toggle('checked', checkbox.checked); listItem.setAttribute('aria-selected', String(checkbox.checked)); + this.updateQuestionTabIndicators(); this.saveCurrentAnswer(); })); @@ -1043,6 +1195,18 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const autoResize = this.setupTextareaAutoResize(freeformTextarea); this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => this.saveCurrentAnswer())); + this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.UpArrow && freeformTextarea.selectionStart === 0 && freeformTextarea.selectionEnd === 0 && listItems.length) { + e.preventDefault(); + focusedIndex = listItems.length - 1; + listItems[focusedIndex].focus(); + } + })); + this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => { + this.updateQuestionTabIndicators(); + })); + freeformContainer.appendChild(freeformTextarea); container.appendChild(freeformContainer); this._freeformTextareas.set(question.id, freeformTextarea); @@ -1058,6 +1222,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (event.keyCode === KeyCode.DownArrow) { e.preventDefault(); + if (focusedIndex >= listItems.length - 1) { + freeformTextarea.focus(); + return; + } focusedIndex = Math.min(focusedIndex + 1, listItems.length - 1); listItems[focusedIndex].focus(); } else if (event.keyCode === KeyCode.UpArrow) { @@ -1126,19 +1294,20 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (data && data.selectedIndex >= 0) { selectedValue = question.options?.[data.selectedIndex]?.value; } - // Find default option if nothing selected (defaultValue is the option id) - if (selectedValue === undefined && typeof question.defaultValue === 'string') { - const defaultOption = question.options?.find(opt => opt.id === question.defaultValue); - selectedValue = defaultOption?.value; - } - // For single-select: if freeform is provided, use ONLY freeform (ignore selection) + // For single-select: freeform takes priority over selection. const freeformTextarea = this._freeformTextareas.get(question.id); const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; if (freeformValue) { - // Freeform takes priority - ignore selectedValue return { selectedValue: undefined, freeformValue }; } + + // Find default option if nothing selected and no freeform text (defaultValue is the option id) + if (selectedValue === undefined && typeof question.defaultValue === 'string') { + const defaultOption = question.options?.find(opt => opt.id === question.defaultValue); + selectedValue = defaultOption?.value; + } + if (selectedValue !== undefined) { return { selectedValue, freeformValue: undefined }; } @@ -1209,35 +1378,19 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const summaryItem = dom.$('.chat-question-summary-item'); - // Category label (use same text as shown in question UI: message ?? title) - const questionLabel = dom.$('span.chat-question-summary-label'); + // Question row with Q: prefix + const questionRow = dom.$('div.chat-question-summary-label'); const questionText = question.message ?? question.title; let labelText = typeof questionText === 'string' ? questionText : questionText.value; - // Remove trailing colons and whitespace to avoid double colons (CSS adds ': ') labelText = labelText.replace(/[:\s]+$/, ''); - questionLabel.textContent = labelText; - summaryItem.appendChild(questionLabel); + questionRow.textContent = localize('chat.questionCarousel.summaryQuestion', 'Q: {0}', labelText); + summaryItem.appendChild(questionRow); - // Format answer with title and description parts + // Answer row with A: prefix const formattedAnswer = this.formatAnswerForSummary(question, answer); - const separatorIndex = formattedAnswer.indexOf(' - '); - - if (separatorIndex !== -1) { - // Answer title (bold) - const answerTitle = dom.$('span.chat-question-summary-answer-title'); - answerTitle.textContent = formattedAnswer.substring(0, separatorIndex); - summaryItem.appendChild(answerTitle); - - // Answer description (normal) - const answerDesc = dom.$('span.chat-question-summary-answer-desc'); - answerDesc.textContent = ' - ' + formattedAnswer.substring(separatorIndex + 3); - summaryItem.appendChild(answerDesc); - } else { - // Just the answer value (bold) - const answerValue = dom.$('span.chat-question-summary-answer-title'); - answerValue.textContent = formattedAnswer; - summaryItem.appendChild(answerValue); - } + const answerRow = dom.$('div.chat-question-summary-answer'); + answerRow.textContent = localize('chat.questionCarousel.summaryAnswer', 'A: {0}', formattedAnswer); + summaryItem.appendChild(answerRow); summaryContainer.appendChild(summaryItem); } @@ -1296,6 +1449,21 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent return renderAsPlaintext(md); } + + + private updateQuestionTabIndicators(): void { + for (const question of this.carousel.questions) { + const indicator = this._questionTabIndicators.get(question.id); + if (!indicator) { + continue; + } + + const hasExplicitAnswer = this._explicitlyAnsweredQuestionIds.has(question.id); + indicator.classList.toggle('codicon-check', hasExplicitAnswer); + indicator.classList.toggle('codicon-circle-filled', !hasExplicitAnswer); + } + } + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { // does not have same content when it is not skipped and is active and we stop the response if (!this._isSkipped && !this.carousel.isUsed && isResponseVM(element) && element.isComplete) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts new file mode 100644 index 0000000000000..dcd85778a0e6f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts @@ -0,0 +1,241 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { disposableTimeout } from '../../../../../../base/common/async.js'; +import { decodeBase64 } from '../../../../../../base/common/buffer.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { basename, joinPath } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { localize, localize2 } from '../../../../../../nls.js'; +import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; +import { IFileDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IProgressService, ProgressLocation } from '../../../../../../platform/progress/common/progress.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { REVEAL_IN_EXPLORER_COMMAND_ID } from '../../../../files/browser/fileConstants.js'; +import { getAttachableImageExtension } from '../../../common/model/chatModel.js'; +import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { ChatAttachmentsContentPart } from './chatAttachmentsContentPart.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; + +export interface IChatToolOutputResourceToolbarContext { + parts: IChatCollapsibleIODataPart[]; +} + +/** + * Delay in milliseconds before decoding base64 image data. + * This avoids expensive decode operations during scrolling. + */ +const IMAGE_DECODE_DELAY_MS = 100; + +/** + * A reusable widget for rendering a group of resource data parts (files, images) + * with attachment pills and a toolbar with save actions. + * + * Used by ChatToolOutputContentSubPart and ChatMcpAppSubPart (for download resources). + */ +export class ChatResourceGroupWidget extends Disposable { + public readonly domNode: HTMLElement; + + constructor( + parts: IChatCollapsibleIODataPart[], + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IFileService private readonly _fileService: IFileService, + ) { + super(); + + const el = dom.h('.chat-collapsible-io-resource-group', [ + dom.h('.chat-collapsible-io-resource-items@items'), + dom.h('.chat-collapsible-io-resource-actions@actions'), + ]); + + this.domNode = el.root; + this._fillInResourceGroup(parts, el.items, el.actions); + } + + private async _fillInResourceGroup(parts: IChatCollapsibleIODataPart[], itemsContainer: HTMLElement, actionsContainer: HTMLElement) { + // First pass: create entries immediately, using file placeholders for base64 images + const entries: IChatRequestVariableEntry[] = []; + const deferredImageParts: { index: number; part: IChatCollapsibleIODataPart }[] = []; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.mimeType && getAttachableImageExtension(part.mimeType)) { + if (part.base64Value) { + // Defer base64 decode - use file placeholder for now + entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); + deferredImageParts.push({ index: i, part }); + } else if (part.value) { + entries.push({ kind: 'image', id: generateUuid(), name: basename(part.uri), value: part.value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }); + } else { + const value = await this._fileService.readFile(part.uri).then(f => f.value.buffer, () => undefined); + if (!value) { + entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); + } else { + entries.push({ kind: 'image', id: generateUuid(), name: basename(part.uri), value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }); + } + } + } else { + entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); + } + } + + if (this._store.isDisposed) { + return; + } + + // Render attachments immediately with placeholders + const attachments = this._register(this._instantiationService.createInstance( + ChatAttachmentsContentPart, + { + variables: entries, + limit: 5, + contentReferences: undefined, + domNode: undefined + } + )); + + attachments.contextMenuHandler = (attachment, event) => { + const index = entries.indexOf(attachment); + const part = parts[index]; + if (part) { + event.preventDefault(); + event.stopPropagation(); + + this._contextMenuService.showContextMenu({ + menuId: MenuId.ChatToolOutputResourceContext, + menuActionOptions: { shouldForwardArgs: true }, + getAnchor: () => ({ x: event.pageX, y: event.pageY }), + getActionsContext: () => ({ parts: [part] } satisfies IChatToolOutputResourceToolbarContext), + }); + } + }; + + itemsContainer.appendChild(attachments.domNode!); + + const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, MenuId.ChatToolOutputResourceToolbar, { + menuOptions: { + shouldForwardArgs: true, + }, + })); + toolbar.context = { parts } satisfies IChatToolOutputResourceToolbarContext; + + // Second pass: decode base64 images asynchronously and update in place + if (deferredImageParts.length > 0) { + this._register(disposableTimeout(() => { + for (const { index, part } of deferredImageParts) { + try { + const value = decodeBase64(part.base64Value!).buffer; + entries[index] = { kind: 'image', id: generateUuid(), name: basename(part.uri), value, mimeType: part.mimeType!, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }; + } catch { + // Keep the file placeholder on decode failure + } + } + + // Update attachments in place + attachments.updateVariables(entries); + }, IMAGE_DECODE_DELAY_MS)); + } + } +} + + +class SaveResourcesAction extends Action2 { + public static readonly ID = 'chat.toolOutput.save'; + constructor() { + super({ + id: SaveResourcesAction.ID, + title: localize2('chat.saveResources', "Save..."), + icon: Codicon.cloudDownload, + menu: [{ + id: MenuId.ChatToolOutputResourceToolbar, + group: 'navigation', + order: 1 + }, { + id: MenuId.ChatToolOutputResourceContext, + }] + }); + } + + async run(accessor: ServicesAccessor, context: IChatToolOutputResourceToolbarContext) { + const fileDialog = accessor.get(IFileDialogService); + const fileService = accessor.get(IFileService); + const notificationService = accessor.get(INotificationService); + const progressService = accessor.get(IProgressService); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const commandService = accessor.get(ICommandService); + const labelService = accessor.get(ILabelService); + const defaultFilepath = await fileDialog.defaultFilePath(); + + const savePart = async (part: IChatCollapsibleIODataPart, isFolder: boolean, uri: URI) => { + const target = isFolder ? joinPath(uri, basename(part.uri)) : uri; + try { + if (part.kind === 'data') { + await fileService.copy(part.uri, target, true); + } else { + // MCP doesn't support streaming data, so no sense trying + const contents = await fileService.readFile(part.uri); + await fileService.writeFile(target, contents.value); + } + } catch (e) { + notificationService.error(localize('chat.saveResources.error', "Failed to save {0}: {1}", basename(part.uri), e)); + } + }; + + const withProgress = async (thenReveal: URI, todo: (() => Promise)[]) => { + await progressService.withProgress({ + location: ProgressLocation.Notification, + delay: 5_000, + title: localize('chat.saveResources.progress', "Saving resources..."), + }, async report => { + for (const task of todo) { + await task(); + report.report({ increment: 1, total: todo.length }); + } + }); + + if (workspaceContextService.isInsideWorkspace(thenReveal)) { + commandService.executeCommand(REVEAL_IN_EXPLORER_COMMAND_ID, thenReveal); + } else { + notificationService.info(localize('chat.saveResources.reveal', "Saved resources to {0}", labelService.getUriLabel(thenReveal))); + } + }; + + if (context.parts.length === 1) { + const part = context.parts[0]; + const uri = await fileDialog.pickFileToSave(joinPath(defaultFilepath, basename(part.uri))); + if (!uri) { + return; + } + await withProgress(uri, [() => savePart(part, false, uri)]); + } else { + const uris = await fileDialog.showOpenDialog({ + title: localize('chat.saveResources.title', "Pick folder to save resources"), + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: workspaceContextService.getWorkspace().folders[0]?.uri, + }); + + if (!uris?.length) { + return; + } + + await withProgress(uris[0], context.parts.map(part => () => savePart(part, true, uris[0]))); + } + } +} + +registerAction2(SaveResourcesAction); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts index 89cd89aea18c2..46171ef0343fb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts @@ -4,39 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; -import { disposableTimeout } from '../../../../../../base/common/async.js'; -import { decodeBase64 } from '../../../../../../base/common/buffer.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { basename, joinPath } from '../../../../../../base/common/resources.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { generateUuid } from '../../../../../../base/common/uuid.js'; import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; -import { localize, localize2 } from '../../../../../../nls.js'; -import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; -import { Action2, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; -import { IFileDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; -import { IProgressService, ProgressLocation } from '../../../../../../platform/progress/common/progress.js'; -import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { REVEAL_IN_EXPLORER_COMMAND_ID } from '../../../../files/browser/fileConstants.js'; -import { getAttachableImageExtension } from '../../../common/model/chatModel.js'; import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { IChatCodeBlockInfo } from '../../chat.js'; import { CodeBlockPart, ICodeBlockData } from './codeBlockPart.js'; -import { ChatAttachmentsContentPart } from './chatAttachmentsContentPart.js'; import { IDisposableReference } from './chatCollections.js'; import { IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatCollapsibleIOPart, IChatCollapsibleIOCodePart, IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; +import { ChatResourceGroupWidget } from './chatResourceGroupWidget.js'; /** * A reusable component for rendering tool output consisting of code blocks and/or resources. @@ -52,8 +32,6 @@ export class ChatToolOutputContentSubPart extends Disposable { private readonly parts: ChatCollapsibleIOPart[], @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IContextMenuService private readonly _contextMenuService: IContextMenuService, - @IFileService private readonly _fileService: IFileService, @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, @@ -101,106 +79,8 @@ export class ChatToolOutputContentSubPart extends Disposable { } private addResourceGroup(parts: IChatCollapsibleIODataPart[], container: HTMLElement) { - const el = dom.h('.chat-collapsible-io-resource-group', [ - dom.h('.chat-collapsible-io-resource-items@items'), - dom.h('.chat-collapsible-io-resource-actions@actions'), - ]); - - this.fillInResourceGroup(parts, el.items, el.actions); - - container.appendChild(el.root); - return el.root; - } - - /** - * Delay in milliseconds before decoding base64 image data. - * This avoids expensive decode operations during scrolling. - */ - private static readonly IMAGE_DECODE_DELAY_MS = 100; - - private async fillInResourceGroup(parts: IChatCollapsibleIODataPart[], itemsContainer: HTMLElement, actionsContainer: HTMLElement) { - // First pass: create entries immediately, using file placeholders for base64 images - const entries: IChatRequestVariableEntry[] = []; - const deferredImageParts: { index: number; part: IChatCollapsibleIODataPart }[] = []; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (part.mimeType && getAttachableImageExtension(part.mimeType)) { - if (part.base64Value) { - // Defer base64 decode - use file placeholder for now - entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); - deferredImageParts.push({ index: i, part }); - } else if (part.value) { - entries.push({ kind: 'image', id: generateUuid(), name: basename(part.uri), value: part.value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }); - } else { - const value = await this._fileService.readFile(part.uri).then(f => f.value.buffer, () => undefined); - if (!value) { - entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); - } else { - entries.push({ kind: 'image', id: generateUuid(), name: basename(part.uri), value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }); - } - } - } else { - entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); - } - } - - if (this._store.isDisposed) { - return; - } - - // Render attachments immediately with placeholders - const attachments = this._register(this._instantiationService.createInstance( - ChatAttachmentsContentPart, - { - variables: entries, - limit: 5, - contentReferences: undefined, - domNode: undefined - } - )); - - attachments.contextMenuHandler = (attachment, event) => { - const index = entries.indexOf(attachment); - const part = parts[index]; - if (part) { - event.preventDefault(); - event.stopPropagation(); - - this._contextMenuService.showContextMenu({ - menuId: MenuId.ChatToolOutputResourceContext, - menuActionOptions: { shouldForwardArgs: true }, - getAnchor: () => ({ x: event.pageX, y: event.pageY }), - getActionsContext: () => ({ parts: [part] } satisfies IChatToolOutputResourceToolbarContext), - }); - } - }; - - itemsContainer.appendChild(attachments.domNode!); - - const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, MenuId.ChatToolOutputResourceToolbar, { - menuOptions: { - shouldForwardArgs: true, - }, - })); - toolbar.context = { parts } satisfies IChatToolOutputResourceToolbarContext; - - // Second pass: decode base64 images asynchronously and update in place - if (deferredImageParts.length > 0) { - this._register(disposableTimeout(() => { - for (const { index, part } of deferredImageParts) { - try { - const value = decodeBase64(part.base64Value!).buffer; - entries[index] = { kind: 'image', id: generateUuid(), name: basename(part.uri), value, mimeType: part.mimeType!, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }; - } catch { - // Keep the file placeholder on decode failure - } - } - - // Update attachments in place - attachments.updateVariables(entries); - }, ChatToolOutputContentSubPart.IMAGE_DECODE_DELAY_MS)); - } + const widget = this._register(this._instantiationService.createInstance(ChatResourceGroupWidget, parts)); + container.appendChild(widget.domNode); } private addCodeBlock(parts: IChatCollapsibleIOCodePart[], container: HTMLElement): void { @@ -253,97 +133,3 @@ export class ChatToolOutputContentSubPart extends Disposable { this._editorReferences.forEach(r => r.object.layout(width)); } } - -interface IChatToolOutputResourceToolbarContext { - parts: IChatCollapsibleIODataPart[]; -} - - - -class SaveResourcesAction extends Action2 { - public static readonly ID = 'chat.toolOutput.save'; - constructor() { - super({ - id: SaveResourcesAction.ID, - title: localize2('chat.saveResources', "Save As..."), - icon: Codicon.cloudDownload, - menu: [{ - id: MenuId.ChatToolOutputResourceToolbar, - group: 'navigation', - order: 1 - }, { - id: MenuId.ChatToolOutputResourceContext, - }] - }); - } - - async run(accessor: ServicesAccessor, context: IChatToolOutputResourceToolbarContext) { - const fileDialog = accessor.get(IFileDialogService); - const fileService = accessor.get(IFileService); - const notificationService = accessor.get(INotificationService); - const progressService = accessor.get(IProgressService); - const workspaceContextService = accessor.get(IWorkspaceContextService); - const commandService = accessor.get(ICommandService); - const labelService = accessor.get(ILabelService); - const defaultFilepath = await fileDialog.defaultFilePath(); - - const savePart = async (part: IChatCollapsibleIODataPart, isFolder: boolean, uri: URI) => { - const target = isFolder ? joinPath(uri, basename(part.uri)) : uri; - try { - if (part.kind === 'data') { - await fileService.copy(part.uri, target, true); - } else { - // MCP doesn't support streaming data, so no sense trying - const contents = await fileService.readFile(part.uri); - await fileService.writeFile(target, contents.value); - } - } catch (e) { - notificationService.error(localize('chat.saveResources.error', "Failed to save {0}: {1}", basename(part.uri), e)); - } - }; - - const withProgress = async (thenReveal: URI, todo: (() => Promise)[]) => { - await progressService.withProgress({ - location: ProgressLocation.Notification, - delay: 5_000, - title: localize('chat.saveResources.progress', "Saving resources..."), - }, async report => { - for (const task of todo) { - await task(); - report.report({ increment: 1, total: todo.length }); - } - }); - - if (workspaceContextService.isInsideWorkspace(thenReveal)) { - commandService.executeCommand(REVEAL_IN_EXPLORER_COMMAND_ID, thenReveal); - } else { - notificationService.info(localize('chat.saveResources.reveal', "Saved resources to {0}", labelService.getUriLabel(thenReveal))); - } - }; - - if (context.parts.length === 1) { - const part = context.parts[0]; - const uri = await fileDialog.pickFileToSave(joinPath(defaultFilepath, basename(part.uri))); - if (!uri) { - return; - } - await withProgress(uri, [() => savePart(part, false, uri)]); - } else { - const uris = await fileDialog.showOpenDialog({ - title: localize('chat.saveResources.title', "Pick folder to save resources"), - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - defaultUri: workspaceContextService.getWorkspace().folders[0]?.uri, - }); - - if (!uris?.length) { - return; - } - - await withProgress(uris[0], context.parts.map(part => () => savePart(part, true, uris[0]))); - } - } -} - -registerAction2(SaveResourcesAction); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css index cd5343e662e84..02dfb90b54567 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css @@ -193,6 +193,23 @@ } } +.mcp-app-downloads { + margin-top: 8px; + + .chat-collapsible-io-resource-group { + animation: mcpDownloadFadeIn 300ms ease-in; + } +} + +.mcp-app-downloads:empty { + display: none; +} + +@keyframes mcpDownloadFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + .chat-confirmation-widget2 { margin-bottom: 8px; border: 1px solid var(--vscode-chat-requestBorder); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 28f9603b6699b..06842836e880d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -12,15 +12,19 @@ .interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { margin: 0; border: 1px solid var(--vscode-input-border, transparent); - background-color: var(--vscode-editor-background); - border-radius: 4px; + background-color: var(--vscode-chat-list-background); + border-radius: var(--vscode-cornerRadius-large); +} + +.interactive-session .interactive-input-part.compact > .chat-question-carousel-widget-container .chat-question-carousel-container { + border-radius: var(--vscode-cornerRadius-small); } /* general questions styling */ .interactive-session .chat-question-carousel-container { margin: 8px 0; border: 1px solid var(--vscode-chat-requestBorder); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-large); display: flex; flex-direction: column; overflow: hidden; @@ -31,21 +35,22 @@ .interactive-session .interactive-input-part > .chat-question-carousel-widget-container { width: 100%; position: relative; + display: flex; + flex-direction: column; + gap: 8px; } /* container and header */ .interactive-session .chat-question-carousel-container .chat-question-carousel-content { display: flex; flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 8px 16px 10px 16px; + background: var(--vscode-chat-list-background); overflow: hidden; .chat-question-header-row { display: flex; flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 0 16px 10px 16px; + background: var(--vscode-chat-list-background); overflow: hidden; .chat-question-title-row { @@ -54,6 +59,8 @@ align-items: center; gap: 8px; min-width: 0; + padding: 4px 8px 4px 16px; + border-bottom: 1px solid var(--vscode-chat-requestBorder); } .chat-question-title { @@ -64,13 +71,6 @@ font-weight: 500; font-size: var(--vscode-chat-font-size-body-s); margin: 0; - padding-top: 4px; - padding-bottom: 4px; - margin-left: -16px; - margin-right: -16px; - padding-left: 16px; - padding-right: 16px; - border-bottom: 1px solid var(--vscode-chat-requestBorder); .rendered-markdown { a { @@ -105,36 +105,35 @@ width: 22px; height: 22px; padding: 0; - border: none; + border: none !important; + box-shadow: none !important; background: transparent !important; - color: var(--vscode-foreground) !important; + color: var(--vscode-icon-foreground) !important; } .monaco-button.chat-question-close:hover:not(.disabled) { background: var(--vscode-toolbar-hoverBackground) !important; } } + } - .chat-question-message { - padding-top: 8px; - font-size: var(--vscode-chat-font-size-body-s); - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.4; + .chat-question-message { + word-wrap: break-word; + overflow-wrap: break-word; + padding: 16px; - .rendered-markdown { - a { - color: var(--vscode-textLink-foreground); - } + .rendered-markdown { + a { + color: var(--vscode-textLink-foreground); + } - a:hover, - a:active { - color: var(--vscode-textLink-activeForeground); - } + a:hover, + a:active { + color: var(--vscode-textLink-activeForeground); + } - p { - margin: 0; - } + p { + margin: 0; } } } @@ -144,41 +143,30 @@ .interactive-session .chat-question-carousel-container .chat-question-input-container { display: flex; flex-direction: column; - margin-top: 4px; + padding-bottom: 12px; min-width: 0; /* some hackiness to get the focus looking right */ - .chat-question-list-item:focus:not(.selected), + .chat-question-list-item:focus, + .chat-question-list-item:focus-visible, .chat-question-list:focus { outline: none; } - .chat-question-list:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - .chat-question-list:focus-within .chat-question-list-item.selected { - outline-width: 1px; - outline-style: solid; - outline-offset: -1px; - outline-color: var(--vscode-focusBorder); - } - .chat-question-list { display: flex; flex-direction: column; - gap: 3px; outline: none; - padding: 4px 0; + margin: 0 8px; + padding: 0 0 4px 0; .chat-question-list-item { display: flex; align-items: flex-start; - gap: 8px; - padding: 3px 8px; + gap: 12px; + padding: 8px 8px 8px 12px; cursor: pointer; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-medium); user-select: none; .chat-question-list-indicator { @@ -189,6 +177,7 @@ justify-content: center; flex-shrink: 0; margin-left: auto; + margin-top: 2px; } .chat-question-list-indicator.codicon-check { @@ -201,11 +190,13 @@ flex: 1; word-wrap: break-word; overflow-wrap: break-word; - padding-top: 2px; + display: flex; + flex-direction: column; } .chat-question-list-label-title { - font-weight: 600; + font-weight: 500; + line-height: 1.4; } .chat-question-list-label-desc { @@ -237,11 +228,7 @@ } .chat-question-list-number { - background-color: transparent; color: var(--vscode-list-activeSelectionForeground); - border-color: var(--vscode-list-activeSelectionForeground); - border-bottom-color: var(--vscode-list-activeSelectionForeground); - box-shadow: none; } } @@ -260,11 +247,11 @@ } .chat-question-freeform { - margin-left: 8px; display: flex; flex-direction: row; align-items: center; - gap: 8px; + margin: 0px 8px 0 20px; + gap: 12px; .chat-question-freeform-number { height: fit-content; @@ -280,11 +267,11 @@ width: 100%; min-height: 24px; max-height: 200px; - padding: 3px 8px; - border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); + padding: 0; + border: none; background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-medium); resize: none; font-family: var(--vscode-chat-font-family, inherit); font-size: var(--vscode-chat-font-size-body-s); @@ -294,53 +281,90 @@ } .chat-question-freeform-textarea:focus { - outline: 1px solid var(--vscode-focusBorder); - border-color: var(--vscode-focusBorder); + outline: none; } .chat-question-freeform-textarea::placeholder { color: var(--vscode-input-placeholderForeground); } + &:focus-within .chat-question-freeform-number { + color: var(--vscode-list-activeSelectionForeground); + } + } /* todo: change to use keybinding service so we don't have to recreate this */ .chat-question-list-number, .chat-question-freeform-number { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 14px; - padding: 0px 4px; - border-style: solid; - border-width: 1px; - border-radius: 3px; font-size: 11px; - font-weight: normal; - background-color: var(--vscode-keybindingLabel-background); - color: var(--vscode-keybindingLabel-foreground); - border-color: var(--vscode-keybindingLabel-border); - border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); - box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); + color: var(--vscode-descriptionForeground); flex-shrink: 0; + line-height: 1rem; } } -/* footer with step indicator and nav buttons */ -.interactive-session .chat-question-carousel-container .chat-question-footer-row { +/* tab bar for multi-question carousels */ +.interactive-session .chat-question-carousel-container .chat-question-tab-bar { display: flex; - justify-content: space-between; align-items: center; - padding: 4px 16px; - border-top: 1px solid var(--vscode-chat-requestBorder); - background: var(--vscode-chat-requestBackground); + gap: 2px; + padding: 4px 8px 4px 4px; + border-bottom: 1px solid var(--vscode-chat-requestBorder); + background: var(--vscode-chat-list-background); + + .chat-question-tabs { + display: flex; + align-items: center; + gap: 2px; + flex: 1; + min-width: 0; + overflow-x: auto; + } - .chat-question-step-indicator { + .chat-question-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px 2px 8px; + border-radius: var(--vscode-cornerRadius-medium); font-size: var(--vscode-chat-font-size-body-s); + cursor: pointer; + font-weight: 500; + white-space: nowrap; + user-select: none; color: var(--vscode-descriptionForeground); + outline: none; + } + + .chat-question-tab .chat-question-tab-indicator { + font-size: 10px; + line-height: 1; + } + + .chat-question-tab .chat-question-tab-indicator.codicon-circle-filled { + color: var(--vscode-textLink-foreground); } - .chat-question-carousel-nav { + .chat-question-tab.no-icon { + padding: 2px 8px; + } + + .chat-question-tab:hover { + background: var(--vscode-list-hoverBackground); + color: var(--vscode-foreground); + } + + .chat-question-tab.active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + } + + .chat-question-tab:focus-visible { + outline: none; + } + + .chat-question-tab-controls { display: flex; align-items: center; gap: 4px; @@ -348,55 +372,74 @@ margin-left: auto; } - .chat-question-nav-arrows { - display: flex; - align-items: center; - gap: 4px; + .chat-question-tab-controls .monaco-button.chat-question-submit-button { + background: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; + height: 22px; + min-width: auto; + padding: 0 8px; } - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none; + .chat-question-tab-controls .monaco-button.chat-question-submit-button:hover:not(.disabled) { + background: var(--vscode-button-hoverBackground) !important; } - /* Secondary buttons (prev, next) use gray secondary background */ - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev, - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next { - background: var(--vscode-button-secondaryBackground) !important; - color: var(--vscode-button-secondaryForeground) !important; + .chat-question-close-container { + flex-shrink: 0; + + .monaco-button.chat-question-close { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-foreground) !important; + } + + .monaco-button.chat-question-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } } +} - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled), - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) { - background: var(--vscode-button-secondaryHoverBackground) !important; +/* footer with submit and cancel buttons */ +.interactive-session .chat-question-carousel-container .chat-question-footer-row { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-top: 1px solid var(--vscode-chat-requestBorder); + background: var(--vscode-chat-list-background); + + .chat-question-submit-hint { + font-size: 11px; + color: var(--vscode-descriptionForeground); } - /* Dedicated submit button uses primary background */ - .chat-question-carousel-nav .monaco-button.chat-question-submit-button { + .monaco-button.chat-question-submit-button { background: var(--vscode-button-background) !important; color: var(--vscode-button-foreground) !important; height: 22px; + width: auto; + flex: 0 0 auto; min-width: auto; padding: 0 8px; } - .chat-question-carousel-nav .monaco-button.chat-question-submit-button:hover:not(.disabled) { + .monaco-button.chat-question-submit-button:hover:not(.disabled) { background: var(--vscode-button-hoverBackground) !important; } - /* Close button uses transparent background */ - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close { - background: transparent !important; - color: var(--vscode-foreground) !important; - } - - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; + .monaco-button.chat-question-cancel-button { + height: 22px; + width: auto; + flex: 0 0 auto; + min-width: auto; + padding: 0 8px; } - } /* summary (after finished) */ @@ -404,13 +447,11 @@ display: flex; flex-direction: column; gap: 8px; - padding: 8px; + padding: 16px; .chat-question-summary-item { display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: baseline; + flex-direction: column; gap: 0; font-size: var(--vscode-chat-font-size-body-s); } @@ -421,19 +462,7 @@ overflow-wrap: break-word; } - .chat-question-summary-label::after { - content: ': '; - white-space: pre; - } - - .chat-question-summary-answer-title { - color: var(--vscode-foreground); - font-weight: 600; - word-wrap: break-word; - overflow-wrap: break-word; - } - - .chat-question-summary-answer-desc { + .chat-question-summary-answer { color: var(--vscode-foreground); word-wrap: break-word; overflow-wrap: break-word; @@ -444,4 +473,15 @@ font-style: italic; font-size: var(--vscode-chat-font-size-body-s); } + + .chat-question-summary-empty { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-s); + padding: 0; + } + + .chat-question-summary-unanswered { + color: var(--vscode-descriptionForeground); + font-style: italic; + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts index 7541799ba2903..4c83b8ac18a2a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts @@ -17,12 +17,15 @@ import { autorun, autorunSelfDisposable, IObservable, observableValue } from '.. import { basename } from '../../../../../../../base/common/resources.js'; import { isFalsyOrWhitespace } from '../../../../../../../base/common/strings.js'; import { hasKey, isDefined } from '../../../../../../../base/common/types.js'; +import { URI } from '../../../../../../../base/common/uri.js'; import { localize } from '../../../../../../../nls.js'; +import { IChatResponseResourceFileSystemProvider } from '../../../../common/widget/chatResponseResourceFileSystemProvider.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../../platform/product/common/productService.js'; import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; + import { IMcpAppResourceContent, McpToolCallUI } from '../../../../../mcp/browser/mcpToolCallUI.js'; import { McpResourceURI } from '../../../../../mcp/common/mcpTypes.js'; import { MCP } from '../../../../../mcp/common/modelContextProtocol.js'; @@ -32,6 +35,7 @@ import { IChatRequestVariableEntry } from '../../../../common/attachments/chatVa import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, IToolResult } from '../../../../common/tools/languageModelToolsService.js'; import { IChatWidgetService } from '../../../chat.js'; +import { IChatCollapsibleIODataPart } from '../chatToolInputOutputContentPart.js'; import { IMcpAppRenderData } from './chatMcpAppSubPart.js'; /** Storage key for persistent webview origins */ @@ -84,6 +88,10 @@ export class ChatMcpAppModel extends Disposable { private readonly _onDidChangeHeight = this._register(new Emitter()); public readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; + /** Accumulated download resource parts from ui/download-file calls */ + private readonly _downloadParts = observableValue(this, []); + public readonly downloadParts: IObservable = this._downloadParts; + /** Full host context for the MCP App */ public readonly hostContext: IObservable; @@ -97,6 +105,7 @@ export class ChatMcpAppModel extends Disposable { @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IWebviewService private readonly _webviewService: IWebviewService, @IStorageService storageService: IStorageService, + @IChatResponseResourceFileSystemProvider private readonly _chatResponseResourceFsProvider: IChatResponseResourceFileSystemProvider, @ILogService private readonly _logService: ILogService, @IProductService private readonly _productService: IProductService, @IOpenerService private readonly _openerService: IOpenerService, @@ -437,6 +446,10 @@ export class ChatMcpAppModel extends Disposable { result = await this._handleOpenLink(request.params); break; + case 'ui/download-file': + result = await this._handleDownloadFile(request.params); + break; + case 'ui/request-display-mode': // VS Code only supports inline display mode result = { mode: 'inline' } satisfies McpApps.McpUiRequestDisplayModeResult; @@ -543,7 +556,8 @@ export class ChatMcpAppModel extends Disposable { resourceLink: {}, resource: {}, structuredContent: {}, - } + }, + downloadFile: {}, }, hostContext: this.hostContext.get(), } satisfies Required; @@ -682,6 +696,45 @@ export class ChatMcpAppModel extends Disposable { widget?.delegateScrollFromMouseWheelEvent(evt as IMouseWheelEvent); } + private async _handleDownloadFile(params: McpApps.McpUiDownloadFileRequest['params']): Promise { + const newParts: IChatCollapsibleIODataPart[] = []; + let hadError = false; + + for (const content of params.contents) { + try { + if (content.type === 'resource') { + // EmbeddedResource — associate inline content with the chat response FS + const resource = content.resource; + const parsed = URI.parse(resource.uri); + + const data: Uint8Array | { base64: string } = hasKey(resource, { text: true }) + ? new TextEncoder().encode(resource.text) + : { base64: resource.blob }; + + const uri = this._chatResponseResourceFsProvider.associate(this.renderData.sessionResource, data, basename(parsed)); + newParts.push({ kind: 'data', mimeType: resource.mimeType, uri }); + } else if (content.type === 'resource_link') { + // ResourceLink — create a part with an MCP resource URI, resolved lazily on save + const mcpUri = McpResourceURI.fromServer( + { id: this.renderData.serverDefinitionId, label: '' }, + content.uri, + ); + newParts.push({ kind: 'data', mimeType: content.mimeType, uri: mcpUri }); + } + } catch (error) { + hadError = true; + this._logService.warn('[MCP App] Failed to process ui/download-file content', error); + } + } + + if (newParts.length > 0) { + const existing = this._downloadParts.get(); + this._downloadParts.set([...existing, ...newParts], undefined); + } + + return hadError ? { isError: true } : {}; + } + private async _handleOpenLink(params: McpApps.McpUiOpenLinkRequest['params']): Promise { const ok = await this._openerService.open(params.url); return { isError: !ok }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts index cf8f4d62ffa86..db87356f0047a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts @@ -21,6 +21,7 @@ import { IChatCodeBlockInfo } from '../../../chat.js'; import { IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatErrorWidget } from '../chatErrorContentPart.js'; import { ChatProgressSubPart } from '../chatProgressContentPart.js'; +import { ChatResourceGroupWidget } from '../chatResourceGroupWidget.js'; import { ChatMcpAppModel, McpAppLoadState } from './chatMcpAppModel.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; @@ -63,6 +64,12 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { /** Current error node */ private _errorNode: HTMLElement | undefined; + /** Container for download resource pills */ + private readonly _downloadContainer: HTMLElement; + + /** Current resource group widget for downloads */ + private readonly _downloadWidget = this._register(new MutableDisposable()); + constructor( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, onDidRemount: Event, @@ -81,6 +88,10 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { this._webviewContainer.style.height = '300px'; // Initial height, will be updated by model this.domNode.appendChild(this._webviewContainer); + // Download container — below webview, for ui/download-file resources + this._downloadContainer = dom.$('div.mcp-app-downloads'); + this.domNode.appendChild(this._downloadContainer); + const targetWindow = dom.getWindow(this.domNode); const getMaxHeight = () => maxWebviewHeightPct * targetWindow.innerHeight; const maxHeight = observableValue('mcpAppMaxHeight', getMaxHeight()); @@ -110,6 +121,21 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { this._updateContainerHeight(); })); + // Observe download parts and render resource group widget + this._register(autorun(reader => { + const parts = this._model.downloadParts.read(reader); + if (parts.length === 0) { + this._downloadWidget.clear(); + dom.clearNode(this._downloadContainer); + return; + } + + dom.clearNode(this._downloadContainer); + const widget = this._instantiationService.createInstance(ChatResourceGroupWidget, parts); + this._downloadWidget.value = widget; + this._downloadContainer.appendChild(widget.domNode); + })); + this._register(onDidRemount(() => { this._model.remount(); })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index e4fc306046d98..8a52ad08d8b2f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -107,7 +107,7 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; -import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { ChatQuestionCarouselAutoReply } from './chatQuestionCarouselAutoReply.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { AccessibilityWorkbenchSettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; @@ -2178,8 +2178,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer()); - private readonly _chatQuestionCarouselWidget = this._register(new MutableDisposable()); - private readonly _chatQuestionCarouselDisposables = this._register(new DisposableStore()); - private _currentQuestionCarouselResponseId: string | undefined; - private _currentQuestionCarouselSessionResource: URI | undefined; + private readonly _chatQuestionCarouselWidgets = this._register(new DisposableMap()); + private readonly _questionCarouselResponseIds = new Map(); + private readonly _questionCarouselSessionResources = new Map(); private _hasQuestionCarouselContextKey: IContextKey | undefined; private readonly _chatEditingTodosDisposables = this._register(new DisposableStore()); private _lastEditingSessionResource: URI | undefined; @@ -1919,7 +1917,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.refreshChatSessionPickers(); this.tryUpdateWidgetController(); this.updateContextUsageWidget(); - if (this._currentQuestionCarouselSessionResource && (!e.currentSessionResource || !isEqual(this._currentQuestionCarouselSessionResource, e.currentSessionResource))) { + let hasMatchingResource = false; + if (e.currentSessionResource) { + for (const r of this._questionCarouselSessionResources.values()) { + if (isEqual(r, e.currentSessionResource)) { + hasMatchingResource = true; + break; + } + } + } + if (this._questionCarouselSessionResources.size > 0 && (!e.currentSessionResource || !hasMatchingResource)) { this.clearQuestionCarousel(); } @@ -2585,60 +2592,74 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge renderQuestionCarousel(carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, options: IChatQuestionCarouselOptions): ChatQuestionCarouselPart { - if (this._chatQuestionCarouselWidget.value) { - const existingCarousel = this._chatQuestionCarouselWidget.value; - const existingResolveId = existingCarousel.carousel.resolveId; - if (existingResolveId && carousel.resolveId && existingResolveId === carousel.resolveId) { - return existingCarousel; - } + const carouselKey = carousel.resolveId ?? `${isResponseVM(context.element) ? context.element.requestId : ''}_${context.contentIndex}`; - // Complete the old carousel's completion promise as skipped before clearing - // This prevents the askQuestions tool from hanging when parallel subagents invoke it - const oldCarousel = existingCarousel.carousel; - if (oldCarousel instanceof ChatQuestionCarouselData && !oldCarousel.completion.isSettled) { - oldCarousel.completion.complete({ answers: undefined }); - } - - this.clearQuestionCarousel(); + // If a carousel with the same key already exists, return it + const existing = this._chatQuestionCarouselWidgets.get(carouselKey); + if (existing) { + return existing; } - // track the response id and session - this._currentQuestionCarouselResponseId = isResponseVM(context.element) ? context.element.requestId : undefined; - this._currentQuestionCarouselSessionResource = isResponseVM(context.element) ? context.element.sessionResource : undefined; + // Track the response id and session for this carousel + if (isResponseVM(context.element)) { + this._questionCarouselResponseIds.set(carouselKey, context.element.requestId); + this._questionCarouselSessionResources.set(carouselKey, context.element.sessionResource); + } - const part = this._chatQuestionCarouselDisposables.add( - this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, options) - ); - this._chatQuestionCarouselWidget.value = part; + const part = this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, options); + this._chatQuestionCarouselWidgets.set(carouselKey, part); this._hasQuestionCarouselContextKey?.set(true); - dom.clearNode(this.chatQuestionCarouselContainer); dom.append(this.chatQuestionCarouselContainer, part.domNode); return part; } - clearQuestionCarousel(responseId?: string): void { - if (responseId && this._currentQuestionCarouselResponseId !== responseId) { - return; + clearQuestionCarousel(responseId?: string, resolveId?: string): void { + if (resolveId !== undefined) { + // Remove a specific carousel by resolveId + const part = this._chatQuestionCarouselWidgets.get(resolveId); + if (part) { + part.domNode.remove(); + this._chatQuestionCarouselWidgets.deleteAndDispose(resolveId); + } + this._questionCarouselResponseIds.delete(resolveId); + this._questionCarouselSessionResources.delete(resolveId); + } else if (responseId !== undefined) { + // Remove all carousels associated with a given responseId + for (const [key, rid] of this._questionCarouselResponseIds) { + if (rid === responseId) { + const part = this._chatQuestionCarouselWidgets.get(key); + if (part) { + part.domNode.remove(); + this._chatQuestionCarouselWidgets.deleteAndDispose(key); + } + this._questionCarouselResponseIds.delete(key); + this._questionCarouselSessionResources.delete(key); + } + } + } else { + // Clear all carousels + this._chatQuestionCarouselWidgets.clearAndDisposeAll(); + this._questionCarouselResponseIds.clear(); + this._questionCarouselSessionResources.clear(); + dom.clearNode(this.chatQuestionCarouselContainer); } - this._chatQuestionCarouselDisposables.clear(); - this._chatQuestionCarouselWidget.clear(); - this._currentQuestionCarouselResponseId = undefined; - this._currentQuestionCarouselSessionResource = undefined; - this._hasQuestionCarouselContextKey?.set(false); - dom.clearNode(this.chatQuestionCarouselContainer); - } - get questionCarouselResponseId(): string | undefined { - return this._currentQuestionCarouselResponseId; + this._hasQuestionCarouselContextKey?.set(this._chatQuestionCarouselWidgets.size > 0); } get questionCarousel(): ChatQuestionCarouselPart | undefined { - return this._chatQuestionCarouselWidget.value; + // Return the focused carousel, or the first one + for (const part of this._chatQuestionCarouselWidgets.values()) { + if (part.hasFocus()) { + return part; + } + } + return this._chatQuestionCarouselWidgets.size > 0 ? this._chatQuestionCarouselWidgets.values().next().value : undefined; } focusQuestionCarousel(): boolean { - const carousel = this._chatQuestionCarouselWidget.value; + const carousel = this.questionCarousel; if (carousel) { carousel.focus(); return true; @@ -2647,17 +2668,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } isQuestionCarouselFocused(): boolean { - const carousel = this._chatQuestionCarouselWidget.value; - return carousel?.hasFocus() ?? false; + for (const part of this._chatQuestionCarouselWidgets.values()) { + if (part.hasFocus()) { + return true; + } + } + return false; } navigateToPreviousQuestion(): boolean { - const carousel = this._chatQuestionCarouselWidget.value; + const carousel = this.questionCarousel; return carousel?.navigateToPreviousQuestion() ?? false; } navigateToNextQuestion(): boolean { - const carousel = this._chatQuestionCarouselWidget.value; + const carousel = this.questionCarousel; return carousel?.navigateToNextQuestion() ?? false; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index a03b216363ceb..0452f785a63ed 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -54,7 +54,8 @@ import { IDynamicVariable } from '../../../../common/attachments/chatVariables.j import { ChatAgentLocation, ChatModeKind, isSupportedChatFileScheme } from '../../../../common/constants.js'; import { isToolSet } from '../../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../../common/chatSessionsService.js'; -import { IPromptsService, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../../../common/promptSyntax/promptTypes.js'; import { ChatSubmitAction, IChatExecuteActionContext } from '../../../actions/chatExecuteActions.js'; import { IChatWidget, IChatWidgetService } from '../../../chat.js'; import { resizeImage } from '../../../chatImageUtils.js'; 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 773f896f6e846..fbd4a16a5794a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -28,7 +28,8 @@ import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { PromptsStorage, Target } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 455e1671b77e6..9484c92e09aed 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -814,10 +814,12 @@ have to be updated for changes to the rules above, or to support more deeply nes background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); border-radius: var(--vscode-cornerRadius-large); - padding: 0 0px 6px 6px; + padding: 0 6px 6px 6px; /* top padding is inside the editor widget */ width: 100%; position: relative; + /* Prevent contents from covering border corner */ + overflow: hidden; } /* Context usage widget container - positioned in the bottom toolbar */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index f57aca1530064..83799c15a7bc8 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -130,6 +130,14 @@ padding: 0 6px 0 14px; } + /* Stacked: symmetric padding */ + &.sessions-control-orientation-stacked { + + .agent-sessions-empty-filter-message { + padding-left: 20px; + } + } + /* Right position: symmetric padding */ &.sessions-control-orientation-sidebyside.chat-view-position-right { diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index f6501831c4cd6..8b0e548eaf2ed 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -20,7 +20,8 @@ import { IChatAgentService } from './participants/chatAgents.js'; import { ChatContextKeys } from './actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; import { IHandOff, isTarget } from './promptSyntax/promptFileParser.js'; -import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage, Target } from './promptSyntax/service/promptsService.js'; +import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { Target } from './promptSyntax/promptTypes.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { hash } from '../../../../base/common/hash.js'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 868b0be875f9b..4d1fd79be9f59 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -14,7 +14,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; -import { HookTypeValue } from '../promptSyntax/hookSchema.js'; +import { HookTypeValue } from '../promptSyntax/hookTypes.js'; import { ISelection } from '../../../../../editor/common/core/selection.js'; import { Command, Location, TextEdit } from '../../../../../editor/common/languages.js'; import { FileType } from '../../../../../platform/files/common/files.js'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index c2393a4b9cd98..25bf8d21d2edd 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -40,7 +40,7 @@ import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, ICha import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; -import { ChatMcpServersStarting, ChatPendingRequestChangeClassification, ChatPendingRequestChangeEvent, ChatPendingRequestChangeEventName, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; +import { ChatMcpServersStarting, ChatPendingRequestChangeClassification, ChatPendingRequestChangeEvent, ChatPendingRequestChangeEventName, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, ChatSendResultSent, ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSessionsService } from '../chatSessionsService.js'; import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js'; @@ -53,7 +53,7 @@ import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../langua import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; -import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; +import { ChatRequestHooks } from '../promptSyntax/hookSchema.js'; const serializedChatKey = 'interactive.sessions'; @@ -947,7 +947,7 @@ export class ChatService extends Disposable implements IChatService { let detectedCommand: IChatAgentCommand | undefined; // Collect hooks from hook .json files - let collectedHooks: IChatRequestHooks | undefined; + let collectedHooks: ChatRequestHooks | undefined; let hasDisabledClaudeHooks = false; try { const hooksInfo = await this.promptsService.getHooks(token, model.sessionResource); @@ -1241,49 +1241,90 @@ export class ChatService extends Disposable implements IChatService { /** * Process the next pending request from the model's queue, if any. * Called after a request completes to continue processing queued requests. + * Multiple consecutive steering requests are combined into a single request. */ private processNextPendingRequest(model: ChatModel): void { - const pendingRequest = model.dequeuePendingRequest(); - if (!pendingRequest) { + // Dequeue all consecutive steering requests and combine them into one + const steeringRequests = model.dequeueAllSteeringRequests(); + + // Then dequeue a single non-steering request if no steering was found + const nextQueued = steeringRequests.length === 0 ? model.dequeuePendingRequest() : undefined; + + const allRequests = steeringRequests.length > 0 ? steeringRequests : (nextQueued ? [nextQueued] : []); + if (allRequests.length === 0) { return; } - this.trace('processNextPendingRequest', `Processing queued request for session ${model.sessionResource}`); + this.trace('processNextPendingRequest', `Processing ${allRequests.length} queued request(s) for session ${model.sessionResource}`); - const deferred = this._queuedRequestDeferreds.get(pendingRequest.request.id); - this._queuedRequestDeferreds.delete(pendingRequest.request.id); + // Collect and remove all deferreds + const deferreds: DeferredPromise[] = []; + for (const req of allRequests) { + const deferred = this._queuedRequestDeferreds.get(req.request.id); + this._queuedRequestDeferreds.delete(req.request.id); + if (deferred) { + deferreds.push(deferred); + } + } + // Build send options from the first request, combining attachments from all + const firstRequest = allRequests[0]; const sendOptions: IChatSendRequestOptions = { - ...pendingRequest.sendOptions, - // Ensure attachedContext is preserved after deserialization, where sendOptions - // loses attachedContext but the request model retains it in variableData. - attachedContext: pendingRequest.request.variableData.variables.slice(), + ...firstRequest.sendOptions, + attachedContext: allRequests.flatMap(req => req.request.variableData.variables.slice()), }; + const location = sendOptions.location ?? sendOptions.locationData?.type ?? model.initialLocation; const defaultAgent = this.chatAgentService.getDefaultAgent(location, sendOptions.modeInfo?.kind); if (!defaultAgent) { this.logService.warn('processNextPendingRequest', `No default agent for location ${location}`); - deferred?.complete({ kind: 'rejected', reason: 'No default agent available' }); + for (const deferred of deferreds) { + deferred.complete({ kind: 'rejected', reason: 'No default agent available' }); + } + return; + } + + // For multiple steering requests, combine texts and re-parse; otherwise use as-is + let parsedRequest: IParsedChatRequest; + try { + if (allRequests.length > 1) { + const combinedText = allRequests.map(req => req.request.message.text).join('\n\n'); + // message.text already includes agent/slash-command prefixes from the + // original parse, so clear them to avoid double-prefixing. + parsedRequest = this.parseChatRequest(model.sessionResource, combinedText, location, { + ...sendOptions, + agentId: undefined, + slashCommand: undefined, + }); + } else { + parsedRequest = firstRequest.request.message; + } + } catch (err) { + this.logService.error('processNextPendingRequest: failed to parse combined chat request', err); + const reason = toErrorMessage(err); + for (const deferred of deferreds) { + deferred.complete({ kind: 'rejected', reason }); + } return; } - const parsedRequest = pendingRequest.request.message; const silentAgent = sendOptions.agentIdSilent ? this.chatAgentService.getAgent(sendOptions.agentIdSilent) : undefined; const agent = silentAgent ?? parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); - // Send the queued request - this will add it to _pendingRequests and handle it normally - const responseState = this._sendRequestAsync(model, model.sessionResource, parsedRequest, pendingRequest.request.attempt, !sendOptions.noCommandDetection, silentAgent ?? defaultAgent, location, sendOptions); + const responseState = this._sendRequestAsync(model, model.sessionResource, parsedRequest, firstRequest.request.attempt, !sendOptions.noCommandDetection, silentAgent ?? defaultAgent, location, sendOptions); - // Resolve the deferred with the sent result - deferred?.complete({ + const result: ChatSendResultSent = { kind: 'sent', data: { ...responseState, agent, slashCommand: agentSlashCommandPart?.command, }, - }); + }; + for (const deferred of deferreds) { + deferred.complete(result); + } } private generateInitialChatTitleIfNeeded(model: ChatModel, request: IChatAgentRequest, defaultAgent: IChatAgentData, token: CancellationToken): void { diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index c609524139d5f..da4c1b50db356 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -15,7 +15,7 @@ import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participa import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; -import { Target } from './promptSyntax/service/promptsService.js'; +import { Target } from './promptSyntax/promptTypes.js'; export const enum ChatSessionStatus { Failed = 0, diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 52fe226750675..9ed80f0451e93 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -11,7 +11,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', PluginsEnabled = 'chat.plugins.enabled', - PluginPaths = 'chat.plugins.paths', + PluginLocations = 'chat.pluginLocations', PluginMarketplaces = 'chat.plugins.marketplaces', AgentEnabled = 'chat.agent.enabled', PlanAgentDefaultModel = 'chat.planAgent.defaultModel', diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 6b322748eed0d..3fb3a88c82fb5 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1994,6 +1994,21 @@ export class ChatModel extends Disposable implements IChatModel { return request; } + /** + * @internal Used by ChatService to dequeue all consecutive steering requests at the front of the queue. + * Returns an empty array if the first pending request is not a steering request. + */ + dequeueAllSteeringRequests(): IChatPendingRequest[] { + const steeringRequests: IChatPendingRequest[] = []; + while (this._pendingRequests.at(0)?.kind === ChatRequestQueueKind.Steering) { + steeringRequests.push(this._pendingRequests.shift()!); + } + if (steeringRequests.length > 0) { + this._onDidChangePendingRequests.fire(); + } + return steeringRequests; + } + /** * @internal Used by ChatService to clear all pending requests */ diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 8a92fdb502f07..817ebbb18b820 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -21,7 +21,7 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatContextKeys } from '../actions/chatContextKeys.js'; import { IChatAgentEditedFileEvent, IChatProgressHistoryResponseContent, IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatAgentData } from '../model/chatModel.js'; -import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; +import { ChatRequestHooks } from '../promptSyntax/hookSchema.js'; import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; @@ -153,7 +153,7 @@ export interface IChatAgentRequest { * Collected hooks configuration for this request. * Contains all hooks defined in hooks .json files, organized by hook type. */ - hooks?: IChatRequestHooks; + hooks?: ChatRequestHooks; /** * Whether any hooks are enabled for this request. */ diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index 50f145ccf1004..26cf4257f0a89 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -13,7 +13,7 @@ import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData } from import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Target } from '../promptSyntax/service/promptsService.js'; +import { Target } from '../promptSyntax/promptTypes.js'; //#region slash service, commands etc diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 81ba54b263fa5..bbc65030621a6 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -10,7 +10,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { SyncDescriptor0 } from '../../../../../platform/instantiation/common/descriptors.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMcpServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { HookType, IHookCommand } from '../promptSyntax/hookSchema.js'; +import { IHookCommand } from '../promptSyntax/hookSchema.js'; +import { HookType } from '../promptSyntax/hookTypes.js'; import { IMarketplacePlugin } from './pluginMarketplaceService.js'; export const IAgentPluginService = createDecorator('agentPluginService'); diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index fa2ed06dbc456..bfd8c768e5e6f 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -5,6 +5,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; +import { untildify } from '../../../../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { cloneAndChange } from '../../../../../base/common/objects.js'; @@ -699,7 +700,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery { - private readonly _pluginPathsConfig: IObservable>; + private readonly _pluginLocationsConfig: IObservable>; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -711,13 +712,13 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery @IInstantiationService instantiationService: IInstantiationService, ) { super(fileService, pathService, logService, instantiationService); - this._pluginPathsConfig = observableConfigValue>(ChatConfiguration.PluginPaths, {}, _configurationService); + this._pluginLocationsConfig = observableConfigValue>(ChatConfiguration.PluginLocations, {}, _configurationService); } public override start(): void { const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); this._register(autorun(reader => { - this._pluginPathsConfig.read(reader); + this._pluginLocationsConfig.read(reader); scheduler.schedule(); })); scheduler.schedule(); @@ -725,14 +726,15 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery protected override async _discoverPluginSources(): Promise { const sources: IPluginSource[] = []; - const config = this._pluginPathsConfig.get(); + const config = this._pluginLocationsConfig.get(); + const userHome = await this._getUserHome(); for (const [path, enabled] of Object.entries(config)) { if (!path.trim()) { continue; } - const resources = this._resolvePluginPath(path.trim()); + const resources = this._resolvePluginPath(path.trim(), userHome); for (const resource of resources) { let stat; try { @@ -762,11 +764,23 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery return sources; } + private async _getUserHome(): Promise { + const userHome = await this._pathService.userHome(); + return userHome.scheme === 'file' ? userHome.fsPath : userHome.path; + } + /** - * Resolves a plugin path to one or more resource URIs. Absolute paths are - * used directly; relative paths are resolved against each workspace folder. + * Resolves a plugin path to one or more resource URIs. Supports: + * - Absolute paths (used directly) + * - Tilde paths (expanded to user home directory) + * - Relative paths (resolved against each workspace folder) */ - private _resolvePluginPath(path: string): URI[] { + private _resolvePluginPath(path: string, userHome: string): URI[] { + if (path.startsWith('~')) { + path = untildify(path, userHome); + } + + // Handle absolute paths if (win32.isAbsolute(path) || posix.isAbsolute(path)) { return [URI.file(path)]; } @@ -781,7 +795,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery * writing to the most specific config target where the key is defined. */ private _updatePluginPathEnabled(configKey: string, value: boolean): void { - const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); + const inspected = this._configurationService.inspect>(ChatConfiguration.PluginLocations); // Walk from most specific to least specific to find where this key is defined const targets = [ @@ -797,7 +811,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery const mapping = getConfigValueInTarget(inspected, target); if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { this._configurationService.updateValue( - ChatConfiguration.PluginPaths, + ChatConfiguration.PluginLocations, { ...mapping, [configKey]: value }, target, ); @@ -808,18 +822,18 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery // Key not found in any target; write to USER_LOCAL as default const current = getConfigValueInTarget(inspected, ConfigurationTarget.USER_LOCAL) ?? {}; this._configurationService.updateValue( - ChatConfiguration.PluginPaths, + ChatConfiguration.PluginLocations, { ...current, [configKey]: value }, ConfigurationTarget.USER_LOCAL, ); } /** - * Removes a plugin path from `chat.plugins.paths` in the most specific + * Removes a plugin path from `chat.pluginLocations` in the most specific * config target where the key is defined. */ private _removePluginPath(configKey: string): void { - const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); + const inspected = this._configurationService.inspect>(ChatConfiguration.PluginLocations); const targets = [ ConfigurationTarget.WORKSPACE_FOLDER, @@ -836,7 +850,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery const updated = { ...mapping }; delete updated[configKey]; this._configurationService.updateValue( - ChatConfiguration.PluginPaths, + ChatConfiguration.PluginLocations, updated, target, ); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index eb567363a8ece..6776c6a2e282a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -4,23 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; -import { HookType, IHookCommand, toHookType, resolveHookCommand } from './hookSchema.js'; - -/** - * Maps Claude hook type names to our abstract HookType. - * Claude uses PascalCase and slightly different names. - * @see https://docs.anthropic.com/en/docs/claude-code/hooks - */ -export const CLAUDE_HOOK_TYPE_MAP: Record = { - 'SessionStart': HookType.SessionStart, - 'UserPromptSubmit': HookType.UserPromptSubmit, - 'PreToolUse': HookType.PreToolUse, - 'PostToolUse': HookType.PostToolUse, - 'PreCompact': HookType.PreCompact, - 'SubagentStart': HookType.SubagentStart, - 'SubagentStop': HookType.SubagentStop, - 'Stop': HookType.Stop, -}; +import { toHookType, resolveHookCommand, IHookCommand } from './hookSchema.js'; +import { HOOKS_BY_TARGET, HookType } from './hookTypes.js'; +import { Target } from './promptTypes.js'; /** * Cached inverse mapping from HookType to Claude hook type name. @@ -31,7 +17,7 @@ let _hookTypeToClaudeName: Map | undefined; function getHookTypeToClaudeNameMap(): Map { if (!_hookTypeToClaudeName) { _hookTypeToClaudeName = new Map(); - for (const [claudeName, hookType] of Object.entries(CLAUDE_HOOK_TYPE_MAP)) { + for (const [claudeName, hookType] of Object.entries(HOOKS_BY_TARGET[Target.Claude])) { _hookTypeToClaudeName.set(hookType, claudeName); } } @@ -42,7 +28,7 @@ function getHookTypeToClaudeNameMap(): Map { * Resolves a Claude hook type name to our abstract HookType. */ export function resolveClaudeHookType(name: string): HookType | undefined { - return CLAUDE_HOOK_TYPE_MAP[name]; + return HOOKS_BY_TARGET[Target.Claude][name]; } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index d00fd26cb1eef..64e46956b17ae 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -5,9 +5,10 @@ import { URI } from '../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../base/common/path.js'; -import { HookType, IHookCommand, toHookType } from './hookSchema.js'; +import { IHookCommand, toHookType } from './hookSchema.js'; import { parseClaudeHooks, extractHookCommandsFromItem } from './hookClaudeCompat.js'; import { resolveCopilotCliHookType } from './hookCopilotCliCompat.js'; +import { HookType } from './hookTypes.js'; /** * Represents a hook source with its original and normalized properties. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts index 587771544b28d..9bf9c6b1076c1 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts @@ -3,7 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { COPILOT_CLI_HOOK_TYPE_MAP, HookType } from './hookSchema.js'; +import { HOOKS_BY_TARGET, HookType } from './hookTypes.js'; +import { Target } from './promptTypes.js'; + +const COPILOT_CLI_HOOK_TYPE_MAP: Record = HOOKS_BY_TARGET[Target.GitHubCopilot]; /** * Cached inverse mapping from HookType to Copilot CLI hook type name. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 62fee88c20e54..0d69dce53bff9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -10,82 +10,8 @@ import { joinPath } from '../../../../../base/common/resources.js'; import { isAbsolute } from '../../../../../base/common/path.js'; import { untildify } from '../../../../../base/common/labels.js'; import { OperatingSystem } from '../../../../../base/common/platform.js'; - -/** - * Enum of available hook types that can be configured in hooks .json - */ -export enum HookType { - SessionStart = 'SessionStart', - UserPromptSubmit = 'UserPromptSubmit', - PreToolUse = 'PreToolUse', - PostToolUse = 'PostToolUse', - PreCompact = 'PreCompact', - SubagentStart = 'SubagentStart', - SubagentStop = 'SubagentStop', - Stop = 'Stop', -} - -/** - * Maps Copilot CLI hook type names to our abstract HookType. - * Copilot CLI uses camelCase names. - */ -export const COPILOT_CLI_HOOK_TYPE_MAP = { - 'sessionStart': HookType.SessionStart, - 'userPromptSubmitted': HookType.UserPromptSubmit, - 'preToolUse': HookType.PreToolUse, - 'postToolUse': HookType.PostToolUse, -} as const satisfies Record; - -/** - * String literal type derived from HookType enum values. - */ -export type HookTypeValue = `${HookType}`; - -/** - * Metadata for hook types including localized labels and descriptions - */ -export const HOOK_TYPES = [ - { - id: HookType.SessionStart, - label: nls.localize('hookType.sessionStart.label', "Session Start"), - description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins.") - }, - { - id: HookType.UserPromptSubmit, - label: nls.localize('hookType.userPromptSubmit.label', "User Prompt Submit"), - description: nls.localize('hookType.userPromptSubmit.description', "Executed when the user submits a prompt to the agent.") - }, - { - id: HookType.PreToolUse, - label: nls.localize('hookType.preToolUse.label', "Pre-Tool Use"), - description: nls.localize('hookType.preToolUse.description', "Executed before the agent uses any tool.") - }, - { - id: HookType.PostToolUse, - label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), - description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") - }, - { - id: HookType.PreCompact, - label: nls.localize('hookType.preCompact.label', "Pre-Compact"), - description: nls.localize('hookType.preCompact.description', "Executed before the agent compacts the conversation context.") - }, - { - id: HookType.SubagentStart, - label: nls.localize('hookType.subagentStart.label', "Subagent Start"), - description: nls.localize('hookType.subagentStart.description', "Executed when a subagent is started.") - }, - { - id: HookType.SubagentStop, - label: nls.localize('hookType.subagentStop.label', "Subagent Stop"), - description: nls.localize('hookType.subagentStop.description', "Executed when a subagent stops.") - }, - { - id: HookType.Stop, - label: nls.localize('hookType.stop.label', "Stop"), - description: nls.localize('hookType.stop.description', "Executed when the agent stops.") - } -] as const; +import { HookType, HOOKS_BY_TARGET, HOOK_METADATA } from './hookTypes.js'; +import { Target } from './promptTypes.js'; /** * A single hook command configuration. @@ -116,22 +42,15 @@ export interface IHookCommand { * Collected hooks for a chat request, organized by hook type. * This is passed to the extension host so it knows what hooks are available. */ -export interface IChatRequestHooks { - readonly [HookType.SessionStart]?: readonly IHookCommand[]; - readonly [HookType.UserPromptSubmit]?: readonly IHookCommand[]; - readonly [HookType.PreToolUse]?: readonly IHookCommand[]; - readonly [HookType.PostToolUse]?: readonly IHookCommand[]; - readonly [HookType.PreCompact]?: readonly IHookCommand[]; - readonly [HookType.SubagentStart]?: readonly IHookCommand[]; - readonly [HookType.SubagentStop]?: readonly IHookCommand[]; - readonly [HookType.Stop]?: readonly IHookCommand[]; -} +export type ChatRequestHooks = { + readonly [K in HookType]?: readonly IHookCommand[]; +}; /** * JSON Schema for GitHub Copilot hook configuration files. * Hooks enable executing custom shell commands at strategic points in an agent's workflow. */ -const hookCommandSchema: IJSONSchema = { +const vscodeHookCommandSchema: IJSONSchema = { type: 'object', additionalProperties: true, required: ['type'], @@ -185,46 +104,26 @@ const hookCommandSchema: IJSONSchema = { const hookArraySchema: IJSONSchema = { type: 'array', - items: hookCommandSchema + items: vscodeHookCommandSchema }; /** - * Hook properties for the VS Code / PascalCase format. + * Builds JSON Schema hook properties for a given target by looking up + * the hook keys from HOOKS_BY_TARGET and descriptions from HOOK_METADATA. */ -const vscodeHookProperties: { [key in HookType]: IJSONSchema } = { - SessionStart: { - ...hookArraySchema, - description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins. Use to initialize environments, log session starts, validate project state, or set up temporary resources.') - }, - UserPromptSubmit: { - ...hookArraySchema, - description: nls.localize('hookFile.userPromptSubmit', 'Executed when the user submits a prompt to the agent. Use to log user requests for auditing and usage analysis.') - }, - PreToolUse: { - ...hookArraySchema, - description: nls.localize('hookFile.preToolUse', 'Executed before the agent uses any tool. This is the most powerful hook as it can approve or deny tool executions. Use to block dangerous commands, enforce security policies, require approval for sensitive operations, or log tool usage.') - }, - PostToolUse: { - ...hookArraySchema, - description: nls.localize('hookFile.postToolUse', 'Executed after a tool completes execution successfully. Use to log execution results, track usage statistics, generate audit trails, or monitor performance.') - }, - PreCompact: { - ...hookArraySchema, - description: nls.localize('hookFile.preCompact', 'Executed before the agent compacts the conversation context. Use to save conversation state, export important information, or prepare for context reduction.') - }, - SubagentStart: { - ...hookArraySchema, - description: nls.localize('hookFile.subagentStart', 'Executed when a subagent is started. Use to log subagent spawning, track nested agent usage, or initialize subagent-specific resources.') - }, - SubagentStop: { - ...hookArraySchema, - description: nls.localize('hookFile.subagentStop', 'Executed when a subagent stops. Use to log subagent completion, cleanup subagent resources, or aggregate subagent results.') - }, - Stop: { - ...hookArraySchema, - description: nls.localize('hookFile.stop', 'Executed when the agent session stops. Use to cleanup resources, generate final reports, or send completion notifications.') - } -}; +function buildHookProperties(target: Target, arraySchema: IJSONSchema): Record { + return Object.fromEntries( + Object.entries(HOOKS_BY_TARGET[target]).map(([key, hookType]) => [ + key, + { ...arraySchema, description: HOOK_METADATA[hookType]?.description } + ]) + ); +} + +/** + * Hook properties for the VS Code format. + */ +const vscodeHookProperties: Record = buildHookProperties(Target.VSCode, hookArraySchema); /** * Hook command schema for the Copilot CLI format. @@ -276,27 +175,9 @@ const copilotCliHookArraySchema: IJSONSchema = { }; /** - * Hook properties for the Copilot CLI / camelCase format. - * Maps from the Copilot CLI hook type names defined in COPILOT_CLI_HOOK_TYPE_MAP. + * Hook properties for the Copilot CLI format. */ -const copilotCliHookProperties: { [key in keyof typeof COPILOT_CLI_HOOK_TYPE_MAP]: IJSONSchema } = { - sessionStart: { - ...copilotCliHookArraySchema, - description: nls.localize('hookFile.cli.sessionStart', 'Executed when a new agent session begins.') - }, - userPromptSubmitted: { - ...copilotCliHookArraySchema, - description: nls.localize('hookFile.cli.userPromptSubmitted', 'Executed when the user submits a prompt to the agent.') - }, - preToolUse: { - ...copilotCliHookArraySchema, - description: nls.localize('hookFile.cli.preToolUse', 'Executed before the agent uses any tool. Can approve or deny tool executions.') - }, - postToolUse: { - ...copilotCliHookArraySchema, - description: nls.localize('hookFile.cli.postToolUse', 'Executed after a tool completes execution successfully.') - }, -}; +const copilotCliHookProperties: Record = buildHookProperties(Target.GitHubCopilot, copilotCliHookArraySchema); export const hookFileSchema: IJSONSchema = { $schema: 'http://json-schema.org/draft-07/schema#', @@ -369,11 +250,6 @@ export const hookFileSchema: IJSONSchema = { */ export const HOOK_SCHEMA_URI = 'vscode://schemas/hooks'; -/** - * Glob pattern for hook files. - */ -export const HOOK_FILE_GLOB = '.github/hooks/*.json'; - /** * Normalizes a raw hook type identifier to the canonical HookType enum value. * Only matches exact enum values. For tool-specific naming conventions (e.g., Claude, Copilot CLI), diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts new file mode 100644 index 0000000000000..66d28c22876eb --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../../../nls.js'; +import { Target } from './promptTypes.js'; + +/** + * Enum of hook types across all targets. For the set of supported hooks per target, see HOOKS_BY_TARGET. + */ +export enum HookType { + SessionStart = 'SessionStart', + SessionEnd = 'SessionEnd', + UserPromptSubmit = 'UserPromptSubmit', + PreToolUse = 'PreToolUse', + PostToolUse = 'PostToolUse', + PreCompact = 'PreCompact', + SubagentStart = 'SubagentStart', + SubagentStop = 'SubagentStop', + Stop = 'Stop', + ErrorOccurred = 'ErrorOccurred', +} + +/** + * String literal type derived from HookType enum values. + */ +export type HookTypeValue = `${HookType}`; + +export const HOOKS_BY_TARGET: Record> = { + // see https://code.visualstudio.com/docs/copilot/customization/hooks#_hook-lifecycle-events + [Target.VSCode]: { + 'SessionStart': HookType.SessionStart, + 'UserPromptSubmit': HookType.UserPromptSubmit, + 'PreToolUse': HookType.PreToolUse, + 'PostToolUse': HookType.PostToolUse, + 'PreCompact': HookType.PreCompact, + 'SubagentStart': HookType.SubagentStart, + 'SubagentStop': HookType.SubagentStop, + 'Stop': HookType.Stop, + }, + // see https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks#types-of-hooks + [Target.GitHubCopilot]: { + 'sessionStart': HookType.SessionStart, + 'sessionEnd': HookType.SessionEnd, + 'userPromptSubmitted': HookType.UserPromptSubmit, + 'preToolUse': HookType.PreToolUse, + 'postToolUse': HookType.PostToolUse, + 'agentStop': HookType.Stop, + 'subagentStop': HookType.SubagentStop, + 'errorOccurred': HookType.ErrorOccurred + }, + // see https://docs.anthropic.com/en/docs/claude-code/hooks + [Target.Claude]: { + 'SessionStart': HookType.SessionStart, + 'UserPromptSubmit': HookType.UserPromptSubmit, + 'PreToolUse': HookType.PreToolUse, + 'PostToolUse': HookType.PostToolUse, + 'PreCompact': HookType.PreCompact, + 'SubagentStart': HookType.SubagentStart, + 'SubagentStop': HookType.SubagentStop, + 'Stop': HookType.Stop, + }, + // if no target, just list all known hook types. + [Target.Undefined]: Object.fromEntries( + Object.values(HookType).map(h => [h, h]) + ) as Record +}; + +/** + * Metadata for a hook type including localized label and description. + */ +export interface IHookTypeMeta { + readonly label: string; + readonly description: string; +} + +/** + * Metadata for hook types including localized labels and descriptions + */ +export const HOOK_METADATA: { [key in HookType]: IHookTypeMeta } = { + [HookType.SessionStart]: { + label: nls.localize('hookType.sessionStart.label', "Session Start"), + description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins.") + }, + [HookType.UserPromptSubmit]: { + label: nls.localize('hookType.userPromptSubmit.label', "User Prompt Submit"), + description: nls.localize('hookType.userPromptSubmit.description', "Executed when the user submits a prompt to the agent.") + }, + [HookType.PreToolUse]: { + label: nls.localize('hookType.preToolUse.label', "Pre-Tool Use"), + description: nls.localize('hookType.preToolUse.description', "Executed before the agent uses any tool.") + }, + [HookType.PostToolUse]: { + label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), + description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") + }, + [HookType.PreCompact]: { + label: nls.localize('hookType.preCompact.label', "Pre-Compact"), + description: nls.localize('hookType.preCompact.description', "Executed before the agent compacts the conversation context.") + }, + [HookType.SubagentStart]: { + label: nls.localize('hookType.subagentStart.label', "Subagent Start"), + description: nls.localize('hookType.subagentStart.description', "Executed when a subagent is started.") + }, + [HookType.SubagentStop]: { + label: nls.localize('hookType.subagentStop.label', "Subagent Stop"), + description: nls.localize('hookType.subagentStop.description', "Executed when a subagent stops.") + }, + [HookType.Stop]: { + label: nls.localize('hookType.stop.label', "Stop"), + description: nls.localize('hookType.stop.description', "Executed when the agent stops.") + }, + [HookType.SessionEnd]: { + label: nls.localize('hookType.sessionEnd.label', "Session End"), + description: nls.localize('hookType.sessionEnd.description', "Executed when an agent session ends.") + }, + [HookType.ErrorOccurred]: { + label: nls.localize('hookType.errorOccurred.label', "Error Occurred"), + description: nls.localize('hookType.errorOccurred.description', "Executed when an error occurs during the agent session.") + } +}; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 5e60a57cf89e0..66f81749bf583 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -12,8 +12,8 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService } from '../../tools/languageModelToolsService.js'; import { IChatModeService } from '../../chatModes.js'; -import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IPromptsService, Target } from '../service/promptsService.js'; +import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; +import { IPromptsService } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { ClaudeHeaderAttributes, ISequenceValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; import { getAttributeDescription, getTarget, getValidAttributeNames, claudeAgentAttributes, claudeRulesAttributes, knownClaudeTools, knownGithubCopilotTools, IValueEntry } from './promptValidator.js'; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 8fe87c45a10ad..c223dc3145188 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -13,8 +13,8 @@ import { localize } from '../../../../../../nls.js'; import { ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/languageModelToolsService.js'; import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; -import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IPromptsService, Target } from '../service/promptsService.js'; +import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; +import { IPromptsService } from '../service/promptsService.js'; import { ClaudeHeaderAttributes, IHeaderAttribute, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; import { getAttributeDescription, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptValidator.js'; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index daf123f315144..838dfadb33c39 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -15,13 +15,13 @@ import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; -import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; +import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; import { GithubPromptHeaderAttributes, ISequenceValue, IHeaderAttribute, IScalarValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, PromptHeaderAttributes, IValue } from '../promptFileParser.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { IPromptsService, Target } from '../service/promptsService.js'; +import { IPromptsService } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { AGENTS_SOURCE_FOLDER, isInClaudeAgentsFolder, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index 0de7e30f98220..42ed43f03000c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { parse, YamlNode, YamlParseError } from '../../../../../base/common/yaml.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { PositionOffsetTransformer } from '../../../../../editor/common/core/text/positionToOffsetImpl.js'; -import { Target } from './service/promptsService.js'; +import { Target } from './promptTypes.js'; export class PromptFileParser { constructor() { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index 8c4d0cbc58a87..c51a7123aef2f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -92,3 +92,9 @@ export function isValidPromptType(type: string): type is PromptsType { return Object.values(PromptsType).includes(type as PromptsType); } +export enum Target { + VSCode = 'vscode', + GitHubCopilot = 'github-copilot', + Claude = 'claude', + Undefined = 'undefined', +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 73f48099a1396..66d01e1d7afc1 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -11,11 +11,11 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IChatModeInstructions, IVariableReference } from '../../chatModes.js'; -import { PromptsType } from '../promptTypes.js'; +import { PromptsType, Target } from '../promptTypes.js'; import { IHandOff, ParsedPromptFile } from '../promptFileParser.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; -import { IChatRequestHooks } from '../hookSchema.js'; import { IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; +import { ChatRequestHooks } from '../hookSchema.js'; /** * Entry emitted by the prompts service when discovery logging occurs. @@ -168,13 +168,6 @@ export function isCustomAgentVisibility(obj: unknown): obj is ICustomAgentVisibi return typeof v.userInvocable === 'boolean' && typeof v.agentInvocable === 'boolean'; } -export enum Target { - VSCode = 'vscode', - GitHubCopilot = 'github-copilot', - Claude = 'claude', - Undefined = 'undefined', -} - export interface ICustomAgent { /** * URI of a custom agent file. @@ -354,7 +347,7 @@ export interface IPromptDiscoveryInfo { } export interface IConfiguredHooksInfo { - readonly hooks: IChatRequestHooks; + readonly hooks: ChatRequestHooks; readonly hasDisabledClaudeHooks: boolean; } 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 6c566c8bbdc06..05f8827bd0fca 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -30,13 +30,14 @@ import { IUserDataProfileService } from '../../../../../services/userDataProfile import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, getCleanPromptName, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; -import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; +import { PROMPT_LANGUAGE_ID, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target, IPromptDiscoveryLogEntry } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, IPromptDiscoveryLogEntry } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; -import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; +import { ChatRequestHooks, IHookCommand } from '../hookSchema.js'; +import { HookType } from '../hookTypes.js'; import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; @@ -1222,16 +1223,7 @@ export class PromptsService extends Disposable implements IPromptsService { const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; let hasDisabledClaudeHooks = false; - const collectedHooks: Record = { - [HookType.SessionStart]: [], - [HookType.UserPromptSubmit]: [], - [HookType.PreToolUse]: [], - [HookType.PostToolUse]: [], - [HookType.PreCompact]: [], - [HookType.SubagentStart]: [], - [HookType.SubagentStop]: [], - [HookType.Stop]: [], - }; + const collectedHooks = new Map(); const defaultFolder = this.workspaceService.getWorkspace().folders[0]; @@ -1266,7 +1258,12 @@ export class PromptsService extends Disposable implements IPromptsService { for (const [hookType, { hooks: commands }] of hooks) { for (const command of commands) { - collectedHooks[hookType].push(command); + let bucket = collectedHooks.get(hookType); + if (!bucket) { + bucket = []; + collectedHooks.set(hookType, bucket); + } + bucket.push(command); this.logger.trace(`[PromptsService] Collected ${hookType} hook from ${hookFile.uri} (format: ${format})`); } } @@ -1279,21 +1276,23 @@ export class PromptsService extends Disposable implements IPromptsService { const plugins = this.agentPluginService.plugins.get(); for (const plugin of plugins) { for (const hook of plugin.hooks.get()) { - collectedHooks[hook.type].push(...hook.hooks); + let bucket = collectedHooks.get(hook.type); + if (!bucket) { + bucket = []; + collectedHooks.set(hook.type, bucket); + } + bucket.push(...hook.hooks); } } // Check if any hooks were collected - const hasHooks = Object.values(collectedHooks).some(arr => arr.length > 0); - if (!hasHooks) { + if (collectedHooks.size === 0) { this.logger.trace('[PromptsService] No valid hooks collected.'); return undefined; } - // Build the result, only including hook types that have entries - const result: IChatRequestHooks = Object.fromEntries( - Object.entries(collectedHooks).filter(([_, commands]) => commands.length > 0) - ) as IChatRequestHooks; + // Build the result + const result: ChatRequestHooks = Object.fromEntries(collectedHooks) as ChatRequestHooks; this.logger.trace(`[PromptsService] Collected hooks: ${JSON.stringify(Object.keys(result))}`); return { hooks: result, hasDisabledClaudeHooks }; diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index bf7dd424e25dc..5cca23e27618e 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -53,6 +53,22 @@ function truncateToLimit(value: string | undefined, limit: number): string | und return value; } +export function formatHeaderForDisplay(header: string): string { + const normalized = header + .trim() + .replace(/[_-]+/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim(); + + if (!normalized) { + return header; + } + + return normalized.charAt(0).toUpperCase() + normalized.slice(1).toLowerCase(); +} + export interface IQuestionOption { readonly label: string; readonly description?: string; @@ -195,7 +211,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { throw new CancellationError(); } - progress.report({ message: localize('askQuestionsTool.progress', 'Analyzing your answers...') }); + progress.report({ message: localize('askQuestionsTool.progress', 'Reviewing your answers') }); const converted = this.convertCarouselAnswers(questions, answerResult?.answers, idToHeaderMap); const { answeredCount, skippedCount, freeTextCount, recommendedAvailableCount, recommendedSelectedCount } = this.collectMetrics(questions, converted); @@ -300,8 +316,9 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { const internalId = generateUuid(); idToHeaderMap.set(internalId, question.header); - // Truncate header for display only - const displayTitle = truncateToLimit(question.header, HardLimits.header) ?? question.header; + // Format + truncate header for display only; preserve original header for answer correlation + const formattedHeader = formatHeaderForDisplay(question.header); + const displayTitle = truncateToLimit(formattedHeader, HardLimits.header) ?? formattedHeader; return { id: internalId, diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts index 9babb75b5bdb0..ee75a3bc056f5 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts @@ -3,17 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { dirname, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ConfirmedReason, ToolConfirmKind } from '../../chatService/chatService.js'; import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationContribution, + ILanguageModelToolConfirmationContributionQuickTreeItem, ILanguageModelToolConfirmationRef } from '../languageModelToolsConfirmationService.js'; +const workspaceAllowlistMemento = observableMemento({ + key: 'chat.externalPath.workspaceAllowlist', + defaultValue: [], + toStorage: value => JSON.stringify(value), + fromStorage: value => { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + }, +}); + export interface IExternalPathInfo { path: string; isDirectory: boolean; @@ -24,26 +39,59 @@ export interface IExternalPathInfo { * accessing paths outside the workspace, with an option to allow all access * from a containing folder for the current chat session. */ -export class ChatExternalPathConfirmationContribution implements ILanguageModelToolConfirmationContribution { +export class ChatExternalPathConfirmationContribution implements ILanguageModelToolConfirmationContribution, IDisposable { readonly canUseDefaultApprovals = false; private readonly _sessionFolderAllowlist = new ResourceMap(); /** Cache of path URI -> resolved git root URI (or null if not in a repo) */ private readonly _gitRootCache = new ResourceMap(); + private readonly _workspaceAllowlist?: ObservableMemento; constructor( private readonly _getPathInfo: (ref: ILanguageModelToolConfirmationRef) => IExternalPathInfo | undefined, + private readonly _labelService: ILabelService, private readonly _findGitRoot?: (pathUri: URI) => Promise, - ) { } + storageService?: IStorageService, + private readonly _pickFolder?: () => Promise, + ) { + if (storageService) { + this._workspaceAllowlist = workspaceAllowlistMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE, storageService); + } + } - getPreConfirmAction(ref: ILanguageModelToolConfirmationRef): ConfirmedReason | undefined { - const pathInfo = this._getPathInfo(ref); - if (!pathInfo || !ref.chatSessionResource) { - return undefined; + dispose(): void { + this._workspaceAllowlist?.dispose(); + } + + private _getWorkspaceFolders(): ResourceSet { + if (!this._workspaceAllowlist) { + return new ResourceSet(); + } + const set = new ResourceSet(); + for (const s of this._workspaceAllowlist.get()) { + try { + set.add(URI.parse(s)); + } catch { + // ignore malformed URIs + } } + return set; + } + + private _setWorkspaceFolders(folders: ResourceSet): void { + if (!this._workspaceAllowlist) { + return; + } + const uriStrings: string[] = []; + for (const uri of folders) { + uriStrings.push(uri.toString()); + } + this._workspaceAllowlist.set(uriStrings, undefined); + } - const allowedFolders = this._sessionFolderAllowlist.get(ref.chatSessionResource); - if (!allowedFolders || allowedFolders.size === 0) { + getPreConfirmAction(ref: ILanguageModelToolConfirmationRef): ConfirmedReason | undefined { + const pathInfo = this._getPathInfo(ref); + if (!pathInfo) { return undefined; } @@ -55,13 +103,26 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT return undefined; } - // Check if path is under any allowed folder - for (const folderUri of allowedFolders) { + // Check workspace-level allowlist + const workspaceFolders = this._getWorkspaceFolders(); + for (const folderUri of workspaceFolders) { if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, folderUri)) { return { type: ToolConfirmKind.UserAction }; } } + // Check session-level allowlist + if (ref.chatSessionResource) { + const sessionFolders = this._sessionFolderAllowlist.get(ref.chatSessionResource); + if (sessionFolders) { + for (const folderUri of sessionFolders) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, folderUri)) { + return { type: ToolConfirmKind.UserAction }; + } + } + } + } + return undefined; } @@ -149,4 +210,82 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT return actions; } + + getManageActions(): ILanguageModelToolConfirmationContributionQuickTreeItem[] { + const items: ILanguageModelToolConfirmationContributionQuickTreeItem[] = []; + + // Workspace-level entries (persisted) + const workspaceFolders = this._getWorkspaceFolders(); + for (const folderUri of workspaceFolders) { + items.push({ + label: this._labelService.getUriLabel(folderUri), + description: localize('workspaceScope', "Workspace"), + checked: true, + onDidChangeChecked: (checked) => { + if (!checked) { + workspaceFolders.delete(folderUri); + this._setWorkspaceFolders(workspaceFolders); + } else { + workspaceFolders.add(folderUri); + this._setWorkspaceFolders(workspaceFolders); + } + }, + }); + } + + // Session-level entries (ephemeral) + const allSessionFolders = new ResourceSet(); + for (const [, folders] of this._sessionFolderAllowlist) { + for (const folder of folders) { + allSessionFolders.add(folder); + } + } + for (const folderUri of allSessionFolders) { + const wasInSessions = [...this._sessionFolderAllowlist].filter(([, folders]) => folders.has(folderUri)); + items.push({ + label: this._labelService.getUriLabel(folderUri), + description: localize('sessionScope', "Session"), + checked: true, + onDidChangeChecked: (checked) => { + if (!checked) { + for (const [, folders] of wasInSessions) { + folders.delete(folderUri); + } + } else { + for (const [, folders] of wasInSessions) { + folders.add(folderUri); + } + } + }, + }); + } + + // "Add Path..." option to add a new workspace-level folder + if (this._pickFolder) { + const pickFolder = this._pickFolder; + items.push({ + pickable: false, + label: localize('addPath', "Add Path..."), + description: localize('addPathDescription', "Allow a folder in this workspace"), + onDidOpen: async () => { + const uri = await pickFolder(); + if (uri) { + const folders = this._getWorkspaceFolders(); + folders.add(uri); + this._setWorkspaceFolders(folders); + } + } + }); + } + + return items; + } + + reset(): void { + this._sessionFolderAllowlist.clear(); + this._gitRootCache.clear(); + if (this._workspaceAllowlist) { + this._workspaceAllowlist.set([], undefined); + } + } } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 5db3195bbdbcc..e5fd0a7a691a0 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -23,7 +23,7 @@ import { ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; -import { IChatRequestHooks } from '../../promptSyntax/hookSchema.js'; +import { ChatRequestHooks } from '../../promptSyntax/hookSchema.js'; import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; import { isBuiltinAgent } from '../../promptSyntax/utils/promptsServiceUtils.js'; import { @@ -252,7 +252,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { await computer.collect(variableSet, token); // Collect hooks from hook .json files - let collectedHooks: IChatRequestHooks | undefined; + let collectedHooks: ChatRequestHooks | undefined; try { const info = await this.promptsService.getHooks(token, invocation.context.sessionResource); collectedHooks = info?.hooks; diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts index d77bd07c0c0f5..2e3c66261b821 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts @@ -42,7 +42,7 @@ export interface ILanguageModelToolConfirmationActionProducer { export interface ILanguageModelToolConfirmationContributionQuickTreeItem extends IQuickTreeItem { onDidTriggerItemButton?(button: IQuickInputButton): void; onDidChangeChecked?(checked: boolean): void; - onDidOpen?(): void; + onDidOpen?(): void | Promise; } /** @@ -85,7 +85,7 @@ export interface ILanguageModelToolsConfirmationService extends ILanguageModelTo readonly _serviceBrand: undefined; /** Opens an IQuickTree to let the user manage their preferences. */ - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void; + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void; /** * Registers a contribution that provides more specific confirmation logic @@ -93,6 +93,15 @@ export interface ILanguageModelToolsConfirmationService extends ILanguageModelTo */ registerConfirmationContribution(toolName: string, contribution: ILanguageModelToolConfirmationContribution): IDisposable; + /** + * Returns true if the tool has confirmation that can be managed, either + * because it has {@link IToolData.canRequestPreApproval} or + * {@link IToolData.canRequestPostApproval} set, because a + * {@link ILanguageModelToolConfirmationContribution} is registered for it, + * or because it has stored auto-confirmation settings. + */ + toolCanManageConfirmation(tool: IToolData): boolean; + /** Resets all tool and server confirmation preferences */ resetToolAutoConfirmation(): void; } diff --git a/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts b/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts index d1d069424dd20..48596accfdf0b 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts @@ -6,21 +6,37 @@ import { decodeBase64, VSBuffer } from '../../../../../base/common/buffer.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { newWriteableStream, ReadableStreamEvents } from '../../../../../base/common/stream.js'; import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { createFileSystemProviderError, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileService, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IStat } from '../../../../../platform/files/common/files.js'; -import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ChatResponseResource } from '../model/chatModel.js'; import { IChatService, IChatToolInvocation, IChatToolInvocationSerialized } from '../chatService/chatService.js'; import { isToolResultInputOutputDetails } from '../tools/languageModelToolsService.js'; +export const IChatResponseResourceFileSystemProvider = createDecorator('chatResponseResourceFileSystemProvider'); + +export interface IChatResponseResourceFileSystemProvider { + readonly _serviceBrand: undefined; + + /** + * Associates arbitrary data with a URI in the chat response resource filesystem. + * The data is scoped to the given session and automatically cleaned up when + * the session is disposed. + * Returns a URI that can later be read via the file service. + */ + associate(sessionResource: URI, data: Uint8Array | { base64: string }, name?: string): URI; +} + export class ChatResponseResourceFileSystemProvider extends Disposable implements - IWorkbenchContribution, + IChatResponseResourceFileSystemProvider, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability { - public static readonly ID = 'workbench.contrib.chatResponseResourceFileSystemProvider'; + declare readonly _serviceBrand: undefined; public readonly onDidChangeCapabilities = Event.None; public readonly onDidChangeFile = Event.None; @@ -32,12 +48,47 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement | FileSystemProviderCapabilities.FileAtomicRead | FileSystemProviderCapabilities.FileReadWrite; + /** In-memory store for data associated via {@link associate}, keyed by URI. */ + private readonly _associated = new ResourceMap(); + + /** Tracks which associated URIs belong to which session, for cleanup on dispose. */ + private readonly _sessionAssociations = new ResourceMap(); + constructor( @IChatService private readonly chatService: IChatService, @IFileService private readonly _fileService: IFileService ) { super(); this._register(this._fileService.registerProvider(ChatResponseResource.scheme, this)); + this._register(this.chatService.onDidDisposeSession(e => { + for (const sessionResource of e.sessionResource) { + const uris = this._sessionAssociations.get(sessionResource); + if (uris) { + for (const uri of uris) { + this._associated.delete(uri); + } + this._sessionAssociations.delete(sessionResource); + } + } + })); + } + + associate(sessionResource: URI, data: Uint8Array | { base64: string }, name?: string): URI { + const id = generateUuid(); + const uri = URI.from({ + scheme: ChatResponseResource.scheme, + path: `/assoc/${id}` + (name ? `/${name}` : ''), + }); + this._associated.set(uri, data); + + let set = this._sessionAssociations.get(sessionResource); + if (!set) { + set = new ResourceSet(); + this._sessionAssociations.set(sessionResource, set); + } + set.add(uri); + + return uri; } readFile(resource: URI): Promise { @@ -108,6 +159,16 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement } private lookupURI(uri: URI): Uint8Array | Promise { + const associated = this._associated.get(uri); + if (associated) { + if (associated instanceof Uint8Array) { + return associated; + } + const decoded = decodeBase64(associated.base64).buffer; + this._associated.set(uri, decoded); + return decoded; + } + const { result, index } = this.findMatchingInvocation(uri); const details = IChatToolInvocation.resultDetails(result); if (!isToolResultInputOutputDetails(details)) { diff --git a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts index 3af7e138aeb54..1d1d822cc305a 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts @@ -6,8 +6,11 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { dirname, extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ChatExternalPathConfirmationContribution } from '../../common/tools/builtinTools/chatExternalPathConfirmation.js'; import { ChatUrlFetchingConfirmationContribution } from '../../common/tools/builtinTools/chatUrlFetchingConfirmation.js'; @@ -25,6 +28,9 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb @IInstantiationService instantiationService: IInstantiationService, @ILanguageModelToolsConfirmationService confirmationService: ILanguageModelToolsConfirmationService, @IFileService fileService: IFileService, + @IStorageService storageService: IStorageService, + @IFileDialogService fileDialogService: IFileDialogService, + @ILabelService labelService: ILabelService, ) { super(); @@ -53,6 +59,7 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb } return undefined; }, + labelService, async (pathUri: URI) => { // Walk up from the path looking for a .git folder to find the repository root let dir = dirname(pathUri); @@ -71,8 +78,18 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb dir = parent; } return undefined; + }, + storageService, + async () => { + const result = await fileDialogService.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + }); + return result?.[0]; } ); + this._register(externalPathConfirmation); this._register(confirmationService.registerConfirmationContribution( 'copilot_readFile', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 863ef9534237f..de8d183379757 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -95,7 +95,7 @@ suite('sessionDateFromNow', () => { suite('AgentSessionsDataSource', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const ONE_DAY = 24 * 60 * 60 * 1000; const WEEK_THRESHOLD = 7 * ONE_DAY; // 7 days @@ -152,7 +152,9 @@ suite('AgentSessionsDataSource', () => { onDidChange: Event.None, groupResults: () => options.groupBy, exclude: options.exclude ?? (() => false), - getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false }) + getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false }), + isDefault: () => true, + reset: () => { }, }; } @@ -182,7 +184,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: undefined }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -202,7 +204,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -223,7 +225,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -244,7 +246,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -263,7 +265,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -281,7 +283,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -299,7 +301,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -319,7 +321,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -353,7 +355,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -382,7 +384,7 @@ suite('AgentSessionsDataSource', () => { test('empty sessions returns empty result', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel([]); const result = Array.from(dataSource.getChildren(mockModel)); @@ -399,7 +401,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -422,7 +424,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -456,7 +458,7 @@ suite('AgentSessionsDataSource', () => { excludeRead: true // Filtering to show only unread sessions }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -481,7 +483,7 @@ suite('AgentSessionsDataSource', () => { excludeRead: false // Not filtering to unread only }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index 8fdd981ea7186..1daf164ddcdd1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -19,12 +19,12 @@ import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IChatModeService } from '../../../../common/chatModes.js'; import { PromptHeaderAutocompletion } from '../../../../common/promptSyntax/languageProviders/promptHeaderAutocompletion.js'; -import { ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { getLanguageIdForPromptsType, PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; -import { getLanguageIdForPromptsType, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index 6f2a25d8ce3f6..f90f6d4570794 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -18,14 +18,14 @@ import { ChatAgentLocation, ChatConfiguration } from '../../../../common/constan import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { PromptHoverProvider } from '../../../../common/promptSyntax/languageProviders/promptHovers.js'; -import { IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { getLanguageIdForPromptsType, PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; -import { getLanguageIdForPromptsType, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; suite('PromptHoverProvider', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index e3cc440de6346..cd985b7ba74f8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -23,9 +23,9 @@ import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptValidator } from '../../../../common/promptSyntax/languageProviders/promptValidator.js'; -import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; +import { PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; -import { ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { MockPromptsService } from '../../../common/promptSyntax/service/mockPromptsService.js'; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 10045c7dce3d4..f6520b663505c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -58,9 +58,7 @@ suite('ChatQuestionCarouselPart', () => { createWidget(carousel); assert.ok(widget.domNode.classList.contains('chat-question-carousel-container')); - assert.ok(widget.domNode.querySelector('.chat-question-header-row')); assert.ok(widget.domNode.querySelector('.chat-question-carousel-content')); - assert.ok(widget.domNode.querySelector('.chat-question-carousel-nav')); }); test('renders question title', () => { @@ -125,7 +123,7 @@ suite('ChatQuestionCarouselPart', () => { assert.strictEqual(messageEl?.querySelector('.rendered-markdown'), null, 'plain string message should not use markdown renderer'); }); - test('renders progress indicator correctly', () => { + test('renders tab bar for multi-question carousel', () => { const carousel = createMockCarousel([ { id: 'q1', type: 'text', title: 'Question 1', message: 'Question 1' }, { id: 'q2', type: 'text', title: 'Question 2', message: 'Question 2' }, @@ -133,11 +131,11 @@ suite('ChatQuestionCarouselPart', () => { ]); createWidget(carousel); - // Progress is shown in the step indicator in the footer as "1/3" - const stepIndicator = widget.domNode.querySelector('.chat-question-step-indicator'); - assert.ok(stepIndicator); - assert.ok(stepIndicator?.textContent?.includes('1')); - assert.ok(stepIndicator?.textContent?.includes('3')); + const tabBar = widget.domNode.querySelector('.chat-question-tab-bar'); + assert.ok(tabBar, 'Tab bar should exist for multi-question carousel'); + const tabs = widget.domNode.querySelectorAll('.chat-question-tab'); + // 3 question tabs + 1 review tab + assert.strictEqual(tabs.length, 4, 'Should have 3 question tabs + 1 review tab'); }); }); @@ -271,42 +269,16 @@ suite('ChatQuestionCarouselPart', () => { }); suite('Navigation', () => { - test('previous button is disabled on first question', () => { - const carousel = createMockCarousel([ - { id: 'q1', type: 'text', title: 'Question 1' }, - { id: 'q2', type: 'text', title: 'Question 2' } - ]); - createWidget(carousel); - - // Use dedicated class selectors for stability - const prevButton = widget.domNode.querySelector('.chat-question-nav-prev') as HTMLButtonElement; - assert.ok(prevButton, 'Previous button should exist'); - assert.ok(prevButton.classList.contains('disabled') || prevButton.disabled, 'Previous button should be disabled on first question'); - }); - - test('next button stays as arrow and is disabled on last question', () => { - const carousel = createMockCarousel([ - { id: 'q1', type: 'text', title: 'Only Question' } - ]); - createWidget(carousel); - - // Use dedicated class selector for stability - const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLButtonElement; - assert.ok(nextButton, 'Next button should exist'); - assert.strictEqual(nextButton.getAttribute('aria-label'), 'Next', 'Next button should preserve Next aria-label on last question'); - assert.ok(nextButton.classList.contains('disabled') || nextButton.disabled, 'Next button should be disabled on last question'); - }); - - test('submit button is shown on last question', () => { + test('single question has no tab bar or submit button', () => { const carousel = createMockCarousel([ { id: 'q1', type: 'text', title: 'Only Question' } ]); createWidget(carousel); - const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; - assert.ok(submitButton, 'Submit button should exist'); - assert.strictEqual(submitButton.getAttribute('aria-label'), 'Submit'); - assert.notStrictEqual(submitButton.style.display, 'none', 'Submit button should be visible on last question'); + const tabBar = widget.domNode.querySelector('.chat-question-tab-bar'); + assert.strictEqual(tabBar, null, 'Tab bar should not exist for single question'); + const submitButton = widget.domNode.querySelector('.chat-question-submit-button'); + assert.strictEqual(submitButton, null, 'Submit button is only in review panel for multi-question'); }); }); @@ -401,13 +373,14 @@ suite('ChatQuestionCarouselPart', () => { suite('Accessibility', () => { test('navigation area has proper role and aria-label', () => { const carousel = createMockCarousel([ - { id: 'q1', type: 'text', title: 'Question 1' } + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } ]); createWidget(carousel); - const nav = widget.domNode.querySelector('.chat-question-carousel-nav'); - assert.strictEqual(nav?.getAttribute('role'), 'navigation'); - assert.ok(nav?.getAttribute('aria-label'), 'Navigation should have aria-label'); + const tabList = widget.domNode.querySelector('.chat-question-tabs'); + assert.strictEqual(tabList?.getAttribute('role'), 'tablist'); + assert.ok(tabList?.getAttribute('aria-label'), 'Tab list should have aria-label'); }); test('single select list has proper role and aria-label', () => { @@ -586,19 +559,20 @@ suite('ChatQuestionCarouselPart', () => { ], true); const firstWidget = createWidget(carousel); - const nextButton = firstWidget.domNode.querySelector('.chat-question-nav-next') as HTMLElement | null; - assert.ok(nextButton, 'next button should exist'); - nextButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + // Click the second tab to navigate + const tabs = firstWidget.domNode.querySelectorAll('.chat-question-tab'); + assert.ok(tabs.length >= 2, 'should have at least 2 tabs'); + (tabs[1] as HTMLElement).click(); + + // Verify navigation happened + assert.strictEqual(tabs[1].getAttribute('aria-selected'), 'true', 'second tab should be selected after click'); firstWidget.dispose(); firstWidget.domNode.remove(); const recreatedWidget = createWidget(carousel); - const stepIndicator = recreatedWidget.domNode.querySelector('.chat-question-step-indicator'); - assert.strictEqual(stepIndicator?.textContent, '2/2', 'should restore the current question index after navigation'); - - const title = recreatedWidget.domNode.querySelector('.chat-question-title'); - assert.ok(title?.textContent?.includes('Question 2'), 'should restore to the second question view'); + const recreatedTabs = recreatedWidget.domNode.querySelectorAll('.chat-question-tab'); + assert.strictEqual(recreatedTabs[1]?.getAttribute('aria-selected'), 'true', 'should restore to second tab after recreation'); }); test('retains draft answers and current question after widget recreation', () => { @@ -613,9 +587,9 @@ suite('ChatQuestionCarouselPart', () => { firstInput.value = 'first draft answer'; firstInput.dispatchEvent(new Event('input', { bubbles: true })); - const nextButton = firstWidget.domNode.querySelector('.chat-question-nav-next') as HTMLElement | null; - assert.ok(nextButton, 'next button should exist'); - nextButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + // Click the second tab to navigate + const tabs = firstWidget.domNode.querySelectorAll('.chat-question-tab'); + (tabs[1] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); const secondInput = firstWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null; assert.ok(secondInput, 'second question input should exist'); @@ -626,16 +600,16 @@ suite('ChatQuestionCarouselPart', () => { firstWidget.domNode.remove(); const recreatedWidget = createWidget(carousel); - const stepIndicator = recreatedWidget.domNode.querySelector('.chat-question-step-indicator'); - assert.strictEqual(stepIndicator?.textContent, '2/2', 'should restore the current question index'); + const recreatedTabs = recreatedWidget.domNode.querySelectorAll('.chat-question-tab'); + assert.strictEqual(recreatedTabs[1]?.getAttribute('aria-selected'), 'true', 'should restore the current question index'); const recreatedSecondInput = recreatedWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null; assert.ok(recreatedSecondInput, 'recreated second question input should exist'); assert.strictEqual(recreatedSecondInput.value, 'second draft answer', 'should restore draft input for current question'); - const prevButton = recreatedWidget.domNode.querySelector('.chat-question-nav-prev') as HTMLElement | null; - assert.ok(prevButton, 'previous button should exist'); - prevButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + // Click the first tab to go back + const recreatedTabsAgain = recreatedWidget.domNode.querySelectorAll('.chat-question-tab'); + (recreatedTabsAgain[0] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); const recreatedFirstInput = recreatedWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null; assert.ok(recreatedFirstInput, 'recreated first question input should exist'); @@ -655,7 +629,7 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(summary, 'Should show summary container after skip'); const summaryItem = summary?.querySelector('.chat-question-summary-item'); assert.ok(summaryItem, 'Should have summary item for the question'); - const summaryValue = summaryItem?.querySelector('.chat-question-summary-answer-title'); + const summaryValue = summaryItem?.querySelector('.chat-question-summary-answer'); assert.ok(summaryValue?.textContent?.includes('default answer'), 'Summary should show the default answer'); }); @@ -689,7 +663,7 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(widget.domNode.classList.contains('chat-question-carousel-used'), 'Should have used class'); const summary = widget.domNode.querySelector('.chat-question-carousel-summary'); assert.ok(summary, 'Should show summary container when isUsed is true'); - const summaryValue = summary?.querySelector('.chat-question-summary-answer-title'); + const summaryValue = summary?.querySelector('.chat-question-summary-answer'); assert.ok(summaryValue?.textContent?.includes('saved answer'), 'Summary should show saved answer from data'); }); diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index f7745a0b6fa9f..c8e5aa2740401 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -19,8 +19,9 @@ import { TestStorageService } from '../../../../test/common/workbenchTestService import { IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatMode, ChatModeService } from '../../common/chatModes.js'; import { ChatModeKind } from '../../common/constants.js'; -import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../common/promptSyntax/service/promptsService.js'; +import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; +import { Target } from '../../common/promptSyntax/promptTypes.js'; class TestChatAgentService implements Partial { _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 9adc29af139d9..f0bd3984aae26 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 @@ -479,6 +479,62 @@ suite('ChatService', () => { completeRequest.complete(); await response.data.responseCompletePromise; }); + + test('multiple steering messages are combined into a single request', async () => { + const requestStarted = new DeferredPromise(); + const completeRequest = new DeferredPromise(); + const invokedRequests: string[] = []; + + const slowAgent: IChatAgentImplementation = { + async invoke(request, progress, history, token) { + invokedRequests.push(request.message); + if (invokedRequests.length === 1) { + requestStarted.complete(); + await completeRequest.p; + } + return {}; + }, + }; + + testDisposables.add(chatAgentService.registerAgent('slowAgent', { ...getAgentData('slowAgent'), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation('slowAgent', slowAgent)); + + const testService = createChatService(); + const modelRef = testDisposables.add(startSessionModel(testService)); + const model = modelRef.object; + + // Start a request that will wait + const response = await testService.sendRequest(model.sessionResource, 'first request', { agentId: 'slowAgent' }); + ChatSendResult.assertSent(response); + + // Wait for the agent to start processing + await requestStarted.p; + + // Queue 3 steering messages while the first request is in progress + const steering1 = await testService.sendRequest(model.sessionResource, 'steering1', { agentId: 'slowAgent', queue: ChatRequestQueueKind.Steering }); + const steering2 = await testService.sendRequest(model.sessionResource, 'steering2', { agentId: 'slowAgent', queue: ChatRequestQueueKind.Steering }); + const steering3 = await testService.sendRequest(model.sessionResource, 'steering3', { agentId: 'slowAgent', queue: ChatRequestQueueKind.Steering }); + assert.ok(ChatSendResult.isQueued(steering1)); + assert.ok(ChatSendResult.isQueued(steering2)); + assert.ok(ChatSendResult.isQueued(steering3)); + + // Complete the first request - should trigger processing of combined steering requests + completeRequest.complete(); + await response.data.responseCompletePromise; + + // Wait for all deferred promises to resolve + await steering1.deferred; + await steering2.deferred; + await steering3.deferred; + + // Should have only invoked 2 requests: the initial and the combined steering + assert.strictEqual(invokedRequests.length, 2, 'Should have only 2 invocations (initial + combined steering)'); + // The combined message includes all steering texts joined with \n\n + assert.ok(invokedRequests[1].includes('steering1'), 'Combined message should include steering1'); + assert.ok(invokedRequests[1].includes('steering2'), 'Combined message should include steering2'); + assert.ok(invokedRequests[1].includes('steering3'), 'Combined message should include steering3'); + assert.ok(invokedRequests[1].includes('\n\n'), 'Combined message should use \\n\\n as separator'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 0136bfb939361..7ee70008f5278 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -13,7 +13,7 @@ import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from '../../commo import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionItemController, IChatSessionItem, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; -import { Target } from '../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../common/promptSyntax/promptTypes.js'; export class MockChatSessionsService implements IChatSessionsService { _serviceBrand: undefined; 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 76b2ac54b072c..2ba30ef5848aa 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { HookType } from '../../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../../common/promptSyntax/hookTypes.js'; import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName, extractHookCommandsFromItem } from '../../../common/promptSyntax/hookClaudeCompat.js'; import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts index ede5eeb5e52c8..7fd7eee9304d6 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { HookType } from '../../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../../common/promptSyntax/hookTypes.js'; import { parseCopilotHooks, parseHooksFromFile, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index d77772465b487..6130e821b9939 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -40,8 +40,8 @@ import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, HOOKS_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; -import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; -import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; +import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; import { mockFiles } from '../testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; @@ -50,7 +50,7 @@ import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../servic import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; import { IRemoteAgentService } from '../../../../../../services/remote/common/remoteAgentService.js'; import { ChatModeKind } from '../../../../common/constants.js'; -import { HookType } from '../../../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../../../common/promptSyntax/hookTypes.js'; import { IContextKeyService, IContextKeyChangeEvent } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js'; diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts index f82b6bbe55dbd..7db5b9b60319e 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; import { NullLogService } from '../../../../../../../platform/log/common/log.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { AskQuestionsTool, IAnswerResult, IQuestion, IQuestionAnswer } from '../../../../common/tools/builtinTools/askQuestionsTool.js'; +import { AskQuestionsTool, formatHeaderForDisplay, IAnswerResult, IQuestion, IQuestionAnswer } from '../../../../common/tools/builtinTools/askQuestionsTool.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; class TestableAskQuestionsTool extends AskQuestionsTool { @@ -149,4 +149,20 @@ suite('AskQuestionsTool - convertCarouselAnswers', () => { assert.deepStrictEqual(result.answers['Case'], { selected: [], freeText: 'yes', skipped: false }); }); + + test('formats headers for carousel tab title display', () => { + assert.deepStrictEqual([ + formatHeaderForDisplay('FocusArea'), + formatHeaderForDisplay('UserValue'), + formatHeaderForDisplay('RiskLevel'), + formatHeaderForDisplay('Already Spaced'), + formatHeaderForDisplay('snake_case_header'), + ], [ + 'Focus area', + 'User value', + 'Risk level', + 'Already spaced', + 'Snake case header', + ]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 2efa4f1af1637..79d99695eb588 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -16,7 +16,8 @@ import { IChatService } from '../../../../common/chatService/chatService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../../../../platform/product/common/productService.js'; -import { ICustomAgent, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts index e8d3da161cbd2..d3ce0affbf835 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts @@ -9,12 +9,15 @@ import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationCo import { IToolData } from '../../../common/tools/languageModelToolsService.js'; export class MockLanguageModelToolsConfirmationService implements ILanguageModelToolsConfirmationService { - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void { + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void { throw new Error('Method not implemented.'); } registerConfirmationContribution(toolName: string, contribution: ILanguageModelToolConfirmationContribution): IDisposable { throw new Error('Method not implemented.'); } + toolCanManageConfirmation(): boolean { + return false; + } resetToolAutoConfirmation(): void { } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts index 49bb014e5d979..4d2d9df2c4914 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts @@ -5,6 +5,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; @@ -23,28 +24,36 @@ export class BrowserMcpGatewayService implements IWorkbenchMcpGatewayService { constructor( @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + @ILogService private readonly _logService: ILogService, ) { } async createGateway(inRemote: boolean): Promise { + this._logService.debug(`[McpGateway][BrowserWorkbench] createGateway requested (inRemote=${inRemote})`); + // Browser can only create gateways in remote environment if (!inRemote) { + this._logService.info('[McpGateway][BrowserWorkbench] Cannot create local gateway in browser environment'); return undefined; } const connection = this._remoteAgentService.getConnection(); if (!connection) { - // Serverless web environment - no gateway available + this._logService.info('[McpGateway][BrowserWorkbench] No remote connection available (serverless web)'); return undefined; } + this._logService.info('[McpGateway][BrowserWorkbench] Creating remote gateway via remote server'); // Use the remote server's gateway service return connection.withChannel(McpGatewayChannelName, async channel => { const service = ProxyChannel.toService(channel); const info = await service.createGateway(undefined); + const address = URI.revive(info.address); + this._logService.info(`[McpGateway][BrowserWorkbench] Remote gateway created: ${address}`); return { - address: URI.revive(info.address), + address, dispose: () => { + this._logService.info(`[McpGateway][BrowserWorkbench] Disposing remote gateway: ${info.gatewayId}`); service.disposeGateway(info.gatewayId); } }; diff --git a/src/vs/workbench/contrib/mcp/browser/mcpGatewayToolBrokerContribution.ts b/src/vs/workbench/contrib/mcp/browser/mcpGatewayToolBrokerContribution.ts index 175bb839214a9..4c041d05f4770 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpGatewayToolBrokerContribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpGatewayToolBrokerContribution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { McpGatewayToolBrokerChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { IMcpService } from '../common/mcpTypes.js'; @@ -13,7 +14,8 @@ export class McpGatewayToolBrokerContribution implements IWorkbenchContribution constructor( @IRemoteAgentService remoteAgentService: IRemoteAgentService, @IMcpService mcpService: IMcpService, + @ILogService logService: ILogService, ) { - remoteAgentService.getConnection()?.registerChannel(McpGatewayToolBrokerChannelName, new McpGatewayToolBrokerChannel(mcpService)); + remoteAgentService.getConnection()?.registerChannel(McpGatewayToolBrokerChannelName, new McpGatewayToolBrokerChannel(mcpService, logService)); } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts index 88a1da8b4b6d8..d1b0a61d23a3f 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts @@ -8,6 +8,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates } from '../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../../../platform/mcp/common/modelContextProtocol.js'; import { McpServer } from './mcpServer.js'; @@ -32,8 +33,10 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh constructor( private readonly _mcpService: IMcpService, + private readonly _logService: ILogService, ) { super(); + this._logService.debug('[McpGateway][ToolBroker] Initialized'); let toolsInitialized = false; this._register(autorun(reader => { @@ -42,6 +45,7 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh } if (toolsInitialized) { + this._logService.debug('[McpGateway][ToolBroker] Tools changed, firing onDidChangeTools'); this._onDidChangeTools.fire(); } else { toolsInitialized = true; @@ -55,6 +59,7 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh } if (resourcesInitialized) { + this._logService.debug('[McpGateway][ToolBroker] Resources changed, firing onDidChangeResources'); this._onDidChangeResources.fire(); } else { resourcesInitialized = true; @@ -93,6 +98,8 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh } async call(_ctx: unknown, command: string, arg?: unknown, cancellationToken?: CancellationToken): Promise { + this._logService.debug(`[McpGateway][ToolBroker] IPC call: ${command}`); + switch (command) { case 'listTools': { const tools = await this._listTools(); @@ -124,11 +131,13 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh private async _listTools(): Promise { const mcpTools: MCP.Tool[] = []; const servers = this._mcpService.servers.get(); + this._logService.debug(`[McpGateway][ToolBroker] listTools: ${servers.length} server(s) known`); await Promise.all(servers.map(server => this._ensureServerReady(server))); for (const server of servers) { const cacheState = server.cacheState.get(); if (cacheState !== McpServerCacheState.Live && cacheState !== McpServerCacheState.Cached && cacheState !== McpServerCacheState.RefreshingFromCached) { + this._logService.debug(`[McpGateway][ToolBroker] Skipping server '${server.definition.id}' (cacheState=${cacheState})`); continue; } @@ -141,58 +150,74 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh } } + this._logService.debug(`[McpGateway][ToolBroker] listTools result: ${mcpTools.length} tool(s): [${mcpTools.map(t => t.name).join(', ')}]`); return mcpTools; } private async _callTool(name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { + this._logService.debug(`[McpGateway][ToolBroker] callTool '${name}' with args: ${JSON.stringify(args)}`); + for (const server of this._mcpService.servers.get()) { const tool = server.tools.get().find(t => t.definition.name === name && (t.visibility & McpToolVisibility.Model) ); if (tool) { + this._logService.debug(`[McpGateway][ToolBroker] Found tool '${name}' on server '${server.definition.id}' (index=${this._getServerIndex(server)})`); const result = await tool.call(args, undefined, token); + this._logService.debug(`[McpGateway][ToolBroker] Tool '${name}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); return { result, serverIndex: this._getServerIndex(server) }; } } + this._logService.warn(`[McpGateway][ToolBroker] Tool '${name}' not found on any server`); throw new Error(`Unknown tool: ${name}`); } private async _listResources(): Promise { const results: IGatewayServerResources[] = []; const servers = this._mcpService.servers.get(); + this._logService.debug(`[McpGateway][ToolBroker] listResources: ${servers.length} server(s) known`); + await Promise.all(servers.map(async server => { await this._ensureServerReady(server); const capabilities = server.capabilities.get(); if (!capabilities || !(capabilities & McpCapability.Resources)) { + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' has no resource capability, skipping`); return; } try { const resources = await McpServer.callOn(server, h => h.listResources()); + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' (index=${this._getServerIndex(server)}) listed ${resources.length} resource(s)`); results.push({ serverIndex: this._getServerIndex(server), resources }); - } catch { - // Server failed; skip + } catch (error) { + this._logService.warn(`[McpGateway][ToolBroker] Server '${server.definition.id}' failed to list resources`, error); } })); + this._logService.debug(`[McpGateway][ToolBroker] listResources result: ${results.length} server(s) with resources`); return results; } private async _readResource(serverIndex: number, uri: string, token: CancellationToken = CancellationToken.None): Promise { const server = this._getServerByIndex(serverIndex); if (!server) { + this._logService.warn(`[McpGateway][ToolBroker] readResource: unknown server index ${serverIndex}`); throw new Error(`Unknown server index: ${serverIndex}`); } - return McpServer.callOn(server, h => h.readResource({ uri }, token), token); + this._logService.debug(`[McpGateway][ToolBroker] readResource '${uri}' from server '${server.definition.id}' (index=${serverIndex})`); + const result = await McpServer.callOn(server, h => h.readResource({ uri }, token), token); + this._logService.debug(`[McpGateway][ToolBroker] readResource returned ${result.contents.length} content(s)`); + return result; } private async _listResourceTemplates(): Promise { const results: IGatewayServerResourceTemplates[] = []; const servers = this._mcpService.servers.get(); + this._logService.debug(`[McpGateway][ToolBroker] listResourceTemplates: ${servers.length} server(s) known`); await Promise.all(servers.map(async server => { await this._ensureServerReady(server); @@ -204,12 +229,14 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh try { const resourceTemplates = await McpServer.callOn(server, h => h.listResourceTemplates()); + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' (index=${this._getServerIndex(server)}) listed ${resourceTemplates.length} resource template(s)`); results.push({ serverIndex: this._getServerIndex(server), resourceTemplates }); - } catch { - // Server failed; skip + } catch (error) { + this._logService.warn(`[McpGateway][ToolBroker] Server '${server.definition.id}' failed to list resource templates`, error); } })); + this._logService.debug(`[McpGateway][ToolBroker] listResourceTemplates result: ${results.length} server(s) with templates`); return results; } @@ -219,12 +246,16 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh return true; } + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' not ready (cacheState=${cacheState}), starting...`); try { - return await startServerAndWaitForLiveTools(server, { + const ready = await startServerAndWaitForLiveTools(server, { promptType: 'all-untrusted', errorOnUserInteraction: true, }); - } catch { + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' ready=${ready}`); + return ready; + } catch (error) { + this._logService.warn(`[McpGateway][ToolBroker] Server '${server.definition.id}' failed to start`, error); return false; } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 143f856c0f479..3a1f490eb07fc 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -904,6 +904,13 @@ export class McpServer extends Disposable implements IMcpServer { tool.name = tool.name.replace(toolInvalidCharRe, '_'); } + // Per MCP spec, properties is optional. But JSON Schema Draft 7 requires + // it for object types. Normalize the schema to include an empty properties + // object if not present. https://github.com/microsoft/vscode/issues/251723 + if (tool.inputSchema && !tool.inputSchema.properties) { + tool.inputSchema = { ...tool.inputSchema, properties: {} }; + } + type JsonDiagnostic = { message: string; range: { line: number; character: number }[] }; let diagnostics: JsonDiagnostic[] = []; diff --git a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts index 2af2c0b4adf0d..71d0dcdc64b08 100644 --- a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts @@ -6,6 +6,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; @@ -24,6 +25,7 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { constructor( @IMainProcessService mainProcessService: IMainProcessService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + @ILogService private readonly _logService: ILogService, ) { this._localPlatformService = ProxyChannel.toService( mainProcessService.getChannel(McpGatewayChannelName) @@ -31,6 +33,7 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { } async createGateway(inRemote: boolean): Promise { + this._logService.debug(`[McpGateway][Workbench] createGateway requested (inRemote=${inRemote})`); if (inRemote) { return this._createRemoteGateway(); } else { @@ -39,11 +42,15 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { } private async _createLocalGateway(): Promise { + this._logService.info('[McpGateway][Workbench] Creating local gateway via main process'); const info = await this._localPlatformService.createGateway(undefined); + const address = URI.revive(info.address); + this._logService.info(`[McpGateway][Workbench] Local gateway created: ${address}`); return { - address: URI.revive(info.address), + address, dispose: () => { + this._logService.info(`[McpGateway][Workbench] Disposing local gateway: ${info.gatewayId}`); this._localPlatformService.disposeGateway(info.gatewayId); } }; @@ -52,17 +59,21 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { private async _createRemoteGateway(): Promise { const connection = this._remoteAgentService.getConnection(); if (!connection) { - // No remote connection - cannot create remote gateway + this._logService.info('[McpGateway][Workbench] No remote connection available for remote gateway'); return undefined; } + this._logService.info('[McpGateway][Workbench] Creating remote gateway via remote server'); return connection.withChannel(McpGatewayChannelName, async channel => { const service = ProxyChannel.toService(channel); const info = await service.createGateway(undefined); + const address = URI.revive(info.address); + this._logService.info(`[McpGateway][Workbench] Remote gateway created: ${address}`); return { - address: URI.revive(info.address), + address, dispose: () => { + this._logService.info(`[McpGateway][Workbench] Disposing remote gateway: ${info.gatewayId}`); service.disposeGateway(info.gatewayId); } }; diff --git a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayToolBrokerContribution.ts b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayToolBrokerContribution.ts index af994c82fe31a..778d53de5f644 100644 --- a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayToolBrokerContribution.ts +++ b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayToolBrokerContribution.ts @@ -5,6 +5,7 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { McpGatewayToolBrokerChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IMcpService } from '../common/mcpTypes.js'; import { McpGatewayToolBrokerChannel } from '../common/mcpGatewayToolBrokerChannel.js'; @@ -13,7 +14,8 @@ export class McpGatewayToolBrokerContribution implements IWorkbenchContribution constructor( @IMainProcessService mainProcessService: IMainProcessService, @IMcpService mcpService: IMcpService, + @ILogService logService: ILogService, ) { - mainProcessService.registerChannel(McpGatewayToolBrokerChannelName, new McpGatewayToolBrokerChannel(mcpService)); + mainProcessService.registerChannel(McpGatewayToolBrokerChannelName, new McpGatewayToolBrokerChannel(mcpService, logService)); } } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts index 53124c8acc37f..f102628f86ac6 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IGatewayCallToolResult } from '../../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../common/modelContextProtocol.js'; import { McpGatewayToolBrokerChannel } from '../../common/mcpGatewayToolBrokerChannel.js'; @@ -18,7 +19,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('lists model-visible tools with namespaced identities', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const serverA = createServer('collectionA', 'serverA', [ createTool('mcp_serverA_echo', async () => ({ content: [{ type: 'text', text: 'A' }] })), @@ -43,7 +44,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('routes tool calls by namespaced identity', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const invoked: string[] = []; const serverA = createServer('collectionA', 'serverA', [ @@ -79,7 +80,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('emits onDidChangeTools when tool lists change', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const server = createServer('collectionA', 'serverA', [ createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] })), ]); @@ -104,7 +105,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('does not start server when cache state is live', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const server = createServer( 'collectionA', @@ -122,7 +123,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('starts server when cache state is unknown', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const server = createServer( 'collectionA', diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index eec28002b77d1..85323b85156cc 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -10,7 +10,7 @@ declare module 'vscode' { /** * The type of hook to execute. */ - export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop'; + export type ChatHookType = 'SessionStart' | 'SessionEnd' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop' | 'ErrorOccurred'; /** * A resolved hook command ready for execution. diff --git a/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts b/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts index ab481a3d9d88f..7d3b546e9c2f5 100644 --- a/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts +++ b/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts @@ -42,7 +42,7 @@ declare module 'vscode' { * Registers a language model tool along with its definition. Unlike {@link lm.registerTool}, * this does not require the tool to be present first in the extension's `package.json` contributions. * - * Multiple tools may be registered with the the same name using the API. In any given context, + * Multiple tools may be registered with the same name using the API. In any given context, * the most specific tool (based on the {@link LanguageModelToolDefinition.models}) will be used. * * @param definition The definition of the tool to register. diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index f3a0ceb0bfffd..6e7624dc0da22 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -702,9 +702,9 @@ } }, "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", "engines": { "node": ">=16.9.0"