Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15

This commit is contained in:
奕桁
2025-08-01 23:06:11 +08:00
340 changed files with 36528 additions and 22931 deletions

View File

@@ -50,7 +50,7 @@ function renderHastNode(
}
// Determine the color to pass down: Use this element's specific color
// if found, otherwise, continue passing down the already inherited color.
// if found; otherwise, continue passing down the already inherited color.
const colorToPassDown = elementColor || inheritedColor;
// Recursively render children, passing the determined color down
@@ -68,9 +68,9 @@ function renderHastNode(
return <React.Fragment>{children}</React.Fragment>;
}
// Handle Root Node: Start recursion with initial inherited color
// Handle Root Node: Start recursion with initially inherited color
if (node.type === 'root') {
// Check if children array is empty - this happens when lowlight can't detect language fallback to plain text
// Check if children array is empty - this happens when lowlight can't detect language fall back to plain text
if (!node.children || node.children.length === 0) {
return null;
}
@@ -88,6 +88,34 @@ function renderHastNode(
return null;
}
function highlightAndRenderLine(
line: string,
language: string | null,
theme: Theme,
): React.ReactNode {
try {
const getHighlightedLine = () =>
!language || !lowlight.registered(language)
? lowlight.highlightAuto(line)
: lowlight.highlight(language, line);
const renderedNode = renderHastNode(getHighlightedLine(), theme, undefined);
return renderedNode !== null ? renderedNode : line;
} catch (_error) {
return line;
}
}
export function colorizeLine(
line: string,
language: string | null,
theme?: Theme,
): React.ReactNode {
const activeTheme = theme || themeManager.getActiveTheme();
return highlightAndRenderLine(line, language, activeTheme);
}
/**
* Renders syntax-highlighted code for Ink applications using a selected theme.
*
@@ -100,9 +128,10 @@ export function colorizeCode(
language: string | null,
availableHeight?: number,
maxWidth?: number,
theme?: Theme,
): React.ReactNode {
const codeToHighlight = code.replace(/\n$/, '');
const activeTheme = themeManager.getActiveTheme();
const activeTheme = theme || themeManager.getActiveTheme();
try {
// Render the HAST tree using the adapted theme
@@ -122,11 +151,6 @@ export function colorizeCode(
}
}
const getHighlightedLines = (line: string) =>
!language || !lowlight.registered(language)
? lowlight.highlightAuto(line)
: lowlight.highlight(language, line);
return (
<MaxSizedBox
maxHeight={availableHeight}
@@ -135,17 +159,19 @@ export function colorizeCode(
overflowDirection="top"
>
{lines.map((line, index) => {
const renderedNode = renderHastNode(
getHighlightedLines(line),
const contentToRender = highlightAndRenderLine(
line,
language,
activeTheme,
undefined,
);
const contentToRender = renderedNode !== null ? renderedNode : line;
return (
<Box key={index}>
<Text color={activeTheme.colors.Gray}>
{`${String(index + 1 + hiddenLinesCount).padStart(padWidth, ' ')} `}
{`${String(index + 1 + hiddenLinesCount).padStart(
padWidth,
' ',
)} `}
</Text>
<Text color={activeTheme.defaultColor} wrap="wrap">
{contentToRender}
@@ -160,7 +186,7 @@ export function colorizeCode(
`[colorizeCode] Error highlighting code for language "${language}":`,
error,
);
// Fallback to plain text with default color on error
// Fall back to plain text with default color on error
// Also display line numbers in fallback
const lines = codeToHighlight.split('\n');
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines

View File

@@ -0,0 +1,345 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import {
isAtCommand,
isSlashCommand,
copyToClipboard,
} from './commandUtils.js';
// Mock child_process
vi.mock('child_process');
// Mock process.platform for platform-specific tests
const mockProcess = vi.hoisted(() => ({
platform: 'darwin',
}));
vi.stubGlobal('process', {
...process,
get platform() {
return mockProcess.platform;
},
});
interface MockChildProcess extends EventEmitter {
stdin: EventEmitter & {
write: Mock;
end: Mock;
};
stderr: EventEmitter;
}
describe('commandUtils', () => {
let mockSpawn: Mock;
let mockChild: MockChildProcess;
beforeEach(async () => {
vi.clearAllMocks();
// Dynamically import and set up spawn mock
const { spawn } = await import('child_process');
mockSpawn = spawn as Mock;
// Create mock child process with stdout/stderr emitters
mockChild = Object.assign(new EventEmitter(), {
stdin: Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(),
}),
stderr: new EventEmitter(),
}) as MockChildProcess;
mockSpawn.mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>);
});
describe('isAtCommand', () => {
it('should return true when query starts with @', () => {
expect(isAtCommand('@file')).toBe(true);
expect(isAtCommand('@path/to/file')).toBe(true);
expect(isAtCommand('@')).toBe(true);
});
it('should return true when query contains @ preceded by whitespace', () => {
expect(isAtCommand('hello @file')).toBe(true);
expect(isAtCommand('some text @path/to/file')).toBe(true);
expect(isAtCommand(' @file')).toBe(true);
});
it('should return false when query does not start with @ and has no spaced @', () => {
expect(isAtCommand('file')).toBe(false);
expect(isAtCommand('hello')).toBe(false);
expect(isAtCommand('')).toBe(false);
expect(isAtCommand('email@domain.com')).toBe(false);
expect(isAtCommand('user@host')).toBe(false);
});
it('should return false when @ is not preceded by whitespace', () => {
expect(isAtCommand('hello@file')).toBe(false);
expect(isAtCommand('text@path')).toBe(false);
});
});
describe('isSlashCommand', () => {
it('should return true when query starts with /', () => {
expect(isSlashCommand('/help')).toBe(true);
expect(isSlashCommand('/memory show')).toBe(true);
expect(isSlashCommand('/clear')).toBe(true);
expect(isSlashCommand('/')).toBe(true);
});
it('should return false when query does not start with /', () => {
expect(isSlashCommand('help')).toBe(false);
expect(isSlashCommand('memory show')).toBe(false);
expect(isSlashCommand('')).toBe(false);
expect(isSlashCommand('path/to/file')).toBe(false);
expect(isSlashCommand(' /help')).toBe(false);
});
});
describe('copyToClipboard', () => {
describe('on macOS (darwin)', () => {
beforeEach(() => {
mockProcess.platform = 'darwin';
});
it('should successfully copy text to clipboard using pbcopy', async () => {
const testText = 'Hello, world!';
// Simulate successful execution
setTimeout(() => {
mockChild.emit('close', 0);
}, 0);
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith('pbcopy', []);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled();
});
it('should handle pbcopy command failure', async () => {
const testText = 'Hello, world!';
// Simulate command failure
setTimeout(() => {
mockChild.stderr.emit('data', 'Command not found');
mockChild.emit('close', 1);
}, 0);
await expect(copyToClipboard(testText)).rejects.toThrow(
"'pbcopy' exited with code 1: Command not found",
);
});
it('should handle spawn error', async () => {
const testText = 'Hello, world!';
setTimeout(() => {
mockChild.emit('error', new Error('spawn error'));
}, 0);
await expect(copyToClipboard(testText)).rejects.toThrow('spawn error');
});
it('should handle stdin write error', async () => {
const testText = 'Hello, world!';
setTimeout(() => {
mockChild.stdin.emit('error', new Error('stdin error'));
}, 0);
await expect(copyToClipboard(testText)).rejects.toThrow('stdin error');
});
});
describe('on Windows (win32)', () => {
beforeEach(() => {
mockProcess.platform = 'win32';
});
it('should successfully copy text to clipboard using clip', async () => {
const testText = 'Hello, world!';
setTimeout(() => {
mockChild.emit('close', 0);
}, 0);
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith('clip', []);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled();
});
});
describe('on Linux', () => {
beforeEach(() => {
mockProcess.platform = 'linux';
});
it('should successfully copy text to clipboard using xclip', async () => {
const testText = 'Hello, world!';
setTimeout(() => {
mockChild.emit('close', 0);
}, 0);
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith('xclip', [
'-selection',
'clipboard',
]);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled();
});
it('should fall back to xsel when xclip fails', async () => {
const testText = 'Hello, world!';
let callCount = 0;
mockSpawn.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
stdin: Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(),
}),
stderr: new EventEmitter(),
}) as MockChildProcess;
setTimeout(() => {
if (callCount === 0) {
// First call (xclip) fails
child.stderr.emit('data', 'xclip not found');
child.emit('close', 1);
callCount++;
} else {
// Second call (xsel) succeeds
child.emit('close', 0);
}
}, 0);
return child as unknown as ReturnType<typeof spawn>;
});
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(mockSpawn).toHaveBeenNthCalledWith(1, 'xclip', [
'-selection',
'clipboard',
]);
expect(mockSpawn).toHaveBeenNthCalledWith(2, 'xsel', [
'--clipboard',
'--input',
]);
});
it('should throw error when both xclip and xsel fail', async () => {
const testText = 'Hello, world!';
let callCount = 0;
mockSpawn.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
stdin: Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(),
}),
stderr: new EventEmitter(),
});
setTimeout(() => {
if (callCount === 0) {
// First call (xclip) fails
child.stderr.emit('data', 'xclip command not found');
child.emit('close', 1);
callCount++;
} else {
// Second call (xsel) fails
child.stderr.emit('data', 'xsel command not found');
child.emit('close', 1);
}
}, 0);
return child as unknown as ReturnType<typeof spawn>;
});
await expect(copyToClipboard(testText)).rejects.toThrow(
/All copy commands failed/,
);
expect(mockSpawn).toHaveBeenCalledTimes(2);
});
});
describe('on unsupported platform', () => {
beforeEach(() => {
mockProcess.platform = 'unsupported';
});
it('should throw error for unsupported platform', async () => {
await expect(copyToClipboard('test')).rejects.toThrow(
'Unsupported platform: unsupported',
);
});
});
describe('error handling', () => {
beforeEach(() => {
mockProcess.platform = 'darwin';
});
it('should handle command exit without stderr', async () => {
const testText = 'Hello, world!';
setTimeout(() => {
mockChild.emit('close', 1);
}, 0);
await expect(copyToClipboard(testText)).rejects.toThrow(
"'pbcopy' exited with code 1",
);
});
it('should handle empty text', async () => {
setTimeout(() => {
mockChild.emit('close', 0);
}, 0);
await copyToClipboard('');
expect(mockChild.stdin.write).toHaveBeenCalledWith('');
});
it('should handle multiline text', async () => {
const multilineText = 'Line 1\nLine 2\nLine 3';
setTimeout(() => {
mockChild.emit('close', 0);
}, 0);
await copyToClipboard(multilineText);
expect(mockChild.stdin.write).toHaveBeenCalledWith(multilineText);
});
it('should handle special characters', async () => {
const specialText = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?';
setTimeout(() => {
mockChild.emit('close', 0);
}, 0);
await copyToClipboard(specialText);
expect(mockChild.stdin.write).toHaveBeenCalledWith(specialText);
});
});
});
});

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'child_process';
/**
* Checks if a query string potentially represents an '@' command.
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
@@ -24,3 +26,57 @@ export const isAtCommand = (query: string): boolean =>
* @returns True if the query looks like an '/' command, false otherwise.
*/
export const isSlashCommand = (query: string): boolean => query.startsWith('/');
//Copies a string snippet to the clipboard for different platforms
export const copyToClipboard = async (text: string): Promise<void> => {
const run = (cmd: string, args: string[]) =>
new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args);
let stderr = '';
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) return resolve();
const errorMsg = stderr.trim();
reject(
new Error(
`'${cmd}' exited with code ${code}${errorMsg ? `: ${errorMsg}` : ''}`,
),
);
});
child.stdin.on('error', reject);
child.stdin.write(text);
child.stdin.end();
});
switch (process.platform) {
case 'win32':
return run('clip', []);
case 'darwin':
return run('pbcopy', []);
case 'linux':
try {
await run('xclip', ['-selection', 'clipboard']);
} catch (primaryError) {
try {
// If xclip fails for any reason, try xsel as a fallback.
await run('xsel', ['--clipboard', '--input']);
} catch (fallbackError) {
const primaryMsg =
primaryError instanceof Error
? primaryError.message
: String(primaryError);
const fallbackMsg =
fallbackError instanceof Error
? fallbackError.message
: String(fallbackError);
throw new Error(
`All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`,
);
}
}
return;
default:
throw new Error(`Unsupported platform: ${process.platform}`);
}
};

View File

@@ -14,6 +14,7 @@ import {
isApiError,
isStructuredError,
} from '@qwen-code/qwen-code-core';
// Free Tier message functions
const getRateLimitErrorMessageGoogleFree = (
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,

View File

@@ -29,7 +29,7 @@ This function aims to find an *intelligent* or "safe" index within the provided
* **Single Line Breaks:** If no double newline is found in a suitable range, it will look for a single newline (`\n`).
* Any newline chosen as a split point must also not be inside a code block.
4. **Fallback to `idealMaxLength`:**
4. **Fall back to `idealMaxLength`:**
* If no "safer" split point (respecting code blocks or finding suitable newlines) is identified before or at `idealMaxLength`, and `idealMaxLength` itself is not determined to be an unsafe split point (e.g., inside a code block), the function may return a length larger than `idealMaxLength`, again it CANNOT break markdown formatting. This could happen with very long lines of text without Markdown block structures or newlines.
**In essence, `findSafeSplitPoint` tries to be a good Markdown citizen when forced to divide content, preferring structural boundaries over arbitrary character limits, with a strong emphasis on not corrupting code blocks.**

View File

@@ -1,41 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { isBinary } from './textUtils';
describe('textUtils', () => {
describe('isBinary', () => {
it('should return true for a buffer containing a null byte', () => {
const buffer = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x1a, 0x0a, 0x00,
]);
expect(isBinary(buffer)).toBe(true);
});
it('should return false for a buffer containing only text', () => {
const buffer = Buffer.from('This is a test string.');
expect(isBinary(buffer)).toBe(false);
});
it('should return false for an empty buffer', () => {
const buffer = Buffer.from([]);
expect(isBinary(buffer)).toBe(false);
});
it('should return false for a null or undefined buffer', () => {
expect(isBinary(null)).toBe(false);
expect(isBinary(undefined)).toBe(false);
});
it('should only check the sample size', () => {
const longBufferWithNullByteAtEnd = Buffer.concat([
Buffer.from('a'.repeat(1024)),
Buffer.from([0x00]),
]);
expect(isBinary(longBufferWithNullByteAtEnd, 512)).toBe(false);
});
});
});

View File

@@ -17,35 +17,6 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
return Math.max(...lines.map((line) => line.length));
};
/**
* Checks if a Buffer is likely binary by testing for the presence of a NULL byte.
* The presence of a NULL byte is a strong indicator that the data is not plain text.
* @param data The Buffer to check.
* @param sampleSize The number of bytes from the start of the buffer to test.
* @returns True if a NULL byte is found, false otherwise.
*/
export function isBinary(
data: Buffer | null | undefined,
sampleSize = 512,
): boolean {
if (!data) {
return false;
}
const sample = data.length > sampleSize ? data.subarray(0, sampleSize) : data;
for (const byte of sample) {
// The presence of a NULL byte (0x00) is one of the most reliable
// indicators of a binary file. Text files should not contain them.
if (byte === 0) {
return true;
}
}
// If no NULL bytes were found in the sample, we assume it's text.
return false;
}
/*
* -------------------------------------------------------------------------
* Unicodeaware helpers (work at the codepoint level rather than UTF16

View File

@@ -20,6 +20,23 @@ vi.mock('update-notifier', () => ({
describe('checkForUpdates', () => {
beforeEach(() => {
vi.resetAllMocks();
// Clear DEV environment variable before each test
delete process.env.DEV;
});
it('should return null when running from source (DEV=true)', async () => {
process.env.DEV = 'true';
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
updateNotifier.mockReturnValue({
update: { current: '1.0.0', latest: '1.1.0' },
});
const result = await checkForUpdates();
expect(result).toBeNull();
expect(getPackageJson).not.toHaveBeenCalled();
expect(updateNotifier).not.toHaveBeenCalled();
});
it('should return null if package.json is missing', async () => {

View File

@@ -10,6 +10,11 @@ import { getPackageJson } from '../../utils/package.js';
export async function checkForUpdates(): Promise<string | null> {
try {
// Skip update check when running from source (development mode)
if (process.env.DEV === 'true') {
return null;
}
const packageJson = await getPackageJson();
if (!packageJson || !packageJson.name || !packageJson.version) {
return null;