feat(ui): add response semantic color (#12450)

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: shambhu-hegde <143840542+shambhu-hegde@users.noreply.github.com>
This commit is contained in:
cornmander
2025-11-02 19:21:26 -05:00
committed by GitHub
parent 9187f6f6d1
commit 462c7d3502
10 changed files with 70 additions and 36 deletions
+5
View File
@@ -88,6 +88,11 @@ color keys. For example:
- `DiffRemoved` (optional, for removed lines in diffs) - `DiffRemoved` (optional, for removed lines in diffs)
- `DiffModified` (optional, for modified lines in diffs) - `DiffModified` (optional, for modified lines in diffs)
You can also override individual UI text roles by adding a nested `text` object.
This object supports the keys `primary`, `secondary`, `link`, `accent`, and
`response`. When `text.response` is provided it takes precedence over
`text.primary` for rendering model responses in chat.
**Required Properties:** **Required Properties:**
- `name` (must match the key in the `customThemes` object and be a string) - `name` (must match the key in the `customThemes` object and be a string)
@@ -93,7 +93,7 @@ export const ShellConfirmationDialog: React.FC<
> >
{commands.map((cmd) => ( {commands.map((cmd) => (
<Text key={cmd} color={theme.text.link}> <Text key={cmd} color={theme.text.link}>
<RenderInline text={cmd} /> <RenderInline text={cmd} defaultColor={theme.text.link} />
</Text> </Text>
))} ))}
</Box> </Box>
@@ -23,8 +23,8 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
<Text color={theme.status.warning}>{prefix}</Text> <Text color={theme.status.warning}>{prefix}</Text>
</Box> </Box>
<Box flexGrow={1}> <Box flexGrow={1}>
<Text wrap="wrap" color={theme.status.warning}> <Text wrap="wrap">
<RenderInline text={text} /> <RenderInline text={text} defaultColor={theme.status.warning} />
</Text> </Text>
</Box> </Box>
</Box> </Box>
@@ -249,9 +249,7 @@ export const ToolConfirmationMessage: React.FC<
bodyContent = ( bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}> <Box flexDirection="column" paddingX={1} marginLeft={1}>
<Text color={theme.text.link}> <RenderInline text={infoProps.prompt} defaultColor={theme.text.link} />
<RenderInline text={infoProps.prompt} />
</Text>
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( {displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text> <Text color={theme.text.primary}>URLs to fetch:</Text>
@@ -6,7 +6,7 @@
import type React from 'react'; import type React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../../colors.js'; import { theme } from '../../semantic-colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface WarningMessageProps { interface WarningMessageProps {
@@ -20,11 +20,11 @@ export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
return ( return (
<Box flexDirection="row" marginTop={1}> <Box flexDirection="row" marginTop={1}>
<Box width={prefixWidth}> <Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text> <Text color={theme.status.warning}>{prefix}</Text>
</Box> </Box>
<Box flexGrow={1}> <Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentYellow}> <Text wrap="wrap">
<RenderInline text={text} /> <RenderInline text={text} defaultColor={theme.status.warning} />
</Text> </Text>
</Box> </Box>
</Box> </Box>
+1
View File
@@ -32,6 +32,7 @@ const noColorSemanticColors: SemanticColors = {
secondary: '', secondary: '',
link: '', link: '',
accent: '', accent: '',
response: '',
}, },
background: { background: {
primary: '', primary: '',
@@ -12,6 +12,7 @@ export interface SemanticColors {
secondary: string; secondary: string;
link: string; link: string;
accent: string; accent: string;
response: string;
}; };
background: { background: {
primary: string; primary: string;
@@ -43,6 +44,7 @@ export const lightSemanticColors: SemanticColors = {
secondary: lightTheme.Gray, secondary: lightTheme.Gray,
link: lightTheme.AccentBlue, link: lightTheme.AccentBlue,
accent: lightTheme.AccentPurple, accent: lightTheme.AccentPurple,
response: lightTheme.Foreground,
}, },
background: { background: {
primary: lightTheme.Background, primary: lightTheme.Background,
@@ -74,6 +76,7 @@ export const darkSemanticColors: SemanticColors = {
secondary: darkTheme.Gray, secondary: darkTheme.Gray,
link: darkTheme.AccentBlue, link: darkTheme.AccentBlue,
accent: darkTheme.AccentPurple, accent: darkTheme.AccentPurple,
response: darkTheme.Foreground,
}, },
background: { background: {
primary: darkTheme.Background, primary: darkTheme.Background,
@@ -105,6 +108,7 @@ export const ansiSemanticColors: SemanticColors = {
secondary: ansiTheme.Gray, secondary: ansiTheme.Gray,
link: ansiTheme.AccentBlue, link: ansiTheme.AccentBlue,
accent: ansiTheme.AccentPurple, accent: ansiTheme.AccentPurple,
response: ansiTheme.Foreground,
}, },
background: { background: {
primary: ansiTheme.Background, primary: ansiTheme.Background,
+6
View File
@@ -38,6 +38,7 @@ export interface CustomTheme {
secondary?: string; secondary?: string;
link?: string; link?: string;
accent?: string; accent?: string;
response?: string;
}; };
background?: { background?: {
primary?: string; primary?: string;
@@ -166,6 +167,7 @@ export class Theme {
secondary: this.colors.Gray, secondary: this.colors.Gray,
link: this.colors.AccentBlue, link: this.colors.AccentBlue,
accent: this.colors.AccentPurple, accent: this.colors.AccentPurple,
response: this.colors.Foreground,
}, },
background: { background: {
primary: this.colors.Background, primary: this.colors.Background,
@@ -427,6 +429,10 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
secondary: customTheme.text?.secondary ?? colors.Gray, secondary: customTheme.text?.secondary ?? colors.Gray,
link: customTheme.text?.link ?? colors.AccentBlue, link: customTheme.text?.link ?? colors.AccentBlue,
accent: customTheme.text?.accent ?? colors.AccentPurple, accent: customTheme.text?.accent ?? colors.AccentPurple,
response:
customTheme.text?.response ??
customTheme.text?.primary ??
colors.Foreground,
}, },
background: { background: {
primary: customTheme.background?.primary ?? colors.Background, primary: customTheme.background?.primary ?? colors.Background,
@@ -19,12 +19,17 @@ const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
interface RenderInlineProps { interface RenderInlineProps {
text: string; text: string;
defaultColor?: string;
} }
const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => { const RenderInlineInternal: React.FC<RenderInlineProps> = ({
text,
defaultColor,
}) => {
const baseColor = defaultColor ?? theme.text.primary;
// Early return for plain text without markdown or URLs // Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) { if (!/[*_~`<[https?:]/.test(text)) {
return <Text color={theme.text.primary}>{text}</Text>; return <Text color={baseColor}>{text}</Text>;
} }
const nodes: React.ReactNode[] = []; const nodes: React.ReactNode[] = [];
@@ -36,7 +41,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
while ((match = inlineRegex.exec(text)) !== null) { while ((match = inlineRegex.exec(text)) !== null) {
if (match.index > lastIndex) { if (match.index > lastIndex) {
nodes.push( nodes.push(
<Text key={`t-${lastIndex}`}> <Text key={`t-${lastIndex}`} color={baseColor}>
{text.slice(lastIndex, match.index)} {text.slice(lastIndex, match.index)}
</Text>, </Text>,
); );
@@ -53,7 +58,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
fullMatch.length > BOLD_MARKER_LENGTH * 2 fullMatch.length > BOLD_MARKER_LENGTH * 2
) { ) {
renderedNode = ( renderedNode = (
<Text key={key} bold> <Text key={key} bold color={baseColor}>
{fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)} {fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)}
</Text> </Text>
); );
@@ -71,7 +76,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
) )
) { ) {
renderedNode = ( renderedNode = (
<Text key={key} italic> <Text key={key} italic color={baseColor}>
{fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)} {fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)}
</Text> </Text>
); );
@@ -81,7 +86,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2 fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
) { ) {
renderedNode = ( renderedNode = (
<Text key={key} strikethrough> <Text key={key} strikethrough color={baseColor}>
{fullMatch.slice( {fullMatch.slice(
STRIKETHROUGH_MARKER_LENGTH, STRIKETHROUGH_MARKER_LENGTH,
-STRIKETHROUGH_MARKER_LENGTH, -STRIKETHROUGH_MARKER_LENGTH,
@@ -111,7 +116,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
const linkText = linkMatch[1]; const linkText = linkMatch[1];
const url = linkMatch[2]; const url = linkMatch[2];
renderedNode = ( renderedNode = (
<Text key={key}> <Text key={key} color={baseColor}>
{linkText} {linkText}
<Text color={theme.text.link}> ({url})</Text> <Text color={theme.text.link}> ({url})</Text>
</Text> </Text>
@@ -124,7 +129,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 // -1 because length is compared to combined length of start and end tags UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 // -1 because length is compared to combined length of start and end tags
) { ) {
renderedNode = ( renderedNode = (
<Text key={key} underline> <Text key={key} underline color={baseColor}>
{fullMatch.slice( {fullMatch.slice(
UNDERLINE_TAG_START_LENGTH, UNDERLINE_TAG_START_LENGTH,
-UNDERLINE_TAG_END_LENGTH, -UNDERLINE_TAG_END_LENGTH,
@@ -143,12 +148,22 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
renderedNode = null; renderedNode = null;
} }
nodes.push(renderedNode ?? <Text key={key}>{fullMatch}</Text>); nodes.push(
renderedNode ?? (
<Text key={key} color={baseColor}>
{fullMatch}
</Text>
),
);
lastIndex = inlineRegex.lastIndex; lastIndex = inlineRegex.lastIndex;
} }
if (lastIndex < text.length) { if (lastIndex < text.length) {
nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>); nodes.push(
<Text key={`t-${lastIndex}`} color={baseColor}>
{text.slice(lastIndex)}
</Text>,
);
} }
return <>{nodes.filter((node) => node !== null)}</>; return <>{nodes.filter((node) => node !== null)}</>;
+21 -16
View File
@@ -35,6 +35,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
renderMarkdown = true, renderMarkdown = true,
}) => { }) => {
const settings = useSettings(); const settings = useSettings();
const responseColor = theme.text.response ?? theme.text.primary;
if (!text) return <></>; if (!text) return <></>;
@@ -138,8 +139,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
// Not a table, treat as regular text // Not a table, treat as regular text
addContentBlock( addContentBlock(
<Box key={key}> <Box key={key}>
<Text wrap="wrap"> <Text wrap="wrap" color={responseColor}>
<RenderInline text={line} /> <RenderInline text={line} defaultColor={responseColor} />
</Text> </Text>
</Box>, </Box>,
); );
@@ -177,8 +178,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
if (line.trim().length > 0) { if (line.trim().length > 0) {
addContentBlock( addContentBlock(
<Box key={key}> <Box key={key}>
<Text wrap="wrap"> <Text wrap="wrap" color={responseColor}>
<RenderInline text={line} /> <RenderInline text={line} defaultColor={responseColor} />
</Text> </Text>
</Box>, </Box>,
); );
@@ -197,35 +198,38 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
case 1: case 1:
headerNode = ( headerNode = (
<Text bold color={theme.text.link}> <Text bold color={theme.text.link}>
<RenderInline text={headerText} /> <RenderInline text={headerText} defaultColor={theme.text.link} />
</Text> </Text>
); );
break; break;
case 2: case 2:
headerNode = ( headerNode = (
<Text bold color={theme.text.link}> <Text bold color={theme.text.link}>
<RenderInline text={headerText} /> <RenderInline text={headerText} defaultColor={theme.text.link} />
</Text> </Text>
); );
break; break;
case 3: case 3:
headerNode = ( headerNode = (
<Text bold color={theme.text.primary}> <Text bold color={responseColor}>
<RenderInline text={headerText} /> <RenderInline text={headerText} defaultColor={responseColor} />
</Text> </Text>
); );
break; break;
case 4: case 4:
headerNode = ( headerNode = (
<Text italic color={theme.text.secondary}> <Text italic color={theme.text.secondary}>
<RenderInline text={headerText} /> <RenderInline
text={headerText}
defaultColor={theme.text.secondary}
/>
</Text> </Text>
); );
break; break;
default: default:
headerNode = ( headerNode = (
<Text color={theme.text.primary}> <Text color={responseColor}>
<RenderInline text={headerText} /> <RenderInline text={headerText} defaultColor={responseColor} />
</Text> </Text>
); );
break; break;
@@ -268,8 +272,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
} else { } else {
addContentBlock( addContentBlock(
<Box key={key}> <Box key={key}>
<Text wrap="wrap" color={theme.text.primary}> <Text wrap="wrap" color={responseColor}>
<RenderInline text={line} /> <RenderInline text={line} defaultColor={responseColor} />
</Text> </Text>
</Box>, </Box>,
); );
@@ -401,6 +405,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
const prefixWidth = prefix.length; const prefixWidth = prefix.length;
const indentation = leadingWhitespace.length; const indentation = leadingWhitespace.length;
const listResponseColor = theme.text.response ?? theme.text.primary;
return ( return (
<Box <Box
@@ -408,11 +413,11 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
flexDirection="row" flexDirection="row"
> >
<Box width={prefixWidth}> <Box width={prefixWidth}>
<Text color={theme.text.primary}>{prefix}</Text> <Text color={listResponseColor}>{prefix}</Text>
</Box> </Box>
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}> <Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
<Text wrap="wrap" color={theme.text.primary}> <Text wrap="wrap" color={listResponseColor}>
<RenderInline text={itemText} /> <RenderInline text={itemText} defaultColor={listResponseColor} />
</Text> </Text>
</Box> </Box>
</Box> </Box>