mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
Sync upstream Gemini-CLI v0.8.2 (#838)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;"`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
136
packages/cli/src/ui/utils/highlight.test.ts
Normal file
136
packages/cli/src/ui/utils/highlight.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
103
packages/cli/src/ui/utils/highlight.ts
Normal file
103
packages/cli/src/ui/utils/highlight.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
170
packages/cli/src/ui/utils/textUtils.test.ts
Normal file
170
packages/cli/src/ui/utils/textUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 surrogate‑pair 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 UTF‑32 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user