From 2fdd259666796a70264b8b48adc4fe2fba2390c4 Mon Sep 17 00:00:00 2001 From: "gemini-cli[bot]" Date: Thu, 14 May 2026 15:57:19 +0000 Subject: [PATCH] fix: strip line/column suffixes from Windows path links This PR implements a workaround for issue #26902 where terminal link handlers on Windows (specifically in the Antigravity editor and standard VS Code) fail to correctly parse and open absolute file paths that include `:line:column` suffixes. On Windows, colons are invalid characters in file paths (except for the drive letter), causing `FileSystemError` when these links are clicked. ### Changes: - Added `stripLineColumnSuffixes` utility in `packages/core/src/utils/textUtils.ts`. - Applied this utility across various output formatting paths to ensure safe link generation on Windows: - `packages/cli/src/ui/utils/textOutput.ts` (Non-interactive mode) - `packages/cli/src/ui/utils/markdownParsingUtils.ts` (Agent output in interactive mode) - `packages/core/src/utils/debugLogger.ts` (Core debug logging) - `packages/cli/src/ui/utils/ConsolePatcher.ts` (Interactive mode console redirection) - Added comprehensive unit tests in `packages/core/src/utils/textUtils.test.ts`. These changes are only active when running on Windows (`process.platform === 'win32'`). Fixes #26902 cc @google-gemini/gemini-cli-maintainers --- packages/cli/src/ui/utils/ConsolePatcher.ts | 6 ++- .../cli/src/ui/utils/markdownParsingUtils.ts | 10 +++- packages/cli/src/ui/utils/textOutput.ts | 6 ++- packages/core/src/utils/debugLogger.ts | 18 +++++-- packages/core/src/utils/textUtils.test.ts | 53 ++++++++++++++++++- packages/core/src/utils/textUtils.ts | 26 +++++++++ 6 files changed, 109 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/utils/ConsolePatcher.ts b/packages/cli/src/ui/utils/ConsolePatcher.ts index ddd26fca0b..942836f7d9 100644 --- a/packages/cli/src/ui/utils/ConsolePatcher.ts +++ b/packages/cli/src/ui/utils/ConsolePatcher.ts @@ -7,6 +7,7 @@ /* eslint-disable no-console */ import util from 'node:util'; +import { stripLineColumnSuffixes } from '@google/gemini-cli-core'; import type { ConsoleMessageItem } from '../types.js'; interface ConsolePatcherParams { @@ -45,7 +46,10 @@ export class ConsolePatcher { console.info = this.originalConsoleInfo; }; - private formatArgs = (args: unknown[]): string => util.format(...args); + private formatArgs = (args: unknown[]): string => { + const formatted = util.format(...args); + return stripLineColumnSuffixes(formatted); + }; private patchConsoleMethod = (type: 'log' | 'warn' | 'error' | 'debug' | 'info') => diff --git a/packages/cli/src/ui/utils/markdownParsingUtils.ts b/packages/cli/src/ui/utils/markdownParsingUtils.ts index 841809f08c..accf36c441 100644 --- a/packages/cli/src/ui/utils/markdownParsingUtils.ts +++ b/packages/cli/src/ui/utils/markdownParsingUtils.ts @@ -11,7 +11,7 @@ import { INK_NAME_TO_HEX_MAP, } from '../themes/color-utils.js'; import { theme } from '../semantic-colors.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, stripLineColumnSuffixes } from '@google/gemini-cli-core'; import { convertLatexToUnicode } from './latexToUnicode.js'; // Constants for Markdown parsing @@ -108,6 +108,12 @@ export const parseMarkdownToANSI = ( defaultColor?: string, ): string => { const baseColor = defaultColor ?? theme.text.primary; + // Strip line and column number suffixes from Windows paths BEFORE any other + // processing. This ensures that we don't break markdown parsing or URL + // detection, and that the final output is safe for Windows terminal links. + // See issue #26902. + const sanitizedRawText = stripLineColumnSuffixes(rawText); + // Convert LaTeX-style math/commands to Unicode BEFORE tokenizing markdown, // so constructs like `$\{P_0, \dots, P_n\}$` are handled as a whole even // when they contain underscores (which the tokenizer would otherwise treat @@ -115,7 +121,7 @@ export const parseMarkdownToANSI = ( // conversion so their contents are preserved verbatim. Unknown `\foo` // sequences are left alone, so Windows paths and regex escapes survive. // See issue #25656. - const text = convertLatexPreservingSpans(rawText); + const text = convertLatexPreservingSpans(sanitizedRawText); // Early return for plain text without markdown or URLs if (!/[*_~`<[https?:]/.test(text)) { return ansiColorize(text, baseColor); diff --git a/packages/cli/src/ui/utils/textOutput.ts b/packages/cli/src/ui/utils/textOutput.ts index 40a750257e..2da50abf3e 100644 --- a/packages/cli/src/ui/utils/textOutput.ts +++ b/packages/cli/src/ui/utils/textOutput.ts @@ -10,6 +10,7 @@ */ import stripAnsi from 'strip-ansi'; +import { stripLineColumnSuffixes } from '@google/gemini-cli-core'; export class TextOutput { private atStartOfLine = true; @@ -27,8 +28,9 @@ export class TextOutput { if (str.length === 0) { return; } - this.outputStream.write(str); - const strippedStr = stripAnsi(str); + const processedStr = stripLineColumnSuffixes(str); + this.outputStream.write(processedStr); + const strippedStr = stripAnsi(processedStr); if (strippedStr.length > 0) { this.atStartOfLine = strippedStr.endsWith('\n'); } diff --git a/packages/core/src/utils/debugLogger.ts b/packages/core/src/utils/debugLogger.ts index 9c5a82c123..09f40e3f54 100644 --- a/packages/core/src/utils/debugLogger.ts +++ b/packages/core/src/utils/debugLogger.ts @@ -7,6 +7,7 @@ /* eslint-disable no-console */ import * as fs from 'node:fs'; import * as util from 'node:util'; +import { stripLineColumnSuffixes } from './textUtils.js'; /** * A simple, centralized logger for developer-facing debug messages. @@ -47,23 +48,32 @@ class DebugLogger { log(...args: unknown[]): void { this.writeToFile('LOG', args); - console.log(...args); + console.log(...stripLineColumnSuffixesFromArgs(args)); } warn(...args: unknown[]): void { this.writeToFile('WARN', args); - console.warn(...args); + console.warn(...stripLineColumnSuffixesFromArgs(args)); } error(...args: unknown[]): void { this.writeToFile('ERROR', args); - console.error(...args); + console.error(...stripLineColumnSuffixesFromArgs(args)); } debug(...args: unknown[]): void { this.writeToFile('DEBUG', args); - console.debug(...args); + console.debug(...stripLineColumnSuffixesFromArgs(args)); } } +function stripLineColumnSuffixesFromArgs(args: unknown[]): unknown[] { + return args.map((arg) => { + if (typeof arg === 'string') { + return stripLineColumnSuffixes(arg); + } + return arg; + }); +} + export const debugLogger = new DebugLogger(); diff --git a/packages/core/src/utils/textUtils.test.ts b/packages/core/src/utils/textUtils.test.ts index c1c572a170..aa3f08b049 100644 --- a/packages/core/src/utils/textUtils.test.ts +++ b/packages/core/src/utils/textUtils.test.ts @@ -4,13 +4,64 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { safeLiteralReplace, truncateString, safeTemplateReplace, + stripLineColumnSuffixes, } from './textUtils.js'; +describe('stripLineColumnSuffixes', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should strip line and column numbers from absolute and relative Windows paths on Windows', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + + expect(stripLineColumnSuffixes('C:\\foo\\bar.js:10:5')).toBe('C:\\foo\\bar.js'); + expect(stripLineColumnSuffixes('D:\\project\\index.ts:42')).toBe( + 'D:\\project\\index.ts', + ); + expect(stripLineColumnSuffixes('src\\utils\\file.ts:10:5')).toBe( + 'src\\utils\\file.ts', + ); + expect(stripLineColumnSuffixes('C:\\path with spaces\\file.txt:1:1')).toBe( + 'C:\\path with spaces\\file.txt', + ); + expect(stripLineColumnSuffixes('Found at C:\\foo.js:10:5.')).toBe( + 'Found at C:\\foo.js.', + ); + }); + + it('should not strip suffixes from non-absolute Windows paths or URLs', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + + expect(stripLineColumnSuffixes('http://localhost:3000')).toBe( + 'http://localhost:3000', + ); + expect(stripLineColumnSuffixes('ftp://127.0.0.1:21')).toBe( + 'ftp://127.0.0.1:21', + ); + expect(stripLineColumnSuffixes('some text with :10:5 suffix')).toBe( + 'some text with :10:5 suffix', + ); + }); + + it('should not strip anything on non-Windows platforms', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + + expect(stripLineColumnSuffixes('C:\\foo\\bar.js:10:5')).toBe( + 'C:\\foo\\bar.js:10:5', + ); + expect(stripLineColumnSuffixes('/home/user/file.ts:10:5')).toBe( + '/home/user/file.ts:10:5', + ); + }); +}); + describe('safeLiteralReplace', () => { it('returns original string when oldString empty or not found', () => { expect(safeLiteralReplace('abc', '', 'X')).toBe('abc'); diff --git a/packages/core/src/utils/textUtils.ts b/packages/core/src/utils/textUtils.ts index c5d62074a0..53ffaf14cc 100644 --- a/packages/core/src/utils/textUtils.ts +++ b/packages/core/src/utils/textUtils.ts @@ -151,3 +151,29 @@ export function sanitizeOutput(output: string): string { const escaped = trimmed.replaceAll('', '</output>'); return `\n${escaped}\n`; } + +/** + * Matches absolute Windows paths (C:\...) or relative Windows paths with at + * least one backslash, followed by a :line[:col] suffix. + * e.g., "C:\file.ts:10:5" or "src\file.ts:10" + * + * This regex is carefully constructed to avoid matching URLs (which don't use + * backslashes in the host/port part). + */ +const WINDOWS_PATH_WITH_SUFFIX_REGEX = + /(([a-zA-Z]:\\|[^\s:<>|"]+\\)[^\s:<>|"]+):\d+(?::\d+)?/g; + +/** + * Strips line and column number suffixes from absolute and relative Windows + * file paths. + * e.g., "C:\path\to\file.ts:10:5" -> "C:\path\to\file.ts" + * + * This is a workaround for issue #26902 where some Windows terminal link + * handlers fail to correctly parse and stat paths with these suffixes. + */ +export function stripLineColumnSuffixes(text: string): string { + if (process.platform !== 'win32' || !text.includes(':')) { + return text; + } + return text.replace(WINDOWS_PATH_WITH_SUFFIX_REGEX, '$1'); +}