diff --git a/src/cli/components/InlineMarkdownRenderer.tsx b/src/cli/components/InlineMarkdownRenderer.tsx index 1b460d3..7afee01 100644 --- a/src/cli/components/InlineMarkdownRenderer.tsx +++ b/src/cli/components/InlineMarkdownRenderer.tsx @@ -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 = ({ 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 = ({ ) { // Bold nodes.push( - + {fullMatch.slice(2, -2)} , ); @@ -58,7 +65,12 @@ export const InlineMarkdown: React.FC = ({ ) { // Italic nodes.push( - + {fullMatch.slice(1, -1)} , ); @@ -69,14 +81,23 @@ export const InlineMarkdown: React.FC = ({ ) { // Strikethrough nodes.push( - + {fullMatch.slice(2, -2)} , ); } else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) { // Inline code nodes.push( - + {fullMatch.slice(1, -1)} , ); @@ -91,9 +112,12 @@ export const InlineMarkdown: React.FC = ({ const linkText = linkMatch[1]; const url = linkMatch[2]; nodes.push( - + {linkText} - ({url}) + + {" "} + ({url}) + , ); } else { diff --git a/src/cli/components/MarkdownDisplay.tsx b/src/cli/components/MarkdownDisplay.tsx index bce517c..7b44edf 100644 --- a/src/cli/components/MarkdownDisplay.tsx +++ b/src/cli/components/MarkdownDisplay.tsx @@ -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 = ({ 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 = ({ {/* Header row */} - + + │ + {headerRow.map((cell, idx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static table content - + {" "} {cell.padEnd(colWidths[idx] ?? 3)} - + + {" "} + │ + ))} {/* Separator */} - + + ├ + {colWidths.map((width, idx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static table content - {"─".repeat(width + 2)} - + + {"─".repeat(width + 2)} + + {idx < colWidths.length - 1 ? "┼" : "┤"} @@ -107,15 +133,20 @@ export const MarkdownDisplay: React.FC = ({ {bodyRows.map((row, rowIdx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static table content - + + │ + {row.map((cell, colIdx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static table content - + {" "} {(cell || "").padEnd(colWidths[colIdx] || 3)} - + + {" "} + │ + ))} @@ -140,7 +171,10 @@ export const MarkdownDisplay: React.FC = ({ const code = codeBlockContent.join("\n"); contentBlocks.push( - {code} + + {code} + {backgroundColor ? " " : null} + , ); codeBlockContent = []; @@ -165,8 +199,13 @@ export const MarkdownDisplay: React.FC = ({ contentBlocks.push( - - + + + {backgroundColor ? " " : null} , ); @@ -193,11 +232,22 @@ export const MarkdownDisplay: React.FC = ({ contentBlocks.push( - {bullet} + + {bullet} + - - + + + {backgroundColor ? " " : null} , @@ -211,9 +261,20 @@ export const MarkdownDisplay: React.FC = ({ if (blockquoteMatch && blockquoteMatch[1] !== undefined) { contentBlocks.push( - - - + + │{" "} + + + + {backgroundColor ? " " : null} , ); @@ -225,7 +286,9 @@ export const MarkdownDisplay: React.FC = ({ if (line.match(hrRegex)) { contentBlocks.push( - ─────────────────────────────── + + ─────────────────────────────── + , ); index++; @@ -261,27 +324,58 @@ export const MarkdownDisplay: React.FC = ({ // Empty lines if (line.trim() === "") { - contentBlocks.push(); + if (backgroundColor) { + // Render a visible space so outer Transform can pad this line + contentBlocks.push( + + + , + ); + } else { + contentBlocks.push(); + } 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( - {hangingIndent > 0 ? ( + {needsTransform ? ( - i === 0 ? ln : " ".repeat(hangingIndent) + ln - } + transform={(ln, i) => { + const indented = + hangingIndent > 0 && i > 0 + ? " ".repeat(hangingIndent) + ln + : ln; + return padLine(indented); + }} > - - + + ) : ( - - + + )} , @@ -294,7 +388,10 @@ export const MarkdownDisplay: React.FC = ({ const code = codeBlockContent.join("\n"); contentBlocks.push( - {code} + + {code} + {backgroundColor ? " " : null} + , ); } diff --git a/src/cli/components/UserMessageRich.tsx b/src/cli/components/UserMessageRich.tsx index 8e80d83..594fca3 100644 --- a/src/cli/components/UserMessageRich.tsx +++ b/src/cli/components/UserMessageRich.tsx @@ -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 ... 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 = ""; + const tagClose = ""; + + 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 ( - - - {">"} - - - - - - ); + // 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 {allLines.join("\n")}; }); UserMessage.displayName = "UserMessage"; diff --git a/src/cli/components/colors.ts b/src/cli/components/colors.ts index c5fcffc..fa8e08d 100644 --- a/src/cli/components/colors.ts +++ b/src/cli/components/colors.ts @@ -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 + }; + }, +}; diff --git a/src/cli/helpers/backfill.ts b/src/cli/helpers/backfill.ts index e1f0d6f..3f82a9d 100644 --- a/src/cli/helpers/backfill.ts +++ b/src/cli/helpers/backfill.ts @@ -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 { diff --git a/src/cli/helpers/terminalTheme.ts b/src/cli/helpers/terminalTheme.ts new file mode 100644 index 0000000..697c37f --- /dev/null +++ b/src/cli/helpers/terminalTheme.ts @@ -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 { + // 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 { + cachedTheme = await detectTerminalThemeAsync(); + return cachedTheme; +} diff --git a/src/index.ts b/src/index.ts index fbea033..3b94a9b 100755 --- a/src/index.ts +++ b/src/index.ts @@ -313,6 +313,10 @@ async function getPinnedAgentNames(): Promise<{ id: string; name: string }[]> { async function main(): Promise { 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(); diff --git a/src/tests/helpers/terminalTheme.test.ts b/src/tests/helpers/terminalTheme.test.ts new file mode 100644 index 0000000..d564f27 --- /dev/null +++ b/src/tests/helpers/terminalTheme.test.ts @@ -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"); + }); +});