feat: add highlighting to user prompt messages (#714)

This commit is contained in:
jnjpng
2026-01-28 14:35:19 -08:00
committed by GitHub
parent 583ce7b395
commit 03db8545ad
8 changed files with 730 additions and 70 deletions

View File

@@ -5,6 +5,7 @@ import { colors } from "./colors.js";
interface InlineMarkdownProps {
text: string;
dimColor?: boolean;
backgroundColor?: string;
}
/**
@@ -15,6 +16,7 @@ interface InlineMarkdownProps {
export const InlineMarkdown: React.FC<InlineMarkdownProps> = ({
text,
dimColor,
backgroundColor,
}) => {
// Early return for plain text without markdown (treat underscores as plain text)
if (!/[*~`[]/.test(text)) {
@@ -47,7 +49,12 @@ export const InlineMarkdown: React.FC<InlineMarkdownProps> = ({
) {
// Bold
nodes.push(
<Text key={key} bold dimColor={dimColor}>
<Text
key={key}
bold
dimColor={dimColor}
backgroundColor={backgroundColor}
>
{fullMatch.slice(2, -2)}
</Text>,
);
@@ -58,7 +65,12 @@ export const InlineMarkdown: React.FC<InlineMarkdownProps> = ({
) {
// Italic
nodes.push(
<Text key={key} italic dimColor={dimColor}>
<Text
key={key}
italic
dimColor={dimColor}
backgroundColor={backgroundColor}
>
{fullMatch.slice(1, -1)}
</Text>,
);
@@ -69,14 +81,23 @@ export const InlineMarkdown: React.FC<InlineMarkdownProps> = ({
) {
// Strikethrough
nodes.push(
<Text key={key} strikethrough dimColor={dimColor}>
<Text
key={key}
strikethrough
dimColor={dimColor}
backgroundColor={backgroundColor}
>
{fullMatch.slice(2, -2)}
</Text>,
);
} else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) {
// Inline code
nodes.push(
<Text key={key} color={colors.link.text}>
<Text
key={key}
color={colors.link.text}
backgroundColor={backgroundColor}
>
{fullMatch.slice(1, -1)}
</Text>,
);
@@ -91,9 +112,12 @@ export const InlineMarkdown: React.FC<InlineMarkdownProps> = ({
const linkText = linkMatch[1];
const url = linkMatch[2];
nodes.push(
<Text key={key}>
<Text key={key} backgroundColor={backgroundColor}>
{linkText}
<Text color={colors.link.url}> ({url})</Text>
<Text color={colors.link.url} backgroundColor={backgroundColor}>
{" "}
({url})
</Text>
</Text>,
);
} else {

View File

@@ -1,12 +1,15 @@
import { Box, Text, Transform } from "ink";
import type React from "react";
import { colors } from "./colors.js";
import stringWidth from "string-width";
import { colors, hexToBgAnsi } from "./colors.js";
import { InlineMarkdown } from "./InlineMarkdownRenderer.js";
interface MarkdownDisplayProps {
text: string;
dimColor?: boolean;
hangingIndent?: number; // indent for wrapped lines within a paragraph
backgroundColor?: string; // background color for all text
contentWidth?: number; // available width — used to pad lines to fill background
}
// Regex patterns for markdown elements (defined outside component to avoid re-creation)
@@ -38,9 +41,23 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
text,
dimColor,
hangingIndent = 0,
backgroundColor,
contentWidth,
}) => {
if (!text) return null;
// Build ANSI background code and line-padding helper for full-width backgrounds.
// Transform callbacks receive already-rendered text (with ANSI codes from child Text
// components), so appended spaces need their own ANSI background coloring.
const bgAnsi = backgroundColor ? hexToBgAnsi(backgroundColor) : "";
const padLine = (ln: string): string => {
if (!contentWidth || !backgroundColor) return ln;
const visWidth = stringWidth(ln);
const pad = Math.max(0, contentWidth - visWidth);
if (pad <= 0) return ln;
return `${ln}${bgAnsi}${" ".repeat(pad)}\x1b[0m`;
};
const lines = text.split("\n");
const contentBlocks: React.ReactNode[] = [];
@@ -78,26 +95,35 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
<Box key={`table-${startIndex}`} flexDirection="column" marginY={0}>
{/* Header row */}
<Box flexDirection="row">
<Text dimColor={dimColor}></Text>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
</Text>
{headerRow.map((cell, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static table content
<Box key={`h-${idx}`} flexDirection="row">
<Text bold dimColor={dimColor}>
<Text bold dimColor={dimColor} backgroundColor={backgroundColor}>
{" "}
{cell.padEnd(colWidths[idx] ?? 3)}
</Text>
<Text dimColor={dimColor}> </Text>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
{" "}
</Text>
</Box>
))}
</Box>
{/* Separator */}
<Box flexDirection="row">
<Text dimColor={dimColor}></Text>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
</Text>
{colWidths.map((width, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static table content
<Box key={`s-${idx}`} flexDirection="row">
<Text dimColor={dimColor}>{"─".repeat(width + 2)}</Text>
<Text dimColor={dimColor}>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
{"─".repeat(width + 2)}
</Text>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
{idx < colWidths.length - 1 ? "┼" : "┤"}
</Text>
</Box>
@@ -107,15 +133,20 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
{bodyRows.map((row, rowIdx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static table content
<Box key={`r-${rowIdx}`} flexDirection="row">
<Text dimColor={dimColor}></Text>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
</Text>
{row.map((cell, colIdx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static table content
<Box key={`c-${colIdx}`} flexDirection="row">
<Text dimColor={dimColor}>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
{" "}
{(cell || "").padEnd(colWidths[colIdx] || 3)}
</Text>
<Text dimColor={dimColor}> </Text>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
{" "}
</Text>
</Box>
))}
</Box>
@@ -140,7 +171,10 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
const code = codeBlockContent.join("\n");
contentBlocks.push(
<Box key={key} paddingLeft={2}>
<Text color={colors.code.inline}>{code}</Text>
<Text color={colors.code.inline} backgroundColor={backgroundColor}>
{code}
{backgroundColor ? " " : null}
</Text>
</Box>,
);
codeBlockContent = [];
@@ -165,8 +199,13 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
contentBlocks.push(
<Box key={key}>
<Text {...style}>
<InlineMarkdown text={content} dimColor={dimColor} />
<Text {...style} backgroundColor={backgroundColor}>
<InlineMarkdown
text={content}
dimColor={dimColor}
backgroundColor={backgroundColor}
/>
{backgroundColor ? " " : null}
</Text>
</Box>,
);
@@ -193,11 +232,22 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
contentBlocks.push(
<Box key={key} paddingLeft={indent} flexDirection="row">
<Box width={bulletWidth} flexShrink={0}>
<Text dimColor={dimColor}>{bullet}</Text>
<Text dimColor={dimColor} backgroundColor={backgroundColor}>
{bullet}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" dimColor={dimColor}>
<InlineMarkdown text={content} dimColor={dimColor} />
<Text
wrap="wrap"
dimColor={dimColor}
backgroundColor={backgroundColor}
>
<InlineMarkdown
text={content}
dimColor={dimColor}
backgroundColor={backgroundColor}
/>
{backgroundColor ? " " : null}
</Text>
</Box>
</Box>,
@@ -211,9 +261,20 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
if (blockquoteMatch && blockquoteMatch[1] !== undefined) {
contentBlocks.push(
<Box key={key} paddingLeft={2}>
<Text dimColor> </Text>
<Text wrap="wrap" dimColor={dimColor}>
<InlineMarkdown text={blockquoteMatch[1]} dimColor={dimColor} />
<Text dimColor backgroundColor={backgroundColor}>
{" "}
</Text>
<Text
wrap="wrap"
dimColor={dimColor}
backgroundColor={backgroundColor}
>
<InlineMarkdown
text={blockquoteMatch[1]}
dimColor={dimColor}
backgroundColor={backgroundColor}
/>
{backgroundColor ? " " : null}
</Text>
</Box>,
);
@@ -225,7 +286,9 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
if (line.match(hrRegex)) {
contentBlocks.push(
<Box key={key}>
<Text dimColor></Text>
<Text dimColor backgroundColor={backgroundColor}>
</Text>
</Box>,
);
index++;
@@ -261,27 +324,58 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
// Empty lines
if (line.trim() === "") {
contentBlocks.push(<Box key={key} height={1} />);
if (backgroundColor) {
// Render a visible space so outer Transform can pad this line
contentBlocks.push(
<Box key={key}>
<Text backgroundColor={backgroundColor}> </Text>
</Box>,
);
} else {
contentBlocks.push(<Box key={key} height={1} />);
}
index++;
continue;
}
// Regular paragraph text with optional hanging indent for wrapped lines
// Regular paragraph text with optional hanging indent and line padding
const needsTransform =
hangingIndent > 0 || (contentWidth && backgroundColor);
contentBlocks.push(
<Box key={key}>
{hangingIndent > 0 ? (
{needsTransform ? (
<Transform
transform={(ln, i) =>
i === 0 ? ln : " ".repeat(hangingIndent) + ln
}
transform={(ln, i) => {
const indented =
hangingIndent > 0 && i > 0
? " ".repeat(hangingIndent) + ln
: ln;
return padLine(indented);
}}
>
<Text wrap="wrap" dimColor={dimColor}>
<InlineMarkdown text={line} dimColor={dimColor} />
<Text
wrap="wrap"
dimColor={dimColor}
backgroundColor={backgroundColor}
>
<InlineMarkdown
text={line}
dimColor={dimColor}
backgroundColor={backgroundColor}
/>
</Text>
</Transform>
) : (
<Text wrap="wrap" dimColor={dimColor}>
<InlineMarkdown text={line} dimColor={dimColor} />
<Text
wrap="wrap"
dimColor={dimColor}
backgroundColor={backgroundColor}
>
<InlineMarkdown
text={line}
dimColor={dimColor}
backgroundColor={backgroundColor}
/>
</Text>
)}
</Box>,
@@ -294,7 +388,10 @@ export const MarkdownDisplay: React.FC<MarkdownDisplayProps> = ({
const code = codeBlockContent.join("\n");
contentBlocks.push(
<Box key="unclosed-code" paddingLeft={2}>
<Text color={colors.code.inline}>{code}</Text>
<Text color={colors.code.inline} backgroundColor={backgroundColor}>
{code}
{backgroundColor ? " " : null}
</Text>
</Box>,
);
}

View File

@@ -1,7 +1,8 @@
import { Box, Text } from "ink";
import { Text } from "ink";
import { memo } from "react";
import stringWidth from "string-width";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { MarkdownDisplay } from "./MarkdownDisplay.js";
import { colors, hexToBgAnsi, hexToFgAnsi } from "./colors";
type UserLine = {
kind: "user";
@@ -10,28 +11,181 @@ type UserLine = {
};
/**
* UserMessageRich - Rich formatting version with two-column layout
* This is a direct port from the old letta-code codebase to preserve the exact styling
* Word-wrap plain text to a given visible width.
* Returns an array of lines, each at most `width` visible characters wide.
*/
function wordWrap(text: string, width: number): string[] {
if (width <= 0) return [text];
const words = text.split(" ");
const lines: string[] = [];
let current = "";
for (const word of words) {
if (current === "") {
current = word;
} else {
const candidate = `${current} ${word}`;
if (stringWidth(candidate) <= width) {
current = candidate;
} else {
lines.push(current);
current = word;
}
}
}
if (current !== "") {
lines.push(current);
}
return lines.length > 0 ? lines : [""];
}
/** Right-padding (in characters) added after content on compact (single-line) messages. */
const COMPACT_PAD = 1;
/**
* Split text into system-reminder blocks and user content blocks.
* System-reminder blocks are identified by <system-reminder>...</system-reminder> tags.
* Returns array of { text, isSystemReminder } objects in order.
*/
function splitSystemReminderBlocks(
text: string,
): Array<{ text: string; isSystemReminder: boolean }> {
const blocks: Array<{ text: string; isSystemReminder: boolean }> = [];
const tagOpen = "<system-reminder>";
const tagClose = "</system-reminder>";
let remaining = text;
while (remaining.length > 0) {
const openIdx = remaining.indexOf(tagOpen);
if (openIdx === -1) {
// No more system-reminder tags, rest is user content
if (remaining.trim()) {
blocks.push({ text: remaining.trim(), isSystemReminder: false });
}
break;
}
// Content before the tag is user content
if (openIdx > 0) {
const before = remaining.slice(0, openIdx).trim();
if (before) {
blocks.push({ text: before, isSystemReminder: false });
}
}
// Find the closing tag
const closeIdx = remaining.indexOf(tagClose, openIdx);
if (closeIdx === -1) {
// Malformed - no closing tag, treat rest as system-reminder
blocks.push({
text: remaining.slice(openIdx).trim(),
isSystemReminder: true,
});
break;
}
// Extract the full system-reminder block (including tags)
const sysBlock = remaining.slice(openIdx, closeIdx + tagClose.length);
blocks.push({ text: sysBlock, isSystemReminder: true });
remaining = remaining.slice(closeIdx + tagClose.length);
}
return blocks;
}
/**
* Render a block of text with "> " prefix (first line) and " " continuation.
* If highlighted, applies background and foreground colors. Otherwise plain text.
*/
function renderBlock(
text: string,
contentWidth: number,
columns: number,
highlighted: boolean,
colorAnsi: string, // combined bg + fg ANSI codes
): string[] {
const inputLines = text.split("\n");
const outputLines: string[] = [];
for (const inputLine of inputLines) {
if (inputLine.trim() === "") {
outputLines.push("");
continue;
}
const wrappedLines = wordWrap(inputLine, contentWidth);
for (const wl of wrappedLines) {
outputLines.push(wl);
}
}
if (outputLines.length === 0) return [];
const isSingleLine = outputLines.length === 1;
return outputLines.map((ol, i) => {
const prefix = i === 0 ? "> " : " ";
const content = prefix + ol;
if (!highlighted) {
return content;
}
const visWidth = stringWidth(content);
if (isSingleLine) {
return `${colorAnsi}${content}${" ".repeat(COMPACT_PAD)}\x1b[0m`;
}
const pad = Math.max(0, columns - visWidth);
return `${colorAnsi}${content}${" ".repeat(pad)}\x1b[0m`;
});
}
/**
* UserMessageRich - Rich formatting for user messages with background highlight
*
* Features:
* - Left column (2 chars wide) with "> " prompt indicator
* - Right column with wrapped text content
* - Full markdown rendering support
* Renders user messages as pre-formatted text with ANSI background codes:
* - "> " prompt prefix on first line, " " continuation on subsequent lines
* - Single-line messages: compact highlight (content + small padding)
* - Multi-line messages: full-width highlight box extending to terminal edge
* - Word wrapping respects the 2-char prefix width
* - System-reminder parts are shown plain (no highlight), user parts highlighted
*/
export const UserMessage = memo(({ line }: { line: UserLine }) => {
const columns = useTerminalWidth();
const contentWidth = Math.max(0, columns - 2);
const contentWidth = Math.max(1, columns - 2);
return (
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text>{">"} </Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<MarkdownDisplay text={line.text} />
</Box>
</Box>
);
// Build combined ANSI code for background + optional foreground
const { background, text: textColor } = colors.userMessage;
const bgAnsi = hexToBgAnsi(background);
const fgAnsi = textColor ? hexToFgAnsi(textColor) : "";
const colorAnsi = bgAnsi + fgAnsi;
// Split into system-reminder blocks and user content blocks
const blocks = splitSystemReminderBlocks(line.text);
const allLines: string[] = [];
for (const block of blocks) {
if (!block.text.trim()) continue;
// Add blank line between blocks (not before first)
if (allLines.length > 0) {
allLines.push("");
}
const blockLines = renderBlock(
block.text,
contentWidth,
columns,
!block.isSystemReminder, // highlight user content, not system-reminder
colorAnsi,
);
allLines.push(...blockLines);
}
return <Text>{allLines.join("\n")}</Text>;
});
UserMessage.displayName = "UserMessage";

View File

@@ -5,6 +5,36 @@
* No colors should be hardcoded in components - all should reference this file.
*/
import { getTerminalTheme } from "../helpers/terminalTheme";
/**
* Parse a hex color (#RRGGBB) to RGB components.
*/
function parseHex(hex: string): { r: number; g: number; b: number } {
const h = hex.replace("#", "");
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
};
}
/**
* Convert a hex color (#RRGGBB) to an ANSI 24-bit background escape sequence.
*/
export function hexToBgAnsi(hex: string): string {
const { r, g, b } = parseHex(hex);
return `\x1b[48;2;${r};${g};${b}m`;
}
/**
* Convert a hex color (#RRGGBB) to an ANSI 24-bit foreground escape sequence.
*/
export function hexToFgAnsi(hex: string): string {
const { r, g, b } = parseHex(hex);
return `\x1b[38;2;${r};${g};${b}m`;
}
// Brand colors (dark mode)
export const brandColors = {
orange: "#FF5533", // dark orange
@@ -38,7 +68,7 @@ export const brandColorsLight = {
} as const;
// Semantic color system
export const colors = {
const _colors = {
// Welcome screen
welcome: {
border: brandColors.primaryAccent,
@@ -169,3 +199,18 @@ export const colors = {
agentName: brandColors.primaryAccent,
},
} as const;
// Combine static colors with theme-aware dynamic properties
export const colors = {
..._colors,
// User messages (past prompts) - theme-aware background
// Uses getter to read theme at render time (after async init)
get userMessage() {
const theme = getTerminalTheme();
return {
background: theme === "light" ? "#dcddf2" : "#ffffff", // light purple for light, white for dark
text: theme === "light" ? undefined : "#000000", // black text for dark terminals
};
},
};