Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0

This commit is contained in:
mingholy.lmh
2025-09-10 21:01:40 +08:00
583 changed files with 30160 additions and 10770 deletions

View File

@@ -15,12 +15,12 @@ import type {
RootContent,
} from 'hast';
import { themeManager } from '../themes/theme-manager.js';
import { Theme } from '../themes/theme.js';
import type { Theme } from '../themes/theme.js';
import {
MaxSizedBox,
MINIMUM_MAX_HEIGHT,
} from '../components/shared/MaxSizedBox.js';
import { LoadedSettings } from '../../config/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
// Configure theming and parsing utilities.
const lowlight = createLowlight(common);
@@ -134,7 +134,7 @@ export function colorizeCode(
): React.ReactNode {
const codeToHighlight = code.replace(/\n$/, '');
const activeTheme = theme || themeManager.getActiveTheme();
const showLineNumbers = settings?.merged.showLineNumbers ?? true;
const showLineNumbers = settings?.merged.ui?.showLineNumbers ?? true;
try {
// Render the HAST tree using the adapted theme

View File

@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import util from 'util';
import { ConsoleMessageItem } from '../types.js';
import util from 'node:util';
import type { ConsoleMessageItem } from '../types.js';
interface ConsolePatcherParams {
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;

View File

@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MarkdownDisplay } from './MarkdownDisplay.js';
import { LoadedSettings } from '../../config/settings.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { EOL } from 'node:os';
describe('<MarkdownDisplay />', () => {
const baseProps = {
@@ -21,7 +22,10 @@ describe('<MarkdownDisplay />', () => {
{ path: '', settings: {} },
{ path: '', settings: {} },
{ path: '', settings: {} },
{ path: '', settings: {} },
[],
true,
new Set(),
);
beforeEach(() => {
@@ -53,7 +57,7 @@ describe('<MarkdownDisplay />', () => {
## Header 2
### Header 3
#### Header 4
`;
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -63,7 +67,10 @@ describe('<MarkdownDisplay />', () => {
});
it('renders a fenced code block with a language', () => {
const text = '```javascript\nconst x = 1;\nconsole.log(x);\n```';
const text = '```javascript\nconst x = 1;\nconsole.log(x);\n```'.replace(
/\n/g,
EOL,
);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -73,7 +80,7 @@ describe('<MarkdownDisplay />', () => {
});
it('renders a fenced code block without a language', () => {
const text = '```\nplain text\n```';
const text = '```\nplain text\n```'.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -83,7 +90,7 @@ describe('<MarkdownDisplay />', () => {
});
it('handles unclosed (pending) code blocks', () => {
const text = '```typescript\nlet y = 2;';
const text = '```typescript\nlet y = 2;'.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} isPending={true} />
@@ -97,7 +104,7 @@ describe('<MarkdownDisplay />', () => {
- item A
* item B
+ item C
`;
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -111,7 +118,7 @@ describe('<MarkdownDisplay />', () => {
* Level 1
* Level 2
* Level 3
`;
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -124,7 +131,7 @@ describe('<MarkdownDisplay />', () => {
const text = `
1. First item
2. Second item
`;
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -140,7 +147,7 @@ Hello
World
***
Test
`;
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -155,7 +162,7 @@ Test
|----------|:--------:|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |
`;
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -169,7 +176,7 @@ Test
Some text before.
| A | B |
|---|
| 1 | 2 |`;
| 1 | 2 |`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -181,7 +188,7 @@ Some text before.
it('inserts a single space between paragraphs', () => {
const text = `Paragraph 1.
Paragraph 2.`;
Paragraph 2.`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -204,7 +211,7 @@ some code
\`\`\`
Another paragraph.
`;
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
@@ -214,12 +221,15 @@ Another paragraph.
});
it('hides line numbers in code blocks when showLineNumbers is false', () => {
const text = '```javascript\nconst x = 1;\n```';
const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, EOL);
const settings = new LoadedSettings(
{ path: '', settings: {} },
{ path: '', settings: { showLineNumbers: false } },
{ path: '', settings: {} },
{ path: '', settings: { ui: { showLineNumbers: false } } },
{ path: '', settings: {} },
[],
true,
new Set(),
);
const { lastFrame } = render(
@@ -232,7 +242,7 @@ Another paragraph.
});
it('shows line numbers in code blocks by default', () => {
const text = '```javascript\nconst x = 1;\n```';
const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />

View File

@@ -6,6 +6,7 @@
import React from 'react';
import { Text, Box } from 'ink';
import { EOL } from 'node:os';
import { Colors } from '../colors.js';
import { colorizeCode } from './CodeColorizer.js';
import { TableRenderer } from './TableRenderer.js';
@@ -34,7 +35,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
}) => {
if (!text) return <></>;
const lines = text.split('\n');
const lines = text.split(EOL);
const headerRegex = /^ *(#{1,4}) +(.*)/;
const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/;
const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/;

View File

@@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
const execAsync = promisify(exec);

View File

@@ -4,9 +4,10 @@
* 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 type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { spawn, SpawnOptions } from 'node:child_process';
import { EventEmitter } from 'node:events';
import {
isAtCommand,
isSlashCommand,
@@ -44,7 +45,7 @@ describe('commandUtils', () => {
beforeEach(async () => {
vi.clearAllMocks();
// Dynamically import and set up spawn mock
const { spawn } = await import('child_process');
const { spawn } = await import('node:child_process');
mockSpawn = spawn as Mock;
// Create mock child process with stdout/stderr emitters
@@ -101,6 +102,20 @@ describe('commandUtils', () => {
expect(isSlashCommand('path/to/file')).toBe(false);
expect(isSlashCommand(' /help')).toBe(false);
});
it('should return false for line comments starting with //', () => {
expect(isSlashCommand('// This is a comment')).toBe(false);
expect(isSlashCommand('// check if variants base info all filled.')).toBe(
false,
);
expect(isSlashCommand('//comment without space')).toBe(false);
});
it('should return false for block comments starting with /*', () => {
expect(isSlashCommand('/* This is a block comment */')).toBe(false);
expect(isSlashCommand('/*\n * Multi-line comment\n */')).toBe(false);
expect(isSlashCommand('/*comment without space*/')).toBe(false);
});
});
describe('copyToClipboard', () => {
@@ -186,6 +201,9 @@ describe('commandUtils', () => {
it('should successfully copy text to clipboard using xclip', async () => {
const testText = 'Hello, world!';
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
setTimeout(() => {
mockChild.emit('close', 0);
@@ -193,10 +211,11 @@ describe('commandUtils', () => {
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith('xclip', [
'-selection',
'clipboard',
]);
expect(mockSpawn).toHaveBeenCalledWith(
'xclip',
['-selection', 'clipboard'],
linuxOptions,
);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled();
});
@@ -204,6 +223,9 @@ describe('commandUtils', () => {
it('should fall back to xsel when xclip fails', async () => {
const testText = 'Hello, world!';
let callCount = 0;
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
mockSpawn.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
@@ -217,7 +239,9 @@ describe('commandUtils', () => {
setTimeout(() => {
if (callCount === 0) {
// First call (xclip) fails
child.stderr.emit('data', 'xclip not found');
const error = new Error('spawn xclip ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
child.emit('error', error);
child.emit('close', 1);
callCount++;
} else {
@@ -232,19 +256,26 @@ describe('commandUtils', () => {
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(mockSpawn).toHaveBeenNthCalledWith(1, 'xclip', [
'-selection',
'clipboard',
]);
expect(mockSpawn).toHaveBeenNthCalledWith(2, 'xsel', [
'--clipboard',
'--input',
]);
expect(mockSpawn).toHaveBeenNthCalledWith(
1,
'xclip',
['-selection', 'clipboard'],
linuxOptions,
);
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
'xsel',
['--clipboard', '--input'],
linuxOptions,
);
});
it('should throw error when both xclip and xsel fail', async () => {
it('should throw error when both xclip and xsel are not found', async () => {
const testText = 'Hello, world!';
let callCount = 0;
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
mockSpawn.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
@@ -253,29 +284,99 @@ describe('commandUtils', () => {
end: vi.fn(),
}),
stderr: new EventEmitter(),
});
}) as MockChildProcess;
setTimeout(() => {
if (callCount === 0) {
// First call (xclip) fails
child.stderr.emit('data', 'xclip command not found');
// First call (xclip) fails with ENOENT
const error = new Error('spawn xclip ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
child.emit('error', error);
child.emit('close', 1);
callCount++;
} else {
// Second call (xsel) fails
child.stderr.emit('data', 'xsel command not found');
// Second call (xsel) fails with ENOENT
const error = new Error('spawn xsel ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
child.emit('error', error);
child.emit('close', 1);
}
}, 0);
return child as unknown as ReturnType<typeof spawn>;
});
await expect(copyToClipboard(testText)).rejects.toThrow(
/All copy commands failed/,
'Please ensure xclip or xsel is installed and configured.',
);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(mockSpawn).toHaveBeenNthCalledWith(
1,
'xclip',
['-selection', 'clipboard'],
linuxOptions,
);
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
'xsel',
['--clipboard', '--input'],
linuxOptions,
);
});
it('should emit error when xclip or xsel fail with stderr output (command installed)', async () => {
const testText = 'Hello, world!';
let callCount = 0;
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
const errorMsg = "Error: Can't open display:";
const exitCode = 1;
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(() => {
// e.g., cannot connect to X server
if (callCount === 0) {
child.stderr.emit('data', errorMsg);
child.emit('close', exitCode);
callCount++;
} else {
child.stderr.emit('data', errorMsg);
child.emit('close', exitCode);
}
}, 0);
return child as unknown as ReturnType<typeof spawn>;
});
const xclipErrorMsg = `'xclip' exited with code ${exitCode}${errorMsg ? `: ${errorMsg}` : ''}`;
const xselErrorMsg = `'xsel' exited with code ${exitCode}${errorMsg ? `: ${errorMsg}` : ''}`;
await expect(copyToClipboard(testText)).rejects.toThrow(
`All copy commands failed. "${xclipErrorMsg}", "${xselErrorMsg}". `,
);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(mockSpawn).toHaveBeenNthCalledWith(
1,
'xclip',
['-selection', 'clipboard'],
linuxOptions,
);
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
'xsel',
['--clipboard', '--input'],
linuxOptions,
);
});
});

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'child_process';
import type { SpawnOptions } from 'node:child_process';
import { spawn } from 'node:child_process';
/**
* Checks if a query string potentially represents an '@' command.
@@ -20,20 +21,38 @@ export const isAtCommand = (query: string): boolean =>
/**
* Checks if a query string potentially represents an '/' command.
* It triggers if the query starts with '/'
* It triggers if the query starts with '/' but excludes code comments like '//' and '/*'.
*
* @param query The input query string.
* @returns True if the query looks like an '/' command, false otherwise.
*/
export const isSlashCommand = (query: string): boolean => query.startsWith('/');
export const isSlashCommand = (query: string): boolean => {
if (!query.startsWith('/')) {
return false;
}
// Exclude line comments that start with '//'
if (query.startsWith('//')) {
return false;
}
// Exclude block comments that start with '/*'
if (query.startsWith('/*')) {
return false;
}
return true;
};
// Copies a string snippet to the clipboard for different platforms
export const copyToClipboard = async (text: string): Promise<void> => {
const run = (cmd: string, args: string[]) =>
const run = (cmd: string, args: string[], options?: SpawnOptions) =>
new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args);
const child = options ? spawn(cmd, args, options) : spawn(cmd, args);
let stderr = '';
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
if (child.stderr) {
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
}
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) return resolve();
@@ -44,11 +63,21 @@ export const copyToClipboard = async (text: string): Promise<void> => {
),
);
});
child.stdin.on('error', reject);
child.stdin.write(text);
child.stdin.end();
if (child.stdin) {
child.stdin.on('error', reject);
child.stdin.write(text);
child.stdin.end();
} else {
reject(new Error('Child process has no stdin stream to write to.'));
}
});
// Configure stdio for Linux clipboard commands.
// - stdin: 'pipe' to write the text that needs to be copied.
// - stdout: 'inherit' since we don't need to capture the command's output on success.
// - stderr: 'pipe' to capture error messages (e.g., "command not found") for better error handling.
const linuxOptions: SpawnOptions = { stdio: ['pipe', 'inherit', 'pipe'] };
switch (process.platform) {
case 'win32':
return run('clip', []);
@@ -56,22 +85,41 @@ export const copyToClipboard = async (text: string): Promise<void> => {
return run('pbcopy', []);
case 'linux':
try {
await run('xclip', ['-selection', 'clipboard']);
await run('xclip', ['-selection', 'clipboard'], linuxOptions);
} catch (primaryError) {
try {
// If xclip fails for any reason, try xsel as a fallback.
await run('xsel', ['--clipboard', '--input']);
await run('xsel', ['--clipboard', '--input'], linuxOptions);
} catch (fallbackError) {
const primaryMsg =
const xclipNotFound =
primaryError instanceof Error &&
(primaryError as NodeJS.ErrnoException).code === 'ENOENT';
const xselNotFound =
fallbackError instanceof Error &&
(fallbackError as NodeJS.ErrnoException).code === 'ENOENT';
if (xclipNotFound && xselNotFound) {
throw new Error(
'Please ensure xclip or xsel is installed and configured.',
);
}
let primaryMsg =
primaryError instanceof Error
? primaryError.message
: String(primaryError);
const fallbackMsg =
if (xclipNotFound) {
primaryMsg = `xclip not found`;
}
let fallbackMsg =
fallbackError instanceof Error
? fallbackError.message
: String(fallbackError);
if (xselNotFound) {
fallbackMsg = `xsel not found`;
}
throw new Error(
`All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`,
`All copy commands failed. "${primaryMsg}", "${fallbackMsg}". `,
);
}
}

View File

@@ -11,7 +11,10 @@ import {
calculateErrorRate,
computeSessionStats,
} from './computeStats.js';
import { ModelMetrics, SessionMetrics } from '../contexts/SessionContext.js';
import type {
ModelMetrics,
SessionMetrics,
} from '../contexts/SessionContext.js';
describe('calculateErrorRate', () => {
it('should return 0 if totalRequests is 0', () => {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
import type {
SessionMetrics,
ComputedSessionStats,
ModelMetrics,

View File

@@ -50,3 +50,11 @@ export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5;
* We use 12 to provide a small buffer.
*/
export const MAX_KITTY_SEQUENCE_LENGTH = 12;
/**
* Character codes for common escape sequences
*/
export const CHAR_CODE_ESC = 27;
export const CHAR_CODE_LEFT_BRACKET = 91;
export const CHAR_CODE_1 = 49;
export const CHAR_CODE_2 = 50;

View File

@@ -23,11 +23,11 @@
* to avoid conflicts with user customizations.
*/
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { isKittyProtocolEnabled } from './kittyProtocolDetector.js';
import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js';

View File

@@ -4,6 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'node:util';
/**
* Calculates the maximum width of a multi-line ASCII art string.
* @param asciiArt The ASCII art string.
@@ -38,3 +41,48 @@ export function cpSlice(str: string, start: number, end?: number): string {
const arr = toCodePoints(str).slice(start, end);
return arr.join('');
}
/**
* Strip characters that can break terminal rendering.
*
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
* - VT control sequences (via Node.js util.stripVTControlCharacters)
* - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
* - C1 control chars (0x80-0x9F) that can cause display issues
*
* Characters preserved:
* - All printable Unicode including emojis
* - DEL (0x7F) - handled functionally by applyOperations, not a display issue
* - CR/LF (0x0D/0x0A) - needed for line breaks
*/
export function stripUnsafeCharacters(str: string): string {
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
return toCodePoints(strippedVT)
.filter((char) => {
const code = char.codePointAt(0);
if (code === undefined) return false;
// Preserve CR/LF for line handling
if (code === 0x0a || code === 0x0d) return true;
// Remove C0 control chars (except CR/LF) that can break display
// Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
if (code >= 0x00 && code <= 0x1f) return false;
// Remove C1 control chars (0x80-0x9f) - legacy 8-bit control codes
if (code >= 0x80 && code <= 0x9f) return false;
// Preserve DEL (0x7f) - it's handled functionally by applyOperations as backspace
// and doesn't cause rendering issues when displayed
// Preserve all other characters including Unicode/emojis
return true;
})
.join('');
}

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import updateNotifier, { UpdateInfo } from 'update-notifier';
import type { UpdateInfo } from 'update-notifier';
import updateNotifier from 'update-notifier';
import semver from 'semver';
import { getPackageJson } from '../../utils/package.js';