feat: add highlighting to user prompt messages (#714)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
Message,
|
||||
TextContent,
|
||||
} from "@letta-ai/letta-client/resources/agents/messages";
|
||||
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants";
|
||||
import type { Buffers } from "./accumulator";
|
||||
|
||||
/**
|
||||
@@ -25,20 +26,57 @@ function getDisplayableToolReturn(
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// const PASTE_LINE_THRESHOLD = 5;
|
||||
// const PASTE_CHAR_THRESHOLD = 500;
|
||||
const CLIP_CHAR_LIMIT_TEXT = 500;
|
||||
// const CLIP_CHAR_LIMIT_JSON = 1000;
|
||||
|
||||
// function countLines(text: string): number {
|
||||
// return (text.match(/\r\n|\r|\n/g) || []).length + 1;
|
||||
// }
|
||||
|
||||
function clip(s: string, limit: number): string {
|
||||
if (!s) return "";
|
||||
return s.length > limit ? `${s.slice(0, limit)}…` : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line endings: convert \r\n and \r to \n
|
||||
*/
|
||||
function normalizeLineEndings(s: string): string {
|
||||
return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate system-reminder content while preserving opening/closing tags.
|
||||
* Removes the middle content and replaces with [...] to keep the message compact
|
||||
* but with proper tag structure.
|
||||
*/
|
||||
function truncateSystemReminder(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
|
||||
const openIdx = text.indexOf(SYSTEM_REMINDER_OPEN);
|
||||
const closeIdx = text.lastIndexOf(SYSTEM_REMINDER_CLOSE);
|
||||
|
||||
if (openIdx === -1 || closeIdx === -1 || closeIdx <= openIdx) {
|
||||
// Malformed, just use regular clip
|
||||
return clip(text, maxLength);
|
||||
}
|
||||
|
||||
const openEnd = openIdx + SYSTEM_REMINDER_OPEN.length;
|
||||
const ellipsis = "\n...\n";
|
||||
|
||||
// Calculate available space for content (split between start and end)
|
||||
const overhead =
|
||||
SYSTEM_REMINDER_OPEN.length +
|
||||
SYSTEM_REMINDER_CLOSE.length +
|
||||
ellipsis.length;
|
||||
const availableContent = maxLength - overhead;
|
||||
if (availableContent <= 0) {
|
||||
// Not enough space, just show tags with ellipsis
|
||||
return `${SYSTEM_REMINDER_OPEN}${ellipsis}${SYSTEM_REMINDER_CLOSE}`;
|
||||
}
|
||||
|
||||
const halfContent = Math.floor(availableContent / 2);
|
||||
const contentStart = text.slice(openEnd, openEnd + halfContent);
|
||||
const contentEnd = text.slice(closeIdx - halfContent, closeIdx);
|
||||
|
||||
return `${SYSTEM_REMINDER_OPEN}${contentStart}${ellipsis}${contentEnd}${SYSTEM_REMINDER_CLOSE}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user message is a compaction summary (system_alert with summary content).
|
||||
* Returns the summary text if found, null otherwise.
|
||||
@@ -80,24 +118,45 @@ function renderAssistantContentParts(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text is purely a system-reminder block (no user content before/after).
|
||||
*/
|
||||
function isOnlySystemReminder(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
return (
|
||||
trimmed.startsWith(SYSTEM_REMINDER_OPEN) &&
|
||||
trimmed.endsWith(SYSTEM_REMINDER_CLOSE)
|
||||
);
|
||||
}
|
||||
|
||||
function renderUserContentParts(
|
||||
parts: string | LettaUserMessageContentUnion[],
|
||||
): string {
|
||||
// UserContent can be a string or an array of text OR image parts
|
||||
// for text parts, we clip them if they're too big (eg copy-pasted chunks)
|
||||
// for image parts, we just show a placeholder
|
||||
// Pure system-reminder parts are truncated (middle) to preserve tags
|
||||
// Mixed content or user text uses simple end truncation
|
||||
// Parts are joined with newlines so each appears as a separate line
|
||||
if (typeof parts === "string") return parts;
|
||||
|
||||
let out = "";
|
||||
const rendered: string[] = [];
|
||||
for (const p of parts) {
|
||||
if (p.type === "text") {
|
||||
const text = p.text || "";
|
||||
out += clip(text, CLIP_CHAR_LIMIT_TEXT);
|
||||
// Normalize line endings (\r\n and \r -> \n) to prevent terminal garbling
|
||||
const normalized = normalizeLineEndings(text);
|
||||
if (isOnlySystemReminder(normalized)) {
|
||||
// Pure system-reminder: truncate middle to preserve tags
|
||||
rendered.push(truncateSystemReminder(normalized, CLIP_CHAR_LIMIT_TEXT));
|
||||
} else {
|
||||
// User content or mixed: simple end truncation
|
||||
rendered.push(clip(normalized, CLIP_CHAR_LIMIT_TEXT));
|
||||
}
|
||||
} else if (p.type === "image") {
|
||||
out += `[Image]`;
|
||||
rendered.push("[Image]");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
// Join with double-newline so each part starts a new paragraph (gets "> " prefix)
|
||||
return rendered.join("\n\n");
|
||||
}
|
||||
|
||||
export function backfillBuffers(buffers: Buffers, history: Message[]): void {
|
||||
|
||||
176
src/cli/helpers/terminalTheme.ts
Normal file
176
src/cli/helpers/terminalTheme.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
export type TerminalTheme = "light" | "dark";
|
||||
|
||||
// Cache for the detected theme
|
||||
let cachedTheme: TerminalTheme | null = null;
|
||||
|
||||
/**
|
||||
* Normalize a hex color component of any length to 8-bit (0-255).
|
||||
* OSC 11 responses may return 1, 2, 3, or 4 hex digits per component.
|
||||
*/
|
||||
export function parseHexComponent(hex: string): number {
|
||||
const value = parseInt(hex, 16);
|
||||
const maxForLength = (1 << (hex.length * 4)) - 1;
|
||||
return Math.round((value / maxForLength) * 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query terminal background color using OSC 11 escape sequence.
|
||||
* Returns the RGB values or null if query fails/times out.
|
||||
*
|
||||
* OSC 11 query: \x1b]11;?\x1b\\ or \x1b]11;?\x07
|
||||
* Response: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ (or \x07 terminator)
|
||||
*
|
||||
* IMPORTANT: Must be called before ink takes control of stdin.
|
||||
*/
|
||||
async function queryTerminalBackground(
|
||||
timeoutMs = 100,
|
||||
): Promise<{ r: number; g: number; b: number } | null> {
|
||||
// Skip if not a TTY
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Capture initial stdin state to restore it reliably
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
const wasFlowing = process.stdin.readableFlowing;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let response = "";
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
try {
|
||||
process.stdin.removeListener("data", onData);
|
||||
// Restore stdin to its original state
|
||||
process.stdin.setRawMode?.(wasRaw ?? false);
|
||||
if (!wasFlowing) {
|
||||
process.stdin.pause();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors — stdin may already be in a valid state
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
response += data.toString();
|
||||
|
||||
// Look for OSC 11 response: ESC]11;rgb:RRRR/GGGG/BBBB followed by ESC\ or BEL
|
||||
// Build regex with ESC character to avoid lint warning about control chars in literals
|
||||
const ESC = "\x1b";
|
||||
const oscPattern = new RegExp(
|
||||
`${ESC}\\]11;rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)`,
|
||||
);
|
||||
const match = response.match(oscPattern);
|
||||
if (match) {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
|
||||
resolve({
|
||||
r: parseHexComponent(match[1] ?? "0"),
|
||||
g: parseHexComponent(match[2] ?? "0"),
|
||||
b: parseHexComponent(match[3] ?? "0"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Set raw mode to capture response
|
||||
process.stdin.setRawMode?.(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.on("data", onData);
|
||||
|
||||
// Send OSC 11 query (using ST terminator \x1b\\)
|
||||
process.stdout.write("\x1b]11;?\x1b\\");
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate perceived luminance using relative luminance formula.
|
||||
* Returns value between 0 (black) and 1 (white).
|
||||
* Using sRGB to linear conversion and ITU-R BT.709 coefficients.
|
||||
*/
|
||||
export function calculateLuminance(r: number, g: number, b: number): number {
|
||||
// Normalize to 0-1
|
||||
const toLinear = (c: number): number => {
|
||||
const s = c / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
const rLin = toLinear(r);
|
||||
const gLin = toLinear(g);
|
||||
const bLin = toLinear(b);
|
||||
|
||||
// ITU-R BT.709 coefficients
|
||||
return 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect terminal theme using OSC 11 query.
|
||||
* Falls back to COLORFGBG env var, then defaults to dark.
|
||||
*/
|
||||
export async function detectTerminalThemeAsync(): Promise<TerminalTheme> {
|
||||
// Try OSC 11 query first
|
||||
const bg = await queryTerminalBackground(100);
|
||||
if (bg) {
|
||||
const luminance = calculateLuminance(bg.r, bg.g, bg.b);
|
||||
// Threshold: 0.5 is mid-gray, but use 0.4 to be more conservative
|
||||
// (most "light" themes have luminance > 0.7)
|
||||
return luminance > 0.4 ? "light" : "dark";
|
||||
}
|
||||
|
||||
// Fall back to COLORFGBG env var
|
||||
const colorfgbg = process.env.COLORFGBG;
|
||||
if (colorfgbg) {
|
||||
const parts = colorfgbg.split(";");
|
||||
const bgIdx = parseInt(parts[parts.length - 1] || "0", 10);
|
||||
if (bgIdx === 7 || bgIdx === 15) return "light";
|
||||
}
|
||||
|
||||
// Default to dark (most common terminal theme)
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous theme detection using only COLORFGBG.
|
||||
* Use detectTerminalThemeAsync() for more accurate OSC 11 detection.
|
||||
*/
|
||||
export function detectTerminalThemeSync(): TerminalTheme {
|
||||
const colorfgbg = process.env.COLORFGBG;
|
||||
if (colorfgbg) {
|
||||
const parts = colorfgbg.split(";");
|
||||
const bg = parseInt(parts[parts.length - 1] || "0", 10);
|
||||
if (bg === 7 || bg === 15) return "light";
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached terminal theme, or detect synchronously if not yet cached.
|
||||
* Call initTerminalTheme() early in app startup for async detection.
|
||||
*/
|
||||
export function getTerminalTheme(): TerminalTheme {
|
||||
if (cachedTheme) return cachedTheme;
|
||||
cachedTheme = detectTerminalThemeSync();
|
||||
return cachedTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize terminal theme detection asynchronously.
|
||||
* Should be called early in app startup before UI renders.
|
||||
*/
|
||||
export async function initTerminalTheme(): Promise<TerminalTheme> {
|
||||
cachedTheme = await detectTerminalThemeAsync();
|
||||
return cachedTheme;
|
||||
}
|
||||
@@ -313,6 +313,10 @@ async function getPinnedAgentNames(): Promise<{ id: string; name: string }[]> {
|
||||
async function main(): Promise<void> {
|
||||
markMilestone("CLI_START");
|
||||
|
||||
// Initialize terminal theme detection (OSC 11 query with fallback)
|
||||
const { initTerminalTheme } = await import("./cli/helpers/terminalTheme");
|
||||
await initTerminalTheme();
|
||||
|
||||
// Initialize settings manager (loads settings once into memory)
|
||||
await settingsManager.initialize();
|
||||
const settings = await settingsManager.getSettingsWithSecureTokens();
|
||||
|
||||
101
src/tests/helpers/terminalTheme.test.ts
Normal file
101
src/tests/helpers/terminalTheme.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
calculateLuminance,
|
||||
detectTerminalThemeSync,
|
||||
parseHexComponent,
|
||||
} from "../../cli/helpers/terminalTheme";
|
||||
|
||||
describe("parseHexComponent", () => {
|
||||
test("parses 2-digit hex (standard 8-bit)", () => {
|
||||
expect(parseHexComponent("00")).toBe(0);
|
||||
expect(parseHexComponent("ff")).toBe(255);
|
||||
expect(parseHexComponent("80")).toBe(128);
|
||||
expect(parseHexComponent("7f")).toBe(127);
|
||||
});
|
||||
|
||||
test("parses 4-digit hex (16-bit) and normalizes to 8-bit", () => {
|
||||
expect(parseHexComponent("0000")).toBe(0);
|
||||
expect(parseHexComponent("ffff")).toBe(255);
|
||||
// 8000/ffff * 255 ≈ 128
|
||||
expect(parseHexComponent("8000")).toBe(128);
|
||||
});
|
||||
|
||||
test("parses 1-digit hex and normalizes to 8-bit", () => {
|
||||
expect(parseHexComponent("0")).toBe(0);
|
||||
expect(parseHexComponent("f")).toBe(255);
|
||||
// 8/15 * 255 = 136
|
||||
expect(parseHexComponent("8")).toBe(136);
|
||||
});
|
||||
|
||||
test("parses 3-digit hex and normalizes to 8-bit", () => {
|
||||
expect(parseHexComponent("000")).toBe(0);
|
||||
expect(parseHexComponent("fff")).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateLuminance", () => {
|
||||
test("returns 0 for pure black", () => {
|
||||
expect(calculateLuminance(0, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
test("returns ~1 for pure white", () => {
|
||||
expect(calculateLuminance(255, 255, 255)).toBeCloseTo(1, 2);
|
||||
});
|
||||
|
||||
test("red has higher luminance than blue", () => {
|
||||
const redLum = calculateLuminance(255, 0, 0);
|
||||
const blueLum = calculateLuminance(0, 0, 255);
|
||||
expect(redLum).toBeGreaterThan(blueLum);
|
||||
});
|
||||
|
||||
test("green contributes most to luminance (BT.709)", () => {
|
||||
const redLum = calculateLuminance(255, 0, 0);
|
||||
const greenLum = calculateLuminance(0, 255, 0);
|
||||
const blueLum = calculateLuminance(0, 0, 255);
|
||||
expect(greenLum).toBeGreaterThan(redLum);
|
||||
expect(greenLum).toBeGreaterThan(blueLum);
|
||||
});
|
||||
|
||||
test("mid-gray has luminance around 0.2", () => {
|
||||
// sRGB mid-gray (128, 128, 128) has relative luminance ~0.216
|
||||
const lum = calculateLuminance(128, 128, 128);
|
||||
expect(lum).toBeGreaterThan(0.19);
|
||||
expect(lum).toBeLessThan(0.23);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectTerminalThemeSync", () => {
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
originalEnv = process.env.COLORFGBG;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.COLORFGBG = originalEnv;
|
||||
} else {
|
||||
delete process.env.COLORFGBG;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns 'dark' when COLORFGBG is not set", () => {
|
||||
delete process.env.COLORFGBG;
|
||||
expect(detectTerminalThemeSync()).toBe("dark");
|
||||
});
|
||||
|
||||
test("returns 'light' when COLORFGBG background is 7", () => {
|
||||
process.env.COLORFGBG = "0;7";
|
||||
expect(detectTerminalThemeSync()).toBe("light");
|
||||
});
|
||||
|
||||
test("returns 'light' when COLORFGBG background is 15", () => {
|
||||
process.env.COLORFGBG = "0;15";
|
||||
expect(detectTerminalThemeSync()).toBe("light");
|
||||
});
|
||||
|
||||
test("returns 'dark' when COLORFGBG background is 0", () => {
|
||||
process.env.COLORFGBG = "15;0";
|
||||
expect(detectTerminalThemeSync()).toBe("dark");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user