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
This commit is contained in:
gemini-cli[bot]
2026-05-14 15:57:19 +00:00
parent 6efdbd3e48
commit 2fdd259666
6 changed files with 109 additions and 10 deletions

View File

@@ -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') =>

View File

@@ -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);

View File

@@ -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');
}

View File

@@ -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();

View File

@@ -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');

View File

@@ -151,3 +151,29 @@ export function sanitizeOutput(output: string): string {
const escaped = trimmed.replaceAll('</output>', '&lt;/output&gt;');
return `<output>\n${escaped}\n</output>`;
}
/**
* 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');
}