diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 14e637aaf2d1d..3e7b3c6765f24 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 cae158ea59014..74d58f3840699 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/extensions.ts b/build/lib/extensions.ts index fac7946fc98af..6c1e65b0f57f3 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/tsgo.ts b/build/lib/tsgo.ts index 7f00c1816b56f..36d925d43139a 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)); - - runReporter(lines.join('\n')); - }; + let stdoutData = ''; + let stderrData = ''; - 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 f1c0784ef28e1..e8f5b1f72d1c1 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/src/vs/base/common/yaml.ts b/src/vs/base/common/yaml.ts index 6f2e801d6964e..1cb0388afea2e 100644 --- a/src/vs/base/common/yaml.ts +++ b/src/vs/base/common/yaml.ts @@ -3,890 +3,1592 @@ * 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(); } -export interface YamlParseError { - readonly message: string; - readonly start: Position; - readonly end: Position; - readonly code: string; -} - -export interface ParseOptions { - readonly allowDuplicateKeys?: boolean; -} - -export interface Position { - readonly line: number; - readonly character: number; -} +// -- AST Node Types ---------------------------------------------------------- -export interface YamlStringNode { - readonly type: 'string'; +export interface YamlScalarNode { + readonly type: 'scalar'; readonly value: string; - readonly start: Position; - readonly end: Position; + readonly rawValue: string; + readonly startOffset: number; + readonly endOffset: number; + readonly format: 'single' | 'double' | 'none' | 'literal' | 'folded'; } -export interface YamlNumberNode { - readonly type: 'number'; - readonly value: number; - readonly start: Position; - readonly end: Position; +export interface YamlMapNode { + readonly type: 'map'; + readonly properties: { key: YamlScalarNode; value: YamlNode }[]; + readonly style: 'block' | 'flow'; + readonly startOffset: number; + readonly endOffset: number; } -export interface YamlBooleanNode { - readonly type: 'boolean'; - readonly value: boolean; - readonly start: Position; - readonly end: Position; +export interface YamlSequenceNode { + readonly type: 'sequence'; + readonly items: YamlNode[]; + readonly style: 'block' | 'flow'; + readonly startOffset: number; + readonly endOffset: number; } -export interface YamlNullNode { - readonly type: 'null'; - readonly value: null; - readonly start: Position; - readonly end: Position; -} +export type YamlNode = YamlSequenceNode | YamlMapNode | YamlScalarNode; -export interface YamlObjectNode { - readonly type: 'object'; - readonly properties: { key: YamlStringNode; value: YamlNode }[]; - readonly start: Position; - readonly end: Position; +export interface YamlParseError { + readonly message: string; + readonly startOffset: number; + readonly endOffset: number; + readonly code: string; } -export interface YamlArrayNode { - readonly type: 'array'; - readonly items: YamlNode[]; - readonly start: Position; - readonly end: Position; +export interface ParseOptions { + readonly allowDuplicateKeys?: boolean; } -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 }; +// -- 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, } -// Specialized node creation functions using a more concise approach -function createStringNode(value: string, start: Position, end: Position): YamlStringNode { - return { type: 'string', value, start, end }; +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; + /** For Scalar tokens: quote style. */ + readonly format: 'single' | 'double' | 'none' | 'literal' | 'folded'; + /** For Indent tokens: the column (number of spaces). */ + readonly indent: number; } -function createNumberNode(value: number, start: Position, end: Position): YamlNumberNode { - return { type: 'number', value, start, end }; +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, + }; } -function createBooleanNode(value: boolean, start: Position, end: Position): YamlBooleanNode { - return { type: 'boolean', value, start, end }; -} +// -- Scanner ----------------------------------------------------------------- -function createNullNode(start: Position, end: Position): YamlNullNode { - return { type: 'null', value: null, start, end }; -} +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; -function createObjectNode(properties: { key: YamlStringNode; value: YamlNode }[], start: Position, end: Position): YamlObjectNode { - return { type: 'object', start, end, properties }; -} + constructor(private readonly input: string) { } -function createArrayNode(items: YamlNode[], start: Position, end: Position): YamlArrayNode { - return { type: 'array', start, end, items }; -} + scan(): Token[] { + while (this.pos < this.input.length) { + this.scanLine(); + } + this.tokens.push(makeToken(TokenType.EOF, this.pos, this.pos)); + return this.tokens; + } -// Utility functions for parsing -function isWhitespace(char: string): boolean { - return char === ' ' || char === '\t'; -} + // 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; + } + 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; + } -// Simplified number validation using regex -function isValidNumber(value: string): boolean { - return /^-?\d*\.?\d+$/.test(value); -} + // Measure leading whitespace → Indent token + const indentStart = this.pos; + let indent = 0; + 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 })); + } -// Lexer/Tokenizer for YAML content -class YamlLexer { - private lines: string[]; - private currentLine: number = 0; - private currentChar: number = 0; + // 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; + } - constructor(lines: string[]) { - this.lines = lines; - } + // 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; + } + } - getCurrentPosition(): Position { - return createPosition(this.currentLine, this.currentChar); - } + // Check for comment-only line + if (this.peekChar() === '#') { + this.scanComment(); + this.scanNewline(); + return; + } - getCurrentLineNumber(): number { - return this.currentLine; - } + // 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; + } - getCurrentCharNumber(): number { - return this.currentChar; + // Scan the rest of the line for tokens + this.scanLineContent(); + this.scanNewline(); } - getCurrentLineText(): string { - return this.currentLine < this.lines.length ? this.lines[this.currentLine] : ''; - } + 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; + } - savePosition(): { line: number; char: number } { - return { line: this.currentLine, char: this.currentChar }; + const ch = this.peekChar(); + + 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(); + } + } } - restorePosition(pos: { line: number; char: number }): void { - this.currentLine = pos.line; - this.currentChar = pos.char; + /** 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'; } - isAtEnd(): boolean { - return this.currentLine >= this.lines.length; + /** 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; } - getCurrentChar(): string { - if (this.isAtEnd() || this.currentChar >= this.lines[this.currentLine].length) { - return ''; + /** 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; } - return this.lines[this.currentLine][this.currentChar]; + return false; } - peek(offset: number = 1): string { - const newChar = this.currentChar + offset; - if (this.currentLine >= this.lines.length || newChar >= this.lines[this.currentLine].length) { - return ''; + 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; + + 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 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; + } + + // 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; + + // Skip the newline + this.consumeNewline(); + + // 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; + } + } + + // Apply folding: empty lines → \n each, otherwise single newline → space + if (emptyLineCount > 0) { + value += '\n'.repeat(emptyLineCount); + } else { + value += ' '; + } + continue; + } + + // Track literal whitespace for folding purposes + if (ch === ' ' || ch === '\t') { + trailingLiteralWs++; + } else { + trailingLiteralWs = 0; + } + value += ch; + this.pos++; } - return this.lines[this.currentLine][newChar]; + + // 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', + })); } - 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++; + private scanUnquotedScalar(): void { + const start = this.pos; + let end = this.pos; + + 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; } + + this.pos++; + // Track the last non-whitespace position to trim trailing whitespace + if (ch !== ' ' && ch !== '\t') { + end = this.pos; + } } - return char; - } - advanceLine(): void { - this.currentLine++; - this.currentChar = 0; + const rawValue = this.input.substring(start, end); + this.tokens.push(makeToken(TokenType.Scalar, start, end, { + rawValue, + value: rawValue, + format: 'none', + })); } - skipWhitespace(): void { - while (!this.isAtEnd() && this.currentChar < this.lines[this.currentLine].length && isWhitespace(this.getCurrentChar())) { - this.advance(); + /** + * 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 === '#'; } - skipToEndOfLine(): void { - this.currentChar = this.lines[this.currentLine].length; - } + /** + * 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++; + } + } + } - getIndentation(): number { - if (this.isAtEnd()) { - return 0; + // 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++; } - 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 { - break; + + // 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++; } } - 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; + // 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; } - // Move to next line and check from beginning - this.currentLine++; - this.currentChar = 0; + // 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 (this.currentLine < this.lines.length) { - const line = this.lines[this.currentLine].trim(); - if (line.length > 0 && !line.startsWith('#')) { - this.skipWhitespace(); - return; + // 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(); } - } -} -// 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; + // 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; + } + } + } - constructor(lines: string[], errors: YamlParseError[], options: ParseOptions) { - this.lexer = new YamlLexer(lines); - this.errors = errors; - this.options = options; - } + // 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); + } - addError(message: string, code: string, start: Position, end: Position): void { - this.errors.push({ message, code, start, 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', + })); } - parseValue(expectedIndent?: number): YamlNode { - this.lexer.skipWhitespace(); + /** + * 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; + } - if (this.lexer.isAtEnd()) { - const pos = this.lexer.getCurrentPosition(); - return createStringNode('', pos, pos); + /** + * 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; + } - const char = this.lexer.getCurrentChar(); + 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), + })); + } - // Handle quoted strings - if (char === '"' || char === `'`) { - return this.parseQuotedString(char); + private scanNewline(): void { + const start = this.pos; + if (this.consumeNewline()) { + this.tokens.push(makeToken(TokenType.Newline, start, this.pos)); } + } - // Handle inline arrays - if (char === '[') { - return this.parseInlineArray(); + private skipInlineWhitespace(): void { + while (this.pos < this.input.length) { + const ch = this.input[this.pos]; + if (ch === ' ' || ch === '\t') { + this.pos++; + } else { + break; + } } + } - // Handle inline objects - if (char === '{') { - return this.parseInlineObject(); + /** 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; + } - // Handle unquoted values - return this.parseUnquotedValue(); + private peekChar(): string { + return this.input[this.pos]; } +} - parseQuotedString(quote: string): YamlNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip opening quote +// -- Parser ------------------------------------------------------------------ - let value = ''; - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) { - value += this.lexer.advance(); - } +class YamlParser { + private pos = 0; + + constructor( + private readonly tokens: Token[], + private readonly input: string, + private readonly errors: YamlParseError[], + private readonly options: ParseOptions, + ) { } - if (this.lexer.getCurrentChar() === quote) { - this.lexer.advance(); // Skip closing quote + 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; + } - const end = this.lexer.getCurrentPosition(); - return createStringNode(value, start, end); + // -- helpers ---------------------------------------------------------- + + private currentToken(): Token { + return this.tokens[this.pos]; } - parseUnquotedValue(): YamlNode { - const start = this.lexer.getCurrentPosition(); - let value = ''; - let endPos = start; + private peek(offset = 0): Token { + return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)]; + } - // 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; } - return false; - }; + private advance(): Token { + const t = this.tokens[this.pos]; + if (t.type !== TokenType.EOF) { + this.pos++; + } + return t; + } - // 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(); - } + private expect(type: TokenType): Token { + const t = this.currentToken(); + if (t.type === type) { + return this.advance(); } - const trimmed = value.trimEnd(); - const diff = value.length - trimmed.length; - if (diff) { - endPos = createPosition(start.line, endPos.character - diff); + 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(); } - const finalValue = (firstChar === '"' || firstChar === `'`) ? trimmed.substring(1) : trimmed; - return this.createValueNode(finalValue, start, endPos); } - private createValueNode(value: string, start: Position, end: Position): YamlNode { - if (value === '') { - return createStringNode('', start, start); + /** 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 -------------- - // Boolean values - if (value === 'true') { - return createBooleanNode(true, start, end); + 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(); } - if (value === 'false') { - return createBooleanNode(false, start, end); + + // 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); } - // Null values - if (value === 'null' || value === '~') { - return createNullNode(start, end); + // Check if this looks like a mapping (scalar followed by colon) + if (this.looksLikeMapping()) { + return this.parseBlockMapping(indent); } - // Number values - const numberValue = Number(value); - if (!isNaN(numberValue) && isFinite(numberValue) && isValidNumber(value)) { - return createNumberNode(numberValue, start, end); + // Otherwise it's a scalar + if (token.type === TokenType.Scalar || token.type === TokenType.Indent) { + return this.parseScalar(parentIndent); } - // Default to string - return createStringNode(value, start, end); + return undefined; } - parseInlineArray(): YamlArrayNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip '[' - this.flowLevel++; + /** 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(); + } - const items: YamlNode[] = []; + /** 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; + } - while (!this.lexer.isAtEnd()) { - this.lexer.skipWhitespace(); + // -- Scalar ---------------------------------------------------------- - // Handle end of array - if (this.lexer.getCurrentChar() === ']') { - this.lexer.advance(); + 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; } - // Handle end of line - continue to next line for multi-line arrays - if (this.lexer.getCurrentChar() === '') { - this.lexer.advanceLine(); - continue; + if (!foundContent) { + // No continuation found - restore position + this.pos = savedPos; + break; } - // 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; + // We found a continuation line. Skip optional indent. + if (this.currentToken().type === TokenType.Indent) { + this.advance(); } - // 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); + // 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 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; - } + // 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; } - this.lexer.skipWhitespace(); + const contToken = this.advance(); - // Handle comma separator - if (this.lexer.getCurrentChar() === ',') { - this.lexer.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; } - const end = this.lexer.getCurrentPosition(); - this.flowLevel--; - return createArrayNode(items, start, end); + return { + type: 'scalar', + value, + rawValue: this.input.substring(firstToken.startOffset, endOffset), + startOffset: firstToken.startOffset, + endOffset, + format: 'none', + }; } - parseInlineObject(): YamlObjectNode { - const start = this.lexer.getCurrentPosition(); - this.lexer.advance(); // Skip '{' - this.flowLevel++; - - const properties: { key: YamlStringNode; value: YamlNode }[] = []; + // -- Block mapping --------------------------------------------------- - while (!this.lexer.isAtEnd()) { - this.lexer.skipWhitespace(); + private parseBlockMapping(baseIndent: number, inlineFirstEntry = false): YamlMapNode { + const startOffset = this.currentToken().startOffset; + const properties: { key: YamlScalarNode; value: YamlNode }[] = []; + const seenKeys = new Set(); - // Handle end of object - if (this.lexer.getCurrentChar() === '}') { - this.lexer.advance(); - break; + // 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); } + } - // 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; + 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); + } - // Save position before parsing to detect if we're making progress - const positionBefore = this.lexer.savePosition(); + const endOffset = properties.length > 0 ? properties[properties.length - 1].value.endOffset : startOffset; + return { type: 'map', properties, style: 'block', startOffset, endOffset }; + } - // Parse key - read until colon - const keyStart = this.lexer.getCurrentPosition(); - let keyValue = ''; + private parseMappingEntry(baseIndent: number): { key: YamlScalarNode; value: YamlNode } | undefined { + // Skip indent + if (this.currentToken().type === TokenType.Indent) { + this.advance(); + } - // Handle quoted keys - if (this.lexer.getCurrentChar() === '"' || this.lexer.getCurrentChar() === `'`) { - const quote = this.lexer.getCurrentChar(); - this.lexer.advance(); // Skip opening quote + // Parse key + const keyToken = this.expect(TokenType.Scalar); + const key = this.scalarFromToken(keyToken); - while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) { - keyValue += this.lexer.advance(); - } + // 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; + } - 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(); - } - } + // Parse value: could be on same line or next line (indented) + const value = this.parseMappingValue(baseIndent, colon); - keyValue = keyValue.trim(); - const keyEnd = this.lexer.getCurrentPosition(); - const key = createStringNode(keyValue, keyStart, keyEnd); + 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(); - this.lexer.skipWhitespace(); + // Same-line flow collections + if (next.type === TokenType.FlowMapStart) { return this.parseFlowMap(); } + if (next.type === TokenType.FlowSeqStart) { return this.parseFlowSeq(); } - // Expect colon - if (this.lexer.getCurrentChar() === ':') { - this.lexer.advance(); + // 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); + } - this.lexer.skipWhitespace(); + // Value is on the next line (skip newlines/comments and check indentation) + this.skipNewlinesAndComments(); + const afterNewline = this.currentToken(); - // Parse value - const value = this.parseValue(); + 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); + } - properties.push({ key, value }); + 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); + } - // 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(); + // -- 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; } } - this.lexer.skipWhitespace(); + const contentToken = this.peekPastIndent(); + if (contentToken.type !== TokenType.Dash) { break; } - // Handle comma separator - if (this.lexer.getCurrentChar() === ',') { - this.lexer.advance(); + // 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; } - const end = this.lexer.getCurrentPosition(); - this.flowLevel--; - return createObjectNode(properties, start, end); + return { type: 'sequence', items, style: 'block', startOffset, endOffset }; } - parseBlockArray(baseIndent: number): YamlArrayNode { - const start = this.lexer.getCurrentPosition(); - const items: YamlNode[] = []; + private parseSequenceItemValue(baseIndent: number, dashToken: Token): YamlNode { + const next = this.currentToken(); - while (!this.lexer.isAtEnd()) { - this.lexer.moveToNextNonEmptyLine(); + // Skip comment after dash + if (next.type === TokenType.Comment) { + this.advance(); + } - if (this.lexer.isAtEnd()) { - break; - } + // Flow collections on same line + if (next.type === TokenType.FlowMapStart) { return this.parseFlowMap(); } + if (next.type === TokenType.FlowSeqStart) { return this.parseFlowSeq(); } - const currentIndent = this.lexer.getIndentation(); + // 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); + } - // If indentation is less than expected, we're done with this array - if (currentIndent < baseIndent) { - break; + // 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); + } - 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; - } + // 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); } - // 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); + 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 createArrayNode(items, start, end); + 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; } - parseBlockObject(baseIndent: number, baseCharPosition?: number): YamlObjectNode { - const start = this.lexer.getCurrentPosition(); - const properties: { key: YamlStringNode; value: YamlNode }[] = []; - const localKeysSeen = new Set(); + // -- Flow map -------------------------------------------------------- - // For parsing from current position (inline object parsing) - const fromCurrentPosition = baseCharPosition !== undefined; - let firstIteration = true; + private parseFlowMap(): YamlMapNode { + const startToken = this.advance(); // consume '{' + const properties: { key: YamlScalarNode; value: YamlNode }[] = []; - while (!this.lexer.isAtEnd()) { - if (!firstIteration || !fromCurrentPosition) { - this.lexer.moveToNextNonEmptyLine(); - } - firstIteration = false; + this.skipFlowWhitespace(); - if (this.lexer.isAtEnd()) { + 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; } - const currentIndent = this.lexer.getIndentation(); + this.skipFlowWhitespace(); - if (fromCurrentPosition) { - // For current position parsing, check character position alignment - this.lexer.skipWhitespace(); - const currentCharPosition = this.lexer.getCurrentCharNumber(); + // 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(); - if (currentCharPosition < baseCharPosition) { - break; - } + // Parse value + value = this.parseFlowValue(); } 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(); + // Key without value (e.g., { key, other: val }) + value = this.makeEmptyScalar(key.endOffset); } - keyValue = keyValue.trim(); - const keyEnd = this.lexer.getCurrentPosition(); - const key = createStringNode(keyValue, keyStart, keyEnd); + properties.push({ key, value }); - // Check for duplicate keys - if (!this.options.allowDuplicateKeys && localKeysSeen.has(keyValue)) { - this.addError(`Duplicate key '${keyValue}'`, 'duplicateKey', keyStart, keyEnd); - } - localKeysSeen.add(keyValue); + this.skipFlowWhitespace(); - // Expect colon - if (this.lexer.getCurrentChar() === ':') { - this.lexer.advance(); + // Consume comma if present + if (this.currentToken().type === TokenType.Comma) { + this.advance(); + this.skipFlowWhitespace(); } + } - this.lexer.skipWhitespace(); - - // Determine if value is on same line or next line(s) - let value: YamlNode; - const valueStart = this.lexer.getCurrentPosition(); + 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'); + } - if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') { - // Value is on next line(s) or empty - this.lexer.advanceLine(); + return { + type: 'map', + properties, + style: 'flow', + startOffset: startToken.startOffset, + endOffset: endToken.type === TokenType.FlowMapEnd ? endToken.endOffset : endToken.startOffset, + }; + } - // Check next line for nested content - if (!this.lexer.isAtEnd()) { - const nextIndent = this.lexer.getIndentation(); + // -- Flow sequence --------------------------------------------------- - if (nextIndent > currentIndent) { - // Nested content - determine if it's an object, array, or just a scalar value - this.lexer.skipWhitespace(); + private parseFlowSeq(): YamlSequenceNode { + const startToken = this.advance(); // consume '[' + const items: YamlNode[] = []; - 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(); + this.skipFlowWhitespace(); - if (this.lexer.getCurrentChar() === '-') { - value = this.parseBlockArray(currentIndent); - } else { - value = createStringNode('', valueStart, valueStart); - } - } else { - value = createStringNode('', valueStart, valueStart); - } - } else { - value = createStringNode('', valueStart, valueStart); - } + 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 { - // 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(); - } else { - break; - } - } + this.emitError(localize('unexpectedTokenInFlowSeq', 'Unexpected token in flow sequence'), this.currentToken().startOffset, this.currentToken().endOffset, 'unexpected-token'); + this.advance(); + continue; + } - // Skip to end of line if we hit a comment - if (this.lexer.getCurrentChar() === '#') { - this.lexer.skipToEndOfLine(); - } + items.push(item); + this.skipFlowWhitespace(); - // Move to next line for next iteration - if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() === '') { - this.lexer.advanceLine(); - } + if (this.currentToken().type === TokenType.Comma) { + this.advance(); + this.skipFlowWhitespace(); } - - properties.push({ key, value }); } - // 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; + 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 createObjectNode(properties, start, end); + return { + type: 'sequence', + items, + style: 'flow', + startOffset: startToken.startOffset, + endOffset: endToken.type === TokenType.FlowSeqEnd ? endToken.endOffset : endToken.startOffset, + }; } - parse(): YamlNode | undefined { - if (this.lexer.isAtEnd()) { - return undefined; + /** + * 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; + } + } - this.lexer.moveToNextNonEmptyLine(); + if (!hasNewline || p >= this.tokens.length) { break; } - if (this.lexer.isAtEnd()) { - return undefined; + 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; + } } - // Determine the root structure type - this.lexer.skipWhitespace(); + return { + type: 'scalar', + value, + rawValue: this.input.substring(token.startOffset, endOffset), + startOffset: token.startOffset, + endOffset, + format: 'none', + }; + } - 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(); - } - } 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(); + /** 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 { - // 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 = ''; - - for (let i = 0; i < remainingLine.length; i++) { - const char = remainingLine[i]; - - 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 - break; - } - } + return this.makeEmptyScalar(this.currentToken().startOffset); + } + } - if (hasColon) { - // Root is an object - return this.parseBlockObject(0); + /** 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 { - // Root is a single value - return this.parseValue(); + 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 c6e3a53e7cc66..3c78fd291d514 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 { ensureNoDisposablesAreLeakedInTestSuite } from './utils.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; +} + +// 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; } -function pos(line: number, character: number): Position { - return { line, character }; +// 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', () => { + suite('Empty input', () => { + test('returns undefined for empty string', () => { + assert.strictEqual(parseOk(''), undefined); + }); + + test('returns undefined for whitespace-only input', () => { + assert.strictEqual(parseOk(' '), undefined); + }); + + test('returns undefined for newline-only input', () => { + assert.strictEqual(parseOk('\n\n'), undefined); + }); + }); + + suite('Scalars', () => { + test('unquoted scalar', () => { + const input = 'hello world'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'none' }); + }); + + 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('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('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('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('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('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('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 }, []); + test('single-quoted scalar', () => { + const input = `'hello world'`; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'single' }); }); - 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('double-quoted scalar', () => { + const input = '"hello world"'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello world', format: 'double' }); }); - 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('double-quoted scalar with escape sequences', () => { + const input = '"hello\\nworld"'; + const node = parseOk(input); + assertScalar(input, node, { value: 'hello\nworld', format: 'double' }); }); - 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('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('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 } - } - ] - }, []); - }); - - 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('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('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('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('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('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 } - } - ] - }, - [] - ); + 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('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('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('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('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('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('mapping offsets', () => { + const input = 'name: John'; + const node = parseOk(input) as YamlMapNode; + assert.strictEqual(node.startOffset, 0); + assert.strictEqual(node.endOffset, 10); }); }); - 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' } - ] - - }, - [] - ); - }); - - - 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('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('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('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' } - } - ] - } - ] - }, - [] - ); + 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' }); + }); + + // 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('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('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('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 } - } - ] - } - ] - } - } - ] - }, - [] - ); - }); - - 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' } - ] - } - } - ] - }, - [] - ); - }); - - 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('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 } - } - ] - }, - [] - ); + 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); }); }); - suite('edge cases and error handling', () => { - - - // 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 } - ); - }); - - 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('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('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: '' } - } - ] - } - } - ] - }, - [] - ); - }); - - 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: '' } - } - ] - }, - [] - ); + 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('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}` } - })); + 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('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); + }); + }); + + 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('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', () => { + 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; + assert.ok(result); + assert.strictEqual(result.type, 'map'); + assert.ok(duration < 500, `Parsing took ${duration}ms, expected < 500ms`); + }); + + 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); + }); - ok(result); - strictEqual(result.type, 'object'); - strictEqual(errors.length, 0); - ok(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); - }); - - 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('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('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('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('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('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('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('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('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('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('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/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 2a452609df613..554a7cde357ea 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; - - if (this._allMenuItems.length >= 50) { - maxWidth = 380; + 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; + } + } + + 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 4ea3a49bff7db..e3969db45e1eb 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 21b49245bebcc..53483956586f0 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 0fb0d916c6ad9..b7b61da059f8e 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/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index 218acce24fd4c..5f6b0c82414a0 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 fbf52a2a06408..4e53a021745ae 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/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index a7c2ce6026f9a..25c88e45dda44 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 diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts index d1d035676a929..a5e7e3e70a876 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); - } - })); + private registerBadgeTracking(): void { + // Update badge when file count changes + this._register(autorun(reader => { + const fileCount = this.activeSessionFileCountObs.read(reader); + this.updateBadge(fileCount); })); - - // 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 + 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 afabe96726c78..5c783fc4c063d 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'; @@ -20,9 +21,11 @@ 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'; @@ -64,6 +67,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() { diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 82c4d8be2c660..dbfcb58ff7d9e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -43,7 +43,8 @@ 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'; @@ -376,7 +377,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); diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 52bd441711531..b81e8fa62848f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -13,6 +13,7 @@ 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 { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; @@ -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(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 0000000000000..bcadacb70d563 --- /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/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index ad793c2770eb4..cea4ababe30a6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -195,6 +195,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( @@ -367,6 +368,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(() => { @@ -759,6 +766,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 +974,9 @@ export class BrowserEditor extends EditorPane { this._elementSelectionCts = undefined; } + // Cancel any active console session + this.stopConsoleSession(); + // Cancel any scheduled screenshots this.cancelScheduledScreenshot(); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index d1bd6f3d4c8fb..dd212d04485c4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -254,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, 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'; @@ -269,7 +297,7 @@ class ToggleDevToolsAction extends Action2 { menu: { id: MenuId.BrowserActionsToolbar, group: 'actions', - order: 2, + order: 3, }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, @@ -542,6 +570,7 @@ registerAction2(GoForwardAction); registerAction2(ReloadAction); registerAction2(FocusUrlInputAction); registerAction2(AddElementToChatAction); +registerAction2(AddConsoleLogsToChatAction); registerAction2(ToggleDevToolsAction); registerAction2(OpenInExternalBrowserAction); registerAction2(ClearGlobalBrowserStorageAction); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index bf6d4807280b1..9ec8cb095ebb3 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 c3200e37a0048..a8d166e68f7d2 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/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 84d403c12ddfe..53be09ee9a2ae 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -644,12 +644,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'], diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 9c54a9062e598..b701123469531 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/promptSyntax/attachInstructionsAction.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts index f009cb3eba931..e2113a96523cf 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/promptFileRewriter.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts index 96e913156aaf9..302ab0356b31c 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 021f38acfd457..538f5d4ac2d68 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 fac4e86a1c080..c301e918ed983 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 01d802c154183..a90166a3a5924 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 f7d22ff52f2dd..7b590f3692216 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/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index 12dbdf346e3b2..560e9ecf84916 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/media/chatConfirmationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css index f200f56f93273..1a3d83505b78b 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/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index abc051db1a168..6aedfb31bfd6f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2269,35 +2269,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 @@ -2353,16 +2324,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 f1cd97d48fb66..4e246bc55abfe 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -998,6 +998,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); 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 0000000000000..8e7831378c1b0 --- /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/modelPickerActionItem2.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts new file mode 100644 index 0000000000000..c19279bee0abe --- /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 4580ea562d725..29d2a72833f9e 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/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 2351ac5537c4d..f541fe01b7b18 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -789,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]; @@ -846,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); @@ -1177,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/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index ff6e4cfffb81a..64a1f9b5ba830 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', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index f77edfbc6b487..4f71f64b7fec7 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,6 +13,7 @@ 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'; @@ -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,27 @@ 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; + readonly paidOnly?: boolean; +} + +interface IChatControlResponse { + readonly version: number; + readonly restrictedChatParticipants: { [name: string]: string[] }; + readonly curatedModels?: (string | IRawCuratedModel)[]; +} + +function normalizeCuratedModels(models: (string | IRawCuratedModel)[]): IRawCuratedModel[] { + return models.map(m => typeof m === 'string' ? { id: m } : m); +} export class LanguageModelsService implements ILanguageModelsService { @@ -476,6 +549,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 +571,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 +1385,167 @@ 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(models: IRawCuratedModel[]): void { + const toPublic = (m: IRawCuratedModel): ICuratedModel => ({ id: m.id, isNew: m.isNew, minVSCodeVersion: m.minVSCodeVersion }); + this._curatedModels = { + free: models.filter(m => !m.paidOnly).map(toPublic), + paid: models.filter(m => m.paidOnly).map(toPublic), + }; + + const newIds = new Set(); + for (const model of models) { + 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 (Array.isArray(curated)) { + this._setCuratedModels(normalizeCuratedModels(curated)); + } + } 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 && Array.isArray(result.curatedModels)) { + this._setCuratedModels(normalizeCuratedModels(result.curatedModels)); + 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/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 55f5e4c319cc8..c3c760b5d6a64 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/languageProviders/PromptHeaderDefinitionProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts index 076f9f2d1ebca..8de8c06dfba12 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 bd4ecc7de0fe7..704d5cd620815 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 c02f3dfcd536d..5e60a57cf89e0 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 662ce1962657e..8fe87c45a10ad 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 55559b5b04fea..8b9a1b018cfe6 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 bb0bc23424442..e11732310ec92 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 1e1d84d7d65e6..912a103fe5845 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 ba398b6754a30..152288f11987b 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/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 4ad52ff646c0a..0439386a34154 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/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 5fe0c45d88f71..775ad671cec74 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 06845cf18a152..a2dcac90b3fbb 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/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 48e2b40d1f17f..ddac0e86cc3dc 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 35b2595109d49..ee3375bdef594 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/promptFileReference.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/promptFileReference.test.ts index 0d09d3047d1ad..728349a04bfd0 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 d4a3d7d430a27..ed6e2f47948eb 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 2b803e88df2a3..05eabacb54dec 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 @@ -184,7 +184,7 @@ suite('PromptsService', () => { contents: [ '---', 'description: \'Root prompt description.\'', - 'tools: [\'my-tool1\', , true]', + 'tools: [\'my-tool1\', , tool]', 'agent: "agent" ', '---', '## Files', @@ -239,7 +239,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 +263,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 c734119ad85a5..2b3e423f8223c 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/externalTerminal/electron-browser/externalTerminal.contribution.ts b/src/vs/workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.ts index 1edaaa8ada121..832c960a20715 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 0000000000000..243b1268dc17c --- /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/services/browserElements/browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts index 7e7ae683bff5d..0f56a1edf3a4d 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 337987925cb69..a98c4de1ba587 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 021dad4e4c979..fa2ddc772882d 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/configurationResolver/browser/baseConfigurationResolverService.ts b/src/vs/workbench/services/configurationResolver/browser/baseConfigurationResolverService.ts index 9277723a59537..2b7e6c69b2ab3 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 9d33b6e6d5e4f..76db126970219 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); + }); });