66
77import React from 'react' ;
88import { 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' ;
915import { theme } from '../semantic-colors.js' ;
1016import { debugLogger } from '@google/gemini-cli-core' ;
1117import { 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 ( ! / [ * _ ~ ` < [ h t t p s ? : ] / . 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 > | h t t p s ? : \/ \/ \S + ) / g;
95+ / ( \* \* \* .* ?\* \* \* | \* \* .* ?\* \* | \* .* ?\* | _ .* ?_ | ~ ~ .* ?~ ~ | \[ .* ?\] \( .* ?\) | ` + .+ ?` + | < u > .* ?< \/ u > | h t t p s ? : \/ \/ \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 ( / ^ h t t p s ? : \/ \/ / ) ) {
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
174236export const RenderInline = React . memo ( RenderInlineInternal ) ;
0 commit comments