mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-29 15:40:10 +00:00
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:
@@ -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') =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -151,3 +151,29 @@ export function sanitizeOutput(output: string): string {
|
||||
const escaped = trimmed.replaceAll('</output>', '</output>');
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user