From a778e041116945172f818daf32101d97099c22a5 Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Thu, 18 Jun 2026 14:28:33 -0500 Subject: [PATCH 1/9] Add experimental cmake.colorizedBuildOutput setting to colorize build output Adds an opt-in cmake.colorizedBuildOutput setting (off/severity, default off, window scope) that highlights build errors, warnings, and notes by severity in the CMake/Build Output panel using basic, theme-aware ANSI colors (rendered by VS Code 1.88+). Colorization is applied post-parse and only on the Output-channel echo, so the diagnostics/Problems panel and the on-disk log file keep clean text. A new pure module src/colorize.ts (with backend unit tests) classifies lines and wraps them with named SGR codes (error=bold red, warning=yellow, note=cyan, success=green) that VS Code remaps to the active theme, including High Contrast. Prototype for #478. --- CHANGELOG.md | 1 + docs/cmake-settings.md | 1 + package.json | 14 +++ package.nls.json | 3 + src/colorize.ts | 103 +++++++++++++++++++++++ src/config.ts | 5 ++ src/diagnostics/build.ts | 39 +++++++-- src/logging.ts | 41 +++++++-- test/unit-tests/backend/colorize.test.ts | 94 +++++++++++++++++++++ test/unit-tests/config.test.ts | 1 + 10 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 src/colorize.ts create mode 100644 test/unit-tests/backend/colorize.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fe12eec7..d5427d1b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Features: - Add support for `${workspaceFolder}`, `${workspaceFolder:name}` variables and relative paths in `cmake.exclude` setting for multi-root workspaces. [#4689](https://github.com/microsoft/vscode-cmake-tools/pull/4689) - Add `onConfigureResult` event to the CMake Tools API that fires after every configure attempt (success or failure), allowing dependent extensions to detect and react to configure failures. [#4021](https://github.com/microsoft/vscode-cmake-tools/issues/4021) - Add `cmake.preConfigureTask` setting to execute a named VS Code task before every CMake configure. [#2449](https://github.com/microsoft/vscode-cmake-tools/issues/2449) [#4960](https://github.com/microsoft/vscode-cmake-tools/pull/4960) [@erdemiru](https://github.com/erdemiru) +- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity in the CMake/Build Output panel using theme-aware ANSI colors. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. diff --git a/docs/cmake-settings.md b/docs/cmake-settings.md index 2c4fcd6dab..0a18e9d07e 100644 --- a/docs/cmake-settings.md +++ b/docs/cmake-settings.md @@ -24,6 +24,7 @@ Options that support substitution, in the table below, allow variable references | `cmake.clearOutputBeforeBuild` | If `true`, clear output before building. | `true` | no | | `cmake.cmakeCommunicationMode` | Specifies the protocol for communicating between the extension and CMake | `automatic` | no | | `cmake.cmakePath`| Specify location of the cmake executable. | `cmake` (causes CMake Tools to search the `PATH` environment variable, as well as some hard-coded locations.) | Supports substitution for `workspaceRoot`, `workspaceFolder`, `workspaceRootFolderName`, `userHome`, `${command:...}` and `${env:...}`. Other substitutions result in an empty string. | +| `cmake.colorizedBuildOutput` | Experimental, opt-in: colorize build output in the CMake/Build Output panel by highlighting errors, warnings, and notes using theme-aware ANSI colors. Requires VS Code 1.88 or newer. One of `off`, `severity`. | `off` | no | | `cmake.configureArgs` | Arguments to CMake that will be passed during the configure process. Prefer to use `cmake.configureSettings` or [CMake variants](variants.md).
It is not recommended to pass `-D` arguments using this setting. | `[]` (empty array-no arguments) | yes | | `cmake.configureEnvironment` | An object containing `key:value` pairs of environment variables, which will be passed to CMake only when configuring.| `null` (no environment variable pairs) | yes | | `cmake.configureOnEdit` | Automatically configure CMake project directories when the path in the `cmake.sourceDirectory` setting is updated or when `CMakeLists.txt` or `*.cmake` files are saved. | `true` | no | diff --git a/package.json b/package.json index acd0d2310e..9042a6733f 100644 --- a/package.json +++ b/package.json @@ -2425,6 +2425,20 @@ "description": "%cmake-tools.configuration.cmake.clearOutputBeforeBuild.description%", "scope": "resource" }, + "cmake.colorizedBuildOutput": { + "type": "string", + "enum": [ + "off", + "severity" + ], + "enumDescriptions": [ + "%cmake-tools.configuration.cmake.colorizedBuildOutput.off.description%", + "%cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description%" + ], + "default": "off", + "markdownDescription": "%cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription%", + "scope": "window" + }, "cmake.configureSettings": { "type": "object", "default": {}, diff --git a/package.nls.json b/package.nls.json index f10e95c733..48a80439d5 100644 --- a/package.nls.json +++ b/package.nls.json @@ -120,6 +120,9 @@ "cmake-tools.configuration.cmake.saveBeforeBuild.description": "Save open files before building.", "cmake-tools.configuration.cmake.buildBeforeRun.description": "Build the target before running it.", "cmake-tools.configuration.cmake.clearOutputBeforeBuild.description": "Clear build output before each build.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription": "Experimental, opt-in: colorize build output in the CMake/Build Output panel by highlighting errors, warnings, and notes using theme-aware ANSI colors. Requires VS Code 1.88 or newer.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.off.description": "Do not colorize build output.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description": "Highlight build errors, warnings, and notes by severity using theme-aware ANSI colors.", "cmake-tools.configuration.cmake.configureSettings.description": "CMake variables to set on the command line. This setting is specific to kits and will not be used for CMake Presets.", "cmake-tools.configuration.cmake.cacheInit.string.description": "Path to a cache-initializing CMake file.", "cmake-tools.configuration.cmake.cacheInit.array.description": "List of cache initializer files.", diff --git a/src/colorize.ts b/src/colorize.ts new file mode 100644 index 0000000000..466c02f0ce --- /dev/null +++ b/src/colorize.ts @@ -0,0 +1,103 @@ +/** + * Pure helpers for colorizing build output with ANSI SGR escape sequences. + * + * This module intentionally has NO dependency on the `vscode` API so it can be + * unit-tested directly under `test/unit-tests/backend`. + * + * Design notes (accessibility): + * - Only the basic named SGR colors (30-37 / 90-97) plus bold are used. VS Code + * remaps these to the active color theme's `terminal.ansi*` tokens, which High + * Contrast themes define explicitly for sufficient contrast. Hardcoded 24-bit + * (truecolor) or 256-color codes would ignore the theme and can render with + * poor contrast, so they are deliberately avoided. + * - Color never carries meaning on its own: the severity words ("error", + * "warning", "note") remain verbatim in the text, and the authoritative + * Problems panel and on-disk log file are unaffected. + * - Errors are additionally bold and notes use cyan (not green) so the red/green + * axis stays uncluttered for red-green color vision deficiency. + */ + +export type BuildColorMode = 'off' | 'severity'; + +export enum BuildLineSeverity { + Error, + Warning, + Note, + Success, + None +} + +const ESC = '\u001b'; +const RESET = `${ESC}[0m`; + +/** Bold red. */ +const SGR_ERROR = `${ESC}[1;31m`; +/** Yellow. */ +const SGR_WARNING = `${ESC}[33m`; +/** Cyan. */ +const SGR_NOTE = `${ESC}[36m`; +/** Green. */ +const SGR_SUCCESS = `${ESC}[32m`; + +// Conservative, well-anchored patterns covering the common compiler/build tools. +// GCC/Clang/CMake use "::: :"; MSVC uses +// " C####:" / "LNK####" / "RC####"; Ninja prints "FAILED:". +const ERROR_RE = /(:\s*(fatal error|error):)|(\b(error|fatal error)\s+(C\d+|LNK\d+|RC\d+|MSB\d+)\s*:)|(^FAILED:)|(\bmake(\[\d+\])?:\s+\*\*\*\s)/i; +const WARNING_RE = /(:\s*warning:)|(\bwarning\s+(C\d+|LNK\d+|MSB\d+|RC\d+)\s*:)/i; +const NOTE_RE = /:\s*(note|remark):/i; +const SUCCESS_RE = /\bBuilt target\s\S/; + +/** + * Classify a single (clean, ANSI-free) build-output line by severity. The + * classification is purely cosmetic — it only drives coloring, never diagnostics. + */ +export function classifyBuildLine(line: string): BuildLineSeverity { + if (ERROR_RE.test(line)) { + return BuildLineSeverity.Error; + } + if (WARNING_RE.test(line)) { + return BuildLineSeverity.Warning; + } + if (NOTE_RE.test(line)) { + return BuildLineSeverity.Note; + } + if (SUCCESS_RE.test(line)) { + return BuildLineSeverity.Success; + } + return BuildLineSeverity.None; +} + +function sgrFor(severity: BuildLineSeverity): string | undefined { + switch (severity) { + case BuildLineSeverity.Error: + return SGR_ERROR; + case BuildLineSeverity.Warning: + return SGR_WARNING; + case BuildLineSeverity.Note: + return SGR_NOTE; + case BuildLineSeverity.Success: + return SGR_SUCCESS; + default: + return undefined; + } +} + +/** + * Return `line` wrapped in an ANSI SGR color sequence based on its severity. + * + * The line is returned unchanged when: + * - `mode` is `'off'`, + * - the line already contains an ANSI escape (a tool emitted its own colors — we + * pass it through so VS Code renders the tool's colors and we avoid nesting), + * - the line does not classify into a known severity. + */ +export function colorizeBuildLine(line: string, mode: BuildColorMode): string { + if (mode === 'off') { + return line; + } + if (line.includes(ESC)) { + return line; + } + const sgr = sgrFor(classifyBuildLine(line)); + return sgr ? `${sgr}${line}${RESET}` : line; +} diff --git a/src/config.ts b/src/config.ts index eb6287e403..6f3d0486e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -193,6 +193,7 @@ export interface ExtensionConfigurationSettings { saveBeforeBuild: boolean; buildBeforeRun: boolean; clearOutputBeforeBuild: boolean; + colorizedBuildOutput: "off" | "severity"; configureSettings: { [key: string]: boolean | number | string | string[] | util.CMakeValue }; cacheInit: string | string[] | null; preferredGenerators: string[]; @@ -392,6 +393,9 @@ export class ConfigurationReader implements vscode.Disposable { get clearOutputBeforeBuild(): boolean { return !!this.configData.clearOutputBeforeBuild; } + get colorizedBuildOutput(): "off" | "severity" { + return this.configData.colorizedBuildOutput; + } get configureSettings(): {[key: string]: boolean | number | string | string[] | util.CMakeValue} { return this.configData.configureSettings; } @@ -712,6 +716,7 @@ export class ConfigurationReader implements vscode.Disposable { saveBeforeBuild: new vscode.EventEmitter(), buildBeforeRun: new vscode.EventEmitter(), clearOutputBeforeBuild: new vscode.EventEmitter(), + colorizedBuildOutput: new vscode.EventEmitter<"off" | "severity">(), configureSettings: new vscode.EventEmitter<{ [key: string]: any }>(), cacheInit: new vscode.EventEmitter(), preferredGenerators: new vscode.EventEmitter(), diff --git a/src/diagnostics/build.ts b/src/diagnostics/build.ts index a990e37a8b..d3b908049f 100644 --- a/src/diagnostics/build.ts +++ b/src/diagnostics/build.ts @@ -7,6 +7,7 @@ import * as proc from '@cmt/proc'; import { OutputConsumer } from '@cmt/proc'; import * as util from '@cmt/util'; import * as vscode from 'vscode'; +import { BuildColorMode, colorizeBuildLine } from '@cmt/colorize'; import * as gcc from '@cmt/diagnostics/gcc'; import * as ghs from '@cmt/diagnostics/ghs'; @@ -324,6 +325,36 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D constructor(readonly logger: Logger | null, config: ConfigurationReader) { super(); this.compileConsumer = new CompileOutputConsumer(config); + this.colorMode = config.colorizedBuildOutput; + } + /** + * How build output should be colorized in the Output channel. Read once per + * build (a fresh consumer is constructed for each build). + */ + private readonly colorMode: BuildColorMode; + /** + * Echo a build-output line to the logger, optionally colorized for the + * Output channel only. Parsing has already happened on the clean `line`, so + * the Problems panel is unaffected; the on-disk log file also stays clean. + */ + private echo(line: string, isError: boolean) { + if (!this.logger) { + return; + } + if (this.colorMode === 'off') { + if (isError) { + this.logger.error(line); + } else { + this.logger.info(line); + } + return; + } + const decorated = colorizeBuildLine(line, this.colorMode); + if (isError) { + this.logger.errorColorized(line, decorated); + } else { + this.logger.infoColorized(line, decorated); + } } /** * Event fired when the progress changes @@ -350,17 +381,13 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D error(line: string) { this.compileConsumer.error(line); - if (this.logger) { - this.logger.error(line); - } + this.echo(line, true); super.error(line); } output(line: string) { this.compileConsumer.output(line); - if (this.logger) { - this.logger.info(line); - } + this.echo(line, false); super.output(line); const progress = this._percent_re.exec(line); if (progress) { diff --git a/src/logging.ts b/src/logging.ts index 42439f95fb..15df4bb5fe 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -147,11 +147,24 @@ class SingletonLogger { } private _log(level: LogLevel, ...args: Stringable[]) { - const trace = vscode.workspace.getConfiguration('cmake').get('enableTraceLogging', false); - if (level === LogLevel.Trace && !trace) { + if (level === LogLevel.Trace && !vscode.workspace.getConfiguration('cmake').get('enableTraceLogging', false)) { return; } const user_message = args.map(a => a.toString()).join(' '); + this._emit(level, user_message); + } + + /** + * Like `_log`, but writes a separate, already-decorated string (e.g. an + * ANSI-colorized line) to the Output channel only. The plain `user_message` + * is still written to the on-disk log file and the developer console, so + * those stay free of escape codes. + */ + logColorized(level: LogLevel, user_message: string, channelMessage: string) { + this._emit(level, user_message, channelMessage); + } + + private _emit(level: LogLevel, user_message: string, channelMessage?: string) { const prefix = new Date().toISOString() + ` [${levelName(level)}]`; const raw_message = `${prefix} ${user_message}`; switch (level) { @@ -171,14 +184,18 @@ class SingletonLogger { console.error('[CMakeTools]', raw_message); break; } - // Write to the logfile asynchronously. + // Write to the logfile asynchronously. Always use the plain text so the + // log file never contains ANSI escape codes. this._logStream.then(strm => strm.write(raw_message + '\n')).catch(e => { console.error('Unhandled error while writing CMakeTools log file', e); }); - // Write to our output channel + // Write to our output channel. When a decorated (colorized) variant is + // provided, only the channel receives it; the timestamp prefix stays + // outside the color sequence. if (levelEnabled(level)) { const showTimestamps = vscode.workspace.getConfiguration('cmake').get('showTimestampsInOutput', false); - this._channel.appendLine(showTimestamps ? raw_message : user_message); + const channelText = channelMessage ?? user_message; + this._channel.appendLine(showTimestamps ? `${prefix} ${channelText}` : channelText); } } @@ -236,6 +253,13 @@ export class Logger { info(...args: Stringable[]) { SingletonLogger.instance().info(this.tag, ...args); } + /** + * Log at Info level, but display a separate already-colorized string in the + * Output channel only (the plain `message` still goes to the log file). + */ + infoColorized(message: string, channelMessage: string) { + SingletonLogger.instance().logColorized(LogLevel.Info, `${this.tag} ${message}`, `${this.tag} ${channelMessage}`); + } note(...args: Stringable[]) { SingletonLogger.instance().note(this.tag, ...args); } @@ -245,6 +269,13 @@ export class Logger { error(...args: Stringable[]) { SingletonLogger.instance().error(this.tag, ...args); } + /** + * Log at Error level, but display a separate already-colorized string in the + * Output channel only (the plain `message` still goes to the log file). + */ + errorColorized(message: string, channelMessage: string) { + SingletonLogger.instance().logColorized(LogLevel.Error, `${this.tag} ${message}`, `${this.tag} ${channelMessage}`); + } fatal(...args: Stringable[]) { SingletonLogger.instance().fatal(this.tag, ...args); } diff --git a/test/unit-tests/backend/colorize.test.ts b/test/unit-tests/backend/colorize.test.ts new file mode 100644 index 0000000000..c686648a22 --- /dev/null +++ b/test/unit-tests/backend/colorize.test.ts @@ -0,0 +1,94 @@ +import { expect } from 'chai'; +import { classifyBuildLine, colorizeBuildLine, BuildLineSeverity } from '@cmt/colorize'; + +/** + * Tests for the pure build-output colorizer in src/colorize.ts. + * + * colorize.ts has no transitive dependency on 'vscode', so it is imported + * directly via the @cmt alias (per the backend-test import strategy). + */ + +const ESC = '\u001b'; +const RESET = `${ESC}[0m`; + +suite('[colorize] classifyBuildLine', () => { + test('GCC/Clang error line', () => { + expect(classifyBuildLine('/src/main.cpp:10:5: error: expected \';\'')).to.equal(BuildLineSeverity.Error); + }); + test('GCC/Clang fatal error line', () => { + expect(classifyBuildLine('/src/main.cpp:1:10: fatal error: foo.h: No such file or directory')).to.equal(BuildLineSeverity.Error); + }); + test('MSVC error code line', () => { + expect(classifyBuildLine('main.cpp(12): error C2065: \'x\': undeclared identifier')).to.equal(BuildLineSeverity.Error); + }); + test('Linker error (LNK) line', () => { + expect(classifyBuildLine('main.obj : error LNK2019: unresolved external symbol')).to.equal(BuildLineSeverity.Error); + }); + test('Ninja FAILED line', () => { + expect(classifyBuildLine('FAILED: CMakeFiles/app.dir/main.cpp.o')).to.equal(BuildLineSeverity.Error); + }); + test('MSBuild MSB error code line', () => { + expect(classifyBuildLine('Project.vcxproj : error MSB8066: Custom build exited with code 1')).to.equal(BuildLineSeverity.Error); + }); + test('GNU make error summary line', () => { + expect(classifyBuildLine('make: *** [Makefile:23: all] Error 2')).to.equal(BuildLineSeverity.Error); + }); + test('GNU make[1] error summary line', () => { + expect(classifyBuildLine('make[1]: *** [CMakeFiles/Makefile2:83: all] Error 2')).to.equal(BuildLineSeverity.Error); + }); + test('GCC/Clang warning line', () => { + expect(classifyBuildLine('/src/main.cpp:7:9: warning: unused variable \'y\'')).to.equal(BuildLineSeverity.Warning); + }); + test('MSVC warning code line', () => { + expect(classifyBuildLine('main.cpp(7): warning C4101: \'y\': unreferenced local variable')).to.equal(BuildLineSeverity.Warning); + }); + test('GCC/Clang note line', () => { + expect(classifyBuildLine('/src/main.cpp:9:3: note: in expansion of macro')).to.equal(BuildLineSeverity.Note); + }); + test('Built target success line', () => { + expect(classifyBuildLine('[100%] Built target app')).to.equal(BuildLineSeverity.Success); + }); + test('Plain progress line is not classified', () => { + expect(classifyBuildLine('[ 50%] Building CXX object CMakeFiles/app.dir/main.cpp.o')).to.equal(BuildLineSeverity.None); + }); + test('Warning line is not misread as error', () => { + // Contains the word "error" only as part of -Werror, must stay a warning. + expect(classifyBuildLine('/src/a.cpp:3:1: warning: -Werror is enabled')).to.equal(BuildLineSeverity.Warning); + }); +}); + +suite('[colorize] colorizeBuildLine', () => { + test('off mode returns the line unchanged', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(colorizeBuildLine(line, 'off')).to.equal(line); + }); + test('severity mode wraps an error in bold red with a trailing reset', () => { + const line = '/src/main.cpp:10:5: error: boom'; + const out = colorizeBuildLine(line, 'severity'); + expect(out).to.equal(`${ESC}[1;31m${line}${RESET}`); + }); + test('severity mode wraps a warning in yellow', () => { + const line = '/src/main.cpp:7:9: warning: meh'; + expect(colorizeBuildLine(line, 'severity')).to.equal(`${ESC}[33m${line}${RESET}`); + }); + test('severity mode wraps a note in cyan', () => { + const line = '/src/main.cpp:9:3: note: here'; + expect(colorizeBuildLine(line, 'severity')).to.equal(`${ESC}[36m${line}${RESET}`); + }); + test('severity mode wraps a success line in green', () => { + const line = '[100%] Built target app'; + expect(colorizeBuildLine(line, 'severity')).to.equal(`${ESC}[32m${line}${RESET}`); + }); + test('unclassified line is returned unchanged in severity mode', () => { + const line = '[ 50%] Building CXX object CMakeFiles/app.dir/main.cpp.o'; + expect(colorizeBuildLine(line, 'severity')).to.equal(line); + }); + test('line that already contains ANSI is passed through unchanged', () => { + const line = `${ESC}[31m/src/main.cpp:10:5: error: already colored${RESET}`; + expect(colorizeBuildLine(line, 'severity')).to.equal(line); + }); + test('every colorized line ends with a reset', () => { + const line = 'main.obj : error LNK2019: unresolved external symbol'; + expect(colorizeBuildLine(line, 'severity').endsWith(RESET)).to.equal(true); + }); +}); diff --git a/test/unit-tests/config.test.ts b/test/unit-tests/config.test.ts index b7421ca13c..c9c8c25533 100644 --- a/test/unit-tests/config.test.ts +++ b/test/unit-tests/config.test.ts @@ -13,6 +13,7 @@ function createConfig(conf: Partial): Configurat saveBeforeBuild: true, buildBeforeRun: true, clearOutputBeforeBuild: true, + colorizedBuildOutput: "off", configureSettings: {}, cacheInit: null, preferredGenerators: [], From 26ed7a42a41fc06c1a615dfddb10373644e01d76 Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Thu, 18 Jun 2026 15:30:46 -0500 Subject: [PATCH 2/9] Render colorized build output in a terminal, not the Output panel The VS Code Output panel is a Monaco text editor and does not render ANSI escape codes (it shows them literally), so writing ANSI to the CMake/Build output channel printed raw escape codes instead of colors. Render colorized build output in a dedicated 'CMake Build' integrated terminal (xterm.js), which interprets ANSI, and revert the output channel + on-disk log file to plain text. The task-based build path (cmake.buildTask) already runs in a terminal and is now colorized in place. The opt-in cmake.colorizedBuildOutput setting (off/severity) and the parser-safe semantic colorizer (src/colorize.ts) are unchanged. --- CHANGELOG.md | 2 +- docs/cmake-settings.md | 2 +- package.nls.json | 4 +- src/buildOutputTerminal.ts | 107 +++++++++++++++++++++++++++++++++++++ src/cmakeProject.ts | 4 ++ src/cmakeTaskProvider.ts | 9 +++- src/diagnostics/build.ts | 27 +++++----- src/extension.ts | 2 + src/logging.ts | 41 ++------------ 9 files changed, 141 insertions(+), 57 deletions(-) create mode 100644 src/buildOutputTerminal.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d5427d1b49..3fdfdc918c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Features: - Add support for `${workspaceFolder}`, `${workspaceFolder:name}` variables and relative paths in `cmake.exclude` setting for multi-root workspaces. [#4689](https://github.com/microsoft/vscode-cmake-tools/pull/4689) - Add `onConfigureResult` event to the CMake Tools API that fires after every configure attempt (success or failure), allowing dependent extensions to detect and react to configure failures. [#4021](https://github.com/microsoft/vscode-cmake-tools/issues/4021) - Add `cmake.preConfigureTask` setting to execute a named VS Code task before every CMake configure. [#2449](https://github.com/microsoft/vscode-cmake-tools/issues/2449) [#4960](https://github.com/microsoft/vscode-cmake-tools/pull/4960) [@erdemiru](https://github.com/erdemiru) -- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity in the CMake/Build Output panel using theme-aware ANSI colors. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) +- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. diff --git a/docs/cmake-settings.md b/docs/cmake-settings.md index 0a18e9d07e..3d03a3ceb1 100644 --- a/docs/cmake-settings.md +++ b/docs/cmake-settings.md @@ -24,7 +24,7 @@ Options that support substitution, in the table below, allow variable references | `cmake.clearOutputBeforeBuild` | If `true`, clear output before building. | `true` | no | | `cmake.cmakeCommunicationMode` | Specifies the protocol for communicating between the extension and CMake | `automatic` | no | | `cmake.cmakePath`| Specify location of the cmake executable. | `cmake` (causes CMake Tools to search the `PATH` environment variable, as well as some hard-coded locations.) | Supports substitution for `workspaceRoot`, `workspaceFolder`, `workspaceRootFolderName`, `userHome`, `${command:...}` and `${env:...}`. Other substitutions result in an empty string. | -| `cmake.colorizedBuildOutput` | Experimental, opt-in: colorize build output in the CMake/Build Output panel by highlighting errors, warnings, and notes using theme-aware ANSI colors. Requires VS Code 1.88 or newer. One of `off`, `severity`. | `off` | no | +| `cmake.colorizedBuildOutput` | Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated **CMake Build** integrated terminal (the VS Code Output panel cannot render ANSI colors). One of `off`, `severity`. | `off` | no | | `cmake.configureArgs` | Arguments to CMake that will be passed during the configure process. Prefer to use `cmake.configureSettings` or [CMake variants](variants.md).
It is not recommended to pass `-D` arguments using this setting. | `[]` (empty array-no arguments) | yes | | `cmake.configureEnvironment` | An object containing `key:value` pairs of environment variables, which will be passed to CMake only when configuring.| `null` (no environment variable pairs) | yes | | `cmake.configureOnEdit` | Automatically configure CMake project directories when the path in the `cmake.sourceDirectory` setting is updated or when `CMakeLists.txt` or `*.cmake` files are saved. | `true` | no | diff --git a/package.nls.json b/package.nls.json index 48a80439d5..a20830498c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -120,9 +120,9 @@ "cmake-tools.configuration.cmake.saveBeforeBuild.description": "Save open files before building.", "cmake-tools.configuration.cmake.buildBeforeRun.description": "Build the target before running it.", "cmake-tools.configuration.cmake.clearOutputBeforeBuild.description": "Clear build output before each build.", - "cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription": "Experimental, opt-in: colorize build output in the CMake/Build Output panel by highlighting errors, warnings, and notes using theme-aware ANSI colors. Requires VS Code 1.88 or newer.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription": "Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors. Because the VS Code Output panel cannot render ANSI colors, colorized output is shown in a dedicated **CMake Build** integrated terminal (the regular CMake/Build Output channel is unchanged).", "cmake-tools.configuration.cmake.colorizedBuildOutput.off.description": "Do not colorize build output.", - "cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description": "Highlight build errors, warnings, and notes by severity using theme-aware ANSI colors.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description": "Highlight build errors, warnings, and notes by severity, shown in a dedicated CMake Build integrated terminal.", "cmake-tools.configuration.cmake.configureSettings.description": "CMake variables to set on the command line. This setting is specific to kits and will not be used for CMake Presets.", "cmake-tools.configuration.cmake.cacheInit.string.description": "Path to a cache-initializing CMake file.", "cmake-tools.configuration.cmake.cacheInit.array.description": "List of cache initializer files.", diff --git a/src/buildOutputTerminal.ts b/src/buildOutputTerminal.ts new file mode 100644 index 0000000000..06b4694681 --- /dev/null +++ b/src/buildOutputTerminal.ts @@ -0,0 +1,107 @@ +/** + * A lazily-created integrated terminal used to display colorized build output. + * + * The VS Code Output panel is a Monaco text editor and does NOT render ANSI + * escape codes (it shows them as literal text). The integrated terminal, backed + * by xterm.js, does render ANSI. So when colorized build output is enabled, the + * default (Output-channel) build path mirrors its output here, where the colors + * actually show. The Output channel and on-disk log file are left untouched. + */ + +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import { BuildColorMode, colorizeBuildLine } from '@cmt/colorize'; + +nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); +const localize: nls.LocalizeFunc = nls.loadMessageBundle(); + +// Terminals require CRLF line endings; a bare '\n' causes staircasing. +const EOL = '\r\n'; + +class BuildOutputTerminal implements vscode.Pseudoterminal { + private readonly writeEmitter = new vscode.EventEmitter(); + private readonly closeEmitter = new vscode.EventEmitter(); + readonly onDidWrite: vscode.Event = this.writeEmitter.event; + readonly onDidClose: vscode.Event = this.closeEmitter.event; + + private terminal?: vscode.Terminal; + private isOpen = false; + private pending: string[] = []; + + // vscode.Pseudoterminal: called when the terminal is first shown. + open(): void { + this.isOpen = true; + if (this.pending.length > 0) { + this.writeEmitter.fire(this.pending.join('')); + this.pending = []; + } + } + + // vscode.Pseudoterminal: called when the user closes the terminal. + close(): void { + this.isOpen = false; + this.terminal = undefined; + this.pending = []; + } + + private ensureTerminal(): void { + if (!this.terminal) { + this.isOpen = false; + this.pending = []; + this.terminal = vscode.window.createTerminal({ + name: localize('cmake.build.colorized.terminal.name', 'CMake Build'), + pty: this + }); + } + } + + private emit(text: string): void { + if (this.isOpen) { + this.writeEmitter.fire(text); + } else { + this.pending.push(text); + } + } + + /** + * Prepare the terminal at the start of a build: create it if needed, clear it + * when requested, and reveal it without stealing keyboard focus. + */ + prepareForBuild(clear: boolean): void { + this.ensureTerminal(); + if (clear) { + // Clear screen + scrollback + move cursor home. + this.emit('\u001b[2J\u001b[3J\u001b[H'); + } + this.terminal?.show(true); + } + + /** Write a single build-output line, colorized according to `mode`. */ + writeLine(line: string, mode: BuildColorMode): void { + this.ensureTerminal(); + this.emit(colorizeBuildLine(line, mode) + EOL); + } + + dispose(): void { + this.writeEmitter.dispose(); + this.closeEmitter.dispose(); + this.terminal?.dispose(); + this.terminal = undefined; + } +} + +let instance: BuildOutputTerminal | undefined; + +/** The shared colorized build-output terminal (created lazily). */ +export function buildOutputTerminal(): BuildOutputTerminal { + if (!instance) { + instance = new BuildOutputTerminal(); + } + return instance; +} + +/** Dispose the shared colorized build-output terminal, if any. */ +export function disposeBuildOutputTerminal(): void { + instance?.dispose(); + instance = undefined; +} diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index 7768dc91e5..924ea320f0 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -27,6 +27,7 @@ import { CTestDriver } from '@cmt/ctest'; import { CPackDriver } from '@cmt/cpack'; import { WorkflowDriver } from '@cmt/workflow'; import { CMakeBuildConsumer } from '@cmt/diagnostics/build'; +import { buildOutputTerminal } from '@cmt/buildOutputTerminal'; import { CMakeOutputConsumer } from '@cmt/diagnostics/cmake'; import { addDiagnosticToCollection, diagnosticSeverity, populateCollection } from '@cmt/diagnostics/util'; import { expandStrings, expandString, ExpansionOptions } from '@cmt/expand'; @@ -2447,6 +2448,9 @@ export class CMakeProject { const combinedToken = util.createCombinedCancellationToken(cancel, cancellationToken); combinedToken.onCancellationRequested(() => rollbar.invokeAsync(localize('stop.on.cancellation', 'Stop on cancellation'), () => this.stop())); buildLogger.info(localize('starting.build', 'Starting build')); + if (drv!.config.colorizedBuildOutput !== 'off') { + buildOutputTerminal().prepareForBuild(drv!.config.clearOutputBeforeBuild); + } await setContextAndStore(isBuildingKey, true); const rc = await drv!.build(newTargets, consumer, isBuildCommand); await setContextAndStore(isBuildingKey, false); diff --git a/src/cmakeTaskProvider.ts b/src/cmakeTaskProvider.ts index fb82028a68..9896bc9f6e 100644 --- a/src/cmakeTaskProvider.ts +++ b/src/cmakeTaskProvider.ts @@ -17,6 +17,7 @@ import * as util from '@cmt/util'; import * as expand from '@cmt/expand'; import { CommandResult } from 'vscode-cmake-tools'; import { CompileOutputConsumer } from '@cmt/diagnostics/build'; +import { BuildColorMode, colorizeBuildLine } from '@cmt/colorize'; import collections from '@cmt/diagnostics/collections'; import { addDiagnosticToCollection, diagnosticSeverity, populateCollection } from '@cmt/diagnostics/util'; @@ -368,6 +369,9 @@ export class CMakeTaskProvider implements vscode.TaskProvider { export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vscode.Pseudoterminal { private writeEmitter = new vscode.EventEmitter(); private closeEmitter = new vscode.EventEmitter(); + // How build-tool output is colorized in this terminal. Only set for build + // tasks (see runBuildTask); stays 'off' for config/test/package/workflow. + private colorMode: BuildColorMode = 'off'; public get onDidWrite(): vscode.Event { return this.writeEmitter.event; } @@ -382,12 +386,12 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc // These two override methods are used to write output and error messages to the terminal, as well // as call the parent class's output and error methods, which store the stdout and stderr messages for returning that info later. override output(line: string): void { - this.writeEmitter.fire(line + endOfLine); + this.writeEmitter.fire(colorizeBuildLine(line, this.colorMode) + endOfLine); super.output(line); } override error(error: string): void { - this.writeEmitter.fire(error + endOfLine); + this.writeEmitter.fire(colorizeBuildLine(error, this.colorMode) + endOfLine); super.error(error); } @@ -559,6 +563,7 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc let cmakePath: string; if (cmakeDriver) { cmakePath = cmakeDriver.getCMakeCommand(); + this.colorMode = cmakeDriver.config.colorizedBuildOutput; if (!this.options) { this.options = {}; diff --git a/src/diagnostics/build.ts b/src/diagnostics/build.ts index d3b908049f..c2bfaee570 100644 --- a/src/diagnostics/build.ts +++ b/src/diagnostics/build.ts @@ -7,7 +7,8 @@ import * as proc from '@cmt/proc'; import { OutputConsumer } from '@cmt/proc'; import * as util from '@cmt/util'; import * as vscode from 'vscode'; -import { BuildColorMode, colorizeBuildLine } from '@cmt/colorize'; +import { BuildColorMode } from '@cmt/colorize'; +import { buildOutputTerminal } from '@cmt/buildOutputTerminal'; import * as gcc from '@cmt/diagnostics/gcc'; import * as ghs from '@cmt/diagnostics/ghs'; @@ -333,27 +334,23 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D */ private readonly colorMode: BuildColorMode; /** - * Echo a build-output line to the logger, optionally colorized for the - * Output channel only. Parsing has already happened on the clean `line`, so - * the Problems panel is unaffected; the on-disk log file also stays clean. + * Echo a build-output line. Parsing has already happened on the clean `line`, + * so the Problems panel is unaffected. The plain line always goes to the + * logger (Output channel + on-disk log file are unchanged). When colorization + * is enabled, a colorized copy is additionally mirrored to the integrated + * terminal, where ANSI actually renders (the Output panel cannot render ANSI). */ private echo(line: string, isError: boolean) { - if (!this.logger) { - return; + if (this.colorMode !== 'off') { + buildOutputTerminal().writeLine(line, this.colorMode); } - if (this.colorMode === 'off') { - if (isError) { - this.logger.error(line); - } else { - this.logger.info(line); - } + if (!this.logger) { return; } - const decorated = colorizeBuildLine(line, this.colorMode); if (isError) { - this.logger.errorColorized(line, decorated); + this.logger.error(line); } else { - this.logger.infoColorized(line, decorated); + this.logger.info(line); } } /** diff --git a/src/extension.ts b/src/extension.ts index 708458f89f..3d12552500 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import * as cpt from 'vscode-cpptools'; import * as nls from 'vscode-nls'; import * as api from 'vscode-cmake-tools'; import { CMakeCache } from '@cmt/cache'; +import { disposeBuildOutputTerminal } from '@cmt/buildOutputTerminal'; import { CMakeProject, ConfigureType, ConfigureTrigger, DiagnosticsConfiguration, DiagnosticsSettings } from '@cmt/cmakeProject'; import { ConfigurationReader, getSettingsChangePromise, TouchBarConfig } from '@cmt/config'; import { CMakeDriver, CMakePreconditionProblems, ConfigureResult, ConfigureResultType } from '@cmt/drivers/cmakeDriver'; @@ -3122,6 +3123,7 @@ export async function deactivate() { if (taskProvider) { taskProvider.dispose(); } + disposeBuildOutputTerminal(); } export function getStatusBar(): StatusBar | undefined { diff --git a/src/logging.ts b/src/logging.ts index 15df4bb5fe..42439f95fb 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -147,24 +147,11 @@ class SingletonLogger { } private _log(level: LogLevel, ...args: Stringable[]) { - if (level === LogLevel.Trace && !vscode.workspace.getConfiguration('cmake').get('enableTraceLogging', false)) { + const trace = vscode.workspace.getConfiguration('cmake').get('enableTraceLogging', false); + if (level === LogLevel.Trace && !trace) { return; } const user_message = args.map(a => a.toString()).join(' '); - this._emit(level, user_message); - } - - /** - * Like `_log`, but writes a separate, already-decorated string (e.g. an - * ANSI-colorized line) to the Output channel only. The plain `user_message` - * is still written to the on-disk log file and the developer console, so - * those stay free of escape codes. - */ - logColorized(level: LogLevel, user_message: string, channelMessage: string) { - this._emit(level, user_message, channelMessage); - } - - private _emit(level: LogLevel, user_message: string, channelMessage?: string) { const prefix = new Date().toISOString() + ` [${levelName(level)}]`; const raw_message = `${prefix} ${user_message}`; switch (level) { @@ -184,18 +171,14 @@ class SingletonLogger { console.error('[CMakeTools]', raw_message); break; } - // Write to the logfile asynchronously. Always use the plain text so the - // log file never contains ANSI escape codes. + // Write to the logfile asynchronously. this._logStream.then(strm => strm.write(raw_message + '\n')).catch(e => { console.error('Unhandled error while writing CMakeTools log file', e); }); - // Write to our output channel. When a decorated (colorized) variant is - // provided, only the channel receives it; the timestamp prefix stays - // outside the color sequence. + // Write to our output channel if (levelEnabled(level)) { const showTimestamps = vscode.workspace.getConfiguration('cmake').get('showTimestampsInOutput', false); - const channelText = channelMessage ?? user_message; - this._channel.appendLine(showTimestamps ? `${prefix} ${channelText}` : channelText); + this._channel.appendLine(showTimestamps ? raw_message : user_message); } } @@ -253,13 +236,6 @@ export class Logger { info(...args: Stringable[]) { SingletonLogger.instance().info(this.tag, ...args); } - /** - * Log at Info level, but display a separate already-colorized string in the - * Output channel only (the plain `message` still goes to the log file). - */ - infoColorized(message: string, channelMessage: string) { - SingletonLogger.instance().logColorized(LogLevel.Info, `${this.tag} ${message}`, `${this.tag} ${channelMessage}`); - } note(...args: Stringable[]) { SingletonLogger.instance().note(this.tag, ...args); } @@ -269,13 +245,6 @@ export class Logger { error(...args: Stringable[]) { SingletonLogger.instance().error(this.tag, ...args); } - /** - * Log at Error level, but display a separate already-colorized string in the - * Output channel only (the plain `message` still goes to the log file). - */ - errorColorized(message: string, channelMessage: string) { - SingletonLogger.instance().logColorized(LogLevel.Error, `${this.tag} ${message}`, `${this.tag} ${channelMessage}`); - } fatal(...args: Stringable[]) { SingletonLogger.instance().fatal(this.tag, ...args); } From 7a8e78472142226eb770214c27b273ff22e4257a Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Thu, 18 Jun 2026 16:22:18 -0500 Subject: [PATCH 3/9] Add a rich build-output mode (glyphs, dimmed progress, banner, summary footer) Extends cmake.colorizedBuildOutput with a 'rich' value and adds a companion cmake.buildOutputGlyphs (unicode/ascii). In the CMake Build terminal, rich mode adds accessible severity glyphs, dims build-progress noise, prints a bold build header, and a colored, localized build-summary footer (succeeded/failed/cancelled + error/warning counts + elapsed time). Counts come from the resolved, parser-filtered diagnostics so they match the Problems panel. Colorization stays display-only (parsing runs on clean lines); off/severity behavior is unchanged. All terminal strings are localized; basic theme-remapped ANSI only (accessible, high-contrast, color-blind aware); the closed-terminal case no longer recreates a hidden terminal. Prototype for #478. --- CHANGELOG.md | 2 +- docs/cmake-settings.md | 3 +- package.json | 20 ++++- package.nls.json | 4 + src/buildOutputTerminal.ts | 44 ++++++++-- src/cmakeProject.ts | 22 ++++- src/cmakeTaskProvider.ts | 8 +- src/colorize.ts | 104 ++++++++++++++++++++++- src/config.ts | 11 ++- src/diagnostics/build.ts | 12 +-- test/unit-tests/backend/colorize.test.ts | 88 ++++++++++++++++++- test/unit-tests/config.test.ts | 1 + 12 files changed, 293 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdfdc918c..2783c3b86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Features: - Add support for `${workspaceFolder}`, `${workspaceFolder:name}` variables and relative paths in `cmake.exclude` setting for multi-root workspaces. [#4689](https://github.com/microsoft/vscode-cmake-tools/pull/4689) - Add `onConfigureResult` event to the CMake Tools API that fires after every configure attempt (success or failure), allowing dependent extensions to detect and react to configure failures. [#4021](https://github.com/microsoft/vscode-cmake-tools/issues/4021) - Add `cmake.preConfigureTask` setting to execute a named VS Code task before every CMake configure. [#2449](https://github.com/microsoft/vscode-cmake-tools/issues/2449) [#4960](https://github.com/microsoft/vscode-cmake-tools/pull/4960) [@erdemiru](https://github.com/erdemiru) -- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) +- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. diff --git a/docs/cmake-settings.md b/docs/cmake-settings.md index 3d03a3ceb1..4f4b8b44a0 100644 --- a/docs/cmake-settings.md +++ b/docs/cmake-settings.md @@ -18,13 +18,14 @@ Options that support substitution, in the table below, allow variable references | `cmake.buildBeforeRun` | If `true`, build the launch/debug target before running the target. | `true` | no | | `cmake.buildDirectory` | Specify the build directory (i.e. the root directory where `CMakeCache.txt` will be generated.) | `${workspaceFolder}/build` | yes | | `cmake.buildEnvironment`| An object containing `key:value` pairs of environment variables, which will be passed only to the compiler. | `null` (no environment variables specified) | yes | +| `cmake.buildOutputGlyphs` | Glyph style for `cmake.colorizedBuildOutput: rich`. Use `ascii` if your terminal font does not render the Unicode severity glyphs (✗ ⚠ ℹ ✓). One of `unicode`, `ascii`. | `unicode` | no | | `cmake.buildTask` | If `true`, generate VS Code tasks for building. | `false` | no | | `cmake.buildToolArgs` | An array of additional arguments to pass to the underlying build tool. | `[]` (empty array-no additional arguments) | yes | | `cmake.cacheInit` | Path, or list of paths, to cache-initialization files. Passed to CMake via the `-C` command-line argument. | `[]` (empty array-no cache initializer files) | no | | `cmake.clearOutputBeforeBuild` | If `true`, clear output before building. | `true` | no | | `cmake.cmakeCommunicationMode` | Specifies the protocol for communicating between the extension and CMake | `automatic` | no | | `cmake.cmakePath`| Specify location of the cmake executable. | `cmake` (causes CMake Tools to search the `PATH` environment variable, as well as some hard-coded locations.) | Supports substitution for `workspaceRoot`, `workspaceFolder`, `workspaceRootFolderName`, `userHome`, `${command:...}` and `${env:...}`. Other substitutions result in an empty string. | -| `cmake.colorizedBuildOutput` | Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated **CMake Build** integrated terminal (the VS Code Output panel cannot render ANSI colors). One of `off`, `severity`. | `off` | no | +| `cmake.colorizedBuildOutput` | Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated **CMake Build** integrated terminal (the VS Code Output panel cannot render ANSI colors). `rich` additionally adds severity glyphs, dimmed progress, a build header, and a colored summary footer. One of `off`, `severity`, `rich`. | `off` | no | | `cmake.configureArgs` | Arguments to CMake that will be passed during the configure process. Prefer to use `cmake.configureSettings` or [CMake variants](variants.md).
It is not recommended to pass `-D` arguments using this setting. | `[]` (empty array-no arguments) | yes | | `cmake.configureEnvironment` | An object containing `key:value` pairs of environment variables, which will be passed to CMake only when configuring.| `null` (no environment variable pairs) | yes | | `cmake.configureOnEdit` | Automatically configure CMake project directories when the path in the `cmake.sourceDirectory` setting is updated or when `CMakeLists.txt` or `*.cmake` files are saved. | `true` | no | diff --git a/package.json b/package.json index 9042a6733f..f8abce1f03 100644 --- a/package.json +++ b/package.json @@ -2429,16 +2429,32 @@ "type": "string", "enum": [ "off", - "severity" + "severity", + "rich" ], "enumDescriptions": [ "%cmake-tools.configuration.cmake.colorizedBuildOutput.off.description%", - "%cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description%" + "%cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description%", + "%cmake-tools.configuration.cmake.colorizedBuildOutput.rich.description%" ], "default": "off", "markdownDescription": "%cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription%", "scope": "window" }, + "cmake.buildOutputGlyphs": { + "type": "string", + "enum": [ + "unicode", + "ascii" + ], + "enumDescriptions": [ + "%cmake-tools.configuration.cmake.buildOutputGlyphs.unicode.description%", + "%cmake-tools.configuration.cmake.buildOutputGlyphs.ascii.description%" + ], + "default": "unicode", + "markdownDescription": "%cmake-tools.configuration.cmake.buildOutputGlyphs.markdownDescription%", + "scope": "window" + }, "cmake.configureSettings": { "type": "object", "default": {}, diff --git a/package.nls.json b/package.nls.json index a20830498c..142611eec0 100644 --- a/package.nls.json +++ b/package.nls.json @@ -123,6 +123,10 @@ "cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription": "Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors. Because the VS Code Output panel cannot render ANSI colors, colorized output is shown in a dedicated **CMake Build** integrated terminal (the regular CMake/Build Output channel is unchanged).", "cmake-tools.configuration.cmake.colorizedBuildOutput.off.description": "Do not colorize build output.", "cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description": "Highlight build errors, warnings, and notes by severity, shown in a dedicated CMake Build integrated terminal.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.rich.description": "Everything in 'severity', plus accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer (error/warning counts and elapsed time).", + "cmake-tools.configuration.cmake.buildOutputGlyphs.markdownDescription": "Glyph style used by `cmake.colorizedBuildOutput: rich`. Use `ascii` if your terminal font does not render the Unicode severity glyphs (✗ ⚠ ℹ ✓).", + "cmake-tools.configuration.cmake.buildOutputGlyphs.unicode.description": "Use Unicode severity glyphs (✗ ⚠ ℹ ✓).", + "cmake-tools.configuration.cmake.buildOutputGlyphs.ascii.description": "Use ASCII severity markers (x ! i +) for fonts that do not render the Unicode glyphs.", "cmake-tools.configuration.cmake.configureSettings.description": "CMake variables to set on the command line. This setting is specific to kits and will not be used for CMake Presets.", "cmake-tools.configuration.cmake.cacheInit.string.description": "Path to a cache-initializing CMake file.", "cmake-tools.configuration.cmake.cacheInit.array.description": "List of cache initializer files.", diff --git a/src/buildOutputTerminal.ts b/src/buildOutputTerminal.ts index 06b4694681..ac1bff2ec9 100644 --- a/src/buildOutputTerminal.ts +++ b/src/buildOutputTerminal.ts @@ -10,7 +10,7 @@ import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; -import { BuildColorMode, colorizeBuildLine } from '@cmt/colorize'; +import { BuildColorMode, BuildOutcome, GlyphStyle, decorateBuildLine, renderBuildBanner, renderBuildSummary } from '@cmt/colorize'; nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); const localize: nls.LocalizeFunc = nls.loadMessageBundle(); @@ -27,6 +27,7 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { private terminal?: vscode.Terminal; private isOpen = false; private pending: string[] = []; + private buildStart = 0; // vscode.Pseudoterminal: called when the terminal is first shown. open(): void { @@ -65,21 +66,50 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { /** * Prepare the terminal at the start of a build: create it if needed, clear it - * when requested, and reveal it without stealing keyboard focus. + * when requested, record the start time, optionally print a bold banner, and + * reveal it without stealing keyboard focus. */ - prepareForBuild(clear: boolean): void { + prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string): void { this.ensureTerminal(); if (clear) { // Clear screen + scrollback + move cursor home. this.emit('\u001b[2J\u001b[3J\u001b[H'); } + this.buildStart = Date.now(); + if (bannerTarget) { + const header = localize('build.colorized.building', 'Building: {0}', bannerTarget); + this.emit(renderBuildBanner(header, glyphs) + EOL); + } this.terminal?.show(true); } - /** Write a single build-output line, colorized according to `mode`. */ - writeLine(line: string, mode: BuildColorMode): void { - this.ensureTerminal(); - this.emit(colorizeBuildLine(line, mode) + EOL); + /** Write a single build-output line, decorated according to `mode`/`glyphs`. */ + writeLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): void { + if (!this.terminal) { + // The user closed the terminal mid-build; drop output rather than + // recreate it hidden (which would surface stale output next build). + return; + } + this.emit(decorateBuildLine(line, mode, glyphs) + EOL); + } + + /** Print the ruled, colored, localized build-summary footer (rich mode). */ + writeSummary(outcome: BuildOutcome, counts: { errors: number; warnings: number }, glyphs: GlyphStyle): void { + if (!this.terminal) { + return; + } + const seconds = ((this.buildStart ? Date.now() - this.buildStart : 0) / 1000).toFixed(1); + const word = outcome === 'succeeded' + ? localize('build.colorized.succeeded', 'Build succeeded') + : outcome === 'cancelled' + ? localize('build.colorized.cancelled', 'Build cancelled') + : localize('build.colorized.failed', 'Build failed'); + const dash = glyphs === 'unicode' ? '—' : '-'; + const countsText = localize('build.colorized.counts', '{0} error(s), {1} warning(s)', counts.errors, counts.warnings); + const statusText = `${word} ${dash} ${countsText} (${seconds}s)`; + for (const line of renderBuildSummary(outcome, statusText, glyphs)) { + this.emit(line + EOL); + } } dispose(): void { diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index 924ea320f0..a7138b2b97 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -2448,8 +2448,10 @@ export class CMakeProject { const combinedToken = util.createCombinedCancellationToken(cancel, cancellationToken); combinedToken.onCancellationRequested(() => rollbar.invokeAsync(localize('stop.on.cancellation', 'Stop on cancellation'), () => this.stop())); buildLogger.info(localize('starting.build', 'Starting build')); - if (drv!.config.colorizedBuildOutput !== 'off') { - buildOutputTerminal().prepareForBuild(drv!.config.clearOutputBeforeBuild); + const buildColorMode = drv!.config.colorizedBuildOutput; + if (buildColorMode !== 'off') { + const banner = buildColorMode === 'rich' ? targetName : undefined; + buildOutputTerminal().prepareForBuild(drv!.config.clearOutputBeforeBuild, drv!.config.buildOutputGlyphs, banner); } await setContextAndStore(isBuildingKey, true); const rc = await drv!.build(newTargets, consumer, isBuildCommand); @@ -2462,6 +2464,8 @@ export class CMakeProject { } else { buildLogger.info(localize('build.finished.with.code', 'Build finished with exit code {0}', rc)); } + let buildErrors = 0; + let buildWarnings = 0; if (drv!.config.parseBuildDiagnostics) { const fileDiags = await consumer!.compileConsumer.resolveDiagnostics(drv!.binaryDir, drv!.sourceDir); if (fileDiags.length > 0) { @@ -2470,6 +2474,16 @@ export class CMakeProject { // the incremental diagnostics added during the build. populateCollection(collections.build, fileDiags); } + // Count from the resolved diagnostics — already filtered by + // `enableOutputParsers`, so the rich-mode summary footer matches + // the Problems panel exactly. + for (const fileDiag of fileDiags) { + if (fileDiag.diag.severity === vscode.DiagnosticSeverity.Error) { + buildErrors++; + } else if (fileDiag.diag.severity === vscode.DiagnosticSeverity.Warning) { + buildWarnings++; + } + } // When empty: either the build succeeded (collection was // already cleared at build start), or the build ran through // the task path and diagnostics were populated by @@ -2480,6 +2494,10 @@ export class CMakeProject { // remain from a previous build that had parsing enabled. collections.build.clear(); } + if (buildColorMode === 'rich') { + const outcome = rc === null ? 'cancelled' : (rc === 0 && buildErrors === 0 ? 'succeeded' : 'failed'); + buildOutputTerminal().writeSummary(outcome, { errors: buildErrors, warnings: buildWarnings }, drv!.config.buildOutputGlyphs); + } await this.cTestController.refreshTests(drv!); await this.refreshCompileDatabase(drv!.expansionOptions); return { diff --git a/src/cmakeTaskProvider.ts b/src/cmakeTaskProvider.ts index 9896bc9f6e..6a1b714e5a 100644 --- a/src/cmakeTaskProvider.ts +++ b/src/cmakeTaskProvider.ts @@ -17,7 +17,7 @@ import * as util from '@cmt/util'; import * as expand from '@cmt/expand'; import { CommandResult } from 'vscode-cmake-tools'; import { CompileOutputConsumer } from '@cmt/diagnostics/build'; -import { BuildColorMode, colorizeBuildLine } from '@cmt/colorize'; +import { BuildColorMode, GlyphStyle, decorateBuildLine } from '@cmt/colorize'; import collections from '@cmt/diagnostics/collections'; import { addDiagnosticToCollection, diagnosticSeverity, populateCollection } from '@cmt/diagnostics/util'; @@ -372,6 +372,7 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc // How build-tool output is colorized in this terminal. Only set for build // tasks (see runBuildTask); stays 'off' for config/test/package/workflow. private colorMode: BuildColorMode = 'off'; + private glyphStyle: GlyphStyle = 'unicode'; public get onDidWrite(): vscode.Event { return this.writeEmitter.event; } @@ -386,12 +387,12 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc // These two override methods are used to write output and error messages to the terminal, as well // as call the parent class's output and error methods, which store the stdout and stderr messages for returning that info later. override output(line: string): void { - this.writeEmitter.fire(colorizeBuildLine(line, this.colorMode) + endOfLine); + this.writeEmitter.fire(decorateBuildLine(line, this.colorMode, this.glyphStyle) + endOfLine); super.output(line); } override error(error: string): void { - this.writeEmitter.fire(colorizeBuildLine(error, this.colorMode) + endOfLine); + this.writeEmitter.fire(decorateBuildLine(error, this.colorMode, this.glyphStyle) + endOfLine); super.error(error); } @@ -564,6 +565,7 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc if (cmakeDriver) { cmakePath = cmakeDriver.getCMakeCommand(); this.colorMode = cmakeDriver.config.colorizedBuildOutput; + this.glyphStyle = cmakeDriver.config.buildOutputGlyphs; if (!this.options) { this.options = {}; diff --git a/src/colorize.ts b/src/colorize.ts index 466c02f0ce..286585e3d8 100644 --- a/src/colorize.ts +++ b/src/colorize.ts @@ -17,7 +17,8 @@ * axis stays uncluttered for red-green color vision deficiency. */ -export type BuildColorMode = 'off' | 'severity'; +export type BuildColorMode = 'off' | 'severity' | 'rich'; +export type GlyphStyle = 'unicode' | 'ascii'; export enum BuildLineSeverity { Error, @@ -29,6 +30,8 @@ export enum BuildLineSeverity { const ESC = '\u001b'; const RESET = `${ESC}[0m`; +const BOLD = `${ESC}[1m`; +const DIM = `${ESC}[2m`; /** Bold red. */ const SGR_ERROR = `${ESC}[1;31m`; @@ -39,6 +42,18 @@ const SGR_NOTE = `${ESC}[36m`; /** Green. */ const SGR_SUCCESS = `${ESC}[32m`; +// Force text (non-emoji) presentation for glyphs that have an emoji variant, so +// they render single-width in the terminal across platforms/fonts. +const VS_TEXT = '\uFE0E'; +const GLYPHS: Record> = { + unicode: { Error: '✗', Warning: `⚠${VS_TEXT}`, Note: `ℹ${VS_TEXT}`, Success: '✓' }, + ascii: { Error: 'x', Warning: '!', Note: 'i', Success: '+' } +}; + +// Build-progress "noise" lines such as "[ 50%] ..." or "[12/34] ...". These are +// de-emphasized (dimmed) in rich mode so real diagnostics stand out. +const PROGRESS_RE = /^\s*\[\s*(?:\d+%|\d+\/\d+)\s*\]/; + // Conservative, well-anchored patterns covering the common compiler/build tools. // GCC/Clang/CMake use "::: :"; MSVC uses // " C####:" / "LNK####" / "RC####"; Ninja prints "FAILED:". @@ -101,3 +116,90 @@ export function colorizeBuildLine(line: string, mode: BuildColorMode): string { const sgr = sgrFor(classifyBuildLine(line)); return sgr ? `${sgr}${line}${RESET}` : line; } + +function glyphFor(severity: BuildLineSeverity, glyphs: GlyphStyle): string | undefined { + switch (severity) { + case BuildLineSeverity.Error: + return GLYPHS[glyphs].Error; + case BuildLineSeverity.Warning: + return GLYPHS[glyphs].Warning; + case BuildLineSeverity.Note: + return GLYPHS[glyphs].Note; + case BuildLineSeverity.Success: + return GLYPHS[glyphs].Success; + default: + return undefined; + } +} + +/** Whether a line is build-progress noise (e.g. "[ 50%] ..." / "[12/34] ..."). */ +export function isProgressNoise(line: string): boolean { + return PROGRESS_RE.test(line); +} + +/** + * Decorate a single build-output line for display in the integrated terminal. + * + * - `off` / `severity`: identical to {@link colorizeBuildLine} (behavior unchanged). + * - `rich`: in addition to the severity color, prefixes an accessible severity + * glyph (the severity word stays in the text, so meaning is never color-only) + * and dims build-progress noise. Lines that already contain ANSI pass through. + */ +export function decorateBuildLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): string { + if (mode !== 'rich') { + return colorizeBuildLine(line, mode); + } + if (line.includes(ESC)) { + return line; + } + const severity = classifyBuildLine(line); + const sgr = sgrFor(severity); + if (sgr) { + const glyph = glyphFor(severity, glyphs); + return `${sgr}${glyph ? `${glyph} ` : ''}${line}${RESET}`; + } + if (isProgressNoise(line)) { + return `${DIM}${line}${RESET}`; + } + return line; +} + +/** A bold header line printed at the start of a rich build. `headerText` is + * already localized by the caller (this module stays vscode-nls-free). */ +export function renderBuildBanner(headerText: string, glyphs: GlyphStyle): string { + const rule = (glyphs === 'unicode' ? '─' : '-').repeat(8); + return `${BOLD}${rule} ${headerText} ${rule}${RESET}`; +} + +export type BuildOutcome = 'succeeded' | 'failed' | 'cancelled'; + +/** + * A two-line, ruled summary printed at the end of a rich build. `statusText` is + * already localized by the caller; this function only applies a color, a glyph, + * and a rule. Color/glyph never carry meaning alone — `statusText` always + * contains the outcome word and the counts. + */ +export function renderBuildSummary(outcome: BuildOutcome, statusText: string, glyphs: GlyphStyle): string[] { + let sgr: string; + let severity: BuildLineSeverity; + switch (outcome) { + case 'succeeded': + sgr = `${ESC}[1;32m`; + severity = BuildLineSeverity.Success; + break; + case 'cancelled': + sgr = `${ESC}[1;33m`; + severity = BuildLineSeverity.Warning; + break; + default: + sgr = `${ESC}[1;31m`; + severity = BuildLineSeverity.Error; + break; + } + const glyph = glyphFor(severity, glyphs); + const rule = (glyphs === 'unicode' ? '─' : '-').repeat(60); + return [ + `${sgr}${rule}${RESET}`, + `${sgr}${glyph} ${statusText}${RESET}` + ]; +} diff --git a/src/config.ts b/src/config.ts index 6f3d0486e9..340eb3bb5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -193,7 +193,8 @@ export interface ExtensionConfigurationSettings { saveBeforeBuild: boolean; buildBeforeRun: boolean; clearOutputBeforeBuild: boolean; - colorizedBuildOutput: "off" | "severity"; + colorizedBuildOutput: "off" | "severity" | "rich"; + buildOutputGlyphs: "unicode" | "ascii"; configureSettings: { [key: string]: boolean | number | string | string[] | util.CMakeValue }; cacheInit: string | string[] | null; preferredGenerators: string[]; @@ -393,9 +394,12 @@ export class ConfigurationReader implements vscode.Disposable { get clearOutputBeforeBuild(): boolean { return !!this.configData.clearOutputBeforeBuild; } - get colorizedBuildOutput(): "off" | "severity" { + get colorizedBuildOutput(): "off" | "severity" | "rich" { return this.configData.colorizedBuildOutput; } + get buildOutputGlyphs(): "unicode" | "ascii" { + return this.configData.buildOutputGlyphs; + } get configureSettings(): {[key: string]: boolean | number | string | string[] | util.CMakeValue} { return this.configData.configureSettings; } @@ -716,7 +720,8 @@ export class ConfigurationReader implements vscode.Disposable { saveBeforeBuild: new vscode.EventEmitter(), buildBeforeRun: new vscode.EventEmitter(), clearOutputBeforeBuild: new vscode.EventEmitter(), - colorizedBuildOutput: new vscode.EventEmitter<"off" | "severity">(), + colorizedBuildOutput: new vscode.EventEmitter<"off" | "severity" | "rich">(), + buildOutputGlyphs: new vscode.EventEmitter<"unicode" | "ascii">(), configureSettings: new vscode.EventEmitter<{ [key: string]: any }>(), cacheInit: new vscode.EventEmitter(), preferredGenerators: new vscode.EventEmitter(), diff --git a/src/diagnostics/build.ts b/src/diagnostics/build.ts index c2bfaee570..473ec77d58 100644 --- a/src/diagnostics/build.ts +++ b/src/diagnostics/build.ts @@ -7,7 +7,7 @@ import * as proc from '@cmt/proc'; import { OutputConsumer } from '@cmt/proc'; import * as util from '@cmt/util'; import * as vscode from 'vscode'; -import { BuildColorMode } from '@cmt/colorize'; +import { BuildColorMode, GlyphStyle } from '@cmt/colorize'; import { buildOutputTerminal } from '@cmt/buildOutputTerminal'; import * as gcc from '@cmt/diagnostics/gcc'; @@ -327,22 +327,24 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D super(); this.compileConsumer = new CompileOutputConsumer(config); this.colorMode = config.colorizedBuildOutput; + this.glyphStyle = config.buildOutputGlyphs; } /** - * How build output should be colorized in the Output channel. Read once per - * build (a fresh consumer is constructed for each build). + * How build output should be decorated in the integrated terminal. Read once + * per build (a fresh consumer is constructed for each build). */ private readonly colorMode: BuildColorMode; + private readonly glyphStyle: GlyphStyle; /** * Echo a build-output line. Parsing has already happened on the clean `line`, * so the Problems panel is unaffected. The plain line always goes to the * logger (Output channel + on-disk log file are unchanged). When colorization - * is enabled, a colorized copy is additionally mirrored to the integrated + * is enabled, a decorated copy is additionally mirrored to the integrated * terminal, where ANSI actually renders (the Output panel cannot render ANSI). */ private echo(line: string, isError: boolean) { if (this.colorMode !== 'off') { - buildOutputTerminal().writeLine(line, this.colorMode); + buildOutputTerminal().writeLine(line, this.colorMode, this.glyphStyle); } if (!this.logger) { return; diff --git a/test/unit-tests/backend/colorize.test.ts b/test/unit-tests/backend/colorize.test.ts index c686648a22..ddc22c9480 100644 --- a/test/unit-tests/backend/colorize.test.ts +++ b/test/unit-tests/backend/colorize.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { classifyBuildLine, colorizeBuildLine, BuildLineSeverity } from '@cmt/colorize'; +import { classifyBuildLine, colorizeBuildLine, decorateBuildLine, isProgressNoise, renderBuildBanner, renderBuildSummary, BuildLineSeverity } from '@cmt/colorize'; /** * Tests for the pure build-output colorizer in src/colorize.ts. @@ -92,3 +92,89 @@ suite('[colorize] colorizeBuildLine', () => { expect(colorizeBuildLine(line, 'severity').endsWith(RESET)).to.equal(true); }); }); + +suite('[colorize] decorateBuildLine (rich)', () => { + test('off mode returns the line unchanged', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(decorateBuildLine(line, 'off', 'unicode')).to.equal(line); + }); + test('severity mode is identical to colorizeBuildLine (no glyph)', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(decorateBuildLine(line, 'severity', 'unicode')).to.equal(colorizeBuildLine(line, 'severity')); + }); + test('rich error: bold red + unicode glyph + trailing reset', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(`${ESC}[1;31m\u2717 ${line}${RESET}`); + }); + test('rich warning: yellow + ascii glyph', () => { + const line = '/src/main.cpp:7:9: warning: meh'; + expect(decorateBuildLine(line, 'rich', 'ascii')).to.equal(`${ESC}[33m! ${line}${RESET}`); + }); + test('rich note: cyan + unicode glyph with text-presentation selector', () => { + const line = '/src/main.cpp:9:3: note: here'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(`${ESC}[36m\u2139\uFE0E ${line}${RESET}`); + }); + test('rich success (Built target) is green with a glyph, not dimmed', () => { + const line = '[100%] Built target app'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(`${ESC}[32m\u2713 ${line}${RESET}`); + }); + test('rich dims build-progress noise', () => { + const line = '[ 50%] Building CXX object foo.o'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(`${ESC}[2m${line}${RESET}`); + }); + test('rich leaves plain non-progress lines unchanged', () => { + const line = 'Scanning dependencies of target app'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(line); + }); + test('rich passes through lines already containing ANSI', () => { + const line = `${ESC}[31malready colored${RESET}`; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(line); + }); +}); + +suite('[colorize] isProgressNoise', () => { + test('percent progress matches', () => { + expect(isProgressNoise('[ 50%] Building CXX object')).to.equal(true); + }); + test('ratio progress matches', () => { + expect(isProgressNoise('[12/34] Linking')).to.equal(true); + }); + test('a plain diagnostic line does not match', () => { + expect(isProgressNoise('/src/x.cpp:1:1: error: x')).to.equal(false); + }); +}); + +suite('[colorize] renderBuildBanner', () => { + test('bold, contains the (already-localized) header text, unicode rule, trailing reset', () => { + const out = renderBuildBanner('Building: app', 'unicode'); + expect(out.startsWith(`${ESC}[1m`)).to.equal(true); + expect(out).to.contain('Building: app'); + expect(out).to.contain('\u2500'); + expect(out.endsWith(RESET)).to.equal(true); + }); + test('ascii style uses dashes', () => { + expect(renderBuildBanner('Building: app', 'ascii')).to.contain('-------- Building: app --------'); + }); +}); + +suite('[colorize] renderBuildSummary', () => { + test('succeeded: green rule + success glyph + verbatim status text + trailing reset', () => { + const [rule, status] = renderBuildSummary('succeeded', 'Build succeeded — 0 error(s), 0 warning(s) (3.4s)', 'unicode'); + expect(rule.startsWith(`${ESC}[1;32m`)).to.equal(true); + expect(status).to.contain('\u2713'); + expect(status).to.contain('Build succeeded — 0 error(s), 0 warning(s) (3.4s)'); + expect(status.endsWith(RESET)).to.equal(true); + }); + test('failed: red rule + error glyph (ascii)', () => { + const [rule, status] = renderBuildSummary('failed', 'Build failed - 2 error(s), 1 warning(s) (1.0s)', 'ascii'); + expect(rule.startsWith(`${ESC}[1;31m`)).to.equal(true); + expect(status).to.contain('Build failed'); + expect(status.startsWith(`${ESC}[1;31mx `)).to.equal(true); + }); + test('cancelled: yellow rule + warning glyph', () => { + const [rule, status] = renderBuildSummary('cancelled', 'Build cancelled', 'unicode'); + expect(rule.startsWith(`${ESC}[1;33m`)).to.equal(true); + expect(status).to.contain('\u26A0'); + expect(status).to.contain('Build cancelled'); + }); +}); diff --git a/test/unit-tests/config.test.ts b/test/unit-tests/config.test.ts index c9c8c25533..3060af68ab 100644 --- a/test/unit-tests/config.test.ts +++ b/test/unit-tests/config.test.ts @@ -14,6 +14,7 @@ function createConfig(conf: Partial): Configurat buildBeforeRun: true, clearOutputBeforeBuild: true, colorizedBuildOutput: "off", + buildOutputGlyphs: "unicode", configureSettings: {}, cacheInit: null, preferredGenerators: [], From fb934a27f7da68d7487318501465284fa57d8d45 Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Thu, 18 Jun 2026 17:16:03 -0500 Subject: [PATCH 4/9] Make the colorized terminal the single build surface with clickable errors When colorized build output is enabled, the dedicated CMake Build terminal is now the single visible surface: a failed build reveals that terminal (honoring cmake.revealLog) instead of stealing focus to the Output channel, and per-line build output is no longer duplicated into the channel (it still goes to the on-disk log file, so diagnostics are preserved). Also makes diagnostic locations clickable: a pseudoterminal has no cwd, so relative paths (e.g. Ninja's ../src/x.cpp) weren't linkable; we now rewrite a relative leading diagnostic path to an absolute one (display-only, when the file exists) so VS Code's built-in terminal link detection jumps to file:line:col. off mode is byte-identical; the Problems panel and log file are unchanged. Prototype for #478. --- CHANGELOG.md | 2 +- src/buildOutputTerminal.ts | 47 ++++++++++--- src/cmakeProject.ts | 24 +++++-- src/colorize.ts | 46 +++++++++++++ src/diagnostics/build.ts | 12 ++++ src/logging.ts | 66 ++++++++++++------ test/unit-tests/backend/colorize.test.ts | 87 +++++++++++++++++++++++- 7 files changed, 250 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2783c3b86f..8c8b8020ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Features: - Add support for `${workspaceFolder}`, `${workspaceFolder:name}` variables and relative paths in `cmake.exclude` setting for multi-root workspaces. [#4689](https://github.com/microsoft/vscode-cmake-tools/pull/4689) - Add `onConfigureResult` event to the CMake Tools API that fires after every configure attempt (success or failure), allowing dependent extensions to detect and react to configure failures. [#4021](https://github.com/microsoft/vscode-cmake-tools/issues/4021) - Add `cmake.preConfigureTask` setting to execute a named VS Code task before every CMake configure. [#2449](https://github.com/microsoft/vscode-cmake-tools/issues/2449) [#4960](https://github.com/microsoft/vscode-cmake-tools/pull/4960) [@erdemiru](https://github.com/erdemiru) -- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) +- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal where error locations are clickable. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. diff --git a/src/buildOutputTerminal.ts b/src/buildOutputTerminal.ts index ac1bff2ec9..37c30ac285 100644 --- a/src/buildOutputTerminal.ts +++ b/src/buildOutputTerminal.ts @@ -3,14 +3,16 @@ * * The VS Code Output panel is a Monaco text editor and does NOT render ANSI * escape codes (it shows them as literal text). The integrated terminal, backed - * by xterm.js, does render ANSI. So when colorized build output is enabled, the - * default (Output-channel) build path mirrors its output here, where the colors - * actually show. The Output channel and on-disk log file are left untouched. + * by xterm.js, does render ANSI. So when colorized build output is enabled, this + * terminal becomes the single visible build surface where the colors actually + * show. The per-line build output is still written to the on-disk log file (for + * diagnostics), but no longer duplicated into the Output channel. */ import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; -import { BuildColorMode, BuildOutcome, GlyphStyle, decorateBuildLine, renderBuildBanner, renderBuildSummary } from '@cmt/colorize'; +import * as util from '@cmt/util'; +import { BuildColorMode, BuildOutcome, GlyphStyle, decorateBuildLine, linkifyLeadingPath, renderBuildBanner, renderBuildSummary } from '@cmt/colorize'; nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); const localize: nls.LocalizeFunc = nls.loadMessageBundle(); @@ -28,6 +30,31 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { private isOpen = false; private pending: string[] = []; private buildStart = 0; + // Base directories used to absolutize relative diagnostic paths so VS Code's + // built-in terminal link detection can make them clickable. Cached per build. + private baseDirs: string[] = []; + private readonly linkCache = new Map(); + + private readonly resolveExisting = (rel: string): string | undefined => { + if (this.linkCache.has(rel)) { + return this.linkCache.get(rel); + } + let abs: string | undefined; + for (const base of this.baseDirs) { + const candidate = util.resolvePath(rel, base); + if (util.checkFileExistsSync(candidate)) { + abs = candidate; + break; + } + } + this.linkCache.set(rel, abs); + return abs; + }; + + /** Reveal the build terminal; `focus` takes keyboard focus. No-op if not created. */ + reveal(focus: boolean): void { + this.terminal?.show(!focus); + } // vscode.Pseudoterminal: called when the terminal is first shown. open(): void { @@ -66,11 +93,13 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { /** * Prepare the terminal at the start of a build: create it if needed, clear it - * when requested, record the start time, optionally print a bold banner, and - * reveal it without stealing keyboard focus. + * when requested, record the start time, and optionally print a bold banner. + * Revealing is left to the caller so it can honor `cmake.revealLog`. */ - prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string): void { + prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs: string[] = []): void { this.ensureTerminal(); + this.baseDirs = baseDirs; + this.linkCache.clear(); if (clear) { // Clear screen + scrollback + move cursor home. this.emit('\u001b[2J\u001b[3J\u001b[H'); @@ -80,7 +109,6 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { const header = localize('build.colorized.building', 'Building: {0}', bannerTarget); this.emit(renderBuildBanner(header, glyphs) + EOL); } - this.terminal?.show(true); } /** Write a single build-output line, decorated according to `mode`/`glyphs`. */ @@ -90,7 +118,8 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { // recreate it hidden (which would surface stale output next build). return; } - this.emit(decorateBuildLine(line, mode, glyphs) + EOL); + const linked = this.baseDirs.length > 0 ? linkifyLeadingPath(line, this.resolveExisting) : line; + this.emit(decorateBuildLine(linked, mode, glyphs) + EOL); } /** Print the ruled, colored, localized build-summary footer (rich mode). */ diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index a7138b2b97..a261a97d58 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -2338,7 +2338,9 @@ export class CMakeProject { */ async runBuild(targets?: string[], showCommandOnly?: boolean, taskConsumer?: proc.OutputConsumer, isBuildCommand?: boolean, cancellationToken?: vscode.CancellationToken): Promise { if (!showCommandOnly) { - log.showChannel(); + if (this.workspaceContext.config.colorizedBuildOutput === 'off') { + log.showChannel(); + } log.info(localize('run.build', 'Building folder: {0}', await this.binaryDir || this.folderName), (targets && targets.length > 0) ? targets.join(', ') : ''); } let drv: CMakeDriver | null; @@ -2398,7 +2400,7 @@ export class CMakeProject { buildLogger.info(localize('starting.build', 'Starting build')); await setContextAndStore(isBuildingKey, true); rc = await drv!.build(newTargets, taskConsumer, isBuildCommand); - if (rc !== 0) { + if (rc !== 0 && this.workspaceContext.config.colorizedBuildOutput === 'off') { log.showChannel(true); // in case build has failed } await setContextAndStore(isBuildingKey, false); @@ -2451,13 +2453,27 @@ export class CMakeProject { const buildColorMode = drv!.config.colorizedBuildOutput; if (buildColorMode !== 'off') { const banner = buildColorMode === 'rich' ? targetName : undefined; - buildOutputTerminal().prepareForBuild(drv!.config.clearOutputBeforeBuild, drv!.config.buildOutputGlyphs, banner); + buildOutputTerminal().prepareForBuild(drv!.config.clearOutputBeforeBuild, drv!.config.buildOutputGlyphs, banner, [drv!.binaryDir, drv!.sourceDir]); + const startReveal = logging.revealLogDecision(); + if (startReveal.show) { + buildOutputTerminal().reveal(startReveal.focus); + } } await setContextAndStore(isBuildingKey, true); const rc = await drv!.build(newTargets, consumer, isBuildCommand); await setContextAndStore(isBuildingKey, false); if (rc !== 0) { - log.showChannel(true); // in case build has failed + if (buildColorMode !== 'off') { + // Reveal the colorized build terminal (not the Output channel) + // so a failed build doesn't yank focus away from it. Honor the + // `cmake.revealLog` setting exactly as the channel reveal would. + const reveal = logging.revealLogDecision(true); + if (reveal.show) { + buildOutputTerminal().reveal(reveal.focus); + } + } else { + log.showChannel(true); // in case build has failed + } } if (rc === null) { buildLogger.info(localize('build.was.terminated', 'Build was terminated')); diff --git a/src/colorize.ts b/src/colorize.ts index 286585e3d8..014a9dbc58 100644 --- a/src/colorize.ts +++ b/src/colorize.ts @@ -164,6 +164,52 @@ export function decorateBuildLine(line: string, mode: BuildColorMode, glyphs: Gl return line; } +/** True if `p` looks like an absolute path (drive, UNC, or leading slash), platform-independently. */ +export function isAbsoluteLike(p: string): boolean { + return /^([A-Za-z]:[\\/]|[\\/])/.test(p); +} + +// Leading diagnostic location: GCC/Clang/Ninja ":[:]:" or MSVC "([,]):". +// The MSVC form requires the trailing ':' so a file literally named like "foo(1).cpp" is not mis-split. +const LEADING_LOCATION_RE = /^(\s*)(.+?)(:\d+(?::\d+)?:|\(\d+(?:,\d+)?\):)/; + +/** The leading "" token of a diagnostic location line, if present. */ +export function leadingPathToken(line: string): { file: string; start: number; end: number } | undefined { + const m = LEADING_LOCATION_RE.exec(line); + if (!m) { + return undefined; + } + const start = m[1].length; + const file = m[2]; + return { file, start, end: start + file.length }; +} + +/** + * Display-only: rewrite the leading *relative* source path of a diagnostic line to + * an absolute path so VS Code's built-in terminal link detection can make it + * clickable (a Pseudoterminal has no cwd, so relative paths are not linkable). + * + * `resolveExisting(rel)` must return the absolute path iff the file exists, else + * `undefined`. This keeps the module pure — all filesystem access is the caller's. + * Only error/warning/note lines are touched, and only when the path is relative + * and resolves to a real file, so it never corrupts non-location text. + */ +export function linkifyLeadingPath(line: string, resolveExisting: (rel: string) => string | undefined): string { + if (line.includes(ESC)) { + return line; + } + const severity = classifyBuildLine(line); + if (severity !== BuildLineSeverity.Error && severity !== BuildLineSeverity.Warning && severity !== BuildLineSeverity.Note) { + return line; + } + const tok = leadingPathToken(line); + if (!tok || isAbsoluteLike(tok.file)) { + return line; + } + const abs = resolveExisting(tok.file); + return abs ? line.slice(0, tok.start) + abs + line.slice(tok.end) : line; +} + /** A bold header line printed at the start of a rich build. `headerText` is * already localized by the caller (this module stays vscode-nls-free). */ export function renderBuildBanner(headerText: string, glyphs: GlyphStyle): string { diff --git a/src/diagnostics/build.ts b/src/diagnostics/build.ts index 473ec77d58..fa9be19ca2 100644 --- a/src/diagnostics/build.ts +++ b/src/diagnostics/build.ts @@ -344,7 +344,19 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D */ private echo(line: string, isError: boolean) { if (this.colorMode !== 'off') { + // Colorized: the terminal is the single visible build surface. The plain + // line still goes to the on-disk log file and developer console, but NOT + // the Output channel — this avoids duplicating the stream and the channel + // stealing focus away from the terminal. buildOutputTerminal().writeLine(line, this.colorMode, this.glyphStyle); + if (this.logger) { + if (isError) { + this.logger.errorFileOnly(line); + } else { + this.logger.infoFileOnly(line); + } + } + return; } if (!this.logger) { return; diff --git a/src/logging.ts b/src/logging.ts index 42439f95fb..119ab866ff 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -147,6 +147,18 @@ class SingletonLogger { } private _log(level: LogLevel, ...args: Stringable[]) { + this._emit(level, true, args); + } + + /** Like the level methods, but writes to the file/console only — never the Output channel. */ + infoFileOnly(...args: Stringable[]) { + this._emit(LogLevel.Info, false, args); + } + errorFileOnly(...args: Stringable[]) { + this._emit(LogLevel.Error, false, args); + } + + private _emit(level: LogLevel, toChannel: boolean, args: Stringable[]) { const trace = vscode.workspace.getConfiguration('cmake').get('enableTraceLogging', false); if (level === LogLevel.Trace && !trace) { return; @@ -175,8 +187,8 @@ class SingletonLogger { this._logStream.then(strm => strm.write(raw_message + '\n')).catch(e => { console.error('Unhandled error while writing CMakeTools log file', e); }); - // Write to our output channel - if (levelEnabled(level)) { + // Write to our output channel (skipped for file-only messages). + if (toChannel && levelEnabled(level)) { const showTimestamps = vscode.workspace.getConfiguration('cmake').get('showTimestampsInOutput', false); this._channel.appendLine(showTimestamps ? raw_message : user_message); } @@ -245,6 +257,13 @@ export class Logger { error(...args: Stringable[]) { SingletonLogger.instance().error(this.tag, ...args); } + /** Log at Info/Error level to the file and developer console only — never the Output channel. */ + infoFileOnly(...args: Stringable[]) { + SingletonLogger.instance().infoFileOnly(this.tag, ...args); + } + errorFileOnly(...args: Stringable[]) { + SingletonLogger.instance().errorFileOnly(this.tag, ...args); + } fatal(...args: Stringable[]) { SingletonLogger.instance().fatal(this.tag, ...args); } @@ -254,23 +273,9 @@ export class Logger { } showChannel(error_to_show?: boolean) { - const reveal_log = vscode.workspace.getConfiguration('cmake').get('revealLog', 'always'); - - let should_show: boolean = false; - if (reveal_log === 'always') { - should_show = true; - } - // won't show if no target information - if (reveal_log === 'error' && error_to_show !== undefined) { - should_show = error_to_show; - } - const should_focus = (reveal_log === 'focus'); - if (should_focus) { - should_show = true; - } - - if (should_show) { - SingletonLogger.instance().showChannel(!should_focus); + const { show, focus } = revealLogDecision(error_to_show); + if (show) { + SingletonLogger.instance().showChannel(!focus); } } @@ -284,6 +289,29 @@ export function createLogger(tag: string) { return new Logger(tag); } +/** + * Decide, based on the `cmake.revealLog` setting, whether a surface (the Output + * channel or the colorized build terminal) should be revealed, and whether it + * should take focus. Shared so the colorized build terminal honors `revealLog` + * exactly as the Output channel does. + */ +export function revealLogDecision(error_to_show?: boolean): { show: boolean; focus: boolean } { + const reveal_log = vscode.workspace.getConfiguration('cmake').get('revealLog', 'always'); + let show = false; + if (reveal_log === 'always') { + show = true; + } + // won't show if no target information + if (reveal_log === 'error' && error_to_show !== undefined) { + show = error_to_show; + } + const focus = (reveal_log === 'focus'); + if (focus) { + show = true; + } + return { show, focus }; +} + export async function showLogFile(): Promise { await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(logFilePath())); } diff --git a/test/unit-tests/backend/colorize.test.ts b/test/unit-tests/backend/colorize.test.ts index ddc22c9480..924b5512b0 100644 --- a/test/unit-tests/backend/colorize.test.ts +++ b/test/unit-tests/backend/colorize.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { classifyBuildLine, colorizeBuildLine, decorateBuildLine, isProgressNoise, renderBuildBanner, renderBuildSummary, BuildLineSeverity } from '@cmt/colorize'; +import { classifyBuildLine, colorizeBuildLine, decorateBuildLine, isProgressNoise, renderBuildBanner, renderBuildSummary, linkifyLeadingPath, isAbsoluteLike, BuildLineSeverity } from '@cmt/colorize'; /** * Tests for the pure build-output colorizer in src/colorize.ts. @@ -178,3 +178,88 @@ suite('[colorize] renderBuildSummary', () => { expect(status).to.contain('Build cancelled'); }); }); + +suite('[colorize] isAbsoluteLike', () => { + test('Windows drive path (backslash)', () => { + expect(isAbsoluteLike('C:\\a\\b')).to.equal(true); + }); + test('Windows drive path (forward slash)', () => { + expect(isAbsoluteLike('C:/a/b')).to.equal(true); + }); + test('POSIX leading slash', () => { + expect(isAbsoluteLike('/a/b')).to.equal(true); + }); + test('UNC / leading backslash', () => { + expect(isAbsoluteLike('\\\\srv\\share')).to.equal(true); + }); + test('relative with dotdot', () => { + expect(isAbsoluteLike('../a/b')).to.equal(false); + }); + test('relative plain', () => { + expect(isAbsoluteLike('src/a.cpp')).to.equal(false); + }); +}); + +suite('[colorize] linkifyLeadingPath', () => { + test('GCC relative path is absolutized when the file exists', () => { + const r = (rel: string) => rel === '../src/x.cpp' ? '/abs/src/x.cpp' : undefined; + expect(linkifyLeadingPath('../src/x.cpp:10:5: error: boom', r)).to.equal('/abs/src/x.cpp:10:5: error: boom'); + }); + test('GCC relative path without a column is absolutized', () => { + const r = (rel: string) => rel === 'src/x.cpp' ? '/abs/src/x.cpp' : undefined; + expect(linkifyLeadingPath('src/x.cpp:7: warning: meh', r)).to.equal('/abs/src/x.cpp:7: warning: meh'); + }); + test('MSVC relative path is absolutized', () => { + const r = (rel: string) => rel === 'src\\x.cpp' ? 'C:\\abs\\src\\x.cpp' : undefined; + expect(linkifyLeadingPath('src\\x.cpp(12): error C2065: x', r)).to.equal('C:\\abs\\src\\x.cpp(12): error C2065: x'); + }); + test('a filename containing "(n)" is not mis-split (GCC colon wins)', () => { + const r = (rel: string) => rel === 'foo(1).cpp' ? '/abs/foo(1).cpp' : undefined; + expect(linkifyLeadingPath('foo(1).cpp:12: error: e', r)).to.equal('/abs/foo(1).cpp:12: error: e'); + }); + test('relative path containing spaces is absolutized as a whole', () => { + const r = (rel: string) => rel === '../my src/x.cpp' ? '/abs/my src/x.cpp' : undefined; + expect(linkifyLeadingPath('../my src/x.cpp:3:1: error: e', r)).to.equal('/abs/my src/x.cpp:3:1: error: e'); + }); + test('missing file leaves the line unchanged (resolver called once)', () => { + let called = 0; + const r = (_rel: string) => { + called++; return undefined; + }; + const line = '../src/x.cpp:10:5: error: boom'; + expect(linkifyLeadingPath(line, r)).to.equal(line); + expect(called).to.equal(1); + }); + test('absolute POSIX path is unchanged and the resolver is not called', () => { + let called = 0; + const r = (_rel: string) => { + called++; return '/nope'; + }; + const line = '/abs/src/x.cpp:1:1: error: boom'; + expect(linkifyLeadingPath(line, r)).to.equal(line); + expect(called).to.equal(0); + }); + test('absolute Windows path is unchanged and the resolver is not called', () => { + let called = 0; + const r = (_rel: string) => { + called++; return 'nope'; + }; + const line = 'C:\\src\\x.cpp:1:1: error: boom'; + expect(linkifyLeadingPath(line, r)).to.equal(line); + expect(called).to.equal(0); + }); + test('non-diagnostic (progress) line is unchanged and the resolver is not called', () => { + let called = 0; + const r = (_rel: string) => { + called++; return '/x'; + }; + const line = '[ 50%] Building CXX object foo.o'; + expect(linkifyLeadingPath(line, r)).to.equal(line); + expect(called).to.equal(0); + }); + test('a line already containing ANSI is passed through unchanged', () => { + const r = (_rel: string) => '/x'; + const line = `${ESC}[31m../src/x.cpp:1:1: error: e${RESET}`; + expect(linkifyLeadingPath(line, r)).to.equal(line); + }); +}); From 05056882c41b9cbf29b280ab51ec4404384aea03 Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Wed, 24 Jun 2026 09:32:32 -0500 Subject: [PATCH 5/9] Make colorized build output portable to the Output pane and add a compiler color mode Builds on the colorized build-output feature (#478) with two additions and a hardening pass: - compiler mode: a new `cmake.colorizedBuildOutput: compiler` value that forwards the compiler's and build tools' own colors instead of synthesizing them, by setting CMAKE_COLOR_DIAGNOSTICS=ON in the configure environment (bakes -fdiagnostics-color / -fcolor-diagnostics into the build rules; requires a reconfigure to take effect) and CLICOLOR_FORCE=1 in the build environment (read at invocation, no reconfigure needed). - Portable sink abstraction: colorization now produces surface-agnostic ANSI via a ColorizedBuildSink interface with a single capability gate, canRenderAnsiInOutput() (false today), and selectSink(). The integrated terminal (BuildOutputTerminal) is used today; if a future VS Code renders ANSI in the Output panel, OutputChannelBuildSink routes the same output there with no other change. - Strip-before-parse: build output is ANSI-stripped (stripAnsi) before diagnostic parsing and before going to the on-disk log, so the Problems panel and log stay clean while the terminal still receives the raw colored line. This also hardens severity/rich against a user running with forced compiler colors. Adds the `compiler` enum value across package.json/package.nls.json/config.ts/docs, wires the task-provider and driver env-injection paths, and extends the colorize backend tests (stripAnsi, compiler passthrough, selectSink). --- CHANGELOG.md | 2 +- docs/cmake-settings.md | 2 +- package.json | 8 +- package.nls.json | 1 + src/buildOutputTerminal.ts | 166 +++++++++++++++++------ src/cmakeProject.ts | 10 +- src/cmakeTaskProvider.ts | 6 +- src/colorize.ts | 60 +++++++- src/config.ts | 6 +- src/diagnostics/build.ts | 41 +++--- src/drivers/cmakeDriver.ts | 24 +++- src/extension.ts | 4 +- test/unit-tests/backend/colorize.test.ts | 102 +++++++++++++- 13 files changed, 343 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8b8020ee..ee5edc3d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Features: - Add support for `${workspaceFolder}`, `${workspaceFolder:name}` variables and relative paths in `cmake.exclude` setting for multi-root workspaces. [#4689](https://github.com/microsoft/vscode-cmake-tools/pull/4689) - Add `onConfigureResult` event to the CMake Tools API that fires after every configure attempt (success or failure), allowing dependent extensions to detect and react to configure failures. [#4021](https://github.com/microsoft/vscode-cmake-tools/issues/4021) - Add `cmake.preConfigureTask` setting to execute a named VS Code task before every CMake configure. [#2449](https://github.com/microsoft/vscode-cmake-tools/issues/2449) [#4960](https://github.com/microsoft/vscode-cmake-tools/pull/4960) [@erdemiru](https://github.com/erdemiru) -- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal where error locations are clickable. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) +- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal where error locations are clickable. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. A `compiler` mode forwards the compiler's and build tools' own colors by setting `CMAKE_COLOR_DIAGNOSTICS=ON` at configure time and `CLICOLOR_FORCE=1` at build time. The colorization is portable: it is rendered in the terminal today and is automatically routed to the Output panel if a future VS Code renders ANSI there. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. diff --git a/docs/cmake-settings.md b/docs/cmake-settings.md index 4f4b8b44a0..ada5a8296e 100644 --- a/docs/cmake-settings.md +++ b/docs/cmake-settings.md @@ -25,7 +25,7 @@ Options that support substitution, in the table below, allow variable references | `cmake.clearOutputBeforeBuild` | If `true`, clear output before building. | `true` | no | | `cmake.cmakeCommunicationMode` | Specifies the protocol for communicating between the extension and CMake | `automatic` | no | | `cmake.cmakePath`| Specify location of the cmake executable. | `cmake` (causes CMake Tools to search the `PATH` environment variable, as well as some hard-coded locations.) | Supports substitution for `workspaceRoot`, `workspaceFolder`, `workspaceRootFolderName`, `userHome`, `${command:...}` and `${env:...}`. Other substitutions result in an empty string. | -| `cmake.colorizedBuildOutput` | Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated **CMake Build** integrated terminal (the VS Code Output panel cannot render ANSI colors). `rich` additionally adds severity glyphs, dimmed progress, a build header, and a colored summary footer. One of `off`, `severity`, `rich`. | `off` | no | +| `cmake.colorizedBuildOutput` | Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated **CMake Build** integrated terminal (the VS Code Output panel cannot render ANSI colors). `rich` additionally adds severity glyphs, dimmed progress, a build header, and a colored summary footer. `compiler` forwards the compiler's and build tools' own colors via `CMAKE_COLOR_DIAGNOSTICS=ON` (configure time, requires a reconfigure) and `CLICOLOR_FORCE=1` (build time). One of `off`, `severity`, `rich`, `compiler`. | `off` | no | | `cmake.configureArgs` | Arguments to CMake that will be passed during the configure process. Prefer to use `cmake.configureSettings` or [CMake variants](variants.md).
It is not recommended to pass `-D` arguments using this setting. | `[]` (empty array-no arguments) | yes | | `cmake.configureEnvironment` | An object containing `key:value` pairs of environment variables, which will be passed to CMake only when configuring.| `null` (no environment variable pairs) | yes | | `cmake.configureOnEdit` | Automatically configure CMake project directories when the path in the `cmake.sourceDirectory` setting is updated or when `CMakeLists.txt` or `*.cmake` files are saved. | `true` | no | diff --git a/package.json b/package.json index f8abce1f03..051eda81fd 100644 --- a/package.json +++ b/package.json @@ -2430,16 +2430,18 @@ "enum": [ "off", "severity", - "rich" + "rich", + "compiler" ], "enumDescriptions": [ "%cmake-tools.configuration.cmake.colorizedBuildOutput.off.description%", "%cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description%", - "%cmake-tools.configuration.cmake.colorizedBuildOutput.rich.description%" + "%cmake-tools.configuration.cmake.colorizedBuildOutput.rich.description%", + "%cmake-tools.configuration.cmake.colorizedBuildOutput.compiler.description%" ], "default": "off", "markdownDescription": "%cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription%", - "scope": "window" + "scope": "resource" }, "cmake.buildOutputGlyphs": { "type": "string", diff --git a/package.nls.json b/package.nls.json index 142611eec0..8971fa85a4 100644 --- a/package.nls.json +++ b/package.nls.json @@ -124,6 +124,7 @@ "cmake-tools.configuration.cmake.colorizedBuildOutput.off.description": "Do not colorize build output.", "cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description": "Highlight build errors, warnings, and notes by severity, shown in a dedicated CMake Build integrated terminal.", "cmake-tools.configuration.cmake.colorizedBuildOutput.rich.description": "Everything in 'severity', plus accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer (error/warning counts and elapsed time).", + "cmake-tools.configuration.cmake.colorizedBuildOutput.compiler.description": "Show the compiler's and build tools' own colors by setting CMAKE_COLOR_DIAGNOSTICS=ON at configure time and CLICOLOR_FORCE=1 at build time. Requires a reconfigure to take effect. Best with GCC/Clang and Ninja/Make; MSVC (cl.exe) emits no colors, so prefer 'severity' or 'rich' for MSVC.", "cmake-tools.configuration.cmake.buildOutputGlyphs.markdownDescription": "Glyph style used by `cmake.colorizedBuildOutput: rich`. Use `ascii` if your terminal font does not render the Unicode severity glyphs (✗ ⚠ ℹ ✓).", "cmake-tools.configuration.cmake.buildOutputGlyphs.unicode.description": "Use Unicode severity glyphs (✗ ⚠ ℹ ✓).", "cmake-tools.configuration.cmake.buildOutputGlyphs.ascii.description": "Use ASCII severity markers (x ! i +) for fonts that do not render the Unicode glyphs.", diff --git a/src/buildOutputTerminal.ts b/src/buildOutputTerminal.ts index 37c30ac285..70c131583d 100644 --- a/src/buildOutputTerminal.ts +++ b/src/buildOutputTerminal.ts @@ -1,18 +1,24 @@ /** - * A lazily-created integrated terminal used to display colorized build output. + * Surfaces for rendering colorized build output. * * The VS Code Output panel is a Monaco text editor and does NOT render ANSI - * escape codes (it shows them as literal text). The integrated terminal, backed - * by xterm.js, does render ANSI. So when colorized build output is enabled, this - * terminal becomes the single visible build surface where the colors actually - * show. The per-line build output is still written to the on-disk log file (for - * diagnostics), but no longer duplicated into the Output channel. + * escape codes (it shows them as literal text), so today colorized build output + * is rendered in an integrated terminal ({@link BuildOutputTerminal}), backed by + * xterm.js, which does render ANSI. The colorization itself (in `@cmt/colorize`) + * produces surface-agnostic ANSI strings, so it is portable: if a future VS Code + * renders ANSI in the Output panel, {@link OutputChannelBuildSink} routes the + * SAME output to an Output channel with no other change — selected by the single + * {@link canRenderAnsiInOutput} capability gate. + * + * In both cases the per-line build output is still written to the on-disk log + * file (for diagnostics) and never duplicated into the regular CMake/Build + * Output channel. */ import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import * as util from '@cmt/util'; -import { BuildColorMode, BuildOutcome, GlyphStyle, decorateBuildLine, linkifyLeadingPath, renderBuildBanner, renderBuildSummary } from '@cmt/colorize'; +import { BuildColorMode, BuildOutcome, ColorizedBuildSink, GlyphStyle, canRenderAnsiInOutput, decorateBuildLine, linkifyLeadingPath, renderBuildBanner, renderBuildSummary, selectSink } from '@cmt/colorize'; nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); const localize: nls.LocalizeFunc = nls.loadMessageBundle(); @@ -20,7 +26,45 @@ const localize: nls.LocalizeFunc = nls.loadMessageBundle(); // Terminals require CRLF line endings; a bare '\n' causes staircasing. const EOL = '\r\n'; -class BuildOutputTerminal implements vscode.Pseudoterminal { +/** Resolve a relative diagnostic path to an absolute one (if it exists), cached. */ +function resolveExistingPath(rel: string, baseDirs: string[], cache: Map): string | undefined { + if (cache.has(rel)) { + return cache.get(rel); + } + let abs: string | undefined; + for (const base of baseDirs) { + const candidate = util.resolvePath(rel, base); + if (util.checkFileExistsSync(candidate)) { + abs = candidate; + break; + } + } + cache.set(rel, abs); + return abs; +} + +/** Linkify (absolutize a relative leading path) then decorate a build-output line. */ +function decoratedLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle, baseDirs: string[], cache: Map): string { + const linked = baseDirs.length > 0 ? linkifyLeadingPath(line, rel => resolveExistingPath(rel, baseDirs, cache)) : line; + return decorateBuildLine(linked, mode, glyphs); +} + +/** Build the localized status text for the build-summary footer (rich mode). */ +function summaryStatusText(outcome: BuildOutcome, counts: { errors: number; warnings: number }, buildStart: number, glyphs: GlyphStyle): string { + const seconds = ((buildStart ? Date.now() - buildStart : 0) / 1000).toFixed(1); + const word = outcome === 'succeeded' + ? localize('build.colorized.succeeded', 'Build succeeded') + : outcome === 'cancelled' + ? localize('build.colorized.cancelled', 'Build cancelled') + : localize('build.colorized.failed', 'Build failed'); + const dash = glyphs === 'unicode' ? '—' : '-'; + const countsText = localize('build.colorized.counts', '{0} error(s), {1} warning(s)', counts.errors, counts.warnings); + return `${word} ${dash} ${countsText} (${seconds}s)`; +} + +const CHANNEL_NAME = localize('cmake.build.colorized.terminal.name', 'CMake Build'); + +class BuildOutputTerminal implements vscode.Pseudoterminal, ColorizedBuildSink { private readonly writeEmitter = new vscode.EventEmitter(); private readonly closeEmitter = new vscode.EventEmitter(); readonly onDidWrite: vscode.Event = this.writeEmitter.event; @@ -35,22 +79,6 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { private baseDirs: string[] = []; private readonly linkCache = new Map(); - private readonly resolveExisting = (rel: string): string | undefined => { - if (this.linkCache.has(rel)) { - return this.linkCache.get(rel); - } - let abs: string | undefined; - for (const base of this.baseDirs) { - const candidate = util.resolvePath(rel, base); - if (util.checkFileExistsSync(candidate)) { - abs = candidate; - break; - } - } - this.linkCache.set(rel, abs); - return abs; - }; - /** Reveal the build terminal; `focus` takes keyboard focus. No-op if not created. */ reveal(focus: boolean): void { this.terminal?.show(!focus); @@ -76,10 +104,7 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { if (!this.terminal) { this.isOpen = false; this.pending = []; - this.terminal = vscode.window.createTerminal({ - name: localize('cmake.build.colorized.terminal.name', 'CMake Build'), - pty: this - }); + this.terminal = vscode.window.createTerminal({ name: CHANNEL_NAME, pty: this }); } } @@ -118,8 +143,7 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { // recreate it hidden (which would surface stale output next build). return; } - const linked = this.baseDirs.length > 0 ? linkifyLeadingPath(line, this.resolveExisting) : line; - this.emit(decorateBuildLine(linked, mode, glyphs) + EOL); + this.emit(decoratedLine(line, mode, glyphs, this.baseDirs, this.linkCache) + EOL); } /** Print the ruled, colored, localized build-summary footer (rich mode). */ @@ -127,15 +151,7 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { if (!this.terminal) { return; } - const seconds = ((this.buildStart ? Date.now() - this.buildStart : 0) / 1000).toFixed(1); - const word = outcome === 'succeeded' - ? localize('build.colorized.succeeded', 'Build succeeded') - : outcome === 'cancelled' - ? localize('build.colorized.cancelled', 'Build cancelled') - : localize('build.colorized.failed', 'Build failed'); - const dash = glyphs === 'unicode' ? '—' : '-'; - const countsText = localize('build.colorized.counts', '{0} error(s), {1} warning(s)', counts.errors, counts.warnings); - const statusText = `${word} ${dash} ${countsText} (${seconds}s)`; + const statusText = summaryStatusText(outcome, counts, this.buildStart, glyphs); for (const line of renderBuildSummary(outcome, statusText, glyphs)) { this.emit(line + EOL); } @@ -149,18 +165,78 @@ class BuildOutputTerminal implements vscode.Pseudoterminal { } } -let instance: BuildOutputTerminal | undefined; +/** + * A {@link ColorizedBuildSink} that writes colorized build output to a VS Code + * Output channel. Only renders colors when the Output panel can render ANSI (see + * {@link canRenderAnsiInOutput}); kept ready so the colorization is portable to + * that surface with no other change. + */ +class OutputChannelBuildSink implements ColorizedBuildSink { + private channel?: vscode.OutputChannel; + private buildStart = 0; + private baseDirs: string[] = []; + private readonly linkCache = new Map(); + + private ensureChannel(): vscode.OutputChannel { + if (!this.channel) { + this.channel = vscode.window.createOutputChannel(CHANNEL_NAME); + } + return this.channel; + } + + reveal(focus: boolean): void { + this.channel?.show(!focus); + } + + prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs: string[] = []): void { + const channel = this.ensureChannel(); + this.baseDirs = baseDirs; + this.linkCache.clear(); + if (clear) { + channel.clear(); + } + this.buildStart = Date.now(); + if (bannerTarget) { + const header = localize('build.colorized.building', 'Building: {0}', bannerTarget); + channel.appendLine(renderBuildBanner(header, glyphs)); + } + } + + writeLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): void { + if (!this.channel) { + return; + } + this.channel.appendLine(decoratedLine(line, mode, glyphs, this.baseDirs, this.linkCache)); + } + + writeSummary(outcome: BuildOutcome, counts: { errors: number; warnings: number }, glyphs: GlyphStyle): void { + if (!this.channel) { + return; + } + const statusText = summaryStatusText(outcome, counts, this.buildStart, glyphs); + for (const line of renderBuildSummary(outcome, statusText, glyphs)) { + this.channel.appendLine(line); + } + } + + dispose(): void { + this.channel?.dispose(); + this.channel = undefined; + } +} + +let instance: ColorizedBuildSink | undefined; -/** The shared colorized build-output terminal (created lazily). */ -export function buildOutputTerminal(): BuildOutputTerminal { +/** The shared colorized build-output sink (terminal today; Output channel if VS Code renders ANSI). */ +export function colorizedBuildSink(): ColorizedBuildSink { if (!instance) { - instance = new BuildOutputTerminal(); + instance = selectSink(canRenderAnsiInOutput(), () => new BuildOutputTerminal(), () => new OutputChannelBuildSink()); } return instance; } -/** Dispose the shared colorized build-output terminal, if any. */ -export function disposeBuildOutputTerminal(): void { +/** Dispose the shared colorized build-output sink, if any. */ +export function disposeColorizedBuildSink(): void { instance?.dispose(); instance = undefined; } diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index a261a97d58..160a04717a 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -27,7 +27,7 @@ import { CTestDriver } from '@cmt/ctest'; import { CPackDriver } from '@cmt/cpack'; import { WorkflowDriver } from '@cmt/workflow'; import { CMakeBuildConsumer } from '@cmt/diagnostics/build'; -import { buildOutputTerminal } from '@cmt/buildOutputTerminal'; +import { colorizedBuildSink } from '@cmt/buildOutputTerminal'; import { CMakeOutputConsumer } from '@cmt/diagnostics/cmake'; import { addDiagnosticToCollection, diagnosticSeverity, populateCollection } from '@cmt/diagnostics/util'; import { expandStrings, expandString, ExpansionOptions } from '@cmt/expand'; @@ -2453,10 +2453,10 @@ export class CMakeProject { const buildColorMode = drv!.config.colorizedBuildOutput; if (buildColorMode !== 'off') { const banner = buildColorMode === 'rich' ? targetName : undefined; - buildOutputTerminal().prepareForBuild(drv!.config.clearOutputBeforeBuild, drv!.config.buildOutputGlyphs, banner, [drv!.binaryDir, drv!.sourceDir]); + colorizedBuildSink().prepareForBuild(drv!.config.clearOutputBeforeBuild, drv!.config.buildOutputGlyphs, banner, [drv!.binaryDir, drv!.sourceDir]); const startReveal = logging.revealLogDecision(); if (startReveal.show) { - buildOutputTerminal().reveal(startReveal.focus); + colorizedBuildSink().reveal(startReveal.focus); } } await setContextAndStore(isBuildingKey, true); @@ -2469,7 +2469,7 @@ export class CMakeProject { // `cmake.revealLog` setting exactly as the channel reveal would. const reveal = logging.revealLogDecision(true); if (reveal.show) { - buildOutputTerminal().reveal(reveal.focus); + colorizedBuildSink().reveal(reveal.focus); } } else { log.showChannel(true); // in case build has failed @@ -2512,7 +2512,7 @@ export class CMakeProject { } if (buildColorMode === 'rich') { const outcome = rc === null ? 'cancelled' : (rc === 0 && buildErrors === 0 ? 'succeeded' : 'failed'); - buildOutputTerminal().writeSummary(outcome, { errors: buildErrors, warnings: buildWarnings }, drv!.config.buildOutputGlyphs); + colorizedBuildSink().writeSummary(outcome, { errors: buildErrors, warnings: buildWarnings }, drv!.config.buildOutputGlyphs); } await this.cTestController.refreshTests(drv!); await this.refreshCompileDatabase(drv!.expansionOptions); diff --git a/src/cmakeTaskProvider.ts b/src/cmakeTaskProvider.ts index 6a1b714e5a..c386a66db2 100644 --- a/src/cmakeTaskProvider.ts +++ b/src/cmakeTaskProvider.ts @@ -17,7 +17,7 @@ import * as util from '@cmt/util'; import * as expand from '@cmt/expand'; import { CommandResult } from 'vscode-cmake-tools'; import { CompileOutputConsumer } from '@cmt/diagnostics/build'; -import { BuildColorMode, GlyphStyle, decorateBuildLine } from '@cmt/colorize'; +import { BuildColorMode, GlyphStyle, decorateBuildLine, stripAnsi } from '@cmt/colorize'; import collections from '@cmt/diagnostics/collections'; import { addDiagnosticToCollection, diagnosticSeverity, populateCollection } from '@cmt/diagnostics/util'; @@ -635,11 +635,11 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc ? { output: (line: string) => { this.output(line); - compileConsumer!.output(line); + compileConsumer!.output(stripAnsi(line)); }, error: (line: string) => { this.error(line); - compileConsumer!.error(line); + compileConsumer!.error(stripAnsi(line)); } } : this; diff --git a/src/colorize.ts b/src/colorize.ts index 014a9dbc58..c85af58bd1 100644 --- a/src/colorize.ts +++ b/src/colorize.ts @@ -17,7 +17,7 @@ * axis stays uncluttered for red-green color vision deficiency. */ -export type BuildColorMode = 'off' | 'severity' | 'rich'; +export type BuildColorMode = 'off' | 'severity' | 'rich' | 'compiler'; export type GlyphStyle = 'unicode' | 'ascii'; export enum BuildLineSeverity { @@ -107,7 +107,7 @@ function sgrFor(severity: BuildLineSeverity): string | undefined { * - the line does not classify into a known severity. */ export function colorizeBuildLine(line: string, mode: BuildColorMode): string { - if (mode === 'off') { + if (mode === 'off' || mode === 'compiler') { return line; } if (line.includes(ESC)) { @@ -117,6 +117,27 @@ export function colorizeBuildLine(line: string, mode: BuildColorMode): string { return sgr ? `${sgr}${line}${RESET}` : line; } +const OSC_RE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g; +// CSI: ESC [ , parameter bytes (0x30-0x3F: 0-9 : ; < = > ?), intermediate bytes +// (0x20-0x2F), final byte (0x40-0x7E). The full parameter-byte class (not just +// "[0-9;?]") covers SGR colon sub-parameters such as ESC[4:3m. +const CSI_RE = /\u001b\[[0-9:;<=>?]*[ -/]*[@-~]/g; +const LONE_ESC_RE = /\u001b[@-Z\\-_]/g; + +/** + * Remove ANSI escape sequences (SGR colors, other CSI, OSC hyperlinks, and lone + * two-character escapes) from a string. Pure; used to feed the diagnostic parsers + * and the on-disk log clean text when a tool emits its own colors (e.g. the + * `compiler` mode forces `-fdiagnostics-color`). No-op fast path for the common + * case of a line without any escape. + */ +export function stripAnsi(s: string): string { + if (s.indexOf(ESC) === -1) { + return s; + } + return s.replace(OSC_RE, '').replace(CSI_RE, '').replace(LONE_ESC_RE, ''); +} + function glyphFor(severity: BuildLineSeverity, glyphs: GlyphStyle): string | undefined { switch (severity) { case BuildLineSeverity.Error: @@ -146,6 +167,11 @@ export function isProgressNoise(line: string): boolean { * and dims build-progress noise. Lines that already contain ANSI pass through. */ export function decorateBuildLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): string { + if (mode === 'off' || mode === 'compiler') { + // off: no decoration. compiler: the tool emits its own real ANSI colors, + // which we forward verbatim (no synthetic severity color or glyph). + return line; + } if (mode !== 'rich') { return colorizeBuildLine(line, mode); } @@ -249,3 +275,33 @@ export function renderBuildSummary(outcome: BuildOutcome, statusText: string, gl `${sgr}${glyph} ${statusText}${RESET}` ]; } + +/** + * A surface that renders colorized build output. Implemented by an integrated + * terminal (today) or — if VS Code can render ANSI in the Output panel — an + * Output channel. The ANSI strings produced by this module are identical for + * both surfaces, so the colorization is portable across them. + */ +export interface ColorizedBuildSink { + prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs?: string[]): void; + writeLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): void; + writeSummary(outcome: BuildOutcome, counts: { errors: number; warnings: number }, glyphs: GlyphStyle): void; + reveal(focus: boolean): void; + dispose(): void; +} + +/** + * Whether the VS Code Output panel can render ANSI escape codes. Today this is + * `false`: the Output panel is a Monaco text editor that shows escapes as literal + * text, so colorized build output is rendered in an integrated terminal instead. + * If a future VS Code renders ANSI in the Output panel, flipping this to `true` + * routes the SAME colorized output to the Output channel with no other change. + */ +export function canRenderAnsiInOutput(): boolean { + return false; +} + +/** Pure sink selection: the Output channel when it can render ANSI, else a terminal. */ +export function selectSink(canRender: boolean, makeTerminal: () => ColorizedBuildSink, makeChannel: () => ColorizedBuildSink): ColorizedBuildSink { + return canRender ? makeChannel() : makeTerminal(); +} diff --git a/src/config.ts b/src/config.ts index 340eb3bb5b..9c358151e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -193,7 +193,7 @@ export interface ExtensionConfigurationSettings { saveBeforeBuild: boolean; buildBeforeRun: boolean; clearOutputBeforeBuild: boolean; - colorizedBuildOutput: "off" | "severity" | "rich"; + colorizedBuildOutput: "off" | "severity" | "rich" | "compiler"; buildOutputGlyphs: "unicode" | "ascii"; configureSettings: { [key: string]: boolean | number | string | string[] | util.CMakeValue }; cacheInit: string | string[] | null; @@ -394,7 +394,7 @@ export class ConfigurationReader implements vscode.Disposable { get clearOutputBeforeBuild(): boolean { return !!this.configData.clearOutputBeforeBuild; } - get colorizedBuildOutput(): "off" | "severity" | "rich" { + get colorizedBuildOutput(): "off" | "severity" | "rich" | "compiler" { return this.configData.colorizedBuildOutput; } get buildOutputGlyphs(): "unicode" | "ascii" { @@ -720,7 +720,7 @@ export class ConfigurationReader implements vscode.Disposable { saveBeforeBuild: new vscode.EventEmitter(), buildBeforeRun: new vscode.EventEmitter(), clearOutputBeforeBuild: new vscode.EventEmitter(), - colorizedBuildOutput: new vscode.EventEmitter<"off" | "severity" | "rich">(), + colorizedBuildOutput: new vscode.EventEmitter<"off" | "severity" | "rich" | "compiler">(), buildOutputGlyphs: new vscode.EventEmitter<"unicode" | "ascii">(), configureSettings: new vscode.EventEmitter<{ [key: string]: any }>(), cacheInit: new vscode.EventEmitter(), diff --git a/src/diagnostics/build.ts b/src/diagnostics/build.ts index fa9be19ca2..59e1f7ef46 100644 --- a/src/diagnostics/build.ts +++ b/src/diagnostics/build.ts @@ -7,8 +7,8 @@ import * as proc from '@cmt/proc'; import { OutputConsumer } from '@cmt/proc'; import * as util from '@cmt/util'; import * as vscode from 'vscode'; -import { BuildColorMode, GlyphStyle } from '@cmt/colorize'; -import { buildOutputTerminal } from '@cmt/buildOutputTerminal'; +import { BuildColorMode, GlyphStyle, stripAnsi } from '@cmt/colorize'; +import { colorizedBuildSink } from '@cmt/buildOutputTerminal'; import * as gcc from '@cmt/diagnostics/gcc'; import * as ghs from '@cmt/diagnostics/ghs'; @@ -342,18 +342,19 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D * is enabled, a decorated copy is additionally mirrored to the integrated * terminal, where ANSI actually renders (the Output panel cannot render ANSI). */ - private echo(line: string, isError: boolean) { + private echo(raw: string, clean: string, isError: boolean) { if (this.colorMode !== 'off') { - // Colorized: the terminal is the single visible build surface. The plain - // line still goes to the on-disk log file and developer console, but NOT - // the Output channel — this avoids duplicating the stream and the channel - // stealing focus away from the terminal. - buildOutputTerminal().writeLine(line, this.colorMode, this.glyphStyle); + // Colorized: the terminal is the single visible build surface and receives + // the RAW line (ANSI preserved). The plain (ANSI-stripped) line still goes to + // the on-disk log file and developer console, but NOT the Output channel — + // this avoids duplicating the stream and the channel stealing focus away from + // the terminal. + colorizedBuildSink().writeLine(raw, this.colorMode, this.glyphStyle); if (this.logger) { if (isError) { - this.logger.errorFileOnly(line); + this.logger.errorFileOnly(clean); } else { - this.logger.infoFileOnly(line); + this.logger.infoFileOnly(clean); } } return; @@ -362,9 +363,9 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D return; } if (isError) { - this.logger.error(line); + this.logger.error(clean); } else { - this.logger.info(line); + this.logger.info(clean); } } /** @@ -391,16 +392,18 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D } error(line: string) { - this.compileConsumer.error(line); - this.echo(line, true); - super.error(line); + const clean = stripAnsi(line); + this.compileConsumer.error(clean); + this.echo(line, clean, true); + super.error(clean); } output(line: string) { - this.compileConsumer.output(line); - this.echo(line, false); - super.output(line); - const progress = this._percent_re.exec(line); + const clean = stripAnsi(line); + this.compileConsumer.output(clean); + this.echo(line, clean, false); + super.output(clean); + const progress = this._percent_re.exec(clean); if (progress) { const percent = progress[1]; this._onProgressEmitter.fire({ diff --git a/src/drivers/cmakeDriver.ts b/src/drivers/cmakeDriver.ts index 376521f0f7..2dbe4cbd84 100644 --- a/src/drivers/cmakeDriver.ts +++ b/src/drivers/cmakeDriver.ts @@ -320,6 +320,15 @@ export abstract class CMakeDriver implements vscode.Disposable { if (extraEnvironmentVariables) { envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(extraEnvironmentVariables, envs)]); } + if (this.config.colorizedBuildOutput === 'compiler') { + // Compiler colorized mode: ask CMake to bake compiler color flags (e.g. + // -fdiagnostics-color / -fcolor-diagnostics) into the generated build system so + // the compiler emits real ANSI colors at build time. CMAKE_COLOR_DIAGNOSTICS is a + // configure-time cache variable, so toggling this mode requires a reconfigure to + // take effect. It is intentionally NOT set as CLICOLOR_FORCE here: configure + // output is routed to the Output channel, which cannot render ANSI. + envs = EnvironmentUtils.merge([envs, { CMAKE_COLOR_DIAGNOSTICS: 'ON' }]); + } return envs; } @@ -327,18 +336,25 @@ export abstract class CMakeDriver implements vscode.Disposable { * Get the environment variables that should be set at CMake-build time. */ async getCMakeBuildCommandEnvironment(in_env?: Environment): Promise { + let envs; if (this.useCMakePresets) { - let envs = EnvironmentUtils.merge([in_env, this._buildPreset?.environment]); + envs = EnvironmentUtils.merge([in_env, this._buildPreset?.environment]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this.config.environment, envs)]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this.config.buildEnvironment, envs)]); - return envs; } else { - let envs = EnvironmentUtils.merge([in_env, this._kitEnvironmentVariables]); + envs = EnvironmentUtils.merge([in_env, this._kitEnvironmentVariables]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this.config.environment, envs)]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this.config.buildEnvironment, envs)]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this._variantEnv, envs)]); - return envs; } + if (this.config.colorizedBuildOutput === 'compiler') { + // Compiler colorized mode: force CLI build tools (cmake --build, Ninja, make) to + // emit ANSI colors even though their output is piped rather than attached to a + // TTY. This is read at invocation time, so it takes effect on the next build with + // no reconfigure required. + envs = EnvironmentUtils.merge([envs, { CLICOLOR_FORCE: '1' }]); + } + return envs; } /** diff --git a/src/extension.ts b/src/extension.ts index 3d12552500..797264e92b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,7 +11,7 @@ import * as cpt from 'vscode-cpptools'; import * as nls from 'vscode-nls'; import * as api from 'vscode-cmake-tools'; import { CMakeCache } from '@cmt/cache'; -import { disposeBuildOutputTerminal } from '@cmt/buildOutputTerminal'; +import { disposeColorizedBuildSink } from '@cmt/buildOutputTerminal'; import { CMakeProject, ConfigureType, ConfigureTrigger, DiagnosticsConfiguration, DiagnosticsSettings } from '@cmt/cmakeProject'; import { ConfigurationReader, getSettingsChangePromise, TouchBarConfig } from '@cmt/config'; import { CMakeDriver, CMakePreconditionProblems, ConfigureResult, ConfigureResultType } from '@cmt/drivers/cmakeDriver'; @@ -3123,7 +3123,7 @@ export async function deactivate() { if (taskProvider) { taskProvider.dispose(); } - disposeBuildOutputTerminal(); + disposeColorizedBuildSink(); } export function getStatusBar(): StatusBar | undefined { diff --git a/test/unit-tests/backend/colorize.test.ts b/test/unit-tests/backend/colorize.test.ts index 924b5512b0..70c21f1e2f 100644 --- a/test/unit-tests/backend/colorize.test.ts +++ b/test/unit-tests/backend/colorize.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { classifyBuildLine, colorizeBuildLine, decorateBuildLine, isProgressNoise, renderBuildBanner, renderBuildSummary, linkifyLeadingPath, isAbsoluteLike, BuildLineSeverity } from '@cmt/colorize'; +import { classifyBuildLine, colorizeBuildLine, decorateBuildLine, isProgressNoise, renderBuildBanner, renderBuildSummary, linkifyLeadingPath, isAbsoluteLike, leadingPathToken, stripAnsi, canRenderAnsiInOutput, selectSink, ColorizedBuildSink, BuildLineSeverity } from '@cmt/colorize'; /** * Tests for the pure build-output colorizer in src/colorize.ts. @@ -263,3 +263,103 @@ suite('[colorize] linkifyLeadingPath', () => { expect(linkifyLeadingPath(line, r)).to.equal(line); }); }); + +suite('[colorize] stripAnsi', () => { + test('no-op fast path: a line without any escape is returned by identity', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(stripAnsi(line)).to.equal(line); + }); + test('removes a leading SGR color and trailing reset', () => { + expect(stripAnsi(`${ESC}[1;31m/src/x.cpp:1:1: error: e${RESET}`)).to.equal('/src/x.cpp:1:1: error: e'); + }); + test('removes multiple interleaved SGR sequences', () => { + expect(stripAnsi(`${ESC}[33mwarn${ESC}[0m: ${ESC}[36mnote${ESC}[0m`)).to.equal('warn: note'); + }); + test('removes a non-SGR CSI sequence (cursor move)', () => { + expect(stripAnsi(`abc${ESC}[2Kdef`)).to.equal('abcdef'); + }); + test('removes an SGR using colon sub-parameters (e.g. underline color ESC[4:3m)', () => { + expect(stripAnsi(`${ESC}[4:3munderlined${ESC}[0m`)).to.equal('underlined'); + }); + test('removes an OSC 8 hyperlink wrapper (BEL-terminated)', () => { + expect(stripAnsi(`${ESC}]8;;file:///x\u0007label${ESC}]8;;\u0007`)).to.equal('label'); + }); + test('removes an OSC sequence terminated by ST (ESC backslash)', () => { + expect(stripAnsi(`${ESC}]0;title${ESC}\\rest`)).to.equal('rest'); + }); + test('is idempotent', () => { + const once = stripAnsi(`${ESC}[31m/src/x.cpp:9:3: note: here${RESET}`); + expect(stripAnsi(once)).to.equal(once); + }); + test('leaves a clean line byte-identical (===)', () => { + const line = 'Scanning dependencies of target app'; + expect(stripAnsi(line)).to.equal(line); + }); +}); + +suite('[colorize] compiler mode passthrough', () => { + test('decorateBuildLine in compiler mode returns the raw line unchanged (no synthetic color/glyph)', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(decorateBuildLine(line, 'compiler', 'unicode')).to.equal(line); + }); + test('decorateBuildLine in compiler mode forwards a tool-colored line verbatim', () => { + const line = `${ESC}[0;1;31merror: ${ESC}[0mboom`; + expect(decorateBuildLine(line, 'compiler', 'unicode')).to.equal(line); + }); + test('colorizeBuildLine in compiler mode returns the line unchanged', () => { + const line = '/src/main.cpp:7:9: warning: meh'; + expect(colorizeBuildLine(line, 'compiler')).to.equal(line); + }); +}); + +suite('[colorize] strip-before-parse preserves classification', () => { + const samples = [ + '/src/main.cpp:10:5: error: expected \';\'', + '/src/main.cpp:7:9: warning: unused variable \'y\'', + '/src/main.cpp:9:3: note: in expansion of macro', + 'main.cpp(12): error C2065: \'x\': undeclared identifier', + '[ 50%] Building CXX object CMakeFiles/app.dir/main.cpp.o' + ]; + test('classifyBuildLine is identical with and without a forced compiler color wrapper', () => { + for (const s of samples) { + const colored = `${ESC}[1;31m${s}${RESET}`; + expect(classifyBuildLine(stripAnsi(colored))).to.equal(classifyBuildLine(s)); + } + }); + test('leadingPathToken (line/col positions) is identical after stripping a leading SGR', () => { + const line = '/src/main.cpp:10:5: error: boom'; + const colored = `${ESC}[1;31m${line}${RESET}`; + expect(leadingPathToken(stripAnsi(colored))).to.deep.equal(leadingPathToken(line)); + }); +}); + +suite('[colorize] sink selection', () => { + const fakeSink = (): ColorizedBuildSink => ({ + prepareForBuild: () => {}, writeLine: () => {}, writeSummary: () => {}, reveal: () => {}, dispose: () => {} + }); + test('the Output panel cannot render ANSI today', () => { + expect(canRenderAnsiInOutput()).to.equal(false); + }); + test('selectSink(false, ...) builds the terminal sink and not the channel sink', () => { + let terminal = 0; + let channel = 0; + selectSink(false, () => { + terminal++; return fakeSink(); + }, () => { + channel++; return fakeSink(); + }); + expect(terminal).to.equal(1); + expect(channel).to.equal(0); + }); + test('selectSink(true, ...) builds the channel sink and not the terminal sink', () => { + let terminal = 0; + let channel = 0; + selectSink(true, () => { + terminal++; return fakeSink(); + }, () => { + channel++; return fakeSink(); + }); + expect(terminal).to.equal(0); + expect(channel).to.equal(1); + }); +}); From 88db87ae2452749c0b8a89ba8acf38793b06f2b8 Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Wed, 24 Jun 2026 09:33:26 -0500 Subject: [PATCH 6/9] Minimize build-output interruptions on automatic triggers and improve terminal-to-output flow The colorized "CMake Build" terminal (and the Output channel in off mode) was revealed at the start of every build and configure, including automatic and programmatic ones - so a configure-on-open, an auto-reconfigure, or a build invoked through the CMake Tools API by Copilot (via the C/C++ DevTools companion) would yank the panel away from a terminal the user was working in. Minimize interruptions: - Add `cmake.revealLogOnAutomaticTrigger` (boolean, default false, scope window). When false, automatic/programmatic operations no longer proactively reveal the build output; explicit user-initiated builds/configures keep their `cmake.revealLog` behavior, and failures always surface. - Factor the reveal policy into a pure, unit-tested decideReveal() and thread an `isAutomatic` flag through runBuild/build/ctest/preTest plus the configure path (classified by ConfigureTrigger via isAutomaticConfigureTrigger). The CMake Tools API entrypoints (build/buildWithResult/ctestWithResult) mark themselves automatic, so the proven Copilot path is covered. editCache's "Configure Now" gets an explicit user trigger so it still reveals. - The gate applies to both the colorized terminal reveal and the Output-channel reveal. Improve terminal-to-output flow: - Add a one-line pointer in the regular Output channel at colorized build start so users who watch it aren't left with a near-empty channel. - The automatic gate also removes the configure-then-terminal panel bounce on a build that needs a reconfigure. - If the user closes the CMake Build terminal mid-build, a build failure now falls back to revealing the Output channel (reveal() returns whether a surface was shown) so failures are never silently lost. Adds backend tests mirroring decideReveal and isAutomaticConfigureTrigger. --- CHANGELOG.md | 1 + docs/cmake-settings.md | 1 + package.json | 6 + package.nls.json | 1 + src/api.ts | 6 +- src/buildOutputTerminal.ts | 19 ++- src/cmakeProject.ts | 68 ++++++-- src/colorize.ts | 4 +- src/ctest.ts | 4 +- src/diagnostics/build.ts | 12 +- src/logging.ts | 50 ++++-- test/unit-tests/backend/colorize.test.ts | 2 +- .../unit-tests/backend/revealDecision.test.ts | 151 ++++++++++++++++++ 13 files changed, 283 insertions(+), 42 deletions(-) create mode 100644 test/unit-tests/backend/revealDecision.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5edc3d3a..5a72f377c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Features: - Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal where error locations are clickable. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. A `compiler` mode forwards the compiler's and build tools' own colors by setting `CMAKE_COLOR_DIAGNOSTICS=ON` at configure time and `CLICOLOR_FORCE=1` at build time. The colorization is portable: it is rendered in the terminal today and is automatically routed to the Output panel if a future VS Code renders ANSI there. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: +- Add `cmake.revealLogOnAutomaticTrigger` (default `false`) so automatic and programmatic CMake operations - configure-on-open, automatic reconfigure, and builds or tests invoked through the CMake Tools API (e.g. by Copilot via the C/C++ DevTools companion) - no longer reveal the build output and steal the panel away from a terminal you're using. This applies to both the regular Output channel and the colorized CMake Build terminal. Explicit builds and configures you start yourself are unchanged, and failures are always shown. [#4988](https://github.com/microsoft/vscode-cmake-tools/issues/4988) - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. - Further reduce CI pipeline time by splitting each platform pipeline into a build job and a parallel test matrix (backend, smoke, unit, integration, four E2E suites), caching `node_modules` between runs, sharing a single Xvfb instance on Linux, dropping a duplicate backend-test load inside the unit-tests Electron suite, and adding per-step timeouts to prevent silent hangs. - Add `cmake.showTimestampsInOutput` setting to display timestamps and log levels in the CMake output channel, useful for tracking build durations. [#4057](https://github.com/microsoft/vscode-cmake-tools/issues/4057) diff --git a/docs/cmake-settings.md b/docs/cmake-settings.md index ada5a8296e..8b38fc3761 100644 --- a/docs/cmake-settings.md +++ b/docs/cmake-settings.md @@ -92,6 +92,7 @@ Options that support substitution, in the table below, allow variable references | `cmake.preferredGenerators` | A list of strings of generator names to try, in order, when configuring a CMake project for the first time. | `[]` | no | | `cmake.preRunCoverageTarget` | Target to build before running tests with coverage using the test explorer. | `null` | no | | `cmake.revealLog` | Controls when the CMake output log should be revealed. Possible values: `focus` (show the log and move focus to the output channel), `always` (show the log but do not move focus), `never` (do not show the log), `error` (show the log only when an error occurs). | `always` | no | +| `cmake.revealLogOnAutomaticTrigger` | Whether automatic or programmatic CMake operations (configure-on-open, automatic reconfigure, and builds or tests invoked through the CMake Tools API, e.g. by Copilot via the C/C++ DevTools companion) reveal the CMake build output. Failures are always shown. | `false` | no | | `cmake.saveBeforeBuild` | If `true` (the default), saves open text documents when build or configure is invoked before running CMake. | `true` | no | | `cmake.setBuildTargetSameAsLaunchTarget` | If `true`, setting the launch/debug target automatically sets the build target to match. | `false` | no | | `cmake.setBuildTypeOnMultiConfig` | If `true`, set build type on multi-config generators. | `false` | no | diff --git a/package.json b/package.json index 051eda81fd..cd7738cb53 100644 --- a/package.json +++ b/package.json @@ -4104,6 +4104,12 @@ ], "description": "%cmake-tools.configuration.cmake.revealLog.description%" }, + "cmake.revealLogOnAutomaticTrigger": { + "type": "boolean", + "default": false, + "markdownDescription": "%cmake-tools.configuration.cmake.revealLogOnAutomaticTrigger.markdownDescription%", + "scope": "window" + }, "cmake.exportCompileCommandsFile": { "type": "boolean", "default": true, diff --git a/package.nls.json b/package.nls.json index 8971fa85a4..65147ef072 100644 --- a/package.nls.json +++ b/package.nls.json @@ -372,6 +372,7 @@ "cmake-tools.configuration.cmake.revealLog.always.description": "The log appears but the output channel doesn't take the cursor focus.", "cmake-tools.configuration.cmake.revealLog.never.description": "The log neither appears nor takes the focus.", "cmake-tools.configuration.cmake.revealLog.onError.description": "The log appears only when the build or the configuration fails.", + "cmake-tools.configuration.cmake.revealLogOnAutomaticTrigger.markdownDescription": "When `true`, automatic and programmatic CMake operations - such as configure-on-open, automatic reconfigure, and builds or tests invoked by other extensions through the CMake Tools API (for example the C/C++ DevTools companion acting on behalf of Copilot) - reveal the CMake build output. When `false` (the default), these operations run quietly so they don't switch the panel away from a terminal you are using. The `#cmake.revealLog#` setting still controls how operations you start yourself reveal the log. Build, test, and configure failures are always shown, regardless of this setting.", "cmake-tools.configuration.cmake.exportCompileCommandsFile.description": "Enables exporting compile_commands.json. This only is used in Kits scenarios. In Presets scenarios, please set this by using CMakePresets.json", "cmake-tools.configuration.cmake.useCMakePresets.description": "Use CMakePresets.json to configure drive CMake configure, build, and test. When using CMakePresets.json, kits, variants, and some settings in settings.json will be ignored.", "cmake-tools.configuration.cmake.useVsDeveloperEnvironment.description": "When using CMake Presets on Windows, use the Visual Studio environment as the parent environment. Selecting auto will only apply the Visual Studio environment when we detect a supported compiler (cl, clang, clang-cl, clang-cpp, clang++), or the Ninja generator is being used.", diff --git a/src/api.ts b/src/api.ts index 3048f69867..5c5b34d01b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -152,17 +152,17 @@ class CMakeProjectWrapper implements api.Project { build(targets?: string[]): Promise { logApiTelemetry('build'); - return withErrorCheck('build', () => this.project.build(targets)); + return withErrorCheck('build', () => this.project.build(targets, undefined, undefined, undefined, true)); } async buildWithResult(targets?: string[], cancellationToken?: vscode.CancellationToken): Promise { logApiTelemetry('buildWithResult'); - return this.project.build(targets, undefined, undefined, cancellationToken); + return this.project.build(targets, undefined, undefined, cancellationToken, true); } async ctestWithResult(tests?: string[], cancellationToken?: vscode.CancellationToken): Promise { logApiTelemetry('ctestWithResult'); - return this.project.ctest(undefined, new CTestOutputLogger(), tests, cancellationToken); + return this.project.ctest(undefined, new CTestOutputLogger(), tests, cancellationToken, true); } install(): Promise { diff --git a/src/buildOutputTerminal.ts b/src/buildOutputTerminal.ts index 70c131583d..e9b5ac9e9d 100644 --- a/src/buildOutputTerminal.ts +++ b/src/buildOutputTerminal.ts @@ -79,9 +79,14 @@ class BuildOutputTerminal implements vscode.Pseudoterminal, ColorizedBuildSink { private baseDirs: string[] = []; private readonly linkCache = new Map(); - /** Reveal the build terminal; `focus` takes keyboard focus. No-op if not created. */ - reveal(focus: boolean): void { - this.terminal?.show(!focus); + /** Reveal the build terminal; `focus` takes keyboard focus. Returns whether a + * terminal was actually revealed (`false` if it was closed/never created). */ + reveal(focus: boolean): boolean { + if (!this.terminal) { + return false; + } + this.terminal.show(!focus); + return true; } // vscode.Pseudoterminal: called when the terminal is first shown. @@ -184,8 +189,12 @@ class OutputChannelBuildSink implements ColorizedBuildSink { return this.channel; } - reveal(focus: boolean): void { - this.channel?.show(!focus); + reveal(focus: boolean): boolean { + if (!this.channel) { + return false; + } + this.channel.show(!focus); + return true; } prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs: string[] = []): void { diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index 160a04717a..f88119d3b4 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -91,6 +91,7 @@ export enum ConfigureTrigger { compilation = "compilation", launch = "launch", commandEditCacheUI = "commandEditCacheUI", + commandEditCache = "commandEditCache", commandConfigure = "commandConfigure", commandConfigureWithDebugger = "commandConfigureWithDebugger", projectOutlineConfigureWithDebugger = "projectOutlineConfigureWithDebugger", @@ -110,6 +111,32 @@ export enum ConfigureTrigger { selectKit = "selectKit" } +/** + * Classifies a {@link ConfigureTrigger} as automatic/programmatic (vs. an explicit user + * action). Automatic configures (configure-on-open, reconfigure on file change, build-induced + * reconfigure, API-driven configures, etc.) should not proactively reveal the output panel and + * steal it away from a terminal the user is working in, while user-initiated configures + * (command palette, kit/preset selection, Quick Start, launch) keep revealing as before. + */ +export function isAutomaticConfigureTrigger(trigger: ConfigureTrigger): boolean { + switch (trigger) { + case ConfigureTrigger.configureOnOpen: + case ConfigureTrigger.configureWithCache: + case ConfigureTrigger.cmakeListsChange: + case ConfigureTrigger.sourceDirectoryChange: + case ConfigureTrigger.compilation: + case ConfigureTrigger.api: + case ConfigureTrigger.taskProvider: + case ConfigureTrigger.workflow: + case ConfigureTrigger.runTests: + case ConfigureTrigger.package: + case ConfigureTrigger.badHomeDir: + return true; + default: + return false; + } +} + export interface DiagnosticsConfiguration { folder: string; cmakeVersion: string; @@ -1890,7 +1917,7 @@ export class CMakeProject { } if (type !== ConfigureType.ShowCommandOnly) { - log.showChannel(); + log.showChannel(undefined, isAutomaticConfigureTrigger(trigger)); log.info(localize('run.configure', 'Configuring project: {0}', this.folderName), extraArgs); } @@ -2336,10 +2363,10 @@ export class CMakeProject { /** * Implementation of `cmake.build` */ - async runBuild(targets?: string[], showCommandOnly?: boolean, taskConsumer?: proc.OutputConsumer, isBuildCommand?: boolean, cancellationToken?: vscode.CancellationToken): Promise { + async runBuild(targets?: string[], showCommandOnly?: boolean, taskConsumer?: proc.OutputConsumer, isBuildCommand?: boolean, cancellationToken?: vscode.CancellationToken, isAutomatic: boolean = false): Promise { if (!showCommandOnly) { if (this.workspaceContext.config.colorizedBuildOutput === 'off') { - log.showChannel(); + log.showChannel(undefined, isAutomatic); } log.info(localize('run.build', 'Building folder: {0}', await this.binaryDir || this.folderName), (targets && targets.length > 0) ? targets.join(', ') : ''); } @@ -2401,7 +2428,7 @@ export class CMakeProject { await setContextAndStore(isBuildingKey, true); rc = await drv!.build(newTargets, taskConsumer, isBuildCommand); if (rc !== 0 && this.workspaceContext.config.colorizedBuildOutput === 'off') { - log.showChannel(true); // in case build has failed + log.showChannel(true, isAutomatic); // in case build has failed } await setContextAndStore(isBuildingKey, false); if (rc === null) { @@ -2452,9 +2479,14 @@ export class CMakeProject { buildLogger.info(localize('starting.build', 'Starting build')); const buildColorMode = drv!.config.colorizedBuildOutput; if (buildColorMode !== 'off') { + // The colorized per-line stream is rendered in the "CMake Build" + // terminal (the Output panel cannot render ANSI). Leave a one-line + // pointer in the regular Output channel for users who watch it, without + // revealing the channel (which would defeat the single-surface design). + buildLogger.info(localize('build.colorized.in.terminal', 'Colorized build output is shown in the "CMake Build" terminal; plain output is written to the CMake Tools log file and diagnostics appear in the Problems panel.')); const banner = buildColorMode === 'rich' ? targetName : undefined; colorizedBuildSink().prepareForBuild(drv!.config.clearOutputBeforeBuild, drv!.config.buildOutputGlyphs, banner, [drv!.binaryDir, drv!.sourceDir]); - const startReveal = logging.revealLogDecision(); + const startReveal = logging.revealLogDecision(undefined, isAutomatic); if (startReveal.show) { colorizedBuildSink().reveal(startReveal.focus); } @@ -2467,12 +2499,14 @@ export class CMakeProject { // Reveal the colorized build terminal (not the Output channel) // so a failed build doesn't yank focus away from it. Honor the // `cmake.revealLog` setting exactly as the channel reveal would. - const reveal = logging.revealLogDecision(true); - if (reveal.show) { - colorizedBuildSink().reveal(reveal.focus); + const reveal = logging.revealLogDecision(true, isAutomatic); + if (reveal.show && !colorizedBuildSink().reveal(reveal.focus)) { + // The terminal was closed mid-build, so the failure has no + // visible surface — fall back to revealing the Output channel. + log.showChannel(true, isAutomatic); } } else { - log.showChannel(true); // in case build has failed + log.showChannel(true, isAutomatic); // in case build has failed } } if (rc === null) { @@ -2536,8 +2570,8 @@ export class CMakeProject { /** * Implementation of `cmake.build` */ - async build(targets?: string[], showCommandOnly?: boolean, isBuildCommand: boolean = true, cancellationToken?: vscode.CancellationToken): Promise { - this.activeBuild = this.runBuild(targets, showCommandOnly, undefined, isBuildCommand, cancellationToken); + async build(targets?: string[], showCommandOnly?: boolean, isBuildCommand: boolean = true, cancellationToken?: vscode.CancellationToken, isAutomatic: boolean = false): Promise { + this.activeBuild = this.runBuild(targets, showCommandOnly, undefined, isBuildCommand, cancellationToken, isAutomatic); return this.activeBuild; } @@ -2579,7 +2613,7 @@ export class CMakeProject { localize('project.not.yet.configured', 'This project has not yet been configured'), localize('configure.now.button', 'Configure Now'))); if (doConfigure) { - if ((await this.configureInternal()).exitCode !== 0) { + if ((await this.configureInternal(ConfigureTrigger.commandEditCache)).exitCode !== 0) { return; } } else { @@ -2767,11 +2801,11 @@ export class CMakeProject { return this.cTestController.runCTest(driver, true, testPreset, consumer); } - private async preTest(fromWorkflow: boolean = false): Promise { + private async preTest(fromWorkflow: boolean = false, isAutomatic: boolean = false): Promise { if (extensionManager !== undefined && extensionManager !== null && !fromWorkflow) { extensionManager.cleanOutputChannel(); } - const buildResult = await this.build(undefined, false, false); + const buildResult = await this.build(undefined, false, false, undefined, isAutomatic); if (buildResult.exitCode !== 0) { throw new Error(localize('build.failed', 'Build failed.')); } @@ -2783,9 +2817,9 @@ export class CMakeProject { return drv; } - async ctest(fromWorkflow: boolean = false, commandConsumer?: proc.CommandConsumer, testsToRun?: string[], cancellationToken?: vscode.CancellationToken): Promise { - const drv = await this.preTest(fromWorkflow); - const retc = await this.cTestController.runCTest(drv, undefined, undefined, commandConsumer, testsToRun, cancellationToken); + async ctest(fromWorkflow: boolean = false, commandConsumer?: proc.CommandConsumer, testsToRun?: string[], cancellationToken?: vscode.CancellationToken, isAutomatic: boolean = false): Promise { + const drv = await this.preTest(fromWorkflow, isAutomatic); + const retc = await this.cTestController.runCTest(drv, undefined, undefined, commandConsumer, testsToRun, cancellationToken, isAutomatic); return retc; } diff --git a/src/colorize.ts b/src/colorize.ts index c85af58bd1..31a12368f0 100644 --- a/src/colorize.ts +++ b/src/colorize.ts @@ -286,7 +286,9 @@ export interface ColorizedBuildSink { prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs?: string[]): void; writeLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): void; writeSummary(outcome: BuildOutcome, counts: { errors: number; warnings: number }, glyphs: GlyphStyle): void; - reveal(focus: boolean): void; + /** Reveal the sink's surface. Returns `true` if a surface was actually revealed + * (`false` if there is nothing to reveal, e.g. the terminal was closed mid-build). */ + reveal(focus: boolean): boolean; dispose(): void; } diff --git a/src/ctest.ts b/src/ctest.ts index 28e5955c77..e6c8a89593 100644 --- a/src/ctest.ts +++ b/src/ctest.ts @@ -483,10 +483,10 @@ export class CTestDriver implements vscode.Disposable { return ctestArgs; } - public async runCTest(driver: CMakeDriver, customizedTask: boolean = false, testPreset?: TestPreset, consumer?: proc.CommandConsumer, specificTestsToRun?: string[], cancellationToken?: vscode.CancellationToken): Promise { + public async runCTest(driver: CMakeDriver, customizedTask: boolean = false, testPreset?: TestPreset, consumer?: proc.CommandConsumer, specificTestsToRun?: string[], cancellationToken?: vscode.CancellationToken, isAutomatic: boolean = false): Promise { if (!customizedTask) { // We don't want to focus on log channel when running tasks. - log.showChannel(); + log.showChannel(undefined, isAutomatic); } if (this.ws.config.testExplorerIntegrationEnabled) { diff --git a/src/diagnostics/build.ts b/src/diagnostics/build.ts index 59e1f7ef46..34b5cb358a 100644 --- a/src/diagnostics/build.ts +++ b/src/diagnostics/build.ts @@ -337,10 +337,14 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D private readonly glyphStyle: GlyphStyle; /** * Echo a build-output line. Parsing has already happened on the clean `line`, - * so the Problems panel is unaffected. The plain line always goes to the - * logger (Output channel + on-disk log file are unchanged). When colorization - * is enabled, a decorated copy is additionally mirrored to the integrated - * terminal, where ANSI actually renders (the Output panel cannot render ANSI). + * so the Problems panel is unaffected. + * + * In `off` mode the clean line goes to the regular CMake/Build Output channel and + * the on-disk log file (legacy behavior). When colorization is enabled, the raw + * (ANSI) line is written to the "CMake Build" terminal sink — the single visible + * build surface — and the clean line is written to the on-disk log file only + * (never the Output channel), so the stream isn't duplicated and the channel + * doesn't steal focus from the terminal. */ private echo(raw: string, clean: string, isError: boolean) { if (this.colorMode !== 'off') { diff --git a/src/logging.ts b/src/logging.ts index 119ab866ff..10c131fc04 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -23,7 +23,7 @@ enum LogLevel { Fatal, } -type RevealLogKey = 'always' | 'never' | 'focus' | 'error'; +export type RevealLogKey = 'always' | 'never' | 'focus' | 'error'; /** * Get the name of a logging level @@ -272,8 +272,8 @@ export class Logger { SingletonLogger.instance().clearOutputChannel(); } - showChannel(error_to_show?: boolean) { - const { show, focus } = revealLogDecision(error_to_show); + showChannel(error_to_show?: boolean, isAutomatic: boolean = false) { + const { show, focus } = revealLogDecision(error_to_show, isAutomatic); if (show) { SingletonLogger.instance().showChannel(!focus); } @@ -294,18 +294,50 @@ export function createLogger(tag: string) { * channel or the colorized build terminal) should be revealed, and whether it * should take focus. Shared so the colorized build terminal honors `revealLog` * exactly as the Output channel does. + * + * When `isAutomatic` is true (the operation was triggered automatically or + * programmatically rather than by an explicit user action), the proactive reveal + * is suppressed unless the user opts in via `cmake.revealLogOnAutomaticTrigger`. + * Failure reveals (`error_to_show === true`) always surface regardless. */ -export function revealLogDecision(error_to_show?: boolean): { show: boolean; focus: boolean } { - const reveal_log = vscode.workspace.getConfiguration('cmake').get('revealLog', 'always'); +export function revealLogDecision(error_to_show?: boolean, isAutomatic: boolean = false): { show: boolean; focus: boolean } { + const config = vscode.workspace.getConfiguration('cmake'); + const reveal_log = config.get('revealLog', 'always'); + const reveal_on_automatic = config.get('revealLogOnAutomaticTrigger', false); + return decideReveal(reveal_log, error_to_show, isAutomatic, reveal_on_automatic); +} + +/** + * Pure decision logic for {@link revealLogDecision}, factored out so it can be + * unit-tested without a VS Code instance. + * + * @param revealLog The `cmake.revealLog` value, controlling *how* a reveal happens. + * @param errorToShow When defined, indicates a build/configure result: `true` for failure. + * A failure reveal (`true`) always surfaces and is never gated. + * @param isAutomatic Whether the operation was triggered automatically/programmatically + * (e.g. configure-on-open, auto-reconfigure, or a build/test invoked through + * the CMake Tools API by another extension such as the C/C++ DevTools + * companion acting for Copilot) rather than by an explicit user action. + * @param revealOnAutomatic The `cmake.revealLogOnAutomaticTrigger` value. When `false`, + * automatic/programmatic proactive reveals are suppressed so they don't + * switch the panel away from a terminal the user is working in. + */ +export function decideReveal(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { show: boolean; focus: boolean } { + const isFailure = errorToShow === true; + // Failures always surface. Suppress only proactive (non-failure) reveals for + // automatic/programmatic triggers, unless the user opted in. + if (isAutomatic && !revealOnAutomatic && !isFailure) { + return { show: false, focus: false }; + } let show = false; - if (reveal_log === 'always') { + if (revealLog === 'always') { show = true; } // won't show if no target information - if (reveal_log === 'error' && error_to_show !== undefined) { - show = error_to_show; + if (revealLog === 'error' && errorToShow !== undefined) { + show = errorToShow; } - const focus = (reveal_log === 'focus'); + const focus = (revealLog === 'focus'); if (focus) { show = true; } diff --git a/test/unit-tests/backend/colorize.test.ts b/test/unit-tests/backend/colorize.test.ts index 70c21f1e2f..b6764020a5 100644 --- a/test/unit-tests/backend/colorize.test.ts +++ b/test/unit-tests/backend/colorize.test.ts @@ -335,7 +335,7 @@ suite('[colorize] strip-before-parse preserves classification', () => { suite('[colorize] sink selection', () => { const fakeSink = (): ColorizedBuildSink => ({ - prepareForBuild: () => {}, writeLine: () => {}, writeSummary: () => {}, reveal: () => {}, dispose: () => {} + prepareForBuild: () => {}, writeLine: () => {}, writeSummary: () => {}, reveal: () => true, dispose: () => {} }); test('the Output panel cannot render ANSI today', () => { expect(canRenderAnsiInOutput()).to.equal(false); diff --git a/test/unit-tests/backend/revealDecision.test.ts b/test/unit-tests/backend/revealDecision.test.ts new file mode 100644 index 0000000000..dd4bd5215e --- /dev/null +++ b/test/unit-tests/backend/revealDecision.test.ts @@ -0,0 +1,151 @@ +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Guards the "minimize interruptions" behavior: automatic/programmatic CMake operations + * (configure-on-open, automatic reconfigure, and builds/tests invoked through the CMake + * Tools API by e.g. Copilot via the C/C++ DevTools companion) must not proactively reveal + * the build output (Output channel or the colorized "CMake Build" terminal) and steal the + * panel from a terminal the user is using - unless `cmake.revealLogOnAutomaticTrigger` is set. + * Explicit user operations keep their `cmake.revealLog` behavior, and failures always surface. + * + * `src/logging.ts` and `src/cmakeProject.ts` transitively import `vscode`, so per the + * backend-test convention (see `expand.test.ts`, `shell-propagation.test.ts`) the pure + * decision logic is mirrored inline here. Keep these mirrors in sync with `decideReveal` + * (src/logging.ts) and `isAutomaticConfigureTrigger` (src/cmakeProject.ts). + */ + +type RevealLogKey = 'always' | 'never' | 'focus' | 'error'; + +// Mirror of `decideReveal` in src/logging.ts. +function decideReveal(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { show: boolean; focus: boolean } { + const isFailure = errorToShow === true; + if (isAutomatic && !revealOnAutomatic && !isFailure) { + return { show: false, focus: false }; + } + let show = false; + if (revealLog === 'always') { + show = true; + } + if (revealLog === 'error' && errorToShow !== undefined) { + show = errorToShow; + } + const focus = (revealLog === 'focus'); + if (focus) { + show = true; + } + return { show, focus }; +} + +// Mirror of `isAutomaticConfigureTrigger` in src/cmakeProject.ts (ConfigureTrigger values). +function isAutomaticConfigureTrigger(trigger: string): boolean { + switch (trigger) { + case 'configureOnOpen': + case 'configureWithCache': + case 'cmakeListsChange': + case 'sourceDirectoryChange': + case 'compilation': + case 'api': + case 'taskProvider': + case 'workflow': + case 'runTests': + case 'package': + case 'badHomeDir': + return true; + default: + return false; + } +} + +function findExtensionDir(): string { + let dir = __dirname; + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, 'package.json'); + if (fs.existsSync(candidate)) { + const json = JSON.parse(fs.readFileSync(candidate, 'utf8')); + if (json?.contributes?.languages?.some((l: { id?: string }) => l.id === 'cmake')) { + return dir; + } + } + dir = path.dirname(dir); + } + throw new Error('CMake Tools package.json (with a `cmake` language contribution) was not found'); +} + +suite('[revealDecision] #4988 minimize interruptions on automatic triggers', () => { + suite('manifest', () => { + let manifest: any; + let nls: any; + suiteSetup(() => { + const dir = findExtensionDir(); + manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + nls = JSON.parse(fs.readFileSync(path.join(dir, 'package.nls.json'), 'utf8')); + }); + + test('declares cmake.revealLogOnAutomaticTrigger as a window-scoped boolean defaulting to false', () => { + const prop = manifest.contributes.configuration.properties['cmake.revealLogOnAutomaticTrigger']; + expect(prop, 'cmake.revealLogOnAutomaticTrigger property').to.be.an('object'); + expect(prop.type, 'type').to.equal('boolean'); + expect(prop.default, 'default').to.equal(false); + expect(prop.scope, 'scope').to.equal('window'); + expect(prop.markdownDescription, 'markdownDescription').to.equal('%cmake-tools.configuration.cmake.revealLogOnAutomaticTrigger.markdownDescription%'); + }); + + test('the NLS markdownDescription key exists', () => { + expect(nls['cmake-tools.configuration.cmake.revealLogOnAutomaticTrigger.markdownDescription'], 'nls key').to.be.a('string').and.not.empty; + }); + }); + + suite('decideReveal', () => { + test('user-initiated operations keep legacy revealLog behavior', () => { + expect(decideReveal('always', undefined, false, false)).to.deep.equal({ show: true, focus: false }); + expect(decideReveal('focus', undefined, false, false)).to.deep.equal({ show: true, focus: true }); + expect(decideReveal('never', undefined, false, false)).to.deep.equal({ show: false, focus: false }); + expect(decideReveal('error', undefined, false, false)).to.deep.equal({ show: false, focus: false }); + }); + + test('automatic operations are suppressed by default (the fix)', () => { + expect(decideReveal('always', undefined, true, false)).to.deep.equal({ show: false, focus: false }); + // Even when the user chose `focus`, an automatic op must not steal focus. + expect(decideReveal('focus', undefined, true, false)).to.deep.equal({ show: false, focus: false }); + }); + + test('automatic operations reveal when the user opts in', () => { + expect(decideReveal('always', undefined, true, true)).to.deep.equal({ show: true, focus: false }); + expect(decideReveal('focus', undefined, true, true)).to.deep.equal({ show: true, focus: true }); + }); + + test('failures always surface, even for automatic operations and even when opted out', () => { + expect(decideReveal('always', true, true, false)).to.deep.equal({ show: true, focus: false }); + expect(decideReveal('error', true, true, false)).to.deep.equal({ show: true, focus: false }); + expect(decideReveal('focus', true, true, false)).to.deep.equal({ show: true, focus: true }); + }); + + test('successful automatic results are not revealed under error mode', () => { + expect(decideReveal('error', false, true, false)).to.deep.equal({ show: false, focus: false }); + }); + + test('never suppresses everything, including failures (subordinate to revealLog as today)', () => { + expect(decideReveal('never', true, false, true)).to.deep.equal({ show: false, focus: false }); + expect(decideReveal('never', true, true, true)).to.deep.equal({ show: false, focus: false }); + }); + }); + + suite('isAutomaticConfigureTrigger', () => { + const automatic = ['configureOnOpen', 'configureWithCache', 'cmakeListsChange', 'sourceDirectoryChange', 'compilation', 'api', 'taskProvider', 'workflow', 'runTests', 'package', 'badHomeDir']; + const userInitiated = ['commandConfigure', 'commandCleanConfigure', 'commandConfigureWithDebugger', 'selectKit', 'selectConfigurePreset', 'quickStart', 'launch', 'setVariant', 'buttonNewKitsDefinition', 'commandEditCacheUI', 'commandEditCache']; + + test('automatic/programmatic triggers are classified automatic', () => { + for (const t of automatic) { + expect(isAutomaticConfigureTrigger(t), t).to.equal(true); + } + }); + + test('explicit user triggers are classified user-initiated', () => { + for (const t of userInitiated) { + expect(isAutomaticConfigureTrigger(t), t).to.equal(false); + } + }); + }); +}); From 1b4523a19351986ac872e812c6a7d6abe70b37d6 Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Wed, 24 Jun 2026 10:21:34 -0500 Subject: [PATCH 7/9] Align colorized reveal with the revealLogOnAutomaticTrigger PR and open the build terminal promptly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adjustments to the colorized build-output reveal work, in anticipation of the cmake.revealLogOnAutomaticTrigger PR (#4988 / #4989) merging first: Layer on #4988 instead of diverging from it: - Align logging.ts `decideReveal` and `Logger.showChannel` to the exact shape used by #4988 (`{ shouldShow, preserveFocus }`), so when that PR merges there is a single shared implementation rather than two competing ones. `revealLogDecision` remains the only colorization-specific addition: a thin wrapper that maps `decideReveal` to the `{ show, focus }` shape the colorized terminal reveal consumes. `isAutomaticConfigureTrigger`, the isAutomatic threading, the API marking, and the setting itself are unchanged and already match #4988. Updated the backend test to the aligned shape and added coverage for the revealLogDecision mapping. Open the CMake Build terminal promptly (bug fix): - Previously the colorized terminal was only created and revealed once the build actually started, which happens after the pre-build configure. With a fresh kit/preset (e.g. gcc) the first configure is slow, so the user was left looking at configuration progress with no terminal. Now the terminal is opened and revealed before the pre-build configure (showing a brief "Preparing build…"), gated on the same reveal decision so automatic/programmatic builds still stay quiet. Adds prepareForConfigure() to the build sink (terminal and the portability Output-channel sink). --- CHANGELOG.md | 2 +- src/buildOutputTerminal.ts | 22 ++++++ src/cmakeProject.ts | 14 ++++ src/colorize.ts | 5 ++ src/logging.ts | 57 ++++++++------- test/unit-tests/backend/colorize.test.ts | 2 +- .../unit-tests/backend/revealDecision.test.ts | 72 ++++++++++++------- 7 files changed, 120 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a72f377c2..78e303e08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Features: - Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal where error locations are clickable. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. A `compiler` mode forwards the compiler's and build tools' own colors by setting `CMAKE_COLOR_DIAGNOSTICS=ON` at configure time and `CLICOLOR_FORCE=1` at build time. The colorization is portable: it is rendered in the terminal today and is automatically routed to the Output panel if a future VS Code renders ANSI there. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: -- Add `cmake.revealLogOnAutomaticTrigger` (default `false`) so automatic and programmatic CMake operations - configure-on-open, automatic reconfigure, and builds or tests invoked through the CMake Tools API (e.g. by Copilot via the C/C++ DevTools companion) - no longer reveal the build output and steal the panel away from a terminal you're using. This applies to both the regular Output channel and the colorized CMake Build terminal. Explicit builds and configures you start yourself are unchanged, and failures are always shown. [#4988](https://github.com/microsoft/vscode-cmake-tools/issues/4988) +- The colorized CMake Build terminal now honors `cmake.revealLogOnAutomaticTrigger`: it opens promptly for builds you start yourself but stays quiet for automatic and programmatic builds (e.g. configure-on-open and builds invoked through the CMake Tools API by Copilot via the C/C++ DevTools companion), and it now opens immediately when you start a build instead of leaving you on configuration progress while a configure runs first. Failures are always shown. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. - Further reduce CI pipeline time by splitting each platform pipeline into a build job and a parallel test matrix (backend, smoke, unit, integration, four E2E suites), caching `node_modules` between runs, sharing a single Xvfb instance on Linux, dropping a duplicate backend-test load inside the unit-tests Electron suite, and adding per-step timeouts to prevent silent hangs. - Add `cmake.showTimestampsInOutput` setting to display timestamps and log levels in the CMake output channel, useful for tracking build durations. [#4057](https://github.com/microsoft/vscode-cmake-tools/issues/4057) diff --git a/src/buildOutputTerminal.ts b/src/buildOutputTerminal.ts index e9b5ac9e9d..57374bc625 100644 --- a/src/buildOutputTerminal.ts +++ b/src/buildOutputTerminal.ts @@ -121,6 +121,20 @@ class BuildOutputTerminal implements vscode.Pseudoterminal, ColorizedBuildSink { } } + /** + * Open the terminal (creating it if needed) before the pre-build configure runs, so the + * "CMake Build" terminal appears immediately when the user starts a build instead of only + * once a (possibly slow) configure completes. Revealing is left to the caller so it can + * honor `cmake.revealLog` / `cmake.revealLogOnAutomaticTrigger`. + */ + prepareForConfigure(clear: boolean): void { + this.ensureTerminal(); + if (clear) { + this.emit('\u001b[2J\u001b[3J\u001b[H'); + } + this.emit(localize('build.colorized.preparing', 'Preparing build…') + EOL); + } + /** * Prepare the terminal at the start of a build: create it if needed, clear it * when requested, record the start time, and optionally print a bold banner. @@ -197,6 +211,14 @@ class OutputChannelBuildSink implements ColorizedBuildSink { return true; } + prepareForConfigure(clear: boolean): void { + const channel = this.ensureChannel(); + if (clear) { + channel.clear(); + } + channel.appendLine(localize('build.colorized.preparing', 'Preparing build…')); + } + prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs: string[] = []): void { const channel = this.ensureChannel(); this.baseDirs = baseDirs; diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index f88119d3b4..4308a2d3ee 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -2388,6 +2388,20 @@ export class CMakeProject { }; } + // Open the colorized "CMake Build" terminal *before* the pre-build configure so it + // appears immediately when the user starts a build, instead of leaving them looking at + // configuration progress while a (possibly slow) configure runs first. Gated on the same + // reveal decision as the build-start reveal, so automatic/programmatic builds stay quiet. + const earlyColorMode = this.workspaceContext.config.colorizedBuildOutput; + if (earlyColorMode !== 'off') { + const earlyReveal = logging.revealLogDecision(undefined, isAutomatic); + if (earlyReveal.show) { + const sink = colorizedBuildSink(); + sink.prepareForConfigure(this.workspaceContext.config.clearOutputBeforeBuild); + sink.reveal(earlyReveal.focus); + } + } + const configResult = await this.ensureConfigured(cancellationToken); if (configResult === null) { throw new Error(localize('unable.to.configure', 'Build failed: Unable to configure the project')); diff --git a/src/colorize.ts b/src/colorize.ts index 31a12368f0..d7223e98d0 100644 --- a/src/colorize.ts +++ b/src/colorize.ts @@ -283,6 +283,11 @@ export function renderBuildSummary(outcome: BuildOutcome, statusText: string, gl * both surfaces, so the colorization is portable across them. */ export interface ColorizedBuildSink { + /** Open the build surface (creating it if needed) *before* the pre-build configure runs, + * optionally clearing it, so the surface appears immediately when the user starts a build + * rather than only once configuration finishes. Writes a short "preparing" notice; the + * caller is responsible for revealing via {@link reveal}. */ + prepareForConfigure(clear: boolean): void; prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs?: string[]): void; writeLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): void; writeSummary(outcome: BuildOutcome, counts: { errors: number; warnings: number }, glyphs: GlyphStyle): void; diff --git a/src/logging.ts b/src/logging.ts index 10c131fc04..19237bcd01 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -272,10 +272,14 @@ export class Logger { SingletonLogger.instance().clearOutputChannel(); } - showChannel(error_to_show?: boolean, isAutomatic: boolean = false) { - const { show, focus } = revealLogDecision(error_to_show, isAutomatic); - if (show) { - SingletonLogger.instance().showChannel(!focus); + showChannel(error_to_show?: boolean, isAutomatic?: boolean) { + const config = vscode.workspace.getConfiguration('cmake'); + const reveal_log = config.get('revealLog', 'always'); + const reveal_on_automatic = config.get('revealLogOnAutomaticTrigger', false); + + const decision = decideReveal(reveal_log, error_to_show, isAutomatic ?? false, reveal_on_automatic); + if (decision.shouldShow) { + SingletonLogger.instance().showChannel(decision.preserveFocus); } } @@ -292,23 +296,21 @@ export function createLogger(tag: string) { /** * Decide, based on the `cmake.revealLog` setting, whether a surface (the Output * channel or the colorized build terminal) should be revealed, and whether it - * should take focus. Shared so the colorized build terminal honors `revealLog` - * exactly as the Output channel does. - * - * When `isAutomatic` is true (the operation was triggered automatically or - * programmatically rather than by an explicit user action), the proactive reveal - * is suppressed unless the user opts in via `cmake.revealLogOnAutomaticTrigger`. - * Failure reveals (`error_to_show === true`) always surface regardless. + * should take focus, returning the colorized-build-terminal friendly `{ show, focus }` + * shape. Thin wrapper over {@link decideReveal} (which the Output-channel reveal in + * `showChannel` also uses), so the terminal honors `cmake.revealLog` and + * `cmake.revealLogOnAutomaticTrigger` exactly as the Output channel does. */ export function revealLogDecision(error_to_show?: boolean, isAutomatic: boolean = false): { show: boolean; focus: boolean } { const config = vscode.workspace.getConfiguration('cmake'); const reveal_log = config.get('revealLog', 'always'); const reveal_on_automatic = config.get('revealLogOnAutomaticTrigger', false); - return decideReveal(reveal_log, error_to_show, isAutomatic, reveal_on_automatic); + const decision = decideReveal(reveal_log, error_to_show, isAutomatic, reveal_on_automatic); + return { show: decision.shouldShow, focus: !decision.preserveFocus }; } /** - * Pure decision logic for {@link revealLogDecision}, factored out so it can be + * Pure decision logic for revealing the CMake output, factored out so it can be * unit-tested without a VS Code instance. * * @param revealLog The `cmake.revealLog` value, controlling *how* a reveal happens. @@ -322,26 +324,27 @@ export function revealLogDecision(error_to_show?: boolean, isAutomatic: boolean * automatic/programmatic proactive reveals are suppressed so they don't * switch the panel away from a terminal the user is working in. */ -export function decideReveal(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { show: boolean; focus: boolean } { - const isFailure = errorToShow === true; - // Failures always surface. Suppress only proactive (non-failure) reveals for - // automatic/programmatic triggers, unless the user opted in. - if (isAutomatic && !revealOnAutomatic && !isFailure) { - return { show: false, focus: false }; - } - let show = false; +export function decideReveal(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { shouldShow: boolean; preserveFocus: boolean } { + const isFailureReveal = errorToShow === true; + // Suppress the proactive reveal for automatic/programmatic operations unless the user + // opts in. Failures (errorToShow === true) always surface so errors are never hidden. + if (isAutomatic && !isFailureReveal && !revealOnAutomatic) { + return { shouldShow: false, preserveFocus: true }; + } + + let shouldShow: boolean = false; if (revealLog === 'always') { - show = true; + shouldShow = true; } // won't show if no target information if (revealLog === 'error' && errorToShow !== undefined) { - show = errorToShow; + shouldShow = errorToShow; } - const focus = (revealLog === 'focus'); - if (focus) { - show = true; + const shouldFocus = (revealLog === 'focus'); + if (shouldFocus) { + shouldShow = true; } - return { show, focus }; + return { shouldShow, preserveFocus: !shouldFocus }; } export async function showLogFile(): Promise { diff --git a/test/unit-tests/backend/colorize.test.ts b/test/unit-tests/backend/colorize.test.ts index b6764020a5..8b6ed7812d 100644 --- a/test/unit-tests/backend/colorize.test.ts +++ b/test/unit-tests/backend/colorize.test.ts @@ -335,7 +335,7 @@ suite('[colorize] strip-before-parse preserves classification', () => { suite('[colorize] sink selection', () => { const fakeSink = (): ColorizedBuildSink => ({ - prepareForBuild: () => {}, writeLine: () => {}, writeSummary: () => {}, reveal: () => true, dispose: () => {} + prepareForConfigure: () => {}, prepareForBuild: () => {}, writeLine: () => {}, writeSummary: () => {}, reveal: () => true, dispose: () => {} }); test('the Output panel cannot render ANSI today', () => { expect(canRenderAnsiInOutput()).to.equal(false); diff --git a/test/unit-tests/backend/revealDecision.test.ts b/test/unit-tests/backend/revealDecision.test.ts index dd4bd5215e..7aff50f032 100644 --- a/test/unit-tests/backend/revealDecision.test.ts +++ b/test/unit-tests/backend/revealDecision.test.ts @@ -19,23 +19,32 @@ import * as path from 'path'; type RevealLogKey = 'always' | 'never' | 'focus' | 'error'; // Mirror of `decideReveal` in src/logging.ts. -function decideReveal(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { show: boolean; focus: boolean } { - const isFailure = errorToShow === true; - if (isAutomatic && !revealOnAutomatic && !isFailure) { - return { show: false, focus: false }; +// Mirror of `decideReveal` in src/logging.ts (returns { shouldShow, preserveFocus }, matching +// the cmake.revealLogOnAutomaticTrigger gate shared with the #4988 work). +function decideReveal(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { shouldShow: boolean; preserveFocus: boolean } { + const isFailureReveal = errorToShow === true; + if (isAutomatic && !isFailureReveal && !revealOnAutomatic) { + return { shouldShow: false, preserveFocus: true }; } - let show = false; + let shouldShow = false; if (revealLog === 'always') { - show = true; + shouldShow = true; } if (revealLog === 'error' && errorToShow !== undefined) { - show = errorToShow; + shouldShow = errorToShow; } - const focus = (revealLog === 'focus'); - if (focus) { - show = true; + const shouldFocus = (revealLog === 'focus'); + if (shouldFocus) { + shouldShow = true; } - return { show, focus }; + return { shouldShow, preserveFocus: !shouldFocus }; +} + +// Mirror of the colorization-only `revealLogDecision` wrapper: maps decideReveal's +// { shouldShow, preserveFocus } to the { show, focus } shape the colorized terminal reveal uses. +function revealLogDecision(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { show: boolean; focus: boolean } { + const d = decideReveal(revealLog, errorToShow, isAutomatic, revealOnAutomatic); + return { show: d.shouldShow, focus: !d.preserveFocus }; } // Mirror of `isAutomaticConfigureTrigger` in src/cmakeProject.ts (ConfigureTrigger values). @@ -99,36 +108,49 @@ suite('[revealDecision] #4988 minimize interruptions on automatic triggers', () suite('decideReveal', () => { test('user-initiated operations keep legacy revealLog behavior', () => { - expect(decideReveal('always', undefined, false, false)).to.deep.equal({ show: true, focus: false }); - expect(decideReveal('focus', undefined, false, false)).to.deep.equal({ show: true, focus: true }); - expect(decideReveal('never', undefined, false, false)).to.deep.equal({ show: false, focus: false }); - expect(decideReveal('error', undefined, false, false)).to.deep.equal({ show: false, focus: false }); + expect(decideReveal('always', undefined, false, false)).to.deep.equal({ shouldShow: true, preserveFocus: true }); + expect(decideReveal('focus', undefined, false, false)).to.deep.equal({ shouldShow: true, preserveFocus: false }); + expect(decideReveal('never', undefined, false, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + expect(decideReveal('error', undefined, false, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); }); test('automatic operations are suppressed by default (the fix)', () => { - expect(decideReveal('always', undefined, true, false)).to.deep.equal({ show: false, focus: false }); + expect(decideReveal('always', undefined, true, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); // Even when the user chose `focus`, an automatic op must not steal focus. - expect(decideReveal('focus', undefined, true, false)).to.deep.equal({ show: false, focus: false }); + expect(decideReveal('focus', undefined, true, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); }); test('automatic operations reveal when the user opts in', () => { - expect(decideReveal('always', undefined, true, true)).to.deep.equal({ show: true, focus: false }); - expect(decideReveal('focus', undefined, true, true)).to.deep.equal({ show: true, focus: true }); + expect(decideReveal('always', undefined, true, true)).to.deep.equal({ shouldShow: true, preserveFocus: true }); + expect(decideReveal('focus', undefined, true, true)).to.deep.equal({ shouldShow: true, preserveFocus: false }); }); test('failures always surface, even for automatic operations and even when opted out', () => { - expect(decideReveal('always', true, true, false)).to.deep.equal({ show: true, focus: false }); - expect(decideReveal('error', true, true, false)).to.deep.equal({ show: true, focus: false }); - expect(decideReveal('focus', true, true, false)).to.deep.equal({ show: true, focus: true }); + expect(decideReveal('always', true, true, false)).to.deep.equal({ shouldShow: true, preserveFocus: true }); + expect(decideReveal('error', true, true, false)).to.deep.equal({ shouldShow: true, preserveFocus: true }); + expect(decideReveal('focus', true, true, false)).to.deep.equal({ shouldShow: true, preserveFocus: false }); }); test('successful automatic results are not revealed under error mode', () => { - expect(decideReveal('error', false, true, false)).to.deep.equal({ show: false, focus: false }); + expect(decideReveal('error', false, true, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); }); test('never suppresses everything, including failures (subordinate to revealLog as today)', () => { - expect(decideReveal('never', true, false, true)).to.deep.equal({ show: false, focus: false }); - expect(decideReveal('never', true, true, true)).to.deep.equal({ show: false, focus: false }); + expect(decideReveal('never', true, false, true)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + expect(decideReveal('never', true, true, true)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + }); + }); + + suite('revealLogDecision (colorized terminal mapping)', () => { + test('maps shouldShow -> show and preserveFocus -> !focus', () => { + // always/user: shown, focus preserved (no focus steal) + expect(revealLogDecision('always', undefined, false, false)).to.deep.equal({ show: true, focus: false }); + // focus/user: shown and takes focus + expect(revealLogDecision('focus', undefined, false, false)).to.deep.equal({ show: true, focus: true }); + // automatic suppressed by default + expect(revealLogDecision('always', undefined, true, false)).to.deep.equal({ show: false, focus: false }); + // failure always shows + expect(revealLogDecision('always', true, true, false)).to.deep.equal({ show: true, focus: false }); }); }); From 212048ab87d98d566dcef6dc19e3a1e109078b82 Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Wed, 24 Jun 2026 10:44:40 -0500 Subject: [PATCH 8/9] Add colorized build output usage telemetry and consolidate its CHANGELOG entry - Telemetry: include cmake.colorizedBuildOutput (off|severity|rich|compiler) and cmake.buildOutputGlyphs (unicode|ascii) as properties on the existing per-build telemetry event so adoption of the experimental colorized build output feature can be tracked. Both are low-cardinality enums containing no user data. The build telemetry properties object is now always defined (it was undefined in presets mode); ConfigType remains kits-mode-only as before. - CHANGELOG: fold the colorized-terminal behavior (prompt open, revealLogOnAutomaticTrigger handling) into the single colorizedBuildOutput Features entry so all colorization content lives solely under Features rather than split across Features and Improvements. --- CHANGELOG.md | 3 +-- src/drivers/cmakeDriver.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e303e08d..adafbfcc45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,9 @@ Features: - Add support for `${workspaceFolder}`, `${workspaceFolder:name}` variables and relative paths in `cmake.exclude` setting for multi-root workspaces. [#4689](https://github.com/microsoft/vscode-cmake-tools/pull/4689) - Add `onConfigureResult` event to the CMake Tools API that fires after every configure attempt (success or failure), allowing dependent extensions to detect and react to configure failures. [#4021](https://github.com/microsoft/vscode-cmake-tools/issues/4021) - Add `cmake.preConfigureTask` setting to execute a named VS Code task before every CMake configure. [#2449](https://github.com/microsoft/vscode-cmake-tools/issues/2449) [#4960](https://github.com/microsoft/vscode-cmake-tools/pull/4960) [@erdemiru](https://github.com/erdemiru) -- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal where error locations are clickable. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. A `compiler` mode forwards the compiler's and build tools' own colors by setting `CMAKE_COLOR_DIAGNOSTICS=ON` at configure time and `CLICOLOR_FORCE=1` at build time. The colorization is portable: it is rendered in the terminal today and is automatically routed to the Output panel if a future VS Code renders ANSI there. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) +- Add experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal where error locations are clickable. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. A `compiler` mode forwards the compiler's and build tools' own colors by setting `CMAKE_COLOR_DIAGNOSTICS=ON` at configure time and `CLICOLOR_FORCE=1` at build time. The colorization is portable: it is rendered in the terminal today and is automatically routed to the Output panel if a future VS Code renders ANSI there. The CMake Build terminal opens promptly when you start a build (even when a configure runs first) and honors `cmake.revealLogOnAutomaticTrigger`, staying quiet for automatic and programmatic builds (e.g. configure-on-open and builds invoked through the CMake Tools API by Copilot via the C/C++ DevTools companion) while always surfacing failures. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: -- The colorized CMake Build terminal now honors `cmake.revealLogOnAutomaticTrigger`: it opens promptly for builds you start yourself but stays quiet for automatic and programmatic builds (e.g. configure-on-open and builds invoked through the CMake Tools API by Copilot via the C/C++ DevTools companion), and it now opens immediately when you start a build instead of leaving you on configuration progress while a configure runs first. Failures are always shown. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. - Further reduce CI pipeline time by splitting each platform pipeline into a build job and a parallel test matrix (backend, smoke, unit, integration, four E2E suites), caching `node_modules` between runs, sharing a single Xvfb instance on Linux, dropping a duplicate backend-test load inside the unit-tests Electron suite, and adding per-step timeouts to prevent silent hangs. - Add `cmake.showTimestampsInOutput` setting to display timestamps and log levels in the CMake output channel, useful for tracking build durations. [#4057](https://github.com/microsoft/vscode-cmake-tools/issues/4057) diff --git a/src/drivers/cmakeDriver.ts b/src/drivers/cmakeDriver.ts index 2dbe4cbd84..f130b7e678 100644 --- a/src/drivers/cmakeDriver.ts +++ b/src/drivers/cmakeDriver.ts @@ -1987,9 +1987,16 @@ export abstract class CMakeDriver implements vscode.Disposable { const timeEnd: number = new Date().getTime(); const duration: number = timeEnd - timeStart; log.info(localize('build.duration', 'Build completed: {0}', util.msToString(duration))); - const telemetryProperties: telemetry.Properties | undefined = this.useCMakePresets ? undefined : { - ConfigType: this.isMultiConfFast ? 'MultiConf' : this.currentBuildType || '' + // Track adoption of the colorized build output feature (cmake.colorizedBuildOutput). + // Both values are low-cardinality enums (no user data): off|severity|rich|compiler and + // unicode|ascii. ConfigType stays kits-mode-only as before. + const telemetryProperties: telemetry.Properties = { + colorizedBuildOutput: this.config.colorizedBuildOutput, + buildOutputGlyphs: this.config.buildOutputGlyphs }; + if (!this.useCMakePresets) { + telemetryProperties['ConfigType'] = this.isMultiConfFast ? 'MultiConf' : this.currentBuildType || ''; + } const telemetryMeasures: telemetry.Measures = { Duration: duration }; From b09888240b467624ae8f039506f002d744fcf02d Mon Sep 17 00:00:00 2001 From: hanniavalera Date: Wed, 24 Jun 2026 16:21:43 -0500 Subject: [PATCH 9/9] Keep colorizedBuildOutput=off byte-identical to release (no ANSI stripping when off) The colorized build-output pipeline stripped ANSI from every build line before parsing, echoing to the Output channel/log, and capturing stdout/stderr - including when cmake.colorizedBuildOutput is off. In the normal case (no ANSI) that is a no-op, but if a tool emits ANSI on its own (e.g. the user forces -fdiagnostics-color=always or CLICOLOR_FORCE), off mode was no longer byte-identical to the shipped release. Strip only when colorization is enabled: `const clean = this.colorMode === 'off' ? line : stripAnsi(line)` in both the main build consumer (diagnostics/build.ts) and the build-task path (cmakeTaskProvider.ts). Off mode now passes the raw line through to the parser, the Output channel/log, and the captured stdout/stderr exactly as before the feature, preserving the feature's opt-out safety guarantee. The colorized modes (severity/rich/compiler) keep strip-before-parse, which is where it matters since compiler mode forces real compiler colors. --- src/cmakeTaskProvider.ts | 4 ++-- src/diagnostics/build.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/cmakeTaskProvider.ts b/src/cmakeTaskProvider.ts index c386a66db2..be74766522 100644 --- a/src/cmakeTaskProvider.ts +++ b/src/cmakeTaskProvider.ts @@ -635,11 +635,11 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc ? { output: (line: string) => { this.output(line); - compileConsumer!.output(stripAnsi(line)); + compileConsumer!.output(this.colorMode === 'off' ? line : stripAnsi(line)); }, error: (line: string) => { this.error(line); - compileConsumer!.error(stripAnsi(line)); + compileConsumer!.error(this.colorMode === 'off' ? line : stripAnsi(line)); } } : this; diff --git a/src/diagnostics/build.ts b/src/diagnostics/build.ts index 34b5cb358a..d47ca4eb43 100644 --- a/src/diagnostics/build.ts +++ b/src/diagnostics/build.ts @@ -396,14 +396,19 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D } error(line: string) { - const clean = stripAnsi(line); + // In `off` mode, behave byte-identically to the shipped release: pass the raw line + // through to the parser, the Output channel/log, and the captured stdout/stderr. Only + // strip ANSI when colorization is enabled (where a tool may emit real colors, e.g. + // `compiler` mode forces CLICOLOR_FORCE), so the parser and on-disk log stay clean while + // the terminal still receives the raw colored line. + const clean = this.colorMode === 'off' ? line : stripAnsi(line); this.compileConsumer.error(clean); this.echo(line, clean, true); super.error(clean); } output(line: string) { - const clean = stripAnsi(line); + const clean = this.colorMode === 'off' ? line : stripAnsi(line); this.compileConsumer.output(clean); this.echo(line, clean, false); super.output(clean);