Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -31,8 +31,9 @@ function renderHastNode(
inheritedColor: string | undefined,
): React.ReactNode {
if (node.type === 'text') {
// Use the color passed down from parent element, if any
return <Text color={inheritedColor}>{node.value}</Text>;
// Use the color passed down from parent element, or the theme's default.
const color = inheritedColor || theme.defaultColor;
return <Text color={color}>{node.value}</Text>;
}
// Handle Element Nodes: Determine color and pass it down, don't wrap

View File

@@ -6,13 +6,13 @@
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import stringWidth from 'string-width';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~"
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
@@ -24,7 +24,7 @@ interface RenderInlineProps {
const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
// Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) {
return <Text>{text}</Text>;
return <Text color={theme.text.primary}>{text}</Text>;
}
const nodes: React.ReactNode[] = [];
@@ -96,7 +96,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
renderedNode = (
<Text key={key} color={Colors.AccentPurple}>
<Text key={key} color={theme.text.accent}>
{codeMatch[2]}
</Text>
);
@@ -113,7 +113,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
renderedNode = (
<Text key={key}>
{linkText}
<Text color={Colors.AccentBlue}> ({url})</Text>
<Text color={theme.text.link}> ({url})</Text>
</Text>
);
}
@@ -133,7 +133,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
);
} else if (fullMatch.match(/^https?:\/\//)) {
renderedNode = (
<Text key={key} color={Colors.AccentBlue}>
<Text key={key} color={theme.text.link}>
{fullMatch}
</Text>
);
@@ -168,6 +168,6 @@ export const getPlainTextLength = (text: string): number => {
.replace(/~~(.*?)~~/g, '$1')
.replace(/`(.*?)`/g, '$1')
.replace(/<u>(.*?)<\/u>/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1');
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
return stringWidth(cleanText);
};

View File

@@ -4,11 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
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 { renderWithProviders } from '../../test-utils/render.js';
describe('<MarkdownDisplay />', () => {
const baseProps = {
@@ -17,184 +16,157 @@ describe('<MarkdownDisplay />', () => {
availableTerminalHeight: 40,
};
const mockSettings = new LoadedSettings(
{ path: '', settings: {} },
{ path: '', settings: {} },
{ path: '', settings: {} },
{ path: '', settings: {} },
[],
true,
new Set(),
);
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing for empty text', () => {
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text="" />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text="" />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a simple paragraph', () => {
const text = 'Hello, world.';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders headers with correct levels', () => {
const text = `
const lineEndings = [
{ name: 'Windows', eol: '\r\n' },
{ name: 'Unix', eol: '\n' },
];
describe.each(lineEndings)('with $name line endings', ({ eol }) => {
it('renders headers with correct levels', () => {
const text = `
# Header 1
## Header 2
### Header 3
#### Header 4
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a fenced code block with a language', () => {
const text = '```javascript\nconst x = 1;\nconsole.log(x);\n```';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a fenced code block with a language', () => {
const text = '```javascript\nconst x = 1;\nconsole.log(x);\n```'.replace(
/\n/g,
eol,
);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a fenced code block without a language', () => {
const text = '```\nplain text\n```';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a fenced code block without a language', () => {
const text = '```\nplain text\n```'.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('handles unclosed (pending) code blocks', () => {
const text = '```typescript\nlet y = 2;';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} isPending={true} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('handles unclosed (pending) code blocks', () => {
const text = '```typescript\nlet y = 2;'.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} isPending={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders unordered lists with different markers', () => {
const text = `
it('renders unordered lists with different markers', () => {
const text = `
- item A
* item B
+ item C
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders nested unordered lists', () => {
const text = `
it('renders nested unordered lists', () => {
const text = `
* Level 1
* Level 2
* Level 3
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders ordered lists', () => {
const text = `
it('renders ordered lists', () => {
const text = `
1. First item
2. Second item
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders horizontal rules', () => {
const text = `
it('renders horizontal rules', () => {
const text = `
Hello
---
World
***
Test
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders tables correctly', () => {
const text = `
it('renders tables correctly', () => {
const text = `
| Header 1 | Header 2 |
|----------|:--------:|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('handles a table at the end of the input', () => {
const text = `
it('handles a table at the end of the input', () => {
const text = `
Some text before.
| A | B |
|---|
| 1 | 2 |`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
| 1 | 2 |`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('inserts a single space between paragraphs', () => {
const text = `Paragraph 1.
it('inserts a single space between paragraphs', () => {
const text = `Paragraph 1.
Paragraph 2.`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
Paragraph 2.`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('correctly parses a mix of markdown elements', () => {
const text = `
it('correctly parses a mix of markdown elements', () => {
const text = `
# Main Title
Here is a paragraph.
@@ -207,55 +179,52 @@ some code
\`\`\`
Another paragraph.
`;
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('hides line numbers in code blocks when showLineNumbers is false', () => {
const text = '```javascript\nconst x = 1;\n```';
const settings = new LoadedSettings(
{ path: '', settings: {} },
{ path: '', settings: {} },
{ path: '', settings: { ui: { showLineNumbers: false } } },
{ path: '', settings: {} },
[],
true,
new Set(),
);
it('hides line numbers in code blocks when showLineNumbers is false', () => {
const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, eol);
const settings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{
path: '',
settings: { ui: { showLineNumbers: false } },
originalSettings: { ui: { showLineNumbers: false } },
},
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
);
const { lastFrame } = render(
<SettingsContext.Provider value={settings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain(' 1 ');
});
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
{ settings },
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain(' 1 ');
});
it('shows line numbers in code blocks by default', () => {
const text = '```javascript\nconst x = 1;\n```';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain(' 1 ');
it('shows line numbers in code blocks by default', () => {
const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain(' 1 ');
});
});
it('correctly splits lines using \\n regardless of platform EOL', () => {
// Test that the component uses \n for splitting, not EOL
const textWithUnixLineEndings = 'Line 1\nLine 2\nLine 3';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={textWithUnixLineEndings} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={textWithUnixLineEndings} />,
);
const output = lastFrame();

View File

@@ -6,7 +6,7 @@
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { colorizeCode } from './CodeColorizer.js';
import { TableRenderer } from './TableRenderer.js';
import { RenderInline } from './InlineMarkdownRenderer.js';
@@ -34,7 +34,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
}) => {
if (!text) return <></>;
const lines = text.split(`\n`);
const lines = text.split(/\r?\n/);
const headerRegex = /^ *(#{1,4}) +(.*)/;
const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/;
const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/;
@@ -173,35 +173,35 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
switch (level) {
case 1:
headerNode = (
<Text bold color={Colors.AccentCyan}>
<Text bold color={theme.text.link}>
<RenderInline text={headerText} />
</Text>
);
break;
case 2:
headerNode = (
<Text bold color={Colors.AccentBlue}>
<Text bold color={theme.text.link}>
<RenderInline text={headerText} />
</Text>
);
break;
case 3:
headerNode = (
<Text bold>
<Text bold color={theme.text.primary}>
<RenderInline text={headerText} />
</Text>
);
break;
case 4:
headerNode = (
<Text italic color={Colors.Gray}>
<Text italic color={theme.text.secondary}>
<RenderInline text={headerText} />
</Text>
);
break;
default:
headerNode = (
<Text>
<Text color={theme.text.primary}>
<RenderInline text={headerText} />
</Text>
);
@@ -245,7 +245,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
} else {
addContentBlock(
<Box key={key}>
<Text wrap="wrap">
<Text wrap="wrap" color={theme.text.primary}>
<RenderInline text={line} />
</Text>
</Box>,
@@ -314,7 +314,9 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
// Not enough space to even show the message meaningfully
return (
<Box paddingLeft={CODE_BLOCK_PREFIX_PADDING}>
<Text color={Colors.Gray}>... code is being written ...</Text>
<Text color={theme.text.secondary}>
... code is being written ...
</Text>
</Box>
);
}
@@ -330,7 +332,7 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
return (
<Box paddingLeft={CODE_BLOCK_PREFIX_PADDING} flexDirection="column">
{colorizedTruncatedCode}
<Text color={Colors.Gray}>... generating more ...</Text>
<Text color={theme.text.secondary}>... generating more ...</Text>
</Box>
);
}
@@ -383,10 +385,10 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
flexDirection="row"
>
<Box width={prefixWidth}>
<Text>{prefix}</Text>
<Text color={theme.text.primary}>{prefix}</Text>
</Box>
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
<Text wrap="wrap">
<Text wrap="wrap" color={theme.text.primary}>
<RenderInline text={itemText} />
</Text>
</Box>

View File

@@ -6,7 +6,7 @@
import React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js';
interface TableRendererProps {
@@ -89,7 +89,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
return (
<Text>
{isHeader ? (
<Text bold color={Colors.AccentCyan}>
<Text bold color={theme.text.link}>
<RenderInline text={cellContent} />
</Text>
) : (
@@ -112,7 +112,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
const borderParts = adjustedWidths.map((w) => char.horizontal.repeat(w));
const border = char.left + borderParts.join(char.middle) + char.right;
return <Text>{border}</Text>;
return <Text color={theme.border.default}>{border}</Text>;
};
// Helper function to render a table row
@@ -123,7 +123,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
});
return (
<Text>
<Text color={theme.text.primary}>
{' '}
{renderedCells.map((cell, index) => (
<React.Fragment key={index}>

View File

@@ -1,6 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<MarkdownDisplay /> > correctly parses a mix of markdown elements 1`] = `
exports[`<MarkdownDisplay /> > correctly splits lines using \\n regardless of platform EOL 1`] = `
"Line 1
Line 2
Line 3"
`;
exports[`<MarkdownDisplay /> > renders a simple paragraph 1`] = `"Hello, world."`;
exports[`<MarkdownDisplay /> > renders nothing for empty text 1`] = `""`;
exports[`<MarkdownDisplay /> > with 'Unix' line endings > correctly parses a mix of markdown elements 1`] = `
"Main Title
Here is a paragraph.
@@ -14,39 +24,31 @@ Another paragraph.
"
`;
exports[`<MarkdownDisplay /> > correctly splits lines using \\n regardless of platform EOL 1`] = `
"Line 1
Line 2
Line 3"
`;
exports[`<MarkdownDisplay /> > handles a table at the end of the input 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > handles a table at the end of the input 1`] = `
"Some text before.
| A | B |
|---|
| 1 | 2 |"
`;
exports[`<MarkdownDisplay /> > handles unclosed (pending) code blocks 1`] = `" 1 let y = 2;"`;
exports[`<MarkdownDisplay /> > with 'Unix' line endings > handles unclosed (pending) code blocks 1`] = `" 1 let y = 2;"`;
exports[`<MarkdownDisplay /> > hides line numbers in code blocks when showLineNumbers is false 1`] = `" const x = 1;"`;
exports[`<MarkdownDisplay /> > with 'Unix' line endings > hides line numbers in code blocks when showLineNumbers is false 1`] = `" const x = 1;"`;
exports[`<MarkdownDisplay /> > inserts a single space between paragraphs 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > inserts a single space between paragraphs 1`] = `
"Paragraph 1.
Paragraph 2."
`;
exports[`<MarkdownDisplay /> > renders a fenced code block with a language 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders a fenced code block with a language 1`] = `
" 1 const x = 1;
2 console.log(x);"
`;
exports[`<MarkdownDisplay /> > renders a fenced code block without a language 1`] = `" 1 plain text"`;
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders a fenced code block without a language 1`] = `" 1 plain text"`;
exports[`<MarkdownDisplay /> > renders a simple paragraph 1`] = `"Hello, world."`;
exports[`<MarkdownDisplay /> > renders headers with correct levels 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders headers with correct levels 1`] = `
"Header 1
Header 2
Header 3
@@ -54,7 +56,7 @@ Header 4
"
`;
exports[`<MarkdownDisplay /> > renders horizontal rules 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders horizontal rules 1`] = `
"Hello
---
World
@@ -63,22 +65,20 @@ Test
"
`;
exports[`<MarkdownDisplay /> > renders nested unordered lists 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders nested unordered lists 1`] = `
" * Level 1
* Level 2
* Level 3
"
`;
exports[`<MarkdownDisplay /> > renders nothing for empty text 1`] = `""`;
exports[`<MarkdownDisplay /> > renders ordered lists 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders ordered lists 1`] = `
" 1. First item
2. Second item
"
`;
exports[`<MarkdownDisplay /> > renders tables correctly 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders tables correctly 1`] = `
"
┌──────────┬──────────┐
│ Header 1 │ Header 2 │
@@ -89,11 +89,99 @@ exports[`<MarkdownDisplay /> > renders tables correctly 1`] = `
"
`;
exports[`<MarkdownDisplay /> > renders unordered lists with different markers 1`] = `
exports[`<MarkdownDisplay /> > with 'Unix' line endings > renders unordered lists with different markers 1`] = `
" - item A
* item B
+ item C
"
`;
exports[`<MarkdownDisplay /> > shows line numbers in code blocks by default 1`] = `" 1 const x = 1;"`;
exports[`<MarkdownDisplay /> > with 'Unix' line endings > shows line numbers in code blocks by default 1`] = `" 1 const x = 1;"`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > correctly parses a mix of markdown elements 1`] = `
"Main Title
Here is a paragraph.
- List item 1
- List item 2
1 some code
Another paragraph.
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > handles a table at the end of the input 1`] = `
"Some text before.
| A | B |
|---|
| 1 | 2 |"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > handles unclosed (pending) code blocks 1`] = `" 1 let y = 2;"`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > hides line numbers in code blocks when showLineNumbers is false 1`] = `" const x = 1;"`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > inserts a single space between paragraphs 1`] = `
"Paragraph 1.
Paragraph 2."
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders a fenced code block with a language 1`] = `
" 1 const x = 1;
2 console.log(x);"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders a fenced code block without a language 1`] = `" 1 plain text"`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders headers with correct levels 1`] = `
"Header 1
Header 2
Header 3
Header 4
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders horizontal rules 1`] = `
"Hello
---
World
---
Test
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders nested unordered lists 1`] = `
" * Level 1
* Level 2
* Level 3
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders ordered lists 1`] = `
" 1. First item
2. Second item
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders tables correctly 1`] = `
"
┌──────────┬──────────┐
│ Header 1 │ Header 2 │
├──────────┼──────────┤
│ Cell 1 │ Cell 2 │
│ Cell 3 │ Cell 4 │
└──────────┴──────────┘
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > renders unordered lists with different markers 1`] = `
" - item A
* item B
+ item C
"
`;
exports[`<MarkdownDisplay /> > with 'Windows' line endings > shows line numbers in code blocks by default 1`] = `" 1 const x = 1;"`;

View File

@@ -4,12 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
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);
import { spawnAsync } from '@qwen-code/qwen-code-core';
/**
* Checks if the system clipboard contains an image (macOS only for now)
@@ -22,11 +19,10 @@ export async function clipboardHasImage(): Promise<boolean> {
try {
// Use osascript to check clipboard type
const { stdout } = await execAsync(
`osascript -e 'clipboard info' 2>/dev/null | grep -qE "«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»" && echo "true" || echo "false"`,
{ shell: '/bin/bash' },
);
return stdout.trim() === 'true';
const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']);
const imageRegex =
/«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/;
return imageRegex.test(stdout);
} catch {
return false;
}
@@ -84,7 +80,7 @@ export async function saveClipboardImage(
end try
`;
const { stdout } = await execAsync(`osascript -e '${script}'`);
const { stdout } = await spawnAsync('osascript', ['-e', script]);
if (stdout.trim() === 'success') {
// Verify the file was created and has content

View File

@@ -14,34 +14,59 @@ import {
CACHE_EFFICIENCY_HIGH,
CACHE_EFFICIENCY_MEDIUM,
} from './displayUtils.js';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
describe('displayUtils', () => {
describe('getStatusColor', () => {
const thresholds = {
green: 80,
yellow: 50,
};
describe('with red threshold', () => {
const thresholds = {
green: 80,
yellow: 50,
red: 20,
};
it('should return green for values >= green threshold', () => {
expect(getStatusColor(90, thresholds)).toBe(Colors.AccentGreen);
expect(getStatusColor(80, thresholds)).toBe(Colors.AccentGreen);
it('should return green for values >= green threshold', () => {
expect(getStatusColor(90, thresholds)).toBe(theme.status.success);
expect(getStatusColor(80, thresholds)).toBe(theme.status.success);
});
it('should return yellow for values < green and >= yellow threshold', () => {
expect(getStatusColor(79, thresholds)).toBe(theme.status.warning);
expect(getStatusColor(50, thresholds)).toBe(theme.status.warning);
});
it('should return red for values < yellow and >= red threshold', () => {
expect(getStatusColor(49, thresholds)).toBe(theme.status.error);
expect(getStatusColor(20, thresholds)).toBe(theme.status.error);
});
it('should return error for values < red threshold', () => {
expect(getStatusColor(19, thresholds)).toBe(theme.status.error);
expect(getStatusColor(0, thresholds)).toBe(theme.status.error);
});
it('should return defaultColor for values < red threshold when provided', () => {
expect(
getStatusColor(19, thresholds, { defaultColor: theme.text.primary }),
).toBe(theme.text.primary);
});
});
it('should return yellow for values < green and >= yellow threshold', () => {
expect(getStatusColor(79, thresholds)).toBe(Colors.AccentYellow);
expect(getStatusColor(50, thresholds)).toBe(Colors.AccentYellow);
});
describe('when red threshold is not provided', () => {
const thresholds = {
green: 80,
yellow: 50,
};
it('should return red for values < yellow threshold', () => {
expect(getStatusColor(49, thresholds)).toBe(Colors.AccentRed);
expect(getStatusColor(0, thresholds)).toBe(Colors.AccentRed);
});
it('should return error color for values < yellow threshold', () => {
expect(getStatusColor(49, thresholds)).toBe(theme.status.error);
});
it('should return defaultColor for values < yellow threshold when provided', () => {
expect(
getStatusColor(49, thresholds, { defaultColor: Colors.Foreground }),
).toBe(Colors.Foreground);
it('should return defaultColor for values < yellow threshold when provided', () => {
expect(
getStatusColor(49, thresholds, { defaultColor: theme.text.primary }),
).toBe(theme.text.primary);
});
});
});

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
// --- Thresholds ---
export const TOOL_SUCCESS_RATE_HIGH = 95;
@@ -19,14 +19,17 @@ export const CACHE_EFFICIENCY_MEDIUM = 15;
// --- Color Logic ---
export const getStatusColor = (
value: number,
thresholds: { green: number; yellow: number },
thresholds: { green: number; yellow: number; red?: number },
options: { defaultColor?: string } = {},
) => {
if (value >= thresholds.green) {
return Colors.AccentGreen;
return theme.status.success;
}
if (value >= thresholds.yellow) {
return Colors.AccentYellow;
return theme.status.warning;
}
return options.defaultColor || Colors.AccentRed;
if (thresholds.red != null && value >= thresholds.red) {
return theme.status.error;
}
return options.defaultColor ?? theme.status.error;
};

View File

@@ -0,0 +1,136 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { parseInputForHighlighting } from './highlight.js';
describe('parseInputForHighlighting', () => {
it('should handle an empty string', () => {
expect(parseInputForHighlighting('', 0)).toEqual([
{ text: '', type: 'default' },
]);
});
it('should handle text with no commands or files', () => {
const text = 'this is a normal sentence';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text, type: 'default' },
]);
});
it('should highlight a single command at the beginning when index is 0', () => {
const text = '/help me';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: '/help', type: 'command' },
{ text: ' me', type: 'default' },
]);
});
it('should NOT highlight a command at the beginning when index is not 0', () => {
const text = '/help me';
expect(parseInputForHighlighting(text, 1)).toEqual([
{ text: '/help', type: 'default' },
{ text: ' me', type: 'default' },
]);
});
it('should highlight a single file path at the beginning', () => {
const text = '@path/to/file.txt please';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: '@path/to/file.txt', type: 'file' },
{ text: ' please', type: 'default' },
]);
});
it('should not highlight a command in the middle', () => {
const text = 'I need /help with this';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: 'I need /help with this', type: 'default' },
]);
});
it('should highlight a file path in the middle', () => {
const text = 'Please check @path/to/file.txt for details';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: 'Please check ', type: 'default' },
{ text: '@path/to/file.txt', type: 'file' },
{ text: ' for details', type: 'default' },
]);
});
it('should highlight files but not commands not at the start', () => {
const text = 'Use /run with @file.js and also /format @another/file.ts';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: 'Use /run with ', type: 'default' },
{ text: '@file.js', type: 'file' },
{ text: ' and also /format ', type: 'default' },
{ text: '@another/file.ts', type: 'file' },
]);
});
it('should handle adjacent highlights at start', () => {
const text = '/run@file.js';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: '/run', type: 'command' },
{ text: '@file.js', type: 'file' },
]);
});
it('should not highlight command at the end of the string', () => {
const text = 'Get help with /help';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: 'Get help with /help', type: 'default' },
]);
});
it('should handle file paths with dots and dashes', () => {
const text = 'Check @./path-to/file-name.v2.txt';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: 'Check ', type: 'default' },
{ text: '@./path-to/file-name.v2.txt', type: 'file' },
]);
});
it('should not highlight command with dashes and numbers not at start', () => {
const text = 'Run /command-123 now';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: 'Run /command-123 now', type: 'default' },
]);
});
it('should highlight command with dashes and numbers at start', () => {
const text = '/command-123 now';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: '/command-123', type: 'command' },
{ text: ' now', type: 'default' },
]);
});
it('should still highlight a file path on a non-zero line', () => {
const text = 'some text @path/to/file.txt';
expect(parseInputForHighlighting(text, 1)).toEqual([
{ text: 'some text ', type: 'default' },
{ text: '@path/to/file.txt', type: 'file' },
]);
});
it('should not highlight command but highlight file on a non-zero line', () => {
const text = '/cmd @file.txt';
expect(parseInputForHighlighting(text, 2)).toEqual([
{ text: '/cmd', type: 'default' },
{ text: ' ', type: 'default' },
{ text: '@file.txt', type: 'file' },
]);
});
it('should highlight a file path with escaped spaces', () => {
const text = 'cat @/my\\ path/file.txt';
expect(parseInputForHighlighting(text, 0)).toEqual([
{ text: 'cat ', type: 'default' },
{ text: '@/my\\ path/file.txt', type: 'file' },
]);
});
});

View File

@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { cpLen, cpSlice } from './textUtils.js';
export type HighlightToken = {
text: string;
type: 'default' | 'command' | 'file';
};
const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_./-])+)/g;
export function parseInputForHighlighting(
text: string,
index: number,
): readonly HighlightToken[] {
if (!text) {
return [{ text: '', type: 'default' }];
}
const tokens: HighlightToken[] = [];
let lastIndex = 0;
let match;
while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) {
const [fullMatch] = match;
const matchIndex = match.index;
// Add the text before the match as a default token
if (matchIndex > lastIndex) {
tokens.push({
text: text.slice(lastIndex, matchIndex),
type: 'default',
});
}
// Add the matched token
const type = fullMatch.startsWith('/') ? 'command' : 'file';
// Only highlight slash commands if the index is 0.
if (type === 'command' && index !== 0) {
tokens.push({
text: fullMatch,
type: 'default',
});
} else {
tokens.push({
text: fullMatch,
type,
});
}
lastIndex = matchIndex + fullMatch.length;
}
// Add any remaining text after the last match
if (lastIndex < text.length) {
tokens.push({
text: text.slice(lastIndex),
type: 'default',
});
}
return tokens;
}
export function buildSegmentsForVisualSlice(
tokens: readonly HighlightToken[],
sliceStart: number,
sliceEnd: number,
): readonly HighlightToken[] {
if (sliceStart >= sliceEnd) return [];
const segments: HighlightToken[] = [];
let tokenCpStart = 0;
for (const token of tokens) {
const tokenLen = cpLen(token.text);
const tokenStart = tokenCpStart;
const tokenEnd = tokenStart + tokenLen;
const overlapStart = Math.max(tokenStart, sliceStart);
const overlapEnd = Math.min(tokenEnd, sliceEnd);
if (overlapStart < overlapEnd) {
const sliceStartInToken = overlapStart - tokenStart;
const sliceEndInToken = overlapEnd - tokenStart;
const rawSlice = cpSlice(token.text, sliceStartInToken, sliceEndInToken);
const last = segments[segments.length - 1];
if (last && last.type === token.type) {
last.text += rawSlice;
} else {
segments.push({ type: token.type, text: rawSlice });
}
}
tokenCpStart += tokenLen;
}
return segments;
}

View File

@@ -32,40 +32,58 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
let responseBuffer = '';
let progressiveEnhancementReceived = false;
let checkFinished = false;
let timeoutId: NodeJS.Timeout | undefined;
const onTimeout = () => {
timeoutId = undefined;
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
};
const handleData = (data: Buffer) => {
if (timeoutId === undefined) {
// Race condition. We have already timed out.
return;
}
responseBuffer += data.toString();
// Check for progressive enhancement response (CSI ? <flags> u)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) {
progressiveEnhancementReceived = true;
// Give more time to get the full set of kitty responses if we have an
// indication the terminal probably supports kitty and we just need to
// wait a bit longer for a response.
clearTimeout(timeoutId);
timeoutId = setTimeout(onTimeout, 1000);
}
// Check for device attributes response (CSI ? <attrs> c)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) {
if (!checkFinished) {
checkFinished = true;
process.stdin.removeListener('data', handleData);
clearTimeout(timeoutId);
timeoutId = undefined;
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
if (progressiveEnhancementReceived) {
// Enable the protocol
process.stdout.write('\x1b[>1u');
protocolSupported = true;
protocolEnabled = true;
// Set up cleanup on exit
process.on('exit', disableProtocol);
process.on('SIGTERM', disableProtocol);
}
detectionComplete = true;
resolve(protocolSupported);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
if (progressiveEnhancementReceived) {
// Enable the protocol
process.stdout.write('\x1b[>1u');
protocolSupported = true;
protocolEnabled = true;
// Set up cleanup on exit
process.on('exit', disableProtocol);
process.on('SIGTERM', disableProtocol);
}
detectionComplete = true;
resolve(protocolSupported);
}
};
@@ -75,17 +93,10 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
process.stdout.write('\x1b[?u'); // Query progressive enhancement
process.stdout.write('\x1b[c'); // Query device attributes
// Timeout after 50ms
setTimeout(() => {
if (!checkFinished) {
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
}
}, 50);
// Timeout after 200ms
// When a iterm2 terminal does not have focus this can take over 90s on a
// fast macbook so we need a somewhat longer threshold than would be ideal.
timeoutId = setTimeout(onTimeout, 200);
});
}

View File

@@ -25,6 +25,31 @@ export const KITTY_KEYCODE_NUMPAD_ENTER = 57414;
export const KITTY_KEYCODE_TAB = 9;
export const KITTY_KEYCODE_BACKSPACE = 127;
/**
* Kitty modifier decoding constants
*
* In Kitty/Ghostty, the modifier parameter is encoded as (1 + bitmask).
* Some terminals also set bit 7 (i.e., add 128) when reporting event types.
*/
export const KITTY_MODIFIER_BASE = 1; // Base value per spec before bitmask decode
export const KITTY_MODIFIER_EVENT_TYPES_OFFSET = 128; // Added when event types are included
/**
* Modifier bit flags for Kitty/Xterm-style parameters.
*
* Per spec, the modifiers parameter encodes (1 + bitmask) where:
* - 1: no modifiers
* - bit 0 (1): Shift
* - bit 1 (2): Alt/Option (reported as "alt" in spec; we map to meta)
* - bit 2 (4): Ctrl
*
* Some terminals add 128 to the entire modifiers field when reporting event types.
* See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#modifiers
*/
export const MODIFIER_SHIFT_BIT = 1;
export const MODIFIER_ALT_BIT = 2;
export const MODIFIER_CTRL_BIT = 4;
/**
* Timing constants for terminal interactions
*/
@@ -49,7 +74,9 @@ export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5;
* Longest reasonable: \x1b[127;15~ = 11 chars (Del with all modifiers)
* We use 12 to provide a small buffer.
*/
export const MAX_KITTY_SEQUENCE_LENGTH = 12;
// Increased to accommodate parameterized forms and occasional colon subfields
// while still being small enough to avoid pathological buffering.
export const MAX_KITTY_SEQUENCE_LENGTH = 32;
/**
* Character codes for common escape sequences

View File

@@ -0,0 +1,170 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import type {
ToolCallConfirmationDetails,
ToolEditConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { escapeAnsiCtrlCodes } from './textUtils.js';
describe('textUtils', () => {
describe('escapeAnsiCtrlCodes', () => {
describe('escapeAnsiCtrlCodes string case study', () => {
it('should replace ANSI escape codes with a visible representation', () => {
const text = '\u001b[31mHello\u001b[0m';
const expected = '\\u001b[31mHello\\u001b[0m';
expect(escapeAnsiCtrlCodes(text)).toBe(expected);
const text2 = "sh -e 'good && bad# \u001b[9D\u001b[K && good";
const expected2 = "sh -e 'good && bad# \\u001b[9D\\u001b[K && good";
expect(escapeAnsiCtrlCodes(text2)).toBe(expected2);
});
it('should not change a string with no ANSI codes', () => {
const text = 'Hello, world!';
expect(escapeAnsiCtrlCodes(text)).toBe(text);
});
it('should handle an empty string', () => {
expect(escapeAnsiCtrlCodes('')).toBe('');
});
describe('toolConfirmationDetails case study', () => {
it('should sanitize command and rootCommand for exec type', () => {
const details: ToolCallConfirmationDetails = {
title: '\u001b[34mfake-title\u001b[0m',
type: 'exec',
command: '\u001b[31mmls -l\u001b[0m',
rootCommand: '\u001b[32msudo apt-get update\u001b[0m',
onConfirm: async () => {},
};
const sanitized = escapeAnsiCtrlCodes(details);
if (sanitized.type === 'exec') {
expect(sanitized.title).toBe('\\u001b[34mfake-title\\u001b[0m');
expect(sanitized.command).toBe('\\u001b[31mmls -l\\u001b[0m');
expect(sanitized.rootCommand).toBe(
'\\u001b[32msudo apt-get update\\u001b[0m',
);
}
});
it('should sanitize properties for edit type', () => {
const details: ToolCallConfirmationDetails = {
type: 'edit',
title: '\u001b[34mEdit File\u001b[0m',
fileName: '\u001b[31mfile.txt\u001b[0m',
filePath: '/path/to/\u001b[32mfile.txt\u001b[0m',
fileDiff:
'diff --git a/file.txt b/file.txt\n--- a/\u001b[33mfile.txt\u001b[0m\n+++ b/file.txt',
onConfirm: async () => {},
} as unknown as ToolEditConfirmationDetails;
const sanitized = escapeAnsiCtrlCodes(details);
if (sanitized.type === 'edit') {
expect(sanitized.title).toBe('\\u001b[34mEdit File\\u001b[0m');
expect(sanitized.fileName).toBe('\\u001b[31mfile.txt\\u001b[0m');
expect(sanitized.filePath).toBe(
'/path/to/\\u001b[32mfile.txt\\u001b[0m',
);
expect(sanitized.fileDiff).toBe(
'diff --git a/file.txt b/file.txt\n--- a/\\u001b[33mfile.txt\\u001b[0m\n+++ b/file.txt',
);
}
});
it('should sanitize properties for mcp type', () => {
const details: ToolCallConfirmationDetails = {
type: 'mcp',
title: '\u001b[34mCloud Run\u001b[0m',
serverName: '\u001b[31mmy-server\u001b[0m',
toolName: '\u001b[32mdeploy\u001b[0m',
toolDisplayName: '\u001b[33mDeploy Service\u001b[0m',
onConfirm: async () => {},
};
const sanitized = escapeAnsiCtrlCodes(details);
if (sanitized.type === 'mcp') {
expect(sanitized.title).toBe('\\u001b[34mCloud Run\\u001b[0m');
expect(sanitized.serverName).toBe('\\u001b[31mmy-server\\u001b[0m');
expect(sanitized.toolName).toBe('\\u001b[32mdeploy\\u001b[0m');
expect(sanitized.toolDisplayName).toBe(
'\\u001b[33mDeploy Service\\u001b[0m',
);
}
});
it('should sanitize properties for info type', () => {
const details: ToolCallConfirmationDetails = {
type: 'info',
title: '\u001b[34mWeb Search\u001b[0m',
prompt: '\u001b[31mSearch for cats\u001b[0m',
urls: ['https://\u001b[32mgoogle.com\u001b[0m'],
onConfirm: async () => {},
};
const sanitized = escapeAnsiCtrlCodes(details);
if (sanitized.type === 'info') {
expect(sanitized.title).toBe('\\u001b[34mWeb Search\\u001b[0m');
expect(sanitized.prompt).toBe(
'\\u001b[31mSearch for cats\\u001b[0m',
);
expect(sanitized.urls?.[0]).toBe(
'https://\\u001b[32mgoogle.com\\u001b[0m',
);
}
});
});
it('should not change the object if no sanitization is needed', () => {
const details: ToolCallConfirmationDetails = {
type: 'info',
title: 'Web Search',
prompt: 'Search for cats',
urls: ['https://google.com'],
onConfirm: async () => {},
};
const sanitized = escapeAnsiCtrlCodes(details);
expect(sanitized).toBe(details);
});
it('should handle nested objects and arrays', () => {
const details = {
a: '\u001b[31mred\u001b[0m',
b: {
c: '\u001b[32mgreen\u001b[0m',
d: ['\u001b[33myellow\u001b[0m', { e: '\u001b[34mblue\u001b[0m' }],
},
f: 123,
g: null,
h: () => '\u001b[35mpurple\u001b[0m',
};
const sanitized = escapeAnsiCtrlCodes(details);
expect(sanitized.a).toBe('\\u001b[31mred\\u001b[0m');
if (typeof sanitized.b === 'object' && sanitized.b !== null) {
const b = sanitized.b as { c: string; d: Array<string | object> };
expect(b.c).toBe('\\u001b[32mgreen\\u001b[0m');
expect(b.d[0]).toBe('\\u001b[33myellow\\u001b[0m');
if (typeof b.d[1] === 'object' && b.d[1] !== null) {
const e = b.d[1] as { e: string };
expect(e.e).toBe('\\u001b[34mblue\\u001b[0m');
}
}
expect(sanitized.f).toBe(123);
expect(sanitized.g).toBe(null);
expect(sanitized.h()).toBe('\u001b[35mpurple\u001b[0m');
});
});
});
});

View File

@@ -5,7 +5,9 @@
*/
import stripAnsi from 'strip-ansi';
import ansiRegex from 'ansi-regex';
import { stripVTControlCharacters } from 'node:util';
import stringWidth from 'string-width';
/**
* Calculates the maximum width of a multi-line ASCII art string.
@@ -26,10 +28,39 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
* code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */
// Cache for code points to reduce GC pressure
const codePointsCache = new Map<string, string[]>();
const MAX_STRING_LENGTH_TO_CACHE = 1000;
export function toCodePoints(str: string): string[] {
// [...str] or Array.from both iterate by UTF32 code point, handling
// surrogate pairs correctly.
return Array.from(str);
// ASCII fast path - check if all chars are ASCII (0-127)
let isAscii = true;
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) {
isAscii = false;
break;
}
}
if (isAscii) {
return str.split('');
}
// Cache short strings
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
const cached = codePointsCache.get(str);
if (cached) {
return cached;
}
}
const result = Array.from(str);
// Cache result (unlimited like Ink)
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
codePointsCache.set(str, result);
}
return result;
}
export function cpLen(str: string): number {
@@ -86,3 +117,100 @@ export function stripUnsafeCharacters(str: string): string {
})
.join('');
}
// String width caching for performance optimization
const stringWidthCache = new Map<string, number>();
/**
* Cached version of stringWidth function for better performance
* Follows Ink's approach with unlimited cache (no eviction)
*/
export const getCachedStringWidth = (str: string): number => {
// ASCII printable chars have width 1
if (/^[\x20-\x7E]*$/.test(str)) {
return str.length;
}
if (stringWidthCache.has(str)) {
return stringWidthCache.get(str)!;
}
const width = stringWidth(str);
stringWidthCache.set(str, width);
return width;
};
/**
* Clear the string width cache
*/
export const clearStringWidthCache = (): void => {
stringWidthCache.clear();
};
const regex = ansiRegex();
/* Recursively traverses a JSON-like structure (objects, arrays, primitives)
* and escapes all ANSI control characters found in any string values.
*
* This function is designed to be robust, handling deeply nested objects and
* arrays. It applies a regex-based replacement to all string values to
* safely escape control characters.
*
* To optimize performance, this function uses a "copy-on-write" strategy.
* It avoids allocating new objects or arrays if no nested string values
* required escaping, returning the original object reference in such cases.
*
* @param obj The JSON-like value (object, array, string, etc.) to traverse.
* @returns A new value with all nested string fields escaped, or the
* original `obj` reference if no changes were necessary.
*/
export function escapeAnsiCtrlCodes<T>(obj: T): T {
if (typeof obj === 'string') {
if (obj.search(regex) === -1) {
return obj; // No changes return original string
}
regex.lastIndex = 0; // needed for global regex
return obj.replace(regex, (match) =>
JSON.stringify(match).slice(1, -1),
) as T;
}
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
let newArr: unknown[] | null = null;
for (let i = 0; i < obj.length; i++) {
const value = obj[i];
const escapedValue = escapeAnsiCtrlCodes(value);
if (escapedValue !== value) {
if (newArr === null) {
newArr = [...obj];
}
newArr[i] = escapedValue;
}
}
return (newArr !== null ? newArr : obj) as T;
}
let newObj: T | null = null;
const keys = Object.keys(obj);
for (const key of keys) {
const value = (obj as Record<string, unknown>)[key];
const escapedValue = escapeAnsiCtrlCodes(value);
if (escapedValue !== value) {
if (newObj === null) {
newObj = { ...obj };
}
(newObj as Record<string, unknown>)[key] = escapedValue;
}
}
return newObj !== null ? newObj : obj;
}