Skip to content

Commit ecfa4e0

Browse files
authored
fix(ui): correct styled table width calculations (#20042)
1 parent 0854413 commit ecfa4e0

25 files changed

Lines changed: 1312 additions & 602 deletions

File tree

packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx

Lines changed: 128 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
import React from 'react';
88
import { Text } from 'ink';
9+
import chalk from 'chalk';
10+
import {
11+
resolveColor,
12+
INK_SUPPORTED_NAMES,
13+
INK_NAME_TO_HEX_MAP,
14+
} from '../themes/color-utils.js';
915
import { theme } from '../semantic-colors.js';
1016
import { debugLogger } from '@google/gemini-cli-core';
1117
import { stripUnsafeCharacters } from './textUtils.js';
@@ -23,46 +29,108 @@ interface RenderInlineProps {
2329
defaultColor?: string;
2430
}
2531

26-
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
27-
text: rawText,
28-
defaultColor,
29-
}) => {
30-
const text = stripUnsafeCharacters(rawText);
32+
/**
33+
* Helper to apply color to a string using ANSI escape codes,
34+
* consistent with how Ink's colorize works.
35+
*/
36+
const ansiColorize = (str: string, color: string | undefined): string => {
37+
if (!color) return str;
38+
const resolved = resolveColor(color);
39+
if (!resolved) return str;
40+
41+
if (resolved.startsWith('#')) {
42+
return chalk.hex(resolved)(str);
43+
}
44+
45+
const mappedHex = INK_NAME_TO_HEX_MAP[resolved];
46+
if (mappedHex) {
47+
return chalk.hex(mappedHex)(str);
48+
}
49+
50+
if (INK_SUPPORTED_NAMES.has(resolved)) {
51+
switch (resolved) {
52+
case 'black':
53+
return chalk.black(str);
54+
case 'red':
55+
return chalk.red(str);
56+
case 'green':
57+
return chalk.green(str);
58+
case 'yellow':
59+
return chalk.yellow(str);
60+
case 'blue':
61+
return chalk.blue(str);
62+
case 'magenta':
63+
return chalk.magenta(str);
64+
case 'cyan':
65+
return chalk.cyan(str);
66+
case 'white':
67+
return chalk.white(str);
68+
case 'gray':
69+
case 'grey':
70+
return chalk.gray(str);
71+
default:
72+
return str;
73+
}
74+
}
75+
76+
return str;
77+
};
78+
79+
/**
80+
* Converts markdown text into a string with ANSI escape codes.
81+
* This mirrors the parsing logic in InlineMarkdownRenderer.tsx
82+
*/
83+
export const parseMarkdownToANSI = (
84+
text: string,
85+
defaultColor?: string,
86+
): string => {
3187
const baseColor = defaultColor ?? theme.text.primary;
3288
// Early return for plain text without markdown or URLs
3389
if (!/[*_~`<[https?:]/.test(text)) {
34-
return <Text color={baseColor}>{text}</Text>;
90+
return ansiColorize(text, baseColor);
3591
}
3692

37-
const nodes: React.ReactNode[] = [];
38-
let lastIndex = 0;
93+
let result = '';
3994
const inlineRegex =
40-
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
95+
/(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
96+
let lastIndex = 0;
4197
let match;
4298

4399
while ((match = inlineRegex.exec(text)) !== null) {
44100
if (match.index > lastIndex) {
45-
nodes.push(
46-
<Text key={`t-${lastIndex}`} color={baseColor}>
47-
{text.slice(lastIndex, match.index)}
48-
</Text>,
49-
);
101+
result += ansiColorize(text.slice(lastIndex, match.index), baseColor);
50102
}
51103

52104
const fullMatch = match[0];
53-
let renderedNode: React.ReactNode = null;
54-
const key = `m-${match.index}`;
105+
let styledPart = '';
55106

56107
try {
57108
if (
58-
fullMatch.startsWith('**') &&
109+
fullMatch.endsWith('***') &&
110+
fullMatch.startsWith('***') &&
111+
fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2
112+
) {
113+
styledPart = chalk.bold(
114+
chalk.italic(
115+
parseMarkdownToANSI(
116+
fullMatch.slice(
117+
BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH,
118+
-BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH,
119+
),
120+
baseColor,
121+
),
122+
),
123+
);
124+
} else if (
59125
fullMatch.endsWith('**') &&
126+
fullMatch.startsWith('**') &&
60127
fullMatch.length > BOLD_MARKER_LENGTH * 2
61128
) {
62-
renderedNode = (
63-
<Text key={key} bold color={baseColor}>
64-
{fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)}
65-
</Text>
129+
styledPart = chalk.bold(
130+
parseMarkdownToANSI(
131+
fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH),
132+
baseColor,
133+
),
66134
);
67135
} else if (
68136
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
@@ -77,23 +145,25 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
77145
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
78146
)
79147
) {
80-
renderedNode = (
81-
<Text key={key} italic color={baseColor}>
82-
{fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)}
83-
</Text>
148+
styledPart = chalk.italic(
149+
parseMarkdownToANSI(
150+
fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH),
151+
baseColor,
152+
),
84153
);
85154
} else if (
86155
fullMatch.startsWith('~~') &&
87156
fullMatch.endsWith('~~') &&
88157
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
89158
) {
90-
renderedNode = (
91-
<Text key={key} strikethrough color={baseColor}>
92-
{fullMatch.slice(
159+
styledPart = chalk.strikethrough(
160+
parseMarkdownToANSI(
161+
fullMatch.slice(
93162
STRIKETHROUGH_MARKER_LENGTH,
94163
-STRIKETHROUGH_MARKER_LENGTH,
95-
)}
96-
</Text>
164+
),
165+
baseColor,
166+
),
97167
);
98168
} else if (
99169
fullMatch.startsWith('`') &&
@@ -102,11 +172,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
102172
) {
103173
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
104174
if (codeMatch && codeMatch[2]) {
105-
renderedNode = (
106-
<Text key={key} color={theme.text.accent}>
107-
{codeMatch[2]}
108-
</Text>
109-
);
175+
styledPart = ansiColorize(codeMatch[2], theme.text.accent);
110176
}
111177
} else if (
112178
fullMatch.startsWith('[') &&
@@ -117,58 +183,54 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
117183
if (linkMatch) {
118184
const linkText = linkMatch[1];
119185
const url = linkMatch[2];
120-
renderedNode = (
121-
<Text key={key} color={baseColor}>
122-
{linkText}
123-
<Text color={theme.text.link}> ({url})</Text>
124-
</Text>
125-
);
186+
styledPart =
187+
parseMarkdownToANSI(linkText, baseColor) +
188+
ansiColorize(' (', baseColor) +
189+
ansiColorize(url, theme.text.link) +
190+
ansiColorize(')', baseColor);
126191
}
127192
} else if (
128193
fullMatch.startsWith('<u>') &&
129194
fullMatch.endsWith('</u>') &&
130195
fullMatch.length >
131-
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 // -1 because length is compared to combined length of start and end tags
196+
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
132197
) {
133-
renderedNode = (
134-
<Text key={key} underline color={baseColor}>
135-
{fullMatch.slice(
198+
styledPart = chalk.underline(
199+
parseMarkdownToANSI(
200+
fullMatch.slice(
136201
UNDERLINE_TAG_START_LENGTH,
137202
-UNDERLINE_TAG_END_LENGTH,
138-
)}
139-
</Text>
203+
),
204+
baseColor,
205+
),
140206
);
141207
} else if (fullMatch.match(/^https?:\/\//)) {
142-
renderedNode = (
143-
<Text key={key} color={theme.text.link}>
144-
{fullMatch}
145-
</Text>
146-
);
208+
styledPart = ansiColorize(fullMatch, theme.text.link);
147209
}
148210
} catch (e) {
149211
debugLogger.warn('Error parsing inline markdown part:', fullMatch, e);
150-
renderedNode = null;
212+
styledPart = '';
151213
}
152214

153-
nodes.push(
154-
renderedNode ?? (
155-
<Text key={key} color={baseColor}>
156-
{fullMatch}
157-
</Text>
158-
),
159-
);
215+
result += styledPart || ansiColorize(fullMatch, baseColor);
160216
lastIndex = inlineRegex.lastIndex;
161217
}
162218

163219
if (lastIndex < text.length) {
164-
nodes.push(
165-
<Text key={`t-${lastIndex}`} color={baseColor}>
166-
{text.slice(lastIndex)}
167-
</Text>,
168-
);
220+
result += ansiColorize(text.slice(lastIndex), baseColor);
169221
}
170222

171-
return <>{nodes.filter((node) => node !== null)}</>;
223+
return result;
224+
};
225+
226+
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
227+
text: rawText,
228+
defaultColor,
229+
}) => {
230+
const text = stripUnsafeCharacters(rawText);
231+
const ansiText = parseMarkdownToANSI(text, defaultColor);
232+
233+
return <Text>{ansiText}</Text>;
172234
};
173235

174236
export const RenderInline = React.memo(RenderInlineInternal);

0 commit comments

Comments
 (0)