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";