import { memo } from "react"; import stringWidth from "string-width"; import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors, hexToBgAnsi, hexToFgAnsi } from "./colors"; import { Text } from "./Text"; type UserLine = { kind: "user"; id: string; text: string; }; /** * 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 = SYSTEM_REMINDER_OPEN; const tagClose = SYSTEM_REMINDER_CLOSE; 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 * * 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(1, columns - 2); // 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";