diff --git a/.npmrc b/.npmrc index e2305b402ea..b07eade64d5 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" target="39.6.0" -ms_build_id="13312042" +ms_build_id="13330601" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/.vscode/settings.json b/.vscode/settings.json index 0a58b8a5bf8..7307607bef7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -70,7 +70,7 @@ "test/smoke/out/**": true, "test/automation/out/**": true, "test/integration/browser/out/**": true, - "src/vs/sessions/**": true + // "src/vs/sessions/**": true }, // --- Search --- "search.exclude": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 14e637aaf2d..3e7b3c6765f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -73,10 +73,11 @@ "owner": "typescript", "applyTo": "closedDocuments", "fileLocation": [ - "absolute" + "relative", + "${workspaceFolder}" ], "pattern": { - "regexp": "Error: ([^(]+)\\((\\d+|\\d+,\\d+|\\d+,\\d+,\\d+,\\d+)\\): (.*)$", + "regexp": "\\] ([^(]+)\\((\\d+,\\d+)\\): (.*)$", "file": 1, "location": 2, "message": 3 diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index cae158ea590..74d58f38406 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'events'; EventEmitter.defaultMaxListeners = 100; import es from 'event-stream'; +import fancyLog from 'fancy-log'; import glob from 'glob'; import gulp from 'gulp'; import filter from 'gulp-filter'; @@ -27,6 +28,25 @@ import watcher from './lib/watch/index.ts'; const root = path.dirname(import.meta.dirname); const commit = getVersion(root); +// Tracks active extension compilations to emit aggregate +// "Starting compilation" / "Finished compilation" messages +// that the problem matcher in tasks.json relies on. +let activeExtensionCompilations = 0; + +function onExtensionCompilationStart(): void { + if (activeExtensionCompilations === 0) { + fancyLog('Starting compilation'); + } + activeExtensionCompilations++; +} + +function onExtensionCompilationEnd(): void { + activeExtensionCompilations--; + if (activeExtensionCompilations === 0) { + fancyLog('Finished compilation'); + } +} + // To save 250ms for each gulp startup, we are caching the result here // const compilations = glob.sync('**/tsconfig.json', { // cwd: extensionsPath, @@ -175,7 +195,25 @@ const tasks = compilations.map(function (tsconfigFile) { const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const watchInput = watcher(src, { ...srcOpts, ...{ readDelay: 200 } }); const watchNonTs = watchInput.pipe(filter(['**', '!**/*.ts'], { dot: true })).pipe(gulp.dest(out)); - const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, { taskName: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); + const tsgoStream = watchInput.pipe(util.debounce(() => { + onExtensionCompilationStart(); + const stream = createTsgoStream(absolutePath, { taskName: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); + // Wrap in a result stream that always emits 'end' (even on + // error) so the debounce resets to idle and can process future + // file changes. Errors from tsgo (e.g. type errors causing a + // non-zero exit code) are already reported by spawnTsgo's + // runReporter, so swallowing the stream error is safe. + const result = es.through(); + stream.on('end', () => { + onExtensionCompilationEnd(); + result.emit('end'); + }); + stream.on('error', () => { + onExtensionCompilationEnd(); + result.emit('end'); + }); + return result; + }, 200)); const watchStream = es.merge(nonts.pipe(gulp.dest(out)), watchNonTs, tsgoStream); return watchStream; diff --git a/build/lib/compilation.ts b/build/lib/compilation.ts index 0d6209e66c8..047e83eb7ff 100644 --- a/build/lib/compilation.ts +++ b/build/lib/compilation.ts @@ -368,8 +368,18 @@ function generateExtensionPointNames() { }, function () { collectedNames.sort(); const content = JSON.stringify(collectedNames, undefined, '\t') + '\n'; + const filePath = 'vs/workbench/services/extensions/common/extensionPoints.json'; + try { + const existing = fs.readFileSync(path.join('src', filePath), 'utf-8'); + if (existing.replace(/\r\n/g, '\n') === content) { + this.emit('end'); + return; + } + } catch { + // File doesn't exist yet, emit it + } this.emit('data', new File({ - path: 'vs/workbench/services/extensions/common/extensionPoints.json', + path: filePath, contents: Buffer.from(content) })); this.emit('end'); diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index fac7946fc98..6c1e65b0f57 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -441,7 +441,7 @@ interface IExtensionManifest { /** * Loosely based on `getExtensionKind` from `src/vs/workbench/services/extensions/common/extensionManifestPropertiesService.ts` */ -function isWebExtension(manifest: IExtensionManifest): boolean { +export function isWebExtension(manifest: IExtensionManifest): boolean { if (Boolean(manifest.browser)) { return true; } @@ -578,11 +578,11 @@ export function packageMarketplaceExtensionsStream(forWeb: boolean): Stream { } export interface IScannedBuiltinExtension { - extensionPath: string; - packageJSON: any; - packageNLS?: any; - readmePath?: string; - changelogPath?: string; + readonly extensionPath: string; + readonly packageJSON: unknown; + readonly packageNLS: unknown | undefined; + readonly readmePath: string | undefined; + readonly changelogPath: string | undefined; } export function scanBuiltinExtensions(extensionsRoot: string, exclude: string[] = []): IScannedBuiltinExtension[] { diff --git a/build/lib/extractExtensionPoints.ts b/build/lib/extractExtensionPoints.ts index 1c442564026..97dbda06452 100644 --- a/build/lib/extractExtensionPoints.ts +++ b/build/lib/extractExtensionPoints.ts @@ -211,10 +211,23 @@ function scanDirectory(dir: string): string[] { return names; } +function normalize(s: string): string { + return s.replace(/\r\n/g, '\n'); +} + function main(): void { const names = scanDirectory(path.join(srcDir, 'vs', 'workbench')); names.sort(); const output = JSON.stringify(names, undefined, '\t') + '\n'; + try { + const existing = fs.readFileSync(outputPath, 'utf-8'); + if (normalize(existing) === normalize(output)) { + console.log(`No changes to ${path.relative(rootDir, outputPath)}`); + return; + } + } catch { + // File doesn't exist yet, write it + } fs.writeFileSync(outputPath, output, 'utf-8'); console.log(`Wrote ${names.length} extension points to ${path.relative(rootDir, outputPath)}`); } diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts index 7f00c1816b5..36d925d4313 100644 --- a/build/lib/tsgo.ts +++ b/build/lib/tsgo.ts @@ -12,13 +12,17 @@ import * as path from 'path'; const root = path.dirname(path.dirname(import.meta.dirname)); const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; +const timestampRegex = /^\[\d{2}:\d{2}:\d{2}\]\s*/; export function spawnTsgo(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): Promise { - function runReporter(stdError: string) { - const matches = (stdError || '').match(/error \w+: (.+)?/g); - fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${matches ? matches.length : 0} errors.`); - for (const match of matches || []) { - fancyLog.error(match); + function runReporter(output: string) { + const lines = (output || '').split('\n'); + const errorLines = lines.filter(line => /error \w+:/.test(line)); + if (errorLines.length > 0) { + fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${errorLines.length} errors.`); + for (const line of errorLines) { + fancyLog(line); + } } } @@ -34,21 +38,28 @@ export function spawnTsgo(projectPath: string, config: { taskName: string; noEmi shell: true }); - const handleData = (data: Buffer) => { - const lines = data.toString() - .split(/\r?\n/) - .map(line => line.replace(ansiRegex, '').trim()) - .filter(line => line.length > 0) - .filter(line => !/Starting compilation|File change detected|Compilation complete/i.test(line)); + let stdoutData = ''; + let stderrData = ''; - runReporter(lines.join('\n')); - }; - - child.stdout?.on('data', handleData); - child.stderr?.on('data', handleData); + child.stdout?.on('data', (data: Buffer) => { + stdoutData += data.toString(); + }); + child.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString(); + }); return new Promise((resolve, reject) => { child.on('exit', code => { + const allOutput = stdoutData + '\n' + stderrData; + const lines = allOutput + .split(/\r?\n/) + .map(line => line.replace(ansiRegex, '').trim()) + .map(line => line.replace(timestampRegex, '')) + .filter(line => line.length > 0) + .filter(line => !/Starting compilation|File change detected|Compilation complete/i.test(line)); + + runReporter(lines.join('\n')); + if (code === 0) { Promise.resolve(onComplete?.()).then(() => resolve(), reject); } else { diff --git a/build/next/index.ts b/build/next/index.ts index f1c0784ef28..e8f5b1f72d1 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -15,6 +15,7 @@ import { getVersion } from '../lib/getVersion.ts'; import product from '../../product.json' with { type: 'json' }; import packageJson from '../../package.json' with { type: 'json' }; import { useEsbuildTranspile } from '../buildConfig.ts'; +import { isWebExtension, type IScannedBuiltinExtension } from '../lib/extensions.ts'; const globAsync = promisify(glob); @@ -378,33 +379,43 @@ async function cleanDir(dir: string): Promise { * Scan for built-in extensions in the given directory. * Returns an array of extension entries for the builtinExtensionsScannerService. */ -function scanBuiltinExtensions(extensionsRoot: string): Array<{ extensionPath: string; packageJSON: unknown }> { - const result: Array<{ extensionPath: string; packageJSON: unknown }> = []; +function scanBuiltinExtensions(extensionsRoot: string): Array { + const scannedExtensions: Array = []; const extensionsPath = path.join(REPO_ROOT, extensionsRoot); if (!fs.existsSync(extensionsPath)) { - return result; + return scannedExtensions; } - for (const entry of fs.readdirSync(extensionsPath, { withFileTypes: true })) { - if (!entry.isDirectory()) { + for (const extensionFolder of fs.readdirSync(extensionsPath)) { + const packageJSONPath = path.join(extensionsPath, extensionFolder, 'package.json'); + if (!fs.existsSync(packageJSONPath)) { continue; } - const packageJsonPath = path.join(extensionsPath, entry.name, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - try { - const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - result.push({ - extensionPath: entry.name, - packageJSON - }); - } catch (e) { - // Skip invalid extensions + try { + const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath, 'utf8')); + if (!isWebExtension(packageJSON)) { + continue; } + const children = fs.readdirSync(path.join(extensionsPath, extensionFolder)); + const packageNLSPath = children.filter(child => child === 'package.nls.json')[0]; + const packageNLS = packageNLSPath ? JSON.parse(fs.readFileSync(path.join(extensionsPath, extensionFolder, packageNLSPath), 'utf8')) : undefined; + const readme = children.filter(child => /^readme(\.txt|\.md|)$/i.test(child))[0]; + const changelog = children.filter(child => /^changelog(\.txt|\.md|)$/i.test(child))[0]; + + scannedExtensions.push({ + extensionPath: extensionFolder, + packageJSON, + packageNLS, + readmePath: readme ? path.join(extensionFolder, readme) : undefined, + changelogPath: changelog ? path.join(extensionFolder, changelog) : undefined, + }); + } catch (e) { + // Skip invalid extensions } } - return result; + return scannedExtensions; } /** diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index a4ef0b2fada..6e28e550f46 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -1069,9 +1069,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/package-lock.json b/build/package-lock.json index ffcaa1455e7..36b85902e22 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -307,17 +307,17 @@ } }, "node_modules/@azure/core-xml": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.4.4.tgz", - "integrity": "sha512-J4FYAqakGXcbfeZjwjMzjNcpcH4E+JtEBv+xcV1yL0Ydn/6wbQfeFKTCHh9wttAi0lmajHw7yBbHPRG+YHckZQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.0.tgz", + "integrity": "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==", "dev": true, "license": "MIT", "dependencies": { - "fast-xml-parser": "^4.4.1", - "tslib": "^2.6.2" + "fast-xml-parser": "^5.0.7", + "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/cosmos": { @@ -5358,23 +5358,19 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", - "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" - }, - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" } ], "license": "MIT", "dependencies": { - "strnum": "^1.0.5" + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -8933,10 +8929,16 @@ } }, "node_modules/strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT" }, "node_modules/structured-source": { @@ -9433,10 +9435,11 @@ } }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 4f25b3268fc..17cb3439ddd 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -7,8 +7,8 @@ "foreground": "#bfbfbf", "disabledForeground": "#666666", "errorForeground": "#f48771", - "descriptionForeground": "#888888", - "icon.foreground": "#888888", + "descriptionForeground": "#8C8C8C", + "icon.foreground": "#8C8C8C", "focusBorder": "#3994BCB3", "textBlockQuote.background": "#242526", "textBlockQuote.border": "#2A2B2CFF", @@ -16,7 +16,7 @@ "textLink.foreground": "#48A0C7", "textLink.activeForeground": "#53A5CA", "textPreformat.background": "#262626", - "textPreformat.foreground": "#888888", + "textPreformat.foreground": "#8C8C8C", "textSeparator.foreground": "#2a2a2aFF", "button.background": "#3994BCF2", "button.foreground": "#FFFFFF", @@ -69,7 +69,7 @@ "list.warningForeground": "#e5ba7d", "activityBar.background": "#191A1B", "activityBar.foreground": "#bfbfbf", - "activityBar.inactiveForeground": "#888888", + "activityBar.inactiveForeground": "#8C8C8C", "activityBar.border": "#2A2B2CFF", "activityBar.activeBorder": "#bfbfbf", "activityBar.activeFocusBorder": "#3994BCB3", @@ -86,7 +86,7 @@ "titleBar.activeBackground": "#191A1B", "titleBar.activeForeground": "#bfbfbf", "titleBar.inactiveBackground": "#191A1B", - "titleBar.inactiveForeground": "#888888", + "titleBar.inactiveForeground": "#8C8C8C", "titleBar.border": "#2A2B2CFF", "menubar.selectionBackground": "#242526", "menubar.selectionForeground": "#bfbfbf", @@ -120,11 +120,11 @@ "editor.lineHighlightBackground": "#242526", "editor.rangeHighlightBackground": "#242526", "editorLink.activeForeground": "#3a94bc", - "editorWhitespace.foreground": "#8888884D", + "editorWhitespace.foreground": "#8C8C8C4D", "editorIndentGuide.background": "#8384854D", "editorIndentGuide.activeBackground": "#838485", "editorRuler.foreground": "#848484", - "editorCodeLens.foreground": "#888888", + "editorCodeLens.foreground": "#8C8C8C", "editorBracketMatch.background": "#3994BC55", "editorBracketMatch.border": "#2A2B2CFF", "editorWidget.background": "#202122", @@ -143,12 +143,12 @@ "peekViewEditor.matchHighlightBackground": "#3994BC33", "peekViewResult.background": "#191A1B", "peekViewResult.fileForeground": "#bfbfbf", - "peekViewResult.lineForeground": "#888888", + "peekViewResult.lineForeground": "#8C8C8C", "peekViewResult.matchHighlightBackground": "#3994BC33", "peekViewResult.selectionBackground": "#3994BC26", "peekViewResult.selectionForeground": "#bfbfbf", "peekViewTitle.background": "#242526", - "peekViewTitleDescription.foreground": "#888888", + "peekViewTitleDescription.foreground": "#8C8C8C", "peekViewTitleLabel.foreground": "#bfbfbf", "editorGutter.background": "#121314", "editorGutter.addedBackground": "#72C892", @@ -166,15 +166,15 @@ "panel.border": "#2A2B2CFF", "panelTitle.activeBorder": "#3994BC", "panelTitle.activeForeground": "#bfbfbf", - "panelTitle.inactiveForeground": "#888888", + "panelTitle.inactiveForeground": "#8C8C8C", "statusBar.background": "#191A1B", - "statusBar.foreground": "#888888", + "statusBar.foreground": "#8C8C8C", "statusBar.border": "#2A2B2CFF", "statusBar.focusBorder": "#3994BCB3", "statusBar.debuggingBackground": "#3994BC", "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#191A1B", - "statusBar.noFolderForeground": "#888888", + "statusBar.noFolderForeground": "#8C8C8C", "statusBarItem.activeBackground": "#4B4C4D", "statusBarItem.hoverBackground": "#262728", "statusBarItem.focusBorder": "#3994BCB3", @@ -184,7 +184,7 @@ "tab.activeBackground": "#121314", "tab.activeForeground": "#bfbfbf", "tab.inactiveBackground": "#191A1B", - "tab.inactiveForeground": "#888888", + "tab.inactiveForeground": "#8C8C8C", "tab.border": "#2A2B2CFF", "tab.lastPinnedBorder": "#2A2B2CFF", "tab.activeBorder": "#121314", @@ -192,12 +192,12 @@ "tab.hoverBackground": "#262728", "tab.hoverForeground": "#bfbfbf", "tab.unfocusedActiveBackground": "#121314", - "tab.unfocusedActiveForeground": "#888888", + "tab.unfocusedActiveForeground": "#8C8C8C", "tab.unfocusedInactiveBackground": "#191A1B", "tab.unfocusedInactiveForeground": "#444444", "editorGroupHeader.tabsBackground": "#191A1B", "editorGroupHeader.tabsBorder": "#2A2B2CFF", - "breadcrumb.foreground": "#888888", + "breadcrumb.foreground": "#8C8C8C", "breadcrumb.background": "#121314", "breadcrumb.focusForeground": "#bfbfbf", "breadcrumb.activeSelectionForeground": "#bfbfbf", @@ -612,7 +612,7 @@ "markup.fenced_code" ], "settings": { - "foreground": "#888888" + "foreground": "#8C8C8C" } }, { diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 95d65795f8b..b86ef612b20 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -7,24 +7,25 @@ "foreground": "#202020", "disabledForeground": "#BBBBBB", "errorForeground": "#ad0707", - "descriptionForeground": "#555555", - "icon.foreground": "#666666", + "descriptionForeground": "#606060", + "icon.foreground": "#606060", "focusBorder": "#0069CCFF", - "textBlockQuote.background": "#EDEDED", + "textBlockQuote.background": "#EAEAEA", "textBlockQuote.border": "#F2F3F4FF", - "textCodeBlock.background": "#EDEDED", + "textCodeBlock.background": "#EAEAEA", "textLink.foreground": "#0069CC", "textLink.activeForeground": "#0069CC", - "textPreformat.foreground": "#666666", + "textPreformat.foreground": "#606060", + "textPreformat.background": "#ECECEC", "textSeparator.foreground": "#EEEEEEFF", "button.background": "#0069CC", "button.foreground": "#FFFFFF", "button.hoverBackground": "#0063C1", "button.border": "#EEEEF1", - "button.secondaryBackground": "#EDEDED", + "button.secondaryBackground": "#EAEAEA", "button.secondaryForeground": "#202020", - "button.secondaryHoverBackground": "#EEEEEE", - "checkbox.background": "#EDEDED", + "button.secondaryHoverBackground": "#F2F3F4", + "checkbox.background": "#EAEAEA", "checkbox.border": "#D8D8D8", "checkbox.foreground": "#202020", "dropdown.background": "#FFFFFF", @@ -61,11 +62,11 @@ "badge.background": "#0069CC", "badge.foreground": "#FFFFFF", "progressBar.background": "#0069CC", - "list.activeSelectionBackground": "#0069CC44", + "list.activeSelectionBackground": "#0069CC1A", "list.activeSelectionForeground": "#202020", - "list.inactiveSelectionBackground": "#E0E0E0", + "list.inactiveSelectionBackground": "#DADADA99", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#EEEEEE", + "list.hoverBackground": "#DADADA4f", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", "list.focusBackground": "#0069CC1A", @@ -77,7 +78,7 @@ "list.warningForeground": "#667309", "activityBar.background": "#FAFAFD", "activityBar.foreground": "#202020", - "activityBar.inactiveForeground": "#666666", + "activityBar.inactiveForeground": "#606060", "activityBar.border": "#F2F3F4FF", "activityBar.activeBorder": "#000000", "activityBar.activeFocusBorder": "#0069CCFF", @@ -94,9 +95,9 @@ "titleBar.activeBackground": "#FAFAFD", "titleBar.activeForeground": "#424242", "titleBar.inactiveBackground": "#FAFAFD", - "titleBar.inactiveForeground": "#666666", + "titleBar.inactiveForeground": "#606060", "titleBar.border": "#F2F3F4FF", - "menubar.selectionBackground": "#EDEDED", + "menubar.selectionBackground": "#EAEAEA", "menubar.selectionForeground": "#202020", "menu.background": "#FAFAFD", "menu.foreground": "#202020", @@ -111,7 +112,7 @@ "commandCenter.border": "#D8D8D8", "editor.background": "#FFFFFF", "editor.foreground": "#202020", - "editorLineNumber.foreground": "#666666", + "editorLineNumber.foreground": "#606060", "editorLineNumber.activeForeground": "#202020", "editorCursor.foreground": "#202020", "editor.selectionBackground": "#0069CC1A", @@ -121,16 +122,16 @@ "editor.wordHighlightStrongBackground": "#0069CC26", "editor.findMatchBackground": "#0069CC40", "editor.findMatchHighlightBackground": "#0069CC1A", - "editor.findRangeHighlightBackground": "#EDEDED", - "editor.hoverHighlightBackground": "#EDEDED", - "editor.lineHighlightBackground": "#EDEDED40", - "editor.rangeHighlightBackground": "#EDEDED", + "editor.findRangeHighlightBackground": "#EAEAEA", + "editor.hoverHighlightBackground": "#EAEAEA", + "editor.lineHighlightBackground": "#EAEAEA40", + "editor.rangeHighlightBackground": "#EAEAEA", "editorLink.activeForeground": "#0069CC", - "editorWhitespace.foreground": "#66666640", + "editorWhitespace.foreground": "#60606040", "editorIndentGuide.background": "#F7F7F740", "editorIndentGuide.activeBackground": "#EEEEEE", "editorRuler.foreground": "#F7F7F7", - "editorCodeLens.foreground": "#666666", + "editorCodeLens.foreground": "#606060", "editorBracketMatch.background": "#0069CC40", "editorBracketMatch.border": "#F2F3F4FF", "editorWidget.background": "#F0F0F3", @@ -148,12 +149,12 @@ "peekViewEditor.matchHighlightBackground": "#0069CC33", "peekViewResult.background": "#F0F0F3", "peekViewResult.fileForeground": "#202020", - "peekViewResult.lineForeground": "#666666", + "peekViewResult.lineForeground": "#606060", "peekViewResult.matchHighlightBackground": "#0069CC33", "peekViewResult.selectionBackground": "#0069CC26", "peekViewResult.selectionForeground": "#202020", "peekViewTitle.background": "#F0F0F3", - "peekViewTitleDescription.foreground": "#666666", + "peekViewTitleDescription.foreground": "#606060", "peekViewTitleLabel.foreground": "#202020", "editorGutter.addedBackground": "#587c0c", "editorGutter.deletedBackground": "#ad0707", @@ -171,17 +172,17 @@ "panel.border": "#F2F3F4FF", "panelTitle.activeBorder": "#000000", "panelTitle.activeForeground": "#202020", - "panelTitle.inactiveForeground": "#666666", + "panelTitle.inactiveForeground": "#606060", "statusBar.background": "#FAFAFD", - "statusBar.foreground": "#666666", + "statusBar.foreground": "#606060", "statusBar.border": "#F2F3F4FF", "statusBar.focusBorder": "#0069CCFF", "statusBar.debuggingBackground": "#0069CC", "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#F0F0F3", - "statusBar.noFolderForeground": "#666666", + "statusBar.noFolderForeground": "#606060", "statusBarItem.activeBackground": "#EEEEEE", - "statusBarItem.hoverBackground": "#EEEEEE", + "statusBarItem.hoverBackground": "#DADADA4f", "statusBarItem.focusBorder": "#0069CCFF", "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", @@ -190,30 +191,30 @@ "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", - "tab.inactiveForeground": "#666666", + "tab.inactiveForeground": "#606060", "tab.border": "#F2F3F4FF", "tab.lastPinnedBorder": "#F2F3F4FF", "tab.activeBorder": "#FAFAFD", "tab.activeBorderTop": "#000000", - "tab.hoverBackground": "#EEEEEE", + "tab.hoverBackground": "#DADADA4f", "tab.hoverForeground": "#202020", "tab.unfocusedActiveBackground": "#FAFAFD", - "tab.unfocusedActiveForeground": "#666666", + "tab.unfocusedActiveForeground": "#606060", "tab.unfocusedInactiveBackground": "#FAFAFD", "tab.unfocusedInactiveForeground": "#BBBBBB", "editorGroupHeader.tabsBackground": "#FAFAFD", "editorGroupHeader.tabsBorder": "#F2F3F4FF", - "breadcrumb.foreground": "#666666", + "breadcrumb.foreground": "#606060", "breadcrumb.background": "#FFFFFF", "breadcrumb.focusForeground": "#202020", "breadcrumb.activeSelectionForeground": "#202020", "breadcrumbPicker.background": "#F0F0F3", "notificationCenter.border": "#F2F3F4FF", "notificationCenterHeader.foreground": "#202020", - "notificationCenterHeader.background": "#F0F0F3", + "notificationCenterHeader.background": "#FAFAFD", "notificationToast.border": "#F2F3F4FF", "notifications.foreground": "#202020", - "notifications.background": "#F0F0F3", + "notifications.background": "#FAFAFD", "notifications.border": "#F2F3F4FF", "notificationLink.foreground": "#0069CC", "notificationsWarningIcon.foreground": "#B69500", @@ -258,6 +259,7 @@ "quickInputTitle.background": "#F0F0F3", "chat.requestBubbleBackground": "#EEF4FB", "chat.requestBubbleHoverBackground": "#E6EDFA", + "chat.thinkingShimmer": "#999999", "editorCommentsWidget.rangeBackground": "#EEF4FB", "editorCommentsWidget.rangeActiveBackground": "#E6EDFA", "charts.foreground": "#202020", @@ -617,7 +619,7 @@ "markup.fenced_code" ], "settings": { - "foreground": "#666666" + "foreground": "#606060" } }, { diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 6dfbb2888a4..ec6b840db47 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -61,11 +61,16 @@ } /* Ensure iframe containers in pane-body render above sidebar z-index */ -.monaco-workbench > div[data-keybinding-context], .monaco-workbench > div[data-keybinding-context] { z-index: 50 !important; } +/* Ensure in-editor pane iframes render below sidebar z-index */ +.monaco-workbench > div[data-parent-flow-to-element-id] { + z-index: 0 !important; +} + + /* Ensure webview containers render above sidebar z-index */ .monaco-workbench .part.sidebar .webview, .monaco-workbench .part.sidebar .webview-container, @@ -200,7 +205,7 @@ background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 95%, black) !important; } -.quick-input-list .quick-input-list-entry .quick-input-list-separator { +.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-separator { height: 16px; margin-top: 2px; display: flex; @@ -208,14 +213,18 @@ font-size: 11px; padding: 0 4px 1px 4px; border-radius: var(--vscode-cornerRadius-small) !important; - background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; + background: color-mix(in srgb, var(--vscode-badge-background) 70%, transparent) !important; color: var(--vscode-badge-foreground) !important; margin-right: 8px; } -.monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, -.monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, -.monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { +.monaco-workbench.vs-dark .quick-input-list .quick-input-list-entry .quick-input-list-separator { + background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; +} + +.monaco-workbench .monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, +.monaco-workbench .monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, +.monaco-workbench .monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { background: transparent !important; color: inherit !important; padding: 0; @@ -437,6 +446,15 @@ box-shadow: var(--shadow-sm); } +.monaco-workbench .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--vscode-badge-background) 70%, transparent) !important; +} + +.monaco-workbench.vs-dark .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { + background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; +} + /* Welcome Tiles */ .monaco-workbench .part.editor .welcomePageContainer .tile { box-shadow: var(--shadow-md); diff --git a/extensions/theme-defaults/themes/dark_vs.json b/extensions/theme-defaults/themes/dark_vs.json index b66165d69e1..d1812b61ac0 100644 --- a/extensions/theme-defaults/themes/dark_vs.json +++ b/extensions/theme-defaults/themes/dark_vs.json @@ -156,7 +156,8 @@ { "scope": "markup.italic", "settings": { - "fontStyle": "italic" + "fontStyle": "italic", + "foreground": "#C586C0" } }, { diff --git a/extensions/theme-defaults/themes/hc_light.json b/extensions/theme-defaults/themes/hc_light.json index e59494f9cfb..572cbe871dd 100644 --- a/extensions/theme-defaults/themes/hc_light.json +++ b/extensions/theme-defaults/themes/hc_light.json @@ -122,7 +122,8 @@ { "scope": "markup.italic", "settings": { - "fontStyle": "italic" + "fontStyle": "italic", + "foreground": "#800080" } }, { diff --git a/extensions/theme-defaults/themes/light_vs.json b/extensions/theme-defaults/themes/light_vs.json index e4cc701f82c..3fdbbead3d0 100644 --- a/extensions/theme-defaults/themes/light_vs.json +++ b/extensions/theme-defaults/themes/light_vs.json @@ -159,7 +159,8 @@ { "scope": "markup.italic", "settings": { - "fontStyle": "italic" + "fontStyle": "italic", + "foreground": "#800080" } }, { diff --git a/extensions/vscode-colorize-tests/test/colorize-results/test_md.json b/extensions/vscode-colorize-tests/test/colorize-results/test_md.json index 648feab845a..7fd99b59bc0 100644 --- a/extensions/vscode-colorize-tests/test/colorize-results/test_md.json +++ b/extensions/vscode-colorize-tests/test/colorize-results/test_md.json @@ -1809,42 +1809,42 @@ "c": "_", "t": "text.html.markdown meta.paragraph.markdown markup.italic.markdown punctuation.definition.italic.markdown", "r": { - "dark_plus": "default: #D4D4D4", - "light_plus": "default: #000000", - "dark_vs": "default: #D4D4D4", - "light_vs": "default: #000000", + "dark_plus": "markup.italic: #C586C0", + "light_plus": "markup.italic: #800080", + "dark_vs": "markup.italic: #C586C0", + "light_vs": "markup.italic: #800080", "hc_black": "default: #FFFFFF", - "dark_modern": "default: #CCCCCC", - "hc_light": "default: #292929", - "light_modern": "default: #3B3B3B" + "dark_modern": "markup.italic: #C586C0", + "hc_light": "markup.italic: #800080", + "light_modern": "markup.italic: #800080" } }, { "c": "italics", "t": "text.html.markdown meta.paragraph.markdown markup.italic.markdown", "r": { - "dark_plus": "default: #D4D4D4", - "light_plus": "default: #000000", - "dark_vs": "default: #D4D4D4", - "light_vs": "default: #000000", + "dark_plus": "markup.italic: #C586C0", + "light_plus": "markup.italic: #800080", + "dark_vs": "markup.italic: #C586C0", + "light_vs": "markup.italic: #800080", "hc_black": "default: #FFFFFF", - "dark_modern": "default: #CCCCCC", - "hc_light": "default: #292929", - "light_modern": "default: #3B3B3B" + "dark_modern": "markup.italic: #C586C0", + "hc_light": "markup.italic: #800080", + "light_modern": "markup.italic: #800080" } }, { "c": "_", "t": "text.html.markdown meta.paragraph.markdown markup.italic.markdown punctuation.definition.italic.markdown", "r": { - "dark_plus": "default: #D4D4D4", - "light_plus": "default: #000000", - "dark_vs": "default: #D4D4D4", - "light_vs": "default: #000000", + "dark_plus": "markup.italic: #C586C0", + "light_plus": "markup.italic: #800080", + "dark_vs": "markup.italic: #C586C0", + "light_vs": "markup.italic: #800080", "hc_black": "default: #FFFFFF", - "dark_modern": "default: #CCCCCC", - "hc_light": "default: #292929", - "light_modern": "default: #3B3B3B" + "dark_modern": "markup.italic: #C586C0", + "hc_light": "markup.italic: #800080", + "light_modern": "markup.italic: #800080" } }, { diff --git a/extensions/vscode-colorize-tests/test/colorize-results/test_rst.json b/extensions/vscode-colorize-tests/test/colorize-results/test_rst.json index aa8468c4d8a..a2838df578b 100644 --- a/extensions/vscode-colorize-tests/test/colorize-results/test_rst.json +++ b/extensions/vscode-colorize-tests/test/colorize-results/test_rst.json @@ -3,14 +3,14 @@ "c": "*italics*", "t": "source.rst markup.italic", "r": { - "dark_plus": "default: #D4D4D4", - "light_plus": "default: #000000", - "dark_vs": "default: #D4D4D4", - "light_vs": "default: #000000", + "dark_plus": "markup.italic: #C586C0", + "light_plus": "markup.italic: #800080", + "dark_vs": "markup.italic: #C586C0", + "light_vs": "markup.italic: #800080", "hc_black": "default: #FFFFFF", - "dark_modern": "default: #CCCCCC", - "hc_light": "default: #292929", - "light_modern": "default: #3B3B3B" + "dark_modern": "markup.italic: #C586C0", + "hc_light": "markup.italic: #800080", + "light_modern": "markup.italic: #800080" } }, { diff --git a/package-lock.json b/package-lock.json index 32f7e1bae0a..8524540f7d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,16 +30,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/headless": "^6.1.0-beta.152", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.165", + "@xterm/addon-image": "^0.10.0-beta.165", + "@xterm/addon-ligatures": "^0.11.0-beta.165", + "@xterm/addon-progress": "^0.3.0-beta.165", + "@xterm/addon-search": "^0.17.0-beta.165", + "@xterm/addon-serialize": "^0.15.0-beta.165", + "@xterm/addon-unicode11": "^0.10.0-beta.165", + "@xterm/addon-webgl": "^0.20.0-beta.164", + "@xterm/headless": "^6.1.0-beta.165", + "@xterm/xterm": "^6.1.0-beta.165", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -150,7 +150,7 @@ "source-map": "0.6.1", "source-map-support": "^0.3.2", "style-loader": "^3.3.2", - "tar": "^7.5.7", + "tar": "^7.5.9", "ts-loader": "^9.5.1", "tsec": "0.2.7", "tslib": "^2.6.3", @@ -3909,30 +3909,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", - "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", + "version": "0.3.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.165.tgz", + "integrity": "sha512-48GUTZg7sKB7tQvtC7FcH22GxxO0cIUVM4hw068Oi3cJnxDLLPQDicPv70fFG7zysGxxEKE7A39GMtHhwFI75Q==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", - "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", + "version": "0.10.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.165.tgz", + "integrity": "sha512-DwYvKRgytc1OYoJVwA/doOTT92K8asgvnt3FzsHt5D+XgniwdvM5nwjxv95p6UXv0kEOxQWFy3sNJl/4g/5pew==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", - "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", + "version": "0.11.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.165.tgz", + "integrity": "sha512-3nuPBH4ZrGYF+yj/tBB/+YaLRnn8qqbR9J9OcvM6aeDfboEeaFAYIpmdqjh+2Rl2JFTIgZoiS3dKLWaUUpk0Tw==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -3942,7 +3942,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -3964,63 +3964,63 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", - "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", + "version": "0.3.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.165.tgz", + "integrity": "sha512-Jl+dhHkFBUafrXCECI/EepcGV1GYuU1X/0oXkPYu/VYfbmkjQSAidmfBAEyS+4+AUK5Lkf6yLdb1N13tZVexyg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", - "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", + "version": "0.17.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.165.tgz", + "integrity": "sha512-3KjonTDJl/8M6jI5nTJITVT+Z528d/5CgqRmn6IV+sDgRfr3W84RZNDsaxXsLoc0GDsxQIB74/FmnNykUQ5Yew==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", - "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", + "version": "0.15.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.165.tgz", + "integrity": "sha512-NjXE+of4NJagrtHlzePBuWQ8a9pBFhhmQuvOhPj9W3CSi+VanuMoM/oRaT1TbR3efHk2JdCsKVDJScEzY8kdjw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", - "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", + "version": "0.10.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.165.tgz", + "integrity": "sha512-a6myeixOXDYeuOj0GK+/LWXbXXWanFVMvQRUMgC7wmUNGgSZiyJ8NPWzhAq6Vib4jSQ02pd+ux4ZtWs5kyvFLg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.151", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", - "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", + "version": "0.20.0-beta.164", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.164.tgz", + "integrity": "sha512-wXTi281yTWY1iAmRh21N6AhcEMopTjIm4xsdDdNmS5LbhxNuhVKNNIGKm5Zhd/G9fpn/vrfC4yZ6KA0lI/ZAxg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", - "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", + "version": "6.1.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.165.tgz", + "integrity": "sha512-GjAqUhEiY7gb12+yIItptgMKUwHMa7o39HpezD7sfNjYLjmvWQcB02jqUdVMsvjjAKTe2YJMXp3RkApeXdMRVg==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", - "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", + "version": "6.1.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.165.tgz", + "integrity": "sha512-OUszO4HSmGPEw3EhboyIcNLQKJQKCDsYHv9kYFcaiK3biuNjGP0VAPVUJOLbf3V9fa1GLUUq+t985blqvTApoA==", "license": "MIT", "workspaces": [ "addons/*" @@ -16389,9 +16389,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/package.json b/package.json index 20b139291a3..4a17850e8f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "16be71799bd7ef33ea9b0206fb548ce74a47daa4", + "distro": "85914d5a600261a53306190177be48aa8f0cdfb4", "author": { "name": "Microsoft Corporation" }, @@ -95,16 +95,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/headless": "^6.1.0-beta.152", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.165", + "@xterm/addon-image": "^0.10.0-beta.165", + "@xterm/addon-ligatures": "^0.11.0-beta.165", + "@xterm/addon-progress": "^0.3.0-beta.165", + "@xterm/addon-search": "^0.17.0-beta.165", + "@xterm/addon-serialize": "^0.15.0-beta.165", + "@xterm/addon-unicode11": "^0.10.0-beta.165", + "@xterm/addon-webgl": "^0.20.0-beta.164", + "@xterm/headless": "^6.1.0-beta.165", + "@xterm/xterm": "^6.1.0-beta.165", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -215,7 +215,7 @@ "source-map": "0.6.1", "source-map-support": "^0.3.2", "style-loader": "^3.3.2", - "tar": "^7.5.7", + "tar": "^7.5.9", "ts-loader": "^9.5.1", "tsec": "0.2.7", "tslib": "^2.6.3", @@ -244,4 +244,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} +} \ No newline at end of file diff --git a/remote/package-lock.json b/remote/package-lock.json index de6d200c162..4cec83e52e8 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/headless": "^6.1.0-beta.152", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.165", + "@xterm/addon-image": "^0.10.0-beta.165", + "@xterm/addon-ligatures": "^0.11.0-beta.165", + "@xterm/addon-progress": "^0.3.0-beta.165", + "@xterm/addon-search": "^0.17.0-beta.165", + "@xterm/addon-serialize": "^0.15.0-beta.165", + "@xterm/addon-unicode11": "^0.10.0-beta.165", + "@xterm/addon-webgl": "^0.20.0-beta.164", + "@xterm/headless": "^6.1.0-beta.165", + "@xterm/xterm": "^6.1.0-beta.165", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -577,30 +577,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", - "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", + "version": "0.3.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.165.tgz", + "integrity": "sha512-48GUTZg7sKB7tQvtC7FcH22GxxO0cIUVM4hw068Oi3cJnxDLLPQDicPv70fFG7zysGxxEKE7A39GMtHhwFI75Q==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", - "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", + "version": "0.10.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.165.tgz", + "integrity": "sha512-DwYvKRgytc1OYoJVwA/doOTT92K8asgvnt3FzsHt5D+XgniwdvM5nwjxv95p6UXv0kEOxQWFy3sNJl/4g/5pew==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", - "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", + "version": "0.11.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.165.tgz", + "integrity": "sha512-3nuPBH4ZrGYF+yj/tBB/+YaLRnn8qqbR9J9OcvM6aeDfboEeaFAYIpmdqjh+2Rl2JFTIgZoiS3dKLWaUUpk0Tw==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -610,67 +610,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", - "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", + "version": "0.3.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.165.tgz", + "integrity": "sha512-Jl+dhHkFBUafrXCECI/EepcGV1GYuU1X/0oXkPYu/VYfbmkjQSAidmfBAEyS+4+AUK5Lkf6yLdb1N13tZVexyg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", - "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", + "version": "0.17.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.165.tgz", + "integrity": "sha512-3KjonTDJl/8M6jI5nTJITVT+Z528d/5CgqRmn6IV+sDgRfr3W84RZNDsaxXsLoc0GDsxQIB74/FmnNykUQ5Yew==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", - "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", + "version": "0.15.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.165.tgz", + "integrity": "sha512-NjXE+of4NJagrtHlzePBuWQ8a9pBFhhmQuvOhPj9W3CSi+VanuMoM/oRaT1TbR3efHk2JdCsKVDJScEzY8kdjw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", - "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", + "version": "0.10.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.165.tgz", + "integrity": "sha512-a6myeixOXDYeuOj0GK+/LWXbXXWanFVMvQRUMgC7wmUNGgSZiyJ8NPWzhAq6Vib4jSQ02pd+ux4ZtWs5kyvFLg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.151", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", - "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", + "version": "0.20.0-beta.164", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.164.tgz", + "integrity": "sha512-wXTi281yTWY1iAmRh21N6AhcEMopTjIm4xsdDdNmS5LbhxNuhVKNNIGKm5Zhd/G9fpn/vrfC4yZ6KA0lI/ZAxg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", - "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", + "version": "6.1.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.165.tgz", + "integrity": "sha512-GjAqUhEiY7gb12+yIItptgMKUwHMa7o39HpezD7sfNjYLjmvWQcB02jqUdVMsvjjAKTe2YJMXp3RkApeXdMRVg==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", - "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", + "version": "6.1.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.165.tgz", + "integrity": "sha512-OUszO4HSmGPEw3EhboyIcNLQKJQKCDsYHv9kYFcaiK3biuNjGP0VAPVUJOLbf3V9fa1GLUUq+t985blqvTApoA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index 603dc5559ba..afaaabaf5c8 100644 --- a/remote/package.json +++ b/remote/package.json @@ -17,16 +17,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/headless": "^6.1.0-beta.152", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.165", + "@xterm/addon-image": "^0.10.0-beta.165", + "@xterm/addon-ligatures": "^0.11.0-beta.165", + "@xterm/addon-progress": "^0.3.0-beta.165", + "@xterm/addon-search": "^0.17.0-beta.165", + "@xterm/addon-serialize": "^0.15.0-beta.165", + "@xterm/addon-unicode11": "^0.10.0-beta.165", + "@xterm/addon-webgl": "^0.20.0-beta.164", + "@xterm/headless": "^6.1.0-beta.165", + "@xterm/xterm": "^6.1.0-beta.165", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index f443df36ae5..eeed651e576 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.165", + "@xterm/addon-image": "^0.10.0-beta.165", + "@xterm/addon-ligatures": "^0.11.0-beta.165", + "@xterm/addon-progress": "^0.3.0-beta.165", + "@xterm/addon-search": "^0.17.0-beta.165", + "@xterm/addon-serialize": "^0.15.0-beta.165", + "@xterm/addon-unicode11": "^0.10.0-beta.165", + "@xterm/addon-webgl": "^0.20.0-beta.164", + "@xterm/xterm": "^6.1.0-beta.165", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -99,30 +99,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", - "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", + "version": "0.3.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.165.tgz", + "integrity": "sha512-48GUTZg7sKB7tQvtC7FcH22GxxO0cIUVM4hw068Oi3cJnxDLLPQDicPv70fFG7zysGxxEKE7A39GMtHhwFI75Q==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", - "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", + "version": "0.10.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.165.tgz", + "integrity": "sha512-DwYvKRgytc1OYoJVwA/doOTT92K8asgvnt3FzsHt5D+XgniwdvM5nwjxv95p6UXv0kEOxQWFy3sNJl/4g/5pew==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", - "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", + "version": "0.11.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.165.tgz", + "integrity": "sha512-3nuPBH4ZrGYF+yj/tBB/+YaLRnn8qqbR9J9OcvM6aeDfboEeaFAYIpmdqjh+2Rl2JFTIgZoiS3dKLWaUUpk0Tw==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -132,58 +132,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", - "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", + "version": "0.3.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.165.tgz", + "integrity": "sha512-Jl+dhHkFBUafrXCECI/EepcGV1GYuU1X/0oXkPYu/VYfbmkjQSAidmfBAEyS+4+AUK5Lkf6yLdb1N13tZVexyg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", - "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", + "version": "0.17.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.165.tgz", + "integrity": "sha512-3KjonTDJl/8M6jI5nTJITVT+Z528d/5CgqRmn6IV+sDgRfr3W84RZNDsaxXsLoc0GDsxQIB74/FmnNykUQ5Yew==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", - "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", + "version": "0.15.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.165.tgz", + "integrity": "sha512-NjXE+of4NJagrtHlzePBuWQ8a9pBFhhmQuvOhPj9W3CSi+VanuMoM/oRaT1TbR3efHk2JdCsKVDJScEzY8kdjw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", - "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", + "version": "0.10.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.165.tgz", + "integrity": "sha512-a6myeixOXDYeuOj0GK+/LWXbXXWanFVMvQRUMgC7wmUNGgSZiyJ8NPWzhAq6Vib4jSQ02pd+ux4ZtWs5kyvFLg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.151", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", - "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", + "version": "0.20.0-beta.164", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.164.tgz", + "integrity": "sha512-wXTi281yTWY1iAmRh21N6AhcEMopTjIm4xsdDdNmS5LbhxNuhVKNNIGKm5Zhd/G9fpn/vrfC4yZ6KA0lI/ZAxg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.152" + "@xterm/xterm": "^6.1.0-beta.165" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.152", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", - "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", + "version": "6.1.0-beta.165", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.165.tgz", + "integrity": "sha512-OUszO4HSmGPEw3EhboyIcNLQKJQKCDsYHv9kYFcaiK3biuNjGP0VAPVUJOLbf3V9fa1GLUUq+t985blqvTApoA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index fa7ad6cca70..35cf04c172a 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.152", - "@xterm/addon-image": "^0.10.0-beta.152", - "@xterm/addon-ligatures": "^0.11.0-beta.152", - "@xterm/addon-progress": "^0.3.0-beta.152", - "@xterm/addon-search": "^0.17.0-beta.152", - "@xterm/addon-serialize": "^0.15.0-beta.152", - "@xterm/addon-unicode11": "^0.10.0-beta.152", - "@xterm/addon-webgl": "^0.20.0-beta.151", - "@xterm/xterm": "^6.1.0-beta.152", + "@xterm/addon-clipboard": "^0.3.0-beta.165", + "@xterm/addon-image": "^0.10.0-beta.165", + "@xterm/addon-ligatures": "^0.11.0-beta.165", + "@xterm/addon-progress": "^0.3.0-beta.165", + "@xterm/addon-search": "^0.17.0-beta.165", + "@xterm/addon-serialize": "^0.15.0-beta.165", + "@xterm/addon-unicode11": "^0.10.0-beta.165", + "@xterm/addon-webgl": "^0.20.0-beta.164", + "@xterm/xterm": "^6.1.0-beta.165", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/src/vs/base/common/yaml.ts b/src/vs/base/common/yaml.ts index 6f2e801d696..1cb0388afea 100644 --- a/src/vs/base/common/yaml.ts +++ b/src/vs/base/common/yaml.ts @@ -3,38 +3,62 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../nls.js'; + /** * Parses a simplified YAML-like input from a single string. * Supports objects, arrays, primitive types (string, number, boolean, null). * Tracks positions for error reporting and node locations. * * Limitations: - * - No multi-line strings or block literals * - No anchors or references * - No complex types (dates, binary) - * - No special handling for escape sequences in strings - * - Indentation must be consistent (spaces only, no tabs) - * - * Notes: - * - New line separators can be either "\n" or "\r\n". The input string is split into lines internally. + * - No single pair implicit entries * * @param input A string containing the YAML-like input * @param errors Array to collect parsing errors - * @param options Parsing options - * @returns The parsed representation (ObjectNode, ArrayNode, or primitive node) + * @returns The parsed representation (YamlMapNode, YamlSequenceNode, or YamlScalarNode) */ export function parse(input: string, errors: YamlParseError[] = [], options: ParseOptions = {}): YamlNode | undefined { - // Normalize both LF and CRLF by splitting on either; CR characters are not retained as part of line text. - // This keeps the existing line/character based lexer logic intact. - const lines = input.length === 0 ? [] : input.split(/\r\n|\n/); - const parser = new YamlParser(lines, errors, options); + const scanner = new YamlScanner(input); + const tokens = scanner.scan(); + const parser = new YamlParser(tokens, input, errors, options); return parser.parse(); } +// -- AST Node Types ---------------------------------------------------------- + +export interface YamlScalarNode { + readonly type: 'scalar'; + readonly value: string; + readonly rawValue: string; + readonly startOffset: number; + readonly endOffset: number; + readonly format: 'single' | 'double' | 'none' | 'literal' | 'folded'; +} + +export interface YamlMapNode { + readonly type: 'map'; + readonly properties: { key: YamlScalarNode; value: YamlNode }[]; + readonly style: 'block' | 'flow'; + readonly startOffset: number; + readonly endOffset: number; +} + +export interface YamlSequenceNode { + readonly type: 'sequence'; + readonly items: YamlNode[]; + readonly style: 'block' | 'flow'; + readonly startOffset: number; + readonly endOffset: number; +} + +export type YamlNode = YamlSequenceNode | YamlMapNode | YamlScalarNode; + export interface YamlParseError { readonly message: string; - readonly start: Position; - readonly end: Position; + readonly startOffset: number; + readonly endOffset: number; readonly code: string; } @@ -42,851 +66,1529 @@ export interface ParseOptions { readonly allowDuplicateKeys?: boolean; } -export interface Position { - readonly line: number; - readonly character: number; +// -- Token Types ------------------------------------------------------------- + +const enum TokenType { + // Scalar values (unquoted, single-quoted, double-quoted) + Scalar, + // Structural tokens + Colon, // ':' + Dash, // '- ' + Comma, // ',' + FlowMapStart, // '{' + FlowMapEnd, // '}' + FlowSeqStart, // '[' + FlowSeqEnd, // ']' + // Whitespace / structure + Newline, + Indent, // leading whitespace at start of line (carries the indent level) + Comment, + DocumentStart, // '---' + DocumentEnd, // '...' + EOF, } -export interface YamlStringNode { - readonly type: 'string'; +interface Token { + readonly type: TokenType; + readonly startOffset: number; + readonly endOffset: number; + /** For Scalar tokens: the raw text (including quotes). */ + readonly rawValue: string; + /** For Scalar tokens: the interpreted string value. */ readonly value: string; - readonly start: Position; - readonly end: Position; + /** For Scalar tokens: quote style. */ + readonly format: 'single' | 'double' | 'none' | 'literal' | 'folded'; + /** For Indent tokens: the column (number of spaces). */ + readonly indent: number; } -export interface YamlNumberNode { - readonly type: 'number'; - readonly value: number; - readonly start: Position; - readonly end: Position; +function makeToken( + type: TokenType, + startOffset: number, + endOffset: number, + extra?: Partial> +): Token { + return { + type, + startOffset, + endOffset, + rawValue: extra?.rawValue ?? '', + value: extra?.value ?? '', + format: extra?.format ?? 'none' as Token['format'], + indent: extra?.indent ?? 0, + }; } -export interface YamlBooleanNode { - readonly type: 'boolean'; - readonly value: boolean; - readonly start: Position; - readonly end: Position; -} +// -- Scanner ----------------------------------------------------------------- -export interface YamlNullNode { - readonly type: 'null'; - readonly value: null; - readonly start: Position; - readonly end: Position; -} +class YamlScanner { + private pos = 0; + private readonly tokens: Token[] = []; + // Track flow nesting depth so commas and flow indicators are only special inside flow collections + private flowDepth = 0; -export interface YamlObjectNode { - readonly type: 'object'; - readonly properties: { key: YamlStringNode; value: YamlNode }[]; - readonly start: Position; - readonly end: Position; -} + constructor(private readonly input: string) { } -export interface YamlArrayNode { - readonly type: 'array'; - readonly items: YamlNode[]; - readonly start: Position; - readonly end: Position; -} - -export type YamlNode = YamlStringNode | YamlNumberNode | YamlBooleanNode | YamlNullNode | YamlObjectNode | YamlArrayNode; - -// Helper functions for position and node creation -function createPosition(line: number, character: number): Position { - return { line, character }; -} - -// Specialized node creation functions using a more concise approach -function createStringNode(value: string, start: Position, end: Position): YamlStringNode { - return { type: 'string', value, start, end }; -} - -function createNumberNode(value: number, start: Position, end: Position): YamlNumberNode { - return { type: 'number', value, start, end }; -} - -function createBooleanNode(value: boolean, start: Position, end: Position): YamlBooleanNode { - return { type: 'boolean', value, start, end }; -} - -function createNullNode(start: Position, end: Position): YamlNullNode { - return { type: 'null', value: null, start, end }; -} - -function createObjectNode(properties: { key: YamlStringNode; value: YamlNode }[], start: Position, end: Position): YamlObjectNode { - return { type: 'object', start, end, properties }; -} - -function createArrayNode(items: YamlNode[], start: Position, end: Position): YamlArrayNode { - return { type: 'array', start, end, items }; -} - -// Utility functions for parsing -function isWhitespace(char: string): boolean { - return char === ' ' || char === '\t'; -} - -// Simplified number validation using regex -function isValidNumber(value: string): boolean { - return /^-?\d*\.?\d+$/.test(value); -} - -// Lexer/Tokenizer for YAML content -class YamlLexer { - private lines: string[]; - private currentLine: number = 0; - private currentChar: number = 0; - - constructor(lines: string[]) { - this.lines = lines; - } - - getCurrentPosition(): Position { - return createPosition(this.currentLine, this.currentChar); - } - - getCurrentLineNumber(): number { - return this.currentLine; - } - - getCurrentCharNumber(): number { - return this.currentChar; - } - - getCurrentLineText(): string { - return this.currentLine < this.lines.length ? this.lines[this.currentLine] : ''; - } - - savePosition(): { line: number; char: number } { - return { line: this.currentLine, char: this.currentChar }; - } - - restorePosition(pos: { line: number; char: number }): void { - this.currentLine = pos.line; - this.currentChar = pos.char; - } - - isAtEnd(): boolean { - return this.currentLine >= this.lines.length; - } - - getCurrentChar(): string { - if (this.isAtEnd() || this.currentChar >= this.lines[this.currentLine].length) { - return ''; + scan(): Token[] { + while (this.pos < this.input.length) { + this.scanLine(); } - return this.lines[this.currentLine][this.currentChar]; + this.tokens.push(makeToken(TokenType.EOF, this.pos, this.pos)); + return this.tokens; } - peek(offset: number = 1): string { - const newChar = this.currentChar + offset; - if (this.currentLine >= this.lines.length || newChar >= this.lines[this.currentLine].length) { - return ''; + // Scan a single logical line (up to and including the newline character) + private scanLine(): void { + // Handle blank lines / lines that are only whitespace + if (this.peekChar() === '\n') { + this.tokens.push(makeToken(TokenType.Newline, this.pos, this.pos + 1)); + this.pos++; + return; } - return this.lines[this.currentLine][newChar]; - } - - advance(): string { - const char = this.getCurrentChar(); - if (this.currentChar >= this.lines[this.currentLine].length && this.currentLine < this.lines.length - 1) { - this.currentLine++; - this.currentChar = 0; - } else { - this.currentChar++; + if (this.peekChar() === '\r') { + const end = this.pos + (this.input[this.pos + 1] === '\n' ? 2 : 1); + this.tokens.push(makeToken(TokenType.Newline, this.pos, end)); + this.pos = end; + return; } - return char; - } - advanceLine(): void { - this.currentLine++; - this.currentChar = 0; - } - - skipWhitespace(): void { - while (!this.isAtEnd() && this.currentChar < this.lines[this.currentLine].length && isWhitespace(this.getCurrentChar())) { - this.advance(); - } - } - - skipToEndOfLine(): void { - this.currentChar = this.lines[this.currentLine].length; - } - - getIndentation(): number { - if (this.isAtEnd()) { - return 0; - } + // Measure leading whitespace → Indent token + const indentStart = this.pos; let indent = 0; - for (let i = 0; i < this.lines[this.currentLine].length; i++) { - if (this.lines[this.currentLine][i] === ' ') { - indent++; - } else if (this.lines[this.currentLine][i] === '\t') { - indent += 4; // Treat tab as 4 spaces - } else { + while (this.pos < this.input.length && (this.input[this.pos] === ' ' || this.input[this.pos] === '\t')) { + indent++; + this.pos++; + } + if (indent > 0) { + this.tokens.push(makeToken(TokenType.Indent, indentStart, this.pos, { indent })); + } + + // If line is now empty (only whitespace before newline/EOF), emit newline + if (this.pos >= this.input.length || this.peekChar() === '\n' || this.peekChar() === '\r') { + if (this.pos < this.input.length) { + const nlStart = this.pos; + const end = this.peekChar() === '\r' && this.input[this.pos + 1] === '\n' ? this.pos + 2 : this.pos + 1; + this.tokens.push(makeToken(TokenType.Newline, nlStart, end)); + this.pos = end; + } + return; + } + + // Check for document markers (--- / ...) at column 0 + if (indent === 0 && this.input.length - this.pos >= 3) { + const c0 = this.input[this.pos]; + const c1 = this.input[this.pos + 1]; + const c2 = this.input[this.pos + 2]; + const c3 = this.input[this.pos + 3]; + const isTerminator = c3 === undefined || c3 === ' ' || c3 === '\t' || c3 === '\n' || c3 === '\r'; + if (c0 === '-' && c1 === '-' && c2 === '-' && isTerminator) { + this.tokens.push(makeToken(TokenType.DocumentStart, this.pos, this.pos + 3)); + this.pos += 3; + this.scanLineContent(); + this.scanNewline(); + return; + } + if (c0 === '.' && c1 === '.' && c2 === '.' && isTerminator) { + this.tokens.push(makeToken(TokenType.DocumentEnd, this.pos, this.pos + 3)); + this.pos += 3; + this.scanLineContent(); + this.scanNewline(); + return; + } + } + + // Check for comment-only line + if (this.peekChar() === '#') { + this.scanComment(); + this.scanNewline(); + return; + } + + // Skip directive lines (e.g., %YAML 1.2, %TAG) - consume rest of line + if (this.peekChar() === '%') { + while (this.pos < this.input.length && this.input[this.pos] !== '\n' && this.input[this.pos] !== '\r') { + this.pos++; + } + this.scanNewline(); + return; + } + + // Scan the rest of the line for tokens + this.scanLineContent(); + this.scanNewline(); + } + + private scanLineContent(): void { + while (this.pos < this.input.length && this.peekChar() !== '\n' && this.peekChar() !== '\r') { + this.skipInlineWhitespace(); + if (this.pos >= this.input.length || this.peekChar() === '\n' || this.peekChar() === '\r') { break; } - } - return indent; - } - moveToNextNonEmptyLine(): void { - while (this.currentLine < this.lines.length) { - // First check current line from current position - if (this.currentChar < this.lines[this.currentLine].length) { - const remainingLine = this.lines[this.currentLine].substring(this.currentChar).trim(); - if (remainingLine.length > 0 && !remainingLine.startsWith('#')) { - this.skipWhitespace(); - return; - } - } + const ch = this.peekChar(); - // Move to next line and check from beginning - this.currentLine++; - this.currentChar = 0; - - if (this.currentLine < this.lines.length) { - const line = this.lines[this.currentLine].trim(); - if (line.length > 0 && !line.startsWith('#')) { - this.skipWhitespace(); - return; - } + if (ch === '#') { + this.scanComment(); + break; // comment consumes rest of line + } else if (ch === '{') { + this.flowDepth++; + this.tokens.push(makeToken(TokenType.FlowMapStart, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === '}' && this.flowDepth > 0) { + this.flowDepth--; + this.tokens.push(makeToken(TokenType.FlowMapEnd, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === '[') { + this.flowDepth++; + this.tokens.push(makeToken(TokenType.FlowSeqStart, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === ']' && this.flowDepth > 0) { + this.flowDepth--; + this.tokens.push(makeToken(TokenType.FlowSeqEnd, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === ',' && this.flowDepth > 0) { + this.tokens.push(makeToken(TokenType.Comma, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === '-' && this.isBlockDash()) { + // Block sequence indicator: '- ' or '-' at end of line + this.tokens.push(makeToken(TokenType.Dash, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === ':' && this.isBlockColon()) { + this.tokens.push(makeToken(TokenType.Colon, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === ':' && this.flowDepth > 0 && this.lastTokenIsJsonLike()) { + // In flow context, ':' immediately following a JSON-like node (quoted scalar, + // flow mapping, or flow sequence) is a value indicator even without trailing space + this.tokens.push(makeToken(TokenType.Colon, this.pos, this.pos + 1)); + this.pos++; + } else if (ch === '\'' || ch === '"') { + this.scanQuotedScalar(ch); + } else if ((ch === '|' || ch === '>') && this.flowDepth === 0 && this.isBlockScalarStart()) { + this.scanBlockScalar(ch as '|' | '>'); + break; // Block scalar consumed multiple lines; return to main scan loop + } else { + this.scanUnquotedScalar(); } } } -} -// Parser class for handling YAML parsing -class YamlParser { - private lexer: YamlLexer; - private errors: YamlParseError[]; - private options: ParseOptions; - // Track nesting level of flow (inline) collections '[' ']' '{' '}' - private flowLevel: number = 0; - - constructor(lines: string[], errors: YamlParseError[], options: ParseOptions) { - this.lexer = new YamlLexer(lines); - this.errors = errors; - this.options = options; + /** Check if '-' is a block sequence dash (followed by space, newline, or EOF) */ + private isBlockDash(): boolean { + const next = this.input[this.pos + 1]; + return next === undefined || next === ' ' || next === '\t' || next === '\n' || next === '\r'; } - addError(message: string, code: string, start: Position, end: Position): void { - this.errors.push({ message, code, start, end }); + /** Check if ':' acts as a mapping value indicator (followed by space, newline, EOF, or flow indicator) */ + private isBlockColon(): boolean { + const next = this.input[this.pos + 1]; + if (next === undefined || next === ' ' || next === '\t' || next === '\n' || next === '\r') { return true; } + // Flow indicators after colon only count inside flow context + if (this.flowDepth > 0 && (next === ',' || next === '}' || next === ']')) { return true; } + return false; } - parseValue(expectedIndent?: number): YamlNode { - this.lexer.skipWhitespace(); - - if (this.lexer.isAtEnd()) { - const pos = this.lexer.getCurrentPosition(); - return createStringNode('', pos, pos); - } - - const char = this.lexer.getCurrentChar(); - - // Handle quoted strings - if (char === '"' || char === `'`) { - return this.parseQuotedString(char); - } - - // Handle inline arrays - if (char === '[') { - return this.parseInlineArray(); - } - - // Handle inline objects - if (char === '{') { - return this.parseInlineObject(); - } - - // Handle unquoted values - return this.parseUnquotedValue(); - } - - parseQuotedString(quote: string): YamlNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip opening quote - - let value = ''; - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) { - value += this.lexer.advance(); - } - - if (this.lexer.getCurrentChar() === quote) { - this.lexer.advance(); // Skip closing quote - } - - const end = this.lexer.getCurrentPosition(); - return createStringNode(value, start, end); - } - - parseUnquotedValue(): YamlNode { - const start = this.lexer.getCurrentPosition(); - let value = ''; - let endPos = start; - - // Helper function to check for value terminators - const isTerminator = (char: string): boolean => { - if (char === '#') { return true; } - // Comma, ']' and '}' only terminate inside flow collections - if (this.flowLevel > 0 && (char === ',' || char === ']' || char === '}')) { return true; } + /** Check if the last non-whitespace token is a JSON-like node (quoted scalar or flow end) */ + private lastTokenIsJsonLike(): boolean { + for (let i = this.tokens.length - 1; i >= 0; i--) { + const t = this.tokens[i]; + if (t.type === TokenType.Newline || t.type === TokenType.Indent || t.type === TokenType.Comment) { + continue; + } + // Quoted scalar or flow collection end bracket + if (t.type === TokenType.Scalar && t.format !== 'none') { return true; } + if (t.type === TokenType.FlowMapEnd || t.type === TokenType.FlowSeqEnd) { return true; } return false; - }; - - // Handle opening quote that might not be closed - const firstChar = this.lexer.getCurrentChar(); - if (firstChar === '"' || firstChar === `'`) { - value += this.lexer.advance(); - endPos = this.lexer.getCurrentPosition(); - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { - const char = this.lexer.getCurrentChar(); - if (char === firstChar || isTerminator(char)) { - break; - } - value += this.lexer.advance(); - endPos = this.lexer.getCurrentPosition(); - } - } else { - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { - const char = this.lexer.getCurrentChar(); - if (isTerminator(char)) { - break; - } - value += this.lexer.advance(); - endPos = this.lexer.getCurrentPosition(); - } } - const trimmed = value.trimEnd(); - const diff = value.length - trimmed.length; - if (diff) { - endPos = createPosition(start.line, endPos.character - diff); - } - const finalValue = (firstChar === '"' || firstChar === `'`) ? trimmed.substring(1) : trimmed; - return this.createValueNode(finalValue, start, endPos); + return false; } - private createValueNode(value: string, start: Position, end: Position): YamlNode { - if (value === '') { - return createStringNode('', start, start); - } + private scanQuotedScalar(quote: '\'' | '"'): void { + const start = this.pos; + this.pos++; // skip opening quote + let value = ''; + // Track trailing literal whitespace count so flow folding only trims + // source-level whitespace, not whitespace produced by escape sequences + let trailingLiteralWs = 0; - // Boolean values - if (value === 'true') { - return createBooleanNode(true, start, end); - } - if (value === 'false') { - return createBooleanNode(false, start, end); - } - - // Null values - if (value === 'null' || value === '~') { - return createNullNode(start, end); - } - - // Number values - const numberValue = Number(value); - if (!isNaN(numberValue) && isFinite(numberValue) && isValidNumber(value)) { - return createNumberNode(numberValue, start, end); - } - - // Default to string - return createStringNode(value, start, end); - } - - parseInlineArray(): YamlArrayNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip '[' - this.flowLevel++; - - const items: YamlNode[] = []; - - while (!this.lexer.isAtEnd()) { - this.lexer.skipWhitespace(); - - // Handle end of array - if (this.lexer.getCurrentChar() === ']') { - this.lexer.advance(); - break; + while (this.pos < this.input.length) { + const ch = this.input[this.pos]; + if (ch === quote) { + // In single-quoted strings, '' is an escaped single quote + if (quote === '\'' && this.input[this.pos + 1] === '\'') { + value += '\''; + this.pos += 2; + trailingLiteralWs = 0; + continue; + } + this.pos++; // skip closing quote + const rawValue = this.input.substring(start, this.pos); + this.tokens.push(makeToken(TokenType.Scalar, start, this.pos, { + rawValue, + value, + format: quote === '\'' ? 'single' : 'double', + })); + return; } - // Handle end of line - continue to next line for multi-line arrays - if (this.lexer.getCurrentChar() === '') { - this.lexer.advanceLine(); + // Handle escape sequences in double-quoted strings + if (quote === '"' && ch === '\\') { + const next = this.input[this.pos + 1]; + // Escaped line break: \ + newline → join lines without inserting a space + if (next === '\n' || next === '\r') { + this.pos++; // skip '\' + this.consumeNewline(); + // Strip leading whitespace on continuation line + this.skipInlineWhitespace(); + trailingLiteralWs = 0; + continue; + } + switch (next) { + case 'n': value += '\n'; break; + case 't': value += '\t'; break; + case '\\': value += '\\'; break; + case '"': value += '"'; break; + case '/': value += '/'; break; + case 'r': value += '\r'; break; + case '0': value += '\0'; break; + case 'a': value += '\x07'; break; + case 'b': value += '\b'; break; + case 'e': value += '\x1b'; break; + case 'v': value += '\v'; break; + case 'f': value += '\f'; break; + case ' ': value += ' '; break; + case '_': value += '\xa0'; break; + case 'x': { + // \xNN - 2-digit hex escape + const hex = this.input.substring(this.pos + 2, this.pos + 4); + const code = parseInt(hex, 16); + if (hex.length === 2 && !isNaN(code)) { + value += String.fromCharCode(code); + this.pos += 4; + } else { + value += '\\x'; + this.pos += 2; + } + trailingLiteralWs = 0; + continue; + } + case 'u': { + // \uNNNN - 4-digit unicode escape + const hex = this.input.substring(this.pos + 2, this.pos + 6); + const code = parseInt(hex, 16); + if (hex.length === 4 && !isNaN(code)) { + value += String.fromCodePoint(code); + this.pos += 6; + } else { + value += '\\u'; + this.pos += 2; + } + trailingLiteralWs = 0; + continue; + } + case 'U': { + // \UNNNNNNNN - 8-digit unicode escape + const hex = this.input.substring(this.pos + 2, this.pos + 10); + const code = parseInt(hex, 16); + if (hex.length === 8 && !isNaN(code)) { + value += String.fromCodePoint(code); + this.pos += 10; + } else { + value += '\\U'; + this.pos += 2; + } + trailingLiteralWs = 0; + continue; + } + default: value += '\\' + (next ?? ''); break; + } + this.pos += 2; + trailingLiteralWs = 0; continue; } - // Handle comments - comments should terminate the array parsing - if (this.lexer.getCurrentChar() === '#') { - // Skip the rest of the line (comment) - this.lexer.skipToEndOfLine(); - this.lexer.advanceLine(); - continue; - } - - // Save position before parsing to detect if we're making progress - const positionBefore = this.lexer.savePosition(); - - // Parse array item - const item = this.parseValue(); - // Skip implicit empty items that arise from a leading comma at the beginning of a new line - // (e.g. a line starting with ",foo" after a comment). A legitimate empty string element - // would have quotes and thus a non-zero span. We only filter zero-length spans. - if (!(item.type === 'string' && item.value === '' && item.start.line === item.end.line && item.start.character === item.end.character)) { - items.push(item); - } - - // Check if we made progress - if not, we're likely stuck - const positionAfter = this.lexer.savePosition(); - if (positionBefore.line === positionAfter.line && positionBefore.char === positionAfter.char) { - // No progress made, advance at least one character to prevent infinite loop - if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { - this.lexer.advance(); - } else { - break; + // Flow folding: handle newlines inside quoted scalars (both single and double) + if (ch === '\n' || ch === '\r') { + // Trim trailing literal whitespace (not escape-produced whitespace) + if (trailingLiteralWs > 0) { + value = value.substring(0, value.length - trailingLiteralWs); } - } + trailingLiteralWs = 0; - this.lexer.skipWhitespace(); + // Skip the newline + this.consumeNewline(); - // Handle comma separator - if (this.lexer.getCurrentChar() === ',') { - this.lexer.advance(); - } - } - - const end = this.lexer.getCurrentPosition(); - this.flowLevel--; - return createArrayNode(items, start, end); - } - - parseInlineObject(): YamlObjectNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip '{' - this.flowLevel++; - - const properties: { key: YamlStringNode; value: YamlNode }[] = []; - - while (!this.lexer.isAtEnd()) { - this.lexer.skipWhitespace(); - - // Handle end of object - if (this.lexer.getCurrentChar() === '}') { - this.lexer.advance(); - break; - } - - // Handle comments - comments should terminate the object parsing - if (this.lexer.getCurrentChar() === '#') { - // Skip the rest of the line (comment) - this.lexer.skipToEndOfLine(); - this.lexer.advanceLine(); - continue; - } - - // Save position before parsing to detect if we're making progress - const positionBefore = this.lexer.savePosition(); - - // Parse key - read until colon - const keyStart = this.lexer.getCurrentPosition(); - let keyValue = ''; - - // Handle quoted keys - if (this.lexer.getCurrentChar() === '"' || this.lexer.getCurrentChar() === `'`) { - const quote = this.lexer.getCurrentChar(); - this.lexer.advance(); // Skip opening quote - - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) { - keyValue += this.lexer.advance(); - } - - if (this.lexer.getCurrentChar() === quote) { - this.lexer.advance(); // Skip closing quote - } - } else { - // Handle unquoted keys - read until colon - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== ':') { - keyValue += this.lexer.advance(); - } - } - - keyValue = keyValue.trim(); - const keyEnd = this.lexer.getCurrentPosition(); - const key = createStringNode(keyValue, keyStart, keyEnd); - - this.lexer.skipWhitespace(); - - // Expect colon - if (this.lexer.getCurrentChar() === ':') { - this.lexer.advance(); - } - - this.lexer.skipWhitespace(); - - // Parse value - const value = this.parseValue(); - - properties.push({ key, value }); - - // Check if we made progress - if not, we're likely stuck - const positionAfter = this.lexer.savePosition(); - if (positionBefore.line === positionAfter.line && positionBefore.char === positionAfter.char) { - // No progress made, advance at least one character to prevent infinite loop - if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') { - this.lexer.advance(); - } else { - break; - } - } - - this.lexer.skipWhitespace(); - - // Handle comma separator - if (this.lexer.getCurrentChar() === ',') { - this.lexer.advance(); - } - } - - const end = this.lexer.getCurrentPosition(); - this.flowLevel--; - return createObjectNode(properties, start, end); - } - - parseBlockArray(baseIndent: number): YamlArrayNode { - const start = this.lexer.getCurrentPosition(); - const items: YamlNode[] = []; - - while (!this.lexer.isAtEnd()) { - this.lexer.moveToNextNonEmptyLine(); - - if (this.lexer.isAtEnd()) { - break; - } - - const currentIndent = this.lexer.getIndentation(); - - // If indentation is less than expected, we're done with this array - if (currentIndent < baseIndent) { - break; - } - - this.lexer.skipWhitespace(); - - // Check for array item marker - if (this.lexer.getCurrentChar() === '-') { - this.lexer.advance(); // Skip '-' - this.lexer.skipWhitespace(); - - const itemStart = this.lexer.getCurrentPosition(); - - // Check if this is a nested structure - if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') { - // Empty item - check if next lines form a nested structure - this.lexer.advanceLine(); - - if (!this.lexer.isAtEnd()) { - const nextIndent = this.lexer.getIndentation(); - - if (nextIndent > currentIndent) { - // Check if the next line starts with a dash (nested array) or has properties (nested object) - this.lexer.skipWhitespace(); - if (this.lexer.getCurrentChar() === '-') { - // It's a nested array - const nestedArray = this.parseBlockArray(nextIndent); - items.push(nestedArray); - } else { - // Check if it looks like an object property (has a colon) - const currentLine = this.lexer.getCurrentLineText(); - const currentPos = this.lexer.getCurrentCharNumber(); - const remainingLine = currentLine.substring(currentPos); - - if (remainingLine.includes(':') && !remainingLine.trim().startsWith('#')) { - // It's a nested object - const nestedObject = this.parseBlockObject(nextIndent, this.lexer.getCurrentCharNumber()); - items.push(nestedObject); - } else { - // Not a nested structure, create empty string - items.push(createStringNode('', itemStart, itemStart)); - } - } - } else { - // No nested content, empty item - items.push(createStringNode('', itemStart, itemStart)); - } - } else { - // End of input, empty item - items.push(createStringNode('', itemStart, itemStart)); - } - } else { - // Parse the item value - // Check if this is a multi-line object by looking for a colon and checking next lines - const currentLine = this.lexer.getCurrentLineText(); - const currentPos = this.lexer.getCurrentCharNumber(); - const remainingLine = currentLine.substring(currentPos); - - // Check if there's a colon on this line (indicating object properties) - const hasColon = remainingLine.includes(':'); - - if (hasColon) { - // Any line with a colon should be treated as an object - // Parse as an object with the current item's indentation as the base - const item = this.parseBlockObject(itemStart.character, itemStart.character); - items.push(item); - } else { - // No colon, parse as regular value - const item = this.parseValue(); - items.push(item); - - // Skip to end of line - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== '#') { - this.lexer.advance(); - } - this.lexer.advanceLine(); - } - } - } else { - // No dash found at expected indent level, break - break; - } - } - - // Calculate end position based on the last item - let end = start; - if (items.length > 0) { - const lastItem = items[items.length - 1]; - end = lastItem.end; - } else { - // If no items, end is right after the start - end = createPosition(start.line, start.character + 1); - } - - return createArrayNode(items, start, end); - } - - parseBlockObject(baseIndent: number, baseCharPosition?: number): YamlObjectNode { - const start = this.lexer.getCurrentPosition(); - const properties: { key: YamlStringNode; value: YamlNode }[] = []; - const localKeysSeen = new Set(); - - // For parsing from current position (inline object parsing) - const fromCurrentPosition = baseCharPosition !== undefined; - let firstIteration = true; - - while (!this.lexer.isAtEnd()) { - if (!firstIteration || !fromCurrentPosition) { - this.lexer.moveToNextNonEmptyLine(); - } - firstIteration = false; - - if (this.lexer.isAtEnd()) { - break; - } - - const currentIndent = this.lexer.getIndentation(); - - if (fromCurrentPosition) { - // For current position parsing, check character position alignment - this.lexer.skipWhitespace(); - const currentCharPosition = this.lexer.getCurrentCharNumber(); - - if (currentCharPosition < baseCharPosition) { - break; - } - } else { - // For normal block parsing, check indentation level - if (currentIndent < baseIndent) { - break; - } - - // Check for incorrect indentation - if (currentIndent > baseIndent) { - const lineStart = createPosition(this.lexer.getCurrentLineNumber(), 0); - const lineEnd = createPosition(this.lexer.getCurrentLineNumber(), this.lexer.getCurrentLineText().length); - this.addError('Unexpected indentation', 'indentation', lineStart, lineEnd); - - // Try to recover by treating it as a property anyway - this.lexer.skipWhitespace(); - } else { - this.lexer.skipWhitespace(); - } - } - - // Parse key - const keyStart = this.lexer.getCurrentPosition(); - let keyValue = ''; - - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== ':') { - keyValue += this.lexer.advance(); - } - - keyValue = keyValue.trim(); - const keyEnd = this.lexer.getCurrentPosition(); - const key = createStringNode(keyValue, keyStart, keyEnd); - - // Check for duplicate keys - if (!this.options.allowDuplicateKeys && localKeysSeen.has(keyValue)) { - this.addError(`Duplicate key '${keyValue}'`, 'duplicateKey', keyStart, keyEnd); - } - localKeysSeen.add(keyValue); - - // Expect colon - if (this.lexer.getCurrentChar() === ':') { - this.lexer.advance(); - } - - this.lexer.skipWhitespace(); - - // Determine if value is on same line or next line(s) - let value: YamlNode; - const valueStart = this.lexer.getCurrentPosition(); - - if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') { - // Value is on next line(s) or empty - this.lexer.advanceLine(); - - // Check next line for nested content - if (!this.lexer.isAtEnd()) { - const nextIndent = this.lexer.getIndentation(); - - if (nextIndent > currentIndent) { - // Nested content - determine if it's an object, array, or just a scalar value - this.lexer.skipWhitespace(); - - if (this.lexer.getCurrentChar() === '-') { - value = this.parseBlockArray(nextIndent); - } else { - // Check if this looks like an object property (has a colon) - const currentLine = this.lexer.getCurrentLineText(); - const currentPos = this.lexer.getCurrentCharNumber(); - const remainingLine = currentLine.substring(currentPos); - - if (remainingLine.includes(':') && !remainingLine.trim().startsWith('#')) { - // It's a nested object - value = this.parseBlockObject(nextIndent); - } else { - // It's just a scalar value on the next line - value = this.parseValue(); - } - } - } else if (!fromCurrentPosition && nextIndent === currentIndent) { - // Same indentation level - check if it's an array item - this.lexer.skipWhitespace(); - - if (this.lexer.getCurrentChar() === '-') { - value = this.parseBlockArray(currentIndent); - } else { - value = createStringNode('', valueStart, valueStart); - } - } else { - value = createStringNode('', valueStart, valueStart); - } - } else { - value = createStringNode('', valueStart, valueStart); - } - } else { - // Value is on the same line - value = this.parseValue(); - - // Skip any remaining content on this line (comments, etc.) - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== '#') { - if (isWhitespace(this.lexer.getCurrentChar())) { - this.lexer.advance(); + // Count empty lines (lines with only whitespace) + let emptyLineCount = 0; + while (this.pos < this.input.length) { + // Skip whitespace at start of line + this.skipInlineWhitespace(); + // Check if this line is empty (another newline follows) + const c = this.input[this.pos]; + if (c === '\n' || c === '\r') { + emptyLineCount++; + this.consumeNewline(); } else { break; } } - // Skip to end of line if we hit a comment - if (this.lexer.getCurrentChar() === '#') { - this.lexer.skipToEndOfLine(); - } - - // Move to next line for next iteration - if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() === '') { - this.lexer.advanceLine(); + // Apply folding: empty lines → \n each, otherwise single newline → space + if (emptyLineCount > 0) { + value += '\n'.repeat(emptyLineCount); + } else { + value += ' '; } + continue; } - properties.push({ key, value }); + // Track literal whitespace for folding purposes + if (ch === ' ' || ch === '\t') { + trailingLiteralWs++; + } else { + trailingLiteralWs = 0; + } + value += ch; + this.pos++; } - // Calculate the end position based on the last property - let end = start; - if (properties.length > 0) { - const lastProperty = properties[properties.length - 1]; - end = lastProperty.value.end; - } - - return createObjectNode(properties, start, end); + // Unterminated string - emit what we have + const rawValue = this.input.substring(start, this.pos); + this.tokens.push(makeToken(TokenType.Scalar, start, this.pos, { + rawValue, + value, + format: quote === '\'' ? 'single' : 'double', + })); } - parse(): YamlNode | undefined { - if (this.lexer.isAtEnd()) { - return undefined; - } + private scanUnquotedScalar(): void { + const start = this.pos; + let end = this.pos; - this.lexer.moveToNextNonEmptyLine(); + while (this.pos < this.input.length) { + const ch = this.input[this.pos]; + // Stop at newline + if (ch === '\n' || ch === '\r') { break; } + // Stop at flow indicators (only inside flow collections) + if (this.flowDepth > 0 && (ch === ',' || ch === '}' || ch === ']')) { break; } + if (this.flowDepth > 0 && (ch === '{' || ch === '[')) { break; } + // Stop at ': ' or ':' at end-of-line (mapping value indicator) + if (ch === ':' && this.isBlockColon()) { break; } + // Stop at ' #' (comment) + if (ch === '#' && this.pos > start && (this.input[this.pos - 1] === ' ' || this.input[this.pos - 1] === '\t')) { break; } - if (this.lexer.isAtEnd()) { - return undefined; - } - - // Determine the root structure type - this.lexer.skipWhitespace(); - - if (this.lexer.getCurrentChar() === '-') { - // Check if this is an array item or a negative number - // Look at the character after the dash - const nextChar = this.lexer.peek(); - if (nextChar === ' ' || nextChar === '\t' || nextChar === '' || nextChar === '#') { - // It's an array item (dash followed by whitespace/end/comment) - return this.parseBlockArray(0); - } else { - // It's likely a negative number or other value, treat as single value - return this.parseValue(); + this.pos++; + // Track the last non-whitespace position to trim trailing whitespace + if (ch !== ' ' && ch !== '\t') { + end = this.pos; } - } else if (this.lexer.getCurrentChar() === '[') { - // Root is an inline array - return this.parseInlineArray(); - } else if (this.lexer.getCurrentChar() === '{') { - // Root is an inline object - return this.parseInlineObject(); - } else { - // Check if this looks like a key-value pair by looking for a colon - // For single values, there shouldn't be a colon - const currentLine = this.lexer.getCurrentLineText(); - const currentPos = this.lexer.getCurrentCharNumber(); - const remainingLine = currentLine.substring(currentPos); + } - // Check if there's a colon that's not inside quotes - let hasColon = false; - let inQuotes = false; - let quoteChar = ''; + const rawValue = this.input.substring(start, end); + this.tokens.push(makeToken(TokenType.Scalar, start, end, { + rawValue, + value: rawValue, + format: 'none', + })); + } - for (let i = 0; i < remainingLine.length; i++) { - const char = remainingLine[i]; + /** + * Check if '|' or '>' at the current position is a block scalar indicator. + * Must be followed by optional indentation/chomping indicators, optional comment, then newline. + */ + private isBlockScalarStart(): boolean { + let p = this.pos + 1; + // Skip optional indentation indicator (digit 1-9) and chomping indicator (+/-) + while (p < this.input.length) { + const c = this.input[p]; + if (c >= '1' && c <= '9') { p++; continue; } + if (c === '+' || c === '-') { p++; continue; } + break; + } + // Skip optional whitespace + while (p < this.input.length && (this.input[p] === ' ' || this.input[p] === '\t')) { p++; } + // Must be at newline, EOF, or comment + if (p >= this.input.length) { return true; } + const c = this.input[p]; + return c === '\n' || c === '\r' || c === '#'; + } - if (!inQuotes && (char === '"' || char === `'`)) { - inQuotes = true; - quoteChar = char; - } else if (inQuotes && char === quoteChar) { - inQuotes = false; - quoteChar = ''; - } else if (!inQuotes && char === ':') { - hasColon = true; - break; - } else if (!inQuotes && char === '#') { - // Comment starts, stop looking + /** + * Scan a block scalar (literal '|' or folded '>'). + * Parses the header line for indentation indicator and chomping mode, + * then collects all content lines that are indented beyond the detected indentation. + */ + private scanBlockScalar(style: '|' | '>'): void { + const start = this.pos; + this.pos++; // skip '|' or '>' + + // Parse header: optional indentation indicator (1-9) and chomping indicator (+/-) + let explicitIndent = 0; + let chomping: 'clip' | 'strip' | 'keep' = 'clip'; + + // The order of indent indicator and chomping indicator can vary (D83L test) + for (let i = 0; i < 2; i++) { + if (this.pos < this.input.length) { + const c = this.input[this.pos]; + if (c >= '1' && c <= '9' && explicitIndent === 0) { + explicitIndent = parseInt(c, 10); + this.pos++; + } else if (c === '-' && chomping === 'clip') { + chomping = 'strip'; + this.pos++; + } else if (c === '+' && chomping === 'clip') { + chomping = 'keep'; + this.pos++; + } + } + } + + // Skip any trailing whitespace on the header line + while (this.pos < this.input.length && (this.input[this.pos] === ' ' || this.input[this.pos] === '\t')) { + this.pos++; + } + + // Skip optional comment on header line + if (this.pos < this.input.length && this.input[this.pos] === '#') { + while (this.pos < this.input.length && this.input[this.pos] !== '\n' && this.input[this.pos] !== '\r') { + this.pos++; + } + } + + // Skip the header line's newline + this.consumeNewline(); + + // Determine the parent block's indentation level. + // Per YAML spec 8.1.1.1, content indentation = parent_block_indent + N + // where N is the explicit indent indicator (or auto-detected). + // Also used to establish a minimum content indent for auto-detection. + const parentBlockIndent = this.getParentBlockIndent(start); + + // Compute the content indentation level + let contentIndent = explicitIndent > 0 ? parentBlockIndent + explicitIndent : 0; + const lines: string[] = []; + let trailingNewlines = 0; + + while (this.pos < this.input.length) { + const lineStart = this.pos; + + // Count leading spaces on this line (tabs are not valid YAML indentation) + let lineIndent = 0; + while (this.pos < this.input.length && this.input[this.pos] === ' ') { + lineIndent++; + this.pos++; + } + + // Check if this is an empty or whitespace-only line + if (this.pos >= this.input.length || this.input[this.pos] === '\n' || this.input[this.pos] === '\r') { + if (contentIndent > 0 && lineIndent >= contentIndent) { + // Whitespace-only line with enough indent - preserve excess whitespace + const preserved = this.input.substring(lineStart + contentIndent, this.pos); + lines.push(preserved); + if (preserved === '') { + // Effectively an empty line - counts as trailing + trailingNewlines++; + } else { + trailingNewlines = 0; + } + } else { + // Truly empty line - part of scalar content + lines.push(''); + trailingNewlines++; + } + // Skip newline + this.consumeNewline(); + continue; + } + + // Check for document markers at column 0 - they terminate the block scalar + if (lineIndent === 0 && this.input.length - this.pos >= 3) { + const c0 = this.input[this.pos]; + const c1 = this.input[this.pos + 1]; + const c2 = this.input[this.pos + 2]; + const c3 = this.input[this.pos + 3]; + const isTerm = c3 === undefined || c3 === ' ' || c3 === '\t' || c3 === '\n' || c3 === '\r'; + if ((c0 === '-' && c1 === '-' && c2 === '-' && isTerm) || + (c0 === '.' && c1 === '.' && c2 === '.' && isTerm)) { + this.pos = lineStart; break; } } - if (hasColon) { - // Root is an object - return this.parseBlockObject(0); - } else { - // Root is a single value - return this.parseValue(); + // Auto-detect content indent from first non-empty line. + // Content must be more indented than the parent block. + if (contentIndent === 0) { + if (lineIndent <= parentBlockIndent) { + // Not enough indentation - terminates the block scalar + this.pos = lineStart; + break; + } + contentIndent = lineIndent; + } + + // If this line's indentation is less than the content indent, the block scalar is done + if (lineIndent < contentIndent) { + this.pos = lineStart; + break; + } + + // Read the rest of the line (the content) + const contentStart = lineStart + contentIndent; + while (this.pos < this.input.length && this.input[this.pos] !== '\n' && this.input[this.pos] !== '\r') { + this.pos++; + } + // The line content includes any extra indentation beyond contentIndent + const lineContent = this.input.substring(contentStart, this.pos); + lines.push(lineContent); + trailingNewlines = 0; + + // Skip newline + this.consumeNewline(); + } + + // Process the collected lines according to the block scalar style + let value: string; + if (style === '|') { + // Literal: join lines with newlines (preserving all line breaks as-is) + value = lines.join('\n'); + } else { + // Folded: per YAML spec, line breaks between adjacent non-more-indented + // content lines are folded into spaces. More-indented lines preserve breaks. + // Empty lines produce \n each. The break from content into an empty run + // is "trimmed" (absorbed) for non-more-indented lines, but preserved + // for more-indented lines. + value = ''; + let lastNonEmptyIsMoreIndented = false; + let inEmptyRun = false; + let seenNonEmpty = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isMoreIndented = line.length > 0 && (line[0] === ' ' || line[0] === '\t'); + + if (line === '') { + // Empty line → contributes one \n + value += '\n'; + inEmptyRun = true; + } else if (i === 0) { + value = line; + lastNonEmptyIsMoreIndented = isMoreIndented; + seenNonEmpty = true; + } else if (inEmptyRun) { + // Transitioning from empty lines back to content. + // If the previous content or current line is more-indented + // AND we've seen content before, the break is preserved. + // Otherwise the empties already provided all needed line breaks. + if ((lastNonEmptyIsMoreIndented || isMoreIndented) && seenNonEmpty) { + value += '\n' + line; + } else { + value += line; + } + lastNonEmptyIsMoreIndented = isMoreIndented; + inEmptyRun = false; + seenNonEmpty = true; + } else if (isMoreIndented || lastNonEmptyIsMoreIndented) { + // More-indented line → preserve newline + value += '\n' + line; + lastNonEmptyIsMoreIndented = isMoreIndented; + seenNonEmpty = true; + } else { + // Normal adjacent non-more-indented lines → fold to space + value += ' ' + line; + lastNonEmptyIsMoreIndented = false; + seenNonEmpty = true; + } } } + + // Apply chomping to trailing newlines + if (trailingNewlines > 0) { + // Strip all trailing newlines from the value + let end = value.length; + while (end > 0 && value[end - 1] === '\n') { + end--; + } + value = value.substring(0, end); + } + + // Determine if there was any actual (non-empty) content + const hasContent = lines.some(l => l !== ''); + + switch (chomping) { + case 'clip': + if (hasContent) { + // Add exactly one trailing newline + value += '\n'; + } + break; + case 'keep': + if (hasContent) { + // Content + trailing: final line break + trailing empty line breaks + value += '\n'.repeat(trailingNewlines + 1); + } else { + // No content, only trailing empties + value = '\n'.repeat(trailingNewlines); + } + break; + case 'strip': + // No trailing newline + break; + } + + const rawValue = this.input.substring(start, this.pos); + this.tokens.push(makeToken(TokenType.Scalar, start, this.pos, { + rawValue, + value, + format: style === '|' ? 'literal' : 'folded', + })); + } + + /** + * Determine the parent block's indentation level for a block scalar. + * Looks at preceding tokens to find the context: + * - After Colon: the indentation of the line containing the mapping key + * - After Dash: the column of the dash + * - At document level: -1 (allows content at indent 0) + */ + private getParentBlockIndent(blockScalarPos: number): number { + for (let i = this.tokens.length - 1; i >= 0; i--) { + const t = this.tokens[i]; + if (t.type === TokenType.Newline || t.type === TokenType.Comment || t.type === TokenType.Indent) { continue; } + if (t.type === TokenType.Colon) { + // Block scalar is a mapping value. The parent indentation + // is the column of the mapping key (the scalar before the colon). + for (let j = i - 1; j >= 0; j--) { + const kt = this.tokens[j]; + if (kt.type === TokenType.Newline || kt.type === TokenType.Comment || kt.type === TokenType.Indent) { continue; } + // Found the key token - return its column + return this.getColumnAt(kt.startOffset); + } + return 0; + } + if (t.type === TokenType.Dash) { + // Block scalar is a sequence item. Parent indent = column of the dash. + return this.getColumnAt(t.startOffset); + } + // Document root - content at indent 0 is valid + if (t.type === TokenType.DocumentStart) { return -1; } + // For any other token, use 0 + break; + } + return 0; + } + + /** + * Get the column (0-based offset from start of line) for a position in the input. + */ + private getColumnAt(offset: number): number { + let col = 0; + let p = offset - 1; + while (p >= 0 && this.input[p] !== '\n' && this.input[p] !== '\r') { + col++; + p--; + } + return col; + } + + private scanComment(): void { + const start = this.pos; + while (this.pos < this.input.length && this.input[this.pos] !== '\n' && this.input[this.pos] !== '\r') { + this.pos++; + } + this.tokens.push(makeToken(TokenType.Comment, start, this.pos, { + rawValue: this.input.substring(start, this.pos), + value: this.input.substring(start, this.pos), + })); + } + + private scanNewline(): void { + const start = this.pos; + if (this.consumeNewline()) { + this.tokens.push(makeToken(TokenType.Newline, start, this.pos)); + } + } + + private skipInlineWhitespace(): void { + while (this.pos < this.input.length) { + const ch = this.input[this.pos]; + if (ch === ' ' || ch === '\t') { + this.pos++; + } else { + break; + } + } + } + + /** Advance past a newline sequence (\r\n, \n, or \r). Returns true if a newline was consumed. */ + private consumeNewline(): boolean { + if (this.pos >= this.input.length) { return false; } + if (this.input[this.pos] === '\r' && this.input[this.pos + 1] === '\n') { + this.pos += 2; + return true; + } + if (this.input[this.pos] === '\n' || this.input[this.pos] === '\r') { + this.pos++; + return true; + } + return false; + } + + private peekChar(): string { + return this.input[this.pos]; } } +// -- Parser ------------------------------------------------------------------ +class YamlParser { + private pos = 0; + + constructor( + private readonly tokens: Token[], + private readonly input: string, + private readonly errors: YamlParseError[], + private readonly options: ParseOptions, + ) { } + + parse(): YamlNode | undefined { + this.skipNewlinesAndComments(); + // Skip document start marker (---) if present + if (this.currentToken().type === TokenType.DocumentStart) { + this.advance(); + this.skipNewlinesAndComments(); + } + if (this.currentToken().type === TokenType.EOF || this.currentToken().type === TokenType.DocumentEnd) { + return undefined; + } + const result = this.parseValue(-1); + return result; + } + + // -- helpers ---------------------------------------------------------- + + private currentToken(): Token { + return this.tokens[this.pos]; + } + + private peek(offset = 0): Token { + return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)]; + } + + private advance(): Token { + const t = this.tokens[this.pos]; + if (t.type !== TokenType.EOF) { + this.pos++; + } + return t; + } + + private expect(type: TokenType): Token { + const t = this.currentToken(); + if (t.type === type) { + return this.advance(); + } + return t; + } + + private emitError(message: string, startOffset: number, endOffset: number, code: string): void { + this.errors.push({ message, startOffset, endOffset, code }); + } + + private skipNewlinesAndComments(): void { + while ( + this.currentToken().type === TokenType.Newline || + this.currentToken().type === TokenType.Comment || + (this.currentToken().type === TokenType.Indent && this.isFollowedByNewlineOrComment()) + ) { + this.advance(); + } + } + + /** Returns true if the current Indent token is followed immediately by Newline/Comment/EOF */ + private isFollowedByNewlineOrComment(): boolean { + const next = this.peek(1); + return next.type === TokenType.Newline || next.type === TokenType.Comment || next.type === TokenType.EOF; + } + + /** + * Determines the current indentation level. + * If the current token is an Indent, returns its indent value. + * Otherwise returns 0 (token is at column 0). + */ + private currentIndent(): number { + if (this.currentToken().type === TokenType.Indent) { + return this.currentToken().indent; + } + return 0; + } + + // -- Main parse entry for a value at a given indentation -------------- + + private parseValue(parentIndent: number): YamlNode | undefined { + this.skipNewlinesAndComments(); + const token = this.currentToken(); + + // Flow collections (also check past indent) + const flowToken = token.type === TokenType.Indent ? this.peek(1) : token; + if (flowToken.type === TokenType.FlowMapStart || flowToken.type === TokenType.FlowSeqStart) { + if (token.type === TokenType.Indent) { this.advance(); } + if (flowToken.type === TokenType.FlowMapStart) { return this.parseFlowMap(); } + return this.parseFlowSeq(); + } + + // Block-level: detect if this is a sequence or mapping + const indent = this.currentIndent(); + + // Determine what the first meaningful token is at this indent + const firstContentToken = this.peekPastIndent(); + + if (firstContentToken.type === TokenType.Dash) { + return this.parseBlockSequence(indent); + } + + // Check if this looks like a mapping (scalar followed by colon) + if (this.looksLikeMapping()) { + return this.parseBlockMapping(indent); + } + + // Otherwise it's a scalar + if (token.type === TokenType.Scalar || token.type === TokenType.Indent) { + return this.parseScalar(parentIndent); + } + + return undefined; + } + + /** Peek past an optional Indent token to see the first content token */ + private peekPastIndent(): Token { + if (this.currentToken().type === TokenType.Indent) { + return this.peek(1); + } + return this.currentToken(); + } + + /** Check if tokens at current position look like a mapping entry (key: value) */ + private looksLikeMapping(): boolean { + let offset = 0; + if (this.peek(offset).type === TokenType.Indent) { offset++; } + if (this.peek(offset).type === TokenType.Scalar) { + offset++; + if (this.peek(offset).type === TokenType.Colon) { return true; } + } + return false; + } + + // -- Scalar ---------------------------------------------------------- + + private parseScalar(parentIndent: number = -1): YamlScalarNode { + // Skip indent if present + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } + const token = this.expect(TokenType.Scalar); + // Quoted scalars are complete as-is (scanner handles their multiline) + if (token.format !== 'none') { + return this.scalarFromToken(token); + } + // For unquoted (plain) scalars, check for multiline continuation + return this.parsePlainMultiline(token, parentIndent); + } + + /** + * Parse a multiline plain scalar. The first line's token is already consumed. + * Continuation lines must be indented deeper than `parentIndent`. + * Line folding rules: + * - Single line break → space + * - Each empty line → preserved as \n + */ + private parsePlainMultiline(firstToken: Token, parentIndent: number): YamlScalarNode { + let value = firstToken.value; + let endOffset = firstToken.endOffset; + + while (true) { + // Save position to backtrack if continuation is not valid + const savedPos = this.pos; + + // Count empty lines (newlines with only whitespace between) + let emptyLineCount = 0; + let foundContent = false; + + while (this.pos < this.tokens.length) { + const t = this.currentToken(); + if (t.type === TokenType.Comment) { + // Comment terminates a plain scalar + break; + } + if (t.type === TokenType.Newline) { + this.advance(); + // Check if the next thing after this newline is blank or content + const afterNewline = this.currentToken(); + if (afterNewline.type === TokenType.Newline) { + // Another newline means an empty line + emptyLineCount++; + continue; + } + if (afterNewline.type === TokenType.Indent) { + // Check what follows the indent + const afterIndent = this.peek(1); + if (afterIndent.type === TokenType.Newline || afterIndent.type === TokenType.EOF) { + // Indent followed by newline = empty line + emptyLineCount++; + this.advance(); // skip the indent + continue; + } + if (afterIndent.type === TokenType.Comment) { + // Comment terminates scalar + break; + } + // Content on this line - check indentation + if (afterNewline.indent > parentIndent) { + // Valid continuation line + foundContent = true; + break; + } else { + // Not deep enough - not a continuation + break; + } + } + if (afterNewline.type === TokenType.EOF) { + break; + } + // Document markers terminate plain scalars + if (afterNewline.type === TokenType.DocumentStart || afterNewline.type === TokenType.DocumentEnd) { + break; + } + // Content at column 0 + if (parentIndent < 0) { + // Top-level: column 0 is valid continuation for parentIndent = -1 + foundContent = true; + break; + } + break; + } + if (t.type === TokenType.Indent) { + // We should only get here at the very start of lookahead when + // the first token after the scalar's end is Indent (no newline before it), + // which shouldn't happen. Break to be safe. + break; + } + // Any other token (EOF, structural) = end of scalar + break; + } + + if (!foundContent) { + // No continuation found - restore position + this.pos = savedPos; + break; + } + + // We found a continuation line. Skip optional indent. + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } + + // The next token must be a Scalar for continuation + if (this.currentToken().type !== TokenType.Scalar) { + // A dash at a deeper indent than the parent is text content, not a sequence indicator + // (e.g., "- single multiline\n - sequence entry" → one scalar "single multiline - sequence entry") + if (this.currentToken().type === TokenType.Dash) { + const dashToken = this.advance(); + let lineText = '-'; + if (this.currentToken().type === TokenType.Scalar) { + const restToken = this.advance(); + lineText = '- ' + restToken.value; + endOffset = restToken.endOffset; + } else { + endOffset = dashToken.endOffset; + } + if (emptyLineCount > 0) { + value += '\n'.repeat(emptyLineCount); + } else { + value += ' '; + } + value += lineText; + continue; + } + // Not a scalar continuation (could be Colon, etc.) + this.pos = savedPos; + break; + } + + // Check that this line doesn't look like a mapping key (scalar followed by colon) + // which would mean the scalar ended and a new mapping entry starts + if (this.peek(1).type === TokenType.Colon) { + this.pos = savedPos; + break; + } + + const contToken = this.advance(); + + // Apply line folding: empty lines become \n, single line break becomes space + if (emptyLineCount > 0) { + value += '\n'.repeat(emptyLineCount); + } else { + value += ' '; + } + value += contToken.value; + endOffset = contToken.endOffset; + } + + return { + type: 'scalar', + value, + rawValue: this.input.substring(firstToken.startOffset, endOffset), + startOffset: firstToken.startOffset, + endOffset, + format: 'none', + }; + } + + // -- Block mapping --------------------------------------------------- + + private parseBlockMapping(baseIndent: number, inlineFirstEntry = false): YamlMapNode { + const startOffset = this.currentToken().startOffset; + const properties: { key: YamlScalarNode; value: YamlNode }[] = []; + const seenKeys = new Set(); + + // When called after a sequence dash, the first key is already at the current position + if (inlineFirstEntry) { + const firstEntry = this.parseMappingEntry(baseIndent); + if (firstEntry) { + seenKeys.add(firstEntry.key.value); + properties.push(firstEntry); + } + } + + while (this.currentToken().type !== TokenType.EOF) { + this.skipNewlinesAndComments(); + if (this.currentToken().type === TokenType.EOF) { break; } + + const indent = this.currentIndent(); + if (indent < baseIndent) { break; } + if (indent !== baseIndent) { + if (indent > baseIndent) { + this.emitError( + localize('unexpectedIndentation', 'Unexpected indentation (expected {0}, got {1})', baseIndent, indent), + this.currentToken().startOffset, + this.currentToken().endOffset, + 'unexpected-indentation', + ); + } else { + break; + } + } + if (!this.looksLikeMapping()) { break; } + + const entry = this.parseMappingEntry(baseIndent); + if (!entry) { break; } + + if (!this.options.allowDuplicateKeys && seenKeys.has(entry.key.value)) { + this.emitError( + localize('duplicateKey', 'Duplicate key: "{0}"', entry.key.value), + entry.key.startOffset, + entry.key.endOffset, + 'duplicate-key', + ); + } + seenKeys.add(entry.key.value); + properties.push(entry); + } + + const endOffset = properties.length > 0 ? properties[properties.length - 1].value.endOffset : startOffset; + return { type: 'map', properties, style: 'block', startOffset, endOffset }; + } + + private parseMappingEntry(baseIndent: number): { key: YamlScalarNode; value: YamlNode } | undefined { + // Skip indent + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } + + // Parse key + const keyToken = this.expect(TokenType.Scalar); + const key = this.scalarFromToken(keyToken); + + // Expect colon + const colon = this.expect(TokenType.Colon); + if (colon.type !== TokenType.Colon) { + this.emitError(localize('expectedColon', 'Expected ":"'), colon.startOffset, colon.endOffset, 'expected-colon'); + return undefined; + } + + // Parse value: could be on same line or next line (indented) + const value = this.parseMappingValue(baseIndent, colon); + + return { key, value }; + } + + private parseMappingValue(baseIndent: number, colonToken: Token): YamlNode { + // Check if there's a value on the same line after the colon + const next = this.currentToken(); + + // Same-line flow collections + if (next.type === TokenType.FlowMapStart) { return this.parseFlowMap(); } + if (next.type === TokenType.FlowSeqStart) { return this.parseFlowSeq(); } + + // Same-line scalar (may be multiline with continuation) + if (next.type === TokenType.Scalar) { + // Skip indent if present (shouldn't be here, but be safe) + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } + const token = this.advance(); + if (token.format !== 'none') { + return this.scalarFromToken(token); + } + // Plain scalar - allow multiline continuation deeper than baseIndent + return this.parsePlainMultiline(token, baseIndent); + } + + // Value is on the next line (skip newlines/comments and check indentation) + this.skipNewlinesAndComments(); + const afterNewline = this.currentToken(); + + if (afterNewline.type === TokenType.EOF) { + // Missing value at end of input + this.emitError(localize('missingValue', 'Missing value'), colonToken.startOffset, colonToken.endOffset, 'missing-value'); + return this.makeEmptyScalar(colonToken.endOffset); + } + + const nextIndent = this.currentIndent(); + + // Special case: a sequence at the same indent as the mapping key is allowed + // as the mapping value (e.g., "foo:\n- 42") + if (nextIndent === baseIndent && this.peekPastIndent().type === TokenType.Dash) { + return this.parseValue(baseIndent) ?? this.makeEmptyScalar(colonToken.endOffset); + } + + if (nextIndent <= baseIndent) { + // No deeper indentation → missing value + this.emitError(localize('missingValue', 'Missing value'), colonToken.startOffset, colonToken.endOffset, 'missing-value'); + return this.makeEmptyScalar(colonToken.endOffset); + } + + // Parse the nested value + return this.parseValue(baseIndent) ?? this.makeEmptyScalar(colonToken.endOffset); + } + + // -- Block sequence -------------------------------------------------- + + private parseBlockSequence(baseIndent: number): YamlSequenceNode { + const items: YamlNode[] = []; + const startOffset = this.currentToken().startOffset; + let endOffset = startOffset; + let isFirstItem = true; + + while (this.currentToken().type !== TokenType.EOF) { + this.skipNewlinesAndComments(); + if (this.currentToken().type === TokenType.EOF) { break; } + + // For the first item, the dash may be on the same line (no Indent token). + // Compute the actual column to check against baseIndent. + let indent: number; + if (isFirstItem && this.currentToken().type === TokenType.Dash) { + indent = this.currentToken().startOffset - this.getLineStart(this.currentToken().startOffset); + } else { + indent = this.currentIndent(); + } + isFirstItem = false; + + if (indent < baseIndent) { break; } + + if (indent !== baseIndent) { + if (indent > baseIndent) { + this.emitError( + localize('unexpectedIndentation', 'Unexpected indentation (expected {0}, got {1})', baseIndent, indent), + this.currentToken().startOffset, + this.currentToken().endOffset, + 'unexpected-indentation', + ); + } else { + break; + } + } + + const contentToken = this.peekPastIndent(); + if (contentToken.type !== TokenType.Dash) { break; } + + // Skip indent + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } + + // Consume the dash + const dashToken = this.advance(); + + // Parse the item value + const itemValue = this.parseSequenceItemValue(baseIndent, dashToken); + items.push(itemValue); + endOffset = itemValue.endOffset; + } + + return { type: 'sequence', items, style: 'block', startOffset, endOffset }; + } + + private parseSequenceItemValue(baseIndent: number, dashToken: Token): YamlNode { + const next = this.currentToken(); + + // Skip comment after dash + if (next.type === TokenType.Comment) { + this.advance(); + } + + // Flow collections on same line + if (next.type === TokenType.FlowMapStart) { return this.parseFlowMap(); } + if (next.type === TokenType.FlowSeqStart) { return this.parseFlowSeq(); } + + // Nested sequence on same line (e.g., '- - value') + if (next.type === TokenType.Dash) { + // The nested sequence's base indent is the column of the dash + const nestedIndent = next.startOffset - this.getLineStart(next.startOffset); + return this.parseBlockSequence(nestedIndent); + } + + // Inline scalar on same line + if (next.type === TokenType.Scalar) { + // Check if this is actually a mapping (key: value on same line after dash) + if (this.peek(1).type === TokenType.Colon) { + // It's an inline mapping after '- ' like '- name: John' + // The implicit indent for continuation lines is the column of the key + const itemIndent = next.startOffset - this.getLineStart(next.startOffset); + return this.parseBlockMapping(itemIndent, true); + } + return this.parseScalar(baseIndent); + } + + // Value on next line + this.skipNewlinesAndComments(); + if (this.currentToken().type === TokenType.EOF) { + this.emitError(localize('missingSeqItemValue', 'Missing sequence item value'), dashToken.startOffset, dashToken.endOffset, 'missing-value'); + return this.makeEmptyScalar(dashToken.endOffset); + } + + const nextIndent = this.currentIndent(); + if (nextIndent <= baseIndent) { + // Empty item (just a dash) + this.emitError(localize('missingSeqItemValue', 'Missing sequence item value'), dashToken.startOffset, dashToken.endOffset, 'missing-value'); + return this.makeEmptyScalar(dashToken.endOffset); + } + + return this.parseValue(baseIndent) ?? this.makeEmptyScalar(dashToken.endOffset); + } + + /** Calculate the start of the line containing the given offset */ + private getLineStart(offset: number): number { + let i = offset - 1; + while (i >= 0 && this.input[i] !== '\n' && this.input[i] !== '\r') { + i--; + } + return i + 1; + } + + // -- Flow map -------------------------------------------------------- + + private parseFlowMap(): YamlMapNode { + const startToken = this.advance(); // consume '{' + const properties: { key: YamlScalarNode; value: YamlNode }[] = []; + + this.skipFlowWhitespace(); + + while (this.currentToken().type !== TokenType.FlowMapEnd && this.currentToken().type !== TokenType.EOF) { + // Parse key (must be a scalar) + let key: YamlScalarNode; + if (this.currentToken().type === TokenType.Scalar) { + key = this.parseFlowScalar(); + } else { + this.emitError(localize('expectedMappingKey', 'Expected mapping key'), this.currentToken().startOffset, this.currentToken().endOffset, 'expected-key'); + break; + } + + this.skipFlowWhitespace(); + + // Check for colon - if missing, the key has an empty value (terminated by comma or }) + let value: YamlNode; + if (this.currentToken().type === TokenType.Colon) { + this.advance(); + this.skipFlowWhitespace(); + + // Parse value + value = this.parseFlowValue(); + } else { + // Key without value (e.g., { key, other: val }) + value = this.makeEmptyScalar(key.endOffset); + } + + properties.push({ key, value }); + + this.skipFlowWhitespace(); + + // Consume comma if present + if (this.currentToken().type === TokenType.Comma) { + this.advance(); + this.skipFlowWhitespace(); + } + } + + const endToken = this.currentToken(); + if (endToken.type === TokenType.FlowMapEnd) { + this.advance(); + } else { + this.emitError(localize('expectedFlowMapEnd', 'Expected "}"'), endToken.startOffset, endToken.endOffset, 'expected-flow-map-end'); + } + + return { + type: 'map', + properties, + style: 'flow', + startOffset: startToken.startOffset, + endOffset: endToken.type === TokenType.FlowMapEnd ? endToken.endOffset : endToken.startOffset, + }; + } + + // -- Flow sequence --------------------------------------------------- + + private parseFlowSeq(): YamlSequenceNode { + const startToken = this.advance(); // consume '[' + const items: YamlNode[] = []; + + this.skipFlowWhitespace(); + + while (this.currentToken().type !== TokenType.FlowSeqEnd && this.currentToken().type !== TokenType.EOF) { + let item: YamlNode; + if (this.currentToken().type === TokenType.FlowMapStart) { + item = this.parseFlowMap(); + } else if (this.currentToken().type === TokenType.FlowSeqStart) { + item = this.parseFlowSeq(); + } else if (this.currentToken().type === TokenType.Scalar) { + item = this.parseFlowScalar(); + } else { + this.emitError(localize('unexpectedTokenInFlowSeq', 'Unexpected token in flow sequence'), this.currentToken().startOffset, this.currentToken().endOffset, 'unexpected-token'); + this.advance(); + continue; + } + + items.push(item); + this.skipFlowWhitespace(); + + if (this.currentToken().type === TokenType.Comma) { + this.advance(); + this.skipFlowWhitespace(); + } + } + + const endToken = this.currentToken(); + if (endToken.type === TokenType.FlowSeqEnd) { + this.advance(); + } else { + this.emitError(localize('expectedFlowSeqEnd', 'Expected "]"'), endToken.startOffset, endToken.endOffset, 'expected-flow-seq-end'); + } + + return { + type: 'sequence', + items, + style: 'flow', + startOffset: startToken.startOffset, + endOffset: endToken.type === TokenType.FlowSeqEnd ? endToken.endOffset : endToken.startOffset, + }; + } + + /** + * Parse a scalar inside a flow collection, handling multiline plain scalars. + * In flow context, plain (unquoted) scalars can span multiple lines; + * line breaks are folded into spaces. + */ + private parseFlowScalar(): YamlScalarNode { + const token = this.advance(); + // Quoted scalars are complete as-is (scanner handles their multiline folding) + if (token.format !== 'none') { + return this.scalarFromToken(token); + } + // For unquoted (plain) scalars, fold continuation lines across newlines + let value = token.value; + let endOffset = token.endOffset; + + while (true) { + // Look ahead for a newline followed by a plain scalar continuation + let hasNewline = false; + let p = this.pos; + while (p < this.tokens.length) { + const t = this.tokens[p]; + if (t.type === TokenType.Newline) { + hasNewline = true; + p++; + } else if (t.type === TokenType.Indent || t.type === TokenType.Comment) { + p++; + } else { + break; + } + } + + if (!hasNewline || p >= this.tokens.length) { break; } + + const nextToken = this.tokens[p]; + if (nextToken.type === TokenType.Scalar && nextToken.format === 'none') { + // Fold continuation line into the scalar + this.pos = p + 1; + value += ' ' + nextToken.value; + endOffset = nextToken.endOffset; + } else { + break; + } + } + + return { + type: 'scalar', + value, + rawValue: this.input.substring(token.startOffset, endOffset), + startOffset: token.startOffset, + endOffset, + format: 'none', + }; + } + + /** Parse a value in flow context (used after colon in flow mappings/implicit mappings) */ + private parseFlowValue(): YamlNode { + if (this.currentToken().type === TokenType.FlowMapStart) { + return this.parseFlowMap(); + } else if (this.currentToken().type === TokenType.FlowSeqStart) { + return this.parseFlowSeq(); + } else if (this.currentToken().type === TokenType.Scalar) { + return this.parseFlowScalar(); + } else { + return this.makeEmptyScalar(this.currentToken().startOffset); + } + } + + /** Skip whitespace, newlines, and comments inside flow collections */ + private skipFlowWhitespace(): void { + while (true) { + const t = this.currentToken().type; + if (t === TokenType.Newline || t === TokenType.Indent || t === TokenType.Comment) { + this.advance(); + } else { + break; + } + } + } + + private scalarFromToken(token: Token): YamlScalarNode { + return { + type: 'scalar', + value: token.value, + rawValue: token.rawValue, + startOffset: token.startOffset, + endOffset: token.endOffset, + format: token.format, + }; + } + + private makeEmptyScalar(offset: number): YamlScalarNode { + return { + type: 'scalar', + value: '', + rawValue: '', + startOffset: offset, + endOffset: offset, + format: 'none', + }; + } +} diff --git a/src/vs/base/test/common/yaml.test.ts b/src/vs/base/test/common/yaml.test.ts index c6e3a53e7cc..3c78fd291d5 100644 --- a/src/vs/base/test/common/yaml.test.ts +++ b/src/vs/base/test/common/yaml.test.ts @@ -2,1173 +2,1131 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { deepStrictEqual, strictEqual, ok } from 'assert'; -import { parse, ParseOptions, YamlParseError, Position, YamlNode } from '../../common/yaml.js'; + +import * as assert from 'assert'; +import { parse, YamlNode, YamlScalarNode, YamlMapNode, YamlSequenceNode, YamlParseError } from '../../common/yaml.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; - -function assertValidParse(input: string[], expected: YamlNode, expectedErrors: YamlParseError[], options?: ParseOptions): void { +// Helper to parse and assert no errors +function parseOk(input: string): YamlNode | undefined { const errors: YamlParseError[] = []; - const text = input.join('\n'); - const actual1 = parse(text, errors, options); - deepStrictEqual(actual1, expected); - deepStrictEqual(errors, expectedErrors); + const result = parse(input, errors); + assert.deepStrictEqual(errors, [], `Unexpected errors: ${JSON.stringify(errors)}`); + return result; } -function pos(line: number, character: number): Position { - return { line, character }; +// Helper to assert a scalar node and verify its offsets match the raw value in the input +function assertScalar(input: string, node: YamlNode | undefined, expected: { value: string; format?: 'single' | 'double' | 'none' | 'literal' | 'folded' }): void { + assert.ok(node, 'Expected a node but got undefined'); + assert.strictEqual(node.type, 'scalar'); + const scalar = node as YamlScalarNode; + assert.strictEqual(scalar.value, expected.value); + if (expected.format !== undefined) { + assert.strictEqual(scalar.format, expected.format); + } + // Verify that the offsets correctly correspond to the rawValue in the input + assert.strictEqual( + input.substring(scalar.startOffset, scalar.endOffset), + scalar.rawValue, + `Offset mismatch: input[${scalar.startOffset}..${scalar.endOffset}] is "${input.substring(scalar.startOffset, scalar.endOffset)}" but rawValue is "${scalar.rawValue}"` + ); +} + +// Helper to assert a map node and return properties for further assertions +function assertMap(node: YamlNode | undefined, expectedKeyCount: number): YamlMapNode { + assert.ok(node, 'Expected a node but got undefined'); + assert.strictEqual(node.type, 'map', `Expected map but got ${node.type}`); + const map = node as YamlMapNode; + assert.strictEqual(map.properties.length, expectedKeyCount, `Expected ${expectedKeyCount} properties but got ${map.properties.length}`); + return map; +} + +// Helper to assert a sequence node and return items +function assertSequence(node: YamlNode | undefined, expectedItemCount: number): YamlSequenceNode { + assert.ok(node, 'Expected a node but got undefined'); + assert.strictEqual(node.type, 'sequence', `Expected sequence but got ${node.type}`); + const seq = node as YamlSequenceNode; + assert.strictEqual(seq.items.length, expectedItemCount, `Expected ${expectedItemCount} items but got ${seq.items.length}`); + return seq; } suite('YAML Parser', () => { ensureNoDisposablesAreLeakedInTestSuite(); - suite('scalars', () => { - - test('numbers', () => { - assertValidParse(['1'], { type: 'number', start: pos(0, 0), end: pos(0, 1), value: 1 }, []); - assertValidParse(['1.234'], { type: 'number', start: pos(0, 0), end: pos(0, 5), value: 1.234 }, []); - assertValidParse(['-42'], { type: 'number', start: pos(0, 0), end: pos(0, 3), value: -42 }, []); + suite('Empty input', () => { + test('returns undefined for empty string', () => { + assert.strictEqual(parseOk(''), undefined); }); - test('boolean', () => { - assertValidParse(['true'], { type: 'boolean', start: pos(0, 0), end: pos(0, 4), value: true }, []); - assertValidParse(['false'], { type: 'boolean', start: pos(0, 0), end: pos(0, 5), value: false }, []); + test('returns undefined for whitespace-only input', () => { + assert.strictEqual(parseOk(' '), undefined); }); - test('null', () => { - assertValidParse(['null'], { type: 'null', start: pos(0, 0), end: pos(0, 4), value: null }, []); - assertValidParse(['~'], { type: 'null', start: pos(0, 0), end: pos(0, 1), value: null }, []); - }); - - test('string', () => { - assertValidParse(['A Developer'], { type: 'string', start: pos(0, 0), end: pos(0, 11), value: 'A Developer' }, []); - assertValidParse(['\'A Developer\''], { type: 'string', start: pos(0, 0), end: pos(0, 13), value: 'A Developer' }, []); - assertValidParse(['"A Developer"'], { type: 'string', start: pos(0, 0), end: pos(0, 13), value: 'A Developer' }, []); - assertValidParse(['*.js,*.ts'], { type: 'string', start: pos(0, 0), end: pos(0, 9), value: '*.js,*.ts' }, []); + test('returns undefined for newline-only input', () => { + assert.strictEqual(parseOk('\n\n'), undefined); }); }); - suite('objects', () => { - - test('simple properties', () => { - assertValidParse(['name: John Doe'], { - type: 'object', start: pos(0, 0), end: pos(0, 14), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 14), value: 'John Doe' } - } - ] - }, []); - assertValidParse(['age: 30'], { - type: 'object', start: pos(0, 0), end: pos(0, 7), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'age' }, - value: { type: 'number', start: pos(0, 5), end: pos(0, 7), value: 30 } - } - ] - }, []); - assertValidParse(['active: true'], { - type: 'object', start: pos(0, 0), end: pos(0, 12), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'active' }, - value: { type: 'boolean', start: pos(0, 8), end: pos(0, 12), value: true } - } - ] - }, []); - assertValidParse(['value: null'], { - type: 'object', start: pos(0, 0), end: pos(0, 11), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'value' }, - value: { type: 'null', start: pos(0, 7), end: pos(0, 11), value: null } - } - ] - }, []); + suite('Scalars', () => { + test('unquoted scalar', () => { + const input = 'hello world'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'none' }); }); - test('value on next line', () => { - assertValidParse( - [ - 'name:', - ' John Doe', - 'colors:', - ' [ Red, Green, Blue ]', - ], - { - type: 'object', start: pos(0, 0), end: pos(3, 22), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(1, 2), end: pos(1, 10), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(2, 0), end: pos(2, 6), value: 'colors' }, - value: { - type: 'array', start: pos(3, 2), end: pos(3, 22), items: [ - { type: 'string', start: pos(3, 4), end: pos(3, 7), value: 'Red' }, - { type: 'string', start: pos(3, 9), end: pos(3, 14), value: 'Green' }, - { type: 'string', start: pos(3, 16), end: pos(3, 20), value: 'Blue' } - ] - } - } - ] - }, - [] - ); + test('literal block scalar format', () => { + const input = [ + 'text: |', + ' line one', + ' line two', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one\nline two\n', format: 'literal' }); }); - test('multiple properties', () => { - assertValidParse( - [ - 'name: John Doe', - 'age: 30' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 7), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 14), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'age' }, - value: { type: 'number', start: pos(1, 5), end: pos(1, 7), value: 30 } - } - ] - }, - [] - ); + test('folded block scalar format', () => { + const input = [ + 'text: >', + ' line one', + ' line two', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one line two\n', format: 'folded' }); }); - test('nested object', () => { - assertValidParse( - [ - 'person:', - ' name: John Doe', - ' age: 30' - ], - { - type: 'object', start: pos(0, 0), end: pos(2, 9), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'person' }, - value: { - type: 'object', start: pos(1, 2), end: pos(2, 9), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 16), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, - value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } - } - ] - } - } - ] - - }, - [] - ); + test('literal block scalar strip chomping (|-)', () => { + const input = [ + 'text: |-', + ' line one', + ' line two', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one\nline two', format: 'literal' }); }); - - test('nested objects with address', () => { - assertValidParse( - [ - 'person:', - ' name: John Doe', - ' age: 30', - ' address:', - ' street: 123 Main St', - ' city: Example City' - ], - { - type: 'object', start: pos(0, 0), end: pos(5, 22), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'person' }, - value: { - type: 'object', start: pos(1, 2), end: pos(5, 22), - properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 16), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, - value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } - }, - { - key: { type: 'string', start: pos(3, 2), end: pos(3, 9), value: 'address' }, - value: { - type: 'object', start: pos(4, 4), end: pos(5, 22), properties: [ - { - key: { type: 'string', start: pos(4, 4), end: pos(4, 10), value: 'street' }, - value: { type: 'string', start: pos(4, 12), end: pos(4, 23), value: '123 Main St' } - }, - { - key: { type: 'string', start: pos(5, 4), end: pos(5, 8), value: 'city' }, - value: { type: 'string', start: pos(5, 10), end: pos(5, 22), value: 'Example City' } - } - ] - } - } - ] - } - } - ] - }, - [] - ); + test('literal block scalar keep chomping (|+)', () => { + const input = [ + 'text: |+', + ' line one', + ' line two', + '', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one\nline two\n', format: 'literal' }); }); - test('properties without space after colon', () => { - assertValidParse( - ['name:John'], - { - type: 'object', start: pos(0, 0), end: pos(0, 9), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 5), end: pos(0, 9), value: 'John' } - } - ] - }, - [] - ); - - // Test mixed: some properties with space, some without - assertValidParse( - [ - 'config:', - ' database:', - ' host:localhost', - ' port: 5432', - ' credentials:', - ' username:admin', - ' password: secret123' - ], - { - type: 'object', start: pos(0, 0), end: pos(6, 25), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'config' }, - value: { - type: 'object', start: pos(1, 2), end: pos(6, 25), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 10), value: 'database' }, - value: { - type: 'object', start: pos(2, 4), end: pos(6, 25), properties: [ - { - key: { type: 'string', start: pos(2, 4), end: pos(2, 8), value: 'host' }, - value: { type: 'string', start: pos(2, 9), end: pos(2, 18), value: 'localhost' } - }, - { - key: { type: 'string', start: pos(3, 4), end: pos(3, 8), value: 'port' }, - value: { type: 'number', start: pos(3, 10), end: pos(3, 14), value: 5432 } - }, - { - key: { type: 'string', start: pos(4, 4), end: pos(4, 15), value: 'credentials' }, - value: { - type: 'object', start: pos(5, 6), end: pos(6, 25), properties: [ - { - key: { type: 'string', start: pos(5, 6), end: pos(5, 14), value: 'username' }, - value: { type: 'string', start: pos(5, 15), end: pos(5, 20), value: 'admin' } - }, - { - key: { type: 'string', start: pos(6, 6), end: pos(6, 14), value: 'password' }, - value: { type: 'string', start: pos(6, 16), end: pos(6, 25), value: 'secret123' } - } - ] - } - } - ] - } - } - ] - } - } - ] - }, - [] - ); + test('folded block scalar strip chomping (>-)', () => { + const input = [ + 'text: >-', + ' line one', + ' line two', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one line two', format: 'folded' }); }); - test('inline objects', () => { - assertValidParse( - ['{name: John, age: 30}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 21), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 5), value: 'name' }, - value: { type: 'string', start: pos(0, 7), end: pos(0, 11), value: 'John' } - }, - { - key: { type: 'string', start: pos(0, 13), end: pos(0, 16), value: 'age' }, - value: { type: 'number', start: pos(0, 18), end: pos(0, 20), value: 30 } - } - ] - }, - [] - ); - - // Test with different data types - assertValidParse( - ['{active: true, score: 85.5, role: null}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 39), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 7), value: 'active' }, - value: { type: 'boolean', start: pos(0, 9), end: pos(0, 13), value: true } - }, - { - key: { type: 'string', start: pos(0, 15), end: pos(0, 20), value: 'score' }, - value: { type: 'number', start: pos(0, 22), end: pos(0, 26), value: 85.5 } - }, - { - key: { type: 'string', start: pos(0, 28), end: pos(0, 32), value: 'role' }, - value: { type: 'null', start: pos(0, 34), end: pos(0, 38), value: null } - } - ] - }, - [] - ); - - // Test empty inline object - assertValidParse( - ['{}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 2), properties: [] - }, - [] - ); - - // Test inline object with quoted keys and values - assertValidParse( - ['{"name": "John Doe", "age": 30}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 31), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 7), value: 'name' }, - value: { type: 'string', start: pos(0, 9), end: pos(0, 19), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(0, 21), end: pos(0, 26), value: 'age' }, - value: { type: 'number', start: pos(0, 28), end: pos(0, 30), value: 30 } - } - ] - }, - [] - ); - - // Test inline object without spaces - assertValidParse( - ['{name:John,age:30}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 18), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 5), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 10), value: 'John' } - }, - { - key: { type: 'string', start: pos(0, 11), end: pos(0, 14), value: 'age' }, - value: { type: 'number', start: pos(0, 15), end: pos(0, 17), value: 30 } - } - ] - }, - [] - ); - - // Test multi-line inline object with internal comment line between properties - assertValidParse( - ['{a:1, # comment about b', ' b:2, c:3}'], - { - type: 'object', start: pos(0, 0), end: pos(1, 10), properties: [ - { - key: { type: 'string', start: pos(0, 1), end: pos(0, 2), value: 'a' }, - value: { type: 'number', start: pos(0, 3), end: pos(0, 4), value: 1 } - }, - { - key: { type: 'string', start: pos(1, 1), end: pos(1, 2), value: 'b' }, - value: { type: 'number', start: pos(1, 3), end: pos(1, 4), value: 2 } - }, - { - key: { type: 'string', start: pos(1, 6), end: pos(1, 7), value: 'c' }, - value: { type: 'number', start: pos(1, 8), end: pos(1, 9), value: 3 } - } - ] - }, - [] - ); + test('folded block scalar keep chomping (>+)', () => { + const input = [ + 'text: >+', + ' line one', + ' line two', + '', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'line one line two\n', format: 'folded' }); }); - test('special characters in values', () => { - // Test values with special characters - assertValidParse( - [`key: value with \t special chars`], - { - type: 'object', start: pos(0, 0), end: pos(0, 31), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'string', start: pos(0, 5), end: pos(0, 31), value: `value with \t special chars` } - } - ] - }, - [] - ); + test('single-quoted scalar', () => { + const input = `'hello world'`; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'single' }); }); - test('various whitespace types', () => { - // Test different types of whitespace - assertValidParse( - [`key:\t \t \t value`], - { - type: 'object', start: pos(0, 0), end: pos(0, 15), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'string', start: pos(0, 10), end: pos(0, 15), value: 'value' } - } - ] - }, - [] - ); + test('double-quoted scalar', () => { + const input = '"hello world"'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'double' }); + }); + + test('double-quoted scalar with escape sequences', () => { + const input = '"hello\\nworld"'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello\nworld', format: 'double' }); + }); + + test('single-quoted scalar with escaped single quote', () => { + const input = `'it''s a test'`; + const node = parseOk(input); + assertScalar(input, node, { value: `it's a test`, format: 'single' }); + }); + + test('scalar offsets are correct', () => { + const node = parseOk('hello') as YamlScalarNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 5); }); }); - suite('arrays', () => { - - - test('arrays', () => { - assertValidParse( - [ - '- Boston Red Sox', - '- Detroit Tigers', - '- New York Yankees' - ], - { - type: 'array', start: pos(0, 0), end: pos(2, 18), items: [ - { type: 'string', start: pos(0, 2), end: pos(0, 16), value: 'Boston Red Sox' }, - { type: 'string', start: pos(1, 2), end: pos(1, 16), value: 'Detroit Tigers' }, - { type: 'string', start: pos(2, 2), end: pos(2, 18), value: 'New York Yankees' } - ] - - }, - [] - ); + suite('Block mappings', () => { + test('simple key-value pair', () => { + const input = 'name: John Doe'; + const node = parseOk(input); + const map = assertMap(node, 1); + assert.strictEqual(map.properties[0].key.value, 'name'); + assertScalar(input, map.properties[0].value, { value: 'John Doe' }); }); - - test('inline arrays', () => { - assertValidParse( - ['[Apple, Banana, Cherry]'], - { - type: 'array', start: pos(0, 0), end: pos(0, 23), items: [ - { type: 'string', start: pos(0, 1), end: pos(0, 6), value: 'Apple' }, - { type: 'string', start: pos(0, 8), end: pos(0, 14), value: 'Banana' }, - { type: 'string', start: pos(0, 16), end: pos(0, 22), value: 'Cherry' } - ] - - }, - [] - ); + test('multiple key-value pairs', () => { + const input = [ + 'name: John Doe', + 'age: 30', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + assert.strictEqual(map.properties[0].key.value, 'name'); + assertScalar(input, map.properties[0].value, { value: 'John Doe' }); + assert.strictEqual(map.properties[1].key.value, 'age'); + assertScalar(input, map.properties[1].value, { value: '30' }); }); - test('inline array with internal comment line', () => { - assertValidParse( - ['[one # comment about two', ',two, three]'], - { - type: 'array', start: pos(0, 0), end: pos(1, 12), items: [ - { type: 'string', start: pos(0, 1), end: pos(0, 4), value: 'one' }, - { type: 'string', start: pos(1, 1), end: pos(1, 4), value: 'two' }, - { type: 'string', start: pos(1, 6), end: pos(1, 11), value: 'three' } - ] - }, - [] - ); + test('nested mappings', () => { + const input = [ + 'name: John Doe', + 'age: 30', + 'mother:', + ' name: Susi Doe', + ' age: 50', + ' address:', + ' street: 123 Main St', + ' city: Example City', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 3); + assert.strictEqual(map.properties[0].key.value, 'name'); + assert.strictEqual(map.properties[2].key.value, 'mother'); + const mother = assertMap(map.properties[2].value, 3); + assert.strictEqual(mother.properties[0].key.value, 'name'); + assertScalar(input, mother.properties[0].value, { value: 'Susi Doe' }); + const address = assertMap(mother.properties[2].value, 2); + assert.strictEqual(address.properties[0].key.value, 'street'); + assertScalar(input, address.properties[0].value, { value: '123 Main St' }); }); - test('multi-line inline arrays', () => { - assertValidParse( - [ - '[', - ' geen, ', - ' yello, red]' - ], - { - type: 'array', start: pos(0, 0), end: pos(2, 15), items: [ - { type: 'string', start: pos(1, 4), end: pos(1, 8), value: 'geen' }, - { type: 'string', start: pos(2, 4), end: pos(2, 9), value: 'yello' }, - { type: 'string', start: pos(2, 11), end: pos(2, 14), value: 'red' } - ] - }, - [] - ); + test('mapping with quoted keys and values', () => { + const input = [ + '"name": \'John Doe\'', + '\'age\': "30"', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + assert.strictEqual(map.properties[0].key.format, 'double'); + assert.strictEqual((map.properties[0].value as YamlScalarNode).format, 'single'); }); - test('arrays of arrays', () => { - assertValidParse( - [ - '-', - ' - Apple', - ' - Banana', - ' - Cherry' - ], - { - type: 'array', start: pos(0, 0), end: pos(3, 10), items: [ - { - type: 'array', start: pos(1, 2), end: pos(3, 10), items: [ - { type: 'string', start: pos(1, 4), end: pos(1, 9), value: 'Apple' }, - { type: 'string', start: pos(2, 4), end: pos(2, 10), value: 'Banana' }, - { type: 'string', start: pos(3, 4), end: pos(3, 10), value: 'Cherry' } - ] - } - ] - }, - [] - ); - }); - - test('inline arrays of inline arrays', () => { - assertValidParse( - [ - '[', - ' [ee], [ff, gg]', - ']', - ], - { - type: 'array', start: pos(0, 0), end: pos(2, 1), items: [ - { - type: 'array', start: pos(1, 2), end: pos(1, 6), items: [ - { type: 'string', start: pos(1, 3), end: pos(1, 5), value: 'ee' }, - ], - }, - { - type: 'array', start: pos(1, 8), end: pos(1, 16), items: [ - { type: 'string', start: pos(1, 9), end: pos(1, 11), value: 'ff' }, - { type: 'string', start: pos(1, 13), end: pos(1, 15), value: 'gg' }, - ], - } - ] - }, - [] - ); - }); - - test('object with array containing single object', () => { - assertValidParse( - [ - 'items:', - '- name: John', - ' age: 30' - ], - { - type: 'object', start: pos(0, 0), end: pos(2, 9), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'items' }, - value: { - type: 'array', start: pos(1, 0), end: pos(2, 9), items: [ - { - type: 'object', start: pos(1, 2), end: pos(2, 9), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 12), value: 'John' } - }, - { - key: { type: 'string', start: pos(2, 2), end: pos(2, 5), value: 'age' }, - value: { type: 'number', start: pos(2, 7), end: pos(2, 9), value: 30 } - } - ] - } - ] - } - } - ] - }, - [] - ); - }); - - test('arrays of objects', () => { - assertValidParse( - [ - '-', - ' name: one', - '- name: two', - '-', - ' name: three' - ], - { - type: 'array', start: pos(0, 0), end: pos(4, 13), items: [ - { - type: 'object', start: pos(1, 2), end: pos(1, 11), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 6), value: 'name' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 11), value: 'one' } - } - ] - }, - { - type: 'object', start: pos(2, 2), end: pos(2, 11), properties: [ - { - key: { type: 'string', start: pos(2, 2), end: pos(2, 6), value: 'name' }, - value: { type: 'string', start: pos(2, 8), end: pos(2, 11), value: 'two' } - } - ] - }, - { - type: 'object', start: pos(4, 2), end: pos(4, 13), properties: [ - { - key: { type: 'string', start: pos(4, 2), end: pos(4, 6), value: 'name' }, - value: { type: 'string', start: pos(4, 8), end: pos(4, 13), value: 'three' } - } - ] - } - ] - }, - [] - ); + test('mapping offsets', () => { + const input = 'name: John'; + const node = parseOk(input) as YamlMapNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 10); }); }); - suite('complex structures', () => { - - test('array of objects', () => { - assertValidParse( - [ - 'products:', - ' - name: Laptop', - ' price: 999.99', - ' in_stock: true', - ' - name: Mouse', - ' price: 25.50', - ' in_stock: false' - ], - { - type: 'object', start: pos(0, 0), end: pos(6, 19), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 8), value: 'products' }, - value: { - type: 'array', start: pos(1, 2), end: pos(6, 19), items: [ - { - type: 'object', start: pos(1, 4), end: pos(3, 18), properties: [ - { - key: { type: 'string', start: pos(1, 4), end: pos(1, 8), value: 'name' }, - value: { type: 'string', start: pos(1, 10), end: pos(1, 16), value: 'Laptop' } - }, - { - key: { type: 'string', start: pos(2, 4), end: pos(2, 9), value: 'price' }, - value: { type: 'number', start: pos(2, 11), end: pos(2, 17), value: 999.99 } - }, - { - key: { type: 'string', start: pos(3, 4), end: pos(3, 12), value: 'in_stock' }, - value: { type: 'boolean', start: pos(3, 14), end: pos(3, 18), value: true } - } - ] - }, - { - type: 'object', start: pos(4, 4), end: pos(6, 19), properties: [ - { - key: { type: 'string', start: pos(4, 4), end: pos(4, 8), value: 'name' }, - value: { type: 'string', start: pos(4, 10), end: pos(4, 15), value: 'Mouse' } - }, - { - key: { type: 'string', start: pos(5, 4), end: pos(5, 9), value: 'price' }, - value: { type: 'number', start: pos(5, 11), end: pos(5, 16), value: 25.50 } - }, - { - key: { type: 'string', start: pos(6, 4), end: pos(6, 12), value: 'in_stock' }, - value: { type: 'boolean', start: pos(6, 14), end: pos(6, 19), value: false } - } - ] - } - ] - } - } - ] - }, - [] - ); + suite('Block sequences', () => { + test('simple sequence', () => { + const input = [ + '- Apple', + '- Banana', + '- Cherry', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'Apple' }); + assertScalar(input, seq.items[1], { value: 'Banana' }); + assertScalar(input, seq.items[2], { value: 'Cherry' }); }); - test('inline array mixed primitives', () => { - assertValidParse( - ['vals: [1, true, null, "str"]'], - { - type: 'object', start: pos(0, 0), end: pos(0, 28), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'vals' }, - value: { - type: 'array', start: pos(0, 6), end: pos(0, 28), items: [ - { type: 'number', start: pos(0, 7), end: pos(0, 8), value: 1 }, - { type: 'boolean', start: pos(0, 10), end: pos(0, 14), value: true }, - { type: 'null', start: pos(0, 16), end: pos(0, 20), value: null }, - { type: 'string', start: pos(0, 22), end: pos(0, 27), value: 'str' } - ] - } - } - ] - }, - [] - ); + // Spec Example 2.4. Sequence of Mappings (229Q) + test('spec 2.4 - sequence of mappings (229Q)', () => { + const input = [ + '-', + ' name: Mark McGwire', + ' hr: 65', + ' avg: 0.278', + '-', + ' name: Sammy Sosa', + ' hr: 63', + ' avg: 0.288', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 2); + + const first = assertMap(seq.items[0], 3); + assert.strictEqual(first.properties[0].key.value, 'name'); + assertScalar(input, first.properties[0].value, { value: 'Mark McGwire' }); + assert.strictEqual(first.properties[1].key.value, 'hr'); + assertScalar(input, first.properties[1].value, { value: '65' }); + assert.strictEqual(first.properties[2].key.value, 'avg'); + assertScalar(input, first.properties[2].value, { value: '0.278' }); + + const second = assertMap(seq.items[1], 3); + assert.strictEqual(second.properties[0].key.value, 'name'); + assertScalar(input, second.properties[0].value, { value: 'Sammy Sosa' }); + assert.strictEqual(second.properties[1].key.value, 'hr'); + assertScalar(input, second.properties[1].value, { value: '63' }); + assert.strictEqual(second.properties[2].key.value, 'avg'); + assertScalar(input, second.properties[2].value, { value: '0.288' }); }); - test('mixed inline structures', () => { - assertValidParse( - ['config: {env: "prod", settings: [true, 42], debug: false}'], - { - type: 'object', start: pos(0, 0), end: pos(0, 57), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'config' }, - value: { - type: 'object', start: pos(0, 8), end: pos(0, 57), properties: [ - { - key: { type: 'string', start: pos(0, 9), end: pos(0, 12), value: 'env' }, - value: { type: 'string', start: pos(0, 14), end: pos(0, 20), value: 'prod' } - }, - { - key: { type: 'string', start: pos(0, 22), end: pos(0, 30), value: 'settings' }, - value: { - type: 'array', start: pos(0, 32), end: pos(0, 42), items: [ - { type: 'boolean', start: pos(0, 33), end: pos(0, 37), value: true }, - { type: 'number', start: pos(0, 39), end: pos(0, 41), value: 42 } - ] - } - }, - { - key: { type: 'string', start: pos(0, 44), end: pos(0, 49), value: 'debug' }, - value: { type: 'boolean', start: pos(0, 51), end: pos(0, 56), value: false } - } - ] - } - } - ] - }, - [] - ); + test('sequence of mappings', () => { + const input = [ + '-', + ' name: Mark McGwire', + ' hr: 65', + ' avg: 0.278', + '-', + ' name: Sammy Sosa', + ' hr: 63', + ' avg: 0.288', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 2); + const first = assertMap(seq.items[0], 3); + assertScalar(input, first.properties[0].value, { value: 'Mark McGwire' }); + const second = assertMap(seq.items[1], 3); + assertScalar(input, second.properties[0].value, { value: 'Sammy Sosa' }); }); - test('with comments', () => { - assertValidParse( - [ - `# This is a comment`, - 'name: John Doe # inline comment', - 'age: 30' - ], - { - type: 'object', start: pos(1, 0), end: pos(2, 7), properties: [ - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'name' }, - value: { type: 'string', start: pos(1, 6), end: pos(1, 14), value: 'John Doe' } - }, - { - key: { type: 'string', start: pos(2, 0), end: pos(2, 3), value: 'age' }, - value: { type: 'number', start: pos(2, 5), end: pos(2, 7), value: 30 } - } - ] - }, - [] - ); + test('map of sequences', () => { + const input = [ + 'american:', + ' - Boston Red Sox', + ' - Detroit Tigers', + ' - New York Yankees', + 'national:', + ' - New York Mets', + ' - Chicago Cubs', + ' - Atlanta Braves', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + const american = assertSequence(map.properties[0].value, 3); + assertScalar(input, american.items[0], { value: 'Boston Red Sox' }); + const national = assertSequence(map.properties[1].value, 3); + assertScalar(input, national.items[2], { value: 'Atlanta Braves' }); + }); + + test('inline mapping after dash', () => { + const input = [ + '- name: Mark McGwire', + ' hr: 65', + '- name: Sammy Sosa', + ' hr: 63', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 2); + const first = assertMap(seq.items[0], 2); + assertScalar(input, first.properties[0].value, { value: 'Mark McGwire' }); }); }); - suite('edge cases and error handling', () => { + suite('Flow mappings', () => { + test('simple flow mapping', () => { + const input = '{hr: 65, avg: 0.278}'; + const node = parseOk(input); + const map = assertMap(node, 2); + assert.strictEqual(map.properties[0].key.value, 'hr'); + assertScalar(input, map.properties[0].value, { value: '65' }); + assert.strictEqual(map.properties[1].key.value, 'avg'); + assertScalar(input, map.properties[1].value, { value: '0.278' }); + }); + test('flow mapping offsets', () => { + const input = '{hr: 65}'; + const node = parseOk(input) as YamlMapNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 8); + }); + }); - // Edge cases - test('duplicate keys error', () => { - assertValidParse( - [ - 'key: 1', - 'key: 2' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 6), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'key' }, - value: { type: 'number', start: pos(1, 5), end: pos(1, 6), value: 2 } - } - ] - }, - [ - { - message: 'Duplicate key \'key\'', - code: 'duplicateKey', - start: pos(1, 0), - end: pos(1, 3) - } - ] - ); + suite('Flow sequences', () => { + test('simple flow sequence', () => { + const input = '[Sammy Sosa , 63, 0.288]'; + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'Sammy Sosa' }); + assertScalar(input, seq.items[1], { value: '63' }); + assertScalar(input, seq.items[2], { value: '0.288' }); + }); + + test('flow sequence with quoted strings', () => { + const input = `[ 'Sammy Sosa', 63, 0.288]`; + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'Sammy Sosa', format: 'single' }); + }); + + test('flow sequence offsets', () => { + const input = '[a, b]'; + const node = parseOk(input) as YamlSequenceNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 6); + }); + }); + + suite('Mixed structures', () => { + test('object with scalars, arrays, inline objects and arrays', () => { + const input = [ + 'object:', + ' street: 123 Main St', + ' city: "Example City"', + 'array:', + ' - Boston Red Sox', + ` - 'Detroit Tigers'`, + 'inline object: {hr: 65, avg: 0.278}', + `inline array: [ 'Sammy Sosa', 63, 0.288]`, + 'bool: false', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 5); + + // Nested object + const obj = assertMap(map.properties[0].value, 2); + assertScalar(input, obj.properties[0].value, { value: '123 Main St' }); + assertScalar(input, obj.properties[1].value, { value: 'Example City', format: 'double' }); + + // Array + const arr = assertSequence(map.properties[1].value, 2); + assertScalar(input, arr.items[0], { value: 'Boston Red Sox' }); + assertScalar(input, arr.items[1], { value: 'Detroit Tigers', format: 'single' }); + + // Inline object + const inlineObj = assertMap(map.properties[2].value, 2); + assertScalar(input, inlineObj.properties[0].value, { value: '65' }); + + // Inline array + const inlineArr = assertSequence(map.properties[3].value, 3); + assertScalar(input, inlineArr.items[0], { value: 'Sammy Sosa', format: 'single' }); + + // Boolean as scalar + assertScalar(input, map.properties[4].value, { value: 'false' }); + }); + + test('arrays of inline arrays', () => { + const input = [ + '- [name , hr, avg ]', + '- [Mark McGwire, 65, 0.278]', + '- [Sammy Sosa , 63, 0.288]', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + + const header = assertSequence(seq.items[0], 3); + assertScalar(input, header.items[0], { value: 'name' }); + assertScalar(input, header.items[1], { value: 'hr' }); + assertScalar(input, header.items[2], { value: 'avg' }); + + const row1 = assertSequence(seq.items[1], 3); + assertScalar(input, row1.items[0], { value: 'Mark McGwire' }); + }); + }); + + suite('Comments', () => { + test('comment-only lines are ignored', () => { + const input = [ + '# This is a comment', + 'name: John', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assert.strictEqual(map.properties[0].key.value, 'name'); + }); + + test('inline comment after value', () => { + const input = [ + 'hr: # 1998 hr ranking', + ' - Mark McGwire', + ' - Sammy Sosa', + 'rbi:', + ' # 1998 rbi ranking', + ' - Sammy Sosa', + ' - Ken Griffey#part of the value, not a comment', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + + const hr = assertSequence(map.properties[0].value, 2); + assertScalar(input, hr.items[0], { value: 'Mark McGwire' }); + + const rbi = assertSequence(map.properties[1].value, 2); + // '#' without leading space is part of the value + assertScalar(input, rbi.items[1], { value: 'Ken Griffey#part of the value, not a comment' }); + }); + }); + + suite('Error handling', () => { + test('missing value emits error and creates empty scalar', () => { + const errors: YamlParseError[] = []; + const input = [ + 'name:', + 'age: 30', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].value, { value: '' }); + assert.ok(errors.some(e => e.code === 'missing-value')); + }); + + test('duplicate keys emit errors', () => { + const errors: YamlParseError[] = []; + const input = [ + 'name: John', + 'name: Jane', + ].join('\n'); + const node = parse(input, errors); + assertMap(node, 2); + assert.ok(errors.some(e => e.code === 'duplicate-key')); }); test('duplicate keys allowed with option', () => { - assertValidParse( - [ - 'key: 1', - 'key: 2' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 6), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 3), value: 'key' }, - value: { type: 'number', start: pos(1, 5), end: pos(1, 6), value: 2 } - } - ] - }, - [], - { allowDuplicateKeys: true } - ); + const errors: YamlParseError[] = []; + const input = [ + 'name: John', + 'name: Jane', + ].join('\n'); + const node = parse(input, errors, { allowDuplicateKeys: true }); + assertMap(node, 2); + assert.strictEqual(errors.length, 0); }); - test('unexpected indentation error with recovery', () => { - // Parser reports error but still captures the over-indented property. - assertValidParse( - [ - 'key: 1', - ' stray: value' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 16), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'number', start: pos(0, 5), end: pos(0, 6), value: 1 } - }, - { - key: { type: 'string', start: pos(1, 4), end: pos(1, 9), value: 'stray' }, - value: { type: 'string', start: pos(1, 11), end: pos(1, 16), value: 'value' } - } - ] - }, - [ - { - message: 'Unexpected indentation', - code: 'indentation', - start: pos(1, 0), - end: pos(1, 16) - } - ] - ); + test('wrong indentation emits error but still parses', () => { + const errors: YamlParseError[] = []; + const input = [ + 'parent:', + ' child1: a', + ' child2: b', + ].join('\n'); + const node = parse(input, errors); + assert.ok(node); + // Should have produced an indentation error + assert.ok(errors.some(e => e.code === 'unexpected-indentation')); + }); + }); + + suite('Offset tracking', () => { + test('scalar offsets in mapping', () => { + const input = 'key: value'; + const map = parseOk(input) as YamlMapNode; + assert.strictEqual(map.properties[0].key.startOffset, 0); + assert.strictEqual(map.properties[0].key.endOffset, 3); + const val = map.properties[0].value as YamlScalarNode; + assert.strictEqual(val.startOffset, 5); + assert.strictEqual(val.endOffset, 10); }); - test('empty values and inline empty array', () => { - assertValidParse( - [ - 'empty:', - 'array: []' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 9), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 5), value: 'empty' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 6), value: '' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 5), value: 'array' }, - value: { type: 'array', start: pos(1, 7), end: pos(1, 9), items: [] } - } - ] - }, - [] - ); + test('offsets are zero-based and endOffset is exclusive', () => { + const input = '"hi"'; + const node = parseOk(input) as YamlScalarNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 4); + assert.strictEqual(node.value, 'hi'); + assert.strictEqual(node.rawValue, '"hi"'); }); + test('sequence item offsets', () => { + const input = [ + '- a', + '- b', + ].join('\n'); + const seq = parseOk(input) as YamlSequenceNode; + const first = seq.items[0] as YamlScalarNode; + assert.strictEqual(first.startOffset, 2); + assert.strictEqual(first.endOffset, 3); + }); + }); - - test('nested empty objects', () => { - // Parser should create nodes for both parent and child, with child having empty string value. - assertValidParse( - [ - 'parent:', - ' child:' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 8), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 6), value: 'parent' }, - value: { - type: 'object', start: pos(1, 2), end: pos(1, 8), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 7), value: 'child' }, - value: { type: 'string', start: pos(1, 8), end: pos(1, 8), value: '' } - } - ] - } - } - ] - }, - [] - ); + suite('Nested sequences', () => { + test('block sequence in block sequence (dash-dash)', () => { + const input = [ + '- - s1_i1', + ' - s1_i2', + '- s2', + ].join('\n'); + const outer = assertSequence(parseOk(input), 2); + const inner = assertSequence(outer.items[0], 2); + assertScalar(input, inner.items[0], { value: 's1_i1' }); + assertScalar(input, inner.items[1], { value: 's1_i2' }); + assertScalar(input, outer.items[1], { value: 's2' }); }); - test('empty object with only colons', () => { - // Test object with empty values - assertValidParse( - ['key1:', 'key2:', 'key3:'], - { - type: 'object', start: pos(0, 0), end: pos(2, 5), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'key1' }, - value: { type: 'string', start: pos(0, 5), end: pos(0, 5), value: '' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'key2' }, - value: { type: 'string', start: pos(1, 5), end: pos(1, 5), value: '' } - }, - { - key: { type: 'string', start: pos(2, 0), end: pos(2, 4), value: 'key3' }, - value: { type: 'string', start: pos(2, 5), end: pos(2, 5), value: '' } - } - ] - }, - [] - ); + test('sequence at same indent as parent mapping key', () => { + const input = [ + 'one:', + '- 2', + '- 3', + 'four: 5', + ].join('\n'); + const map = assertMap(parseOk(input), 2); + assertScalar(input, map.properties[0].key, { value: 'one' }); + const seq = assertSequence(map.properties[0].value, 2); + assertScalar(input, seq.items[0], { value: '2' }); + assertScalar(input, seq.items[1], { value: '3' }); + assertScalar(input, map.properties[1].key, { value: 'four' }); + assertScalar(input, map.properties[1].value, { value: '5' }); + }); + + test('sequence indented under mapping key', () => { + const input = [ + 'foo:', + ' - 42', + 'bar:', + ' - 44', + ].join('\n'); + const map = assertMap(parseOk(input), 2); + const seq1 = assertSequence(map.properties[0].value, 1); + assertScalar(input, seq1.items[0], { value: '42' }); + const seq2 = assertSequence(map.properties[1].value, 1); + assertScalar(input, seq2.items[0], { value: '44' }); + }); + }); + + suite('Multiline plain scalars', () => { + test('multiline scalar in mapping value', () => { + const input = [ + 'a: b', + ' c', + ].join('\n'); + const map = assertMap(parseOk(input), 1); + assertScalar(input, map.properties[0].value, { value: 'b c' }); + }); + + test('multiline scalar with multiple continuation lines', () => { + const input = [ + 'plain:', + ' This unquoted scalar', + ' spans many lines.', + ].join('\n'); + const map = assertMap(parseOk(input), 1); + assertScalar(input, map.properties[0].value, { value: 'This unquoted scalar spans many lines.' }); + }); + + test('multiline scalar at top level', () => { + const input = [ + 'a', + 'b', + ' c', + 'd', + ].join('\n'); + const result = parseOk(input); + assertScalar(input, result, { value: 'a b c d' }); + }); + + test('multiline scalar with empty line preserves newline', () => { + const input = [ + 'a: val1', + ' val2', + '', + ' val3', + ].join('\n'); + const map = assertMap(parseOk(input), 1); + // Empty line between val2 and val3 becomes \n + assertScalar(input, map.properties[0].value, { value: 'val1 val2\nval3' }); + }); + + test('multiline scalar stops at same indent as mapping', () => { + const input = [ + 'a: b', + ' c', + 'd: e', + ].join('\n'); + const map = assertMap(parseOk(input), 2); + assertScalar(input, map.properties[0].value, { value: 'b c' }); + assertScalar(input, map.properties[1].value, { value: 'e' }); + }); + + test('multiline scalar value on next line', () => { + const input = [ + 'a:', + ' b', + ' c', + ].join('\n'); + const map = assertMap(parseOk(input), 1); + assertScalar(input, map.properties[0].value, { value: 'b c' }); + }); + + test('multiline scalar stops at comment', () => { + const input = [ + 'value1', + '# a comment', + 'value2', + ].join('\n'); + // Comment terminates the scalar continuation, so value2 is not part of value1 + const result = parseOk(input); + assertScalar(input, result, { value: 'value1' }); + }); + + test('multiline scalar with multiple mappings', () => { + const input = [ + 'a: b', + ' c', + 'd:', + ' e', + ' f', + ].join('\n'); + const map = assertMap(parseOk(input), 2); + assertScalar(input, map.properties[0].value, { value: 'b c' }); + assertScalar(input, map.properties[1].value, { value: 'e f' }); + }); + }); + + suite('Edge cases', () => { + test('colon in unquoted value', () => { + const input = 'url: http://example.com'; + const map = parseOk(input) as YamlMapNode; + assertScalar(input, map.properties[0].value, { value: 'http://example.com' }); + }); + + test('trailing whitespace is trimmed from unquoted scalars', () => { + const input = 'name: John '; + const map = parseOk(input) as YamlMapNode; + assertScalar(input, map.properties[0].value, { value: 'John' }); + }); + + test('empty flow map', () => { + const node = parseOk('{}'); + const map = assertMap(node, 0); + assert.strictEqual(map.startOffset, 0); + assert.strictEqual(map.endOffset, 2); + }); + + test('empty flow sequence', () => { + const node = parseOk('[]'); + const seq = assertSequence(node, 0); + assert.strictEqual(seq.startOffset, 0); + assert.strictEqual(seq.endOffset, 2); + }); + + test('CRLF line endings', () => { + const input = 'name: John\r\nage: 30'; + const map = parseOk(input) as YamlMapNode; + assertMap(map, 2); + assertScalar(input, map.properties[0].value, { value: 'John' }); + assertScalar(input, map.properties[1].value, { value: '30' }); + }); + }); + + suite('Old test suite', () => { + + test('mapping value on next line', () => { + const input = [ + 'name:', + ' John Doe', + 'colors:', + ' [ Red, Green, Blue ]', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].value, { value: 'John Doe' }); + const colors = assertSequence(map.properties[1].value, 3); + assertScalar(input, colors.items[0], { value: 'Red' }); + assertScalar(input, colors.items[1], { value: 'Green' }); + assertScalar(input, colors.items[2], { value: 'Blue' }); + }); + + test('flow map with different data types', () => { + const input = '{active: true, score: 85.5, role: null}'; + const node = parseOk(input); + const map = assertMap(node, 3); + assertScalar(input, map.properties[0].key, { value: 'active' }); + assertScalar(input, map.properties[0].value, { value: 'true' }); + assertScalar(input, map.properties[1].key, { value: 'score' }); + assertScalar(input, map.properties[1].value, { value: '85.5' }); + assertScalar(input, map.properties[2].key, { value: 'role' }); + assertScalar(input, map.properties[2].value, { value: 'null' }); + }); + + test('flow map with quoted keys and values', () => { + const input = '{"name": "John Doe", "age": 30}'; + const node = parseOk(input); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'name', format: 'double' }); + assertScalar(input, map.properties[0].value, { value: 'John Doe', format: 'double' }); + assertScalar(input, map.properties[1].key, { value: 'age', format: 'double' }); + assertScalar(input, map.properties[1].value, { value: '30' }); + }); + + test('special characters in values', () => { + const input = `key: value with \t special chars`; + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: `value with \t special chars` }); + }); + + test('various whitespace after colon', () => { + const input = `key:\t \t \t value`; + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'value' }); + }); + + test('inline array with comment continuation', () => { + const input = [ + '[one # comment about two', + ',two, three]', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'one' }); + assertScalar(input, seq.items[1], { value: 'two' }); + assertScalar(input, seq.items[2], { value: 'three' }); + }); + + test('multi-line flow sequence', () => { + const input = [ + '[', + ' geen, ', + ' yello, red]', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + assertScalar(input, seq.items[0], { value: 'geen' }); + assertScalar(input, seq.items[1], { value: 'yello' }); + assertScalar(input, seq.items[2], { value: 'red' }); + }); + + test('nested block sequences (dash on next line)', () => { + const input = [ + '-', + ' - Apple', + ' - Banana', + ' - Cherry', + ].join('\n'); + const node = parseOk(input); + const outer = assertSequence(node, 1); + const inner = assertSequence(outer.items[0], 3); + assertScalar(input, inner.items[0], { value: 'Apple' }); + assertScalar(input, inner.items[1], { value: 'Banana' }); + assertScalar(input, inner.items[2], { value: 'Cherry' }); + }); + + test('nested flow sequences', () => { + const input = [ + '[', + ' [ee], [ff, gg]', + ']', + ].join('\n'); + const node = parseOk(input); + const outer = assertSequence(node, 2); + const first = assertSequence(outer.items[0], 1); + assertScalar(input, first.items[0], { value: 'ee' }); + const second = assertSequence(outer.items[1], 2); + assertScalar(input, second.items[0], { value: 'ff' }); + assertScalar(input, second.items[1], { value: 'gg' }); + }); + + test('mapping with sequence containing a mapping', () => { + const input = [ + 'items:', + '- name: John', + ' age: 30', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].key, { value: 'items' }); + const seq = assertSequence(map.properties[0].value, 1); + const item = assertMap(seq.items[0], 2); + assertScalar(input, item.properties[0].value, { value: 'John' }); + assertScalar(input, item.properties[1].value, { value: '30' }); + }); + + test('sequence of mappings with varying styles', () => { + const input = [ + '-', + ' name: one', + '- name: two', + '-', + ' name: three', + ].join('\n'); + const node = parseOk(input); + const seq = assertSequence(node, 3); + const first = assertMap(seq.items[0], 1); + assertScalar(input, first.properties[0].value, { value: 'one' }); + const second = assertMap(seq.items[1], 1); + assertScalar(input, second.properties[0].value, { value: 'two' }); + const third = assertMap(seq.items[2], 1); + assertScalar(input, third.properties[0].value, { value: 'three' }); + }); + + test('sequence of multi-property mappings', () => { + const input = [ + 'products:', + ' - name: Laptop', + ' price: 999.99', + ' in_stock: true', + ' - name: Mouse', + ' price: 25.50', + ' in_stock: false', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + const products = assertSequence(map.properties[0].value, 2); + const laptop = assertMap(products.items[0], 3); + assertScalar(input, laptop.properties[0].value, { value: 'Laptop' }); + assertScalar(input, laptop.properties[1].value, { value: '999.99' }); + assertScalar(input, laptop.properties[2].value, { value: 'true' }); + const mouse = assertMap(products.items[1], 3); + assertScalar(input, mouse.properties[0].value, { value: 'Mouse' }); + assertScalar(input, mouse.properties[1].value, { value: '25.50' }); + assertScalar(input, mouse.properties[2].value, { value: 'false' }); + }); + + test('flow sequence with mixed types', () => { + // Note: current parser treats all values as scalars (strings), not typed + const input = 'vals: [1, true, null, "str"]'; + const node = parseOk(input); + const map = assertMap(node, 1); + const vals = assertSequence(map.properties[0].value, 4); + assertScalar(input, vals.items[0], { value: '1' }); + assertScalar(input, vals.items[1], { value: 'true' }); + assertScalar(input, vals.items[2], { value: 'null' }); + assertScalar(input, vals.items[3], { value: 'str', format: 'double' }); + }); + + test('flow map with nested flow sequence', () => { + const input = 'config: {env: "prod", settings: [true, 42], debug: false}'; + const node = parseOk(input); + const map = assertMap(node, 1); + const config = assertMap(map.properties[0].value, 3); + assertScalar(input, config.properties[0].key, { value: 'env' }); + assertScalar(input, config.properties[0].value, { value: 'prod', format: 'double' }); + const settings = assertSequence(config.properties[1].value, 2); + assertScalar(input, settings.items[0], { value: 'true' }); + assertScalar(input, settings.items[1], { value: '42' }); + assertScalar(input, config.properties[2].key, { value: 'debug' }); + assertScalar(input, config.properties[2].value, { value: 'false' }); + }); + + test('full-line and inline comments', () => { + const input = [ + '# This is a comment', + 'name: John Doe # inline comment', + 'age: 30', + ].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'name' }); + assertScalar(input, map.properties[0].value, { value: 'John Doe' }); + assertScalar(input, map.properties[1].key, { value: 'age' }); + assertScalar(input, map.properties[1].value, { value: '30' }); + }); + + test('unexpected indentation with recovery', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key: 1', + ' stray: value', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'key' }); + assertScalar(input, map.properties[0].value, { value: '1' }); + assertScalar(input, map.properties[1].key, { value: 'stray' }); + assertScalar(input, map.properties[1].value, { value: 'value' }); + // Should report an indentation error + assert.ok(errors.some(e => e.code === 'unexpected-indentation')); + }); + + test('empty value followed by non-empty', () => { + const input = [ + 'empty:', + 'array: []', + ].join('\n'); + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'empty' }); + assertScalar(input, map.properties[0].value, { value: '' }); + assertScalar(input, map.properties[1].key, { value: 'array' }); + const arr = assertSequence(map.properties[1].value, 0); + assert.ok(arr); + }); + + test('nested mapping with empty value', () => { + const input = [ + 'parent:', + ' child:', + ].join('\n'); + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 1); + const parent = assertMap(map.properties[0].value, 1); + assertScalar(input, parent.properties[0].key, { value: 'child' }); + assertScalar(input, parent.properties[0].value, { value: '' }); + }); + + test('multiple keys with empty values', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key1:', + 'key2:', + 'key3:', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 3); + assertScalar(input, map.properties[0].key, { value: 'key1' }); + assertScalar(input, map.properties[0].value, { value: '' }); + assertScalar(input, map.properties[1].key, { value: 'key2' }); + assertScalar(input, map.properties[1].value, { value: '' }); + assertScalar(input, map.properties[2].key, { value: 'key3' }); + assertScalar(input, map.properties[2].value, { value: '' }); }); test('large input performance', () => { - // Test that large inputs are handled efficiently - const input = Array.from({ length: 1000 }, (_, i) => `key${i}: value${i}`); - const expectedProperties = Array.from({ length: 1000 }, (_, i) => ({ - key: { type: 'string' as const, start: pos(i, 0), end: pos(i, `key${i}`.length), value: `key${i}` }, - value: { type: 'string' as const, start: pos(i, `key${i}: `.length), end: pos(i, `key${i}: value${i}`.length), value: `value${i}` } - })); - + const lines = Array.from({ length: 1000 }, (_, i) => `key${i}: value${i}`); + const input = lines.join('\n'); const start = Date.now(); - assertValidParse( - input, - { - type: 'object', - start: pos(0, 0), - end: pos(999, 'key999: value999'.length), - properties: expectedProperties - }, - [] - ); + const node = parseOk(input); const duration = Date.now() - start; - - ok(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); + const map = assertMap(node, 1000); + assertScalar(input, map.properties[0].key, { value: 'key0' }); + assertScalar(input, map.properties[999].key, { value: 'key999' }); + assert.ok(duration < 500, `Parsing took ${duration}ms, expected < 500ms`); }); test('deeply nested structure performance', () => { - // Test that deeply nested structures are handled efficiently const lines = []; for (let i = 0; i < 50; i++) { - const indent = ' '.repeat(i); - lines.push(`${indent}level${i}:`); + lines.push(' '.repeat(i) + `level${i}:`); } lines.push(' '.repeat(50) + 'deepValue: reached'); - + const input = lines.join('\n'); const start = Date.now(); const errors: YamlParseError[] = []; - const result = parse(lines.join('\n'), errors); + const result = parse(input, errors); const duration = Date.now() - start; - - ok(result); - strictEqual(result.type, 'object'); - strictEqual(errors.length, 0); - ok(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); + assert.ok(result); + assert.strictEqual(result.type, 'map'); + assert.ok(duration < 500, `Parsing took ${duration}ms, expected < 500ms`); }); - test('malformed array with position issues', () => { - // Test malformed arrays that might cause position advancement issues - assertValidParse( - [ - 'key: [', - '', - '', - '', - '' - ], - { - type: 'object', start: pos(0, 0), end: pos(5, 0), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'array', start: pos(0, 5), end: pos(5, 0), items: [] } - } - ] - }, - [] - ); + test('unclosed flow sequence with empty lines', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key: [', + '', + '', + '', + '', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].key, { value: 'key' }); + const seq = map.properties[0].value as YamlSequenceNode; + assert.strictEqual(seq.type, 'sequence'); + assert.strictEqual(seq.items.length, 0); }); - test('self-referential like structure', () => { - // Test structures that might appear self-referential - assertValidParse( - [ - 'a:', - ' b:', - ' a:', - ' b:', - ' value: test' - ], - { - type: 'object', start: pos(0, 0), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 1), value: 'a' }, - value: { - type: 'object', start: pos(1, 2), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(1, 2), end: pos(1, 3), value: 'b' }, - value: { - type: 'object', start: pos(2, 4), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(2, 4), end: pos(2, 5), value: 'a' }, - value: { - type: 'object', start: pos(3, 6), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(3, 6), end: pos(3, 7), value: 'b' }, - value: { - type: 'object', start: pos(4, 8), end: pos(4, 19), properties: [ - { - key: { type: 'string', start: pos(4, 8), end: pos(4, 13), value: 'value' }, - value: { type: 'string', start: pos(4, 15), end: pos(4, 19), value: 'test' } - } - ] - } - } - ] - } - } - ] - } - } - ] - } - } - ] - }, - [] - ); + test('deeply nested same-named keys', () => { + const input = [ + 'a:', + ' b:', + ' a:', + ' b:', + ' value: test', + ].join('\n'); + const node = parseOk(input); + const outerA = assertMap(node, 1); + assertScalar(input, outerA.properties[0].key, { value: 'a' }); + const outerB = assertMap(outerA.properties[0].value, 1); + assertScalar(input, outerB.properties[0].key, { value: 'b' }); + const innerA = assertMap(outerB.properties[0].value, 1); + assertScalar(input, innerA.properties[0].key, { value: 'a' }); + const innerB = assertMap(innerA.properties[0].value, 1); + assertScalar(input, innerB.properties[0].key, { value: 'b' }); + const leaf = assertMap(innerB.properties[0].value, 1); + assertScalar(input, leaf.properties[0].key, { value: 'value' }); + assertScalar(input, leaf.properties[0].value, { value: 'test' }); }); - test('array with empty lines', () => { - // Test arrays spanning multiple lines with empty lines - assertValidParse( - ['arr: [', '', 'item1,', '', 'item2', '', ']'], - { - type: 'object', start: pos(0, 0), end: pos(6, 1), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'arr' }, - value: { - type: 'array', start: pos(0, 5), end: pos(6, 1), items: [ - { type: 'string', start: pos(2, 0), end: pos(2, 5), value: 'item1' }, - { type: 'string', start: pos(4, 0), end: pos(4, 5), value: 'item2' } - ] - } - } - ] - }, - [] - ); + test('flow sequence with empty lines between items', () => { + const input = ['arr: [', '', 'item1,', '', 'item2', '', ']'].join('\n'); + const node = parseOk(input); + const map = assertMap(node, 1); + const seq = assertSequence(map.properties[0].value, 2); + assertScalar(input, seq.items[0], { value: 'item1' }); + assertScalar(input, seq.items[1], { value: 'item2' }); }); - test('whitespace advancement robustness', () => { - // Test that whitespace advancement works correctly - assertValidParse( - [`key: value`], - { - type: 'object', start: pos(0, 0), end: pos(0, 15), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 3), value: 'key' }, - value: { type: 'string', start: pos(0, 10), end: pos(0, 15), value: 'value' } - } - ] - }, - [] - ); + test('excessive whitespace after colon', () => { + const input = 'key: value'; + const node = parseOk(input); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].value, { value: 'value' }); }); - - test('missing end quote in string values', () => { - // Test unclosed double quote - parser treats it as bare string with quote included - assertValidParse( - ['name: "John'], - { - type: 'object', start: pos(0, 0), end: pos(0, 11), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 11), value: 'John' } - } - ] - }, - [] - ); - - // Test unclosed single quote - parser treats it as bare string with quote included - assertValidParse( - ['description: \'Hello world'], - { - type: 'object', start: pos(0, 0), end: pos(0, 25), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 11), value: 'description' }, - value: { type: 'string', start: pos(0, 13), end: pos(0, 25), value: 'Hello world' } - } - ] - }, - [] - ); - - // Test unclosed quote in multi-line context - assertValidParse( - [ - 'data: "incomplete', - 'next: value' - ], - { - type: 'object', start: pos(0, 0), end: pos(1, 11), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'data' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 17), value: 'incomplete' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 4), value: 'next' }, - value: { type: 'string', start: pos(1, 6), end: pos(1, 11), value: 'value' } - } - ] - }, - [] - ); - - // Test properly quoted strings for comparison - assertValidParse( - ['name: "John"'], - { - type: 'object', start: pos(0, 0), end: pos(0, 12), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'name' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 12), value: 'John' } - } - ] - }, - [] - ); + test('unclosed double quote', () => { + const input = 'name: "John'; + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].key, { value: 'name' }); + // Parser should recover: value should be 'John' (sans quote) + assertScalar(input, map.properties[0].value, { value: 'John' }); }); - test('comment in inline array #269078', () => { - // Test malformed array with comment-like content - should not cause endless loop - assertValidParse( - [ - 'mode: agent', - 'tools: [#r' - ], - { - type: 'object', start: pos(0, 0), end: pos(2, 0), properties: [ - { - key: { type: 'string', start: pos(0, 0), end: pos(0, 4), value: 'mode' }, - value: { type: 'string', start: pos(0, 6), end: pos(0, 11), value: 'agent' } - }, - { - key: { type: 'string', start: pos(1, 0), end: pos(1, 5), value: 'tools' }, - value: { type: 'array', start: pos(1, 7), end: pos(2, 0), items: [] } - } - ] - }, - [] - ); + test('unclosed single quote', () => { + const input = `description: 'Hello world`; + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 1); + assertScalar(input, map.properties[0].key, { value: 'description' }); + assertScalar(input, map.properties[0].value, { value: 'Hello world' }); }); + test('comment in unclosed flow sequence', () => { + const input = [ + 'mode: agent', + 'tools: [#r', + ].join('\n'); + const errors: YamlParseError[] = []; + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].key, { value: 'mode' }); + assertScalar(input, map.properties[0].value, { value: 'agent' }); + assertScalar(input, map.properties[1].key, { value: 'tools' }); + const seq = map.properties[1].value as YamlSequenceNode; + assert.strictEqual(seq.type, 'sequence'); + assert.strictEqual(seq.items.length, 0); + }); + test('duplicate keys emit error', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key: 1', + 'key: 2', + ].join('\n'); + const node = parse(input, errors); + const map = assertMap(node, 2); + assertScalar(input, map.properties[0].value, { value: '1' }); + assertScalar(input, map.properties[1].value, { value: '2' }); + assert.ok(errors.some(e => e.code === 'duplicate-key')); + }); + + test('duplicate keys allowed via option', () => { + const errors: YamlParseError[] = []; + const input = [ + 'key: 1', + 'key: 2', + ].join('\n'); + const node = parse(input, errors, { allowDuplicateKeys: true }); + assertMap(node, 2); + assert.strictEqual(errors.length, 0); + }); }); - }); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 5b64a6cbb1d..a055db7e3dd 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -37,7 +37,9 @@ import { IEncryptionMainService } from '../../platform/encryption/common/encrypt import { EncryptionMainService } from '../../platform/encryption/electron-main/encryptionMainService.js'; import { NativeBrowserElementsMainService, INativeBrowserElementsMainService } from '../../platform/browserElements/electron-main/nativeBrowserElementsMainService.js'; import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js'; +import { ipcBrowserViewGroupChannelName } from '../../platform/browserView/common/browserViewGroup.js'; import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js'; +import { BrowserViewGroupMainService, IBrowserViewGroupMainService } from '../../platform/browserView/electron-main/browserViewGroupMainService.js'; import { BrowserViewCDPProxyServer, IBrowserViewCDPProxyServer } from '../../platform/browserView/electron-main/browserViewCDPProxyServer.js'; import { NativeParsedArgs } from '../../platform/environment/common/argv.js'; import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js'; @@ -1043,6 +1045,7 @@ export class CodeApplication extends Disposable { // Browser View services.set(IBrowserViewCDPProxyServer, new SyncDescriptor(BrowserViewCDPProxyServer, undefined, true)); services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */)); + services.set(IBrowserViewGroupMainService, new SyncDescriptor(BrowserViewGroupMainService, undefined, false /* proxied to other processes */)); // Keyboard Layout services.set(IKeyboardLayoutMainService, new SyncDescriptor(KeyboardLayoutMainService)); @@ -1206,6 +1209,11 @@ export class CodeApplication extends Disposable { mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel); sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewChannelName, browserViewChannel)); + // Browser View Group + const browserViewGroupChannel = ProxyChannel.fromService(accessor.get(IBrowserViewGroupMainService), disposables); + mainProcessElectronServer.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel); + sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel)); + // Signing const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables); mainProcessElectronServer.registerChannel('sign', signChannel); diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 6569ef1fbc8..e112b958d73 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -136,6 +136,7 @@ import { IMeteredConnectionService } from '../../../platform/meteredConnection/c import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; import { IPlaywrightService } from '../../../platform/browserView/common/playwrightService.js'; import { PlaywrightService } from '../../../platform/browserView/node/playwrightService.js'; +import { IBrowserViewGroupRemoteService, BrowserViewGroupRemoteService } from '../../../platform/browserView/node/browserViewGroupRemoteService.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -404,6 +405,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService)); // Playwright + services.set(IBrowserViewGroupRemoteService, new SyncDescriptor(BrowserViewGroupRemoteService)); services.set(IPlaywrightService, new SyncDescriptor(PlaywrightService)); return new InstantiationService(services); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts index 475902a836e..68e5f4502e2 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts @@ -513,6 +513,8 @@ export class InlineEditItem extends InlineSuggestionItemBase { let inlineEditModelVersion = this._inlineEditModelVersion; let newAction: InlineSuggestionAction | undefined; + const updatedTarget = TextModelValueReference.snapshot(textModel); + if (this.action?.kind === 'edit') { // TODO What about rename? edits = edits.map(innerEdit => innerEdit.applyTextModelChanges(textModelChanges)); @@ -546,7 +548,7 @@ export class InlineEditItem extends InlineSuggestionItemBase { snippetInfo: this.snippetInfo, stringEdit: newEdit, alternativeAction: this.action.alternativeAction, - target: this.originalTextRef, + target: updatedTarget, }; } else if (this.action?.kind === 'jumpTo') { const jumpToOffset = this.action.offset; @@ -560,7 +562,7 @@ export class InlineEditItem extends InlineSuggestionItemBase { kind: 'jumpTo', position: newJumpToPosition, offset: newJumpToOffset, - target: this.originalTextRef, + target: updatedTarget, }; } else { newAction = undefined; @@ -582,7 +584,7 @@ export class InlineEditItem extends InlineSuggestionItemBase { newDisplayLocation, lastChangePartOfInlineEdit, inlineEditModelVersion, - this.originalTextRef, + updatedTarget, ); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts index 52015f27148..afecdbf91d5 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts @@ -36,7 +36,7 @@ export class GutterIndicatorMenuContent { constructor( private readonly _editorObs: ObservableCodeEditor, private readonly _data: InlineSuggestionGutterMenuData, - private readonly _close: (focusEditor: boolean) => void, + private readonly _close: (focusEditor: boolean, commandId?: string) => void, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ICommandService private readonly _commandService: ICommandService, @@ -59,12 +59,36 @@ export class GutterIndicatorMenuContent { isActive: activeElement.map(v => v === options.id), onHoverChange: v => activeElement.set(v ? options.id : undefined, undefined), onAction: () => { - this._close(true); - return this._commandService.executeCommand(typeof options.commandId === 'string' ? options.commandId : options.commandId.get(), ...(options.commandArgs ?? [])); + const commandId = typeof options.commandId === 'string' ? options.commandId : options.commandId.get(); + this._close(true, commandId); + return this._commandService.executeCommand(commandId, ...(options.commandArgs ?? [])); }, }; }; + const extensionCommandGroups = this._data.extensionCommands.map(group => + group.map((c, idx) => option(createOptionArgs({ + id: c.command.id + '_' + idx, + title: c.command.title, + icon: c.icon ?? Codicon.symbolEvent, + commandId: c.command.id, + commandArgs: c.command.arguments + }))) + ); + + const extensionCommandNodes: ChildNode = []; + for (const group of extensionCommandGroups) { + if (group.length > 0) { + extensionCommandNodes.push(separator()); + extensionCommandNodes.push(...group); + } + } + + if (this._data.extensionCommandsOnly) { + // drop leading separator + return hoverContent(extensionCommandNodes.slice(1)); + } + const title = header(this._data.displayName); const gotoAndAccept = option(createOptionArgs({ @@ -88,14 +112,6 @@ export class GutterIndicatorMenuContent { commandId: inlineSuggestCommitAlternativeActionId, })) : undefined; - const extensionCommands = this._data.extensionCommands.map((c, idx) => option(createOptionArgs({ - id: c.command.id + '_' + idx, - title: c.command.title, - icon: c.icon ?? Codicon.symbolEvent, - commandId: c.command.id, - commandArgs: c.command.arguments - }))); - const showModelEnabled = false; const modelOptions = showModelEnabled ? this._data.modelInfo?.models.map((m: { id: string; name: string }) => option({ title: m.name, @@ -160,11 +176,10 @@ export class GutterIndicatorMenuContent { toggleCollapsedMode, modelOptions.length ? separator() : undefined, ...modelOptions, - extensionCommands.length ? separator() : undefined, snooze, settings, - ...extensionCommands, + ...extensionCommandNodes, actionBarFooter ? separator() : undefined, actionBarFooter diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts index 39294382663..27a5b2edaea 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts @@ -36,6 +36,7 @@ import { InlineSuggestAlternativeAction } from '../../../model/InlineSuggestAlte import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { IUserInteractionService } from '../../../../../../../platform/userInteraction/browser/userInteractionService.js'; +import { Event, Emitter } from '../../../../../../../base/common/event.js'; /** * Customization options for the gutter indicator appearance and behavior. @@ -58,10 +59,11 @@ export class InlineEditsGutterIndicatorData { export class InlineSuggestionGutterMenuData { public static fromInlineSuggestion(suggestion: InlineSuggestionItem): InlineSuggestionGutterMenuData { const alternativeAction = suggestion.action?.kind === 'edit' ? suggestion.action.alternativeAction : undefined; + const commands = suggestion.source.inlineSuggestions.commands ?? []; return new InlineSuggestionGutterMenuData( suggestion.gutterMenuLinkAction, suggestion.source.provider.displayName ?? localize('inlineSuggestion', "Inline Suggestion"), - suggestion.source.inlineSuggestions.commands ?? [], + commands.length > 0 ? [commands] : [], alternativeAction, suggestion.source.provider.modelInfo, suggestion.source.provider.setModelId?.bind(suggestion.source.provider), @@ -71,10 +73,11 @@ export class InlineSuggestionGutterMenuData { constructor( readonly action: Command | undefined, readonly displayName: string, - readonly extensionCommands: InlineCompletionCommand[], + readonly extensionCommands: InlineCompletionCommand[][], readonly alternativeAction: InlineSuggestAlternativeAction | undefined, readonly modelInfo: IInlineCompletionModelInfo | undefined, readonly setModelId: ((modelId: string) => Promise) | undefined, + readonly extensionCommandsOnly: boolean = false, ) { } } @@ -97,6 +100,10 @@ const CODICON_SIZE_PX = 16; const CODICON_PADDING_PX = 2; export class InlineEditsGutterIndicator extends Disposable { + + private readonly _onDidCloseWithCommand = this._register(new Emitter()); + readonly onDidCloseWithCommand: Event = this._onDidCloseWithCommand.event; + constructor( private readonly _editorObs: ObservableCodeEditor, private readonly _data: IObservable, @@ -472,10 +479,13 @@ export class InlineEditsGutterIndicator extends Disposable { GutterIndicatorMenuContent, this._editorObs, data.gutterMenuData, - (focusEditor) => { + (focusEditor, commandId) => { if (focusEditor) { this._editorObs.editor.focus(); } + if (commandId) { + this._onDidCloseWithCommand.fire(commandId); + } h?.dispose(); }, ).toDisposableLiveElement()); @@ -584,6 +594,7 @@ export class InlineEditsGutterIndicator extends Disposable { width: layout.map(l => l.iconRect.width), position: 'relative', right: layout.map(l => l.iconDirection === 'top' ? '1px' : '0'), + color: this._data.map(d => d?.customization?.icon?.color ? asCssVariable(d.customization.icon.color.id) : undefined), } }, [ layout.map((l, reader) => withStyles(renderIcon(l.icon.read(reader)), { fontSize: toPx(Math.min(l.iconRect.width - CODICON_PADDING_PX, CODICON_SIZE_PX)) })), diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css index cbc244fc3ed..5359dc95b9d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css @@ -217,6 +217,10 @@ .monaco-keybinding-key { font-size: 13px; opacity: 0.7; + padding: 0; + border: none; + margin: 0; + min-width: unset; } &.active { @@ -270,4 +274,3 @@ background-position: center; background-repeat: no-repeat; } - diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 2a452609df6..554a7cde357 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { Button } from '../../../base/browser/ui/button/button.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; @@ -18,7 +19,7 @@ import './actionWidget.css'; import { localize } from '../../../nls.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; -import { defaultListStyles } from '../../theme/browser/defaultStyles.js'; +import { defaultButtonStyles, defaultListStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable } from '../../theme/common/colorRegistry.js'; import { ILayoutService } from '../../layout/browser/layoutService.js'; import { IHoverService } from '../../hover/browser/hover.js'; @@ -67,16 +68,42 @@ export interface IActionListItem { * Optional toolbar actions shown when the item is focused or hovered. */ readonly toolbarActions?: IAction[]; + /** + * Optional section identifier. Items with the same section belong to the same + * collapsible group. Only meaningful when the ActionList is created with + * collapsible sections. + */ + readonly section?: string; + /** + * When true, clicking this item toggles the section's collapsed state + * instead of selecting it. + */ + readonly isSectionToggle?: boolean; + /** + * Optional CSS class name to add to the row container. + */ + readonly className?: string; + /** + * Optional badge text to display after the label (e.g., "New"). + */ + readonly badge?: string; + /** + * When set, the description is rendered as a primary button. + * The callback is invoked when the button is clicked. + */ + readonly descriptionButton?: { readonly label: string; readonly onDidClick: () => void }; } interface IActionMenuTemplateData { readonly container: HTMLElement; readonly icon: HTMLElement; readonly text: HTMLElement; + readonly badge: HTMLElement; readonly description?: HTMLElement; readonly keybinding: KeybindingLabel; readonly toolbar: HTMLElement; readonly elementDisposables: DisposableStore; + previousClassName?: string; } export const enum ActionListItemKind { @@ -159,6 +186,10 @@ class ActionItemRenderer implements IListRenderer, IAction text.className = 'title'; container.append(text); + const badge = document.createElement('span'); + badge.className = 'action-item-badge'; + container.append(badge); + const description = document.createElement('span'); description.className = 'description'; container.append(description); @@ -171,7 +202,7 @@ class ActionItemRenderer implements IListRenderer, IAction const elementDisposables = new DisposableStore(); - return { container, icon, text, description, keybinding, toolbar, elementDisposables }; + return { container, icon, text, badge, description, keybinding, toolbar, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -194,10 +225,40 @@ class ActionItemRenderer implements IListRenderer, IAction dom.setVisibility(!element.hideIcon, data.icon); + // Apply optional className - clean up previous to avoid stale classes + // from virtualized row reuse + if (data.previousClassName) { + data.container.classList.remove(data.previousClassName); + } + data.container.classList.toggle('action-list-custom', !!element.className); + if (element.className) { + data.container.classList.add(element.className); + } + data.previousClassName = element.className; + data.text.textContent = stripNewlines(element.label); + // Render optional badge + if (element.badge) { + data.badge.textContent = element.badge; + data.badge.style.display = ''; + } else { + data.badge.textContent = ''; + data.badge.style.display = 'none'; + } + // if there is a keybinding, prioritize over description for now - if (element.keybinding) { + if (element.descriptionButton) { + data.description!.textContent = ''; + data.description!.style.display = 'inline'; + const button = new Button(data.description!, { ...defaultButtonStyles, small: true }); + button.label = element.descriptionButton.label; + data.elementDisposables.add(button.onDidClick(e => { + e?.stopPropagation(); + element.descriptionButton!.onDidClick(); + })); + data.elementDisposables.add(button); + } else if (element.keybinding) { data.description!.textContent = element.keybinding.getLabel(); data.description!.style.display = 'inline'; data.description!.style.letterSpacing = '0.5px'; @@ -261,6 +322,26 @@ function getKeyboardNavigationLabel(item: IActionListItem): string | undef return undefined; } +/** + * Options for configuring the action list. + */ +export interface IActionListOptions { + /** + * When true, shows a filter input at the bottom of the list. + */ + readonly showFilter?: boolean; + + /** + * Section IDs that should be collapsed by default. + */ + readonly collapsedByDefault?: ReadonlySet; + + /** + * Minimum width for the action list. + */ + readonly minWidth?: number; +} + export class ActionList extends Disposable { public readonly domNode: HTMLElement; @@ -277,12 +358,20 @@ export class ActionList extends Disposable { private _hover = this._register(new MutableDisposable()); + private readonly _collapsedSections = new Set(); + private _filterText = ''; + private readonly _filterInput: HTMLInputElement | undefined; + private readonly _filterContainer: HTMLElement | undefined; + private _lastMinWidth = 0; + private _hasLaidOut = false; + constructor( user: string, preview: boolean, items: readonly IActionListItem[], private readonly _delegate: IActionListDelegate, accessibilityProvider: Partial>> | undefined, + private readonly _options: IActionListOptions | undefined, @IContextViewService private readonly _contextViewService: IContextViewService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ILayoutService private readonly _layoutService: ILayoutService, @@ -291,6 +380,14 @@ export class ActionList extends Disposable { super(); this.domNode = document.createElement('div'); this.domNode.classList.add('actionList'); + + // Initialize collapsed sections + if (this._options?.collapsedByDefault) { + for (const section of this._options.collapsedByDefault) { + this._collapsedSections.add(section); + } + } + const virtualDelegate: IListVirtualDelegate> = { getHeight: element => { switch (element.kind) { @@ -312,7 +409,7 @@ export class ActionList extends Disposable { new SeparatorRenderer(), ], { keyboardSupport: false, - typeNavigationEnabled: true, + typeNavigationEnabled: !this._options?.showFilter, keyboardNavigationLabelProvider: { getKeyboardNavigationLabel }, accessibilityProvider: { getAriaLabel: element => { @@ -352,13 +449,151 @@ export class ActionList extends Disposable { this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); this._allMenuItems = items; - this._list.splice(0, this._list.length, this._allMenuItems); + + // Create filter input + if (this._options?.showFilter) { + this._filterContainer = document.createElement('div'); + this._filterContainer.className = 'action-list-filter'; + + this._filterInput = document.createElement('input'); + this._filterInput.type = 'text'; + this._filterInput.className = 'action-list-filter-input'; + this._filterInput.placeholder = localize('actionList.filter.placeholder', "Search..."); + this._filterInput.setAttribute('aria-label', localize('actionList.filter.ariaLabel', "Filter items")); + this._filterContainer.appendChild(this._filterInput); + + this._register(dom.addDisposableListener(this._filterInput, 'input', () => { + this._filterText = this._filterInput!.value; + this._applyFilter(); + })); + + // Keyboard navigation from filter input + this._register(dom.addDisposableListener(this._filterInput, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + this._list.domFocus(); + const lastIndex = this._list.length - 1; + if (lastIndex >= 0) { + this._list.focusLast(undefined, this.focusCondition); + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + this._list.domFocus(); + this.focusNext(); + } else if (e.key === 'Enter') { + e.preventDefault(); + this.acceptSelected(); + } else if (e.key === 'Escape') { + if (this._filterText) { + e.preventDefault(); + e.stopPropagation(); + this._filterInput!.value = ''; + this._filterText = ''; + this._applyFilter(); + } + } + })); + } + + this._applyFilter(); if (this._list.length) { this.focusNext(); } } + private _toggleSection(section: string): void { + if (this._collapsedSections.has(section)) { + this._collapsedSections.delete(section); + } else { + this._collapsedSections.add(section); + } + this._applyFilter(); + } + + private _applyFilter(): void { + const filterLower = this._filterText.toLowerCase(); + const isFiltering = filterLower.length > 0; + const visible: IActionListItem[] = []; + + for (const item of this._allMenuItems) { + if (item.kind === ActionListItemKind.Header) { + if (isFiltering) { + // When filtering, skip all headers + continue; + } + visible.push(item); + continue; + } + + if (item.kind === ActionListItemKind.Separator) { + if (isFiltering) { + continue; + } + visible.push(item); + continue; + } + + // Action item + if (isFiltering) { + // When filtering, skip section toggle items and only match content + if (item.isSectionToggle) { + continue; + } + // Match against label and description + const label = (item.label ?? '').toLowerCase(); + const desc = (item.description ?? '').toLowerCase(); + if (label.includes(filterLower) || desc.includes(filterLower)) { + visible.push(item); + } + } else { + // Update icon for section toggle items based on collapsed state + if (item.isSectionToggle && item.section) { + const collapsed = this._collapsedSections.has(item.section); + visible.push({ + ...item, + group: { ...item.group!, icon: collapsed ? Codicon.chevronRight : Codicon.chevronDown }, + }); + continue; + } + // Not filtering - check collapsed sections + if (item.section && this._collapsedSections.has(item.section)) { + continue; + } + visible.push(item); + } + } + + // Capture whether the filter input currently has focus before splice + // which may cause DOM changes that shift focus. + const filterInputHasFocus = this._filterInput && dom.isActiveElement(this._filterInput); + + this._list.splice(0, this._list.length, visible); + + // Re-layout to adjust height after items changed + if (this._hasLaidOut) { + this.layout(this._lastMinWidth); + // Restore focus after splice destroyed DOM elements, + // otherwise the blur handler in ActionWidgetService closes the widget. + // Keep focus on the filter input if the user is typing a filter. + if (filterInputHasFocus) { + this._filterInput!.focus(); + } else { + this._list.domFocus(); + } + // Reposition the context view so the widget grows in the correct direction + this._contextViewService.layout(); + } + } + + /** + * Returns the filter container element, if filter is enabled. + * The caller is responsible for appending it to the widget DOM. + */ + get filterContainer(): HTMLElement | undefined { + return this._filterContainer; + } + private focusCondition(element: IActionListItem): boolean { return !element.disabled && element.kind === ActionListItemKind.Action; } @@ -371,39 +606,57 @@ export class ActionList extends Disposable { } layout(minWidth: number): number { - // Updating list height, depending on how many separators and headers there are. - const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length; - const numSeparators = this._allMenuItems.filter(item => item.kind === 'separator').length; - const itemsHeight = this._allMenuItems.length * this._actionLineHeight; - const heightWithHeaders = itemsHeight + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight; - const heightWithSeparators = heightWithHeaders + numSeparators * this._separatorLineHeight - numSeparators * this._actionLineHeight; - this._list.layout(heightWithSeparators); - let maxWidth = minWidth; + this._hasLaidOut = true; + this._lastMinWidth = minWidth; + // Compute height based on currently visible items in the list + const visibleCount = this._list.length; + let listHeight = 0; + for (let i = 0; i < visibleCount; i++) { + const element = this._list.element(i); + switch (element.kind) { + case ActionListItemKind.Header: + listHeight += this._headerLineHeight; + break; + case ActionListItemKind.Separator: + listHeight += this._separatorLineHeight; + break; + default: + listHeight += this._actionLineHeight; + break; + } + } - if (this._allMenuItems.length >= 50) { - maxWidth = 380; + this._list.layout(listHeight); + const effectiveMinWidth = Math.max(minWidth, this._options?.minWidth ?? 0); + let maxWidth = effectiveMinWidth; + + if (visibleCount >= 50) { + maxWidth = Math.max(380, effectiveMinWidth); } else { // For finding width dynamically (not using resize observer) - const itemWidths: number[] = this._allMenuItems.map((_, index): number => { - const element = this._getRowElement(index); + const itemWidths: number[] = []; + for (let i = 0; i < visibleCount; i++) { + const element = this._getRowElement(i); if (element) { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - return width; + itemWidths.push(width); } - return 0; - }); + } // resize observer - can be used in the future since list widget supports dynamic height but not width - maxWidth = Math.max(...itemWidths, minWidth); + maxWidth = Math.max(...itemWidths, effectiveMinWidth); } + const filterHeight = this._filterContainer ? 36 : 0; const maxVhPrecentage = 0.7; - const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage); - this._list.layout(height, maxWidth); + const maxHeight = this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage; + const height = Math.min(listHeight + filterHeight, maxHeight); + const listFinalHeight = height - filterHeight; + this._list.layout(listFinalHeight, maxWidth); - this.domNode.style.height = `${height}px`; + this.domNode.style.height = `${listFinalHeight}px`; this._list.domFocus(); return maxWidth; @@ -447,6 +700,10 @@ export class ActionList extends Disposable { } const element = e.elements[0]; + if (element.isSectionToggle) { + this._list.setSelection([]); + return; + } if (element.item && this.focusCondition(element)) { this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent); } else { @@ -526,6 +783,11 @@ export class ActionList extends Disposable { } private onListClick(e: IListMouseEvent>): void { + if (e.element && e.element.isSectionToggle && e.element.section) { + const section = e.element.section; + queueMicrotask(() => this._toggleSection(section)); + return; + } if (e.element && this.focusCondition(e.element)) { this._list.setFocus([]); } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 4ea3a49bff7..e3969db45e1 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -122,6 +122,7 @@ display: flex; gap: 6px; align-items: center; + color: var(--vscode-foreground) !important; } .action-widget .monaco-list-row.action .codicon { @@ -150,6 +151,16 @@ text-overflow: ellipsis; } +.action-widget .monaco-list-row.action .action-item-badge { + padding: 0px 6px; + border-radius: 10px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + line-height: 18px; + flex-shrink: 0; +} + .action-widget .monaco-list-row.action .monaco-keybinding > .monaco-keybinding-key { background-color: var(--vscode-keybindingLabel-background); color: var(--vscode-keybindingLabel-foreground); @@ -205,8 +216,10 @@ .action-widget .monaco-list .monaco-list-row .description { opacity: 0.7; margin-left: 0.5em; + flex-shrink: 0; } + /* Item toolbar - shows on hover/focus */ .action-widget .monaco-list-row.action .action-list-item-toolbar { display: none; @@ -227,3 +240,29 @@ gap: 4px; font-size: 12px; } + +/* Filter input */ +.action-widget .action-list-filter { + border-top: 1px solid var(--vscode-editorHoverWidget-border); + padding: 4px; +} + +.action-widget .action-list-filter-input { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 3px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; + outline: none; +} + +.action-widget .action-list-filter-input:focus { + border-color: var(--vscode-focusBorder); +} + +.action-widget .action-list-filter-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 21b49245beb..53483956586 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -10,7 +10,7 @@ import { KeyCode, KeyMod } from '../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import './actionWidget.css'; import { localize, localize2 } from '../../../nls.js'; -import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, previewSelectedActionCommand } from './actionList.js'; +import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, IActionListOptions, previewSelectedActionCommand } from './actionList.js'; import { Action2, registerAction2 } from '../../actions/common/actions.js'; import { IContextKeyService, RawContextKey } from '../../contextkey/common/contextkey.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; @@ -36,7 +36,7 @@ export const IActionWidgetService = createDecorator('actio export interface IActionWidgetService { readonly _serviceBrand: undefined; - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>): void; + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void; hide(didCancel?: boolean): void; @@ -60,10 +60,10 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { super(); } - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>): void { + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void { const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService); - const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider); + const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions); this._contextViewService.showContextView({ getAnchor: () => anchor, render: (container: HTMLElement) => { @@ -137,6 +137,11 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { } } + // Filter input (appended after the list, before action bar visually) + if (this._list.value?.filterContainer) { + widget.appendChild(this._list.value.filterContainer); + } + const width = this._list.value?.layout(actionBarWidth); widget.style.width = `${width}px`; diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 0fb0d916c6a..b7b61da059f 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -6,7 +6,7 @@ import { IActionWidgetService } from './actionWidget.js'; import { IAction } from '../../../base/common/actions.js'; import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover } from './actionList.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { Codicon } from '../../../base/common/codicons.js'; import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js'; @@ -52,6 +52,11 @@ export interface IActionWidgetDropdownOptions extends IBaseDropdownOptions { * provided, no telemetry will be sent. */ readonly reporter?: { id: string; name?: string; includeOptions?: boolean }; + + /** + * Options for the underlying ActionList (filter, collapsible sections). + */ + readonly listOptions?: IActionListOptions; } /** @@ -201,7 +206,8 @@ export class ActionWidgetDropdown extends BaseDropdown { this._options.getAnchor?.() ?? this.element, undefined, actionBarActions, - accessibilityProvider + accessibilityProvider, + this._options.listOptions ); } diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index c3d2db6d982..86afa862d5d 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -281,10 +281,11 @@ export class MenuId { static readonly ChatToolOutputResourceContext = new MenuId('ChatToolOutputResourceContext'); static readonly ChatMultiDiffContext = new MenuId('ChatMultiDiffContext'); static readonly ChatConfirmationMenu = new MenuId('ChatConfirmationMenu'); - static readonly ChatEditorInlineGutter = new MenuId('ChatEditorInlineGutter'); + static readonly ChatEditorInlineMenu = new MenuId('ChatEditorInlineGutter'); static readonly ChatEditorInlineExecute = new MenuId('ChatEditorInputExecute'); static readonly ChatEditorInlineInputSide = new MenuId('ChatEditorInputSide'); static readonly InlineChatEditorAffordance = new MenuId('InlineChatEditorAffordance'); + static readonly InlineChatInput = new MenuId('InlineChatInput'); static readonly AccessibleView = new MenuId('AccessibleView'); static readonly MultiDiffEditorContent = new MenuId('MultiDiffEditorContent'); static readonly MultiDiffEditorFileToolbar = new MenuId('MultiDiffEditorFileToolbar'); diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index 218acce24fd..5f6b0c82414 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -46,6 +46,10 @@ export interface INativeBrowserElementsService { getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise; startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise; + + startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise; + + getConsoleLogs(locator: IBrowserTargetLocator): Promise; } /** diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index fbf52a2a064..4e53a021745 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -25,6 +25,17 @@ interface NodeDataResponse { bounds: IRectangle; } +const MAX_CONSOLE_LOG_ENTRIES = 1000; +const consoleLogStore = new Map(); + +function locatorKey(locator: IBrowserTargetLocator): string { + const key = locator.browserViewId ?? locator.webviewId; + if (!key) { + return 'unknown'; + } + return key; +} + export class NativeBrowserElementsMainService extends Disposable implements INativeBrowserElementsMainService { _serviceBrand: undefined; @@ -38,11 +49,82 @@ export class NativeBrowserElementsMainService extends Disposable implements INat get windowId(): never { throw new Error('Not implemented in electron-main'); } + async getConsoleLogs(windowId: number | undefined, locator: IBrowserTargetLocator): Promise { + const key = locatorKey(locator); + const entries = consoleLogStore.get(key); + if (!entries || entries.length === 0) { + return undefined; + } + return entries.join('\n'); + } + + async startConsoleSession(windowId: number | undefined, token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise { + const window = this.windowById(windowId); + if (!window?.win) { + return undefined; + } + + let targetWebContents: Electron.WebContents | undefined; + if (locator.browserViewId) { + targetWebContents = this.browserViewMainService.tryGetBrowserView(locator.browserViewId)?.webContents; + } + + if (!targetWebContents) { + return undefined; + } + + const key = locatorKey(locator); + if (!consoleLogStore.has(key)) { + consoleLogStore.set(key, []); + } + + const levelMap: Record = { 0: 'log', 1: 'warning', 2: 'error' }; + const onConsoleMessage = (_event: Electron.Event, level: number, message: string, _line: number, _sourceId: string) => { + const levelName = levelMap[level] ?? 'log'; + const formatted = `[${levelName}] ${message}`; + const current = consoleLogStore.get(key) ?? []; + current.push(formatted); + if (current.length > MAX_CONSOLE_LOG_ENTRIES) { + current.splice(0, current.length - MAX_CONSOLE_LOG_ENTRIES); + } + consoleLogStore.set(key, current); + }; + + const cleanupListeners = () => { + targetWebContents?.off('console-message', onConsoleMessage); + window.win?.webContents.off('ipc-message', onIpcMessage); + }; + + const onIpcMessage = async (_event: Electron.Event, channel: string, closedCancelAndDetachId: number) => { + if (channel === `vscode:cancelConsoleSession${cancelAndDetachId}`) { + if (cancelAndDetachId !== closedCancelAndDetachId) { + return; + } + cleanupListeners(); + consoleLogStore.delete(key); + } + }; + + targetWebContents.on('console-message', onConsoleMessage); + + targetWebContents.once('destroyed', () => { + cleanupListeners(); + consoleLogStore.delete(key); + }); + + token.onCancellationRequested(() => { + cleanupListeners(); + consoleLogStore.delete(key); + }); + + window.win.webContents.on('ipc-message', onIpcMessage); + } + /** * Find the webview target that matches the given locator. * Checks either webviewId or browserViewId depending on what's provided. */ - async findWebviewTarget(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise { + private async findWebviewTarget(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise { const { targetInfos } = await debuggers.sendCommand('Target.getTargets'); if (locator.webviewId) { diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 5c8c9517dfb..f22fd39e70b 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -275,9 +275,4 @@ export interface IBrowserViewService { * @param id The browser view identifier */ clearStorage(id: string): Promise; - - /** - * Get a CDP WebSocket endpoint URL. - */ - getDebugWebSocketEndpoint(): Promise; } diff --git a/src/vs/platform/browserView/common/browserViewGroup.ts b/src/vs/platform/browserView/common/browserViewGroup.ts new file mode 100644 index 00000000000..0f43b98c8b0 --- /dev/null +++ b/src/vs/platform/browserView/common/browserViewGroup.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { IDisposable } from '../../../base/common/lifecycle.js'; + +export const ipcBrowserViewGroupChannelName = 'browserViewGroup'; + +/** + * Fired when a browser view is added to or removed from a group. + */ +export interface IBrowserViewGroupViewEvent { + /** The ID of the browser view that was added or removed. */ + readonly viewId: string; +} + +/** + * A browser view group - an isolated collection of browser views. + * + * This interface is shared between the main-process entity and remote proxies. + */ +export interface IBrowserViewGroup extends IDisposable { + readonly id: string; + + readonly onDidAddView: Event; + readonly onDidRemoveView: Event; + readonly onDidDestroy: Event; + + addView(viewId: string): Promise; + removeView(viewId: string): Promise; + getDebugWebSocketEndpoint(): Promise; +} + +/** + * Common service for managing browser view groups across processes. + * + * A browser view group is an isolated collection of browser views that can be + * independently exposed to different services or CDP clients. + * + * This interface is consumed via {@link ProxyChannel}. + * The main-process implementation is {@link BrowserViewGroupMainService}. + */ +export interface IBrowserViewGroupService { + + // Dynamic events - one per group instance, keyed by group ID. + onDynamicDidAddView(groupId: string): Event; + onDynamicDidRemoveView(groupId: string): Event; + onDynamicDidDestroy(groupId: string): Event; + + /** + * Create a new browser view group. + * @returns The id of the newly created group. + */ + createGroup(): Promise; + + /** + * Destroy a browser view group. + * Views in the group are **not** destroyed - they are simply detached. + * @param groupId The group identifier. + */ + destroyGroup(groupId: string): Promise; + + /** + * Add a browser view to a group. + * A view can belong to multiple groups simultaneously. + * @param groupId The group identifier. + * @param viewId The browser view identifier. + */ + addViewToGroup(groupId: string, viewId: string): Promise; + + /** + * Remove a browser view from a group. + * @param groupId The group identifier. + * @param viewId The browser view identifier. + */ + removeViewFromGroup(groupId: string, viewId: string): Promise; + + /** + * Get a short-lived CDP WebSocket endpoint URL for a specific group. + * The returned URL contains a single-use token. + * @param groupId The group identifier. + */ + getDebugWebSocketEndpoint(groupId: string): Promise; +} diff --git a/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts b/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts index 9142b497166..30ad512c042 100644 --- a/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts +++ b/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts @@ -22,44 +22,60 @@ export interface IBrowserViewCDPProxyServer { readonly _serviceBrand: undefined; /** - * Returns a debug endpoint with a short-lived, single-use token. + * Returns a debug endpoint with a short-lived, single-use token for a specific browser target. */ - getWebSocketEndpoint(): Promise; + getWebSocketEndpointForTarget(target: ICDPBrowserTarget): Promise; + + /** + * Unregister a previously registered browser target. + */ + removeTarget(target: ICDPBrowserTarget): Promise; } /** * WebSocket server that provides CDP debugging for browser views. + * + * Manages a registry of {@link ICDPBrowserTarget} instances, each reachable + * at its own `/devtools/browser/{id}` WebSocket endpoint. */ export class BrowserViewCDPProxyServer extends Disposable implements IBrowserViewCDPProxyServer { declare readonly _serviceBrand: undefined; private server: http.Server | undefined; private port: number | undefined; - private readonly tokens: TokenManager; + + private readonly tokens = this._register(new TokenManager()); + private readonly targets = new Map(); constructor( - private readonly browserTarget: ICDPBrowserTarget, @ILogService private readonly logService: ILogService ) { super(); - - this.tokens = this._register(new TokenManager()); } /** - * Returns a debug endpoint with a short-lived, single-use token in the - * WebSocket URL. The token is revoked once a WebSocket connection is made - * or after 30 seconds, whichever comes first. + * Register a browser target and return a WebSocket endpoint URL for it. + * The target is reachable at `/devtools/browser/{targetId}`. */ - async getWebSocketEndpoint(): Promise { + async getWebSocketEndpointForTarget(target: ICDPBrowserTarget): Promise { await this.ensureServerStarted(); - const token = await this.tokens.issueToken(); - return this.getWebSocketUrl(token); + const targetInfo = await target.getTargetInfo(); + const targetId = targetInfo.targetId; + + // Register (or re-register) the target + this.targets.set(targetId, target); + + const token = await this.tokens.issueToken(targetId); + return `ws://localhost:${this.port}/devtools/browser/${targetId}?token=${token}`; } - private getWebSocketUrl(token: string): string { - return `ws://localhost:${this.port}/devtools/browser?token=${token}`; + /** + * Unregister a previously registered browser target. + */ + async removeTarget(target: ICDPBrowserTarget): Promise { + const targetInfo = await target.getTargetInfo(); + this.targets.delete(targetInfo.targetId); } private async ensureServerStarted(): Promise { @@ -93,14 +109,7 @@ export class BrowserViewCDPProxyServer extends Disposable implements IBrowserVie private handleWebSocketUpgrade(req: http.IncomingMessage, socket: Socket): void { const [pathname, params] = (req.url || '').split('?'); - const token = new URLSearchParams(params).get('token'); - if (!token || !this.tokens.consumeToken(token)) { - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); - socket.end(); - return; - } - - const browserMatch = pathname.match(/^\/devtools\/browser(\/.*)?$/); + const browserMatch = pathname.match(/^\/devtools\/browser\/([^/?]+)$/); this.logService.debug(`[BrowserViewDebugProxy] WebSocket upgrade requested: ${pathname}`); @@ -111,6 +120,24 @@ export class BrowserViewCDPProxyServer extends Disposable implements IBrowserVie return; } + const targetId = browserMatch[1]; + + const token = new URLSearchParams(params).get('token'); + const tokenTargetId = token && this.tokens.consumeToken(token); + if (!tokenTargetId || tokenTargetId !== targetId) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.end(); + return; + } + + const target = this.targets.get(targetId); + if (!target) { + this.logService.warn(`[BrowserViewDebugProxy] Browser target not found: ${targetId}`); + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.end(); + return; + } + this.logService.debug(`[BrowserViewDebugProxy] WebSocket connected: ${pathname}`); const upgraded = upgradeToISocket(req, socket, { @@ -122,7 +149,7 @@ export class BrowserViewCDPProxyServer extends Disposable implements IBrowserVie return; } - const proxy = new CDPBrowserProxy(this.browserTarget); + const proxy = new CDPBrowserProxy(target); const disposables = this.wireWebSocket(upgraded, proxy); this._register(disposables); this._register(upgraded); @@ -200,31 +227,35 @@ export class BrowserViewCDPProxyServer extends Disposable implements IBrowserVie } } -class TokenManager extends Disposable { - /** Map of currently valid single-use tokens. Each expires after 30 seconds. */ - private readonly tokens = new Map(); +class TokenManager extends Disposable { + /** Map of currently valid single-use tokens to their associated details. */ + private readonly tokens = new Map(); /** - * Creates a short-lived, single-use token. + * Creates a short-lived, single-use token bound to a specific target. * The token is revoked once consumed or after 30 seconds. */ - async issueToken(): Promise { + async issueToken(details: TDetails): Promise { const token = this.makeToken(); - this.tokens.set(token, { expiresAt: Date.now() + 30_000 }); + this.tokens.set(token, { details: Object.freeze(details), expiresAt: Date.now() + 30_000 }); this._register(disposableTimeout(() => this.tokens.delete(token), 30_000)); return token; } - consumeToken(token: string): boolean { + /** + * Consume a token. Returns the details it was issued with, or + * `undefined` if the token is invalid or expired. + */ + consumeToken(token: string): TDetails | undefined { if (!token) { - return false; + return undefined; } const info = this.tokens.get(token); if (!info) { - return false; + return undefined; } this.tokens.delete(token); - return Date.now() <= info.expiresAt; + return Date.now() <= info.expiresAt ? info.details : undefined; } private makeToken(): string { diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts new file mode 100644 index 00000000000..efd4d01abf3 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { BrowserView } from './browserView.js'; +import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; +import { CDPBrowserProxy } from '../common/cdp/proxy.js'; +import { IBrowserViewGroup, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; +import { IBrowserViewCDPProxyServer } from './browserViewCDPProxyServer.js'; +import { IBrowserViewMainService } from './browserViewMainService.js'; + +/** + * An isolated group of {@link BrowserView} instances exposed as CDP targets. + * + * Each group represents an independent CDP "browser" endpoint + * (`/devtools/browser/{id}`). Different groups can expose different + * subsets of browser views, enabling selective target visibility across + * CDP sessions. + * + * Created via {@link BrowserViewGroupMainService.createGroup}. + */ +export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, IBrowserViewGroup { + + private readonly views = new Map(); + private readonly viewListeners = this._register(new DisposableStore()); + + /** All context IDs known to this group, including those from views added to it. */ + private readonly knownContextIds = new Set(); + /** Browser context IDs created by this group via {@link createBrowserContext}. */ + private readonly ownedContextIds = new Set(); + + private readonly _onTargetCreated = this._register(new Emitter()); + readonly onTargetCreated: Event = this._onTargetCreated.event; + + private readonly _onTargetDestroyed = this._register(new Emitter()); + readonly onTargetDestroyed: Event = this._onTargetDestroyed.event; + + private readonly _onDidAddView = this._register(new Emitter()); + readonly onDidAddView: Event = this._onDidAddView.event; + + private readonly _onDidRemoveView = this._register(new Emitter()); + readonly onDidRemoveView: Event = this._onDidRemoveView.event; + + private readonly _onDidDestroy = this._register(new Emitter()); + readonly onDidDestroy: Event = this._onDidDestroy.event; + + constructor( + readonly id: string, + @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService, + @IBrowserViewCDPProxyServer private readonly cdpProxyServer: IBrowserViewCDPProxyServer, + ) { + super(); + } + + // #region View management + + /** + * Add a {@link BrowserView} to this group. + * Fires {@link onDidAddView} and {@link onTargetCreated}. + * Automatically removes the view when it closes. + */ + async addView(viewId: string): Promise { + if (this.views.has(viewId)) { + return; + } + const view = this.browserViewMainService.tryGetBrowserView(viewId); + if (!view) { + throw new Error(`Browser view ${viewId} not found`); + } + this.views.set(view.id, view); + this.knownContextIds.add(view.session.id); + this._onDidAddView.fire({ viewId: view.id }); + this._onTargetCreated.fire(view); + + this.viewListeners.add(Event.once(view.onDidClose)(() => { + this.removeView(viewId); + })); + } + + /** + * Remove a {@link BrowserView} from this group. + * Fires {@link onDidRemoveView} and {@link onTargetDestroyed} if the view was tracked. + */ + async removeView(viewId: string): Promise { + const view = this.views.get(viewId); + if (view && this.views.delete(viewId)) { + this._onDidRemoveView.fire({ viewId: view.id }); + this._onTargetDestroyed.fire(view); + } + } + + // #endregion + + // #region ICDPBrowserTarget implementation + + getVersion(): CDPBrowserVersion { + return this.browserViewMainService.getVersion(); + } + + getWindowForTarget(target: ICDPTarget): { windowId: number; bounds: CDPWindowBounds } { + return this.browserViewMainService.getWindowForTarget(target); + } + + async attach(): Promise { + return new CDPBrowserProxy(this); + } + + async getTargetInfo(): Promise { + return { + targetId: this.id, + type: 'browser', + title: this.getVersion().product, + url: '', + attached: true, + canAccessOpener: false + }; + } + + getTargets(): IterableIterator { + return this.views.values(); + } + + async createTarget(url: string, browserContextId?: string): Promise { + if (browserContextId && !this.knownContextIds.has(browserContextId)) { + throw new Error(`Unknown browser context ${browserContextId}`); + } + + const target = await this.browserViewMainService.createTarget(url, browserContextId); + if (target instanceof BrowserView) { + await this.addView(target.id); + } + return target; + } + + async activateTarget(target: ICDPTarget): Promise { + return this.browserViewMainService.activateTarget(target); + } + + async closeTarget(target: ICDPTarget): Promise { + if (target instanceof BrowserView) { + await this.removeView(target.id); + } + return this.browserViewMainService.closeTarget(target); + } + + // Browser context management + + /** + * Returns only the browser context IDs that are visible to this group, + * i.e. contexts used by views currently in the group. + */ + getBrowserContexts(): string[] { + return [...this.knownContextIds]; + } + + async createBrowserContext(): Promise { + const contextId = await this.browserViewMainService.createBrowserContext(); + this.knownContextIds.add(contextId); + this.ownedContextIds.add(contextId); + return contextId; + } + + async disposeBrowserContext(browserContextId: string): Promise { + if (!this.ownedContextIds.has(browserContextId)) { + throw new Error('Can only dispose browser contexts created by this group'); + } + + // Close views in this group that belong to the context before disposing + for (const view of this.views.values()) { + if (view.session.id === browserContextId) { + await this.removeView(view.id); + } + } + + this.knownContextIds.delete(browserContextId); + this.ownedContextIds.delete(browserContextId); + return this.browserViewMainService.disposeBrowserContext(browserContextId); + } + + // #endregion + + // #region CDP endpoint + + /** + * Get a WebSocket endpoint URL for connecting to this group's CDP + * session. The URL contains a short-lived, single-use token. + */ + async getDebugWebSocketEndpoint(): Promise { + return this.cdpProxyServer.getWebSocketEndpointForTarget(this); + } + + // #endregion + + override dispose(): void { + this._onDidDestroy.fire(); + this.cdpProxyServer.removeTarget(this); + super.dispose(); + } +} diff --git a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts new file mode 100644 index 00000000000..20dd6331c0e --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { Event } from '../../../base/common/event.js'; +import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { IBrowserViewGroupService, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; +import { BrowserViewGroup } from './browserViewGroup.js'; + +export const IBrowserViewGroupMainService = createDecorator('browserViewGroupMainService'); + +export interface IBrowserViewGroupMainService extends IBrowserViewGroupService { + readonly _serviceBrand: undefined; +} + +/** + * Main-process service that manages {@link BrowserViewGroup} instances. + * + * Implements {@link IBrowserViewGroupService} so it can be surfaced to + * the workbench/shared process via {@link ProxyChannel}. + */ +export class BrowserViewGroupMainService extends Disposable implements IBrowserViewGroupMainService { + declare readonly _serviceBrand: undefined; + + private readonly groups = this._register(new DisposableMap()); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService + ) { + super(); + } + + async createGroup(): Promise { + const id = generateUuid(); + const group = this.instantiationService.createInstance(BrowserViewGroup, id); + this.groups.set(id, group); + + // Auto-cleanup when the group disposes itself + Event.once(group.onDidDestroy)(() => { + this.groups.deleteAndLeak(id); + }); + + return id; + } + + async destroyGroup(groupId: string): Promise { + this.groups.deleteAndDispose(groupId); + } + + async addViewToGroup(groupId: string, viewId: string): Promise { + return this._getGroup(groupId).addView(viewId); + } + + async removeViewFromGroup(groupId: string, viewId: string): Promise { + return this._getGroup(groupId).removeView(viewId); + } + + async getDebugWebSocketEndpoint(groupId: string): Promise { + return this._getGroup(groupId).getDebugWebSocketEndpoint(); + } + + onDynamicDidAddView(groupId: string): Event { + return this._getGroup(groupId).onDidAddView; + } + + onDynamicDidRemoveView(groupId: string): Event { + return this._getGroup(groupId).onDidRemoveView; + } + + onDynamicDidDestroy(groupId: string): Event { + return this._getGroup(groupId).onDidDestroy; + } + + /** + * Get a group or throw if not found. + */ + private _getGroup(groupId: string): BrowserViewGroup { + const group = this.groups.get(groupId); + if (!group) { + throw new Error(`Browser view group ${groupId} not found`); + } + return group; + } +} + diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index f29b4c18a43..68e97b58b4f 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -15,7 +15,6 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { BrowserViewUri } from '../common/browserViewUri.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; -import { IBrowserViewCDPProxyServer } from './browserViewCDPProxyServer.js'; import { IProductService } from '../../product/common/productService.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; @@ -51,8 +50,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, - @IProductService private readonly productService: IProductService, - @IBrowserViewCDPProxyServer private readonly cdpProxyServer: IBrowserViewCDPProxyServer, + @IProductService private readonly productService: IProductService ) { super(); } @@ -363,8 +361,4 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa ); await browserSession.electronSession.clearData(); } - - async getDebugWebSocketEndpoint(): Promise { - return this.cdpProxyServer.getWebSocketEndpoint(); - } } diff --git a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts new file mode 100644 index 00000000000..b4aaffb612d --- /dev/null +++ b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; +import { IBrowserViewGroup, IBrowserViewGroupService, IBrowserViewGroupViewEvent, ipcBrowserViewGroupChannelName } from '../common/browserViewGroup.js'; + +export const IBrowserViewGroupRemoteService = createDecorator('browserViewGroupRemoteService'); + +/** + * Remote-process service for managing browser view groups. + * + * Connects to the main-process {@link BrowserViewGroupMainService} via + * IPC and provides {@link IBrowserViewGroup} instances for + * interacting with groups. + * + * Usable from the shared process. + */ +export interface IBrowserViewGroupRemoteService { + readonly _serviceBrand: undefined; + + /** + * Create a new browser view group. + */ + createGroup(): Promise; +} + +/** + * Remote proxy for a browser view group living in the main process. + */ +class RemoteBrowserViewGroup extends Disposable implements IBrowserViewGroup { + constructor( + readonly id: string, + private readonly groupService: IBrowserViewGroupService, + ) { + super(); + + this._register(groupService.onDynamicDidDestroy(this.id)(() => { + // Avoid loops + this.dispose(true); + })); + } + + get onDidAddView(): Event { + return this.groupService.onDynamicDidAddView(this.id); + } + + get onDidRemoveView(): Event { + return this.groupService.onDynamicDidRemoveView(this.id); + } + + get onDidDestroy(): Event { + return this.groupService.onDynamicDidDestroy(this.id); + } + + async addView(viewId: string): Promise { + return this.groupService.addViewToGroup(this.id, viewId); + } + + async removeView(viewId: string): Promise { + return this.groupService.removeViewFromGroup(this.id, viewId); + } + + async getDebugWebSocketEndpoint(): Promise { + return this.groupService.getDebugWebSocketEndpoint(this.id); + } + + override dispose(fromService = false): void { + if (!fromService) { + this.groupService.destroyGroup(this.id); + } + super.dispose(); + } +} + +export class BrowserViewGroupRemoteService implements IBrowserViewGroupRemoteService { + declare readonly _serviceBrand: undefined; + + private readonly _groupService: IBrowserViewGroupService; + private readonly _groups = new Map(); + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, + ) { + const channel = mainProcessService.getChannel(ipcBrowserViewGroupChannelName); + this._groupService = ProxyChannel.toService(channel); + } + + async createGroup(): Promise { + const id = await this._groupService.createGroup(); + return this._wrap(id); + } + + private _wrap(id: string): IBrowserViewGroup { + const group = new RemoteBrowserViewGroup(id, this._groupService); + this._groups.set(id, group); + + Event.once(group.onDidDestroy)(() => { + this._groups.delete(id); + }); + + return group; + } +} diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 2dcd8fbd04e..f19ee20bd94 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../base/common/lifecycle.js'; -import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { DeferredPromise } from '../../../base/common/async.js'; import { ILogService } from '../../log/common/log.js'; -import { IBrowserViewService, ipcBrowserViewChannelName } from '../common/browserView.js'; import { IPlaywrightService } from '../common/playwrightService.js'; -import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; +import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; +import { IBrowserViewGroup } from '../common/browserViewGroup.js'; // eslint-disable-next-line local/code-import-patterns -import type { Browser } from 'playwright-core'; +import type { Browser, BrowserContext, Page } from 'playwright-core'; /** * Shared-process implementation of {@link IPlaywrightService}. @@ -19,22 +19,22 @@ import type { Browser } from 'playwright-core'; export class PlaywrightService extends Disposable implements IPlaywrightService { declare readonly _serviceBrand: undefined; - private readonly browserViewService: IBrowserViewService; private _browser: Browser | undefined; + private _pages: PlaywrightPageManager | undefined; private _initPromise: Promise | undefined; constructor( - @IMainProcessService mainProcessService: IMainProcessService, + @IBrowserViewGroupRemoteService private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, @ILogService private readonly logService: ILogService, ) { super(); - - const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); - this.browserViewService = ProxyChannel.toService(channel); } + /** + * Ensure the Playwright browser connection and page map are initialized. + */ async initialize(): Promise { - if (this._browser?.isConnected()) { + if (this._pages) { return; } @@ -44,30 +44,41 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this._initPromise = (async () => { try { - this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); + this.logService.debug('[PlaywrightService] Creating browser view group'); + const group = this._register(await this.browserViewGroupRemoteService.createGroup()); + this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); const playwright = await import('playwright-core'); - const endpoint = await this.browserViewService.getDebugWebSocketEndpoint(); + const endpoint = await group.getDebugWebSocketEndpoint(); const browser = await playwright.chromium.connectOverCDP(endpoint); this.logService.debug('[PlaywrightService] Connected to browser'); - browser.on('disconnected', () => { - this.logService.debug('[PlaywrightService] Browser disconnected'); - if (this._browser === browser) { - this._browser = undefined; - } - }); - // This can happen if the service was disposed while we were waiting for the connection. In that case, clean up immediately. if (this._initPromise === undefined) { browser.close().catch(() => { /* ignore */ }); throw new Error('PlaywrightService was disposed during initialization'); } + const pageManager = this._register(new PlaywrightPageManager(group, browser, this.logService)); + + browser.on('disconnected', () => { + this.logService.debug('[PlaywrightService] Browser disconnected'); + if (this._browser === browser) { + group.dispose(); + pageManager.dispose(); + + this._browser = undefined; + this._pages = undefined; + this._initPromise = undefined; + } + }); + this._browser = browser; - } finally { + this._pages = pageManager; + } catch (e) { this._initPromise = undefined; + throw e; } })(); @@ -83,3 +94,272 @@ export class PlaywrightService extends Disposable implements IPlaywrightService super.dispose(); } } + +/** + * Correlates browser view IDs with Playwright {@link Page} instances. + * + * When a browser view is added to a group, two asynchronous events follow + * through independent channels: + * + * 1. The group fires {@link IBrowserViewGroup.onDidAddView} (via IPC). + * 2. Playwright receives a CDP `Target.targetCreated` event (via WebSocket) + * and fires a `page` event on the matching {@link BrowserContext}. + * + * This class pairs the two event streams by FIFO ordering: the first view-ID + * received is matched with the first page event received. + * + * A periodic scan handles the case where Playwright creates a new + * {@link BrowserContext} for a target whose session was previously unknown. + */ +class PlaywrightPageManager extends Disposable { + + private readonly _viewIdToPage = new Map(); + private readonly _pageToViewId = new WeakMap(); + + /** View IDs received from the group but not yet matched with a page. */ + private _viewIdQueue: Array<{ + viewId: string; + page: DeferredPromise; + }> = []; + + /** Pages received from Playwright but not yet matched with a view ID. */ + private _pageQueue: Array<{ + page: Page; + viewId: DeferredPromise; + }> = []; + + private readonly _watchedContexts = new WeakSet(); + private _scanTimer: ReturnType | undefined; + + constructor( + private readonly _group: IBrowserViewGroup, + private readonly _browser: Browser, + private readonly logService: ILogService, + ) { + super(); + + this._register(_group.onDidAddView(e => this.onViewAdded(e.viewId))); + this._register(_group.onDidRemoveView(e => this.onViewRemoved(e.viewId))); + this.scanForNewContexts(); + } + + /** + * Create a new page in the browser and return its associated page and view ID. + */ + async newPage(): Promise<{ viewId: string; page: Page }> { + const page = await this._browser.newPage(); + const viewId = await this.onPageAdded(page); + + return { viewId, page }; + } + + /** + * Explicitly add an existing browser view to the CDP group. + */ + async addPage(viewId: string): Promise { + if (this._viewIdToPage.has(viewId)) { + return; + } + if (this._viewIdQueue.some(item => item.viewId === viewId)) { + return; + } + + // ensure the viewId is queued so we can immediately fetch the promise via getPage(). + this.onViewAdded(viewId); + + try { + await this._group.addView(viewId); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + this.logService.error('[PlaywrightPageMap] Failed to add view:', errorMessage); + this.onViewRemoved(viewId); + } + } + + /** + * Remove a browser view from the CDP group. + */ + async removePage(viewId: string): Promise { + this.onViewRemoved(viewId); + try { + await this._group.removeView(viewId); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + this.logService.error('[PlaywrightPageMap] Failed to remove view:', errorMessage); + } + } + + /** + * Get the Playwright {@link Page} for a browser view that has already been added. + * Throws if the view has not been added. + */ + async getPage(viewId: string): Promise { + const resolved = this._viewIdToPage.get(viewId); + if (resolved) { + return resolved; + } + const queued = this._viewIdQueue.find(item => item.viewId === viewId); + if (queued) { + return queued.page.p; + } + + throw new Error(`Page "${viewId}" has not been added to the Playwright service`); + } + + /** + * Called when the group fires onDidAddView. Creates a deferred entry in + * the view ID queue and attempts to match it with a page. + */ + private onViewAdded(viewId: string, timeoutMs = 10000): Promise { + const resolved = this._viewIdToPage.get(viewId); + if (resolved) { + return Promise.resolve(resolved); + } + const queued = this._viewIdQueue.find(item => item.viewId === viewId); + if (queued) { + return queued.page.p; + } + + const deferred = new DeferredPromise(); + const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for page`)), timeoutMs); + + deferred.p.finally(() => { + clearTimeout(timeout); + this._viewIdQueue = this._viewIdQueue.filter(item => item.viewId !== viewId); + if (this._viewIdQueue.length === 0) { + this.stopScanning(); + } + }); + + this._viewIdQueue.push({ viewId, page: deferred }); + this.tryMatch(); + this.ensureScanning(); + + return deferred.p; + } + + private onViewRemoved(viewId: string): void { + this._viewIdQueue = this._viewIdQueue.filter(item => item.viewId !== viewId); + const page = this._viewIdToPage.get(viewId); + if (page) { + this._pageToViewId.delete(page); + } + this._viewIdToPage.delete(viewId); + } + + private onPageAdded(page: Page, timeoutMs = 10000): Promise { + const resolved = this._pageToViewId.get(page); + if (resolved) { + return Promise.resolve(resolved); + } + const queued = this._pageQueue.find(item => item.page === page); + if (queued) { + return queued.viewId.p; + } + + this.onContextAdded(page.context()); + page.once('close', () => this.onPageRemoved(page)); + + const deferred = new DeferredPromise(); + const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for browser view`)), timeoutMs); + deferred.p.finally(() => { + clearTimeout(timeout); + this._pageQueue = this._pageQueue.filter(item => item.page !== page); + }); + + this._pageQueue.push({ page, viewId: deferred }); + this.tryMatch(); + + return deferred.p; + } + + private onPageRemoved(page: Page): void { + this._pageQueue = this._pageQueue.filter(item => item.page !== page); + const viewId = this._pageToViewId.get(page); + if (viewId) { + this._viewIdToPage.delete(viewId); + } + this._pageToViewId.delete(page); + } + + private onContextAdded(context: BrowserContext): void { + if (this._watchedContexts.has(context)) { + return; + } + this._watchedContexts.add(context); + + context.on('page', (page: Page) => this.onPageAdded(page)); + context.on('close', () => this.onContextRemoved(context)); + + for (const page of context.pages()) { + this.onPageAdded(page); + } + } + + private onContextRemoved(context: BrowserContext): void { + this._watchedContexts.delete(context); + } + + // --- Matching --- + + /** + * Pair up queued view IDs with queued pages in FIFO order and resolve + * any callers waiting for the matched view IDs. + */ + private tryMatch(): void { + while (this._viewIdQueue.length > 0 && this._pageQueue.length > 0) { + const viewIdItem = this._viewIdQueue.shift()!; + const pageItem = this._pageQueue.shift()!; + + this._viewIdToPage.set(viewIdItem.viewId, pageItem.page); + this._pageToViewId.set(pageItem.page, viewIdItem.viewId); + + viewIdItem.page.complete(pageItem.page); + pageItem.viewId.complete(viewIdItem.viewId); + + this.logService.debug(`[PlaywrightPageMap] Matched view ${viewIdItem.viewId} → page`); + } + + if (this._viewIdQueue.length === 0) { + this.stopScanning(); + } + } + + // --- Context scanning --- + + /** + * Watch all current {@link BrowserContext BrowserContexts} for new pages. + * Also processes any existing pages in newly discovered contexts. + */ + private scanForNewContexts(): void { + for (const context of this._browser.contexts()) { + this.onContextAdded(context); + } + } + + private ensureScanning(): void { + if (this._scanTimer === undefined) { + this._scanTimer = setInterval(() => this.scanForNewContexts(), 100); + } + } + + private stopScanning(): void { + if (this._scanTimer !== undefined) { + clearInterval(this._scanTimer); + this._scanTimer = undefined; + } + } + + override dispose(): void { + this.stopScanning(); + for (const { page } of this._viewIdQueue) { + page.error(new Error('PlaywrightPageMap disposed')); + } + for (const { viewId } of this._pageQueue) { + viewId.error(new Error('PlaywrightPageMap disposed')); + } + this._viewIdQueue = []; + this._pageQueue = []; + super.dispose(); + } +} diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 4431f19b659..18c7915e8f2 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -339,7 +339,7 @@ export interface IPtyService { shutdown(id: number, immediate: boolean): Promise; input(id: number, data: string): Promise; sendSignal(id: number, signal: string): Promise; - resize(id: number, cols: number, rows: number): Promise; + resize(id: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): Promise; clearBuffer(id: number): Promise; getInitialCwd(id: number): Promise; getCwd(id: number): Promise; @@ -391,7 +391,7 @@ export interface IPtyServiceContribution { handleProcessReady(persistentProcessId: number, process: ITerminalChildProcess): void; handleProcessDispose(persistentProcessId: number): void; handleProcessInput(persistentProcessId: number, data: string): void; - handleProcessResize(persistentProcessId: number, cols: number, rows: number): void; + handleProcessResize(persistentProcessId: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void; } export interface IPtyHostController { @@ -810,7 +810,7 @@ export interface ITerminalChildProcess { input(data: string): void; sendSignal(signal: string): void; processBinary(data: string): Promise; - resize(cols: number, rows: number): void; + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void; clearBuffer(): void | Promise; /** diff --git a/src/vs/platform/terminal/node/ptyHostService.ts b/src/vs/platform/terminal/node/ptyHostService.ts index d85ba59ef12..0e66435f1ef 100644 --- a/src/vs/platform/terminal/node/ptyHostService.ts +++ b/src/vs/platform/terminal/node/ptyHostService.ts @@ -254,8 +254,8 @@ export class PtyHostService extends Disposable implements IPtyHostService { processBinary(id: number, data: string): Promise { return this._proxy.processBinary(id, data); } - resize(id: number, cols: number, rows: number): Promise { - return this._proxy.resize(id, cols, rows); + resize(id: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): Promise { + return this._proxy.resize(id, cols, rows, pixelWidth, pixelHeight); } clearBuffer(id: number): Promise { return this._proxy.clearBuffer(id); diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index 826b09f47c8..e73086df28c 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -455,13 +455,13 @@ export class PtyService extends Disposable implements IPtyService { return this._throwIfNoPty(id).writeBinary(data); } @traceRpc - async resize(id: number, cols: number, rows: number): Promise { + async resize(id: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): Promise { const pty = this._throwIfNoPty(id); if (pty) { for (const contrib of this._contributions) { - contrib.handleProcessResize(id, cols, rows); + contrib.handleProcessResize(id, cols, rows, pixelWidth, pixelHeight); } - pty.resize(cols, rows); + pty.resize(cols, rows, pixelWidth, pixelHeight); } } @traceRpc @@ -902,7 +902,7 @@ class PersistentTerminalProcess extends Disposable { writeBinary(data: string): Promise { return this._terminalProcess.processBinary(data); } - resize(cols: number, rows: number): void { + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void { if (this._inReplay) { return; } @@ -911,7 +911,7 @@ class PersistentTerminalProcess extends Disposable { // Buffered events should flush when a resize occurs this._bufferer.flushBuffer(this._persistentProcessId); - return this._terminalProcess.resize(cols, rows); + return this._terminalProcess.resize(cols, rows, pixelWidth, pixelHeight); } async clearBuffer(): Promise { this._serializer.clearBuffer(); diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index 707bfa7fc0f..1f49fa67daa 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -515,7 +515,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess } } - resize(cols: number, rows: number): void { + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void { if (this._store.isDisposed) { return; } @@ -537,7 +537,10 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess this._logService.trace('node-pty.IPty#resize', cols, rows); try { - this._ptyProcess.resize(cols, rows); + const pixelSize = pixelWidth !== undefined && pixelHeight !== undefined + ? { width: pixelWidth, height: pixelHeight } + : undefined; + this._ptyProcess.resize(cols, rows, pixelSize); } catch (e) { // Swallow error if the pty has already exited this._logService.trace('node-pty.IPty#resize exception ' + e.message); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 17ff3fee470..1448a8a3dca 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -704,6 +704,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-02-17 | Added `-webkit-app-region: drag` to sidebar title area so it can be used to drag the window; interactive children (actions, composite bar, labels) marked `no-drag`; CSS rules scoped to `.agent-sessions-workbench` in `parts/media/sidebarPart.css` | | 2026-02-13 | Documentation sync: Updated all file names, class names, and references to match current implementation. `AgenticWorkbench` → `Workbench`, `AgenticSidebarPart` → `SidebarPart`, `AgenticAuxiliaryBarPart` → `AuxiliaryBarPart`, `AgenticPanelPart` → `PanelPart`, `agenticWorkbench.ts` → `workbench.ts`, `agenticWorkbenchMenus.ts` → `menus.ts`, `agenticLayoutActions.ts` → `layoutActions.ts`, `AgenticTitleBarWidget` → `SessionsTitleBarWidget`, `AgenticTitleBarContribution` → `SessionsTitleBarContribution`. Removed references to deleted files (`sidebarRevealButton.ts`, `floatingToolbar.ts`, `agentic.contributions.ts`, `agenticTitleBarWidget.ts`). Updated pane composite architecture from `SyncDescriptor`-based to `AgenticPaneCompositePartService`. Moved account widget docs from titlebar to sidebar footer. Added documentation for sidebar footer, project bar, traffic light spacer, card appearance styling, widget directory, and new contrib structure (`accountMenu/`, `chat/`, `configuration/`, `sessions/`). Updated titlebar actions to reflect Run Script split button and Open submenu. Removed Toggle Maximize panel action (no longer registered). Updated contributions section with all current contributions and their locations. | | 2026-02-13 | Changed grid structure: sidebar now spans full window height at root level (HORIZONTAL root orientation); Titlebar moved inside right section; Grid is now `Sidebar \| [Titlebar / TopRight / Panel]` instead of `Titlebar / [Sidebar \| RightSection]`; Panel maximize now excludes both titlebar and sidebar; Floating toolbar positioning no longer depends on titlebar height | | 2026-02-11 | Simplified titlebar: replaced `BrowserTitlebarPart`-derived implementation with standalone `TitlebarPart` using three `MenuWorkbenchToolBar` sections (left/center/right); Removed `CommandCenterControl`, `WindowTitle`, layout toolbar, and manual toolbar management; Center section uses `Menus.CommandCenter` which renders session picker via `IActionViewItemService`; Right section uses `Menus.TitleBarRight` which includes account submenu; Removed `commandCenterControl.ts` file | diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 8d0fdbe0d1d..a6f273f3aeb 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -23,4 +23,5 @@ export const Menus = { AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), AuxiliaryBarTitleLeft: new MenuId('SessionsAuxiliaryBarTitleLeft'), SidebarFooter: new MenuId('SessionsSidebarFooter'), + SidebarCustomizations: new MenuId('SessionsSidebarCustomizations'), } as const; diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index abb205600d5..d4e677a6215 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -105,7 +105,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { { hasTitle: true, trailingSeparator: false, - borderWidth: () => 0, + borderWidth: () => (this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder)) ? 1 : 0, }, AuxiliaryBarPart.activeViewSettingsKey, ActiveAuxiliaryContext.bindTo(contextKeyService), diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index 1c845edee3b..d8f4c728949 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -8,6 +8,26 @@ display: none; } +/* Make the sidebar title area draggable to move the window */ +.agent-sessions-workbench .part.sidebar > .composite.title { + position: relative; +} + +.agent-sessions-workbench .part.sidebar > .composite.title > .titlebar-drag-region { + top: 0; + left: 0; + display: block; + position: absolute; + width: 100%; + height: 100%; + -webkit-app-region: drag; +} + +/* Interactive elements in the title area must not be draggable */ +.agent-sessions-workbench .part.sidebar > .composite.title .action-item { + -webkit-app-region: no-drag; +} + /* Sidebar Footer Container */ .monaco-workbench .part.sidebar > .sidebar-footer { display: flex; diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index fcae124b6e1..d632124f3de 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -99,7 +99,7 @@ export class SidebarPart extends AbstractPaneCompositePart { ) { super( Parts.SIDEBAR_PART, - { hasTitle: true, trailingSeparator: false, borderWidth: () => 0 }, + { hasTitle: true, trailingSeparator: false, borderWidth: () => (this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder)) ? 1 : 0 }, SidebarPart.activeViewletSettingsKey, ActiveViewletContext.bindTo(contextKeyService), SidebarFocusContext.bindTo(contextKeyService), @@ -134,6 +134,12 @@ export class SidebarPart extends AbstractPaneCompositePart { protected override createTitleArea(parent: HTMLElement): HTMLElement | undefined { const titleArea = super.createTitleArea(parent); + if (titleArea) { + // Add a drag region so the sidebar title area can be used to move the window, + // matching the titlebar's drag behavior. + prepend(titleArea, $('div.titlebar-drag-region')); + } + // macOS native: the sidebar spans full height and the traffic lights // overlay the top-left corner. Add a fixed-width spacer inside the // title area to push content horizontally past the traffic lights. diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index b2231dd47b8..f8839f0db5b 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -203,7 +203,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { })); // Right toolbar (driven by Menus.TitleBarRight - includes account submenu) - const rightToolbarContainer = append(this.rightContent, $('div.action-toolbar-container')); + const rightToolbarContainer = prepend(this.rightContent, $('div.action-toolbar-container')); this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRight, { contextMenu: Menus.TitleBarContext, telemetrySource: 'titlePart.right', @@ -258,8 +258,15 @@ export class TitlebarPart extends Part implements ITitlebarPart { private lastLayoutDimension: Dimension | undefined; + get hasZoomableElements(): boolean { + return true; // sessions titlebar always has command center and toolbar actions + } + get preventZoom(): boolean { - return getZoomFactor(getWindow(this.element)) < 1; + // Prevent zooming behavior if any of the following conditions are met: + // 1. Shrinking below the window control size (zoom < 1) + // 2. No custom items are present in the title bar + return getZoomFactor(getWindow(this.element)) < 1 || !this.hasZoomableElements; } override layout(width: number, height: number): void { @@ -342,6 +349,7 @@ export class AuxiliaryTitlebarPart extends TitlebarPart implements IAuxiliaryTit constructor( readonly container: HTMLElement, editorGroupsContainer: IEditorGroupsContainer, + private readonly mainTitlebar: TitlebarPart, @IContextMenuService contextMenuService: IContextMenuService, @IConfigurationService configurationService: IConfigurationService, @IInstantiationService instantiationService: IInstantiationService, @@ -354,6 +362,15 @@ export class AuxiliaryTitlebarPart extends TitlebarPart implements IAuxiliaryTit const id = AuxiliaryTitlebarPart.COUNTER++; super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); } + + override get preventZoom(): boolean { + // Prevent zooming behavior if any of the following conditions are met: + // 1. Shrinking below the window control size (zoom < 1) + // 2. No custom items are present in the main title bar + // The auxiliary title bar never contains any zoomable items itself, + // but we want to match the behavior of the main title bar. + return getZoomFactor(getWindow(this.element)) < 1 || !this.mainTitlebar.hasZoomableElements; + } } /** @@ -366,17 +383,21 @@ export class TitleService extends MultiWindowParts implements ITit readonly mainPart: TitlebarPart; constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @IThemeService themeService: IThemeService ) { super('workbench.agentSessionsTitleService', themeService, storageService); - this.mainPart = this._register(this.instantiationService.createInstance(MainTitlebarPart)); + this.mainPart = this._register(this.createMainTitlebarPart()); this.onMenubarVisibilityChange = this.mainPart.onMenubarVisibilityChange; this._register(this.registerPart(this.mainPart)); } + protected createMainTitlebarPart(): TitlebarPart { + return this.instantiationService.createInstance(MainTitlebarPart); + } + //#region Auxiliary Titlebar Parts createAuxiliaryTitlebarPart(container: HTMLElement, editorGroupsContainer: IEditorGroupsContainer, instantiationService: IInstantiationService): IAuxiliaryTitlebarPart { @@ -386,7 +407,7 @@ export class TitleService extends MultiWindowParts implements ITit const disposables = new DisposableStore(); - const titlebarPart = instantiationService.createInstance(AuxiliaryTitlebarPart, titlebarPartContainer, editorGroupsContainer); + const titlebarPart = this.doCreateAuxiliaryTitlebarPart(titlebarPartContainer, editorGroupsContainer, instantiationService); disposables.add(this.registerPart(titlebarPart)); disposables.add(Event.runAndSubscribe(titlebarPart.onDidChange, () => titlebarPartContainer.style.height = `${titlebarPart.height}px`)); @@ -397,6 +418,10 @@ export class TitleService extends MultiWindowParts implements ITit return titlebarPart; } + protected doCreateAuxiliaryTitlebarPart(container: HTMLElement, editorGroupsContainer: IEditorGroupsContainer, instantiationService: IInstantiationService): TitlebarPart & IAuxiliaryTitlebarPart { + return instantiationService.createInstance(AuxiliaryTitlebarPart, container, editorGroupsContainer, this.mainPart); + } + //#endregion //#region Service Implementation diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index a7c2ce6026f..d9b4e673d1a 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -236,7 +236,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { private readonly partVisibility: IPartVisibilityState = { sidebar: true, - auxiliaryBar: true, + auxiliaryBar: false, editor: false, panel: false, chatBar: true @@ -1059,12 +1059,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { } this.partVisibility.sidebar = !hidden; - - // Adjust CSS - for hiding, defer adding the class until animation - // completes so the part stays visible during the exit animation. - if (!hidden) { - this.mainContainer.classList.remove(LayoutClasses.SIDEBAR_HIDDEN); - } + this.mainContainer.classList.toggle(LayoutClasses.SIDEBAR_HIDDEN, hidden); // Propagate to grid this.workbenchGrid.setViewVisible( @@ -1087,12 +1082,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { } this.partVisibility.auxiliaryBar = !hidden; - - // Adjust CSS - for hiding, defer adding the class until animation - // completes so the part stays visible during the exit animation. - if (!hidden) { - this.mainContainer.classList.remove(LayoutClasses.AUXILIARYBAR_HIDDEN); - } + this.mainContainer.classList.toggle(LayoutClasses.AUXILIARYBAR_HIDDEN, hidden); // Propagate to grid this.workbenchGrid.setViewVisible( @@ -1115,15 +1105,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { } this.partVisibility.editor = !hidden; - - // Adjust CSS for main container - if (hidden) { - this.mainContainer.classList.add(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN); - this.mainContainer.classList.remove(LayoutClasses.EDITOR_MODAL_VISIBLE); - } else { - this.mainContainer.classList.remove(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN); - this.mainContainer.classList.add(LayoutClasses.EDITOR_MODAL_VISIBLE); - } + this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); + this.mainContainer.classList.toggle(LayoutClasses.EDITOR_MODAL_VISIBLE, !hidden); // Show/hide modal if (hidden) { @@ -1144,13 +1127,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { } this.partVisibility.panel = !hidden; - - // Adjust CSS - for hiding, defer adding the class until animation - // completes because `.nopanel .part.panel { display: none !important }` - // would instantly hide the panel content mid-animation. - if (!hidden) { - this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); - } + this.mainContainer.classList.toggle(LayoutClasses.PANEL_HIDDEN, hidden); // Propagate to grid this.workbenchGrid.setViewVisible( @@ -1173,13 +1150,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { } this.partVisibility.chatBar = !hidden; - - // Adjust CSS - if (hidden) { - this.mainContainer.classList.add(LayoutClasses.CHATBAR_HIDDEN); - } else { - this.mainContainer.classList.remove(LayoutClasses.CHATBAR_HIDDEN); - } + this.mainContainer.classList.toggle(LayoutClasses.CHATBAR_HIDDEN, hidden); // Propagate to grid this.workbenchGrid.setViewVisible(this.chatBarPartView, !hidden); diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts index 2b87f4219fa..9c05cd61f76 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts @@ -39,7 +39,7 @@ import { ILabelService } from '../../../../platform/label/common/label.js'; import { parseAllHookFiles } from '../../../../workbench/contrib/chat/browser/promptSyntax/hookUtils.js'; import { OS } from '../../../../base/common/platform.js'; import { IRemoteAgentService } from '../../../../workbench/services/remote/common/remoteAgentService.js'; -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Action, Separator } from '../../../../base/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; @@ -340,7 +340,7 @@ export class AICustomizationListWidget extends Disposable { @IPathService private readonly pathService: IPathService, @ILabelService private readonly labelService: ILabelService, @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @ILogService private readonly logService: ILogService, @IClipboardService private readonly clipboardService: IClipboardService, @ISCMService private readonly scmService: ISCMService, diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts index e087542891a..131fbeff9e9 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts @@ -5,7 +5,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { URI } from '../../../../base/common/uri.js'; -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { localize } from '../../../../nls.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -96,7 +96,7 @@ export const SIDEBAR_MIN_WIDTH = 150; export const SIDEBAR_MAX_WIDTH = 350; export const CONTENT_MIN_WIDTH = 400; -export function getActiveSessionRoot(activeSessionService: ISessionsWorkbenchService): URI | undefined { +export function getActiveSessionRoot(activeSessionService: ISessionsManagementService): URI | undefined { const session = activeSessionService.getActiveSession(); return session?.worktree ?? session?.repository; } diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts index 10fad486f0c..4571e7b0e0e 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts @@ -59,7 +59,7 @@ import { showConfigureHooksQuickPick } from '../../../../workbench/contrib/chat/ import { CustomizationCreatorService } from './customizationCreatorService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IWorkingCopyService } from '../../../../workbench/services/workingCopy/common/workingCopyService.js'; @@ -176,7 +176,7 @@ export class AICustomizationManagementEditor extends EditorPane { @IConfigurationService private readonly configurationService: IConfigurationService, @ILayoutService private readonly layoutService: ILayoutService, @ICommandService private readonly commandService: ICommandService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, ) { diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts index d6681398747..677fdf24320 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts @@ -27,7 +27,7 @@ import { AICustomizationManagementEditorInput } from './aiCustomizationManagemen import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; const $ = DOM.$; @@ -66,7 +66,7 @@ export class AICustomizationOverviewView extends ViewPane { @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IPromptsService private readonly promptsService: IPromptsService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts index 271a1f94d06..e1e8717e6af 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; @@ -30,7 +30,7 @@ export class CustomizationCreatorService { @ICommandService private readonly commandService: ICommandService, @IChatService private readonly chatService: IChatService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IPromptsService private readonly promptsService: IPromptsService, @IQuickInputService private readonly quickInputService: IQuickInputService, ) { } diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index b8948468fea..b24b57c831f 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -35,7 +35,7 @@ import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; //#region Context Keys @@ -487,7 +487,7 @@ export class AICustomizationViewPane extends ViewPane { @IMenuService private readonly menuService: IMenuService, @ILogService private readonly logService: ILogService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts index d1d035676a9..a5e7e3e70a8 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -13,7 +13,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; import { isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -44,7 +44,6 @@ import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/la import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; -import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; @@ -56,6 +55,7 @@ import { IActivityService, NumberBadge } from '../../../../workbench/services/ac import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; const $ = dom.$; @@ -94,6 +94,11 @@ interface IChangesFolderItem { readonly name: string; } +interface IActiveSession { + readonly resource: URI; + readonly sessionType: string; +} + type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem { @@ -201,8 +206,14 @@ export class ChangesViewPane extends ViewPane { this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); } - // Track the active session's editing session resource - private readonly activeSessionResource = observableValue(this, undefined); + // Track the active session used by this view + private readonly activeSession: IObservableWithChange; + private readonly activeSessionFileCountObs: IObservableWithChange; + private readonly activeSessionHasChangesObs: IObservableWithChange; + + get activeSessionHasChanges(): IObservable { + return this.activeSessionHasChangesObs; + } // Badge for file count private readonly badgeDisposable = this._register(new MutableDisposable()); @@ -220,9 +231,9 @@ export class ChangesViewPane extends ViewPane { @IHoverService hoverService: IHoverService, @IChatEditingService private readonly chatEditingService: IChatEditingService, @IEditorService private readonly editorService: IEditorService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IActivityService private readonly activityService: IActivityService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, ) { @@ -235,113 +246,85 @@ export class ChangesViewPane extends ViewPane { this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); this.viewModeContextKey.set(initialMode); + // Track active session from sessions management service + this.activeSession = derivedOpts({ + equalsFn: (a, b) => isEqual(a?.resource, b?.resource), + }, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + if (!activeSession?.resource) { + return undefined; + } + + return { + resource: activeSession.resource, + sessionType: getChatSessionType(activeSession.resource), + }; + }).recomputeInitiallyAndOnChange(this._store); + + this.activeSessionFileCountObs = this.createActiveSessionFileCountObservable(); + this.activeSessionHasChangesObs = this.activeSessionFileCountObs.map(fileCount => fileCount > 0).recomputeInitiallyAndOnChange(this._store); + // Setup badge tracking this.registerBadgeTracking(); - // Track active session from focused chat widgets - this.registerActiveSessionTracking(); - // Set chatSessionType on the view's context key service so ViewTitle // menu items can use it in their `when` clauses. Update reactively // when the active session changes. const viewSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); this._register(autorun(reader => { - const sessionResource = this.activeSessionResource.read(reader); - viewSessionTypeKey.set(sessionResource ? getChatSessionType(sessionResource) : ''); + const activeSession = this.activeSession.read(reader); + viewSessionTypeKey.set(activeSession?.sessionType ?? ''); })); } - private registerActiveSessionTracking(): void { - // Initialize with the last focused widget's session if available - const lastFocused = this.chatWidgetService.lastFocusedWidget; - if (lastFocused?.viewModel?.sessionResource) { - this.activeSessionResource.set(lastFocused.viewModel.sessionResource, undefined); - } - - // Listen for new widgets and track their focus - this._register(this.chatWidgetService.onDidAddWidget(widget => { - this._register(widget.onDidFocus(() => { - if (widget.viewModel?.sessionResource) { - this.activeSessionResource.set(widget.viewModel.sessionResource, undefined); - } - })); - - // Also track view model changes (when a widget loads a different session) - this._register(widget.onDidChangeViewModel(({ currentSessionResource }) => { - // Only update if this widget is focused - if (this.chatWidgetService.lastFocusedWidget === widget && currentSessionResource) { - this.activeSessionResource.set(currentSessionResource, undefined); - } - })); - })); - - // Track focus changes on existing widgets - for (const widget of this.chatWidgetService.getAllWidgets()) { - this._register(widget.onDidFocus(() => { - if (widget.viewModel?.sessionResource) { - this.activeSessionResource.set(widget.viewModel.sessionResource, undefined); - } - })); - - this._register(widget.onDidChangeViewModel(({ currentSessionResource }) => { - if (this.chatWidgetService.lastFocusedWidget === widget && currentSessionResource) { - this.activeSessionResource.set(currentSessionResource, undefined); - } - })); - } - } - private registerBadgeTracking(): void { - // Signal observable that triggers when sessions data changes + // Update badge when file count changes + this._register(autorun(reader => { + const fileCount = this.activeSessionFileCountObs.read(reader); + this.updateBadge(fileCount); + })); + } + + private createActiveSessionFileCountObservable(): IObservableWithChange { + const activeSessionResource = this.activeSession.map(a => a?.resource); + const sessionsChangedSignal = observableFromEvent( this, this.agentSessionsService.model.onDidChangeSessions, () => ({}), ); - // Observable for session file changes from agentSessionsService (cloud/background sessions) - // Reactive to both activeSessionResource changes AND session data changes const sessionFileChangesObs = derived(reader => { - const sessionResource = this.activeSessionResource.read(reader); + const sessionResource = activeSessionResource.read(reader); sessionsChangedSignal.read(reader); if (!sessionResource) { return Iterable.empty(); } + const model = this.agentSessionsService.getSession(sessionResource); return model?.changes instanceof Array ? model.changes : Iterable.empty(); }); - // Create observable for the number of files changed in the active session - // Combines both editing session entries and session file changes (for cloud/background sessions) - const fileCountObs = derived(reader => { - const sessionResource = this.activeSessionResource.read(reader); - if (!sessionResource) { + return derived(reader => { + const activeSession = this.activeSession.read(reader); + if (!activeSession) { return 0; } - // Background chat sessions render the working set based on the session files, not the editing session - const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; + const isBackgroundSession = activeSession.sessionType === AgentSessionProviders.Background; - // Count from editing session entries (skip for background sessions) let editingSessionCount = 0; if (!isBackgroundSession) { const sessions = this.chatEditingService.editingSessionsObs.read(reader); - const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); editingSessionCount = session ? session.entries.read(reader).length : 0; } - // Count from session file changes (cloud/background sessions) const sessionFiles = [...sessionFileChangesObs.read(reader)]; const sessionFilesCount = sessionFiles.length; return editingSessionCount + sessionFilesCount; - }); - - // Update badge when file count changes - this._register(autorun(reader => { - const fileCount = fileCountObs.read(reader); - this.updateBadge(fileCount); - })); + }).recomputeInitiallyAndOnChange(this._store); } private updateBadge(fileCount: number): void { @@ -404,25 +387,26 @@ export class ChangesViewPane extends ViewPane { private onVisible(): void { this.renderDisposables.clear(); + const activeSessionResource = this.activeSession.map(a => a?.resource); // Create observable for the active editing session // Note: We must read editingSessionsObs to establish a reactive dependency, // so that the view updates when a new editing session is added (e.g., cloud sessions) const activeEditingSessionObs = derived(reader => { - const sessionResource = this.activeSessionResource.read(reader); - if (!sessionResource) { + const activeSession = this.activeSession.read(reader); + if (!activeSession) { return undefined; } const sessions = this.chatEditingService.editingSessionsObs.read(reader); - return sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + return sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); }); // Create observable for edit session entries from the ACTIVE session only (local editing sessions) const editSessionEntriesObs = derived(reader => { - const sessionResource = this.activeSessionResource.read(reader); + const activeSession = this.activeSession.read(reader); // Background chat sessions render the working set based on the session files, not the editing session - if (sessionResource && getChatSessionType(sessionResource) === AgentSessionProviders.Background) { + if (activeSession?.sessionType === AgentSessionProviders.Background) { return []; } @@ -462,9 +446,9 @@ export class ChangesViewPane extends ViewPane { ); // Observable for session file changes from agentSessionsService (cloud/background sessions) - // Reactive to both activeSessionResource changes AND session data changes + // Reactive to both activeSession changes AND session data changes const sessionFileChangesObs = derived(reader => { - const sessionResource = this.activeSessionResource.read(reader); + const sessionResource = activeSessionResource.read(reader); sessionsChangedSignal.read(reader); if (!sessionResource) { return Iterable.empty(); @@ -530,8 +514,8 @@ export class ChangesViewPane extends ViewPane { // `chatSessionType == copilotcli` (e.g. Create Pull Request) are shown const chatSessionTypeKey = scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); this.renderDisposables.add(autorun(reader => { - const sessionResource = this.activeSessionResource.read(reader); - chatSessionTypeKey.set(sessionResource ? getChatSessionType(sessionResource) : ''); + const activeSession = this.activeSession.read(reader); + chatSessionTypeKey.set(activeSession?.sessionType ?? ''); })); // Bind required context keys for the menu buttons @@ -560,7 +544,7 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(autorun(reader => { const { isSessionMenu, added, removed } = topLevelStats.read(reader); - const sessionResource = this.activeSessionResource.read(reader); + const sessionResource = activeSessionResource.read(reader); reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 8d3344be48d..b17a4083009 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -14,15 +15,19 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { ISessionsWorkbenchService, IsNewChatSessionContext } from '../../sessions/browser/sessionsWorkbenchService.js'; -import { ITerminalService, ITerminalGroupService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { ISessionsManagementService, IsNewChatSessionContext } from '../../sessions/browser/sessionsManagementService.js'; +import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { Menus } from '../../../browser/menus.js'; import { BranchChatSessionAction } from './branchChatSessionAction.js'; import { RunScriptContribution } from './runScriptAction.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { AgenticPromptsService } from './promptsService.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ChatViewContainerId, ChatViewId } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { NewChatViewPane, SessionsViewId } from './newChatViewPane.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; @@ -46,9 +51,9 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { override async run(accessor: ServicesAccessor,): Promise { const hostService = accessor.get(IHostService); - const agentSessionsService = accessor.get(ISessionsWorkbenchService); + const sessionsManagementService = accessor.get(ISessionsManagementService); - const activeSession = agentSessionsService.activeSession.get(); + const activeSession = sessionsManagementService.activeSession.get(); if (!activeSession) { return; } @@ -64,6 +69,33 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { } registerAction2(OpenSessionWorktreeInVSCodeAction); +class NewChatInSessionsWindowAction extends Action2 { + + constructor() { + super({ + id: 'workbench.action.sessions.newChat', + title: localize2('chat.newEdits.label', "New Chat"), + category: CHAT_CATEGORY, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 2, + primary: KeyMod.CtrlCmd | KeyCode.KeyN, + secondary: [KeyMod.CtrlCmd | KeyCode.KeyL], + mac: { + primary: KeyMod.CtrlCmd | KeyCode.KeyN, + secondary: [KeyMod.WinCtrl | KeyCode.KeyL] + }, + } + }); + } + + override run(accessor: ServicesAccessor): void { + const sessionsManagementService = accessor.get(ISessionsManagementService); + sessionsManagementService.openNewSession(); + } +} + +registerAction2(NewChatInSessionsWindowAction); + export class OpenSessionInTerminalAction extends Action2 { constructor() { @@ -81,10 +113,10 @@ export class OpenSessionInTerminalAction extends Action2 { override async run(accessor: ServicesAccessor,): Promise { const terminalService = accessor.get(ITerminalService); - const terminalGroupService = accessor.get(ITerminalGroupService); - const agentSessionsService = accessor.get(ISessionsWorkbenchService); + const viewsService = accessor.get(IViewsService); + const sessionsManagementService = accessor.get(ISessionsManagementService); - const activeSession = agentSessionsService.activeSession.get(); + const activeSession = sessionsManagementService.activeSession.get(); const repository = isAgentSession(activeSession) && activeSession.providerType !== AgentSessionProviders.Cloud ? activeSession.worktree : undefined; @@ -94,7 +126,7 @@ export class OpenSessionInTerminalAction extends Action2 { terminalService.setActiveInstance(instance); } } - terminalGroupService.showPanel(true); + await viewsService.openView(TERMINAL_VIEW_ID, true); } } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 6316384afb3..75d90b5f97d 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -12,7 +12,7 @@ height: 100%; box-sizing: border-box; overflow-x: hidden; - transition: justify-content 0.4s ease; + padding-bottom: 10%; } .chat-full-welcome.revealed { @@ -29,32 +29,29 @@ overflow: visible; } -/* Mascot */ -.chat-full-welcome-mascot { - width: 80px; - height: 80px; +/* Watermark letterpress */ +.chat-full-welcome-letterpress { + width: 100%; + max-width: 200px; + aspect-ratio: 1/1; + background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-dark.svg'); background-size: contain; - background-repeat: no-repeat; background-position: center; + background-repeat: no-repeat; margin-top: 8px; - margin-bottom: 12px; - animation: chat-full-welcome-mascot-bounce 1s ease-in-out infinite; - transition: animation-duration 0.3s ease; - background-image: url('../../../../../workbench/browser/media/code-icon.svg'); + margin-bottom: 20px; } -.chat-full-welcome.revealed .chat-full-welcome-mascot { - animation-duration: 2s; +.vs .chat-full-welcome-letterpress { + background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-light.svg'); } -@keyframes chat-full-welcome-mascot-bounce { - 0%, 100% { - transform: translateY(0); - } +.hc-light .chat-full-welcome-letterpress { + background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-hcLight.svg'); +} - 50% { - transform: translateY(-6px); - } +.hc-black .chat-full-welcome-letterpress { + background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-hcDark.svg'); } /* Input slot */ @@ -71,18 +68,18 @@ animation: chat-full-welcome-fade-in 0.35s ease 0.15s both; } -/* Option group pickers container (below the input) */ +/* Option group pickers container (above the input) */ .chat-full-welcome-pickers-container { display: none; - justify-content: center; width: 100%; max-width: 800px; - margin: 12px; + margin: 0 0 24px 0; + padding: 0; box-sizing: border-box; } .chat-full-welcome.revealed .chat-full-welcome-pickers-container { - display: flex; + display: block; animation: chat-full-welcome-fade-in 0.35s ease 0.1s both; } @@ -103,6 +100,39 @@ margin-bottom: 0; } +/* Local mode picker (Workspace / Worktree) below input */ +.chat-full-welcome-local-mode { + width: 100%; + max-width: 800px; + margin-top: 8px; + box-sizing: border-box; + display: none; + flex-direction: row; + align-items: center; + min-height: 28px; +} + +.chat-full-welcome.revealed .chat-full-welcome-local-mode { + display: flex; +} + +.sessions-chat-local-mode-left { + display: flex; + align-items: center; + min-width: 0; +} + +.sessions-chat-local-mode-spacer { + flex: 1; +} + +.sessions-chat-local-mode-right { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + /* Ensure the input editor fits properly */ .chat-full-welcome-inputSlot .interactive-input-part { margin: 0; @@ -128,7 +158,7 @@ background-color: var(--vscode-input-background) !important; } -/* Pickers row - flat horizontal bar below the input */ +/* Pickers row - two equal halves */ .chat-full-welcome-pickers { display: flex; flex-direction: row; @@ -136,56 +166,76 @@ align-items: center; width: 100%; box-sizing: border-box; - padding: 0 2px; + padding: 0; } .chat-full-welcome-pickers:empty { display: none; } -/* Left group (target dropdown + left-side extension pickers) */ -.sessions-chat-pickers-left { - display: flex; - align-items: center; - gap: 2px; - min-width: 0; -} - -/* Spacer between left and right groups */ -.sessions-chat-pickers-spacer { +/* Left half: target switcher, right-justified */ +.sessions-chat-pickers-left-half { flex: 1; -} - -/* Right group (repo/folder pickers) */ -.sessions-chat-extension-pickers-right { display: flex; + justify-content: flex-end; align-items: center; - gap: 2px; min-width: 0; } -.sessions-chat-extension-pickers-right:empty { - display: none; -} - -/* Left extension pickers container */ -.sessions-chat-extension-pickers-left { +/* Right half: pickers, left-justified */ +.sessions-chat-pickers-right-half { + flex: 1; display: flex; + justify-content: flex-start; align-items: center; - gap: 2px; min-width: 0; } -.sessions-chat-extension-pickers-left:empty { +/* Separator between switcher and folder picker */ +.sessions-chat-pickers-left-separator { + width: 1px; + height: 22px; + background-color: var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + margin: 0 12px; + flex-shrink: 0; display: none; } +/* Target switcher radio buttons - bigger and fancier */ +.sessions-chat-dropdown-wrapper .monaco-custom-radio > .monaco-button { + font-size: 13px; + line-height: 1.4em; + padding: 4px 14px; +} + +.sessions-chat-dropdown-wrapper .monaco-custom-radio > .monaco-button:first-child { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} + +.sessions-chat-dropdown-wrapper .monaco-custom-radio > .monaco-button:last-child { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} + +.sessions-chat-dropdown-wrapper .monaco-custom-radio > .monaco-button:focus { + outline: none; +} + +/* Folder label next to the picker */ +.sessions-chat-folder-label { + font-size: 13px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + margin-right: 6px; +} + /* Target dropdown button */ .sessions-chat-dropdown-button { display: flex; align-items: center; height: 16px; - padding: 3px 0 3px 6px; + padding: 3px 3px 3px 6px; cursor: pointer; font-size: 13px; color: var(--vscode-descriptionForeground); @@ -231,7 +281,7 @@ display: flex; align-items: center; height: 16px; - padding: 3px 0 3px 6px; + padding: 3px 3px 3px 6px; background-color: transparent; border: none; color: var(--vscode-descriptionForeground); diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 54a452f8c3d..1ada74835c2 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -7,7 +7,8 @@ import './media/chatWidget.css'; import './media/chatWelcomePart.css'; import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { toAction } from '../../../../base/common/actions.js'; +import { Separator, toAction } from '../../../../base/common/actions.js'; +import { Radio } from '../../../../base/browser/ui/radio/radio.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; @@ -25,17 +26,14 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.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 { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import { asCSSUrl } from '../../../../base/browser/cssValue.js'; -import { FileAccess } from '../../../../base/common/network.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import { AgentSessionProviders, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; import { SearchableOptionPickerActionItem } from '../../../../workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.js'; @@ -43,10 +41,15 @@ import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/c import { IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { IModelPickerDelegate, ModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; +import { IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; +import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.js'; import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; @@ -104,6 +107,20 @@ class TargetConfig extends Disposable implements ITargetConfig { this._onDidChangeSelectedTarget.fire(target); } } + + setAllowedTargets(targets: AgentSessionProviders[]): void { + const newSet = new Set(targets); + this._allowedTargets.set(newSet, undefined); + this._onDidChangeAllowedTargets.fire(newSet); + + // If the currently selected target is no longer allowed, switch to the first allowed target + const current = this._selectedTarget.get(); + if (current && !newSet.has(current)) { + const fallback = newSet.values().next().value; + this._selectedTarget.set(fallback, undefined); + this._onDidChangeSelectedTarget.fire(fallback); + } + } } // #endregion @@ -119,6 +136,7 @@ export interface INewChatSendRequestData { readonly query: string; readonly sendOptions: IChatSendRequestOptions; readonly selectedOptions: ReadonlyMap; + readonly folderUri?: URI; } /** @@ -139,7 +157,7 @@ export interface INewChatWidgetOptions { */ class NewChatWidget extends Disposable { - private readonly _targetConfig: ITargetConfig; + private readonly _targetConfig: TargetConfig; private readonly _options: INewChatWidgetOptions; // Input @@ -155,6 +173,11 @@ class NewChatWidget extends Disposable { private _extensionPickersLeftContainer: HTMLElement | undefined; private _extensionPickersRightContainer: HTMLElement | undefined; private _inputSlot: HTMLElement | undefined; + private _localModeContainer: HTMLElement | undefined; + private _localModeDropdownContainer: HTMLElement | undefined; + private _localModePickersContainer: HTMLElement | undefined; + private _localMode: 'workspace' | 'worktree' = 'worktree'; + private _selectedFolderUri: URI | undefined; private readonly _pickerWidgets = new Map(); private readonly _pickerWidgetDisposables = this._register(new DisposableStore()); private readonly _optionEmitters = new Map>(); @@ -171,19 +194,29 @@ class NewChatWidget extends Disposable { @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IProductService private readonly productService: IProductService, @ILogService private readonly logService: ILogService, @IHoverService _hoverService: IHoverService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IWorkspacesService private readonly workspacesService: IWorkspacesService, + @IStorageService private readonly storageService: IStorageService, ) { super(); this._targetConfig = this._register(new TargetConfig(options.targetConfig)); this._options = options; + // Restore last picked folder + const lastFolder = this.storageService.get('agentSessions.lastPickedFolder', StorageScope.PROFILE); + if (lastFolder) { + try { this._selectedFolderUri = URI.parse(lastFolder); } catch { /* ignore */ } + } + // When target changes, regenerate pending resource this._register(this._targetConfig.onDidChangeSelectedTarget(() => { this._generatePendingSessionResource(); - this._updateTargetDropdown(); + this._notifyFolderSelection(); this._renderExtensionPickers(true); + this._renderLocalModePicker(); })); this._register(this._targetConfig.onDidChangeAllowedTargets(() => { @@ -196,6 +229,7 @@ class NewChatWidget extends Disposable { // Listen for option group changes to re-render pickers this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { + this._notifyFolderSelection(); this._renderExtensionPickers(); })); @@ -224,11 +258,12 @@ class NewChatWidget extends Disposable { const wrapper = dom.append(container, dom.$('.sessions-chat-widget')); const welcomeElement = dom.append(wrapper, dom.$('.chat-full-welcome')); - // Mascot + // Watermark letterpress const header = dom.append(welcomeElement, dom.$('.chat-full-welcome-header')); - const quality = this.productService.quality ?? 'stable'; - const mascot = dom.append(header, dom.$('.chat-full-welcome-mascot')); - mascot.style.backgroundImage = asCSSUrl(FileAccess.asBrowserUri(`vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-${quality}.svg`)); + dom.append(header, dom.$('.chat-full-welcome-letterpress')); + + // Option group pickers (above the input) + this._pickersContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-pickers-container')); // Input slot this._inputSlot = dom.append(welcomeElement, dom.$('.chat-full-welcome-inputSlot')); @@ -239,8 +274,11 @@ class NewChatWidget extends Disposable { this._createToolbar(inputArea); this._inputSlot.appendChild(inputArea); - // Option group pickers (below the input) - this._pickersContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-pickers-container')); + // Local mode picker (below the input, shown when Local is selected) + this._localModeContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode')); + this._localModeDropdownContainer = dom.append(this._localModeContainer, dom.$('.sessions-chat-local-mode-left')); + dom.append(this._localModeContainer, dom.$('.sessions-chat-local-mode-spacer')); + this._localModePickersContainer = dom.append(this._localModeContainer, dom.$('.sessions-chat-local-mode-right')); // Render target buttons & extension pickers this._renderOptionGroupPickers(); @@ -251,12 +289,23 @@ class NewChatWidget extends Disposable { // Generate pending resource for option changes this._generatePendingSessionResource(); + // Render local mode picker + this._renderLocalModePicker(); + // Reveal welcomeElement.classList.add('revealed'); } - private _generatePendingSessionResource(): void { + private _getEffectiveTarget(): AgentSessionProviders | undefined { const target = this._targetConfig.selectedTarget.get(); + if (target === AgentSessionProviders.Local && this._localMode === 'worktree') { + return AgentSessionProviders.Background; + } + return target; + } + + private _generatePendingSessionResource(): void { + const target = this._getEffectiveTarget(); if (!target || target === AgentSessionProviders.Local) { this._pendingSessionResource = undefined; return; @@ -354,7 +403,7 @@ class NewChatWidget extends Disposable { const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; const modelPicker = this.instantiationService.createInstance( - ModelPickerActionItem, action, undefined, delegate, pickerOptions, + EnhancedModelPickerActionItem, action, delegate, pickerOptions, ); this._modelPickerDisposable.value = modelPicker; modelPicker.render(container); @@ -397,17 +446,15 @@ class NewChatWidget extends Disposable { const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); - // Left group: target dropdown + non-repo extension pickers - const leftGroup = dom.append(pickersRow, dom.$('.sessions-chat-pickers-left')); - this._targetDropdownContainer = dom.append(leftGroup, dom.$('.sessions-chat-dropdown-wrapper')); + // Left half: target switcher (right-justified within its half) + const leftHalf = dom.append(pickersRow, dom.$('.sessions-chat-pickers-left-half')); + this._targetDropdownContainer = dom.append(leftHalf, dom.$('.sessions-chat-dropdown-wrapper')); this._renderTargetDropdown(this._targetDropdownContainer); - this._extensionPickersLeftContainer = dom.append(leftGroup, dom.$('.sessions-chat-extension-pickers-left')); - // Spacer - dom.append(pickersRow, dom.$('.sessions-chat-pickers-spacer')); - - // Right group: repo/folder pickers - this._extensionPickersRightContainer = dom.append(pickersRow, dom.$('.sessions-chat-extension-pickers-right')); + // Right half: separator + pickers (left-justified within its half) + const rightHalf = dom.append(pickersRow, dom.$('.sessions-chat-pickers-right-half')); + this._extensionPickersLeftContainer = dom.append(rightHalf, dom.$('.sessions-chat-pickers-left-separator')); + this._extensionPickersRightContainer = dom.append(rightHalf, dom.$('.sessions-chat-extension-pickers-right')); this._renderExtensionPickers(); } @@ -418,62 +465,146 @@ class NewChatWidget extends Disposable { return; } - const activeType = this._targetConfig.selectedTarget.get() ?? AgentSessionProviders.Background; - const icon = getAgentSessionProviderIcon(activeType); - const name = getAgentSessionProviderName(activeType); + const activeType = this._targetConfig.selectedTarget.get() ?? AgentSessionProviders.Local; + const targets = [AgentSessionProviders.Local, AgentSessionProviders.Cloud].filter(t => allowed.has(t)); + const activeIndex = targets.indexOf(activeType); - const button = dom.append(container, dom.$('.sessions-chat-dropdown-button')); + const radio = new Radio({ + items: targets.map(target => ({ + text: getAgentSessionProviderName(target), + isActive: target === activeType, + })), + }); + this._welcomeContentDisposables.add(radio); + container.appendChild(radio.domNode); + + if (activeIndex >= 0) { + radio.setActiveItem(activeIndex); + } + + this._welcomeContentDisposables.add(radio.onDidSelect(index => { + this._targetConfig.setSelectedTarget(targets[index]); + })); + } + + // --- Local mode picker (Workspace / Worktree) --- + + private readonly _localModeDisposables = this._register(new DisposableStore()); + + private _renderLocalModePicker(): void { + if (!this._localModeContainer || !this._localModeDropdownContainer || !this._localModePickersContainer) { + return; + } + + this._localModeDisposables.clear(); + dom.clearNode(this._localModeDropdownContainer); + dom.clearNode(this._localModePickersContainer); + + const selectedTarget = this._targetConfig.selectedTarget.get(); + if (selectedTarget !== AgentSessionProviders.Local) { + this._localModeContainer.style.visibility = 'hidden'; + return; + } + + this._localModeContainer.style.visibility = ''; + + // Dropdown button for Workspace / Worktree + const modeLabel = this._localMode === 'workspace' + ? localize('localMode.workspace', "Workspace") + : localize('localMode.worktree', "Worktree"); + const modeIcon = this._localMode === 'workspace' ? Codicon.folder : Codicon.worktree; + + const button = dom.append(this._localModeDropdownContainer, dom.$('.sessions-chat-dropdown-button')); button.tabIndex = 0; button.role = 'button'; button.ariaHasPopup = 'true'; - dom.append(button, renderIcon(icon)); - dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, name)); + dom.append(button, renderIcon(modeIcon)); + dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, modeLabel)); dom.append(button, renderIcon(Codicon.chevronDown)); - this._welcomeContentDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, () => { - const currentAllowed = this._targetConfig.allowedTargets.get(); - const currentActive = this._targetConfig.selectedTarget.get(); - const actions = [...currentAllowed] - .filter(t => t !== AgentSessionProviders.Local) - .map(sessionType => { - const label = getAgentSessionProviderName(sessionType); - return toAction({ - id: `target.${sessionType}`, - label, - checked: sessionType === currentActive, - run: () => this._targetConfig.setSelectedTarget(sessionType), - }); - }); + this._localModeDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, () => { + const actions = [ + toAction({ + id: 'localMode.workspace', + label: localize('localMode.workspace', "Workspace"), + checked: this._localMode === 'workspace', + run: () => this._setLocalMode('workspace'), + }), + toAction({ + id: 'localMode.worktree', + label: localize('localMode.worktree', "Worktree"), + checked: this._localMode === 'worktree', + run: () => this._setLocalMode('worktree'), + }), + ]; this.contextMenuService.showContextMenu({ getAnchor: () => button, getActions: () => actions, }); })); + + // Render pickers in the right side + this._renderLocalModePickers(); } - private _updateTargetDropdown(): void { - if (!this._targetDropdownContainer) { + private _setLocalMode(mode: 'workspace' | 'worktree'): void { + if (this._localMode !== mode) { + this._localMode = mode; + this._generatePendingSessionResource(); + this._notifyFolderSelection(); + this._renderLocalModePicker(); + } + } + + private _notifyFolderSelection(): void { + if (!this._pendingSessionResource) { return; } - dom.clearNode(this._targetDropdownContainer); - this._renderTargetDropdown(this._targetDropdownContainer); + const folderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + if (folderUri) { + this.chatSessionsService.notifySessionOptionsChange( + this._pendingSessionResource, + [{ optionId: 'repository', value: folderUri.fsPath }] + ).catch((err) => this.logService.error('Failed to notify extension of folder selection:', err)); + } + } + + private _renderLocalModePickers(): void { + if (!this._localModePickersContainer) { + return; + } + dom.clearNode(this._localModePickersContainer); + + if (this._localMode === 'worktree') { + // Worktree mode: render extension pickers for Background provider + this._renderExtensionPickersInContainer(this._localModePickersContainer, AgentSessionProviders.Background); + } } // --- Welcome: Extension option pickers --- private _renderExtensionPickers(force?: boolean): void { - if (!this._extensionPickersLeftContainer || !this._extensionPickersRightContainer) { + if (!this._extensionPickersRightContainer) { return; } - const activeSessionType = this._targetConfig.selectedTarget.get(); + const activeSessionType = this._getEffectiveTarget(); if (!activeSessionType) { this._clearExtensionPickers(); return; } + // For Local target, show folder picker in top row and handle bottom row + if (this._targetConfig.selectedTarget.get() === AgentSessionProviders.Local) { + this._clearExtensionPickers(); + this._renderLocalFolderPickerInTopRow(); + this._renderLocalModePicker(); + return; + } + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); if (!optionGroups || optionGroups.length === 0) { + this._clearExtensionPickers(); return; } @@ -503,7 +634,15 @@ class NewChatWidget extends Disposable { return; } - visibleGroups.sort((a, b) => (a.when ? 1 : 0) - (b.when ? 1 : 0)); + visibleGroups.sort((a, b) => { + // Repo/folder pickers first, then others + const aRepo = isRepoOrFolderGroup(a) ? 0 : 1; + const bRepo = isRepoOrFolderGroup(b) ? 0 : 1; + if (aRepo !== bRepo) { + return aRepo - bRepo; + } + return (a.when ? 1 : 0) - (b.when ? 1 : 0); + }); if (!force && this._pickerWidgets.size === visibleGroups.length) { const allMatch = visibleGroups.every(g => this._pickerWidgets.has(g.id)); @@ -514,6 +653,11 @@ class NewChatWidget extends Disposable { this._clearExtensionPickers(); + // Show the separator between target switcher and extension pickers + if (this._extensionPickersLeftContainer) { + this._extensionPickersLeftContainer.style.display = 'block'; + } + for (const optionGroup of visibleGroups) { const initialItem = this._getDefaultOptionForGroup(optionGroup); const initialState = { group: optionGroup, item: initialItem }; @@ -556,15 +700,150 @@ class NewChatWidget extends Disposable { this._pickerWidgetDisposables.add(widget); this._pickerWidgets.set(optionGroup.id, widget); - // Repo/folder pickers go to the right; others go to the left - const isRightAligned = isRepoOrFolderGroup(optionGroup); - const targetContainer = isRightAligned ? this._extensionPickersRightContainer! : this._extensionPickersLeftContainer!; + // All pickers go to the right + const targetContainer = this._extensionPickersRightContainer!; const slot = dom.append(targetContainer, dom.$('.sessions-chat-picker-slot')); widget.render(slot); } } + private _renderLocalFolderPickerInTopRow(): void { + if (!this._extensionPickersRightContainer) { + return; + } + + // Show the separator + if (this._extensionPickersLeftContainer) { + this._extensionPickersLeftContainer.style.display = 'block'; + } + + this._renderLocalFolderPickerInContainer(this._extensionPickersRightContainer, this._pickerWidgetDisposables); + } + + private _renderLocalFolderPickerInContainer(container: HTMLElement, disposables: DisposableStore): void { + const currentFolderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + const folderName = currentFolderUri ? basename(currentFolderUri) : localize('pickFolder', "Pick Folder"); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + const button = dom.append(slot, dom.$('.sessions-chat-dropdown-button')); + button.tabIndex = 0; + button.role = 'button'; + button.ariaHasPopup = 'true'; + dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, folderName)); + dom.append(button, renderIcon(Codicon.chevronDown)); + + const switchFolder = async (folderUri: URI) => { + this._selectedFolderUri = folderUri; + this.storageService.store('agentSessions.lastPickedFolder', folderUri.toString(), StorageScope.PROFILE, StorageTarget.MACHINE); + this._notifyFolderSelection(); + this._renderExtensionPickers(true); + }; + + disposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, async () => { + const recentlyOpened = await this.workspacesService.getRecentlyOpened(); + const recentFolders = recentlyOpened.workspaces + .filter(isRecentFolder) + .filter(r => !currentFolderUri || !isEqual(r.folderUri, currentFolderUri)) + .slice(0, 10); + + const actions = recentFolders.map(recent => toAction({ + id: recent.folderUri.toString(), + label: recent.label || basename(recent.folderUri), + run: () => switchFolder(recent.folderUri), + })); + + actions.push(new Separator()); + actions.push(toAction({ + id: 'browse', + label: localize('browseFolder', "Browse..."), + run: async () => { + const selected = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectFolder', "Select Folder"), + }); + if (selected?.[0]) { + await switchFolder(selected[0]); + } + }, + })); + + this.contextMenuService.showContextMenu({ + getAnchor: () => button, + getActions: () => actions, + }); + })); + } + + private _renderExtensionPickersInContainer(container: HTMLElement, sessionType: AgentSessionProviders): void { + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(sessionType); + if (!optionGroups || optionGroups.length === 0) { + return; + } + + const visibleGroups: IChatSessionProviderOptionGroup[] = []; + for (const group of optionGroups) { + if (isModelOptionGroup(group)) { + continue; + } + if (group.id === 'repository') { + continue; + } + const hasItems = group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable; + const passesWhenClause = this._evaluateOptionGroupVisibility(group); + if (hasItems && passesWhenClause) { + visibleGroups.push(group); + } + } + + for (const optionGroup of visibleGroups) { + const initialItem = this._getDefaultOptionForGroup(optionGroup); + const initialState = { group: optionGroup, item: initialItem }; + + if (initialItem) { + this._updateOptionContextKey(optionGroup.id, initialItem.id); + } + + const emitter = this._getOrCreateOptionEmitter(optionGroup.id); + const itemDelegate: IChatSessionPickerDelegate = { + getCurrentOption: () => this._selectedOptions.get(optionGroup.id) ?? this._getDefaultOptionForGroup(optionGroup), + onDidChangeOption: emitter.event, + setOption: (option: IChatSessionProviderOptionItem) => { + this._selectedOptions.set(optionGroup.id, option); + this._updateOptionContextKey(optionGroup.id, option.id); + emitter.fire(option); + + if (this._pendingSessionResource) { + this.chatSessionsService.notifySessionOptionsChange( + this._pendingSessionResource, + [{ optionId: optionGroup.id, value: option }] + ).catch((err) => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); + } + + this._renderLocalModePickers(); + }, + getOptionGroup: () => { + const groups = this.chatSessionsService.getOptionGroupsForSessionType(sessionType); + return groups?.find((g: { id: string }) => g.id === optionGroup.id); + }, + getSessionResource: () => this._pendingSessionResource, + }; + + const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); + const widget = this.instantiationService.createInstance( + optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, + action, initialState, itemDelegate + ); + + this._localModeDisposables.add(widget); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + widget.render(slot); + } + } + private _evaluateOptionGroupVisibility(optionGroup: { id: string; when?: string }): boolean { if (!optionGroup.when) { return true; @@ -578,7 +857,7 @@ class NewChatWidget extends Disposable { } private _syncOptionsFromSession(sessionResource: URI): void { - const activeSessionType = this._targetConfig.selectedTarget.get(); + const activeSessionType = this._getEffectiveTarget(); if (!activeSessionType) { return; } @@ -640,7 +919,7 @@ class NewChatWidget extends Disposable { this._pickerWidgets.clear(); this._optionEmitters.clear(); if (this._extensionPickersLeftContainer) { - dom.clearNode(this._extensionPickersLeftContainer); + this._extensionPickersLeftContainer.style.display = 'none'; } if (this._extensionPickersRightContainer) { dom.clearNode(this._extensionPickersRightContainer); @@ -655,7 +934,7 @@ class NewChatWidget extends Disposable { return; } - const target = this._targetConfig.selectedTarget.get(); + const target = this._getEffectiveTarget(); if (!target) { this.logService.warn('ChatWelcomeWidget: No target selected, cannot create session'); return; @@ -682,12 +961,15 @@ class NewChatWidget extends Disposable { agentIdSilent: contribution?.type, }; + const folderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + this._options.onSendRequest?.({ resource, target, query, sendOptions, selectedOptions: new Map(this._selectedOptions), + folderUri, }); } @@ -704,6 +986,10 @@ class NewChatWidget extends Disposable { focusInput(): void { this._editor?.focus(); } + + updateAllowedTargets(targets: AgentSessionProviders[]): void { + this._targetConfig.setAllowedTargets(targets); + } } // #endregion @@ -730,7 +1016,8 @@ export class NewChatViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ILogService private readonly logService: ILogService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -743,12 +1030,12 @@ export class NewChatViewPane extends ViewPane { NewChatWidget, { targetConfig: { - allowedTargets: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], - defaultTarget: AgentSessionProviders.Background, + allowedTargets: this.computeAllowedTargets(), + defaultTarget: AgentSessionProviders.Local, }, onSendRequest: (data) => { - this.activeSessionService.openSessionAndSend( - data.resource, data.query, data.sendOptions, data.selectedOptions + this.activeSessionService.sendRequestForNewSession( + data.resource, data.query, data.sendOptions, data.selectedOptions, data.folderUri ).catch(e => this.logService.error('NewChatViewPane: Failed to open session and send request', e)); }, } satisfies INewChatWidgetOptions, @@ -756,6 +1043,15 @@ export class NewChatViewPane extends ViewPane { this._widget.render(container); this._widget.focusInput(); + + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => { + this._widget?.updateAllowedTargets(this.computeAllowedTargets()); + })); + } + + private computeAllowedTargets(): AgentSessionProviders[] { + const targets: AgentSessionProviders[] = [AgentSessionProviders.Local, AgentSessionProviders.Cloud]; + return targets; } protected override layoutBody(height: number, width: number): void { diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 754a3f995ee..69c0e8e2497 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -17,7 +17,7 @@ import { IWorkbenchEnvironmentService } from '../../../../workbench/services/env import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { ISearchService } from '../../../../workbench/services/search/common/search.js'; import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; export class AgenticPromptsService extends PromptsService { protected override createPromptFilesLocator(): PromptFilesLocator { @@ -36,7 +36,7 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { @IUserDataProfileService userDataService: IUserDataProfileService, @ILogService logService: ILogService, @IPathService pathService: IPathService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, ) { super( fileService, diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index e9368f846a5..6138f48d98e 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -13,7 +13,7 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; -import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; import { Menus } from '../../../browser/menus.js'; @@ -54,7 +54,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor( @IStorageService private readonly _storageService: IStorageService, @ITerminalService private readonly _terminalService: ITerminalService, - @ISessionsWorkbenchService activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService activeSessionService: ISessionsManagementService, @IQuickInputService private readonly _quickInputService: IQuickInputService, ) { super(); diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index db053fcef22..9a5d00df685 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -8,7 +8,6 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; Registry.as(Extensions.Configuration).registerDefaultConfigurations([{ overrides: { - 'chat.agentsControl.clickBehavior': 'focus', 'chat.agentsControl.enabled': true, 'chat.agent.maxRequests': 1000, 'chat.restoreLastPanelSession': true, diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index c5211ee8e27..b81e8fa6284 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -13,8 +13,9 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; +import { SessionsAuxiliaryBarContribution } from './sessionsAuxiliaryBarContribution.js'; import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; -import { SessionsWorkbenchService, ISessionsWorkbenchService } from './sessionsWorkbenchService.js'; +import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -46,5 +47,6 @@ const agentSessionsViewDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SessionsAuxiliaryBarContribution.ID, SessionsAuxiliaryBarContribution, WorkbenchPhase.AfterRestored); -registerSingleton(ISessionsWorkbenchService, SessionsWorkbenchService, InstantiationType.Delayed); +registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts new file mode 100644 index 00000000000..bcadacb70d5 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { autorun } from '../../../../base/common/observable.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { CHANGES_VIEW_ID, ChangesViewPane } from '../../changesView/browser/changesView.js'; + +export class SessionsAuxiliaryBarContribution extends Disposable { + + static readonly ID = 'workbench.contrib.sessionsAuxiliaryBarContribution'; + + private readonly activeChangesListener = this._register(new MutableDisposable()); + private activeChangesView: ChangesViewPane | null = null; + + constructor( + @IViewsService private readonly viewsService: IViewsService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + ) { + super(); + + this.tryBindToChangesView(); + + this._register(this.viewsService.onDidChangeViewVisibility(e => { + if (e.id !== CHANGES_VIEW_ID) { + return; + } + + this.tryBindToChangesView(); + })); + } + + private tryBindToChangesView(): void { + const changesView = this.viewsService.getViewWithId(CHANGES_VIEW_ID); + if (!changesView) { + this.activeChangesView = null; + this.activeChangesListener.clear(); + return; + } + + if (this.activeChangesView === changesView) { + return; + } + + this.activeChangesView = changesView; + this.activeChangesListener.value = autorun(reader => { + const hasChanges = changesView.activeSessionHasChanges.read(reader); + this.syncAuxiliaryBarVisibility(hasChanges); + }); + } + + private syncAuxiliaryBarVisibility(hasChanges: boolean): void { + const shouldHideAuxiliaryBar = !hasChanges; + const isAuxiliaryBarVisible = this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); + if (shouldHideAuxiliaryBar === !isAuxiliaryBarVisible) { + return; + } + + this.layoutService.setPartHidden(shouldHideAuxiliaryBar, Parts.AUXILIARYBAR_PART); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsWorkbenchService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts similarity index 61% rename from src/vs/sessions/contrib/sessions/browser/sessionsWorkbenchService.ts rename to src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 1df7b2a3c04..5ebd3496cb3 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsWorkbenchService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -12,12 +12,17 @@ import { IContextKey, IContextKeyService, RawContextKey } from '../../../../plat import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ISessionOpenOptions, openSession as openSessionDefault } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; -import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatViewId, ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.js'; import { IChatSessionItem, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; -import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { LocalChatSessionUri } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); @@ -43,7 +48,7 @@ export type IActiveSessionItem = (IChatSessionItem | IAgentSession) & { readonly worktree: URI | undefined; }; -export interface ISessionsWorkbenchService { +export interface ISessionsManagementService { readonly _serviceBrand: undefined; /** @@ -62,22 +67,22 @@ export interface ISessionsWorkbenchService { */ openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise; - /** - * Open a new session, apply options, and send the initial request. - * This is the main entry point for the new-chat welcome widget. - */ - openSessionAndSend(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise; - /** * Switch to the new-session view. * No-op if the current session is already a new session. */ openNewSession(): void; + + /** + * Open a new session, apply options, and send the initial request. + * This is the main entry point for the new-chat welcome widget. + */ + sendRequestForNewSession(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap, folderUri?: URI): Promise; } -export const ISessionsWorkbenchService = createDecorator('sessionsWorkbenchService'); +export const ISessionsManagementService = createDecorator('sessionsManagementService'); -export class SessionsWorkbenchService extends Disposable implements ISessionsWorkbenchService { +export class SessionsManagementService extends Disposable implements ISessionsManagementService { declare readonly _serviceBrand: undefined; @@ -96,6 +101,9 @@ export class SessionsWorkbenchService extends Disposable implements ISessionsWor @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, + @IViewsService private readonly viewsService: IViewsService, ) { super(); @@ -107,9 +115,7 @@ export class SessionsWorkbenchService extends Disposable implements ISessionsWor this.lastSelectedSession = this.loadLastSelectedSession(); // Save on shutdown - this._register(this.storageService.onWillSaveState(() => { - this.saveLastSelectedSession(); - })); + this._register(this.storageService.onWillSaveState(() => this.saveLastSelectedSession())); // Update active session when session options change this._register(this.chatSessionsService.onDidChangeSessionOptions(sessionResource => { @@ -124,9 +130,7 @@ export class SessionsWorkbenchService extends Disposable implements ISessionsWor })); // Update active session when the agent sessions model changes (e.g., metadata updates with worktree/repository info) - this._register(this.agentSessionsService.model.onDidChangeSessions(() => { - this.refreshActiveSessionFromModel(); - })); + this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.refreshActiveSessionFromModel())); } private refreshActiveSessionFromModel(): void { @@ -137,6 +141,12 @@ export class SessionsWorkbenchService extends Disposable implements ISessionsWor const agentSession = this.agentSessionsService.model.getSession(currentActive.resource); if (!agentSession) { + // Only switch sessions if the active session was a known agent session + // that got deleted. New session resources that aren't yet in the model + // should not trigger a switch. + if (isAgentSession(currentActive)) { + this.showNextSession(); + } return; } @@ -149,6 +159,19 @@ export class SessionsWorkbenchService extends Disposable implements ISessionsWor this._activeSession.set(activeSessionItem, undefined); } + private showNextSession(): void { + const sessions = this.agentSessionsService.model.sessions + .filter(s => !s.isArchived()) + .sort((a, b) => (b.timing.lastRequestEnded ?? b.timing.created) - (a.timing.lastRequestEnded ?? a.timing.created)); + + if (sessions.length > 0) { + this.setActiveSession(sessions[0]); + this.instantiationService.invokeFunction(openSessionDefault, sessions[0]); + } else { + this.openNewSession(); + } + } + private getRepositoryFromMetadata(metadata: { readonly [key: string]: unknown } | undefined): [URI | undefined, URI | undefined] { if (!metadata) { return [undefined, undefined]; @@ -189,44 +212,106 @@ export class SessionsWorkbenchService extends Disposable implements ISessionsWor } async openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise { - const session = this.agentSessionsService.model.getSession(sessionResource); - if (session) { - this.isNewChatSessionContext.set(false); - this.setActiveSession(session); - await this.instantiationService.invokeFunction(openSessionDefault, session, openOptions); + this.isNewChatSessionContext.set(false); + + const existingSession = this.agentSessionsService.model.getSession(sessionResource); + if (existingSession) { + await this.openExistingSession(existingSession, openOptions); + } else if (LocalChatSessionUri.isLocalSession(sessionResource)) { + await this.openLocalSession(); } else { - // For new sessions, load via the chat service first so the model - // is ready before the ChatViewPane renders it. - const modelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); - // Switch view only after the model is loaded so the ChatViewPane - // has content immediately when it becomes visible. - this.isNewChatSessionContext.set(false); - const chatWidget = await this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); - if (!chatWidget?.viewModel) { - this.logService.warn(`[ActiveSessionService] Failed to open session: ${sessionResource.toString()}`); - modelRef?.dispose(); - return; - } - const repository = this.getRepositoryFromSessionOption(sessionResource); - const activeSessionItem: IActiveSessionItem = { - resource: sessionResource, - label: chatWidget.viewModel.model.title || '', - timing: chatWidget.viewModel.model.timing, - repository, - worktree: undefined - }; - this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); - this._activeSession.set(activeSessionItem, undefined); + await this.openNewRemoteSession(sessionResource); } } - async openSessionAndSend(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise { - // 1. Open the session in ChatViewPane - this transitions views, - // loads the model, and connects it to the ChatWidget so - // tool invocations work. + /** + * Open an existing agent session - set it as active and reveal it. + */ + private async openExistingSession(session: IAgentSession, openOptions?: ISessionOpenOptions): Promise { + this.setActiveSession(session); + await this.instantiationService.invokeFunction(openSessionDefault, session, openOptions); + } + + /** + * Open a fresh local chat session - show the ChatViewPane and clear the widget. + */ + private async openLocalSession(): Promise { + const view = await this.viewsService.openView(ChatViewId) as ChatViewPane | undefined; + if (view) { + await view.widget.clear(); + if (view.widget.viewModel) { + const folder = this.workspaceContextService.getWorkspace().folders[0]; + const activeSessionItem: IActiveSessionItem = { + resource: view.widget.viewModel.sessionResource, + label: view.widget.viewModel.model.title || '', + timing: view.widget.viewModel.model.timing, + repository: folder?.uri, + worktree: undefined + }; + this._activeSession.set(activeSessionItem, undefined); + } + } + } + + /** + * Open a new remote session - load the model first, then show it in the ChatViewPane. + */ + private async openNewRemoteSession(sessionResource: URI): Promise { + const modelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + const chatWidget = await this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget?.viewModel) { + this.logService.warn(`[ActiveSessionService] Failed to open session: ${sessionResource.toString()}`); + modelRef?.dispose(); + return; + } + const repository = this.getRepositoryFromSessionOption(sessionResource); + const activeSessionItem: IActiveSessionItem = { + resource: sessionResource, + label: chatWidget.viewModel.model.title || '', + timing: chatWidget.viewModel.model.timing, + repository, + worktree: undefined + }; + this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); + this._activeSession.set(activeSessionItem, undefined); + } + + async sendRequestForNewSession(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap, folderUri?: URI): Promise { + if (LocalChatSessionUri.isLocalSession(sessionResource)) { + await this.sendLocalSession(sessionResource, query, folderUri); + } else { + await this.sendCustomSession(sessionResource, query, sendOptions, selectedOptions); + } + } + + /** + * Local sessions run directly through the ChatWidget. + * Set the workspace folder, open a fresh chat view, and submit via acceptInput. + */ + private async sendLocalSession(sessionResource: URI, query: string, folderUri?: URI): Promise { + if (folderUri) { + await this.workspaceEditingService.updateFolders(0, this.workspaceContextService.getWorkspace().folders.length, [{ uri: folderUri }]); + } + await this.openSession(sessionResource); - // 2. Apply selected options to the contributed session + const widget = this.chatWidgetService.lastFocusedWidget; + if (widget) { + widget.setInput(query); + widget.acceptInput(query); + } + } + + /** + * Custom sessions (worktree, cloud, etc.) go through the chat service. + * Apply selected options, send the request, then wait for the extension + * to create an agent session so it appears in the sidebar. + */ + private async sendCustomSession(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise { + // 1. Open the session - loads the model and shows the ChatViewPane + await this.openSession(sessionResource); + + // 2. Apply selected options (repository, branch, etc.) to the contributed session if (selectedOptions && selectedOptions.size > 0) { const modelRef = this.chatService.getActiveSessionReference(sessionResource); if (modelRef) { @@ -245,22 +330,17 @@ export class SessionsWorkbenchService extends Disposable implements ISessionsWor } } - // 3. Snapshot existing session resources so we can detect the new one + // 3. Send the request const existingResources = new Set( this.agentSessionsService.model.sessions.map(s => s.resource.toString()) ); - - // 4. Send the request through the chat service - the model is now - // connected to the ChatWidget, so tools and rendering work. const result = await this.chatService.sendRequest(sessionResource, query, sendOptions); if (result.kind === 'rejected') { this.logService.error(`[ActiveSessionService] sendRequest rejected: ${result.reason}`); return; } - // 5. After send, the extension creates an agent session. Wait for it - // and set it as the active session so the titlebar and sidebar - // reflect the new session. + // 4. Wait for the extension to create an agent session, then set it as active let newSession = this.agentSessionsService.model.sessions.find( s => !existingResources.has(s.resource.toString()) ); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 5d271a457ed..526fdb358d9 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -18,7 +18,7 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { URI } from '../../../../base/common/uri.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { ISessionsWorkbenchService } from './sessionsWorkbenchService.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; import { FocusAgentSessionsAction } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsActions.js'; import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.js'; import { autorun } from '../../../../base/common/observable.js'; @@ -60,7 +60,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { options: IBaseActionViewItemOptions | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, @IHoverService private readonly hoverService: IHoverService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IChatService private readonly chatService: IChatService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @ICommandService private readonly commandService: ICommandService, @@ -344,7 +344,9 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } private _showSessionsPicker(): void { - const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined); + const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { + overrideSessionOpen: (session, openOptions) => this.activeSessionService.openSession(session.resource, openOptions) + }); picker.pickAgentSession(); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 63d62b4fa9d..ab3ba0b5a08 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -23,7 +23,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; -import { ISessionsWorkbenchService } from './sessionsWorkbenchService.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -73,7 +73,6 @@ const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; - private newSessionButtonContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; private aiCustomizationContainer: HTMLElement | undefined; @@ -98,7 +97,7 @@ export class AgenticSessionsViewPane extends ViewPane { @IMcpService private readonly mcpService: IMcpService, @IStorageService private readonly storageService: IStorageService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -156,7 +155,7 @@ export class AgenticSessionsViewPane extends ViewPane { const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); // New Session Button - const newSessionButtonContainer = this.newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container')); + const newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container')); const newSessionButton = this._register(new Button(newSessionButtonContainer, { ...defaultButtonStyles, secondary: true })); newSessionButton.label = localize('newSession', "New Session"); this._register(newSessionButton.onDidClick(() => this.activeSessionService.openNewSession())); @@ -195,6 +194,8 @@ export class AgenticSessionsViewPane extends ViewPane { if (!sessionsControl.reveal(activeSession.resource)) { sessionsControl.clearFocus(); } + } else { + sessionsControl.clearFocus(); // clear selection when a new session is created } })); @@ -402,14 +403,11 @@ export class AgenticSessionsViewPane extends ViewPane { protected override layoutBody(height: number, width: number): void { super.layoutBody(height, width); - if (!this.sessionsControl || !this.newSessionButtonContainer) { + if (!this.sessionsControl || !this.sessionsControlContainer) { return; } - const buttonHeight = this.newSessionButtonContainer.offsetHeight; - const customizationHeight = this.aiCustomizationContainer?.offsetHeight || 0; - const availableSessionsHeight = height - buttonHeight - customizationHeight; - this.sessionsControl.layout(availableSessionsHeight, width); + this.sessionsControl.layout(this.sessionsControlContainer.offsetHeight, width); } override focus(): void { diff --git a/src/vs/sessions/electron-browser/parts/titlebarPart.ts b/src/vs/sessions/electron-browser/parts/titlebarPart.ts new file mode 100644 index 00000000000..8c2c0597b63 --- /dev/null +++ b/src/vs/sessions/electron-browser/parts/titlebarPart.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getZoomFactor } from '../../../base/browser/browser.js'; +import { getWindow, getWindowId } from '../../../base/browser/dom.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { INativeHostService } from '../../../platform/native/common/native.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { useWindowControlsOverlay } from '../../../platform/window/common/window.js'; +import { IHostService } from '../../../workbench/services/host/browser/host.js'; +import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titlebar/titlebarPart.js'; +import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; +import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; +import { TitlebarPart, TitleService } from '../../browser/parts/titlebarPart.js'; + +export class NativeTitlebarPart extends TitlebarPart { + + private cachedWindowControlStyles: { bgColor: string; fgColor: string } | undefined; + private cachedWindowControlHeight: number | undefined; + + constructor( + id: string, + targetWindow: CodeWindow, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHostService hostService: IHostService, + @INativeHostService private readonly nativeHostService: INativeHostService, + ) { + super(id, targetWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); + } + + override updateStyles(): void { + super.updateStyles(); + + if (this.element) { + if (useWindowControlsOverlay(this.configurationService)) { + if ( + !this.cachedWindowControlStyles || + this.cachedWindowControlStyles.bgColor !== this.element.style.backgroundColor || + this.cachedWindowControlStyles.fgColor !== this.element.style.color + ) { + this.nativeHostService.updateWindowControls({ + targetWindowId: getWindowId(getWindow(this.element)), + backgroundColor: this.element.style.backgroundColor, + foregroundColor: this.element.style.color + }); + } + } + } + } + + override layout(width: number, height: number): void { + super.layout(width, height); + + if (useWindowControlsOverlay(this.configurationService)) { + const newHeight = Math.round(height * getZoomFactor(getWindow(this.element))); + if (newHeight !== this.cachedWindowControlHeight) { + this.cachedWindowControlHeight = newHeight; + this.nativeHostService.updateWindowControls({ + targetWindowId: getWindowId(getWindow(this.element)), + height: newHeight + }); + } + } + } +} + +class MainNativeTitlebarPart extends NativeTitlebarPart { + + constructor( + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHostService hostService: IHostService, + @INativeHostService nativeHostService: INativeHostService, + ) { + super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + } +} + +class AuxiliaryNativeTitlebarPart extends NativeTitlebarPart implements IAuxiliaryTitlebarPart { + + private static COUNTER = 1; + + get height() { return this.minimumHeight; } + + constructor( + readonly container: HTMLElement, + editorGroupsContainer: IEditorGroupsContainer, + private readonly mainTitlebar: TitlebarPart, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHostService hostService: IHostService, + @INativeHostService nativeHostService: INativeHostService, + ) { + const id = AuxiliaryNativeTitlebarPart.COUNTER++; + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + } + + override get preventZoom(): boolean { + return getZoomFactor(getWindow(this.element)) < 1 || !this.mainTitlebar.hasZoomableElements; + } +} + +export class NativeTitleService extends TitleService { + + protected override createMainTitlebarPart(): MainNativeTitlebarPart { + return this.instantiationService.createInstance(MainNativeTitlebarPart); + } + + protected override doCreateAuxiliaryTitlebarPart(container: HTMLElement, editorGroupsContainer: IEditorGroupsContainer, instantiationService: IInstantiationService): AuxiliaryNativeTitlebarPart { + return instantiationService.createInstance(AuxiliaryNativeTitlebarPart, container, editorGroupsContainer, this.mainPart); + } +} diff --git a/src/vs/sessions/electron-browser/titleService.ts b/src/vs/sessions/electron-browser/titleService.ts new file mode 100644 index 00000000000..9ffc7a93650 --- /dev/null +++ b/src/vs/sessions/electron-browser/titleService.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; +import { ITitleService } from '../../workbench/services/title/browser/titleService.js'; +import { NativeTitleService } from './parts/titlebarPart.js'; + +registerSingleton(ITitleService, NativeTitleService, InstantiationType.Eager); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 7e8c8e57a47..4119d7eaf13 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -351,9 +351,9 @@ import '../workbench/contrib/surveys/browser/nps.contribution.js'; import '../workbench/contrib/surveys/browser/languageSurveys.contribution.js'; // Welcome -import '../workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.js'; -import '../workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.contribution.js'; -import '../workbench/contrib/welcomeWalkthrough/browser/walkThrough.contribution.js'; +// import '../workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.js'; +// import '../workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.contribution.js'; +// import '../workbench/contrib/welcomeWalkthrough/browser/walkThrough.contribution.js'; import '../workbench/contrib/welcomeViews/common/viewsWelcome.contribution.js'; import '../workbench/contrib/welcomeViews/common/newFile.contribution.js'; diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index f690a68b72e..b9a4d26199c 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -9,6 +9,7 @@ import './sessions.common.main.js'; //#region --- workbench (agentic desktop main) import './electron-browser/sessions.main.js'; +import './electron-browser/titleService.js'; import '../workbench/electron-browser/desktop.contribution.js'; //#endregion diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index f3d03c9d08a..cf52a6f3e30 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -264,7 +264,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape const options: vscode.LanguageModelToolInvocationStreamOptions = { rawInput: context.rawInput, chatRequestId: context.chatRequestId, - chatSessionId: context.chatSessionId, chatSessionResource: context.chatSessionResource, chatInteractionId: context.chatInteractionId }; diff --git a/src/vs/workbench/api/common/extHostTreeViews.ts b/src/vs/workbench/api/common/extHostTreeViews.ts index 6734cb807f7..fce5e9d47db 100644 --- a/src/vs/workbench/api/common/extHostTreeViews.ts +++ b/src/vs/workbench/api/common/extHostTreeViews.ts @@ -151,7 +151,13 @@ export class ExtHostTreeViews extends Disposable implements ExtHostTreeViewsShap dispose: async () => { // Wait for the registration promise to finish before doing the dispose. await registerPromise; - this._treeViews.delete(viewId); + // Only notify the main thread if this view was not replaced by a new registration. + // When an extension disposes a view and immediately re-registers it, the new + // registration may have already updated _treeViews before this async dispose runs. + if (this._treeViews.get(viewId) === treeView) { + this._treeViews.delete(viewId); + this._proxy.$disposeTree(viewId); + } treeView.dispose(); } }; @@ -1108,6 +1114,5 @@ class ExtHostTreeView extends Disposable { this._refreshCancellationSource.dispose(); this._clearAll(); - this._proxy.$disposeTree(this._viewId); } } diff --git a/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts b/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts index 88d9928ac79..751a4ff73f9 100644 --- a/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts @@ -499,6 +499,30 @@ suite('ExtHostTreeView', function () { }); }); + test('dispose and re-register tree view', async () => { + const disposeTreeSpy = sinon.spy(target, '$disposeTree'); + const registerSpy = sinon.spy(target, '$registerTreeViewDataProvider'); + + // Create, dispose, and re-register a tree view with the same id + const treeView1 = testObject.createTreeView('reRegisterTreeProvider', { treeDataProvider: aNodeTreeDataProvider() }, extensionsDescription); + treeView1.dispose(); + const treeView2 = testObject.createTreeView('reRegisterTreeProvider', { treeDataProvider: aNodeTreeDataProvider() }, extensionsDescription); + + // Let all pending microtasks (the async dispose) settle + await new Promise(r => setTimeout(r, 0)); + + // The new view should work — $getChildren should return results, not reject + const elements = await testObject.$getChildren('reRegisterTreeProvider'); + assert.deepStrictEqual(unBatchChildren(elements)?.map(e => e.handle), ['0/0:a', '0/0:b']); + + // $registerTreeViewDataProvider should have been called twice (once per createTreeView) + assert.strictEqual(registerSpy.callCount, 2); + // $disposeTree should NOT have been called — the old async dispose should detect it was replaced + assert.strictEqual(disposeTreeSpy.callCount, 0); + + treeView2.dispose(); + }); + test('reveal will throw an error if getParent is not implemented', () => { const treeView = testObject.createTreeView('treeDataProvider', { treeDataProvider: aNodeTreeDataProvider() }, extensionsDescription); return treeView.reveal({ key: 'a' }) diff --git a/src/vs/workbench/browser/contextkeys.ts b/src/vs/workbench/browser/contextkeys.ts index 16c7e5e2267..678a225f1df 100644 --- a/src/vs/workbench/browser/contextkeys.ts +++ b/src/vs/workbench/browser/contextkeys.ts @@ -16,7 +16,6 @@ import { getRemoteName } from '../../platform/remote/common/remoteHosts.js'; import { getVirtualWorkspaceScheme } from '../../platform/workspace/common/virtualWorkspace.js'; import { IWorkingCopyService } from '../services/workingCopy/common/workingCopyService.js'; import { isNative } from '../../base/common/platform.js'; -import { IPaneCompositePartService } from '../services/panecomposite/browser/panecomposite.js'; import { WebFileSystemAccess } from '../../platform/files/browser/webFileSystemAccess.js'; import { IProductService } from '../../platform/product/common/productService.js'; import { getTitleBarStyle } from '../../platform/window/common/window.js'; @@ -75,7 +74,6 @@ export class WorkbenchContextKeysHandler extends Disposable { @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IEditorService private readonly editorService: IEditorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IPaneCompositePartService private readonly paneCompositeService: IPaneCompositePartService, @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, ) { super(); @@ -180,6 +178,7 @@ export class WorkbenchContextKeysHandler extends Disposable { // Sidebar this.sideBarVisibleContext = SideBarVisibleContext.bindTo(this.contextKeyService); + this.sideBarVisibleContext.set(this.layoutService.isVisible(Parts.SIDEBAR_PART)); // Title Bar this.titleAreaVisibleContext = TitleBarVisibleContext.bindTo(this.contextKeyService); @@ -245,14 +244,12 @@ export class WorkbenchContextKeysHandler extends Disposable { this._register(this.layoutService.onDidChangePanelAlignment(alignment => this.panelAlignmentContext.set(alignment))); - this._register(this.paneCompositeService.onDidPaneCompositeClose(() => this.updateSideBarContextKeys())); - this._register(this.paneCompositeService.onDidPaneCompositeOpen(() => this.updateSideBarContextKeys())); - this._register(this.layoutService.onDidChangePartVisibility(() => { this.mainEditorAreaVisibleContext.set(this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow)); this.panelVisibleContext.set(this.layoutService.isVisible(Parts.PANEL_PART)); this.panelMaximizedContext.set(this.layoutService.isPanelMaximized()); this.auxiliaryBarVisibleContext.set(this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)); + this.sideBarVisibleContext.set(this.layoutService.isVisible(Parts.SIDEBAR_PART)); this.updateTitleBarContextKeys(); })); @@ -326,10 +323,6 @@ export class WorkbenchContextKeysHandler extends Disposable { } } - private updateSideBarContextKeys(): void { - this.sideBarVisibleContext.set(this.layoutService.isVisible(Parts.SIDEBAR_PART)); - } - private updateTitleBarContextKeys(): void { this.titleAreaVisibleContext.set(this.layoutService.isVisible(Parts.TITLEBAR_PART, mainWindow)); this.titleBarStyleContext.set(getTitleBarStyle(this.configurationService)); diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index b58cb981051..bdbd911fe28 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -78,8 +78,8 @@ export class ModalEditorPart { if (e.target === modalElement) { EventHelper.stop(e, true); - // Guide focus back into the modal when clicking outside modal - editorPartContainer.focus(); + // Close modal when clicking outside the dialog + editorPart.close(); } })); diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index d717ada29b9..732fa1974e4 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -68,11 +68,6 @@ export interface IBrowserViewWorkbenchService { * Clear all storage data for the current workspace browser session */ clearWorkspaceStorage(): Promise; - - /** - * Get the endpoint for connecting to a browser view's CDP proxy server - */ - getDebugWebSocketEndpoint(): Promise; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index ad793c2770e..e9f065deb3e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -52,6 +52,7 @@ export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browse export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused")); export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); +export const CONTEXT_BROWSER_HAS_ERROR = new RawContextKey('browserHasError', false, localize('browser.hasError', "Whether the browser has a load error")); export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); @@ -188,6 +189,7 @@ export class BrowserEditor extends EditorPane { private _canGoForwardContext!: IContextKey; private _storageScopeContext!: IContextKey; private _hasUrlContext!: IContextKey; + private _hasErrorContext!: IContextKey; private _devToolsOpenContext!: IContextKey; private _elementSelectionActiveContext!: IContextKey; @@ -195,6 +197,7 @@ export class BrowserEditor extends EditorPane { private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; private _elementSelectionCts: CancellationTokenSource | undefined; + private _consoleSessionCts: CancellationTokenSource | undefined; private _screenshotTimeout: ReturnType | undefined; constructor( @@ -226,6 +229,7 @@ export class BrowserEditor extends EditorPane { this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService); + this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService); this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); @@ -367,6 +371,12 @@ export class BrowserEditor extends EditorPane { // Update navigation bar and context keys from model this.updateNavigationState(navEvent); + + if (navEvent.url) { + this.startConsoleSession(); + } else { + this.stopConsoleSession(); + } })); this._inputDisposables.add(this._model.onDidChangeLoadingState(() => { @@ -519,6 +529,7 @@ export class BrowserEditor extends EditorPane { } const error: IBrowserViewLoadError | undefined = this._model.error; + this._hasErrorContext.set(!!error); if (error) { // Update error content @@ -759,6 +770,66 @@ export class BrowserEditor extends EditorPane { } } + async addConsoleLogsToChat(): Promise { + const resourceUri = this.input?.resource; + if (!resourceUri) { + return; + } + + const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) }; + + try { + const logs = await this.browserElementsService.getConsoleLogs(locator); + if (!logs) { + return; + } + + const toAttach: IChatRequestVariableEntry[] = []; + toAttach.push({ + id: 'console-logs-' + Date.now(), + name: localize('consoleLogs', 'Console Logs'), + fullName: localize('consoleLogs', 'Console Logs'), + value: logs, + kind: 'element', + icon: ThemeIcon.fromId(Codicon.output.id), + }); + + const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; + widget?.attachmentModel?.addContext(...toAttach); + } catch (error) { + this.logService.error('BrowserEditor.addConsoleLogsToChat: Failed to get console logs', error); + } + } + + private startConsoleSession(): void { + // don't restart if already running + if (this._consoleSessionCts) { + return; + } + + const resourceUri = this.input?.resource; + if (!resourceUri || !this._model?.url) { + return; + } + + const cts = new CancellationTokenSource(); + this._consoleSessionCts = cts; + const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) }; + + this.browserElementsService.startConsoleSession(cts.token, locator).catch(error => { + if (!cts.token.isCancellationRequested) { + this.logService.error('BrowserEditor: Failed to start console session', error); + } + }); + } + + private stopConsoleSession(): void { + if (this._consoleSessionCts) { + this._consoleSessionCts.dispose(true); + this._consoleSessionCts = undefined; + } + } + /** * Update navigation state and context keys */ @@ -907,6 +978,9 @@ export class BrowserEditor extends EditorPane { this._elementSelectionCts = undefined; } + // Cancel any active console session + this.stopConsoleSession(); + // Cancel any scheduled screenshots this.cancelScheduledScreenshot(); @@ -920,6 +994,7 @@ export class BrowserEditor extends EditorPane { this._canGoBackContext.reset(); this._canGoForwardContext.reset(); this._hasUrlContext.reset(); + this._hasErrorContext.reset(); this._storageScopeContext.reset(); this._devToolsOpenContext.reset(); this._elementSelectionActiveContext.reset(); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 43ce32c896b..39305f4c57b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -11,7 +11,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; @@ -55,7 +55,12 @@ class OpenIntegratedBrowserAction extends Action2 { logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl'); - await editorService.openEditor({ resource }, group); + const editorPane = await editorService.openEditor({ resource }, group); + + // Lock the group when opening to the side + if (options.openToSide && editorPane?.group) { + editorPane.group.lock(true); + } } } @@ -223,7 +228,7 @@ class AddElementToChatAction extends Action2 { category: BrowserCategory, icon: Codicon.inspect, f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, enabled), + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, @@ -249,6 +254,34 @@ class AddElementToChatAction extends Action2 { } } +class AddConsoleLogsToChatAction extends Action2 { + static readonly ID = 'workbench.action.browser.addConsoleLogsToChat'; + + constructor() { + const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); + super({ + id: AddConsoleLogsToChatAction.ID, + title: localize2('browser.addConsoleLogsToChatAction', 'Add Console Logs to Chat'), + category: BrowserCategory, + icon: Codicon.output, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 2, + when: enabled + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.addConsoleLogsToChat(); + } + } +} + class ToggleDevToolsAction extends Action2 { static readonly ID = 'workbench.action.browser.toggleDevTools'; @@ -259,12 +292,12 @@ class ToggleDevToolsAction extends Action2 { category: BrowserCategory, icon: Codicon.terminal, f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL), + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), menu: { id: MenuId.BrowserActionsToolbar, group: 'actions', - order: 2, + order: 3, }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, @@ -290,7 +323,8 @@ class OpenInExternalBrowserAction extends Action2 { category: BrowserCategory, icon: Codicon.linkExternal, f1: true, - precondition: BROWSER_EDITOR_ACTIVE, + // Note: We do allow opening in an external browser even if there is an error page shown + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL), menu: { id: MenuId.BrowserActionsToolbar, group: ActionGroupPage, @@ -422,7 +456,7 @@ class ShowBrowserFindAction extends Action2 { title: localize2('browser.showFindAction', 'Find in Page'), category: BrowserCategory, f1: true, - precondition: BROWSER_EDITOR_ACTIVE, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), menu: { id: MenuId.BrowserActionsToolbar, group: ActionGroupPage, @@ -537,6 +571,7 @@ registerAction2(GoForwardAction); registerAction2(ReloadAction); registerAction2(FocusUrlInputAction); registerAction2(AddElementToChatAction); +registerAction2(AddConsoleLogsToChatAction); registerAction2(ToggleDevToolsAction); registerAction2(OpenInExternalBrowserAction); registerAction2(ClearGlobalBrowserStorageAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index 30199e27cbf..68d2376c587 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -54,8 +54,4 @@ export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService const workspaceId = this.workspaceContextService.getWorkspace().id; return this._browserViewService.clearWorkspaceStorage(workspaceId); } - - async getDebugWebSocketEndpoint() { - return this._browserViewService.getDebugWebSocketEndpoint(); - } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 7ac92123928..88e50c6a498 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -605,14 +605,6 @@ export function registerChatActions() { const chatVisible = viewsService.isViewVisible(ChatViewId); const clickBehavior = configurationService.getValue(ChatConfiguration.AgentsControlClickBehavior); switch (clickBehavior) { - case AgentsControlClickBehavior.Focus: - if (chatLocation === ViewContainerLocation.AuxiliaryBar) { - layoutService.setAuxiliaryBarMaximized(true); - } else { - this.updatePartVisibility(layoutService, chatLocation, true); - } - (await widgetService.revealWidget())?.focusInput(); - break; case AgentsControlClickBehavior.Cycle: if (chatVisible) { if ( diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index e5781a9fc88..5542c4cad88 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -129,6 +129,7 @@ class AttachFileToChatAction extends AttachResourceAction { id: AttachFileToChatAction.ID, title: localize2('workbench.action.chat.attachFile.label', "Add File to Chat"), category: CHAT_CATEGORY, + icon: Codicon.attach, precondition: ChatContextKeys.enabled, f1: true, menu: [{ @@ -173,9 +174,14 @@ class AttachFileToChatAction extends AttachResourceAction { ) ) }, { - id: MenuId.ChatEditorInlineGutter, - group: '2_chat', - order: 2, + id: MenuId.InlineChatEditorAffordance, + group: '0_chat', + order: 3, + when: ContextKeyExpr.and(ChatContextKeys.enabled, EditorContextKeys.hasNonEmptySelection.negate()) + }, { + id: MenuId.ChatEditorInlineMenu, + group: '0_chat', + order: 3, when: ContextKeyExpr.and(ChatContextKeys.enabled, EditorContextKeys.hasNonEmptySelection.negate()) }] }); @@ -290,6 +296,7 @@ class AttachSelectionToChatAction extends Action2 { id: AttachSelectionToChatAction.ID, title: localize2('workbench.action.chat.attachSelection.label', "Add Selection to Chat"), category: CHAT_CATEGORY, + icon: Codicon.attach, f1: true, precondition: ChatContextKeys.enabled, menu: [{ @@ -307,9 +314,14 @@ class AttachSelectionToChatAction extends Action2 { ) ) }, { - id: MenuId.ChatEditorInlineGutter, - group: '2_chat', - order: 1, + id: MenuId.InlineChatEditorAffordance, + group: '0_chat', + order: 2, + when: ContextKeyExpr.and(ChatContextKeys.enabled, EditorContextKeys.hasNonEmptySelection) + }, { + id: MenuId.ChatEditorInlineMenu, + group: '0_chat', + order: 2, when: ContextKeyExpr.and(ChatContextKeys.enabled, EditorContextKeys.hasNonEmptySelection) }] }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index bf6d4807280..9ec8cb095eb 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -181,7 +181,7 @@ abstract class SubmitAction extends Action2 { } const requestInProgressOrPendingToolCall = ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.Editing.hasToolConfirmation); -const whenNotInProgress = ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), ChatContextKeys.Editing.hasToolConfirmation.negate()); +const whenNotInProgress = ChatContextKeys.requestInProgress.negate(); export class ChatSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.chat.submit'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index c3200e37a00..a8d166e68f7 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -13,17 +13,12 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestQueueKind, IChatService } from '../../common/chatService/chatService.js'; -import { ChatConfiguration } from '../../common/constants.js'; import { isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; -const queueingEnabledCondition = ContextKeyExpr.equals(`config.${ChatConfiguration.RequestQueueingEnabled}`, true); -const requestInProgressOrPendingToolCall = ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.Editing.hasToolConfirmation); - const queuingActionsPresent = ContextKeyExpr.and( - queueingEnabledCondition, - ContextKeyExpr.or(requestInProgressOrPendingToolCall, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.QueueOrSteer)), + ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.QueueOrSteer)), ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.Sent), ); @@ -141,7 +136,6 @@ export class ChatRemovePendingRequestAction extends Action2 { group: 'navigation', order: 4, when: ContextKeyExpr.and( - queueingEnabledCondition, ChatContextKeys.isRequest, ChatContextKeys.isPendingRequest ) @@ -181,7 +175,6 @@ export class ChatSendPendingImmediatelyAction extends Action2 { group: 'navigation', order: 3, when: ContextKeyExpr.and( - queueingEnabledCondition, ChatContextKeys.isRequest, ChatContextKeys.isPendingRequest ) @@ -239,11 +232,8 @@ export class ChatRemoveAllPendingRequestsAction extends Action2 { id: MenuId.ChatContext, group: 'navigation', order: 3, - when: ContextKeyExpr.and( - queueingEnabledCondition, - ChatContextKeys.hasPendingRequests - ) - }] + when: ChatContextKeys.hasPendingRequests, + }], }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 56f51542f99..160fde28808 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -149,7 +149,7 @@ export class PickAgentSessionAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const instantiationService = accessor.get(IInstantiationService); - const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, undefined); + const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, undefined, undefined); await agentSessionsPicker.pickAgentSession(); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index 27fe44366cb..056f9ecd07f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -11,7 +11,7 @@ import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; -import { openSession } from './agentSessionsOpener.js'; +import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js'; import { IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; import { IAgentSessionsService } from './agentSessionsService.js'; import { AgentSessionsSorter, groupAgentSessionsByDate, sessionDateFromNow } from './agentSessionsViewer.js'; @@ -62,12 +62,17 @@ export function getSessionButtons(session: IAgentSession): IQuickInputButton[] { return buttons; } +export interface IAgentSessionsPickerOptions { + overrideSessionOpen?(session: IAgentSession, openOptions?: ISessionOpenOptions): Promise; +} + export class AgentSessionsPicker { private readonly sorter = new AgentSessionsSorter(); constructor( private readonly anchor: HTMLElement | undefined, + private readonly options: IAgentSessionsPickerOptions | undefined, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -87,13 +92,19 @@ export class AgentSessionsPicker { disposables.add(picker.onDidAccept(e => { const pick = picker.selectedItems[0]; if (pick) { - this.instantiationService.invokeFunction(openSession, pick.session, { + const openOptions: ISessionOpenOptions = { sideBySide: e.inBackground, editorOptions: { preserveFocus: e.inBackground, pinned: e.inBackground } - }); + }; + + if (this.options?.overrideSessionOpen) { + this.options.overrideSessionOpen(pick.session, openOptions); + } else { + this.instantiationService.invokeFunction(openSession, pick.session, openOptions); + } } if (!e.inBackground) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index bcc94cad564..daabea59fff 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -758,13 +758,19 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map ({ + section, + label: localize('agentSessions.sectionWithCount', "{0} ({1})", AgentSessionSectionLabels[section], sessions.length), + sessions + }); + return new Map([ - [AgentSessionSection.InProgress, { section: AgentSessionSection.InProgress, label: AgentSessionSectionLabels[AgentSessionSection.InProgress], sessions: inProgressSessions }], - [AgentSessionSection.Today, { section: AgentSessionSection.Today, label: AgentSessionSectionLabels[AgentSessionSection.Today], sessions: todaySessions }], - [AgentSessionSection.Yesterday, { section: AgentSessionSection.Yesterday, label: AgentSessionSectionLabels[AgentSessionSection.Yesterday], sessions: yesterdaySessions }], - [AgentSessionSection.Week, { section: AgentSessionSection.Week, label: AgentSessionSectionLabels[AgentSessionSection.Week], sessions: weekSessions }], - [AgentSessionSection.Older, { section: AgentSessionSection.Older, label: AgentSessionSectionLabels[AgentSessionSection.Older], sessions: olderSessions }], - [AgentSessionSection.Archived, { section: AgentSessionSection.Archived, label: localize('agentSessions.archivedSectionWithCount', "Archived ({0})", archivedSessions.length), sessions: archivedSessions }], + [AgentSessionSection.InProgress, sectionWithCount(AgentSessionSection.InProgress, inProgressSessions)], + [AgentSessionSection.Today, sectionWithCount(AgentSessionSection.Today, todaySessions)], + [AgentSessionSection.Yesterday, sectionWithCount(AgentSessionSection.Yesterday, yesterdaySessions)], + [AgentSessionSection.Week, sectionWithCount(AgentSessionSection.Week, weekSessions)], + [AgentSessionSection.Older, sectionWithCount(AgentSessionSection.Older, olderSessions)], + [AgentSessionSection.Archived, sectionWithCount(AgentSessionSection.Archived, archivedSessions)], ]); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 8b115b1fc94..cd8b77b0a0c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -204,11 +204,10 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.AgentsControlClickBehavior]: { type: 'string', - enum: [AgentsControlClickBehavior.Default, AgentsControlClickBehavior.Cycle, AgentsControlClickBehavior.Focus], + enum: [AgentsControlClickBehavior.Default, AgentsControlClickBehavior.Cycle], enumDescriptions: [ nls.localize('chat.agentsControl.clickBehavior.default', "Clicking chat icon toggles chat visibility."), nls.localize('chat.agentsControl.clickBehavior.cycle', "Clicking chat icon cycles through: show chat, maximize chat, hide chat. This requires chat to be contained in the secondary sidebar."), - nls.localize('chat.agentsControl.clickBehavior.focus', "Clicking chat icon focuses the chat view and maximizes it if located in the secondary sidebar.") ], markdownDescription: nls.localize('chat.agentsControl.clickBehavior', "Controls the behavior when clicking on the chat icon in the command center."), default: product.quality !== 'stable' ? AgentsControlClickBehavior.Cycle : AgentsControlClickBehavior.Default, @@ -644,12 +643,6 @@ configurationRegistry.registerConfiguration({ enumItemLabels: ExploreAgentDefaultModel.modelLabels, markdownEnumDescriptions: ExploreAgentDefaultModel.modelDescriptions }, - [ChatConfiguration.RequestQueueingEnabled]: { - type: 'boolean', - description: nls.localize('chat.requestQueuing.enabled.description', "When enabled, allows queuing additional messages while a request is in progress and steering the current request with a new message."), - default: true, - tags: ['experimental'], - }, [ChatConfiguration.RequestQueueingDefaultAction]: { type: 'string', enum: ['queue', 'steer'], @@ -689,15 +682,10 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'], }, - ['chat.statusWidget.sku']: { - type: 'string', - enum: ['free', 'anonymous'], - enumDescriptions: [ - nls.localize('chat.statusWidget.sku.free', "Show status widget for free tier users."), - nls.localize('chat.statusWidget.sku.anonymous', "Show status widget for anonymous users.") - ], - description: nls.localize('chat.statusWidget.enabled.description', "Controls which user type should see the status widget in new chat sessions when quota is exceeded."), - default: undefined, + ['chat.statusWidget.anonymous']: { + type: 'boolean', + description: nls.localize('chat.statusWidget.anonymous.description', "Controls whether anonymous users see the status widget in new chat sessions when rate limited."), + default: false, tags: ['experimental', 'advanced'], experiment: { mode: 'auto' diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts index cbaa0bc4753..439ee6ac394 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts @@ -10,7 +10,7 @@ import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platfor import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; -import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { ActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction, IActionRunner } from '../../../../../base/common/actions.js'; import { $, addDisposableGenericMouseMoveListener, append } from '../../../../../base/browser/dom.js'; import { assertType } from '../../../../../base/common/types.js'; @@ -35,7 +35,7 @@ export class ChatEditingAcceptRejectActionViewItem extends ActionViewItem { constructor( action: IAction, - options: IBaseActionViewItemOptions, + options: IActionViewItemOptions, private readonly _entry: IObservable, private readonly _editor: { focus(): void } | undefined, private readonly _keybindingService: IKeybindingService, @@ -92,7 +92,7 @@ export class ChatEditingAcceptRejectActionViewItem extends ActionViewItem { protected override getTooltip(): string | undefined { const value = super.getTooltip(); - if (!value || this.options.keybinding) { + if (!value) { return value; } return this._keybindingService.appendKeybinding(value, this._action.id); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts index acbbd2c650f..07b5ef8421f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts @@ -34,6 +34,7 @@ import { IChatResponseModel } from '../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { IDocumentDiff2 } from './chatEditingCodeEditorIntegration.js'; import { pendingRewriteMinimap } from './chatEditingModifiedFileEntry.js'; +import { chatSessionResourceToId } from '../../common/model/chatUri.js'; type affectedLines = { linesAdded: number; linesRemoved: number; lineCount: number; hasRemainingEdits: boolean }; type acceptedOrRejectedLines = affectedLines & { state: 'accepted' | 'rejected' }; @@ -260,7 +261,7 @@ export class ChatEditingTextModelChangeService extends Disposable { return EditSources.unknown({ name: 'editSessionUndoRedo' }); } - const sessionId = responseModel.session.sessionId; + const sessionId = chatSessionResourceToId(responseModel.session.sessionResource); const request = responseModel.session.getRequests().at(-1); const languageId = this.modifiedModel.getLanguageId(); const agent = responseModel.agent; diff --git a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts index e5848bf941a..494c639a67a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts +++ b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts @@ -23,7 +23,6 @@ import * as nls from '../../../../nls.js'; const MAX_CHANGES = 100; const MAX_DIFFS_SIZE_BYTES = 900 * 1024; -const MAX_SESSIONS_WITH_FULL_DIFFS = 5; const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB per file /** * Regex to match `url = ` lines in git config. @@ -374,7 +373,83 @@ function computeDiffHunks( } /** - * Captures repository state from the first available SCM repository. + * Captures lightweight repository metadata (branch, commit, remote) from SCM providers. + * No file I/O or diff computation - reads only from already-loaded SCM observables. + * Used on chat message submission to record the point-in-time commit state. + */ +export function captureRepoMetadata(scmService: ISCMService): IExportableRepoData | undefined { + const repositories = [...scmService.repositories]; + if (repositories.length === 0) { + return undefined; + } + + const repository = repositories[0]; + const rootUri = repository.provider.rootUri; + if (!rootUri) { + return undefined; + } + + let localBranch: string | undefined; + let localHeadCommit: string | undefined; + let remoteTrackingBranch: string | undefined; + let remoteHeadCommit: string | undefined; + let remoteBaseBranch: string | undefined; + + const historyProvider = repository.provider.historyProvider?.get(); + if (historyProvider) { + const historyItemRef = historyProvider.historyItemRef.get(); + localBranch = historyItemRef?.name; + localHeadCommit = historyItemRef?.revision; + + const historyItemRemoteRef = historyProvider.historyItemRemoteRef.get(); + if (historyItemRemoteRef) { + remoteTrackingBranch = historyItemRemoteRef.name; + remoteHeadCommit = historyItemRemoteRef.revision; + } + + const historyItemBaseRef = historyProvider.historyItemBaseRef.get(); + if (historyItemBaseRef) { + remoteBaseBranch = historyItemBaseRef.name; + } + } + + // Determine workspace type and sync status without file I/O. + // Cannot determine remoteUrl/remoteVendor or detect plain-folder here (requires reading .git/config). + // The full captureRepoInfo at export time will produce accurate classification. + let workspaceType: IExportableRepoData['workspaceType']; + let syncStatus: IExportableRepoData['syncStatus']; + + if (remoteTrackingBranch || remoteHeadCommit || remoteBaseBranch) { + workspaceType = 'remote-git'; + + if (!remoteTrackingBranch) { + syncStatus = 'unpublished'; + } else if (localHeadCommit && remoteHeadCommit && localHeadCommit === remoteHeadCommit) { + syncStatus = 'synced'; + } else { + syncStatus = 'unpushed'; + } + } else { + // No remote refs available; conservatively classify as local-git + workspaceType = 'local-git'; + syncStatus = 'local-only'; + } + + return { + workspaceType, + syncStatus, + localBranch, + remoteTrackingBranch, + remoteBaseBranch, + localHeadCommit, + remoteHeadCommit, + diffsStatus: 'notCaptured', + }; +} + +/** + * Captures full repository state including working tree diffs. + * Performs file I/O and diff computation - should only be called on explicit user action (e.g., export). */ export async function captureRepoInfo(scmService: ISCMService, fileService: IFileService): Promise { const repositories = [...scmService.repositories]; @@ -564,7 +639,9 @@ export async function captureRepoInfo(scmService: ISCMService, fileService: IFil } /** - * Captures repository information for chat sessions on creation and first message. + * Captures lightweight repository metadata for chat sessions on first message. + * Only reads from already-loaded SCM provider observables, no file I/O. + * Full diff capture is deferred to export time (see chatExportZip.ts). */ export class ChatRepoInfoContribution extends Disposable implements IWorkbenchContribution { @@ -576,7 +653,6 @@ export class ChatRepoInfoContribution extends Disposable implements IWorkbenchCo @IChatService private readonly chatService: IChatService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @ISCMService private readonly scmService: ISCMService, - @IFileService private readonly fileService: IFileService, @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configurationService: IConfigurationService, ) { @@ -586,12 +662,12 @@ export class ChatRepoInfoContribution extends Disposable implements IWorkbenchCo this.registerConfigurationIfInternal(); })); - this._register(this.chatService.onDidSubmitRequest(async ({ chatSessionResource }) => { + this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => { const model = this.chatService.getSession(chatSessionResource); if (!model) { return; } - await this.captureAndSetRepoData(model); + this.captureAndSetRepoMetadata(model); })); } @@ -612,7 +688,7 @@ export class ChatRepoInfoContribution extends Disposable implements IWorkbenchCo properties: { [ChatConfiguration.RepoInfoEnabled]: { type: 'boolean', - description: nls.localize('chat.repoInfo.enabled', "Controls whether repository information (branch, commit, working tree diffs) is captured at the start of chat sessions for internal diagnostics."), + description: nls.localize('chat.repoInfo.enabled', "Controls whether lightweight repository metadata (branch, commit, remotes) is captured when a chat request is submitted for internal diagnostics."), default: false, } } @@ -622,12 +698,15 @@ export class ChatRepoInfoContribution extends Disposable implements IWorkbenchCo this.logService.debug('[ChatRepoInfo] Configuration registered for internal user'); } - private async captureAndSetRepoData(model: IChatModel): Promise { + /** + * Captures lightweight metadata (branch, commit, remote refs) on first message. + * Synchronous, no file I/O. Reads only from SCM provider observables. + */ + private captureAndSetRepoMetadata(model: IChatModel): void { if (!this.chatEntitlementService.isInternal) { return; } - // Check if repo info capture is enabled via configuration if (!this.configurationService.getValue(ChatConfiguration.RepoInfoEnabled)) { return; } @@ -637,55 +716,17 @@ export class ChatRepoInfoContribution extends Disposable implements IWorkbenchCo } try { - const repoData = await captureRepoInfo(this.scmService, this.fileService); - if (repoData) { - model.setRepoData(repoData); - if (!repoData.localHeadCommit && repoData.workspaceType !== 'plain-folder') { - this.logService.warn('[ChatRepoInfo] Captured repo data without commit hash - git history may not be ready'); + const metadata = captureRepoMetadata(this.scmService); + if (metadata) { + model.setRepoData(metadata); + if (!metadata.localHeadCommit) { + this.logService.warn('[ChatRepoInfo] Captured repo metadata without commit hash - git history may not be ready'); } - - // Trim diffs from older sessions to manage storage - this.trimOldSessionDiffs(); } else { this.logService.debug('[ChatRepoInfo] No SCM repository available for chat session'); } } catch (error) { - this.logService.warn('[ChatRepoInfo] Failed to capture repo info:', error); - } - } - - /** - * Trims diffs from older sessions, keeping full diffs only for the most recent sessions. - */ - private trimOldSessionDiffs(): void { - try { - // Get all sessions with repoData that has diffs - const sessionsWithDiffs: { model: IChatModel; timestamp: number }[] = []; - - for (const model of this.chatService.chatModels.get()) { - if (model.repoData?.diffs && model.repoData.diffs.length > 0 && model.repoData.diffsStatus === 'included') { - sessionsWithDiffs.push({ model, timestamp: model.timestamp }); - } - } - - // Sort by timestamp descending (most recent first) - sessionsWithDiffs.sort((a, b) => b.timestamp - a.timestamp); - - // Trim diffs from sessions beyond the limit - for (let i = MAX_SESSIONS_WITH_FULL_DIFFS; i < sessionsWithDiffs.length; i++) { - const { model } = sessionsWithDiffs[i]; - if (model.repoData) { - const trimmedRepoData: IExportableRepoData = { - ...model.repoData, - diffs: undefined, - diffsStatus: 'trimmedForStorage' - }; - model.setRepoData(trimmedRepoData); - this.logService.trace(`[ChatRepoInfo] Trimmed diffs from older session: ${model.sessionResource.toString()}`); - } - } - } catch (error) { - this.logService.warn('[ChatRepoInfo] Failed to trim old session diffs:', error); + this.logService.warn('[ChatRepoInfo] Failed to capture repo metadata:', error); } } } 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 cb8b452684a..bb70941b5ac 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -993,15 +993,25 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public async getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise { - const existingSessionData = this._sessions.get(sessionResource); - if (existingSessionData) { - return existingSessionData.session; + { + const existingSessionData = this._sessions.get(sessionResource); + if (existingSessionData) { + return existingSessionData.session; + } } if (!(await raceCancellationError(this.canResolveChatSession(sessionResource), token))) { throw Error(`Can not find provider for ${sessionResource}`); } + // Check again after async provider resolution + { + const existingSessionData = this._sessions.get(sessionResource); + if (existingSessionData) { + return existingSessionData.session; + } + } + const resolvedType = this._resolveToPrimaryType(sessionResource.scheme) || sessionResource.scheme; const provider = this._contentProviders.get(resolvedType); if (!provider) { @@ -1009,6 +1019,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } const session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); + + // Make sure another session wasn't created while we were awaiting the provider + { + const existingSessionData = this._sessions.get(sessionResource); + if (existingSessionData) { + session.dispose(); + return existingSessionData.session; + } + } + const sessionData = new ContributedChatSessionData(session, sessionResource.scheme, sessionResource, session.options, resource => { sessionData.dispose(); this._sessions.delete(resource); @@ -1016,6 +1036,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._sessions.set(sessionResource, sessionData); + // Make sure any listeners are aware of the new session and its options + this._onDidChangeSessionOptions.fire(sessionResource); + return session; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 9929aa7064e..8922f2cbf35 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -31,6 +31,7 @@ import { ChatSetupController } from './chatSetupController.js'; import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IHostService } from '../../../../services/host/browser/host.js'; +import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', @@ -49,7 +50,7 @@ export class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService)); + return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService), accessor.get(IWorkbenchAssignmentService)); }); } @@ -72,7 +73,8 @@ export class ChatSetup { @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, - @IHostService private readonly hostService: IHostService + @IHostService private readonly hostService: IHostService, + @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, ) { } skipDialog(): void { @@ -162,7 +164,8 @@ export class ChatSetup { private async showDialog(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }): Promise { const disposables = new DisposableStore(); - const buttons = this.getButtons(options); + const useCloseButton = await this.experimentService.getTreatment('chatSetupDialogCloseButton'); + const buttons = this.getButtons(options, useCloseButton); const dialog = disposables.add(new Dialog( this.layoutService.activeContainer, @@ -174,8 +177,8 @@ export class ChatSetup { detail: ' ', // workaround allowing us to render the message in large icon: Codicon.copilotLarge, alignment: DialogContentsAlignment.Vertical, - cancelId: buttons.length - 1, - disableCloseButton: true, + cancelId: useCloseButton ? buttons.length : buttons.length - 1, + disableCloseButton: !useCloseButton, renderFooter: footer => footer.appendChild(this.createDialogFooter(disposables, options)), buttonOptions: buttons.map(button => button[2]) }, this.keybindingService, this.layoutService, this.hostService) @@ -187,7 +190,7 @@ export class ChatSetup { return buttons[button]?.[1] ?? ChatSetupStrategy.Canceled; } - private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { + private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }, useCloseButton?: boolean): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { type ContinueWithButton = [string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]; const styleButton = (...classes: string[]) => ({ styleButton: (button: IButton) => button.element.classList.add(...classes) }); @@ -221,7 +224,9 @@ export class ChatSetup { buttons = [[localize('setupAIButton', "Use AI Features"), ChatSetupStrategy.DefaultSetup, undefined]]; } - buttons.push([localize('skipForNow', "Skip for now"), ChatSetupStrategy.Canceled, styleButton('link-button', 'skip-button')]); + if (!useCloseButton) { + buttons.push([localize('skipForNow', "Skip for now"), ChatSetupStrategy.Canceled, styleButton('link-button', 'skip-button')]); + } return buttons; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 9c54a9062e5..b7011234695 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -14,11 +14,14 @@ import { IChatSlashCommandService } from '../common/participants/chatSlashComman import { IChatService } from '../common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; import { ACTION_ID_NEW_CHAT } from './actions/chatActions.js'; -import { ChatSubmitAction, OpenModelPickerAction } from './actions/chatExecuteActions.js'; +import { ChatSubmitAction, OpenModePickerAction, OpenModelPickerAction } from './actions/chatExecuteActions.js'; import { ConfigureToolsAction } from './actions/chatToolActions.js'; import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; import { IChatWidgetService } from './chat.js'; +import { CONFIGURE_INSTRUCTIONS_ACTION_ID } from './promptSyntax/attachInstructionsAction.js'; import { showConfigureHooksQuickPick } from './promptSyntax/hookActions.js'; +import { CONFIGURE_PROMPTS_ACTION_ID } from './promptSyntax/runPromptAction.js'; +import { CONFIGURE_SKILLS_ACTION_ID } from './promptSyntax/skillActions.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -93,7 +96,7 @@ export class ChatSlashCommandsContribution extends Disposable { silent: true, locations: [ChatAgentLocation.Chat] }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.customagents'); + await commandService.executeCommand(OpenModePickerAction.ID); })); this._store.add(slashCommandService.registerSlashCommand({ command: 'skills', @@ -103,7 +106,7 @@ export class ChatSlashCommandsContribution extends Disposable { silent: true, locations: [ChatAgentLocation.Chat] }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.skills'); + await commandService.executeCommand(CONFIGURE_SKILLS_ACTION_ID); })); this._store.add(slashCommandService.registerSlashCommand({ command: 'instructions', @@ -113,7 +116,7 @@ export class ChatSlashCommandsContribution extends Disposable { silent: true, locations: [ChatAgentLocation.Chat] }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.instructions'); + await commandService.executeCommand(CONFIGURE_INSTRUCTIONS_ACTION_ID); })); this._store.add(slashCommandService.registerSlashCommand({ command: 'prompts', @@ -123,7 +126,7 @@ export class ChatSlashCommandsContribution extends Disposable { silent: true, locations: [ChatAgentLocation.Chat] }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.prompts'); + await commandService.executeCommand(CONFIGURE_PROMPTS_ACTION_ID); })); this._store.add(slashCommandService.registerSlashCommand({ command: 'rename', diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index a4ab011764a..3a5d17812a8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -112,6 +112,11 @@ export interface ITipDefinition { * Command IDs that are allowed to be executed from this tip's markdown. */ readonly enabledCommands?: string[]; + /** + * Chat model IDs for which this tip is eligible. + * Compared against the lowercased `chatModelId` context key. + */ + readonly onlyWhenModelIds?: readonly string[]; /** * Command IDs that, if ever executed in this workspace, make this tip ineligible. * The tip won't be shown if the user has already performed the action it suggests. @@ -144,6 +149,12 @@ export interface ITipDefinition { * Static catalog of tips. Each tip has an optional when clause for eligibility. */ const TIP_CATALOG: ITipDefinition[] = [ + { + id: 'tip.switchToAuto', + message: localize('tip.switchToAuto', "Tip: Using gpt-4.1? Try switching to [Auto](command:workbench.action.chat.openModelPicker) in the model picker for better coding performance."), + enabledCommands: ['workbench.action.chat.openModelPicker'], + onlyWhenModelIds: ['gpt-4.1'], + }, { id: 'tip.agentMode', message: localize('tip.agentMode', "Tip: Try [Agents](command:workbench.action.chat.openEditSession) to make edits across your project and run commands."), @@ -615,6 +626,16 @@ export class ChatTipService extends Disposable implements IChatTipService { // Return the already-shown tip for stable rerenders if (this._tipRequestId === 'welcome' && this._shownTip) { + if (!this._isEligible(this._shownTip, contextKeyService)) { + const nextTip = this._findNextEligibleTip(this._shownTip.id, contextKeyService); + if (nextTip) { + this._shownTip = nextTip; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, nextTip.id, StorageScope.PROFILE, StorageTarget.USER); + const tip = this._createTip(nextTip); + this._onDidNavigateTip.fire(tip); + return tip; + } + } return this._createTip(this._shownTip); } @@ -623,6 +644,24 @@ export class ChatTipService extends Disposable implements IChatTipService { return tip; } + private _findNextEligibleTip(currentTipId: string, contextKeyService: IContextKeyService): ITipDefinition | undefined { + const currentIndex = TIP_CATALOG.findIndex(tip => tip.id === currentTipId); + if (currentIndex === -1) { + return undefined; + } + + const dismissedIds = new Set(this._getDismissedTipIds()); + for (let i = 1; i < TIP_CATALOG.length; i++) { + const idx = (currentIndex + i) % TIP_CATALOG.length; + const candidate = TIP_CATALOG[idx]; + if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { + return candidate; + } + } + + return undefined; + } + private _pickTip(sourceId: string, contextKeyService: IContextKeyService): IChatTip | undefined { // Record the current mode for future eligibility decisions. this._tracker.recordCurrentMode(contextKeyService); @@ -711,6 +750,13 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean { + if (tip.onlyWhenModelIds?.length) { + const currentModelId = this._getCurrentChatModelId(contextKeyService); + const isModelMatch = tip.onlyWhenModelIds.some(modelId => currentModelId === modelId || currentModelId.startsWith(`${modelId}-`)); + if (!isModelMatch) { + return false; + } + } if (tip.when && !contextKeyService.contextMatchesRules(tip.when)) { this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize()); return false; @@ -722,6 +768,42 @@ export class ChatTipService extends Disposable implements IChatTipService { return true; } + private _getCurrentChatModelId(contextKeyService: IContextKeyService): string { + const normalize = (modelId: string | undefined): string => { + const normalizedModelId = modelId?.toLowerCase() ?? ''; + if (!normalizedModelId) { + return ''; + } + + if (normalizedModelId.includes('/')) { + return normalizedModelId.split('/').at(-1) ?? ''; + } + + return normalizedModelId; + }; + + const contextKeyModelId = normalize(contextKeyService.getContextKeyValue(ChatContextKeys.chatModelId.key)); + if (contextKeyModelId) { + return contextKeyModelId; + } + + const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key) ?? ChatAgentLocation.Chat; + const sessionType = contextKeyService.getContextKeyValue(ChatContextKeys.chatSessionType.key) ?? ''; + const candidateStorageKeys = sessionType + ? [`chat.currentLanguageModel.${location}.${sessionType}`, `chat.currentLanguageModel.${location}`] + : [`chat.currentLanguageModel.${location}`]; + + for (const storageKey of candidateStorageKeys) { + const persistedModelIdentifier = this._storageService.get(storageKey, StorageScope.APPLICATION); + const persistedModelId = normalize(persistedModelIdentifier); + if (persistedModelId) { + return persistedModelId; + } + } + + return ''; + } + private _isChatLocation(contextKeyService: IContextKeyService): boolean { const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key); return !location || location === ChatAgentLocation.Chat; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts index f009cb3eba9..e2113a96523 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts @@ -26,12 +26,12 @@ import { IOpenerService } from '../../../../../platform/opener/common/opener.js' /** * Action ID for the `Attach Instruction` action. */ -const ATTACH_INSTRUCTIONS_ACTION_ID = 'workbench.action.chat.attach.instructions'; +export const ATTACH_INSTRUCTIONS_ACTION_ID = 'workbench.action.chat.attach.instructions'; /** * Action ID for the `Configure Instruction` action. */ -const CONFIGURE_INSTRUCTIONS_ACTION_ID = 'workbench.action.chat.configure.instructions'; +export const CONFIGURE_INSTRUCTIONS_ACTION_ID = 'workbench.action.chat.configure.instructions'; class ManageInstructionsFilesAction extends Action2 { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index da74b4da74d..5113db79b5c 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -318,8 +318,8 @@ const MAKE_VISIBLE_BUTTON: IQuickInputButton = { * Button that sets a prompt file to be invisible. */ const MAKE_INVISIBLE_BUTTON: IQuickInputButton = { - tooltip: localize('makeInvisible', "Hide from agent picker"), - iconClass: ThemeIcon.asClassName(Codicon.eyeClosed), + tooltip: localize('makeInvisible', "Shown in chat view agent picker. Click to hide."), + iconClass: ThemeIcon.asClassName(Codicon.eye), }; export class PromptFilePickers { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts index 96e913156aa..302ab0356b3 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts @@ -44,7 +44,7 @@ export class PromptFileRewriter { this.rewriteAttribute(model, '', toolsAttr.range); return; } else { - this.rewriteTools(model, newTools, toolsAttr.value.range, toolsAttr.value.type === 'string'); + this.rewriteTools(model, newTools, toolsAttr.value.range, toolsAttr.value.type === 'scalar'); } } @@ -77,7 +77,7 @@ export class PromptFileRewriter { if (!nameAttr) { return; } - if (nameAttr.value.type === 'string' && nameAttr.value.value === newName) { + if (nameAttr.value.type === 'scalar' && nameAttr.value.value === newName) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index 021f38acfd4..538f5d4ac2d 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -72,21 +72,21 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider return undefined; } let value = toolsAttr.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type !== 'array') { + if (value.type !== 'sequence') { return undefined; } const items = value.items; - const selectedTools = items.filter(item => item.type === 'string').map(item => item.value); + const selectedTools = items.filter(item => item.type === 'scalar').map(item => item.value); const codeLens: CodeLens = { range: toolsAttr.range.collapseToStart(), command: { title: localize('configure-tools.capitalized.ellipsis', "Configure Tools..."), id: this.cmdId, - arguments: [model, toolsAttr.range, toolsAttr.value.type === 'string', selectedTools, target] + arguments: [model, toolsAttr.range, toolsAttr.value.type === 'scalar', selectedTools, target] } }; return { lenses: [codeLens] }; diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/runPromptAction.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/runPromptAction.ts index fac4e86a1c0..c301e918ed9 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/runPromptAction.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/runPromptAction.ts @@ -57,7 +57,7 @@ const RUN_SELECTED_PROMPT_ACTION_ID = 'workbench.action.chat.run.prompt'; /** * Action ID for the `Configure Prompt Files...` action. */ -const CONFIGURE_PROMPTS_ACTION_ID = 'workbench.action.chat.configure.prompts'; +export const CONFIGURE_PROMPTS_ACTION_ID = 'workbench.action.chat.configure.prompts'; /** * Constructor options for the `Run Prompt` base action. diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/skillActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/skillActions.ts index 01d802c1541..a90166a3a59 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/skillActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/skillActions.ts @@ -19,7 +19,7 @@ import { IOpenerService } from '../../../../../platform/opener/common/opener.js' /** * Action ID for the `Configure Skills` action. */ -const CONFIGURE_SKILLS_ACTION_ID = 'workbench.action.chat.configure.skills'; +export const CONFIGURE_SKILLS_ACTION_ID = 'workbench.action.chat.configure.skills'; class ManageSkillsAction extends Action2 { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index f7d22ff52f2..7b590f36922 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -445,6 +445,10 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo if (dto.context?.sessionResource) { model = this._chatService.getSession(dto.context.sessionResource); request = model?.getRequests().at(-1); + if (request?.response?.isCanceled || request?.response?.isComplete) { + this._logService.debug(`[LanguageModelToolsService#invokeTool] Ignoring tool ${dto.toolId} for cancelled/complete request ${request.id}`); + throw new CancellationError(); + } } // Check if there's an existing pending tool call from streaming phase BEFORE hook check diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index d4b4cec56b2..812f076e628 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -120,11 +120,6 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const element = context.element; const inUndoStop = (findLast(context.content, e => e.kind === 'undoStop', context.contentIndex) as IChatUndoStop | undefined)?.id; - // We release editors in order so that it's more likely that the same editor will - // be assigned if this element is re-rendered right away, like it often is during - // progressive rendering - const orderedDisposablesList: IDisposable[] = []; - // Need to track the index of the codeblock within the response so it can have a unique ID, // and within this part to find it within the codeblocks array let globalCodeBlockIndexStart = codeBlockStartIndex; @@ -141,11 +136,28 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const enableMath = configurationService.getValue(ChatConfiguration.EnableMath); + const renderStore = this._register(new MutableDisposable()); + const doRenderMarkdown = () => { if (this._store.isDisposed) { return; } + // Dispose previous render and reset state for re-render + const store = new DisposableStore(); + renderStore.value = store; + dom.clearNode(this.domNode); + this.allRefs.length = 0; + this._codeblocks.length = 0; + this.mathLayoutParticipants.clear(); + globalCodeBlockIndexStart = codeBlockStartIndex; + thisPartCodeBlockIndexStart = 0; + + // We release editors in order so that it's more likely that the same editor will + // be assigned if this element is re-rendered right away, like it often is during + // progressive rendering + const orderedDisposablesList: IDisposable[] = []; + // TODO: Move katex support into chatMarkdownRenderer const markedExtensions = enableMath ? coalesce([MarkedKatexSupport.getExtension(dom.getWindow(context.container), { @@ -160,7 +172,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP breaks: true, }; - const result = this._register(renderer.render(markdown.content, { + const result = store.add(renderer.render(markdown.content, { sanitizerConfig: MarkedKatexSupport.getSanitizerOptions({ allowedTags: allowedChatMarkdownHtmlTags, allowedAttributes: allowedMarkdownHtmlAttributes, @@ -201,7 +213,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } if (languageId === 'vscode-extensions') { - const chatExtensions = this._register(instantiationService.createInstance(ChatExtensionsContentPart, { kind: 'extensions', extensions: text.split(',') })); + const chatExtensions = store.add(instantiationService.createInstance(ChatExtensionsContentPart, { kind: 'extensions', extensions: text.split(',') })); return chatExtensions.domNode; } const globalIndex = globalCodeBlockIndexStart++; @@ -216,8 +228,8 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP range = parsedBody.range && Range.lift(parsedBody.range); const modelRefPromise = this.textModelService.createModelReference(parsedBody.uri); textModel = modelRefPromise.then(ref => { - if (!this._store.isDisposed) { - this._register(ref); + if (!store.isDisposed) { + store.add(ref); } return ref.object.textEditorModel; }); @@ -330,12 +342,12 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } const markdownDecorationsRenderer = instantiationService.createInstance(ChatMarkdownDecorationsRenderer); - this._register(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(markdown, result.element)); + store.add(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(markdown, result.element)); const layoutParticipants = new Lazy(() => { const observer = new ResizeObserver(() => this.mathLayoutParticipants.forEach(layout => layout())); observer.observe(this.domNode); - this._register(toDisposable(() => observer.disconnect())); + store.add(toDisposable(() => observer.disconnect())); return this.mathLayoutParticipants; }); @@ -357,19 +369,21 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP scrollable.scanDomNode(); } - orderedDisposablesList.reverse().forEach(d => this._register(d)); + orderedDisposablesList.reverse().forEach(d => store.add(d)); }; + // Always render immediately + doRenderMarkdown(); + if (enableMath && !MarkedKatexSupport.getExtension(dom.getWindow(context.container))) { - // Need to load async + // KaTeX not yet loaded - load it and re-render when ready MarkedKatexSupport.loadExtension(dom.getWindow(context.container)) + .then(() => { + doRenderMarkdown(); + }) .catch(e => { console.error('Failed to load MarkedKatexSupport extension:', e); - }).finally(() => { - doRenderMarkdown(); }); - } else { - doRenderMarkdown(); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index 12dbdf346e3..560e9ecf849 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -260,45 +260,6 @@ export class ChatMarkdownDecorationsRenderer { return container; } - /** - * Renders a parsed chat request as plain text DOM elements with inline decoration spans - * for agents, slash commands, and other special parts. No markdown rendering is used. - */ - renderParsedRequestToPlainText(parsedRequest: IParsedChatRequest, store: DisposableStore): HTMLElement { - const container = dom.$('span'); - container.style.whiteSpace = 'pre-wrap'; - for (const part of parsedRequest.parts) { - if (part instanceof ChatRequestTextPart) { - container.appendChild(document.createTextNode(part.text)); - } else if (part instanceof ChatRequestAgentPart) { - const widget = this.renderResourceWidget(`${chatAgentLeader}${part.agent.name}`, undefined, store); - const hover: Lazy = new Lazy(() => store.add(this.instantiationService.createInstance(ChatAgentHover))); - store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), widget, () => { - hover.value.setAgent(part.agent.id); - return hover.value.domNode; - }, getChatAgentHoverOptions(() => part.agent, this.commandService))); - container.appendChild(widget); - } else { - const title = this.getDecorationTitle(part); - const widget = this.renderResourceWidget(part.text, title ? { title } : undefined, store); - container.appendChild(widget); - } - } - return container; - } - - private getDecorationTitle(part: IParsedChatRequestPart): string | undefined { - const uri = part instanceof ChatRequestDynamicVariablePart && part.data instanceof URI ? - part.data : - undefined; - return uri ? this.labelService.getUriLabel(uri, { relative: true }) : - part instanceof ChatRequestSlashCommandPart ? part.slashCommand.detail : - part instanceof ChatRequestAgentSubcommandPart ? part.command.description : - part instanceof ChatRequestSlashPromptPart ? part.name : - part instanceof ChatRequestToolPart ? (this.toolsService.getTool(part.toolId)?.userDescription) : - undefined; - } - private injectKeybindingHint(a: HTMLAnchorElement, href: string, keybindingService: IKeybindingService): void { const command = href.match(/command:([^\)]+)/)?.[1]; if (command) { 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 3bd10b0b917..d1284a8d1e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; +import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; 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 { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; +import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { InputBox } from '../../../../../../base/browser/ui/inputbox/inputBox.js'; @@ -55,6 +58,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private readonly _multiSelectCheckboxes: Map = new Map(); private readonly _freeformTextareas: Map = new Map(); private readonly _inputBoxes: DisposableStore = this._register(new DisposableStore()); + private readonly _questionRenderStore = this._register(new MutableDisposable()); /** * Disposable store for interactive UI components (header, nav buttons, etc.) @@ -66,6 +70,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent public readonly carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, private readonly _options: IChatQuestionCarouselOptions, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, @IHoverService private readonly _hoverService: IHoverService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, ) { @@ -233,7 +238,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const question = this.carousel.questions[this._currentIndex]; if (question) { const questionText = question.message ?? question.title; - const messageContent = typeof questionText === 'string' ? questionText : questionText.value; + const messageContent = this.getQuestionText(questionText); const questionCount = this.carousel.questions.length; const alertMessage = questionCount === 1 ? messageContent @@ -265,6 +270,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private clearInteractiveResources(): void { // Dispose interactive UI disposables (header, nav buttons, etc.) this._interactiveUIStore.clear(); + this._questionRenderStore.clear(); this._inputBoxes.clear(); this._textInputBoxes.clear(); this._singleSelectItems.clear(); @@ -400,7 +406,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } const questionText = question.message ?? question.title; - const messageContent = typeof questionText === 'string' ? questionText : questionText.value; + const messageContent = this.getQuestionText(questionText); const questionCount = this.carousel.questions.length; if (questionCount === 1) { @@ -429,6 +435,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent return; } + const questionRenderStore = new DisposableStore(); + this._questionRenderStore.value = questionRenderStore; + // Clear previous input boxes and stale references this._inputBoxes.clear(); this._textInputBoxes.clear(); @@ -452,26 +461,29 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const questionText = question.message ?? question.title; if (questionText) { const title = dom.$('.chat-question-title'); - const messageContent = typeof questionText === 'string' - ? questionText - : questionText.value; + const messageContent = this.getQuestionText(questionText); title.setAttribute('aria-label', messageContent); - // 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 (isMarkdownString(questionText)) { + const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(MarkdownString.lift(questionText))); + title.appendChild(renderedTitle.element); } else { - title.textContent = messageContent; + // Check for subtitle in parentheses at the end + const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/); + if (parenMatch) { + // Main title (bold) + const mainTitle = dom.$('span.chat-question-title-main'); + mainTitle.textContent = parenMatch[1]; + title.appendChild(mainTitle); + + // Subtitle in parentheses (normal weight) + const subtitle = dom.$('span.chat-question-title-subtitle'); + subtitle.textContent = ' ' + parenMatch[2]; + title.appendChild(subtitle); + } else { + title.textContent = messageContent; + } } headerRow.appendChild(title); } @@ -1180,6 +1192,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } + private getQuestionText(questionText: string | IMarkdownString): string { + if (typeof questionText === 'string') { + return questionText; + } + + return renderAsPlaintext(questionText); + } + 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/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 7a9859e7a58..256999f36bd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -66,7 +66,7 @@ export class ChatTipContentPart extends Disposable { const nextTip = this._getNextTip(); if (nextTip) { this._renderTip(nextTip); - this.focus(); + dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.focus()); } else { this._onDidHide.fire(); } @@ -74,7 +74,7 @@ export class ChatTipContentPart extends Disposable { this._register(this._chatTipService.onDidNavigateTip(tip => { this._renderTip(tip); - this.focus(); + dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.focus()); })); this._register(this._chatTipService.onDidHideTip(() => { 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 f200f56f932..1a3d83505b7 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 @@ -202,12 +202,12 @@ .chat-confirmation-widget2 { margin-bottom: 8px; border: 1px solid var(--vscode-chat-requestBorder); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-medium); } .chat-confirmation-widget2 .chat-confirmation-widget-title { border-bottom: 1px solid var(--vscode-chat-requestBorder); - padding: 5px 9px; + padding: 4px 8px; display: flex; justify-content: space-between; column-gap: 10px; @@ -218,6 +218,10 @@ p { margin: 0 !important; } + + .codicon { + margin-right: 4px; + } } p, @@ -261,7 +265,7 @@ .chat-confirmation-message-terminal .chat-confirmation-message-terminal-disclaimer p:last-child { margin-bottom: 0 !important; - padding: 5px 9px 0 9px; + padding: 4px 8px 0 8px; } .chat-confirmation-widget-container.hideButtons .chat-confirmation-widget-buttons, @@ -271,7 +275,7 @@ .chat-confirmation-widget2 .chat-confirmation-widget-buttons { display: flex; - padding: 5px 9px; + padding: 4px 8px; .chat-buttons { display: flex; @@ -283,6 +287,14 @@ width: inherit; } } + + .monaco-button-dropdown > .monaco-button.monaco-dropdown-button { + padding: 0px 4px 0 3px; + } + + .monaco-button-dropdown > .monaco-button.monaco-dropdown-button.codicon[class*='codicon-'] { + font-size: 12px; + } } .chat-confirmation-widget2 .interactive-result-code-block.compare { 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 4a6cbd669af..57a3a06ca78 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 @@ -60,6 +60,21 @@ font-size: var(--vscode-chat-font-size-body-s); margin: 0; + .rendered-markdown { + a { + color: var(--vscode-textLink-foreground); + } + + a:hover, + a:active { + color: var(--vscode-textLink-activeForeground); + } + + p { + margin: 0; + } + } + .chat-question-title-main { font-weight: 500; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 5a297297f9a..5caa093f45d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1932,6 +1932,25 @@ export class ChatWidget extends Disposable implements IChatWidget { this.renderFollowups(); this.renderChatSuggestNextWidget(); })); + let previousModelIdentifier: string | undefined; + this._register(autorun(reader => { + const modelIdentifier = this.inputPart.selectedLanguageModel.read(reader)?.identifier; + if (previousModelIdentifier === undefined) { + previousModelIdentifier = modelIdentifier; + return; + } + + if (previousModelIdentifier === modelIdentifier) { + return; + } + + previousModelIdentifier = modelIdentifier; + if (!this._gettingStartedTipPartRef) { + return; + } + + this.chatTipService.getWelcomeTip(this.contextKeyService); + })); this._register(autorun(r => { const toolSetIds = new Set(); @@ -1989,7 +2008,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this._codeBlockModelCollection.clear(); - this.container.setAttribute('data-session-id', model.sessionId); this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); // Pass input model reference to input part for state syncing @@ -2250,35 +2268,6 @@ export class ChatWidget extends Disposable implements IChatWidget { } } - private hasPendingQuestionCarousel(response: IChatResponseModel | undefined): boolean { - return Boolean(response?.response.value.some(part => part.kind === 'questionCarousel' && !part.isUsed)); - } - - - private dismissPendingQuestionCarousel(): void { - if (!this.viewModel) { - return; - } - - const inputPart = this.inputPartDisposable.value; - if (!inputPart) { - return; - } - - const responseId = inputPart.questionCarouselResponseId; - if (!responseId || this.viewModel.model.lastRequest?.id !== responseId) { - return; - } - - const carouselPart = inputPart.questionCarousel; - if (!carouselPart) { - return; - } - - carouselPart.ignore(); - inputPart.clearQuestionCarousel(responseId); - } - private async _acceptInput(query: { query: string } | undefined, options: IChatAcceptInputOptions = {}): Promise { if (!query && this.input.generating) { // if the user submits the input and generation finishes quickly, just submit it for them @@ -2334,16 +2323,17 @@ export class ChatWidget extends Disposable implements IChatWidget { } const model = this.viewModel.model; - - // Enable steering while a question carousel is pending, useful for when the questions are off track and the user needs to course correct. - const hasPendingQuestionCarousel = this.hasPendingQuestionCarousel(model.lastRequest?.response); - const shouldAutoSteer = hasPendingQuestionCarousel && options.queue === undefined; - if (shouldAutoSteer) { - options.queue = ChatRequestQueueKind.Steering; - this.dismissPendingQuestionCarousel(); - } - const requestInProgress = model.requestInProgress.get(); + // Cancel the request if the user chooses to take a different path. + // This is a bit of a heuristic for the common case of tool confirmation+reroute. + // But we don't do this if there are queued messages, because we would either + // discard them or need a prompt (as in `confirmPendingRequestsBeforeSend`) + // which could be a surprising behavior if the user finishes typing a steering + // request just as confirmation is triggered. + if (model.requestNeedsInput.get() && !model.getPendingRequests().length) { + this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource); + options.queue ??= ChatRequestQueueKind.Queued; + } if (requestInProgress) { options.queue ??= ChatRequestQueueKind.Queued; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index a9d05cafbbc..cfe1d784a8b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -126,6 +126,7 @@ import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js' import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; import { Target } from '../../../common/promptSyntax/service/promptsService.js'; +import { InlineCompletionsController } from '../../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; const $ = dom.$; @@ -349,6 +350,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private editingSentRequestKey!: IContextKey; private chatModeKindKey: IContextKey; private chatModeNameKey: IContextKey; + private chatModelIdKey: IContextKey; private withinEditSessionKey: IContextKey; private filePartOfEditSessionKey: IContextKey; private chatSessionHasOptions: IContextKey; @@ -567,6 +569,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._hasQuestionCarouselContextKey = ChatContextKeys.Editing.hasQuestionCarousel.bindTo(contextKeyService); this.chatModeKindKey = ChatContextKeys.chatModeKind.bindTo(contextKeyService); this.chatModeNameKey = ChatContextKeys.chatModeName.bindTo(contextKeyService); + this.chatModelIdKey = ChatContextKeys.chatModelId.bindTo(contextKeyService); this.withinEditSessionKey = ChatContextKeys.withinEditSessionDiff.bindTo(contextKeyService); this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); @@ -638,6 +641,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); this._register(autorun(reader => { const lm = this._currentLanguageModel.read(reader); + this.chatModelIdKey.set(lm?.metadata.id.toLowerCase() ?? ''); if (lm?.metadata.name) { this.accessibilityService.alert(lm.metadata.name); } @@ -995,6 +999,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier) { this._currentLanguageModel.set(model, undefined); + // Record usage for the recently used models list + this.languageModelsService.recordModelUsage(model.identifier); + if (this.cachedWidth) { // For quick chat and editor chat, relayout because the input may need to shrink to accomodate the model name this.layout(this.cachedWidth); @@ -2067,7 +2074,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID, InlineCompletionsController.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); SuggestController.get(this._inputEditor)?.forceRenderingAbove(); @@ -2281,7 +2288,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let inputModel = this.modelService.getModel(this.inputUri); if (!inputModel) { - inputModel = this.modelService.createModel('', null, this.inputUri, true); + inputModel = this.modelService.createModel('', null, this.inputUri, false); } this.textModelResolverService.createModelReference(this.inputUri).then(ref => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts new file mode 100644 index 00000000000..8e7831378c1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -0,0 +1,513 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { renderIcon, renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { KeyCode } from '../../../../../../base/common/keyCodes.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { ActionListItemKind, IActionListItem, IActionListOptions } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js'; +import { ICuratedModel, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; +import { IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import * as semver from '../../../../../../base/common/semver/semver.js'; + +function isVersionAtLeast(current: string, required: string): boolean { + const currentSemver = semver.coerce(current); + if (!currentSemver) { + return false; + } + return semver.gte(currentSemver, required); +} + +/** + * Section identifiers for collapsible groups in the model picker. + */ +const ModelPickerSection = { + Other: 'other', +} as const; + +type ChatModelChangeClassification = { + owner: 'lramos15'; + comment: 'Reporting when the model picker is switched'; + fromModel?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous chat model' }; + toModel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new chat model' }; +}; + +type ChatModelChangeEvent = { + fromModel: string | TelemetryTrustedValue | undefined; + toModel: string | TelemetryTrustedValue; +}; + +function createModelItem( + action: IActionWidgetDropdownAction & { section?: string }, +): IActionListItem { + return { + item: action, + kind: ActionListItemKind.Action, + label: action.label, + description: action.description, + group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, + hideIcon: false, + section: action.section, + }; +} + +function createModelAction( + model: ILanguageModelChatMetadataAndIdentifier, + selectedModelId: string | undefined, + onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, + section?: string, +): IActionWidgetDropdownAction & { section?: string } { + return { + id: model.identifier, + enabled: true, + icon: model.metadata.statusIcon, + checked: model.identifier === selectedModelId, + class: undefined, + description: model.metadata.multiplier ?? model.metadata.detail, + tooltip: model.metadata.name, + label: model.metadata.name, + section, + run: () => onSelect(model), + }; +} + +/** + * Builds the grouped items for the model picker dropdown. + * + * Layout: + * 1. Auto (always first) + * 2. Recently used + curated models (merged, sorted alphabetically, no header) + * 3. Other Models (collapsible toggle, sorted alphabetically) + * - Last item is "Manage Models..." + */ +function buildModelPickerItems( + models: ILanguageModelChatMetadataAndIdentifier[], + selectedModelId: string | undefined, + recentModelIds: string[], + curatedModels: ICuratedModel[], + isProUser: boolean, + currentVSCodeVersion: string, + onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, + commandService: ICommandService, + openerService: IOpenerService, + upgradePlanUrl: string | undefined, +): IActionListItem[] { + const items: IActionListItem[] = []; + + // Collect all available models + const allModelsMap = new Map(); + for (const model of models) { + allModelsMap.set(model.identifier, model); + } + + // Build a secondary lookup by metadata.id for flexible matching + const modelsByMetadataId = new Map(); + for (const model of models) { + modelsByMetadataId.set(model.metadata.id, model); + } + + // Track which model IDs have been placed in the promoted group + const placed = new Set(); + + // --- 1. Auto --- + const isAutoSelected = !selectedModelId || !allModelsMap.has(selectedModelId); + const defaultModel = models.find(m => Object.values(m.metadata.isDefaultForLocation).some(v => v)); + const autoDescription = defaultModel?.metadata.multiplier ?? defaultModel?.metadata.detail; + items.push(createModelItem({ + id: 'auto', + enabled: true, + checked: isAutoSelected, + class: undefined, + tooltip: localize('chat.modelPicker.auto', "Auto"), + label: localize('chat.modelPicker.auto', "Auto"), + description: autoDescription, + run: () => { + if (defaultModel) { + onSelect(defaultModel); + } + } + })); + + // --- 2. Promoted models (recently used + curated, merged & sorted alphabetically) --- + const promotedModels: ILanguageModelChatMetadataAndIdentifier[] = []; + const unavailableCurated: { curated: ICuratedModel; reason: 'upgrade' | 'update' | 'admin' }[] = []; + + // Add recently used (skip the default model - it's already represented by "Auto") + for (const id of recentModelIds) { + const model = allModelsMap.get(id); + if (model && !placed.has(model.identifier) && model !== defaultModel) { + promotedModels.push(model); + placed.add(model.identifier); + } + } + + // Add curated - available ones become promoted, unavailable ones become disabled entries + for (const curated of curatedModels) { + const model = allModelsMap.get(curated.id) ?? modelsByMetadataId.get(curated.id); + if (model && !placed.has(model.identifier)) { + promotedModels.push(model); + placed.add(model.identifier); + } else if (!model) { + // Model is not available - determine reason + if (!isProUser) { + unavailableCurated.push({ curated, reason: 'upgrade' }); + } else if (curated.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, curated.minVSCodeVersion)) { + unavailableCurated.push({ curated, reason: 'update' }); + } else { + unavailableCurated.push({ curated, reason: 'admin' }); + } + } + } + + // Sort alphabetically for a stable list + promotedModels.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + + if (promotedModels.length > 0 || unavailableCurated.length > 0) { + items.push({ + kind: ActionListItemKind.Separator, + }); + for (const model of promotedModels) { + const action = createModelAction(model, selectedModelId, onSelect); + items.push(createModelItem(action)); + } + + // Unavailable curated models shown as disabled with action button + for (const { curated, reason } of unavailableCurated) { + const label = reason === 'upgrade' + ? localize('chat.modelPicker.upgrade', "Upgrade") + : reason === 'update' + ? localize('chat.modelPicker.update', "Update VS Code") + : localize('chat.modelPicker.adminEnable', "Contact Admin"); + const onButtonClick = reason === 'upgrade' && upgradePlanUrl + ? () => openerService.open(URI.parse(upgradePlanUrl)) + : reason === 'update' + ? () => commandService.executeCommand('update.checkForUpdate') + : () => { }; + items.push({ + item: { + id: curated.id, + enabled: false, + checked: false, + class: undefined, + tooltip: label, + label: curated.id, + description: label, + run: () => { } + }, + kind: ActionListItemKind.Action, + label: curated.id, + descriptionButton: { label, onDidClick: onButtonClick }, + disabled: true, + group: { title: '', icon: Codicon.blank }, + hideIcon: false, + className: 'unavailable-model', + }); + } + } + + // --- 3. Other Models (collapsible) --- + const otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; + for (const model of models) { + if (!placed.has(model.identifier)) { + // Skip the default model - it's already represented by the top-level "Auto" entry + const isDefault = Object.values(model.metadata.isDefaultForLocation).some(v => v); + if (isDefault) { + continue; + } + otherModels.push(model); + } + } + + if (otherModels.length > 0) { + items.push({ + kind: ActionListItemKind.Separator, + }); + items.push({ + item: { + id: 'otherModels', + enabled: true, + checked: false, + class: undefined, + tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + label: localize('chat.modelPicker.otherModels', "Other Models"), + run: () => { /* toggle handled by isSectionToggle */ } + }, + kind: ActionListItemKind.Action, + label: localize('chat.modelPicker.otherModels', "Other Models"), + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: ModelPickerSection.Other, + isSectionToggle: true, + }); + for (const model of otherModels) { + const action = createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other); + items.push(createModelItem(action)); + } + + // "Manage Models..." entry inside Other Models section, styled as a link + items.push({ + item: { + id: 'manageModels', + enabled: true, + checked: false, + class: 'manage-models-action', + tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), + label: localize('chat.manageModels', "Manage Models..."), + icon: Codicon.settingsGear, + run: () => { + commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); + } + }, + kind: ActionListItemKind.Action, + label: localize('chat.manageModels', "Manage Models..."), + group: { title: '', icon: Codicon.settingsGear }, + hideIcon: false, + section: ModelPickerSection.Other, + className: 'manage-models-link', + }); + } + + return items; +} + +/** + * Returns the ActionList options for the model picker (filter + collapsed sections). + */ +function getModelPickerListOptions(): IActionListOptions { + return { + showFilter: true, + collapsedByDefault: new Set([ModelPickerSection.Other]), + minWidth: 300, + }; +} + +export type ModelPickerBadge = 'info' | 'warning'; + +/** + * A model selection dropdown widget. + * + * Renders a button showing the currently selected model name. + * On click, opens a grouped picker popup with: + * Auto → Promoted (recently used + curated) → Other Models (collapsed with search). + * + * The widget owns its state - set models, selection, and curated IDs via setters. + * Listen for selection changes via `onDidChangeSelection`. + */ +export class ModelPickerWidget extends Disposable { + + private readonly _onDidChangeSelection = this._register(new Emitter()); + readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; + + private _models: ILanguageModelChatMetadataAndIdentifier[] = []; + private _selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; + private _badge: ModelPickerBadge | undefined; + + private _domNode: HTMLElement | undefined; + private _badgeIcon: HTMLElement | undefined; + + get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { + return this._selectedModel; + } + + get domNode(): HTMLElement | undefined { + return this._domNode; + } + + constructor( + @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, + @ICommandService private readonly _commandService: ICommandService, + @IOpenerService private readonly _openerService: IOpenerService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IProductService private readonly _productService: IProductService, + @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, + ) { + super(); + } + + setModels(models: ILanguageModelChatMetadataAndIdentifier[]): void { + this._models = models; + this._renderLabel(); + } + + setSelectedModel(model: ILanguageModelChatMetadataAndIdentifier | undefined): void { + this._selectedModel = model; + this._renderLabel(); + } + + setBadge(badge: ModelPickerBadge | undefined): void { + this._badge = badge; + this._updateBadge(); + } + + render(container: HTMLElement): void { + this._domNode = dom.append(container, dom.$('a.action-label')); + this._domNode.tabIndex = 0; + this._domNode.setAttribute('role', 'button'); + this._domNode.setAttribute('aria-haspopup', 'true'); + this._domNode.setAttribute('aria-expanded', 'false'); + + this._badgeIcon = dom.append(this._domNode, dom.$('span.model-picker-badge')); + this._updateBadge(); + + this._renderLabel(); + + // Open picker on click + this._register(dom.addDisposableListener(this._domNode, dom.EventType.MOUSE_DOWN, (e) => { + if (e.button !== 0) { + return; // only left click + } + dom.EventHelper.stop(e, true); + this.show(); + })); + + // Open picker on Enter/Space + this._register(dom.addDisposableListener(this._domNode, dom.EventType.KEY_DOWN, (e) => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + dom.EventHelper.stop(e, true); + this.show(); + } + })); + } + + show(anchor?: HTMLElement): void { + const anchorElement = anchor ?? this._domNode; + if (!anchorElement) { + return; + } + + // Mark new models as seen immediately when the picker is opened + this._languageModelsService.markNewModelsAsSeen(); + + const previousModel = this._selectedModel; + + const onSelect = (model: ILanguageModelChatMetadataAndIdentifier) => { + this._telemetryService.publicLog2('chat.modelChange', { + fromModel: previousModel?.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(previousModel.identifier) : 'unknown', + toModel: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(model.identifier) : 'unknown' + }); + this._selectedModel = model; + this._renderLabel(); + this._onDidChangeSelection.fire(model); + }; + + const isPro = isProUser(this._entitlementService.entitlement); + const curatedModels = this._languageModelsService.getCuratedModels(); + const curatedForTier = isPro ? curatedModels.paid : curatedModels.free; + + const items = buildModelPickerItems( + this._models, + this._selectedModel?.identifier, + this._languageModelsService.getRecentlyUsedModelIds(7), + curatedForTier, + isPro, + this._productService.version, + onSelect, + this._commandService, + this._openerService, + this._productService.defaultChatAgent?.upgradePlanUrl, + ); + + const listOptions = getModelPickerListOptions(); + const previouslyFocusedElement = dom.getActiveElement(); + + const delegate = { + onSelect: (action: IActionWidgetDropdownAction) => { + this._actionWidgetService.hide(); + action.run(); + }, + onHide: () => { + this._domNode?.setAttribute('aria-expanded', 'false'); + if (dom.isHTMLElement(previouslyFocusedElement)) { + previouslyFocusedElement.focus(); + } + } + }; + + this._domNode?.setAttribute('aria-expanded', 'true'); + + this._actionWidgetService.show( + 'ChatModelPicker', + false, + items, + delegate, + anchorElement, + undefined, + [], + { + isChecked(element) { + return element.kind === 'action' && !!element?.item?.checked; + }, + getRole: (e) => { + switch (e.kind) { + case 'action': return 'menuitemcheckbox'; + case 'separator': return 'separator'; + default: return 'separator'; + } + }, + getWidgetRole: () => 'menu', + }, + listOptions + ); + } + + private _updateBadge(): void { + if (this._badgeIcon) { + if (this._badge) { + const icon = this._badge === 'info' ? Codicon.info : Codicon.warning; + dom.reset(this._badgeIcon, renderIcon(icon)); + this._badgeIcon.style.display = ''; + this._badgeIcon.classList.toggle('info', this._badge === 'info'); + this._badgeIcon.classList.toggle('warning', this._badge === 'warning'); + } else { + this._badgeIcon.style.display = 'none'; + } + } + } + + private _renderLabel(): void { + if (!this._domNode) { + return; + } + + const { name, statusIcon } = this._selectedModel?.metadata || {}; + const domChildren: (HTMLElement | string)[] = []; + + if (statusIcon) { + const iconElement = renderIcon(statusIcon); + domChildren.push(iconElement); + } + + domChildren.push(dom.$('span.chat-input-picker-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); + + // Badge icon between label and chevron + if (this._badgeIcon) { + domChildren.push(this._badgeIcon); + } + + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + + dom.reset(this._domNode, ...domChildren); + + // Aria + const modelName = this._selectedModel?.metadata.name ?? localize('chat.modelPicker.auto', "Auto"); + this._domNode.ariaLabel = localize('chat.modelPicker.ariaLabel', "Pick Model, {0}", modelName); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts index 03954d815de..87fa84c517c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts @@ -51,10 +51,7 @@ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget const entitlement = this.chatEntitlementService.entitlement; const isAnonymous = this.chatEntitlementService.anonymous; - // Free tier is always enabled, anonymous is controlled by experiment via chat.statusWidget.sku - const enabledSku = this.configurationService.getValue('chat.statusWidget.sku'); - - if (isAnonymous && enabledSku === 'anonymous') { + if (isAnonymous && this.configurationService.getValue('chat.statusWidget.anonymous')) { this.createWidgetContent('anonymous'); } else if (entitlement === ChatEntitlement.Free) { this.createWidgetContent('free'); @@ -82,7 +79,7 @@ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget this.actionButton.element.classList.add('chat-status-button'); if (enabledSku === 'anonymous') { - const message = localize('chat.anonymousRateLimited.message', "You've reached the limit for chat messages. Try Copilot Pro for free."); + const message = localize('chat.anonymousRateLimited.message', "You've reached the limit for chat messages. Sign in to use Copilot Free."); const buttonLabel = localize('chat.anonymousRateLimited.signIn', "Sign In"); this.messageElement.textContent = message; this.actionButton.label = buttonLabel; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts new file mode 100644 index 00000000000..c19279bee0a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getActiveWindow } from '../../../../../../base/browser/dom.js'; +import { IManagedHoverContent } from '../../../../../../base/browser/ui/hover/hover.js'; +import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js'; +import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { BaseActionViewItem } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../../../base/common/actions.js'; +import { MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { localize } from '../../../../../../nls.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; +import { ModelPickerWidget } from './chatModelPicker.js'; +import { IModelPickerDelegate } from './modelPickerActionItem.js'; + +/** + * Enhanced action view item for selecting a language model in the chat interface. + * + * Wraps a {@link ModelPickerWidget} and adapts it for use in an action bar, + * providing curated model suggestions, upgrade prompts, and grouped layout. + */ +export class EnhancedModelPickerActionItem extends BaseActionViewItem { + private readonly _pickerWidget: ModelPickerWidget; + private readonly _managedHover = this._register(new MutableDisposable()); + + constructor( + action: IAction, + delegate: IModelPickerDelegate, + private readonly pickerOptions: IChatInputPickerOptions, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + ) { + super(undefined, action); + + this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget)); + this._pickerWidget.setModels(delegate.getModels()); + this._pickerWidget.setSelectedModel(delegate.currentModel.get()); + this._updateBadge(); + + // Sync delegate → widget when model list or selection changes externally + this._register(autorun(t => { + const model = delegate.currentModel.read(t); + this._pickerWidget.setSelectedModel(model); + this._updateTooltip(); + })); + + // Sync widget → delegate when user picks a model + this._register(this._pickerWidget.onDidChangeSelection(model => { + delegate.setModel(model); + })); + + // Update models when language models change + this._register(this.languageModelsService.onDidChangeLanguageModels(() => { + this._pickerWidget.setModels(delegate.getModels()); + })); + + // Update badge when new models appear + this._register(this.languageModelsService.onDidChangeNewModelIds(() => this._updateBadge())); + } + + override render(container: HTMLElement): void { + this._pickerWidget.render(container); + this.element = this._pickerWidget.domNode; + this._updateTooltip(); + container.classList.add('chat-input-picker-item'); + } + + private _getAnchorElement(): HTMLElement { + if (this.element && getActiveWindow().document.contains(this.element)) { + return this.element; + } + return this.pickerOptions.getOverflowAnchor?.() ?? this.element!; + } + + public openModelPicker(): void { + this._showPicker(); + } + + public show(): void { + this._showPicker(); + } + + private _showPicker(): void { + this._pickerWidget.show(this._getAnchorElement()); + } + + private _updateBadge(): void { + const hasNew = this.languageModelsService.getNewModelIds().length > 0; + this._pickerWidget.setBadge(hasNew ? 'info' : undefined); + } + + private _updateTooltip(): void { + if (!this.element) { + return; + } + const hoverContent = this._getHoverContents(); + if (typeof hoverContent === 'string' && hoverContent) { + this._managedHover.value = getBaseLayerHoverDelegate().setupManagedHover( + getDefaultHoverDelegate('mouse'), + this.element, + hoverContent + ); + } else { + this._managedHover.clear(); + } + } + + private _getHoverContents(): IManagedHoverContent | undefined { + let label = localize('chat.modelPicker.label', "Pick Model"); + const keybindingLabel = this.keybindingService.lookupKeybinding(this._action.id, this._contextKeyService)?.getLabel(); + if (keybindingLabel) { + label += ` (${keybindingLabel})`; + } + const { statusIcon, tooltip } = this._pickerWidget.selectedModel?.metadata || {}; + return statusIcon && tooltip ? `${label} • ${tooltip}` : label; + } +} 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 4580ea562d7..29d2a72833f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -124,6 +124,7 @@ 0% { background-position: 120% 0; } + 100% { background-position: -120% 0; } @@ -457,14 +458,12 @@ } /* not ideal but I cannot query the last div with this class... */ - .rendered-markdown:last-of-type > P > SPAN:empty, - .rendered-markdown:last-of-type > SPAN:empty { + .rendered-markdown:last-of-type > P > SPAN:empty { display: inline-block; width: 11px; } - .rendered-markdown:last-of-type > P > SPAN:empty::after, - .rendered-markdown:last-of-type > SPAN:empty::after { + .rendered-markdown:last-of-type > P > SPAN:empty::after { content: ''; white-space: nowrap; overflow: hidden; @@ -1093,8 +1092,7 @@ have to be updated for changes to the rules above, or to support more deeply nes border: 1px solid var(--vscode-input-border, transparent); background-color: var(--vscode-editor-background); border-bottom: none; - border-top-left-radius: 4px; - border-top-right-radius: 4px; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; flex-direction: column; gap: 2px; overflow: hidden; @@ -1336,14 +1334,27 @@ have to be updated for changes to the rules above, or to support more deeply nes .action-label { min-width: 0px; overflow: hidden; + position: relative; .chat-input-picker-label { overflow: hidden; text-overflow: ellipsis; } - .codicon-warning { - color: var(--vscode-problemsWarningIcon-foreground); + .model-picker-badge { + display: inline-flex; + align-items: center; + margin-left: 4px; + flex-shrink: 0; + font-size: 12px; + + .codicon.codicon-info { + color: var(--vscode-problemsInfoIcon-foreground) !important; + } + + .codicon.codicon-warning { + color: var(--vscode-problemsWarningIcon-foreground) !important; + } } span + .chat-input-picker-label { @@ -1367,6 +1378,20 @@ have to be updated for changes to the rules above, or to support more deeply nes } } +/* Manage Models link style in model picker */ +.action-widget .monaco-list-row.action.manage-models-link { + color: var(--vscode-textLink-foreground) !important; +} + +.action-widget .monaco-list-row.action.manage-models-link .codicon { + color: var(--vscode-textLink-foreground) !important; +} + +.action-widget .monaco-list-row.action.manage-models-link:hover, +.action-widget .monaco-list-row.action.manage-models-link.focused { + color: var(--vscode-textLink-activeForeground) !important; +} + .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label, @@ -1375,12 +1400,7 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 3px 0px 3px 6px; display: flex; align-items: center; - color: var(--vscode-descriptionForeground); -} - -.monaco-workbench .interactive-session .chat-input-toolbars .monaco-action-bar .action-item .codicon.codicon, -.monaco-workbench .interactive-session .chat-input-toolbars .action-label .codicon.codicon { - color: var(--vscode-descriptionForeground) !important; + color: var(--vscode-foreground); } .monaco-workbench .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label .codicon-chevron-down, diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index df7195252c1..a824546fe77 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -66,7 +66,9 @@ import { IChatEntitlementService } from '../../../../../services/chat/common/cha import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; interface IChatViewPaneState extends Partial { - /** @deprecated */ + /** + * @deprecated This is kept around to support old view states. However it should not be set on new states and `sessionResource` should be used instead. + */ sessionId?: string; sessionResource?: URI; @@ -695,7 +697,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { await this.updateWidgetLockState(model.sessionResource); // Update widget lock state based on session type // remember as model to restore in view state - this.viewState.sessionId = model.sessionId; this.viewState.sessionResource = model.sessionResource; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts index 0cbc75d1883..b60a4f3b64f 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts @@ -78,7 +78,7 @@ export class ChatViewTitleControl extends Disposable { async run(accessor: ServicesAccessor): Promise { const instantiationService = accessor.get(IInstantiationService); - const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, that.titleLabel.value?.element); + const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, that.titleLabel.value?.element, undefined); await agentSessionsPicker.pickAgentSession(); } })); diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 529f40e06fc..b97209bce15 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -45,6 +45,7 @@ export namespace ChatContextKeys { export const inChatTerminalToolOutput = new RawContextKey('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") }); export const chatModeKind = new RawContextKey('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") }); export const chatModeName = new RawContextKey('chatModeName', '', { type: 'string', description: localize('chatModeName', "The name of the current chat mode (e.g. 'Plan' for custom modes).") }); + export const chatModelId = new RawContextKey('chatModelId', '', { type: 'string', description: localize('chatModelId', "The short id of the currently selected chat model (for example 'gpt-4.1').") }); export const supported = ContextKeyExpr.or(IsWebContext.negate(), RemoteNameContext.notEqualsTo(''), ContextKeyExpr.has('config.chat.experimental.serverlessWebEnabled')); export const enabled = new RawContextKey('chatIsEnabled', false, { type: 'boolean', description: localize('chatIsEnabled', "True when chat is enabled because a default chat participant is activated with an implementation.") }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index e315b22d9a1..f295461bad9 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -28,6 +28,7 @@ import { Progress } from '../../../../../platform/progress/common/progress.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { InlineChatConfigKeys } from '../../../inlineChat/common/inlineChat.js'; import { IMcpService } from '../../../mcp/common/mcpTypes.js'; import { awaitStatsForSession } from '../chat.js'; @@ -155,6 +156,7 @@ export class ChatService extends Disposable implements IChatService { @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(); @@ -787,21 +789,16 @@ export class ChatService extends Disposable implements IChatService { } const hasPendingRequest = this._pendingRequests.has(sessionResource); - const hasPendingQueue = model.getPendingRequests().length > 0; if (options?.queue) { - return this.queuePendingRequest(model, sessionResource, request, options); + const queued = this.queuePendingRequest(model, sessionResource, request, options); + this.processPendingRequests(sessionResource); + return queued; } else if (hasPendingRequest) { this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); return { kind: 'rejected', reason: 'Request already in progress' }; } - if (options?.queue && hasPendingQueue) { - const queued = this.queuePendingRequest(model, sessionResource, request, options); - this.processNextPendingRequest(model); - return queued; - } - const requests = model.getRequests(); for (let i = requests.length - 1; i >= 0; i -= 1) { const request = requests[i]; @@ -844,6 +841,13 @@ export class ChatService extends Disposable implements IChatService { parserContext = { selectedAgent: agent, mode: options.modeInfo?.kind }; const commandPart = options.slashCommand ? ` ${chatSubcommandLeader}${options.slashCommand}` : ''; request = `${chatAgentLeader}${agent.name}${commandPart} ${request}`; + } else if (options?.agentIdSilent && !parserContext?.forcedAgent) { + // Resolve slash commandsin the context of locked participant so its subcommands take precedence over global + // slash commands with the same name. + const silentAgent = this.chatAgentService.getAgent(options.agentIdSilent); + if (silentAgent) { + parserContext = { ...parserContext, forcedAgent: silentAgent }; + } } const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionResource, request, location, parserContext); @@ -869,7 +873,7 @@ export class ChatService extends Disposable implements IChatService { agent: agentPart?.agent ?? defaultAgent, agentSlashCommandPart, commandPart, - sessionId: model.sessionId, + sessionResource: model.sessionResource, location: model.initialLocation, options, enableCommandDetection @@ -1138,6 +1142,10 @@ export class ChatService extends Disposable implements IChatService { completeResponseCreated(); this.trace('sendRequest', `Provider returned response for session ${model.sessionResource}`); + if (rawResult.errorDetails?.isRateLimited) { + this.chatEntitlementService.markAnonymousRateLimited(); + } + shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; request.response?.complete(); if (agentOrCommandFollowups) { @@ -1171,9 +1179,12 @@ export class ChatService extends Disposable implements IChatService { let shouldProcessPending = false; const rawResponsePromise = sendRequestInternal(); // Note- requestId is not known at this point, assigned later - this._pendingRequests.set(model.sessionResource, this.instantiationService.createInstance(CancellableRequest, source, undefined)); + const cancellableRequest = this.instantiationService.createInstance(CancellableRequest, source, undefined); + this._pendingRequests.set(model.sessionResource, cancellableRequest); rawResponsePromise.finally(() => { - this._pendingRequests.deleteAndDispose(model.sessionResource); + if (this._pendingRequests.get(model.sessionResource) === cancellableRequest) { + this._pendingRequests.deleteAndDispose(model.sessionResource); + } // Process the next pending request from the queue if any if (shouldProcessPending) { this.processNextPendingRequest(model); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 6cd2769fbec..2a0f2cf7cc8 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -13,6 +13,7 @@ import { ChatAgentVoteDirection, ChatCopyKind, IChatSendRequestOptions, IChatUse import { isImageVariableEntry } from '../attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; +import { chatSessionResourceToId } from '../model/chatUri.js'; type ChatVoteEvent = { direction: 'up' | 'down'; @@ -263,7 +264,7 @@ export class ChatRequestTelemetry { agent: IChatAgentData; agentSlashCommandPart: ChatRequestAgentSubcommandPart | undefined; commandPart: ChatRequestSlashCommandPart | undefined; - sessionId: string; + sessionResource: URI; location: ChatAgentLocation; options: IChatSendRequestOptions | undefined; enableCommandDetection: boolean; @@ -294,7 +295,7 @@ export class ChatRequestTelemetry { agent: detectedAgent?.id ?? this.opts.agent.id, agentExtensionId: detectedAgent?.extensionId.value ?? this.opts.agent.extensionId.value, slashCommand: this.opts.agentSlashCommandPart ? this.opts.agentSlashCommandPart.command.name : this.opts.commandPart?.slashCommand.command, - chatSessionId: this.opts.sessionId, + chatSessionId: chatSessionResourceToId(this.opts.sessionResource), enableCommandDetection: this.opts.enableCommandDetection, isParticipantDetected: !!detectedAgent, location: this.opts.location, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 989e005c0b5..8ccd21ac647 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -262,7 +262,7 @@ export interface IChatSessionsService { /** * Fired when options for a chat session change. */ - onDidChangeSessionOptions: Event; + readonly onDidChangeSessionOptions: Event; /** * Get the capabilities for a specific session type @@ -279,7 +279,7 @@ export interface IChatSessionsService { * Returns whether the session type requires custom models. When true, the model picker should show filtered custom models. */ requiresCustomModelsForSessionType(chatSessionType: string): boolean; - onDidChangeOptionGroups: Event; + readonly onDidChangeOptionGroups: Event; getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index ff6e4cfffb8..8066635dc74 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -13,7 +13,6 @@ export enum ChatConfiguration { AgentEnabled = 'chat.agent.enabled', PlanAgentDefaultModel = 'chat.planAgent.defaultModel', ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', - RequestQueueingEnabled = 'chat.requestQueuing.enabled', RequestQueueingDefaultAction = 'chat.requestQueuing.defaultAction', AgentStatusEnabled = 'chat.agentsControl.enabled', EditorAssociations = 'chat.editorAssociations', @@ -93,7 +92,6 @@ export enum CollapsedToolsDisplayMode { export enum AgentsControlClickBehavior { Default = 'default', Cycle = 'cycle', - Focus = 'focus', } export type RawChatParticipantLocation = 'panel' | 'terminal' | 'notebook' | 'editing-session'; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index f77edfbc6b4..e452196a385 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { SequencerByKey } from '../../../../base/common/async.js'; +import { SequencerByKey, timeout } from '../../../../base/common/async.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IStringDictionary } from '../../../../base/common/collections.js'; @@ -13,11 +13,12 @@ import { hash } from '../../../../base/common/hash.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { IJSONSchema, TypeFromJsonSchema } from '../../../../base/common/jsonSchema.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { equals } from '../../../../base/common/objects.js'; import Severity from '../../../../base/common/severity.js'; import { format, isFalsyOrWhitespace } from '../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { isString } from '../../../../base/common/types.js'; +import { isObject, isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; @@ -25,6 +26,8 @@ import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../pla import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; import { IQuickInputService, QuickInputHideReason } from '../../../../platform/quickinput/common/quickInput.js'; import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -363,6 +366,55 @@ export interface ILanguageModelsService { configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise; migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise; + + /** + * Returns the most recently used model identifiers, ordered by most-recent-first. + * @param maxCount Maximum number of entries to return (default 7). + */ + getRecentlyUsedModelIds(maxCount?: number): string[]; + + /** + * Records that a model was used, updating the recently used list. + */ + recordModelUsage(modelIdentifier: string): void; + + /** + * Returns the curated models from the models control manifest, + * separated into free and paid tiers. + */ + getCuratedModels(): ICuratedModels; + + /** + * Returns the IDs of curated models that are marked as new and have not been seen yet. + */ + getNewModelIds(): string[]; + + /** + * Fires when the set of new (unseen) model IDs changes. + */ + readonly onDidChangeNewModelIds: Event; + + /** + * Marks all new models as seen, clearing the new badge. + */ + markNewModelsAsSeen(): void; + + /** + * Observable map of restricted chat participant names to allowed extension publisher/IDs. + * Fetched from the chat control manifest. + */ + readonly restrictedChatParticipants: IObservable<{ [name: string]: string[] }>; +} + +export interface ICuratedModel { + readonly id: string; + readonly isNew?: boolean; + readonly minVSCodeVersion?: string; +} + +export interface ICuratedModels { + readonly free: ICuratedModel[]; + readonly paid: ICuratedModel[]; } const languageModelChatProviderType = { @@ -451,6 +503,25 @@ export const languageModelChatProviderExtensionPoint = ExtensionsRegistry.regist }); const CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY = 'chatModelPickerPreferences'; +const CHAT_MODEL_RECENTLY_USED_STORAGE_KEY = 'chatModelRecentlyUsed'; +const CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY = 'chatModelSeenNewModels'; +const CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY = 'chat.participantNameRegistry'; +const CHAT_CURATED_MODELS_STORAGE_KEY = 'chat.curatedModels'; + +interface IRawCuratedModel { + readonly id: string; + readonly isNew?: boolean; + readonly minVSCodeVersion?: string; +} + +interface IChatControlResponse { + readonly version: number; + readonly restrictedChatParticipants: { [name: string]: string[] }; + readonly curatedModels?: { + readonly free?: IRawCuratedModel[]; + readonly paid?: IRawCuratedModel[]; + }; +} export class LanguageModelsService implements ILanguageModelsService { @@ -476,6 +547,20 @@ export class LanguageModelsService implements ILanguageModelsService { private readonly _onLanguageModelChange = this._store.add(new Emitter()); readonly onDidChangeLanguageModels: Event = this._onLanguageModelChange.event; + private _recentlyUsedModelIds: string[] = []; + private _curatedModels: ICuratedModels = { free: [], paid: [] }; + private _newModelIds: Set = new Set(); + private _seenNewModelIds: Set = new Set(); + + private _chatControlUrl: string | undefined; + private _chatControlDisposed = false; + + private readonly _restrictedChatParticipants = observableValue<{ [name: string]: string[] }>(this, Object.create(null)); + readonly restrictedChatParticipants: IObservable<{ [name: string]: string[] }> = this._restrictedChatParticipants; + + private readonly _onDidChangeNewModelIds = this._store.add(new Emitter()); + readonly onDidChangeNewModelIds: Event = this._onDidChangeNewModelIds.event; + constructor( @IExtensionService private readonly _extensionService: IExtensionService, @ILogService private readonly _logService: ILogService, @@ -484,9 +569,14 @@ export class LanguageModelsService implements ILanguageModelsService { @ILanguageModelsConfigurationService private readonly _languageModelsConfigurationService: ILanguageModelsConfigurationService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @ISecretStorageService private readonly _secretStorageService: ISecretStorageService, + @IProductService private readonly _productService: IProductService, + @IRequestService private readonly _requestService: IRequestService, ) { this._hasUserSelectableModels = ChatContextKeys.languageModelsAreUserSelectable.bindTo(_contextKeyService); this._modelPickerUserPreferences = this._readModelPickerPreferences(); + this._recentlyUsedModelIds = this._readRecentlyUsedModels(); + this._seenNewModelIds = this._readSeenNewModels(); + this._initChatControlData(); this._store.add(this._storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY, this._store)(() => this._onDidChangeModelPickerPreferences())); this._store.add(this.onDidChangeLanguageModels(() => this._hasUserSelectableModels.set(this._modelCache.size > 0 && Array.from(this._modelCache.values()).some(model => model.isUserSelectable)))); @@ -1293,7 +1383,174 @@ export class LanguageModelsService implements ILanguageModelsService { await this.addLanguageModelsProviderGroup(name, vendor, configuration); } + //#region Recently used models + + private _readRecentlyUsedModels(): string[] { + return this._storageService.getObject(CHAT_MODEL_RECENTLY_USED_STORAGE_KEY, StorageScope.PROFILE, []); + } + + private _saveRecentlyUsedModels(): void { + this._storageService.store(CHAT_MODEL_RECENTLY_USED_STORAGE_KEY, this._recentlyUsedModelIds, StorageScope.PROFILE, StorageTarget.USER); + } + + getRecentlyUsedModelIds(maxCount: number = 7): string[] { + // Filter to only include models that still exist in the cache + return this._recentlyUsedModelIds + .filter(id => this._modelCache.has(id)) + .slice(0, maxCount); + } + + recordModelUsage(modelIdentifier: string): void { + // Remove if already present (to move to front) + const index = this._recentlyUsedModelIds.indexOf(modelIdentifier); + if (index !== -1) { + this._recentlyUsedModelIds.splice(index, 1); + } + // Add to front + this._recentlyUsedModelIds.unshift(modelIdentifier); + // Cap at a reasonable max to avoid unbounded growth + if (this._recentlyUsedModelIds.length > 20) { + this._recentlyUsedModelIds.length = 20; + } + this._saveRecentlyUsedModels(); + } + + //#endregion + + //#region Curated models + + getCuratedModels(): ICuratedModels { + return this._curatedModels; + } + + private _setCuratedModels(free: IRawCuratedModel[], paid: IRawCuratedModel[]): void { + const toPublic = (m: IRawCuratedModel): ICuratedModel => ({ id: m.id, isNew: m.isNew, minVSCodeVersion: m.minVSCodeVersion }); + + this._curatedModels = { free: [], paid: [] }; + const newIds = new Set(); + + for (const model of free) { + this._curatedModels.free.push(toPublic(model)); + if (model.isNew) { + newIds.add(model.id); + } + } + + for (const model of paid) { + this._curatedModels.paid.push(toPublic(model)); + if (model.isNew) { + newIds.add(model.id); + } + } + + this._newModelIds = newIds; + this._onDidChangeNewModelIds.fire(); + } + + getNewModelIds(): string[] { + const result: string[] = []; + for (const id of this._newModelIds) { + if (!this._seenNewModelIds.has(id)) { + result.push(id); + } + } + return result; + } + + markNewModelsAsSeen(): void { + let changed = false; + for (const id of this._newModelIds) { + if (!this._seenNewModelIds.has(id)) { + this._seenNewModelIds.add(id); + changed = true; + } + } + if (changed) { + this._saveSeenNewModels(); + this._onDidChangeNewModelIds.fire(); + } + } + + private _readSeenNewModels(): Set { + return new Set(this._storageService.getObject(CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY, StorageScope.PROFILE, [])); + } + + private _saveSeenNewModels(): void { + this._storageService.store(CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY, [...this._seenNewModelIds], StorageScope.PROFILE, StorageTarget.USER); + } + + //#endregion + + //#region Chat control data + + private _initChatControlData(): void { + this._chatControlUrl = this._productService.chatParticipantRegistry; + if (!this._chatControlUrl) { + return; + } + + // Restore participant registry from storage + const raw = this._storageService.get(CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY, StorageScope.APPLICATION); + try { + this._restrictedChatParticipants.set(JSON.parse(raw ?? '{}'), undefined); + } catch (err) { + this._storageService.remove(CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY, StorageScope.APPLICATION); + } + + // Restore curated models from storage + const rawCurated = this._storageService.get(CHAT_CURATED_MODELS_STORAGE_KEY, StorageScope.APPLICATION); + try { + const curated = JSON.parse(rawCurated ?? '{}'); + if (isObject(curated) && Array.isArray(curated.free) && Array.isArray(curated.paid)) { + this._setCuratedModels(curated.free, curated.paid); + } + } catch (err) { + this._storageService.remove(CHAT_CURATED_MODELS_STORAGE_KEY, StorageScope.APPLICATION); + } + + this._refreshChatControlData(); + } + + private _refreshChatControlData(): void { + if (this._chatControlDisposed) { + return; + } + + this._fetchChatControlData() + .catch(err => this._logService.warn('Failed to fetch chat control data', err)) + .then(() => timeout(5 * 60 * 1000)) // every 5 minutes + .then(() => this._refreshChatControlData()); + } + + private async _fetchChatControlData(): Promise { + const context = await this._requestService.request({ type: 'GET', url: this._chatControlUrl! }, CancellationToken.None); + + if (context.res.statusCode !== 200) { + throw new Error('Could not get chat control data.'); + } + + const result = await asJson(context); + + if (!result || result.version !== 1) { + throw new Error('Unexpected chat control response.'); + } + + // Update restricted chat participants + const registry = result.restrictedChatParticipants; + this._restrictedChatParticipants.set(registry, undefined); + this._storageService.store(CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY, JSON.stringify(registry), StorageScope.APPLICATION, StorageTarget.MACHINE); + + // Update curated models + if (result.curatedModels) { + this._setCuratedModels(result.curatedModels?.free ?? [], result.curatedModels?.paid ?? []); + this._storageService.store(CHAT_CURATED_MODELS_STORAGE_KEY, JSON.stringify(result.curatedModels), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + } + + //#endregion + dispose() { + this._chatControlDisposed = true; this._store.dispose(); this._providers.clear(); } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index e804c918744..c5d858de46c 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -2652,7 +2652,6 @@ export class ChatModel extends Disposable implements IChatModel { creationDate: this._timestamp, customTitle: this._customTitle, inputState: this.inputModel.toJSON(), - repoData: this._repoData, }; } diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 55f5e4c319c..c3c760b5d6a 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -4,14 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { findLast } from '../../../../../base/common/arraysFind.js'; -import { timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { revive, Revived } from '../../../../../base/common/marshalling.js'; -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { IObservable } from '../../../../../base/common/observable.js'; import { equalsIgnoreCase } from '../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -20,16 +19,13 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ChatContextKeys } from '../actions/chatContextKeys.js'; import { IChatAgentEditedFileEvent, IChatProgressHistoryResponseContent, IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatAgentData } from '../model/chatModel.js'; import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; +import { ILanguageModelsService } from '../languageModels.js'; //#region agent service, commands etc @@ -661,13 +657,6 @@ export class MergedChatAgent implements IChatAgent { export const IChatAgentNameService = createDecorator('chatAgentNameService'); -type IChatParticipantRegistry = { [name: string]: string[] }; - -interface IChatParticipantRegistryResponse { - readonly version: number; - readonly restrictedChatParticipants: IChatParticipantRegistry; -} - export interface IChatAgentNameService { _serviceBrand: undefined; getAgentNameRestriction(chatAgentData: IChatAgentData): boolean; @@ -675,64 +664,11 @@ export interface IChatAgentNameService { export class ChatAgentNameService implements IChatAgentNameService { - private static readonly StorageKey = 'chat.participantNameRegistry'; - declare _serviceBrand: undefined; - private readonly url!: string; - private registry = observableValue(this, Object.create(null)); - private disposed = false; - constructor( - @IProductService productService: IProductService, - @IRequestService private readonly requestService: IRequestService, - @ILogService private readonly logService: ILogService, - @IStorageService private readonly storageService: IStorageService + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, ) { - if (!productService.chatParticipantRegistry) { - return; - } - - this.url = productService.chatParticipantRegistry; - - const raw = storageService.get(ChatAgentNameService.StorageKey, StorageScope.APPLICATION); - - try { - this.registry.set(JSON.parse(raw ?? '{}'), undefined); - } catch (err) { - storageService.remove(ChatAgentNameService.StorageKey, StorageScope.APPLICATION); - } - - this.refresh(); - } - - private refresh(): void { - if (this.disposed) { - return; - } - - this.update() - .catch(err => this.logService.warn('Failed to fetch chat participant registry', err)) - .then(() => timeout(5 * 60 * 1000)) // every 5 minutes - .then(() => this.refresh()); - } - - private async update(): Promise { - const context = await this.requestService.request({ type: 'GET', url: this.url }, CancellationToken.None); - - if (context.res.statusCode !== 200) { - throw new Error('Could not get extensions report.'); - } - - const result = await asJson(context); - - if (!result || result.version !== 1) { - throw new Error('Unexpected chat participant registry response.'); - } - - const registry = result.restrictedChatParticipants; - this.registry.set(registry, undefined); - this.storageService.store(ChatAgentNameService.StorageKey, JSON.stringify(registry), StorageScope.APPLICATION, StorageTarget.MACHINE); } /** @@ -752,7 +688,7 @@ export class ChatAgentNameService implements IChatAgentNameService { private checkAgentNameRestriction(name: string, chatAgentData: IChatAgentData): IObservable { // Registry is a map of name to an array of extension publisher IDs or extension IDs that are allowed to use it. // Look up the list of extensions that are allowed to use this name - const allowList = this.registry.map(registry => registry[name.toLowerCase()]); + const allowList = this.languageModelsService.restrictedChatParticipants.map(registry => registry[name.toLowerCase()]); return allowList.map(allowList => { if (!allowList) { return true; @@ -761,10 +697,6 @@ export class ChatAgentNameService implements IChatAgentNameService { return allowList.some(id => equalsIgnoreCase(id, id.includes('.') ? chatAgentData.extensionId.value : chatAgentData.extensionPublisherId)); }); } - - dispose() { - this.disposed = true; - } } export function getFullyQualifiedId(chatAgentData: IChatAgentData): string { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 50c007fc116..3c3e2597382 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { match, splitGlobAware } from '../../../../../base/common/glob.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { OperatingSystem } from '../../../../../base/common/platform.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; @@ -14,6 +15,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { IFileService } from '../../../../../platform/files/common/files.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariableEntry, toPromptFileVariableEntry, toPromptTextVariableEntry, PromptFileVariableKind, IPromptTextVariableEntry, ChatRequestToolReferenceEntry, toToolVariableEntry } from '../attachments/chatVariableEntries.js'; @@ -68,6 +70,7 @@ export class ComputeAutomaticInstructions { @IConfigurationService private readonly _configurationService: IConfigurationService, @IWorkspaceContextService private readonly _workspaceService: IWorkspaceContextService, @IFileService private readonly _fileService: IFileService, + @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, ) { @@ -294,6 +297,10 @@ export class ComputeAutomaticInstructions { const readTool = this._getTool('readFile'); const runSubagentTool = this._getTool(VSCodeToolReference.runSubagent); + const remoteEnv = await this._remoteAgentService.getEnvironment(); + const remoteOS = remoteEnv?.os; + const filePath = (uri: URI) => getFilePath(uri, remoteOS); + const entries: string[] = []; if (readTool) { @@ -316,13 +323,13 @@ export class ComputeAutomaticInstructions { if (description) { entries.push(`${description}`); } - entries.push(`${getFilePath(uri)}`); + entries.push(`${filePath(uri)}`); const applyToPattern = this._getApplyToPattern(applyTo, paths); if (applyToPattern) { entries.push(`${applyToPattern}`); } } else { - entries.push(`${getFilePath(uri)}`); + entries.push(`${filePath(uri)}`); } entries.push(''); hasContent = true; @@ -335,7 +342,7 @@ export class ComputeAutomaticInstructions { const description = folderName.trim().length === 0 ? localize('instruction.file.description.agentsmd.root', 'Instructions for the workspace') : localize('instruction.file.description.agentsmd.folder', 'Instructions for folder \'{0}\'', folderName); entries.push(''); entries.push(`${description}`); - entries.push(`${getFilePath(uri)}`); + entries.push(`${filePath(uri)}`); entries.push(''); hasContent = true; @@ -378,7 +385,7 @@ export class ComputeAutomaticInstructions { if (skill.description) { entries.push(`${skill.description}`); } - entries.push(`${getFilePath(skill.uri)}`); + entries.push(`${filePath(skill.uri)}`); entries.push(''); } entries.push('', '', ''); // add trailing newline @@ -492,9 +499,19 @@ export class ComputeAutomaticInstructions { } -function getFilePath(uri: URI): string { +export function getFilePath(uri: URI, remoteOS: OperatingSystem | undefined): string { if (uri.scheme === Schemas.file || uri.scheme === Schemas.vscodeRemote) { - return uri.fsPath; + const fsPath = uri.fsPath; + // uri.fsPath uses the local OS's path separators, but the path + // may belong to a remote with a different OS. Normalize separators + // to match the remote OS (idempotent when local and remote match). + if (remoteOS !== undefined) { + if (remoteOS === OperatingSystem.Windows) { + return fsPath.replace(/\//g, '\\'); + } + return fsPath.replace(/\\/g, '/'); + } + return fsPath; } return uri.toString(); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts index 076f9f2d1eb..8de8c06dfba 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts @@ -39,7 +39,7 @@ export class PromptHeaderDefinitionProvider implements DefinitionProvider { } const agentAttr = header.getAttribute(PromptHeaderAttributes.agent) ?? header.getAttribute(PromptHeaderAttributes.mode); - if (agentAttr && agentAttr.value.type === 'string' && agentAttr.range.containsPosition(position)) { + if (agentAttr && agentAttr.value.type === 'scalar' && agentAttr.range.containsPosition(position)) { const agent = this.chatModeService.findModeByName(agentAttr.value.value); if (agent && agent.uri) { return { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts index bd4ecc7de0f..704d5cd6208 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts @@ -119,17 +119,17 @@ export class PromptCodeActionProvider implements CodeActionProvider { return; } let value = toolsAttr.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type !== 'array') { + if (value.type !== 'sequence') { return; } const values = value.items; const deprecatedNames = new Lazy(() => this.languageModelToolsService.getDeprecatedFullReferenceNames()); const edits: TextEdit[] = []; for (const item of values) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { continue; } const newNames = deprecatedNames.value.get(item.value); 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 c02f3dfcd53..5e60a57cf89 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -15,7 +15,7 @@ import { IChatModeService } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService, Target } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; -import { ClaudeHeaderAttributes, IArrayValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { ClaudeHeaderAttributes, ISequenceValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; import { getAttributeDescription, getTarget, getValidAttributeNames, claudeAgentAttributes, claudeRulesAttributes, knownClaudeTools, knownGithubCopilotTools, IValueEntry } from './promptValidator.js'; import { localize } from '../../../../../../nls.js'; import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js'; @@ -161,7 +161,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (promptType === PromptsType.prompt || promptType === PromptsType.agent) { if (attribute.key === PromptHeaderAttributes.model) { - if (attribute.value.type === 'array') { + if (attribute.value.type === 'sequence') { // if the position is inside the tools metadata, we provide tool name completions const getValues = async () => { if (target === Target.Claude) { @@ -175,10 +175,10 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { } if (attribute.key === PromptHeaderAttributes.tools || attribute.key === ClaudeHeaderAttributes.disallowedTools) { let value = attribute.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type === 'array') { + if (value.type === 'sequence') { // if the position is inside the tools metadata, we provide tool name completions const getValues = async () => { if (target === Target.GitHubCopilot) { @@ -195,7 +195,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { } } if (attribute.key === PromptHeaderAttributes.agents) { - if (attribute.value.type === 'array') { + if (attribute.value.type === 'sequence') { return this.provideArrayCompletions(model, position, attribute.value, async () => { return await this.promptsService.getCustomAgents(CancellationToken.None); }); @@ -329,12 +329,12 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return result; } - private async provideArrayCompletions(model: ITextModel, position: Position, arrayValue: IArrayValue, getValues: () => Promise>): Promise { + private async provideArrayCompletions(model: ITextModel, position: Position, arrayValue: ISequenceValue, getValues: () => Promise>): Promise { const getSuggestions = async (toolRange: Range, currentItem?: IValue) => { const suggestions: CompletionItem[] = []; const entries = await getValues(); const quotePreference = getQuotePreference(arrayValue, model); - const existingValues = new Set(arrayValue.items.filter(item => item !== currentItem).filter(item => item.type === 'string').map(item => item.value)); + const existingValues = new Set(arrayValue.items.filter(item => item !== currentItem).filter(item => item.type === 'scalar').map(item => item.value)); for (const entry of entries) { const entryName = entry.name; if (existingValues.has(entryName)) { 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 662ce196265..8fe87c45a10 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -99,12 +99,12 @@ export class PromptHoverProvider implements HoverProvider { private getToolHover(node: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined { let value = node.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type === 'array') { + if (value.type === 'sequence') { for (const toolName of value.items) { - if (toolName.type === 'string' && toolName.range.containsPosition(position)) { + if (toolName.type === 'scalar' && toolName.range.containsPosition(position)) { const description = this.getToolHoverByName(toolName.value, toolName.range, target); if (description) { return description; @@ -181,14 +181,14 @@ export class PromptHoverProvider implements HoverProvider { } return undefined; }; - if (node.value.type === 'string') { + if (node.value.type === 'scalar') { const hover = modelHoverContent(node.value.value); if (hover) { return hover; } - } else if (node.value.type === 'array') { + } else if (node.value.type === 'sequence') { for (const item of node.value.items) { - if (item.type === 'string' && item.range.containsPosition(position)) { + if (item.type === 'scalar' && item.range.containsPosition(position)) { const hover = modelHoverContent(item.value); if (hover) { return hover; @@ -202,7 +202,7 @@ export class PromptHoverProvider implements HoverProvider { private getAgentHover(agentAttribute: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined { const lines: string[] = []; const value = agentAttribute.value; - if (value.type === 'string' && value.range.containsPosition(position)) { + if (value.type === 'scalar' && value.range.containsPosition(position)) { const agent = this.chatModeService.findModeByName(value.value); if (agent) { const description = agent.description.get() || (isBuiltinChatMode(agent) ? localize('promptHeader.prompt.agent.builtInDesc', 'Built-in agent') : localize('promptHeader.prompt.agent.customDesc', 'Custom agent')); 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 55559b5b04f..8b9a1b018cf 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -16,7 +16,7 @@ import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { GithubPromptHeaderAttributes, IArrayValue, IHeaderAttribute, IStringValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.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'; @@ -67,7 +67,7 @@ export class PromptValidator { } const nameAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.name); - if (!nameAttribute || nameAttribute.value.type !== 'string') { + if (!nameAttribute || nameAttribute.value.type !== 'scalar') { return; } @@ -253,7 +253,7 @@ export class PromptValidator { if (!nameAttribute) { return; } - if (nameAttribute.value.type !== 'string') { + if (nameAttribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.nameMustBeString', "The 'name' attribute must be a string."), nameAttribute.range, MarkerSeverity.Error)); return; } @@ -268,7 +268,7 @@ export class PromptValidator { if (!descriptionAttribute) { return; } - if (descriptionAttribute.value.type !== 'string') { + if (descriptionAttribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.descriptionMustBeString', "The 'description' attribute must be a string."), descriptionAttribute.range, MarkerSeverity.Error)); return; } @@ -283,7 +283,7 @@ export class PromptValidator { if (!argumentHintAttribute) { return; } - if (argumentHintAttribute.value.type !== 'string') { + if (argumentHintAttribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.argumentHintMustBeString', "The 'argument-hint' attribute must be a string."), argumentHintAttribute.range, MarkerSeverity.Error)); return; } @@ -298,26 +298,26 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'string' && attribute.value.type !== 'array') { + if (attribute.value.type !== 'scalar' && attribute.value.type !== 'sequence') { report(toMarker(localize('promptValidator.modelMustBeStringOrArray', "The 'model' attribute must be a string or an array of strings."), attribute.value.range, MarkerSeverity.Error)); return; } const modelNames: [string, Range][] = []; - if (attribute.value.type === 'string') { + if (attribute.value.type === 'scalar') { const modelName = attribute.value.value.trim(); if (modelName.length === 0) { report(toMarker(localize('promptValidator.modelMustBeNonEmpty', "The 'model' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error)); return; } modelNames.push([modelName, attribute.value.range]); - } else if (attribute.value.type === 'array') { + } else if (attribute.value.type === 'sequence') { if (attribute.value.items.length === 0) { report(toMarker(localize('promptValidator.modelArrayMustNotBeEmpty', "The 'model' array must not be empty."), attribute.value.range, MarkerSeverity.Error)); return; } for (const item of attribute.value.items) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { report(toMarker(localize('promptValidator.modelArrayMustContainStrings', "The 'model' array must contain only strings."), item.range, MarkerSeverity.Error)); return; } @@ -356,7 +356,7 @@ export class PromptValidator { if (!attribute) { continue; } - if (attribute.value.type !== 'string') { + if (attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.claude.attributeMustBeString', "The '{0}' attribute must be a string.", claudeAttributeName), attribute.value.range, MarkerSeverity.Error)); continue; } else { @@ -393,7 +393,7 @@ export class PromptValidator { if (!attribute) { return undefined; // default agent for prompts is Agent } - if (attribute.value.type !== 'string') { + if (attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.attributeMustBeString', "The '{0}' attribute must be a string.", attribute.key), attribute.value.range, MarkerSeverity.Error)); return undefined; } @@ -405,7 +405,7 @@ export class PromptValidator { return this.validateAgentValue(attribute.value, report); } - private validateAgentValue(value: IStringValue, report: (markers: IMarkerData) => void): IChatMode | undefined { + private validateAgentValue(value: IScalarValue, report: (markers: IMarkerData) => void): IChatMode | undefined { const agents = this.chatModeService.getModes(); const availableAgents = []; @@ -431,10 +431,10 @@ export class PromptValidator { report(toMarker(localize('promptValidator.toolsOnlyInAgent', "The 'tools' attribute is only supported when using agents. Attribute will be ignored."), attribute.range, MarkerSeverity.Warning)); } let value = attribute.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type !== 'array') { + if (value.type !== 'sequence') { report(toMarker(localize('promptValidator.toolsMustBeArrayOrMap', "The 'tools' attribute must be an array or a comma separated string."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -445,12 +445,12 @@ export class PromptValidator { } } - private validateVSCodeTools(valueItem: IArrayValue, report: (markers: IMarkerData) => void) { + private validateVSCodeTools(valueItem: ISequenceValue, report: (markers: IMarkerData) => void) { if (valueItem.items.length > 0) { const available = new Set(this.languageModelToolsService.getFullReferenceNames()); const deprecatedNames = this.languageModelToolsService.getDeprecatedFullReferenceNames(); for (const item of valueItem.items) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { report(toMarker(localize('promptValidator.eachToolMustBeString', "Each tool name in the 'tools' attribute must be a string."), item.range, MarkerSeverity.Error)); } else if (item.value) { if (!available.has(item.value)) { @@ -477,7 +477,7 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'string') { + if (attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.applyToMustBeString', "The 'applyTo' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -505,12 +505,12 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'array') { + if (attribute.value.type !== 'sequence') { report(toMarker(localize('promptValidator.pathsMustBeArray', "The 'paths' attribute must be an array of glob patterns."), attribute.value.range, MarkerSeverity.Error)); return; } for (const item of attribute.value.items) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { report(toMarker(localize('promptValidator.eachPathMustBeString', "Each entry in the 'paths' attribute must be a string."), item.range, MarkerSeverity.Error)); continue; } @@ -535,7 +535,7 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'array' && attribute.value.type !== 'string') { + if (attribute.value.type !== 'sequence' && attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.excludeAgentMustBeArray', "The 'excludeAgent' attribute must be an string or array."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -546,12 +546,12 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'array') { + if (attribute.value.type !== 'sequence') { report(toMarker(localize('promptValidator.handoffsMustBeArray', "The 'handoffs' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); return; } for (const item of attribute.value.items) { - if (item.type !== 'object') { + if (item.type !== 'map') { report(toMarker(localize('promptValidator.eachHandoffMustBeObject', "Each handoff in the 'handoffs' attribute must be an object with 'label', 'agent', 'prompt' and optional 'send'."), item.range, MarkerSeverity.Error)); continue; } @@ -559,34 +559,34 @@ export class PromptValidator { for (const prop of item.properties) { switch (prop.key.value) { case 'label': - if (prop.value.type !== 'string' || prop.value.value.trim().length === 0) { + if (prop.value.type !== 'scalar' || prop.value.value.trim().length === 0) { report(toMarker(localize('promptValidator.handoffLabelMustBeNonEmptyString', "The 'label' property in a handoff must be a non-empty string."), prop.value.range, MarkerSeverity.Error)); } break; case 'agent': - if (prop.value.type !== 'string' || prop.value.value.trim().length === 0) { + if (prop.value.type !== 'scalar' || prop.value.value.trim().length === 0) { report(toMarker(localize('promptValidator.handoffAgentMustBeNonEmptyString', "The 'agent' property in a handoff must be a non-empty string."), prop.value.range, MarkerSeverity.Error)); } else { this.validateAgentValue(prop.value, report); } break; case 'prompt': - if (prop.value.type !== 'string') { + if (prop.value.type !== 'scalar') { report(toMarker(localize('promptValidator.handoffPromptMustBeString', "The 'prompt' property in a handoff must be a string."), prop.value.range, MarkerSeverity.Error)); } break; case 'send': - if (prop.value.type !== 'boolean') { + if (!isTrueOrFalse(prop.value)) { report(toMarker(localize('promptValidator.handoffSendMustBeBoolean', "The 'send' property in a handoff must be a boolean."), prop.value.range, MarkerSeverity.Error)); } break; case 'showContinueOn': - if (prop.value.type !== 'boolean') { + if (!isTrueOrFalse(prop.value)) { report(toMarker(localize('promptValidator.handoffShowContinueOnMustBeBoolean', "The 'showContinueOn' property in a handoff must be a boolean."), prop.value.range, MarkerSeverity.Error)); } break; case 'model': - if (prop.value.type !== 'string') { + if (prop.value.type !== 'scalar') { report(toMarker(localize('promptValidator.handoffModelMustBeString', "The 'model' property in a handoff must be a string."), prop.value.range, MarkerSeverity.Error)); } break; @@ -614,7 +614,7 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'string') { + if (attribute.value.type !== 'scalar') { report(toMarker(localize('promptValidator.targetMustBeString', "The 'target' attribute must be a string."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -634,8 +634,8 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'boolean') { - report(toMarker(localize('promptValidator.userInvocableMustBeBoolean', "The 'user-invocable' attribute must be a boolean."), attribute.value.range, MarkerSeverity.Error)); + if (!isTrueOrFalse(attribute.value)) { + report(toMarker(localize('promptValidator.userInvocableMustBeBoolean', "The 'user-invocable' attribute must be 'true' or 'false'."), attribute.value.range, MarkerSeverity.Error)); return; } } @@ -653,8 +653,8 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'boolean') { - report(toMarker(localize('promptValidator.disableModelInvocationMustBeBoolean', "The 'disable-model-invocation' attribute must be a boolean."), attribute.value.range, MarkerSeverity.Error)); + if (!isTrueOrFalse(attribute.value)) { + report(toMarker(localize('promptValidator.disableModelInvocationMustBeBoolean', "The 'disable-model-invocation' attribute must be 'true' or 'false'."), attribute.value.range, MarkerSeverity.Error)); return; } } @@ -664,7 +664,7 @@ export class PromptValidator { if (!attribute) { return; } - if (attribute.value.type !== 'array') { + if (attribute.value.type !== 'sequence') { report(toMarker(localize('promptValidator.agentsMustBeArray', "The 'agents' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); return; } @@ -677,7 +677,7 @@ export class PromptValidator { // Check each item is a string and agent exists const agentNames: string[] = []; for (const item of attribute.value.items) { - if (item.type !== 'string') { + if (item.type !== 'scalar') { report(toMarker(localize('promptValidator.eachAgentMustBeString', "Each agent name in the 'agents' attribute must be a string."), item.range, MarkerSeverity.Error)); } else if (item.value) { agentNames.push(item.value); @@ -697,6 +697,13 @@ export class PromptValidator { } } +function isTrueOrFalse(value: IValue): boolean { + if (value.type === 'scalar') { + return (value.value === 'true' || value.value === 'false') && value.format === 'none'; + } + return false; +} + const allAttributeNames: Record = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], @@ -877,33 +884,33 @@ export function mapClaudeTools(claudeToolNames: readonly string[]): string[] { export const claudeAgentAttributes: Record = { 'name': { - type: 'string', + type: 'scalar', description: localize('attribute.name', "Unique identifier using lowercase letters and hyphens (required)"), }, 'description': { - type: 'string', + type: 'scalar', description: localize('attribute.description', "When to delegate to this subagent (required)"), }, 'tools': { - type: 'array', + type: 'sequence', description: localize('attribute.tools', "Array of tools the subagent can use. Inherits all tools if omitted"), defaults: ['Read, Edit, Bash'], items: knownClaudeTools }, 'disallowedTools': { - type: 'array', + type: 'sequence', description: localize('attribute.disallowedTools', "Tools to deny, removed from inherited or specified list"), defaults: ['Write, Edit, Bash'], items: knownClaudeTools }, 'model': { - type: 'string', + type: 'scalar', description: localize('attribute.model', "Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit."), defaults: ['sonnet', 'opus', 'haiku', 'inherit'], enums: knownClaudeModels }, 'permissionMode': { - type: 'string', + type: 'scalar', description: localize('attribute.permissionMode', "Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan."), defaults: ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan'], enums: [ @@ -916,11 +923,11 @@ export const claudeAgentAttributes: Record = { 'description': { - type: 'string', + type: 'scalar', description: localize('attribute.rules.description', "A description of what this rule covers, used to provide context about when it applies."), }, 'paths': { - type: 'array', + type: 'sequence', description: localize('attribute.rules.paths', "Array of glob patterns that describe for which files the rule applies. Based on these patterns, the file is automatically included in the prompt when the context contains a file that matches.\nExample: `['src/**/*.ts', 'test/**']`"), }, }; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index bb0bc234244..e11732310ec 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -7,8 +7,9 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { dirname, joinPath } from '../../../../../base/common/resources.js'; import { splitLinesIncludeSeparators } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; -import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../base/common/yaml.js'; +import { parse, YamlNode, YamlParseError } from '../../../../../base/common/yaml.js'; import { Range } from '../../../../../editor/common/core/range.js'; +import { PositionOffsetTransformer } from '../../../../../editor/common/core/text/positionToOffsetImpl.js'; import { Target } from './service/promptsService.js'; export class PromptFileParser { @@ -107,19 +108,38 @@ export class PromptHeader { private get _parsedHeader(): ParsedHeader { if (this._parsed === undefined) { const yamlErrors: YamlParseError[] = []; - const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join(''); - const node = parse(lines, yamlErrors); + const headerContent = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join(''); + const node = parse(headerContent, yamlErrors); + const transformer = new PositionOffsetTransformer(headerContent); + const asRange = ({ startOffset, endOffset }: { startOffset: number; endOffset: number }): Range => { + const startPos = transformer.getPosition(startOffset), endPos = transformer.getPosition(endOffset); + const headerDelta = this.range.startLineNumber - 1; + return new Range(startPos.lineNumber + headerDelta, startPos.column, endPos.lineNumber + headerDelta, endPos.column); + }; + const asValue = (node: YamlNode): IValue => { + switch (node.type) { + case 'scalar': + return { type: 'scalar', value: node.value, range: asRange(node), format: node.format }; + case 'sequence': + return { type: 'sequence', items: node.items.map(item => asValue(item)), range: asRange(node) }; + case 'map': { + const properties = node.properties.map(property => ({ key: asValue(property.key) as IScalarValue, value: asValue(property.value) })); + return { type: 'map', properties, range: asRange(node) }; + } + } + }; + const attributes = []; - const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: this.asRange(err), code: err.code })); + const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: asRange(err), code: err.code })); if (node) { - if (node.type !== 'object') { + if (node.type !== 'map') { errors.push({ message: 'Invalid header, expecting pairs', range: this.range, code: 'INVALID_YAML' }); } else { for (const property of node.properties) { attributes.push({ key: property.key.value, - range: this.asRange({ start: property.key.start, end: property.value.end }), - value: this.asValue(property.value) + range: asRange({ startOffset: property.key.startOffset, endOffset: property.value.endOffset }), + value: asValue(property.value) }); } } @@ -129,29 +149,6 @@ export class PromptHeader { return this._parsed; } - private asRange({ start, end }: { start: YamlPosition; end: YamlPosition }): Range { - return new Range(this.range.startLineNumber + start.line, start.character + 1, this.range.startLineNumber + end.line, end.character + 1); - } - - private asValue(node: YamlNode): IValue { - switch (node.type) { - case 'string': - return { type: 'string', value: node.value, range: this.asRange(node) }; - case 'number': - return { type: 'number', value: node.value, range: this.asRange(node) }; - case 'boolean': - return { type: 'boolean', value: node.value, range: this.asRange(node) }; - case 'null': - return { type: 'null', value: node.value, range: this.asRange(node) }; - case 'array': - return { type: 'array', items: node.items.map(item => this.asValue(item)), range: this.asRange(node) }; - case 'object': { - const properties = node.properties.map(property => ({ key: this.asValue(property.key) as IStringValue, value: this.asValue(property.value) })); - return { type: 'object', properties, range: this.asRange(node) }; - } - } - } - public get attributes(): IHeaderAttribute[] { return this._parsedHeader.attributes; } @@ -166,7 +163,7 @@ export class PromptHeader { private getStringAttribute(key: string): string | undefined { const attribute = this._parsedHeader.attributes.find(attr => attr.key === key); - if (attribute?.value.type === 'string') { + if (attribute?.value.type === 'scalar') { return attribute.value.value; } return undefined; @@ -210,11 +207,7 @@ export class PromptHeader { } public get infer(): boolean | undefined { - const attribute = this._parsedHeader.attributes.find(attr => attr.key === PromptHeaderAttributes.infer); - if (attribute?.value.type === 'boolean') { - return attribute.value.value; - } - return undefined; + return this.getBooleanAttribute(PromptHeaderAttributes.infer); } public get tools(): string[] | undefined { @@ -223,13 +216,13 @@ export class PromptHeader { return undefined; } let value = toolsAttribute.value; - if (value.type === 'string') { + if (value.type === 'scalar') { value = parseCommaSeparatedList(value); } - if (value.type === 'array') { + if (value.type === 'sequence') { const tools: string[] = []; for (const item of value.items) { - if (item.type === 'string' && item.value) { + if (item.type === 'scalar' && item.value) { tools.push(item.value); } } @@ -243,11 +236,11 @@ export class PromptHeader { if (!handoffsAttribute) { return undefined; } - if (handoffsAttribute.value.type === 'array') { + if (handoffsAttribute.value.type === 'sequence') { // Array format: list of objects: { agent, label, prompt, send?, showContinueOn?, model? } const handoffs: IHandOff[] = []; for (const item of handoffsAttribute.value.items) { - if (item.type === 'object') { + if (item.type === 'map') { let agent: string | undefined; let label: string | undefined; let prompt: string | undefined; @@ -255,17 +248,17 @@ export class PromptHeader { let showContinueOn: boolean | undefined; let model: string | undefined; for (const prop of item.properties) { - if (prop.key.value === 'agent' && prop.value.type === 'string') { + if (prop.key.value === 'agent' && prop.value.type === 'scalar') { agent = prop.value.value; - } else if (prop.key.value === 'label' && prop.value.type === 'string') { + } else if (prop.key.value === 'label' && prop.value.type === 'scalar') { label = prop.value.value; - } else if (prop.key.value === 'prompt' && prop.value.type === 'string') { + } else if (prop.key.value === 'prompt' && prop.value.type === 'scalar') { prompt = prop.value.value; - } else if (prop.key.value === 'send' && prop.value.type === 'boolean') { - send = prop.value.value; - } else if (prop.key.value === 'showContinueOn' && prop.value.type === 'boolean') { - showContinueOn = prop.value.value; - } else if (prop.key.value === 'model' && prop.value.type === 'string') { + } else if (prop.key.value === 'send' && prop.value.type === 'scalar') { + send = parseBoolean(prop.value); + } else if (prop.key.value === 'showContinueOn' && prop.value.type === 'scalar') { + showContinueOn = parseBoolean(prop.value); + } else if (prop.key.value === 'model' && prop.value.type === 'scalar') { model = prop.value.value; } } @@ -292,10 +285,10 @@ export class PromptHeader { if (!attribute) { return undefined; } - if (attribute.value.type === 'array') { + if (attribute.value.type === 'sequence') { const result: string[] = []; for (const item of attribute.value.items) { - if (item.type === 'string' && item.value) { + if (item.type === 'scalar' && item.value) { result.push(item.value); } } @@ -309,13 +302,13 @@ export class PromptHeader { if (!attribute) { return undefined; } - if (attribute.value.type === 'string') { + if (attribute.value.type === 'scalar') { return [attribute.value.value]; } - if (attribute.value.type === 'array') { + if (attribute.value.type === 'sequence') { const result: string[] = []; for (const item of attribute.value.items) { - if (item.type === 'string') { + if (item.type === 'scalar') { result.push(item.value); } } @@ -339,13 +332,22 @@ export class PromptHeader { private getBooleanAttribute(key: string): boolean | undefined { const attribute = this._parsedHeader.attributes.find(attr => attr.key === key); - if (attribute?.value.type === 'boolean') { - return attribute.value.value; + if (attribute?.value.type === 'scalar') { + return parseBoolean(attribute.value); } return undefined; } } +function parseBoolean(stringValue: IScalarValue): boolean | undefined { + if (stringValue.value === 'true') { + return true; + } else if (stringValue.value === 'false') { + return false; + } + return undefined; +} + export interface IHandOff { readonly agent: string; readonly label: string; @@ -361,24 +363,26 @@ export interface IHeaderAttribute { readonly value: IValue; } -export interface IStringValue { readonly type: 'string'; readonly value: string; readonly range: Range } -export interface INumberValue { readonly type: 'number'; readonly value: number; readonly range: Range } -export interface INullValue { readonly type: 'null'; readonly value: null; readonly range: Range } -export interface IBooleanValue { readonly type: 'boolean'; readonly value: boolean; readonly range: Range } +export interface IScalarValue { + readonly type: 'scalar'; + readonly value: string; + readonly range: Range; + readonly format: 'single' | 'double' | 'none' | 'literal' | 'folded'; +} -export interface IArrayValue { - readonly type: 'array'; +export interface ISequenceValue { + readonly type: 'sequence'; readonly items: readonly IValue[]; readonly range: Range; } -export interface IObjectValue { - readonly type: 'object'; - readonly properties: { key: IStringValue; value: IValue }[]; +export interface IMapValue { + readonly type: 'map'; + readonly properties: { key: IScalarValue; value: IValue }[]; readonly range: Range; } -export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | IObjectValue | INullValue; +export type IValue = IScalarValue | ISequenceValue | IMapValue; interface ParsedBody { @@ -492,10 +496,10 @@ export interface IBodyVariableReference { * Values can be unquoted or quoted (single or double quotes). * * @param input A string containing comma-separated values - * @returns An IArrayValue containing the parsed values and their ranges + * @returns An ISequenceValue containing the parsed values and their ranges */ -export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue { - const result: IStringValue[] = []; +export function parseCommaSeparatedList(stringValue: IScalarValue): ISequenceValue { + const result: IScalarValue[] = []; const input = stringValue.value; const positionOffset = stringValue.range.getStartPosition(); let pos = 0; @@ -514,6 +518,7 @@ export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue const startPos = pos; let value = ''; let endPos: number; + let quoteStyle: 'single' | 'double' | 'none'; const char = input[pos]; if (char === '"' || char === `'`) { @@ -530,7 +535,7 @@ export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue if (pos < input.length) { pos++; } - + quoteStyle = quote === '"' ? 'double' : 'single'; } else { // Unquoted string - read until comma or end const startPos = pos; @@ -540,9 +545,10 @@ export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue } value = value.trimEnd(); endPos = startPos + value.length; + quoteStyle = 'none'; } - result.push({ type: 'string', value: value, range: new Range(positionOffset.lineNumber, positionOffset.column + startPos, positionOffset.lineNumber, positionOffset.column + endPos) }); + result.push({ type: 'scalar', value: value, range: new Range(positionOffset.lineNumber, positionOffset.column + startPos, positionOffset.lineNumber, positionOffset.column + endPos), format: quoteStyle }); // Skip whitespace after value while (pos < input.length && isWhitespace(input[pos])) { @@ -555,7 +561,7 @@ export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue } } - return { type: 'array', items: result, range: stringValue.range }; + return { type: 'sequence', items: result, range: stringValue.range }; } 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 1e1d84d7d65..912a103fe58 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -548,10 +548,10 @@ export class PromptsService extends Disposable implements IPromptsService { let metadata: any | undefined; if (ast.header) { const advanced = ast.header.getAttribute(PromptHeaderAttributes.advancedOptions); - if (advanced && advanced.value.type === 'object') { + if (advanced && advanced.value.type === 'map') { metadata = {}; for (const [key, value] of Object.entries(advanced.value)) { - if (['string', 'number', 'boolean'].includes(value.type)) { + if (value.type === 'scalar') { metadata[key] = value; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts index ba398b6754a..152288f1198 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ITextModel } from '../../../../../../editor/common/model.js'; -import { IArrayValue } from '../promptFileParser.js'; +import { ISequenceValue } from '../promptFileParser.js'; const isSimpleNameRegex = /^[\w\/\.-]+$/; @@ -20,8 +20,8 @@ export function formatArrayValue(name: string, quotePreference?: QuotePreference export type QuotePreference = '\'' | '\"' | ''; -export function getQuotePreference(arrayValue: IArrayValue, model: ITextModel): QuotePreference { - const firstStringItem = arrayValue.items.find(item => item.type === 'string' && isSimpleNameRegex.test(item.value)); +export function getQuotePreference(arrayValue: ISequenceValue, model: ITextModel): QuotePreference { + const firstStringItem = arrayValue.items.find(item => item.type === 'scalar' && isSimpleNameRegex.test(item.value)); const firstChar = firstStringItem ? model.getValueInRange(firstStringItem.range).charAt(0) : undefined; if (firstChar === `'` || firstChar === `"`) { return firstChar; diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 3316f148d31..d14a93696e0 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -349,8 +349,6 @@ export interface IToolInvocationStreamContext { toolCallId: string; rawInput: unknown; chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource?: URI; chatInteractionId?: string; } diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts index 7de3b5ea61e..a32ee2b588a 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts @@ -7,7 +7,6 @@ import { joinPath } from '../../../../../base/common/resources.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -19,7 +18,6 @@ import { IChatWidgetService } from '../../browser/chat.js'; import { captureRepoInfo } from '../../browser/chatRepoInfo.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatService } from '../../common/chatService/chatService.js'; -import { ChatConfiguration } from '../../common/constants.js'; import { ISCMService } from '../../../scm/common/scm.js'; export function registerChatExportZipAction() { @@ -42,9 +40,6 @@ export function registerChatExportZipAction() { const notificationService = accessor.get(INotificationService); const scmService = accessor.get(ISCMService); const fileService = accessor.get(IFileService); - const configurationService = accessor.get(IConfigurationService); - - const repoInfoEnabled = configurationService.getValue(ChatConfiguration.RepoInfoEnabled) ?? false; const widget = widgetService.lastFocusedWidget; if (!widget || !widget.viewModel) { @@ -83,36 +78,32 @@ export function registerChatExportZipAction() { }); } - if (repoInfoEnabled) { - const currentRepoData = await captureRepoInfo(scmService, fileService); - if (currentRepoData) { - files.push({ - path: 'chat.repo.end.json', - contents: JSON.stringify(currentRepoData, undefined, 2) - }); - } + const currentRepoData = await captureRepoInfo(scmService, fileService); + if (currentRepoData) { + files.push({ + path: 'chat.repo.end.json', + contents: JSON.stringify(currentRepoData, undefined, 2) + }); + } - if (!model.repoData && !currentRepoData) { - notificationService.notify({ - severity: Severity.Warning, - message: localize('chatExportZip.noRepoData', "Exported chat without repository context. No Git repository was detected.") - }); - } + if (!model.repoData && !currentRepoData) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('chatExportZip.noRepoData', "Exported chat without repository context. No Git repository was detected.") + }); } } else { - if (repoInfoEnabled) { - const currentRepoData = await captureRepoInfo(scmService, fileService); - if (currentRepoData) { - files.push({ - path: 'chat.repo.begin.json', - contents: JSON.stringify(currentRepoData, undefined, 2) - }); - } else { - notificationService.notify({ - severity: Severity.Warning, - message: localize('chatExportZip.noRepoData', "Exported chat without repository context. No Git repository was detected.") - }); - } + const currentRepoData = await captureRepoInfo(scmService, fileService); + if (currentRepoData) { + files.push({ + path: 'chat.repo.begin.json', + contents: JSON.stringify(currentRepoData, undefined, 2) + }); + } else { + notificationService.notify({ + severity: Severity.Warning, + message: localize('chatExportZip.noRepoData', "Exported chat without repository context. No Git repository was detected.") + }); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 4ad52ff646c..0439386a341 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Emitter } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelProviderDescriptor } from '../../../common/languageModels.js'; +import { ICuratedModels, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelProviderDescriptor } from '../../../common/languageModels.js'; import { ChatModelGroup, ChatModelsViewModel, ILanguageModelEntry, ILanguageModelProviderEntry, isLanguageModelProviderEntry, isLanguageModelGroupEntry, ILanguageModelGroupEntry } from '../../../browser/chatManagement/chatModelsViewModel.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IStringDictionary } from '../../../../../../base/common/collections.js'; @@ -134,6 +135,14 @@ class MockLanguageModelsService implements ILanguageModelsService { } async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } + + getRecentlyUsedModelIds(_maxCount?: number): string[] { return []; } + recordModelUsage(_modelIdentifier: string): void { } + getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } + getNewModelIds(): string[] { return []; } + onDidChangeNewModelIds = Event.None; + markNewModelsAsSeen(): void { } + restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } suite('ChatModelsViewModel', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index f25d39059a6..5ee2250a373 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -14,7 +14,7 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IStorageService, InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -100,6 +100,73 @@ suite('ChatTipService', () => { assert.ok(tip.content.value.length > 0, 'Tip should have content'); }); + test('returns Auto switch tip when current model is gpt-4.1', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1'); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('does not return Auto switch tip when current model is not gpt-4.1', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.notStrictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('does not return Auto switch tip when current model context key is empty and no fallback is available', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, ''); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.notStrictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('returns Auto switch tip when current model is persisted and context key is empty', () => { + storageService.store('chat.currentLanguageModel.panel', 'copilot/gpt-4.1-2025-04-14', StorageScope.APPLICATION, StorageTarget.USER); + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, ''); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('returns Auto switch tip when current model is versioned gpt-4.1', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1-2025-04-14'); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.switchToAuto'); + }); + + test('switching models advances away from gpt-4.1 tip', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1'); + + const firstTip = service.getWelcomeTip(contextKeyService); + assert.ok(firstTip); + assert.strictEqual(firstTip.id, 'tip.switchToAuto'); + + const switchedContextKeyService = new MockContextKeyServiceWithRulesMatching(); + switchedContextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + const nextTip = service.getWelcomeTip(switchedContextKeyService); + + assert.ok(nextTip); + assert.notStrictEqual(nextTip.id, 'tip.switchToAuto'); + }); + test('returns same welcome tip on rerender', () => { const service = createService(); 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 5fe0c45d88f..775ad671cec 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 @@ -273,7 +273,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test with invalid model array"', - `model: ['MAE 4 (olama)', 123]`, + `model: ['MAE 4 (olama)', []]`, '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); @@ -299,7 +299,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test with invalid model type"', - `model: 123`, + `model: {}`, '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); @@ -312,7 +312,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test"', - `tools: ['tool1', 2]`, + `tools: ['tool1', {}]`, '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); @@ -773,7 +773,7 @@ suite('PromptValidator', () => { { const content = [ '---', - 'name: 123', + 'name: []', 'description: "Test agent"', 'target: vscode', '---', @@ -1005,7 +1005,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test"', - `agents: ['agent', 123]`, + `agents: ['agent', {}]`, `tools: ['agent']`, '---', ].join('\n'); @@ -1127,7 +1127,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be 'true' or 'false'.`); } // Invalid user-invocable: number value @@ -1143,7 +1143,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be 'true' or 'false'.`); } }); @@ -1204,7 +1204,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } // Invalid disable-model-invocation: number value @@ -1220,7 +1220,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } }); }); @@ -1242,7 +1242,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Instr"', - 'applyTo: 5', + 'applyTo: []', '---', ].join('\n'); const markers = await validate(content, PromptsType.instructions); @@ -1646,7 +1646,7 @@ suite('PromptValidator', () => { test('skill with non-string name type does not validate folder match', async () => { const content = [ '---', - 'name: 123', + 'name: []', 'description: Test Skill', '---', 'This is a skill.' @@ -1729,7 +1729,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be 'true' or 'false'.`); } // Number value instead of boolean @@ -1745,7 +1745,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be 'true' or 'false'.`); } }); @@ -1789,7 +1789,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } // Number value instead of boolean @@ -1805,7 +1805,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } }); @@ -1842,7 +1842,7 @@ suite('PromptValidator', () => { '---', 'name: my-skill', 'description: Test Skill', - 'argument-hint: 123', + 'argument-hint: []', '---', 'Body' ].join('\n'); @@ -1910,32 +1910,6 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The 'paths' attribute must be an array of glob patterns.`); }); - test('claude rules paths entries must be strings', async () => { - const content = [ - '---', - 'description: "Rules"', - `paths: [123, '**/*.ts']`, - '---', - ].join('\n'); - const markers = await validate(content, PromptsType.instructions, claudeRulesUri); - assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `Each entry in the 'paths' attribute must be a string.`); - }); - - test('claude rules paths entries must be non-empty', async () => { - const content = [ - '---', - 'description: "Rules"', - `paths: ['']`, - '---', - ].join('\n'); - const markers = await validate(content, PromptsType.instructions, claudeRulesUri); - assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `Path entries must be non-empty glob patterns.`); - }); - test('claude rules with unknown attribute shows warning', async () => { const content = [ '---', @@ -1962,7 +1936,6 @@ suite('PromptValidator', () => { [ { severity: MarkerSeverity.Error, message: `The 'description' attribute should not be empty.` }, { severity: MarkerSeverity.Error, message: `Path entries must be non-empty glob patterns.` }, - { severity: MarkerSeverity.Error, message: `Each entry in the 'paths' attribute must be a string.` }, ] ); }); @@ -2047,7 +2020,7 @@ suite('PromptValidator', () => { '---', 'name: test-agent', 'description: Test', - 'model: 123', + 'model: []', '---', ].join('\n'); const markers = await validate(content, PromptsType.agent, claudeAgentUri); @@ -2085,21 +2058,6 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `Unknown value 'allowAll', valid: default, acceptEdits, plan, delegate, dontAsk, bypassPermissions.`); }); - test('Claude agent with non-string permissionMode value', async () => { - const content = [ - '---', - 'name: test-agent', - 'description: Test', - 'model: sonnet', - 'permissionMode: true', - '---', - ].join('\n'); - const markers = await validate(content, PromptsType.agent, claudeAgentUri); - assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'permissionMode' attribute must be a string.`); - }); - test('Claude agent with valid memory values', async () => { for (const mem of ['user', 'project', 'local']) { const content = [ diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 06845cf18a1..a2dcac90b3f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -620,6 +620,40 @@ suite('LanguageModelToolsService', () => { }, 'Expected tool call to be cancelled'); }); + test('rejects tool invocation for cancelled request id', async () => { + let invoked = false; + const tool = registerToolForTest(service, store, 'testTool', { + invoke: async () => { + invoked = true; + return { content: [{ kind: 'text', value: 'done' }] }; + } + }); + + const sessionId = 'sessionId-cancelled-request'; + const requestId = 'requestId-cancelled-request'; + const fakeModel = { + sessionId, + sessionResource: LocalChatSessionUri.forSession(sessionId), + getRequests: () => [{ + id: requestId, + modelId: 'test-model', + response: { isCanceled: true }, + }], + } as ChatModel; + chatService.addSession(fakeModel); + + const dto: IToolInvocation = { + ...tool.makeDto({ a: 1 }, { sessionId }), + chatRequestId: requestId, + }; + + await assert.rejects(service.invokeTool(dto, async () => 0, CancellationToken.None), err => { + return isCancellationError(err); + }, 'Expected tool invocation to be rejected for cancelled request id'); + + assert.strictEqual(invoked, false, 'Tool implementation should not run after request cancellation'); + }); + test('toFullReferenceNames', () => { setupToolsForTest(service, store); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 4cffb9f84e7..2d91102c1e1 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 @@ -5,6 +5,7 @@ import assert from 'assert'; import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../../../../browser/widget/chatContentParts/chatQuestionCarouselPart.js'; @@ -85,6 +86,25 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(title?.textContent?.includes('Fallback title text')); }); + test('renders markdown in question message', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'text', + title: 'Question', + message: new MarkdownString('Please review **details** in [docs](https://example.com)') + } + ]); + createWidget(carousel); + + const title = widget.domNode.querySelector('.chat-question-title'); + assert.ok(title, 'title element should exist'); + assert.ok(title?.querySelector('.rendered-markdown'), 'markdown content should be rendered'); + assert.strictEqual(title?.textContent?.includes('**details**'), false, 'markdown syntax should not be shown as raw text'); + const link = title?.querySelector('a') as HTMLAnchorElement | null; + assert.ok(link, 'markdown link should render as anchor'); + }); + test('renders progress indicator correctly', () => { const carousel = createMockCarousel([ { id: 'q1', type: 'text', title: 'Question 1', message: 'Question 1' }, 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 601e1389741..15a5add3196 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 @@ -33,7 +33,8 @@ import { IExtensionService, nullExtensionDescription } from '../../../../../serv import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; -import { InMemoryTestFileService, mock, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { InMemoryTestFileService, mock, TestChatEntitlementService, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -163,6 +164,7 @@ suite('ChatService', () => { [IPromptsService, new MockPromptsService()], ))); instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); + instantiationService.stub(IChatEntitlementService, new TestChatEntitlementService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); instantiationService.stub(ITelemetryService, NullTelemetryService); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 48e2b40d1f1..ddac0e86cc3 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -22,6 +22,8 @@ import { ContextKeyExpression } from '../../../../../platform/contextkey/common/ import { ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IRequestService } from '../../../../../platform/request/common/request.js'; suite('LanguageModels', function () { @@ -50,6 +52,8 @@ suite('LanguageModels', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); languageModels.deltaLanguageModelChatProviderDescriptors([ @@ -251,6 +255,8 @@ suite('LanguageModels - When Clause', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); languageModelsWithWhen.deltaLanguageModelChatProviderDescriptors([ @@ -312,6 +318,8 @@ suite('LanguageModels - Model Picker Preferences Storage', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); // Register vendor1 used in most tests @@ -548,6 +556,8 @@ suite('LanguageModels - Model Change Events', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); // Register the vendor first @@ -895,6 +905,8 @@ suite('LanguageModels - Vendor Change Events', function () { }, new class extends mock() { }, new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, ); }); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 35b2595109d..ee3375bdef5 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -7,8 +7,9 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IStringDictionary } from '../../../../../base/common/collections.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IChatMessage, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; +import { IChatMessage, ICuratedModels, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; export class NullLanguageModelsService implements ILanguageModelsService { @@ -85,4 +86,24 @@ export class NullLanguageModelsService implements ILanguageModelsService { } async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } + + getRecentlyUsedModelIds(_maxCount?: number): string[] { + return []; + } + + recordModelUsage(_modelIdentifier: string): void { } + + getCuratedModels(): ICuratedModels { + return { free: [], paid: [] }; + } + + getNewModelIds(): string[] { + return []; + } + + onDidChangeNewModelIds = Event.None; + + markNewModelsAsSeen(): void { } + + restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 3cb6fe2ad58..2d008a2567f 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -8,6 +8,7 @@ import * as sinon from 'sinon'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { Schemas } from '../../../../../../base/common/network.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; @@ -27,7 +28,7 @@ import { testWorkspace } from '../../../../../../platform/workspace/test/common/ import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { TestContextService, TestUserDataProfileService } from '../../../../../test/common/workbenchTestServices.js'; import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariableEntry, toFileVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { ComputeAutomaticInstructions, InstructionsCollectionEvent } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; +import { ComputeAutomaticInstructions, getFilePath, InstructionsCollectionEvent } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; import { AGENTS_SOURCE_FOLDER, CLAUDE_RULES_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 } from '../../../common/promptSyntax/promptTypes.js'; @@ -39,6 +40,7 @@ import { IPathService } from '../../../../../services/path/common/pathService.js import { IFileQuery, ISearchService } from '../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../services/extensions/common/extensions.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { IRemoteAgentService } from '../../../../../../workbench/services/remote/common/remoteAgentService.js'; import { basename } from '../../../../../../base/common/resources.js'; import { match } from '../../../../../../base/common/glob.js'; import { ChatModeKind } from '../../../common/constants.js'; @@ -169,6 +171,10 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ILanguageModelToolsService; instaService.stub(ILanguageModelToolsService, toolsService); + instaService.stub(IRemoteAgentService, { + getEnvironment: () => Promise.resolve(null), + }); + service = disposables.add(instaService.createInstance(PromptsService)); instaService.stub(IPromptsService, service); }); @@ -2011,3 +2017,53 @@ suite('ComputeAutomaticInstructions', () => { assert.ok(!paths.includes(claudeMdUri.path), 'Should not include CLAUDE.md (symlink to copilot)'); }); }); + +suite('getFilePath', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return fsPath for file:// URIs', () => { + const uri = URI.file('/workspace/src/file.ts'); + const result = getFilePath(uri, undefined); + assert.strictEqual(result, uri.fsPath); + }); + + test('should return fsPath for vscode-remote URIs', () => { + const uri = URI.from({ scheme: Schemas.vscodeRemote, path: '/workspace/src/file.ts' }); + const result = getFilePath(uri, undefined); + assert.strictEqual(result, uri.fsPath); + }); + + test('should return uri.toString() for other schemes', () => { + const uri = URI.from({ scheme: 'untitled', path: '/workspace/src/file.ts' }); + const result = getFilePath(uri, undefined); + assert.strictEqual(result, uri.toString()); + }); + + test('should use backslashes when remote is Windows', () => { + const uri = URI.from({ scheme: Schemas.vscodeRemote, path: '/C:/Users/dev/project/file.ts' }); + const result = getFilePath(uri, OperatingSystem.Windows); + assert.ok(!result.includes('/'), 'Should not contain forward slashes'); + assert.ok(result.includes('\\'), 'Should contain backslashes'); + }); + + test('should use forward slashes when remote is Linux', () => { + const uri = URI.from({ scheme: Schemas.vscodeRemote, path: '/home/user/project/file.ts' }); + const result = getFilePath(uri, OperatingSystem.Linux); + assert.ok(!result.includes('\\'), 'Should not contain backslashes'); + assert.ok(result.includes('/home/user/project/file.ts'), 'Should contain the forward-slash path'); + }); + + test('should use forward slashes when remote is macOS', () => { + const uri = URI.from({ scheme: Schemas.vscodeRemote, path: '/Users/dev/project/file.ts' }); + const result = getFilePath(uri, OperatingSystem.Macintosh); + assert.ok(!result.includes('\\'), 'Should not contain backslashes'); + assert.ok(result.includes('/Users/dev/project/file.ts'), 'Should contain the forward-slash path'); + }); + + test('should not replace slashes when remoteOS is undefined', () => { + const uri = URI.file('/workspace/src/file.ts'); + const result = getFilePath(uri, undefined); + assert.strictEqual(result, uri.fsPath); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts index 0d09d3047d1..728349a04bf 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts @@ -323,7 +323,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', '', '[](./some-other-folder/non-existing-folder)', @@ -360,7 +360,7 @@ suite('PromptFileReference', function () { name: 'another-file.prompt.md', contents: [ '---', - 'tools: [\'my-tool3\', false, "my-tool2" ]', + 'tools: [\'my-tool3\', "my-tool2" ]', '---', `[](${rootFolder}/folder1/some-other-folder)`, 'another-file.prompt.md contents\t [#file:file.txt](../file.txt)', @@ -441,7 +441,7 @@ suite('PromptFileReference', function () { contents: [ '---', 'applyTo: \'**/*\'', - 'tools: [ false, \'my-tool12\' , ]', + 'tools: [ \'my-tool12\' , ]', 'description: \'Description of my prompt.\'', '---', '## Files', @@ -457,7 +457,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], @@ -547,7 +547,7 @@ suite('PromptFileReference', function () { contents: [ '---', 'applyTo: \'**/*\'', - 'tools: [ false, \'my-tool12\' , ]', + 'tools: [ \'my-tool12\' , ]', 'description: \'Description of my instructions file.\'', '---', '## Files', @@ -563,7 +563,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], @@ -668,7 +668,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', 'agent: \'agent\'\t', '---', ' some more\t content', @@ -771,7 +771,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], @@ -874,7 +874,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], @@ -961,7 +961,7 @@ suite('PromptFileReference', function () { name: 'file2.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool12\' , ]', + 'tools: [ \'my-tool12\' , ]', 'description: \'Description of the prompt file.\'', '---', '## Files', @@ -977,7 +977,7 @@ suite('PromptFileReference', function () { name: 'file3.prompt.md', contents: [ '---', - 'tools: [ false, \'my-tool1\' , ]', + 'tools: [ \'my-tool1\' , ]', '---', ' some more\t content', ], diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts index d4a3d7d430a..ed6e2f47948 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts @@ -8,7 +8,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { URI } from '../../../../../../../base/common/uri.js'; -import { IStringValue, parseCommaSeparatedList, PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; +import { IScalarValue, parseCommaSeparatedList, PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; suite('PromptFileParser', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -30,12 +30,12 @@ suite('PromptFileParser', () => { assert.ok(result.body); assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 5, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'string', value: 'Agent test', range: new Range(2, 14, 2, 26) } }, - { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(3, 8, 3, 15) } }, + { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'scalar', value: 'Agent test', range: new Range(2, 14, 2, 26), format: 'double' } }, + { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'scalar', value: 'GPT 4.1', range: new Range(3, 8, 3, 15), format: 'none' } }, { key: 'tools', range: new Range(4, 1, 4, 26), value: { - type: 'array', - items: [{ type: 'string', value: 'tool1', range: new Range(4, 9, 4, 16) }, { type: 'string', value: 'tool2', range: new Range(4, 18, 4, 25) }], + type: 'sequence', + items: [{ type: 'scalar', value: 'tool1', range: new Range(4, 9, 4, 16), format: 'single' }, { type: 'scalar', value: 'tool2', range: new Range(4, 18, 4, 25), format: 'single' }], range: new Range(4, 8, 4, 26) } }, @@ -80,29 +80,29 @@ suite('PromptFileParser', () => { assert.ok(result.header); assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 13, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'string', value: 'Agent test', range: new Range(2, 14, 2, 26) } }, - { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(3, 8, 3, 15) } }, + { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'scalar', value: 'Agent test', range: new Range(2, 14, 2, 26), format: 'double' } }, + { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'scalar', value: 'GPT 4.1', range: new Range(3, 8, 3, 15), format: 'none' } }, { key: 'handoffs', range: new Range(4, 1, 12, 15), value: { - type: 'array', - range: new Range(5, 3, 12, 15), + type: 'sequence', + range: new Range(5, 1, 12, 15), items: [ { - type: 'object', range: new Range(5, 5, 8, 16), + type: 'map', range: new Range(5, 5, 8, 16), properties: [ - { key: { type: 'string', value: 'label', range: new Range(5, 5, 5, 10) }, value: { type: 'string', value: 'Implement', range: new Range(5, 12, 5, 23) } }, - { key: { type: 'string', value: 'agent', range: new Range(6, 5, 6, 10) }, value: { type: 'string', value: 'Default', range: new Range(6, 12, 6, 19) } }, - { key: { type: 'string', value: 'prompt', range: new Range(7, 5, 7, 11) }, value: { type: 'string', value: 'Implement the plan', range: new Range(7, 13, 7, 33) } }, - { key: { type: 'string', value: 'send', range: new Range(8, 5, 8, 9) }, value: { type: 'boolean', value: false, range: new Range(8, 11, 8, 16) } }, + { key: { type: 'scalar', value: 'label', range: new Range(5, 5, 5, 10), format: 'none' }, value: { type: 'scalar', value: 'Implement', range: new Range(5, 12, 5, 23), format: 'double' } }, + { key: { type: 'scalar', value: 'agent', range: new Range(6, 5, 6, 10), format: 'none' }, value: { type: 'scalar', value: 'Default', range: new Range(6, 12, 6, 19), format: 'none' } }, + { key: { type: 'scalar', value: 'prompt', range: new Range(7, 5, 7, 11), format: 'none' }, value: { type: 'scalar', value: 'Implement the plan', range: new Range(7, 13, 7, 33), format: 'double' } }, + { key: { type: 'scalar', value: 'send', range: new Range(8, 5, 8, 9), format: 'none' }, value: { type: 'scalar', value: 'false', range: new Range(8, 11, 8, 16), format: 'none' } }, ] }, { - type: 'object', range: new Range(9, 5, 12, 15), + type: 'map', range: new Range(9, 5, 12, 15), properties: [ - { key: { type: 'string', value: 'label', range: new Range(9, 5, 9, 10) }, value: { type: 'string', value: 'Save', range: new Range(9, 12, 9, 18) } }, - { key: { type: 'string', value: 'agent', range: new Range(10, 5, 10, 10) }, value: { type: 'string', value: 'Default', range: new Range(10, 12, 10, 19) } }, - { key: { type: 'string', value: 'prompt', range: new Range(11, 5, 11, 11) }, value: { type: 'string', value: 'Save the plan to a file', range: new Range(11, 13, 11, 38) } }, - { key: { type: 'string', value: 'send', range: new Range(12, 5, 12, 9) }, value: { type: 'boolean', value: true, range: new Range(12, 11, 12, 15) } }, + { key: { type: 'scalar', value: 'label', range: new Range(9, 5, 9, 10), format: 'none' }, value: { type: 'scalar', value: 'Save', range: new Range(9, 12, 9, 18), format: 'double' } }, + { key: { type: 'scalar', value: 'agent', range: new Range(10, 5, 10, 10), format: 'none' }, value: { type: 'scalar', value: 'Default', range: new Range(10, 12, 10, 19), format: 'none' } }, + { key: { type: 'scalar', value: 'prompt', range: new Range(11, 5, 11, 11), format: 'none' }, value: { type: 'scalar', value: 'Save the plan to a file', range: new Range(11, 13, 11, 38), format: 'double' } }, + { key: { type: 'scalar', value: 'send', range: new Range(12, 5, 12, 9), format: 'none' }, value: { type: 'scalar', value: 'true', range: new Range(12, 11, 12, 15), format: 'none' } }, ] }, ] @@ -180,8 +180,8 @@ suite('PromptFileParser', () => { assert.ok(result.body); assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 4, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 54), value: { type: 'string', value: 'Code style instructions for TypeScript', range: new Range(2, 14, 2, 54) } }, - { key: 'applyTo', range: new Range(3, 1, 3, 14), value: { type: 'string', value: '*.ts', range: new Range(3, 10, 3, 14) } }, + { key: 'description', range: new Range(2, 1, 2, 54), value: { type: 'scalar', value: 'Code style instructions for TypeScript', range: new Range(2, 14, 2, 54), format: 'double' } }, + { key: 'applyTo', range: new Range(3, 1, 3, 14), value: { type: 'scalar', value: '*.ts', range: new Range(3, 10, 3, 14), format: 'none' } }, ]); assert.deepEqual(result.body.range, { startLineNumber: 5, startColumn: 1, endLineNumber: 6, endColumn: 1 }); assert.equal(result.body.offset, 76); @@ -212,13 +212,13 @@ suite('PromptFileParser', () => { assert.ok(result.body); assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 6, endColumn: 1 }); assert.deepEqual(result.header.attributes, [ - { key: 'description', range: new Range(2, 1, 2, 48), value: { type: 'string', value: 'General purpose coding assistant', range: new Range(2, 14, 2, 48) } }, - { key: 'agent', range: new Range(3, 1, 3, 13), value: { type: 'string', value: 'agent', range: new Range(3, 8, 3, 13) } }, - { key: 'model', range: new Range(4, 1, 4, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(4, 8, 4, 15) } }, + { key: 'description', range: new Range(2, 1, 2, 48), value: { type: 'scalar', value: 'General purpose coding assistant', range: new Range(2, 14, 2, 48), format: 'double' } }, + { key: 'agent', range: new Range(3, 1, 3, 13), value: { type: 'scalar', value: 'agent', range: new Range(3, 8, 3, 13), format: 'none' } }, + { key: 'model', range: new Range(4, 1, 4, 15), value: { type: 'scalar', value: 'GPT 4.1', range: new Range(4, 8, 4, 15), format: 'none' } }, { key: 'tools', range: new Range(5, 1, 5, 30), value: { - type: 'array', - items: [{ type: 'string', value: 'search', range: new Range(5, 9, 5, 17) }, { type: 'string', value: 'terminal', range: new Range(5, 19, 5, 29) }], + type: 'sequence', + items: [{ type: 'scalar', value: 'search', range: new Range(5, 9, 5, 17), format: 'single' }, { type: 'scalar', value: 'terminal', range: new Range(5, 19, 5, 29), format: 'single' }], range: new Range(5, 8, 5, 30) } }, @@ -306,53 +306,53 @@ suite('PromptFileParser', () => { suite('parseCommaSeparatedList', () => { - function assertCommaSeparatedList(input: string, expected: IStringValue[]): void { - const actual = parseCommaSeparatedList({ type: 'string', value: input, range: new Range(1, 1, 1, input.length + 1) }); + function assertCommaSeparatedList(input: string, expected: IScalarValue[]): void { + const actual = parseCommaSeparatedList({ type: 'scalar', value: input, range: new Range(1, 1, 1, input.length + 1), format: 'none' }); assert.deepStrictEqual(actual.items, expected); } test('simple unquoted values', () => { assertCommaSeparatedList('a, b, c', [ - { type: 'string', value: 'a', range: new Range(1, 1, 1, 2) }, - { type: 'string', value: 'b', range: new Range(1, 4, 1, 5) }, - { type: 'string', value: 'c', range: new Range(1, 7, 1, 8) } + { type: 'scalar', value: 'a', range: new Range(1, 1, 1, 2), format: 'none' }, + { type: 'scalar', value: 'b', range: new Range(1, 4, 1, 5), format: 'none' }, + { type: 'scalar', value: 'c', range: new Range(1, 7, 1, 8), format: 'none' } ]); }); test('unquoted values without spaces', () => { assertCommaSeparatedList('foo,bar,baz', [ - { type: 'string', value: 'foo', range: new Range(1, 1, 1, 4) }, - { type: 'string', value: 'bar', range: new Range(1, 5, 1, 8) }, - { type: 'string', value: 'baz', range: new Range(1, 9, 1, 12) } + { type: 'scalar', value: 'foo', range: new Range(1, 1, 1, 4), format: 'none' }, + { type: 'scalar', value: 'bar', range: new Range(1, 5, 1, 8), format: 'none' }, + { type: 'scalar', value: 'baz', range: new Range(1, 9, 1, 12), format: 'none' } ]); }); test('double quoted values', () => { assertCommaSeparatedList('"hello", "world"', [ - { type: 'string', value: 'hello', range: new Range(1, 1, 1, 8) }, - { type: 'string', value: 'world', range: new Range(1, 10, 1, 17) } + { type: 'scalar', value: 'hello', range: new Range(1, 1, 1, 8), format: 'double' }, + { type: 'scalar', value: 'world', range: new Range(1, 10, 1, 17), format: 'double' } ]); }); test('single quoted values', () => { assertCommaSeparatedList(`'one', 'two'`, [ - { type: 'string', value: 'one', range: new Range(1, 1, 1, 6) }, - { type: 'string', value: 'two', range: new Range(1, 8, 1, 13) } + { type: 'scalar', value: 'one', range: new Range(1, 1, 1, 6), format: 'single' }, + { type: 'scalar', value: 'two', range: new Range(1, 8, 1, 13), format: 'single' } ]); }); test('mixed quoted and unquoted values', () => { assertCommaSeparatedList('unquoted, "double", \'single\'', [ - { type: 'string', value: 'unquoted', range: new Range(1, 1, 1, 9) }, - { type: 'string', value: 'double', range: new Range(1, 11, 1, 19) }, - { type: 'string', value: 'single', range: new Range(1, 21, 1, 29) } + { type: 'scalar', value: 'unquoted', range: new Range(1, 1, 1, 9), format: 'none' }, + { type: 'scalar', value: 'double', range: new Range(1, 11, 1, 19), format: 'double' }, + { type: 'scalar', value: 'single', range: new Range(1, 21, 1, 29), format: 'single' } ]); }); test('quoted values with commas inside', () => { assertCommaSeparatedList('"a,b", "c,d"', [ - { type: 'string', value: 'a,b', range: new Range(1, 1, 1, 6) }, - { type: 'string', value: 'c,d', range: new Range(1, 8, 1, 13) } + { type: 'scalar', value: 'a,b', range: new Range(1, 1, 1, 6), format: 'double' }, + { type: 'scalar', value: 'c,d', range: new Range(1, 8, 1, 13), format: 'double' } ]); }); @@ -362,46 +362,46 @@ suite('PromptFileParser', () => { test('single value', () => { assertCommaSeparatedList('single', [ - { type: 'string', value: 'single', range: new Range(1, 1, 1, 7) } + { type: 'scalar', value: 'single', range: new Range(1, 1, 1, 7), format: 'none' } ]); }); test('values with extra whitespace', () => { assertCommaSeparatedList(' a , b , c ', [ - { type: 'string', value: 'a', range: new Range(1, 3, 1, 4) }, - { type: 'string', value: 'b', range: new Range(1, 9, 1, 10) }, - { type: 'string', value: 'c', range: new Range(1, 15, 1, 16) } + { type: 'scalar', value: 'a', range: new Range(1, 3, 1, 4), format: 'none' }, + { type: 'scalar', value: 'b', range: new Range(1, 9, 1, 10), format: 'none' }, + { type: 'scalar', value: 'c', range: new Range(1, 15, 1, 16), format: 'none' } ]); }); test('quoted value with spaces', () => { assertCommaSeparatedList('"hello world", "foo bar"', [ - { type: 'string', value: 'hello world', range: new Range(1, 1, 1, 14) }, - { type: 'string', value: 'foo bar', range: new Range(1, 16, 1, 25) } + { type: 'scalar', value: 'hello world', range: new Range(1, 1, 1, 14), format: 'double' }, + { type: 'scalar', value: 'foo bar', range: new Range(1, 16, 1, 25), format: 'double' } ]); }); test('with position offset', () => { // Simulate parsing a list that starts at line 5, character 10 - const result = parseCommaSeparatedList({ type: 'string', value: 'a, b, c', range: new Range(6, 11, 6, 18) }); + const result = parseCommaSeparatedList({ type: 'scalar', value: 'a, b, c', range: new Range(6, 11, 6, 18), format: 'none' }); assert.deepStrictEqual(result.items, [ - { type: 'string', value: 'a', range: new Range(6, 11, 6, 12) }, - { type: 'string', value: 'b', range: new Range(6, 14, 6, 15) }, - { type: 'string', value: 'c', range: new Range(6, 17, 6, 18) } + { type: 'scalar', value: 'a', range: new Range(6, 11, 6, 12), format: 'none' }, + { type: 'scalar', value: 'b', range: new Range(6, 14, 6, 15), format: 'none' }, + { type: 'scalar', value: 'c', range: new Range(6, 17, 6, 18), format: 'none' } ]); }); test('entire input wrapped in double quotes', () => { // When the entire input is wrapped in quotes, it should be treated as a single quoted value assertCommaSeparatedList('"a, b, c"', [ - { type: 'string', value: 'a, b, c', range: new Range(1, 1, 1, 10) } + { type: 'scalar', value: 'a, b, c', range: new Range(1, 1, 1, 10), format: 'double' } ]); }); test('entire input wrapped in single quotes', () => { // When the entire input is wrapped in single quotes, it should be treated as a single quoted value assertCommaSeparatedList(`'a, b, c'`, [ - { type: 'string', value: 'a, b, c', range: new Range(1, 1, 1, 10) } + { type: 'scalar', value: 'a, b, c', range: new Range(1, 1, 1, 10), format: 'single' } ]); }); 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 2b803e88df2..dd8f81d4dd3 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 @@ -47,6 +47,7 @@ import { InMemoryStorageService, IStorageService } from '../../../../../../../pl import { IPathService } from '../../../../../../services/path/common/pathService.js'; import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; 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'; @@ -153,6 +154,10 @@ suite('PromptsService', () => { } }); + instaService.stub(IRemoteAgentService, { + getEnvironment: () => Promise.resolve(null), + }); + service = disposables.add(instaService.createInstance(PromptsService)); instaService.stub(IPromptsService, service); }); @@ -184,7 +189,7 @@ suite('PromptsService', () => { contents: [ '---', 'description: \'Root prompt description.\'', - 'tools: [\'my-tool1\', , true]', + 'tools: [\'my-tool1\', , tool]', 'agent: "agent" ', '---', '## Files', @@ -239,7 +244,7 @@ suite('PromptsService', () => { contents: [ '---', 'description: "Another file description."', - 'tools: [\'my-tool3\', false, "my-tool2" ]', + 'tools: [\'my-tool3\', "my-tool2" ]', 'applyTo: "**/*.tsx"', '---', `[](${rootFolder}/folder1/some-other-folder)`, @@ -263,7 +268,7 @@ suite('PromptsService', () => { const result1 = await service.parseNew(rootFileUri, CancellationToken.None); assert.deepEqual(result1.uri, rootFileUri); assert.deepEqual(result1.header?.description, 'Root prompt description.'); - assert.deepEqual(result1.header?.tools, ['my-tool1']); + assert.deepEqual(result1.header?.tools, ['my-tool1', 'tool']); assert.deepEqual(result1.header?.agent, 'agent'); assert.ok(result1.body); assert.deepEqual( diff --git a/src/vs/workbench/contrib/debug/common/debugger.ts b/src/vs/workbench/contrib/debug/common/debugger.ts index c734119ad85..2b3e423f822 100644 --- a/src/vs/workbench/contrib/debug/common/debugger.ts +++ b/src/vs/workbench/contrib/debug/common/debugger.ts @@ -118,7 +118,7 @@ export class Debugger implements IDebugger, IDebuggerMetadata { throw new Error(nls.localize('cannot.find.da', "Cannot find debug adapter for type '{0}'.", this.type)); } - async substituteVariables(folder: IWorkspaceFolder | undefined, config: IConfig): Promise { + async substituteVariables(folder: IWorkspaceFolder | undefined, config: IConfig): Promise { const substitutedConfig = await this.adapterManager.substituteVariables(this.type, folder, config); return await this.configurationResolverService.resolveWithInteractionReplace(folder, substitutedConfig, 'launch', this.variables, substitutedConfig.__configurationTarget); } diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css index 2e1cece4685..8daf6dc9427 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css @@ -9,7 +9,7 @@ display: flex; flex-direction: column; margin: 0px auto; - max-width: 90%; + max-width: 95%; } .extension-editor .clickable { diff --git a/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts b/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts index 1edaaa8ada1..832c960a207 100644 --- a/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts +++ b/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts @@ -5,6 +5,7 @@ import * as nls from '../../../../nls.js'; import * as paths from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; import { DEFAULT_TERMINAL_OSX, IExternalTerminalSettings } from '../../../../platform/externalTerminal/common/externalTerminal.js'; import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; @@ -19,6 +20,9 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { TerminalContextKeys } from '../../terminal/common/terminalContextKey.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; const OPEN_NATIVE_CONSOLE_COMMAND_ID = 'workbench.action.terminal.openNativeConsole'; KeybindingsRegistry.registerCommandAndKeybindingRule({ @@ -32,9 +36,30 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ const terminalService = accessor.get(IExternalTerminalService); const configurationService = accessor.get(IConfigurationService); const remoteAuthorityResolverService = accessor.get(IRemoteAuthorityResolverService); - const root = historyService.getLastActiveWorkspaceRoot(); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const quickInputService = accessor.get(IQuickInputService); + const labelService = accessor.get(ILabelService); const config = configurationService.getValue('terminal.external'); + // When there are multiple workspace folders, let the user pick one + const folders = workspaceContextService.getWorkspace().folders; + let root: URI | undefined; + if (folders.length > 1) { + const folderPicks: IQuickPickItem[] = folders.map(folder => ({ + label: folder.name, + description: labelService.getUriLabel(folder.uri, { relative: true }) + })); + const pick = await quickInputService.pick(folderPicks, { + placeHolder: nls.localize('selectWorkspace', "Select workspace folder") + }); + if (!pick) { + return; + } + root = folders[folderPicks.indexOf(pick)].uri; + } else { + root = historyService.getLastActiveWorkspaceRoot(); + } + // It's a local workspace, open the root if (root?.scheme === Schemas.file) { terminalService.openTerminal(config, root.fsPath); diff --git a/src/vs/workbench/contrib/externalTerminal/test/electron-browser/externalTerminal.contribution.test.ts b/src/vs/workbench/contrib/externalTerminal/test/electron-browser/externalTerminal.contribution.test.ts new file mode 100644 index 00000000000..243b1268dc1 --- /dev/null +++ b/src/vs/workbench/contrib/externalTerminal/test/electron-browser/externalTerminal.contribution.test.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IHistoryService } from '../../../../services/history/common/history.js'; +import { IExternalTerminalService } from '../../../../../platform/externalTerminal/electron-browser/externalTerminalService.js'; +import { IExternalTerminalSettings } from '../../../../../platform/externalTerminal/common/externalTerminal.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IRemoteAuthorityResolverService } from '../../../../../platform/remote/common/remoteAuthorityResolver.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import '../../electron-browser/externalTerminal.contribution.js'; + +suite('ExternalTerminal contribution', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let openTerminalCalls: { cwd: string | undefined }[]; + let pickCalls: IQuickPickItem[][]; + + function createWorkspaceFolder(uri: URI, name: string, index: number): IWorkspaceFolder { + return { + uri, + name, + index, + toResource: (relativePath: string) => URI.joinPath(uri, relativePath) + }; + } + + function setupServices(options: { + folders: IWorkspaceFolder[]; + lastActiveRoot?: URI; + lastActiveFile?: URI; + pickedFolder?: IWorkspaceFolder | undefined; + }) { + instantiationService = store.add(new TestInstantiationService()); + + openTerminalCalls = []; + pickCalls = []; + + instantiationService.stub(IHistoryService, new class extends mock() { + override getLastActiveWorkspaceRoot() { + return options.lastActiveRoot; + } + override getLastActiveFile(_schemeFilter: string) { + return options.lastActiveFile; + } + }); + + instantiationService.stub(IExternalTerminalService, new class extends mock() { + override async openTerminal(_config: IExternalTerminalSettings, cwd: string | undefined) { + openTerminalCalls.push({ cwd }); + } + }); + + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + terminal: { external: { linuxExec: 'xterm', osxExec: 'Terminal.app', windowsExec: 'cmd' } } + })); + + instantiationService.stub(IRemoteAuthorityResolverService, new class extends mock() { + }); + + instantiationService.stub(IWorkspaceContextService, new class extends mock() { + override getWorkspace(): IWorkspace { + return { + id: 'test-workspace', + folders: options.folders, + }; + } + }); + + instantiationService.stub(IQuickInputService, new class extends mock() { + override async pick(picks: T[]): Promise { + pickCalls.push(picks); + if (options.pickedFolder) { + const index = options.folders.indexOf(options.pickedFolder); + return picks[index]; + } + return undefined; + } + }); + + instantiationService.stub(ILabelService, new class extends mock() { + override getUriLabel(uri: URI) { + return uri.fsPath; + } + }); + } + + test('single folder - uses last active workspace root', async () => { + const folderUri = URI.file('/workspace/project'); + const folder = createWorkspaceFolder(folderUri, 'project', 0); + + setupServices({ + folders: [folder], + lastActiveRoot: folderUri, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.deepStrictEqual(openTerminalCalls, [{ cwd: folderUri.fsPath }]); + assert.deepStrictEqual(pickCalls, []); + }); + + test('multiple folders - shows picker and opens selected folder', async () => { + const folder1Uri = URI.file('/workspace/project1'); + const folder2Uri = URI.file('/workspace/project2'); + const folder1 = createWorkspaceFolder(folder1Uri, 'project1', 0); + const folder2 = createWorkspaceFolder(folder2Uri, 'project2', 1); + + setupServices({ + folders: [folder1, folder2], + pickedFolder: folder2, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.strictEqual(pickCalls.length, 1); + assert.deepStrictEqual(openTerminalCalls, [{ cwd: folder2Uri.fsPath }]); + }); + + test('multiple folders - picker cancelled does not open terminal', async () => { + const folder1Uri = URI.file('/workspace/project1'); + const folder2Uri = URI.file('/workspace/project2'); + const folder1 = createWorkspaceFolder(folder1Uri, 'project1', 0); + const folder2 = createWorkspaceFolder(folder2Uri, 'project2', 1); + + setupServices({ + folders: [folder1, folder2], + pickedFolder: undefined, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.strictEqual(pickCalls.length, 1); + assert.deepStrictEqual(openTerminalCalls, []); + }); + + test('no workspace root - falls back to active file directory', async () => { + const fileUri = URI.file('/workspace/project/src/file.ts'); + const expectedDir = URI.file('/workspace/project/src').fsPath; + + setupServices({ + folders: [], + lastActiveRoot: undefined, + lastActiveFile: fileUri, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.deepStrictEqual(openTerminalCalls, [{ cwd: expectedDir }]); + }); + + test('no workspace, no file - opens terminal without cwd', async () => { + setupServices({ + folders: [], + lastActiveRoot: undefined, + lastActiveFile: undefined, + }); + + const handler = CommandsRegistry.getCommand('workbench.action.terminal.openNativeConsole')!.handler; + await instantiationService.invokeFunction(handler); + + assert.deepStrictEqual(openTerminalCalls, [{ cwd: undefined }]); + }); +}); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index c3bb43bc90b..ce4653f5cfb 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -93,7 +93,8 @@ MenuRegistry.appendMenuItem(MenuId.InlineChatEditorAffordance, { order: 1, command: { id: ACTION_START, - title: localize('editCode', "Edit Code"), + title: localize('editCode', "Ask for Edits"), + shortTitle: localize('editCodeShort', "Ask for Edits"), icon: Codicon.sparkle, }, when: EditorContextKeys.hasNonEmptySelection, @@ -103,6 +104,7 @@ MenuRegistry.appendMenuItem(MenuId.InlineChatEditorAffordance, { registerAction2(InlineChatActions.StartSessionAction); registerAction2(InlineChatActions.FocusInlineChat); +registerAction2(InlineChatActions.SubmitInlineChatInputAction); const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index f7dc57db292..1b1a6396311 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,7 +11,7 @@ import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diff import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatRunOptions } from './inlineChatController.js'; -import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE } from '../common/inlineChat.js'; +import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE, CTX_INLINE_CHAT_INPUT_HAS_TEXT } from '../common/inlineChat.js'; import { ctxHasEditorModification, ctxHasRequestInProgress } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId } from '../../../../platform/actions/common/actions.js'; @@ -75,10 +75,6 @@ export class StartSessionAction extends Action2 { id: MenuId.ChatTitleBarMenu, group: 'a_open', order: 3, - }, { - id: MenuId.ChatEditorInlineGutter, - group: '1_chat', - order: 1, }] }); } @@ -330,3 +326,28 @@ export class UndoAndCloseSessionAction2 extends KeepOrUndoSessionAction { }); } } + +export class SubmitInlineChatInputAction extends AbstractInlineChatAction { + + constructor() { + super({ + id: 'inlineChat.submitInput', + title: localize2('submitInput', "Send"), + icon: Codicon.send, + precondition: CTX_INLINE_CHAT_INPUT_HAS_TEXT, + menu: [{ + id: MenuId.InlineChatInput, + group: '0_main', + order: 1, + }] + }); + } + + override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { + const value = ctrl.inputWidget.value; + if (value) { + ctrl.inputWidget.hide(); + ctrl.run({ message: value, autoSend: true }); + } + } +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts index 58ecc761706..ea0933b7e44 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts @@ -23,15 +23,18 @@ import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { generateUuid } from '../../../../base/common/uuid.js'; +import { Event } from '../../../../base/common/event.js'; type InlineChatAffordanceEvent = { mode: string; id: string; + commandId: string; }; type InlineChatAffordanceClassification = { mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The affordance mode: gutter or editor.' }; id: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'UUID to correlate shown and selected events.' }; + commandId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The command that was executed.' }; owner: 'jrieken'; comment: 'Tracks when the inline chat affordance is shown or selected.'; }; @@ -83,7 +86,7 @@ export class InlineChatAffordance extends Disposable { affordanceId = generateUuid(); const mode = affordance.read(undefined); if (mode === 'gutter' || mode === 'editor') { - telemetryService.publicLog2('inlineChatAffordance/shown', { mode, id: affordanceId }); + telemetryService.publicLog2('inlineChatAffordance/shown', { mode, id: affordanceId, commandId: '' }); } selectionData.set(value, undefined); })); @@ -106,7 +109,12 @@ export class InlineChatAffordance extends Disposable { } })); - this._store.add(this.#instantiationService.createInstance( + // Hide when the editor context menu shows + this._store.add(this.#editor.onContextMenu(() => { + selectionData.set(undefined, undefined); + })); + + const gutterAffordance = this._store.add(this.#instantiationService.createInstance( InlineChatGutterAffordance, editorObs, derived(r => affordance.read(r) === 'gutter' ? selectionData.read(r) : undefined), @@ -119,17 +127,19 @@ export class InlineChatAffordance extends Disposable { derived(r => affordance.read(r) === 'editor' ? selectionData.read(r) : undefined) ); this._store.add(editorAffordance); - this._store.add(editorAffordance.onDidRunAction(() => { + + this._store.add(Event.any(editorAffordance.onDidRunAction, gutterAffordance.onDidRunAction)(commandId => { if (affordanceId) { - telemetryService.publicLog2('inlineChatAffordance/selected', { mode: 'editor', id: affordanceId }); + telemetryService.publicLog2('inlineChatAffordance/selected', { mode: affordance.get(), id: affordanceId, commandId }); } })); this._store.add(autorun(r => { - const isEditor = affordance.read(r) === 'editor'; + const mode = affordance.read(r); + const hideWithSelection = mode === 'editor' || mode === 'gutter'; const controller = CodeActionController.get(this.#editor); if (controller) { - controller.onlyLightBulbWithEmptySelection = isEditor; + controller.onlyLightBulbWithEmptySelection = hideWithSelection; } })); @@ -139,10 +149,6 @@ export class InlineChatAffordance extends Disposable { return; } - if (affordanceId) { - telemetryService.publicLog2('inlineChatAffordance/selected', { mode: 'gutter', id: affordanceId }); - } - // Reveal the line in case it's outside the viewport (e.g., when triggered from sticky scroll) this.#editor.revealLineInCenterIfOutsideViewport(data.lineNumber, ScrollType.Immediate); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 1790933cbb8..79335bf93cd 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -118,6 +118,7 @@ export class InlineChatController implements IEditorContribution { private readonly _renderMode: IObservable<'zone' | 'hover'>; private readonly _zone: Lazy; private readonly _gutterIndicator: InlineChatAffordance; + private readonly _inputWidget: InlineChatInputWidget; private readonly _currentSession: IObservable; @@ -129,6 +130,10 @@ export class InlineChatController implements IEditorContribution { return Boolean(this._currentSession.get()); } + get inputWidget(): InlineChatInputWidget { + return this._inputWidget; + } + constructor( private readonly _editor: ICodeEditor, @IInstantiationService private readonly _instaService: IInstantiationService, @@ -152,7 +157,7 @@ export class InlineChatController implements IEditorContribution { const notebookAgentConfig = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, this._configurationService); this._renderMode = observableConfigValue(InlineChatConfigKeys.RenderMode, 'zone', this._configurationService); - const overlayWidget = this._store.add(this._instaService.createInstance(InlineChatInputWidget, editorObs)); + const overlayWidget = this._inputWidget = this._store.add(this._instaService.createInstance(InlineChatInputWidget, editorObs)); const sessionOverlayWidget = this._store.add(this._instaService.createInstance(InlineChatSessionOverlayWidget, editorObs)); this._gutterIndicator = this._store.add(this._instaService.createInstance(InlineChatAffordance, this._editor, overlayWidget)); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts index 0aacdb12572..519ae158e7f 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts @@ -147,8 +147,8 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi private _position: IContentWidgetPosition | null = null; private _isVisible = false; - private readonly _onDidRunAction = this._store.add(new Emitter()); - readonly onDidRunAction: Event = this._onDidRunAction.event; + private readonly _onDidRunAction = this._store.add(new Emitter()); + readonly onDidRunAction: Event = this._onDidRunAction.event; readonly allowEditorOverflow = false; readonly suppressMouseDown = false; @@ -179,7 +179,10 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi return undefined; } })); - this._store.add(toolbar.actionRunner.onDidRun(() => this._onDidRunAction.fire())); + this._store.add(toolbar.actionRunner.onDidRun((e) => { + this._onDidRunAction.fire(e.action.id); + this._hide(); + })); this._store.add(autorun(r => { const sel = selection.read(r); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts index 26c3a60a339..19d67a29e95 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts @@ -4,41 +4,73 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { autorun, constObservable, derived, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { autorun, constObservable, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { ObservableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; import { LineRange } from '../../../../editor/common/core/ranges/lineRange.js'; import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; +import { InlineCompletionCommand } from '../../../../editor/common/languages.js'; +import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; import { InlineEditsGutterIndicator, InlineEditsGutterIndicatorData, InlineSuggestionGutterMenuData, SimpleInlineSuggestModel } from '../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.js'; import { InlineEditTabAction } from '../../../../editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.js'; -import { localize } from '../../../../nls.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { HoverService } from '../../../../platform/hover/browser/hoverService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IUserInteractionService } from '../../../../platform/userInteraction/browser/userInteractionService.js'; -import { ACTION_START } from '../common/inlineChat.js'; export class InlineChatGutterAffordance extends InlineEditsGutterIndicator { + private readonly _onDidRunAction = this._store.add(new Emitter()); + readonly onDidRunAction: Event = this._onDidRunAction.event; + constructor( private readonly _myEditorObs: ObservableCodeEditor, selection: IObservable, private readonly _hover: ISettableObservable<{ rect: DOMRect; above: boolean; lineNumber: number } | undefined>, - @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IKeybindingService _keybindingService: IKeybindingService, @IHoverService hoverService: HoverService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService accessibilityService: IAccessibilityService, @IThemeService themeService: IThemeService, @IUserInteractionService userInteractionService: IUserInteractionService, + @IMenuService menuService: IMenuService, + @IContextKeyService contextKeyService: IContextKeyService, ) { + + const menu = menuService.createMenu(MenuId.InlineChatEditorAffordance, contextKeyService); + const menuObs = observableFromEvent(menu.onDidChange, () => menu.getActions({ renderShortTitle: false })); + + const codeActionController = CodeActionController.get(_myEditorObs.editor); + const lightBulbObs = codeActionController?.lightBulbState; + const data = derived(r => { const value = selection.read(r); if (!value) { return undefined; } + const commandGroups: InlineCompletionCommand[][] = []; + for (const [, groupActions] of menuObs.read(r)) { + const group: InlineCompletionCommand[] = []; + for (const action of groupActions) { + if (action instanceof MenuItemAction) { + group.push({ + command: { id: action.item.id, title: action.label }, + icon: ThemeIcon.isThemeIcon(action.item.icon) ? action.item.icon : undefined + }); + } + } + if (group.length > 0) { + commandGroups.push(group); + } + } + // Use the cursor position (active end of selection) to determine the line const cursorPosition = value.getPosition(); const lineRange = new LineRange(cursorPosition.lineNumber, cursorPosition.lineNumber + 1); @@ -47,20 +79,23 @@ export class InlineChatGutterAffordance extends InlineEditsGutterIndicator { const gutterMenuData = new InlineSuggestionGutterMenuData( undefined, // action '', // displayName - [], // extensionCommands + commandGroups, // extensionCommands undefined, // alternativeAction undefined, // modelInfo undefined, // setModelId + true, // extensionCommandsOnly ); + // Use lightbulb icon/color when code actions are available, otherwise sparkle + const lightBulbInfo = lightBulbObs?.read(r); + const icon = lightBulbInfo ? lightBulbInfo.icon : Codicon.sparkle; + return new InlineEditsGutterIndicatorData( gutterMenuData, lineRange, new SimpleInlineSuggestModel(() => { }, () => this._doShowHover()), undefined, // altAction - { - icon: Codicon.sparkle, - } + { icon } ); }); @@ -71,18 +106,14 @@ export class InlineChatGutterAffordance extends InlineEditsGutterIndicator { hoverService, instantiationService, accessibilityService, themeService, userInteractionService ); + this._store.add(menu); + this._store.add(autorun(r => { const element = _hover.read(r); this._hoverVisible.set(!!element, undefined); })); - } - protected override _showHover(): void { - this._hoverService.showInstantHover({ - target: this._iconRef.element, - content: this._keybindingService.appendKeybinding(localize('inlineChatGutterHover', "Inline Chat"), ACTION_START), - // appearance: { showPointer: true } - }); + this._store.add(this.onDidCloseWithCommand(commandId => this._onDidRunAction.fire(commandId))); } private _doShowHover(): void { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 0284206c2bf..844b7dae003 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -5,10 +5,11 @@ import './media/inlineChatOverlayWidget.css'; import * as dom from '../../../../base/browser/dom.js'; -import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; +import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; -import { IAction, Separator } from '../../../../base/common/actions.js'; +import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -23,20 +24,19 @@ import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/b import { IModelService } from '../../../../editor/common/services/model.js'; import { localize } from '../../../../nls.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ChatEditingAcceptRejectActionViewItem } from '../../chat/browser/chatEditing/chatEditingEditorOverlay.js'; -import { ACTION_START } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_INPUT_HAS_TEXT } from '../common/inlineChat.js'; import { StickyScrollController } from '../../../../editor/contrib/stickyScroll/browser/stickyScrollController.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOptions.js'; import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; -import { InlineChatRunOptions } from './inlineChatController.js'; import { IInlineChatSession2 } from './inlineChatSessionService.js'; -import { Position } from '../../../../editor/common/core/position.js'; import { CancelChatActionId } from '../../chat/browser/actions/chatExecuteActions.js'; import { assertType } from '../../../../base/common/types.js'; @@ -46,8 +46,9 @@ import { assertType } from '../../../../base/common/types.js'; export class InlineChatInputWidget extends Disposable { private readonly _domNode: HTMLElement; + private readonly _container: HTMLElement; private readonly _inputContainer: HTMLElement; - private readonly _actionBar: ActionBar; + private readonly _toolbarContainer: HTMLElement; private readonly _input: IActiveCodeEditor; private readonly _position = observableValue(this, null); readonly position: IObservable = this._position; @@ -55,7 +56,7 @@ export class InlineChatInputWidget extends Disposable { private readonly _showStore = this._store.add(new DisposableStore()); private readonly _stickyScrollHeight: IObservable; - private _inlineStartAction: IAction | undefined; + private readonly _layoutData: IObservable<{ totalWidth: number; toolbarWidth: number; height: number; editorPad: number }>; private _anchorLineNumber: number = 0; private _anchorLeft: number = 0; private _anchorAbove: boolean = false; @@ -63,9 +64,9 @@ export class InlineChatInputWidget extends Disposable { constructor( private readonly _editorObs: ObservableCodeEditor, - @IKeybindingService private readonly _keybindingService: IKeybindingService, - @IMenuService private readonly _menuService: IMenuService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @ICommandService private readonly _commandService: ICommandService, + @IMenuService private readonly _menuService: IMenuService, @IInstantiationService instantiationService: IInstantiationService, @IModelService modelService: IModelService, @IConfigurationService configurationService: IConfigurationService, @@ -75,25 +76,48 @@ export class InlineChatInputWidget extends Disposable { // Create container this._domNode = dom.$('.inline-chat-gutter-menu'); + // Create inner container (background + focus border) + this._container = dom.append(this._domNode, dom.$('.inline-chat-gutter-container')); + // Create input editor container - this._inputContainer = dom.append(this._domNode, dom.$('.input')); - this._inputContainer.style.width = '200px'; - this._inputContainer.style.height = '26px'; - this._inputContainer.style.display = 'flex'; - this._inputContainer.style.alignItems = 'center'; - this._inputContainer.style.justifyContent = 'center'; + this._inputContainer = dom.append(this._container, dom.$('.input')); + + // Create toolbar container + this._toolbarContainer = dom.append(this._container, dom.$('.toolbar')); + + // Create vertical actions bar below the input container + const actionsContainer = dom.append(this._domNode, dom.$('.inline-chat-gutter-actions')); + const actionBar = this._store.add(new ActionBar(actionsContainer, { + orientation: ActionsOrientation.VERTICAL, + preventLoopNavigation: true, + })); + const actionsMenu = this._store.add(this._menuService.createMenu(MenuId.ChatEditorInlineMenu, this._contextKeyService)); + const updateActions = () => { + const actions = getFlatActionBarActions(actionsMenu.getActions({ shouldForwardArgs: true })); + actionBar.clear(); + actionBar.push(actions); + dom.setVisibility(actions.length > 0, actionsContainer); + }; + this._store.add(actionsMenu.onDidChange(updateActions)); + updateActions(); // Create editor options const options = getSimpleEditorOptions(configurationService); - options.wordWrap = 'on'; + options.wordWrap = 'off'; + options.wrappingStrategy = 'advanced'; options.lineNumbers = 'off'; options.glyphMargin = false; options.lineDecorationsWidth = 0; options.lineNumbersMinChars = 0; options.folding = false; options.minimap = { enabled: false }; - options.scrollbar = { vertical: 'auto', horizontal: 'hidden', alwaysConsumeMouseWheel: true, verticalSliderSize: 6 }; + options.scrollbar = { vertical: 'hidden', horizontal: 'hidden', alwaysConsumeMouseWheel: true }; options.renderLineHighlight = 'none'; + options.fontFamily = DEFAULT_FONT_FAMILY; + options.fontSize = 13; + options.lineHeight = 20; + options.cursorWidth = 1; + options.padding = { top: 2, bottom: 2 }; const codeEditorWidgetOptions: ICodeEditorWidgetOptions = { isSimpleWidget: true, @@ -107,86 +131,149 @@ export class InlineChatInputWidget extends Disposable { const model = this._store.add(modelService.createModel('', null, URI.parse(`gutter-input:${Date.now()}`), true)); this._input.setModel(model); + // Create toolbar + const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._toolbarContainer, MenuId.InlineChatInput, { + telemetrySource: 'inlineChatInput.toolbar', + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { + primaryGroup: () => true, + }, + menuOptions: { shouldForwardArgs: true }, + })); + // Initialize sticky scroll height observable const stickyScrollController = StickyScrollController.get(this._editorObs.editor); this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); + // Track toolbar width changes + const toolbarWidth = observableValue(this, 0); + const resizeObserver = new dom.DisposableResizeObserver(() => { + toolbarWidth.set(dom.getTotalWidth(toolbar.getElement()), undefined); + }); + this._store.add(resizeObserver); + this._store.add(resizeObserver.observe(toolbar.getElement())); + + // Compute min and max widget width based on editor content width + const maxWidgetWidth = derived(r => { + const layoutInfo = this._editorObs.layoutInfo.read(r); + return Math.max(0, Math.round(layoutInfo.contentWidth * 0.70)); + }); + const minWidgetWidth = derived(r => { + const layoutInfo = this._editorObs.layoutInfo.read(r); + return Math.max(0, Math.round(layoutInfo.contentWidth * 0.33)); + }); + + const contentWidth = observableFromEvent(this, this._input.onDidChangeModelContent, () => this._input.getContentWidth()); + const contentHeight = observableFromEvent(this, this._input.onDidContentSizeChange, () => this._input.getContentHeight()); + + this._layoutData = derived(r => { + const editorPad = 6; + const totalWidth = contentWidth.read(r) + editorPad + toolbarWidth.read(r); + const minWidth = minWidgetWidth.read(r); + const maxWidth = maxWidgetWidth.read(r); + const clampedWidth = this._input.getOption(EditorOption.wordWrap) === 'on' + ? maxWidth + : Math.max(minWidth, Math.min(totalWidth, maxWidth)); + + const lineHeight = this._input.getOption(EditorOption.lineHeight); + const clampedHeight = Math.min(contentHeight.read(r), (3 * lineHeight)); + + if (totalWidth > clampedWidth) { + // enable word wrap + this._input.updateOptions({ wordWrap: 'on', }); + } + + return { + editorPad, + toolbarWidth: toolbarWidth.read(r), + totalWidth: clampedWidth, + height: clampedHeight + }; + }); + + // Update container width and editor layout when width changes + this._store.add(autorun(r => { + const { editorPad, toolbarWidth, totalWidth, height } = this._layoutData.read(r); + + const inputWidth = totalWidth - toolbarWidth - editorPad; + this._container.style.width = `${totalWidth}px`; + this._inputContainer.style.width = `${inputWidth}px`; + this._input.layout({ width: inputWidth, height }); + })); + + // Toggle focus class on the container + this._store.add(this._input.onDidFocusEditorText(() => this._container.classList.add('focused'))); + this._store.add(this._input.onDidBlurEditorText(() => this._container.classList.remove('focused'))); + + // Toggle scroll decoration on the toolbar + this._store.add(this._input.onDidScrollChange(e => { + this._toolbarContainer.classList.toggle('fake-scroll-decoration', e.scrollTop > 0); + })); + // Update placeholder based on selection state this._store.add(autorun(r => { const selection = this._editorObs.cursorSelection.read(r); const hasSelection = selection && !selection.isEmpty(); const placeholderText = hasSelection - ? localize('placeholderWithSelection', "Modify selected code") - : localize('placeholderNoSelection', "Generate code"); + ? localize('placeholderWithSelection', "Describe how to change this") + : localize('placeholderNoSelection', "Describe what to generate"); - this._input.updateOptions({ placeholder: this._keybindingService.appendKeybinding(placeholderText, ACTION_START) }); + this._input.updateOptions({ placeholder: placeholderText }); })); - // Listen to content size changes and resize the input editor (max 3 lines) - this._store.add(this._input.onDidContentSizeChange(e => { - if (e.contentHeightChanged) { - this._updateInputHeight(e.contentHeight); - } - })); - // Handle Enter key to submit and ArrowDown to focus action bar + // Track input text for context key and adjust width based on content + const inputHasText = CTX_INLINE_CHAT_INPUT_HAS_TEXT.bindTo(this._contextKeyService); + this._store.add(this._input.onDidChangeModelContent(() => { + inputHasText.set(this._input.getModel().getValue().trim().length > 0); + })); + this._store.add(toDisposable(() => inputHasText.reset())); + + // Handle Enter key to submit, ArrowDown to move to actions this._store.add(this._input.onKeyDown(e => { if (e.keyCode === KeyCode.Enter && !e.shiftKey) { - const value = this._input.getModel().getValue() ?? ''; - // TODO@jrieken this isn't nice - if (this._inlineStartAction && value) { - e.preventDefault(); - e.stopPropagation(); - this._actionBar.actionRunner.run( - this._inlineStartAction, - { message: value, autoSend: true } satisfies InlineChatRunOptions - ); - } + e.preventDefault(); + e.stopPropagation(); + this._commandService.executeCommand('inlineChat.submitInput'); } else if (e.keyCode === KeyCode.Escape) { // Hide overlay if input is empty const value = this._input.getModel().getValue() ?? ''; if (!value) { e.preventDefault(); e.stopPropagation(); - this._hide(); + this.hide(); } - } else if (e.keyCode === KeyCode.DownArrow) { - // Focus first action bar item when at the end of the input - const inputModel = this._input.getModel(); + } else if (e.keyCode === KeyCode.DownArrow && !actionBar.isEmpty()) { + const model = this._input.getModel(); const position = this._input.getPosition(); - const lastLineNumber = inputModel.getLineCount(); - const lastLineMaxColumn = inputModel.getLineMaxColumn(lastLineNumber); - if (Position.equals(position, new Position(lastLineNumber, lastLineMaxColumn))) { + if (position && position.lineNumber === model.getLineCount()) { e.preventDefault(); e.stopPropagation(); - this._actionBar.focus(); + actionBar.focus(0); } } })); - // Create vertical action bar - this._actionBar = this._store.add(new ActionBar(this._domNode, { - orientation: ActionsOrientation.VERTICAL, - preventLoopNavigation: true, - })); - - // Handle ArrowUp on first action bar item to focus input editor - this._store.add(dom.addDisposableListener(this._actionBar.domNode, 'keydown', e => { + // ArrowUp on first action bar item moves focus back to input editor + this._store.add(dom.addDisposableListener(actionBar.domNode, 'keydown', (e: KeyboardEvent) => { const event = new StandardKeyboardEvent(e); - if (event.equals(KeyCode.UpArrow) && this._actionBar.isFocused(this._actionBar.viewItems.findIndex(item => item.action.id !== Separator.ID))) { - event.preventDefault(); - event.stopPropagation(); - this._input.focus(); + if (event.keyCode === KeyCode.UpArrow) { + const firstItem = actionBar.viewItems[0] as BaseActionViewItem | undefined; + if (firstItem?.element && dom.isAncestorOfActiveElement(firstItem.element)) { + event.preventDefault(); + event.stopPropagation(); + this._input.focus(); + } } }, true)); // Track focus - hide when focus leaves const focusTracker = this._store.add(dom.trackFocus(this._domNode)); - this._store.add(focusTracker.onDidBlur(() => this._hide())); + this._store.add(focusTracker.onDidBlur(() => this.hide())); + } - // Handle action bar cancel (Escape key) - this._store.add(this._actionBar.onDidCancel(() => this._hide())); - this._store.add(this._actionBar.onWillRun(() => this._hide())); + get value(): string { + return this._input.getModel().getValue().trim(); } /** @@ -199,11 +286,8 @@ export class InlineChatInputWidget extends Disposable { this._showStore.clear(); // Clear input state + this._input.updateOptions({ wordWrap: 'off' }); this._input.getModel().setValue(''); - this._updateInputHeight(this._input.getContentHeight()); - - // Refresh actions from menu - this._refreshActions(); // Store anchor info for scroll updates this._anchorLineNumber = lineNumber; @@ -234,7 +318,7 @@ export class InlineChatInputWidget extends Disposable { ); const hasContent = !!this._input.getModel().getValue(); if (!isLineVisible && !hasContent) { - this._hide(); + this.hide(); } else { this._updatePosition(); } @@ -277,44 +361,16 @@ export class InlineChatInputWidget extends Disposable { /** * Hide the widget (removes from editor but does not dispose). */ - private _hide(): void { + hide(): void { // Focus editor if focus is still within the editor's DOM const editorDomNode = this._editorObs.editor.getDomNode(); if (editorDomNode && dom.isAncestorOfActiveElement(editorDomNode)) { this._editorObs.editor.focus(); } this._position.set(null, undefined); + this._input.getModel().setValue(''); this._showStore.clear(); } - - private _refreshActions(): void { - // Clear existing actions - this._actionBar.clear(); - this._inlineStartAction = undefined; - - // Get fresh actions from menu - const actions = getFlatActionBarActions(this._menuService.getMenuActions(MenuId.ChatEditorInlineGutter, this._contextKeyService, { shouldForwardArgs: true })); - - // Set actions with keybindings (skip ACTION_START since we have the input editor) - for (const action of actions) { - if (action.id === ACTION_START) { - this._inlineStartAction = action; - continue; - } - const keybinding = this._keybindingService.lookupKeybinding(action.id)?.getLabel(); - this._actionBar.push(action, { icon: false, label: true, keybinding }); - } - } - - private _updateInputHeight(contentHeight: number): void { - const lineHeight = this._input.getOption(EditorOption.lineHeight); - const maxHeight = 3 * lineHeight; - const clampedHeight = Math.min(contentHeight, maxHeight); - const containerPadding = 8; - - this._inputContainer.style.height = `${clampedHeight + containerPadding}px`; - this._input.layout({ width: 200, height: clampedHeight }); - } } /** @@ -452,7 +508,7 @@ export class InlineChatSessionOverlayWidget extends Disposable { return undefined; // use default action view item with label } - return new ChatEditingAcceptRejectActionViewItem(action, options, entry, undefined, that._keybindingService, primaryActions); + return new ChatEditingAcceptRejectActionViewItem(action, { ...options, keybinding: undefined }, entry, undefined, that._keybindingService, primaryActions); } })); diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css index 2215f0f84dd..da1e670cf8b 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css @@ -321,45 +321,3 @@ .monaco-workbench .inline-chat .chat-attached-context { padding: 2px 0px; } - -/* Gutter menu overlay widget */ -.inline-chat-gutter-menu { - background: var(--vscode-menu-background); - border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border)); - border-radius: 4px; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); - padding: 4px 0; - min-width: 160px; - z-index: 10000; -} - -.inline-chat-gutter-menu .input { - padding: 0 8px; -} - -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item { - display: flex; - justify-content: space-between; - border-radius: 3px; - margin: 0 4px; -} - -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item .action-label { - font-size: 13px; - width: 100%; -} - -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):hover, -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):focus-within { - background-color: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); - outline: 1px solid var(--vscode-menu-selectionBorder, transparent); - outline-offset: -1px; -} - -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):hover .action-label, -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):focus-within .action-label { - color: var(--vscode-list-activeSelectionForeground); - outline: 1px solid var(--vscode-menu-selectionBorder, transparent); - outline-offset: -1px; -} diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css index 14913c7eb45..b50a69f8752 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ .inline-chat-content-widget { - background-color: var(--vscode-editor-background); - padding: 2px; + background-color: var(--vscode-panel-background); + padding: 0 1px; border-radius: 8px; display: flex; align-items: center; @@ -14,21 +14,27 @@ min-width: var(--vscode-inline-chat-affordance-height); min-height: var(--vscode-inline-chat-affordance-height); line-height: var(--vscode-inline-chat-affordance-height); + border: 1px solid var(--vscode-input-border, transparent); } .inline-chat-content-widget .action-label.codicon.codicon-light-bulb, -.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-sparkle { +.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-sparkle, +.inline-edits-view-gutter-indicator .codicon.codicon-light-bulb, +.inline-edits-view-gutter-indicator .codicon.codicon-lightbulb-sparkle { margin: 0; color: var(--vscode-editorLightBulb-foreground); } .inline-chat-content-widget .action-label.codicon.codicon-lightbulb-autofix, -.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-sparkle-autofix { +.inline-chat-content-widget .action-label.codicon.codicon-lightbulb-sparkle-autofix, +.inline-edits-view-gutter-indicator .codicon.codicon-lightbulb-autofix, +.inline-edits-view-gutter-indicator .codicon.codicon-lightbulb-sparkle-autofix { margin: 0; color: var(--vscode-editorLightBulbAutoFix-foreground, var(--vscode-editorLightBulb-foreground)); } -.inline-chat-content-widget .action-label.codicon.codicon-sparkle-filled { +.inline-chat-content-widget .action-label.codicon.codicon-sparkle-filled, +.inline-edits-view-gutter-indicator .codicon.codicon-sparkle-filled { margin: 0; color: var(--vscode-editorLightBulbAi-foreground, var(--vscode-icon-foreground)); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css index 1af3ff339a1..294867b3056 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css @@ -3,12 +3,93 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + +/* Gutter menu overlay widget */ +.inline-chat-gutter-menu { + background: var(--vscode-panel-background); + border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border)); + border-radius: 8px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + z-index: 10000; +} + +.inline-chat-gutter-menu .input { + padding: 0 3px; +} + +.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item { + display: flex; + justify-content: space-between; + border-radius: 3px; + margin: 0 4px; +} + +.inline-chat-gutter-menu .inline-chat-gutter-actions { + padding-bottom: 2px; +} + +.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item .action-label { + font-size: 13px; + width: 100%; +} + +.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):hover, +.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):focus-within { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + outline: 1px solid var(--vscode-menu-selectionBorder, transparent); + outline-offset: -1px; +} + +.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):hover .action-label, +.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):focus-within .action-label { + color: var(--vscode-list-activeSelectionForeground); + outline: 1px solid var(--vscode-menu-selectionBorder, transparent); + outline-offset: -1px; +} + + .inline-chat-gutter-menu.clamped { transition: top 100ms; } -.inline-chat-gutter-menu .input .monaco-editor-background { - background-color: var(--vscode-menu-background); +.inline-chat-gutter-menu .inline-chat-gutter-container { + box-sizing: border-box; + display: flex; + align-items: center; + margin: 6px 4px 4px 4px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + overflow: hidden; +} + +.inline-chat-gutter-menu .inline-chat-gutter-container.focused { + border-color: var(--vscode-focusBorder); +} + +.inline-chat-gutter-menu .inline-chat-gutter-container > .input { + flex: 1; + min-width: 0; +} + +.inline-chat-gutter-menu .inline-chat-gutter-container > .input .monaco-editor-background { + background-color: var(--vscode-input-background); +} + +.inline-chat-gutter-menu .inline-chat-gutter-container > .toolbar { + display: flex; + align-items: center; + align-self: stretch; + padding: 0 4px; +} + +.inline-chat-gutter-menu .inline-chat-gutter-container > .toolbar.fake-scroll-decoration { + box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset; +} + +.inline-chat-gutter-menu .inline-chat-gutter-container > .toolbar .monaco-action-bar .actions-container { + gap: 2px; } .inline-chat-session-overlay-widget { diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index e8866c447f3..99591004f82 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -108,6 +108,7 @@ export const CTX_INLINE_CHAT_FOCUSED = new RawContextKey('inlineChatFoc export const CTX_INLINE_CHAT_EDITING = new RawContextKey('inlineChatEditing', true, localize('inlineChatEditing', "Whether the user is currently editing or generating code in the inline chat")); export const CTX_INLINE_CHAT_RESPONSE_FOCUSED = new RawContextKey('inlineChatResponseFocused', false, localize('inlineChatResponseFocused', "Whether the interactive widget's response is focused")); export const CTX_INLINE_CHAT_EMPTY = new RawContextKey('inlineChatEmpty', false, localize('inlineChatEmpty', "Whether the interactive editor input is empty")); +export const CTX_INLINE_CHAT_INPUT_HAS_TEXT = new RawContextKey('inlineChatInputHasText', false, localize('inlineChatInputHasText', "Whether the inline chat input widget has text")); export const CTX_INLINE_CHAT_INNER_CURSOR_FIRST = new RawContextKey('inlineChatInnerCursorFirst', false, localize('inlineChatInnerCursorFirst', "Whether the cursor of the iteractive editor input is on the first line")); export const CTX_INLINE_CHAT_INNER_CURSOR_LAST = new RawContextKey('inlineChatInnerCursorLast', false, localize('inlineChatInnerCursorLast', "Whether the cursor of the iteractive editor input is on the last line")); export const CTX_INLINE_CHAT_OUTER_CURSOR_POSITION = new RawContextKey<'above' | 'below' | ''>('inlineChatOuterCursorPosition', '', localize('inlineChatOuterCursorPosition', "Whether the cursor of the outer editor is above or below the interactive editor input")); diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 82bc7998af4..2cf3ac03c1e 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -880,7 +880,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { let promise: Promise | undefined = undefined; if (task.configurationProperties.isBackground) { const problemMatchers = await this._resolveMatchers(resolver, task.configurationProperties.problemMatchers); - const watchingProblemMatcher = new WatchingProblemCollector(problemMatchers, this._markerService, this._modelService, this._fileService); + const watchingProblemMatcher = new WatchingProblemCollector(problemMatchers, this._markerService, this._modelService, this._fileService, this._logService); if ((problemMatchers.length > 0) && !watchingProblemMatcher.isWatching()) { this._appendOutput(nls.localize('TerminalTaskSystem.nonWatchingMatcher', 'Task {0} is a background task but uses a problem matcher without a background pattern', task._label)); this._showOutput(); @@ -1047,7 +1047,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { this._fireTaskEvent(TaskEvent.general(TaskEventKind.Active, task, terminal.instanceId)); const problemMatchers = await this._resolveMatchers(resolver, task.configurationProperties.problemMatchers); - const startStopProblemMatcher = new StartStopProblemCollector(problemMatchers, this._markerService, this._modelService, ProblemHandlingStrategy.Clean, this._fileService); + const startStopProblemMatcher = new StartStopProblemCollector(problemMatchers, this._markerService, this._modelService, ProblemHandlingStrategy.Clean, this._fileService, this._logService); this._terminalStatusManager.addTerminal(task, terminal, startStopProblemMatcher); this._taskProblemMonitor.addTerminal(terminal, startStopProblemMatcher); const problemMatcherListener = startStopProblemMatcher.onDidStateChange((event) => { diff --git a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts index 892a948cb65..8ede878caca 100644 --- a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts +++ b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts @@ -15,6 +15,7 @@ import { IMarkerService, IMarkerData, MarkerSeverity, IMarker } from '../../../. import { generateUuid } from '../../../../base/common/uuid.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { isWindows } from '../../../../base/common/platform.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export const enum ProblemCollectorEventKind { BackgroundProcessingBegins = 'backgroundProcessingBegins', @@ -67,11 +68,11 @@ export abstract class AbstractProblemCollector extends Disposable implements IDi protected readonly _onDidRequestInvalidateLastMarker = this._register(new Emitter()); readonly onDidRequestInvalidateLastMarker = this._onDidRequestInvalidateLastMarker.event; - constructor(public readonly problemMatchers: ProblemMatcher[], protected markerService: IMarkerService, protected modelService: IModelService, fileService?: IFileService) { + constructor(public readonly problemMatchers: ProblemMatcher[], protected markerService: IMarkerService, protected modelService: IModelService, fileService?: IFileService, protected readonly logService?: ILogService) { super(); this.matchers = Object.create(null); this.bufferLength = 1; - problemMatchers.map(elem => createLineMatcher(elem, fileService)).forEach((matcher) => { + problemMatchers.map(elem => createLineMatcher(elem, fileService, logService)).forEach((matcher) => { const length = matcher.matchLength; if (length > this.bufferLength) { this.bufferLength = length; @@ -364,8 +365,8 @@ export class StartStopProblemCollector extends AbstractProblemCollector implemen private _hasStarted: boolean = false; - constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, _strategy: ProblemHandlingStrategy = ProblemHandlingStrategy.Clean, fileService?: IFileService) { - super(problemMatchers, markerService, modelService, fileService); + constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, _strategy: ProblemHandlingStrategy = ProblemHandlingStrategy.Clean, fileService?: IFileService, logService?: ILogService) { + super(problemMatchers, markerService, modelService, fileService, logService); const ownerSet: { [key: string]: boolean } = Object.create(null); problemMatchers.forEach(description => ownerSet[description.owner] = true); this.owners = Object.keys(ownerSet); @@ -422,8 +423,8 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private lines: string[] = []; public beginPatterns: RegExp[] = []; - constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, fileService?: IFileService) { - super(problemMatchers, markerService, modelService, fileService); + constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, fileService?: IFileService, logService?: ILogService) { + super(problemMatchers, markerService, modelService, fileService, logService); this.resetCurrentResource(); this.backgroundPatterns = []; this._activeBackgroundMatchers = new Set(); @@ -514,7 +515,12 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private async tryBegin(line: string): Promise { let result = false; for (const background of this.backgroundPatterns) { + const start = Date.now(); const matches = background.begin.regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow begin regexp took ${elapsed}ms to execute`, background.begin.regexp.source); + } if (matches) { if (this._activeBackgroundMatchers.has(background.key)) { continue; @@ -543,7 +549,12 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private tryFinish(line: string): boolean { let result = false; for (const background of this.backgroundPatterns) { + const start = Date.now(); const matches = background.end.regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow end regexp took ${elapsed}ms to execute`, background.end.regexp.source); + } if (matches) { if (this._numberOfMatches > 0) { this._onDidFindErrors.fire(this.markerService.read({ owner: background.matcher.owner })); diff --git a/src/vs/workbench/contrib/tasks/common/problemMatcher.ts b/src/vs/workbench/contrib/tasks/common/problemMatcher.ts index fad04dd91be..479c93d6ce1 100644 --- a/src/vs/workbench/contrib/tasks/common/problemMatcher.ts +++ b/src/vs/workbench/contrib/tasks/common/problemMatcher.ts @@ -24,6 +24,7 @@ import { IMarkerData, MarkerSeverity } from '../../../../platform/markers/common import { ExtensionsRegistry, ExtensionMessageCollector } from '../../../services/extensions/common/extensionsRegistry.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { FileType, IFileService, IFileStatWithPartialMetadata, IFileSystemProvider } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export enum FileLocationKind { Default, @@ -301,12 +302,12 @@ export interface ILineMatcher { handle(lines: string[], start?: number): IHandleResult; } -export function createLineMatcher(matcher: ProblemMatcher, fileService?: IFileService): ILineMatcher { +export function createLineMatcher(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService): ILineMatcher { const pattern = matcher.pattern; if (Array.isArray(pattern)) { - return new MultiLineMatcher(matcher, fileService); + return new MultiLineMatcher(matcher, fileService, logService); } else { - return new SingleLineMatcher(matcher, fileService); + return new SingleLineMatcher(matcher, fileService, logService); } } @@ -315,10 +316,12 @@ const endOfLine: string = Platform.OS === Platform.OperatingSystem.Windows ? '\r abstract class AbstractLineMatcher implements ILineMatcher { private matcher: ProblemMatcher; private fileService?: IFileService; + private logService?: ILogService; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { this.matcher = matcher; this.fileService = fileService; + this.logService = logService; } public handle(lines: string[], start: number = 0): IHandleResult { @@ -331,6 +334,16 @@ abstract class AbstractLineMatcher implements ILineMatcher { public abstract get matchLength(): number; + protected regexpExec(regexp: RegExp, line: string): RegExpExecArray | null { + const start = Date.now(); + const result = regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow regexp took ${elapsed}ms to execute`, regexp.source); + } + return result; + } + protected fillProblemData(data: IProblemData | undefined, pattern: IProblemPattern, matches: RegExpExecArray): data is IProblemData { if (data) { this.fillProperty(data, 'file', pattern, matches, true); @@ -482,8 +495,8 @@ class SingleLineMatcher extends AbstractLineMatcher { private pattern: IProblemPattern; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { - super(matcher, fileService); + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { + super(matcher, fileService, logService); this.pattern = matcher.pattern; } @@ -497,7 +510,7 @@ class SingleLineMatcher extends AbstractLineMatcher { if (this.pattern.kind !== undefined) { data.kind = this.pattern.kind; } - const matches = this.pattern.regexp.exec(lines[start]); + const matches = this.regexpExec(this.pattern.regexp, lines[start]); if (matches) { this.fillProblemData(data, this.pattern, matches); if (data.kind === ProblemLocationKind.Location && !data.location && !data.line && data.file) { @@ -521,8 +534,8 @@ class MultiLineMatcher extends AbstractLineMatcher { private patterns: IProblemPattern[]; private data: IProblemData | undefined; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { - super(matcher, fileService); + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { + super(matcher, fileService, logService); this.patterns = matcher.pattern; } @@ -537,7 +550,7 @@ class MultiLineMatcher extends AbstractLineMatcher { data.kind = this.patterns[0].kind; for (let i = 0; i < this.patterns.length; i++) { const pattern = this.patterns[i]; - const matches = pattern.regexp.exec(lines[i + start]); + const matches = this.regexpExec(pattern.regexp, lines[i + start]); if (!matches) { return { match: null, continue: false }; } else { @@ -559,7 +572,7 @@ class MultiLineMatcher extends AbstractLineMatcher { public override next(line: string): IProblemMatch | null { const pattern = this.patterns[this.patterns.length - 1]; Assert.ok(pattern.loop === true && this.data !== null); - const matches = pattern.regexp.exec(line); + const matches = this.regexpExec(pattern.regexp, line); if (!matches) { this.data = undefined; return null; diff --git a/src/vs/workbench/contrib/terminal/browser/remotePty.ts b/src/vs/workbench/contrib/terminal/browser/remotePty.ts index e27137d903c..68d16e842f8 100644 --- a/src/vs/workbench/contrib/terminal/browser/remotePty.ts +++ b/src/vs/workbench/contrib/terminal/browser/remotePty.ts @@ -80,14 +80,14 @@ export class RemotePty extends BasePty implements ITerminalChildProcess { return this._remoteTerminalChannel.processBinary(this.id, e); } - resize(cols: number, rows: number): void { + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void { if (this._inReplay || this._lastDimensions.cols === cols && this._lastDimensions.rows === rows) { return; } this._startBarrier.wait().then(_ => { this._lastDimensions.cols = cols; this._lastDimensions.rows = rows; - this._remoteTerminalChannel.resize(this.id, cols, rows); + this._remoteTerminalChannel.resize(this.id, cols, rows, pixelWidth, pixelHeight); }); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 7a71b3c59e1..a9fd028211a 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -2006,7 +2006,11 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } private async _updatePtyDimensions(rawXterm: XTermTerminal): Promise { - await this._processManager.setDimensions(rawXterm.cols, rawXterm.rows); + const pixelWidth = rawXterm.dimensions?.css.canvas.width; + const pixelHeight = rawXterm.dimensions?.css.canvas.height; + const roundedPixelWidth = pixelWidth ? Math.round(pixelWidth) : undefined; + const roundedPixelHeight = pixelHeight ? Math.round(pixelHeight) : undefined; + await this._processManager.setDimensions(rawXterm.cols, rawXterm.rows, undefined, roundedPixelWidth, roundedPixelHeight); } setShellType(shellType: TerminalShellType | undefined) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 251a94fd3e6..2cc3d43b763 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -581,16 +581,16 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce return os; } - setDimensions(cols: number, rows: number): Promise; - setDimensions(cols: number, rows: number, sync: false): Promise; - setDimensions(cols: number, rows: number, sync: true): void; - setDimensions(cols: number, rows: number, sync?: boolean): MaybePromise { + setDimensions(cols: number, rows: number, sync?: undefined, pixelWidth?: number, pixelHeight?: number): Promise; + setDimensions(cols: number, rows: number, sync: false, pixelWidth?: number, pixelHeight?: number): Promise; + setDimensions(cols: number, rows: number, sync: true, pixelWidth?: number, pixelHeight?: number): void; + setDimensions(cols: number, rows: number, sync?: boolean, pixelWidth?: number, pixelHeight?: number): MaybePromise { if (sync) { - this._resize(cols, rows); + this._resize(cols, rows, pixelWidth, pixelHeight); return; } - return this.ptyProcessReady.then(() => this._resize(cols, rows)); + return this.ptyProcessReady.then(() => this._resize(cols, rows, pixelWidth, pixelHeight)); } async setUnicodeVersion(version: '6' | '11'): Promise { @@ -606,13 +606,13 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce await this._terminalService.setNextCommandId(process.id, commandLine, commandId); } - private _resize(cols: number, rows: number) { + private _resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number) { if (!this._process) { return; } // The child process could already be terminated try { - this._process.resize(cols, rows); + this._process.resize(cols, rows, pixelWidth, pixelHeight); } catch (error) { // We tried to write to a closed pipe / channel. if (error.code !== 'EPIPE' && error.code !== 'ERR_IPC_CHANNEL_CLOSED') { diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index d66f1c39a38..be33e2b306b 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -256,6 +256,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach kittyKeyboard: config.enableKittyKeyboardProtocol, win32InputMode: config.enableWin32InputMode, }, + allowTransparency: config.enableImages, windowOptions: { getWinSizePixels: true, getCellSizePixels: true, @@ -551,6 +552,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this.raw.options.wordSeparator = config.wordSeparators; this.raw.options.ignoreBracketedPasteMode = config.ignoreBracketedPasteMode; this.raw.options.rescaleOverlappingGlyphs = config.rescaleOverlappingGlyphs; + this.raw.options.allowTransparency = config.enableImages; this.raw.options.vtExtensions = { kittyKeyboard: config.enableKittyKeyboardProtocol, win32InputMode: config.enableWin32InputMode, diff --git a/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts b/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts index b728f908338..6b6e849e344 100644 --- a/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts +++ b/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts @@ -231,8 +231,8 @@ export class RemoteTerminalChannelClient implements IPtyHostController { shutdown(id: number, immediate: boolean): Promise { return this._channel.call(RemoteTerminalChannelRequest.Shutdown, [id, immediate]); } - resize(id: number, cols: number, rows: number): Promise { - return this._channel.call(RemoteTerminalChannelRequest.Resize, [id, cols, rows]); + resize(id: number, cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): Promise { + return this._channel.call(RemoteTerminalChannelRequest.Resize, [id, cols, rows, pixelWidth, pixelHeight]); } clearBuffer(id: number): Promise { return this._channel.call(RemoteTerminalChannelRequest.ClearBuffer, [id]); diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 19a1d971cdb..12fa676c314 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -300,9 +300,9 @@ export interface ITerminalProcessManager extends IDisposable, ITerminalProcessIn relaunch(shellLaunchConfig: IShellLaunchConfig, cols: number, rows: number, reset: boolean): Promise; write(data: string): Promise; sendSignal(signal: string): Promise; - setDimensions(cols: number, rows: number): Promise; - setDimensions(cols: number, rows: number, sync: false): Promise; - setDimensions(cols: number, rows: number, sync: true): void; + setDimensions(cols: number, rows: number, sync?: undefined, pixelWidth?: number, pixelHeight?: number): Promise; + setDimensions(cols: number, rows: number, sync: false, pixelWidth?: number, pixelHeight?: number): Promise; + setDimensions(cols: number, rows: number, sync: true, pixelWidth?: number, pixelHeight?: number): void; clearBuffer(): Promise; setUnicodeVersion(version: '6' | '11'): Promise; setNextCommandId(commandLine: string, commandId: string): Promise; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index e97facbaa35..3500a7243e1 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -342,7 +342,7 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.TerminalTitle]: { 'type': 'string', - 'default': '${sequence}', + 'default': '${process}', 'markdownDescription': terminalTitle }, [TerminalSettingId.TerminalDescription]: { @@ -654,7 +654,7 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.EnableImages]: { restricted: true, - markdownDescription: localize('terminal.integrated.enableImages', "Enables image support in the terminal, this will only work when {0} is enabled. Both sixel and iTerm's inline image protocol are supported on Linux and macOS. This will only work on Windows for versions of ConPTY >= v2 which is shipped with Windows itself, see also {1}. Images will currently not be restored between window reloads/reconnects.", `\`#${TerminalSettingId.GpuAcceleration}#\``, `\`#${TerminalSettingId.WindowsUseConptyDll}#\``), + markdownDescription: localize('terminal.integrated.enableImages', "Enables image support in the terminal, this will only work when {0} is enabled. Both sixel and iTerm's inline image protocol are supported on Linux and macOS. This will only work on Windows for versions of ConPTY >= v2 which is shipped with Windows itself, see also {1}. Images will currently not be restored between window reloads/reconnects. When enabled, transparency mode is also turned on in the terminal.", `\`#${TerminalSettingId.GpuAcceleration}#\``, `\`#${TerminalSettingId.WindowsUseConptyDll}#\``), type: 'boolean', default: false }, diff --git a/src/vs/workbench/contrib/terminal/electron-browser/localPty.ts b/src/vs/workbench/contrib/terminal/electron-browser/localPty.ts index 6405af52054..eed9c7fa8e1 100644 --- a/src/vs/workbench/contrib/terminal/electron-browser/localPty.ts +++ b/src/vs/workbench/contrib/terminal/electron-browser/localPty.ts @@ -52,13 +52,13 @@ export class LocalPty extends BasePty implements ITerminalChildProcess { this._proxy.sendSignal(this.id, signal); } - resize(cols: number, rows: number): void { + resize(cols: number, rows: number, pixelWidth?: number, pixelHeight?: number): void { if (this._inReplay || this._lastDimensions.cols === cols && this._lastDimensions.rows === rows) { return; } this._lastDimensions.cols = cols; this._lastDimensions.rows = rows; - this._proxy.resize(this.id, cols, rows); + this._proxy.resize(this.id, cols, rows, pixelWidth, pixelHeight); } async clearBuffer(): Promise { diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index ad7aa09f9ea..7f5c8e64d14 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -503,7 +503,7 @@ const apiMenus: IAPIMenu[] = [ }, { key: 'chat/editor/inlineGutter', - id: MenuId.ChatEditorInlineGutter, + id: MenuId.ChatEditorInlineMenu, description: localize('menus.chatEditorInlineGutter', "The inline gutter menu in the chat editor."), supportsSubmenus: false, proposed: 'contribChatEditorInlineGutterMenu', diff --git a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts index 7e7ae683bff..0f56a1edf3a 100644 --- a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts @@ -17,4 +17,8 @@ export interface IBrowserElementsService { getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise; startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise; + + startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise; + + getConsoleLogs(locator: IBrowserTargetLocator): Promise; } diff --git a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts index 337987925cb..a98c4de1ba5 100644 --- a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts @@ -21,6 +21,14 @@ class WebBrowserElementsService implements IBrowserElementsService { async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { throw new Error('Not implemented'); } + + async startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { + throw new Error('Not implemented'); + } + + async getConsoleLogs(locator: IBrowserTargetLocator): Promise { + throw new Error('Not implemented'); + } } registerSingleton(IBrowserElementsService, WebBrowserElementsService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts index 021dad4e4c9..fa2ddc77288 100644 --- a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts @@ -33,6 +33,27 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { @INativeBrowserElementsService private readonly simpleBrowser: INativeBrowserElementsService ) { } + async getConsoleLogs(locator: IBrowserTargetLocator): Promise { + return await this.simpleBrowser.getConsoleLogs(locator); + } + + async startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { + const cancelAndDetachId = cancelAndDetachIdPool++; + const onCancelChannel = `vscode:cancelConsoleSession${cancelAndDetachId}`; + + const disposable = token.onCancellationRequested(() => { + ipcRenderer.send(onCancelChannel, cancelAndDetachId); + disposable.dispose(); + }); + try { + await this.simpleBrowser.startConsoleSession(token, locator, cancelAndDetachId); + } catch (error) { + throw new Error('Failed to start console session', { cause: error }); + } finally { + disposable.dispose(); + } + } + async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { const cancelAndDetachId = cancelAndDetachIdPool++; const onCancelChannel = `vscode:cancelCurrentSession${cancelAndDetachId}`; @@ -44,8 +65,9 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { try { await this.simpleBrowser.startDebugSession(token, locator, cancelAndDetachId); } catch (error) { + throw new Error('No debug session target found', { cause: error }); + } finally { disposable.dispose(); - throw new Error('No debug session target found', error); } } diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index adca06c9c5a..919d79dc291 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -164,6 +164,8 @@ export interface IChatEntitlementService { readonly anonymous: boolean; readonly anonymousObs: IObservable; + markAnonymousRateLimited(): void; + update(token: CancellationToken): Promise; } @@ -511,6 +513,15 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme //#endregion + markAnonymousRateLimited(): void { + if (!this.anonymous) { + return; + } + + this.chatQuotaExceededContextKey.set(true); + this._onDidChangeQuotaExceeded.fire(); + } + async update(token: CancellationToken): Promise { await this.requests?.value.forceResolveEntitlement(token); } diff --git a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts index 9277723a595..2b7e6c69b2a 100644 --- a/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts +++ b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts @@ -145,7 +145,12 @@ export abstract class BaseConfigurationResolverService extends AbstractVariableR override async resolveWithInteractionReplace(folder: IWorkspaceFolderData | undefined, config: unknown, section?: string, variables?: IStringDictionary, target?: ConfigurationTarget): Promise { const parsed = ConfigurationResolverExpression.parse(config); - await this.resolveWithInteraction(folder, parsed, section, variables, target); + const resolved = await this.resolveWithInteraction(folder, parsed, section, variables, target); + + // Skip if input variable was canceled + if (resolved === undefined) { + return undefined; + } return parsed.toObject(); } diff --git a/src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts b/src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts index 9d33b6e6d5e..76db1269702 100644 --- a/src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts +++ b/src/vs/workbench/services/configurationResolver/test/electron-browser/configurationResolverService.test.ts @@ -752,6 +752,23 @@ suite('Configuration Resolver Service', () => { assert.strictEqual(0, mockCommandService.callCount); }); }); + + test('canceled input', async () => { + stub(quickInputService, 'input').resolves(undefined); + + const configuration = { + 'name': 'Attach to Process', + 'type': 'node', + 'request': 'attach', + 'processId': '${input:input1}', + 'port': 5858, + 'sourceMaps': false, + 'outDir': null + }; + + const result = await configurationResolverService!.resolveWithInteractionReplace(workspace, configuration, 'tasks'); + assert.strictEqual(result, undefined); + }); }); diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index 25ebf2bcc52..e625aa79e5b 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -806,6 +806,8 @@ export class TestChatEntitlementService implements IChatEntitlementService { onDidChangeAnonymous = Event.None; readonly anonymousObs = observableValue({}, false); + markAnonymousRateLimited(): void { } + readonly previewFeaturesDisabled = false; } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 603f8a6fcb8..b23503fa232 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -1020,8 +1020,6 @@ declare module 'vscode' { readonly rawInput?: unknown; readonly chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - readonly chatSessionId?: string; readonly chatSessionResource?: Uri; readonly chatInteractionId?: string; }