mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat(cli): introduce visualize tool for rich data display
- Implement `visualize` tool in core to support tables, charts, and diffs. - Add `RichDataDisplay` UI component using Ink for rendering visualizations. - Integrate visualization support into `ToolResultDisplay`. - Update system prompts to encourage the use of the `visualize` tool for structured data. - Add `info` semantic color to themes. - Fix shell parser initialization in tests.
This commit is contained in:
@@ -112,6 +112,7 @@ describe('<Header />', () => {
|
||||
error: '',
|
||||
success: '',
|
||||
warning: '',
|
||||
info: '',
|
||||
},
|
||||
});
|
||||
const Gradient = await import('ink-gradient');
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { RichDataDisplay } from './RichDataDisplay.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('RichDataDisplay', () => {
|
||||
it('should render table visualization', () => {
|
||||
const data = {
|
||||
type: 'table' as const,
|
||||
data: [{ name: 'Test', value: 123 }],
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('name');
|
||||
expect(output).toContain('value');
|
||||
expect(output).toContain('Test');
|
||||
expect(output).toContain('123');
|
||||
});
|
||||
|
||||
it('should render bar chart visualization', () => {
|
||||
const data = {
|
||||
type: 'bar_chart' as const,
|
||||
title: 'Sales',
|
||||
data: [
|
||||
{ label: 'Q1', value: 10 },
|
||||
{ label: 'Q2', value: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Sales');
|
||||
expect(output).toContain('Q1');
|
||||
expect(output).toContain('Q2');
|
||||
expect(output).toContain('█'); // Check for bar character
|
||||
});
|
||||
|
||||
it('should render line chart visualization', () => {
|
||||
const data = {
|
||||
type: 'line_chart' as const,
|
||||
title: 'Trends',
|
||||
data: [
|
||||
{ label: 'Jan', value: 10 },
|
||||
{ label: 'Feb', value: 20 },
|
||||
{ label: 'Mar', value: 15 },
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Trends');
|
||||
expect(output).toContain('Jan');
|
||||
expect(output).toContain('Feb');
|
||||
expect(output).toContain('Mar');
|
||||
expect(output).toContain('•'); // Check for plot point
|
||||
expect(output).toContain('│'); // Check for axis
|
||||
});
|
||||
|
||||
it('should render pie chart visualization', () => {
|
||||
const data = {
|
||||
type: 'pie_chart' as const,
|
||||
title: 'Market Share',
|
||||
data: [
|
||||
{ label: 'A', value: 50 },
|
||||
{ label: 'B', value: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Market Share');
|
||||
expect(output).toContain('A');
|
||||
expect(output).toContain('B');
|
||||
expect(output).toContain('50.0%');
|
||||
expect(output).toContain('█'); // Check for proportional bar
|
||||
});
|
||||
|
||||
it('should show saved file path', () => {
|
||||
const data = {
|
||||
type: 'table' as const,
|
||||
data: [],
|
||||
savedFilePath: '/path/to/file.csv',
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Saved to: /path/to/file.csv');
|
||||
});
|
||||
|
||||
it('should render diff visualization', () => {
|
||||
const data = {
|
||||
type: 'diff' as const,
|
||||
data: {
|
||||
fileDiff:
|
||||
'diff --git a/file.txt b/file.txt\nindex 123..456 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-foo\n+bar',
|
||||
fileName: 'file.txt',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('foo');
|
||||
expect(output).toContain('bar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { Table, type Column } from '../Table.js';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import * as Diff from 'diff';
|
||||
import type { RichVisualization } from '@google/gemini-cli-core';
|
||||
import { getPlainTextLength } from '../../utils/InlineMarkdownRenderer.js';
|
||||
|
||||
interface RichDataDisplayProps {
|
||||
data: RichVisualization;
|
||||
availableWidth: number;
|
||||
}
|
||||
|
||||
export const RichDataDisplay: React.FC<RichDataDisplayProps> = ({
|
||||
data,
|
||||
availableWidth,
|
||||
}) => {
|
||||
const {
|
||||
type,
|
||||
title,
|
||||
data: rawData,
|
||||
columns: providedColumns,
|
||||
savedFilePath,
|
||||
} = data;
|
||||
|
||||
const normalizeData = (
|
||||
data: unknown[],
|
||||
providedCols: typeof providedColumns,
|
||||
) =>
|
||||
data.map((item) => {
|
||||
const record = item as Record<string, unknown>;
|
||||
let label = 'Unknown';
|
||||
let value = 0;
|
||||
|
||||
if (providedCols && providedCols.length >= 2) {
|
||||
label = String(record[providedCols[0].key]);
|
||||
value = Number(record[providedCols[1].key]);
|
||||
} else {
|
||||
// Auto-detect
|
||||
const keys = Object.keys(record);
|
||||
const labelKey =
|
||||
keys.find((k) => typeof record[k] === 'string') || keys[0];
|
||||
const valueKey = keys.find((k) => typeof record[k] === 'number');
|
||||
if (labelKey) label = String(record[labelKey]);
|
||||
if (valueKey) value = Number(record[valueKey]);
|
||||
}
|
||||
return { label, value };
|
||||
});
|
||||
|
||||
const renderContent = () => {
|
||||
if (type === 'table' && Array.isArray(rawData)) {
|
||||
const tableData = rawData as Array<Record<string, unknown>>;
|
||||
|
||||
// Infer columns if not provided
|
||||
let columns: Array<Column<Record<string, unknown>>> = [];
|
||||
if (providedColumns) {
|
||||
columns = providedColumns.map((col) => ({
|
||||
key: col.key,
|
||||
header: col.label,
|
||||
}));
|
||||
} else if (tableData.length > 0) {
|
||||
columns = Object.keys(tableData[0]).map((key) => ({
|
||||
key,
|
||||
header: key,
|
||||
}));
|
||||
}
|
||||
|
||||
// Calculate widths based on content
|
||||
const paddingPerCol = 2; // Extra buffer
|
||||
const columnContentWidths = columns.map((col) => {
|
||||
const headerWidth = getPlainTextLength(String(col.header));
|
||||
const maxDataWidth = Math.max(
|
||||
...tableData.map((row) =>
|
||||
getPlainTextLength(String(row[col.key] || '')),
|
||||
),
|
||||
0,
|
||||
);
|
||||
return Math.max(headerWidth, maxDataWidth) + paddingPerCol;
|
||||
});
|
||||
|
||||
const totalContentWidth = columnContentWidths.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (totalContentWidth > availableWidth && columns.length > 0) {
|
||||
// Scale down if exceeds available width
|
||||
const scaleFactor = availableWidth / totalContentWidth;
|
||||
columns = columns.map((col, i) => ({
|
||||
...col,
|
||||
width: Math.max(4, Math.floor(columnContentWidths[i] * scaleFactor)),
|
||||
}));
|
||||
} else {
|
||||
// Use content widths or distribute remaining space
|
||||
columns = columns.map((col, i) => ({
|
||||
...col,
|
||||
width: columnContentWidths[i],
|
||||
}));
|
||||
}
|
||||
|
||||
return <Table data={tableData} columns={columns} />;
|
||||
} else if (type === 'bar_chart' && Array.isArray(rawData)) {
|
||||
const normalized = normalizeData(rawData as unknown[], providedColumns);
|
||||
|
||||
const maxValue = Math.max(...normalized.map((d) => d.value), 1);
|
||||
const maxLabelLen = Math.max(...normalized.map((d) => d.label.length), 1);
|
||||
const barAreaWidth = Math.max(10, availableWidth - maxLabelLen - 10);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{normalized.map((item, i) => {
|
||||
const barLen = Math.max(
|
||||
0,
|
||||
Math.floor((item.value / maxValue) * barAreaWidth),
|
||||
);
|
||||
const bar = '█'.repeat(barLen);
|
||||
return (
|
||||
<Box key={i}>
|
||||
<Text>{item.label.padEnd(maxLabelLen + 1)}</Text>
|
||||
<Text color={theme.text.accent}>{bar}</Text>
|
||||
<Text> {item.value}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
} else if (type === 'line_chart' && Array.isArray(rawData)) {
|
||||
const normalized = normalizeData(rawData as unknown[], providedColumns);
|
||||
if (normalized.length === 0) return <Text>No data to display.</Text>;
|
||||
|
||||
const maxValue = Math.max(...normalized.map((d) => d.value), 0);
|
||||
const minValue = Math.min(...normalized.map((d) => d.value), 0);
|
||||
const range = Math.max(maxValue - minValue, 1);
|
||||
const chartHeight = 10;
|
||||
|
||||
// Plotting
|
||||
const rows: string[][] = Array.from({ length: chartHeight }, () =>
|
||||
Array.from({ length: normalized.length }, () => ' '),
|
||||
);
|
||||
|
||||
normalized.forEach((item, x) => {
|
||||
const y = Math.min(
|
||||
chartHeight - 1,
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(((item.value - minValue) / range) * (chartHeight - 1)),
|
||||
),
|
||||
);
|
||||
rows[chartHeight - 1 - y][x] = '•';
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{rows.map((row, i) => {
|
||||
const yValue =
|
||||
minValue + (range * (chartHeight - 1 - i)) / (chartHeight - 1);
|
||||
return (
|
||||
<Box key={i}>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
{yValue.toFixed(1).padStart(8)} │
|
||||
</Text>
|
||||
<Text color={theme.text.accent}>{row.join(' ')}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box marginLeft={10}>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
└─{'──'.repeat(normalized.length)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={10}>
|
||||
{normalized.map((item, i) => (
|
||||
<Box key={i} width={3}>
|
||||
<Text color={theme.text.secondary} dimColor wrap="truncate-end">
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (type === 'pie_chart' && Array.isArray(rawData)) {
|
||||
const normalized = normalizeData(rawData as unknown[], providedColumns);
|
||||
const total = normalized.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
const colors = [
|
||||
theme.text.accent,
|
||||
theme.status.success,
|
||||
theme.status.warning,
|
||||
theme.status.info,
|
||||
'#FF6B6B',
|
||||
'#4D96FF',
|
||||
'#6BCB77',
|
||||
'#FFD93D',
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{/* Proportional Bar */}
|
||||
<Box height={1} marginBottom={1}>
|
||||
{normalized.map((item, i) => {
|
||||
const percent = total > 0 ? item.value / total : 0;
|
||||
const barWidth = Math.max(
|
||||
1,
|
||||
Math.floor(percent * availableWidth),
|
||||
);
|
||||
return (
|
||||
<Text key={i} color={colors[i % colors.length]}>
|
||||
{'█'.repeat(barWidth)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{/* Legend */}
|
||||
{normalized.map((item, i) => {
|
||||
const percent = total > 0 ? (item.value / total) * 100 : 0;
|
||||
return (
|
||||
<Box key={i}>
|
||||
<Text color={colors[i % colors.length]}>■ </Text>
|
||||
<Text bold>{item.label}: </Text>
|
||||
<Text>{item.value} </Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
({percent.toFixed(1)}%)
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
} else if (
|
||||
type === 'diff' &&
|
||||
(typeof rawData === 'object' || typeof rawData === 'string') &&
|
||||
rawData
|
||||
) {
|
||||
let diffContent: string | undefined;
|
||||
let filename = 'Diff';
|
||||
|
||||
if (typeof rawData === 'string') {
|
||||
diffContent = rawData;
|
||||
} else {
|
||||
const diffData = rawData as {
|
||||
fileDiff?: string;
|
||||
fileName?: string;
|
||||
old?: string;
|
||||
new?: string;
|
||||
oldContent?: string;
|
||||
newContent?: string;
|
||||
originalContent?: string;
|
||||
};
|
||||
|
||||
diffContent = diffData.fileDiff;
|
||||
filename = diffData.fileName || 'Diff';
|
||||
|
||||
if (!diffContent) {
|
||||
const oldVal =
|
||||
diffData.old ?? diffData.oldContent ?? diffData.originalContent;
|
||||
const newVal = diffData.new ?? diffData.newContent;
|
||||
if (oldVal !== undefined && newVal !== undefined) {
|
||||
diffContent = Diff.createPatch(
|
||||
filename,
|
||||
String(oldVal),
|
||||
String(newVal),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diffContent) {
|
||||
return (
|
||||
<DiffRenderer
|
||||
diffContent={diffContent}
|
||||
filename={filename}
|
||||
availableTerminalHeight={20} // Reasonable default or pass from props
|
||||
terminalWidth={availableWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.error}>
|
||||
Error: Diff data missing 'fileDiff' property.
|
||||
</Text>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
Expected data to be a string or an object with
|
||||
'fileDiff', or both 'old' and 'new'
|
||||
content.
|
||||
</Text>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
Received keys:{' '}
|
||||
{typeof rawData === 'object'
|
||||
? Object.keys(rawData).join(', ')
|
||||
: 'none (string)'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Text>Unknown visualization type: {type}</Text>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
{title && (
|
||||
<Text bold color={theme.text.accent} underline>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{renderContent()}
|
||||
{savedFilePath && (
|
||||
<Text color={theme.status.success} dimColor>
|
||||
{`Saved to: ${savedFilePath}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -301,4 +301,44 @@ describe('ToolResultDisplay', () => {
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).toContain('Line 50');
|
||||
});
|
||||
|
||||
it('renders rich visualization result (diff)', () => {
|
||||
const richResult = {
|
||||
type: 'diff' as const,
|
||||
data: {
|
||||
fileDiff:
|
||||
'diff --git a/test.ts b/test.ts\n--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
|
||||
fileName: 'test.ts',
|
||||
},
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={richResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('old');
|
||||
expect(output).toContain('new');
|
||||
});
|
||||
|
||||
it('renders rich visualization result (table)', () => {
|
||||
const richResult = {
|
||||
type: 'table' as const,
|
||||
data: [{ name: 'Test', value: 123 }],
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={richResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Test');
|
||||
expect(output).toContain('123');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
AnsiOutput,
|
||||
AnsiLine,
|
||||
RichVisualization,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
@@ -19,6 +23,7 @@ import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { RichDataDisplay } from './RichDataDisplay.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
|
||||
@@ -190,6 +195,20 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'object' &&
|
||||
'type' in truncatedResultDisplay &&
|
||||
'data' in truncatedResultDisplay &&
|
||||
['table', 'bar_chart', 'pie_chart', 'line_chart', 'diff'].includes(
|
||||
(truncatedResultDisplay as RichVisualization).type,
|
||||
)
|
||||
) {
|
||||
content = (
|
||||
<RichDataDisplay
|
||||
data={truncatedResultDisplay as RichVisualization}
|
||||
availableWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const shouldDisableTruncation =
|
||||
isAlternateBuffer ||
|
||||
|
||||
@@ -55,6 +55,7 @@ const noColorSemanticColors: SemanticColors = {
|
||||
error: '',
|
||||
success: '',
|
||||
warning: '',
|
||||
info: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface SemanticColors {
|
||||
error: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
info: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ export const lightSemanticColors: SemanticColors = {
|
||||
error: lightTheme.AccentRed,
|
||||
success: lightTheme.AccentGreen,
|
||||
warning: lightTheme.AccentYellow,
|
||||
info: lightTheme.AccentBlue,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,6 +101,7 @@ export const darkSemanticColors: SemanticColors = {
|
||||
error: darkTheme.AccentRed,
|
||||
success: darkTheme.AccentGreen,
|
||||
warning: darkTheme.AccentYellow,
|
||||
info: darkTheme.AccentBlue,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,5 +134,6 @@ export const ansiSemanticColors: SemanticColors = {
|
||||
error: ansiTheme.AccentRed,
|
||||
success: ansiTheme.AccentGreen,
|
||||
warning: ansiTheme.AccentYellow,
|
||||
info: ansiTheme.AccentBlue,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,6 +149,7 @@ export class Theme {
|
||||
error: this.colors.AccentRed,
|
||||
success: this.colors.AccentGreen,
|
||||
warning: this.colors.AccentYellow,
|
||||
info: this.colors.AccentBlue,
|
||||
},
|
||||
};
|
||||
this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map
|
||||
@@ -414,6 +415,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||
error: customTheme.status?.error ?? colors.AccentRed,
|
||||
success: customTheme.status?.success ?? colors.AccentGreen,
|
||||
warning: customTheme.status?.warning ?? colors.AccentYellow,
|
||||
info: customTheme.status?.info ?? colors.AccentBlue,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -177,13 +177,67 @@ export const RenderInline = React.memo(RenderInlineInternal);
|
||||
* This is useful for calculating column widths in tables
|
||||
*/
|
||||
export const getPlainTextLength = (text: string): number => {
|
||||
const cleanText = text
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/_(.*?)_/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
.replace(/<u>(.*?)<\/u>/g, '$1')
|
||||
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
|
||||
const inlineRegex =
|
||||
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
|
||||
|
||||
const cleanText = text.replace(inlineRegex, (fullMatch) => {
|
||||
if (
|
||||
fullMatch.startsWith('**') &&
|
||||
fullMatch.endsWith('**') &&
|
||||
fullMatch.length > BOLD_MARKER_LENGTH * 2
|
||||
) {
|
||||
return fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH);
|
||||
}
|
||||
if (
|
||||
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
|
||||
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
|
||||
(fullMatch.startsWith('_') && fullMatch.endsWith('_')))
|
||||
) {
|
||||
return fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH);
|
||||
}
|
||||
if (
|
||||
fullMatch.startsWith('~~') &&
|
||||
fullMatch.endsWith('~~') &&
|
||||
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
|
||||
) {
|
||||
return fullMatch.slice(
|
||||
STRIKETHROUGH_MARKER_LENGTH,
|
||||
-STRIKETHROUGH_MARKER_LENGTH,
|
||||
);
|
||||
}
|
||||
if (
|
||||
fullMatch.startsWith('`') &&
|
||||
fullMatch.endsWith('`') &&
|
||||
fullMatch.length > INLINE_CODE_MARKER_LENGTH
|
||||
) {
|
||||
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
|
||||
if (codeMatch && codeMatch[2]) {
|
||||
return codeMatch[2];
|
||||
}
|
||||
}
|
||||
if (
|
||||
fullMatch.startsWith('[') &&
|
||||
fullMatch.includes('](') &&
|
||||
fullMatch.endsWith(')')
|
||||
) {
|
||||
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
return linkMatch[1];
|
||||
}
|
||||
}
|
||||
if (
|
||||
fullMatch.startsWith('<u>') &&
|
||||
fullMatch.endsWith('</u>') &&
|
||||
fullMatch.length >
|
||||
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
|
||||
) {
|
||||
return fullMatch.slice(
|
||||
UNDERLINE_TAG_START_LENGTH,
|
||||
-UNDERLINE_TAG_END_LENGTH,
|
||||
);
|
||||
}
|
||||
return fullMatch;
|
||||
});
|
||||
|
||||
return stringWidth(cleanText);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user