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
};
},
};

View File

@@ -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 {

View 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;
}

View File

@@ -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();

View 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");
});
});