193 lines
5.5 KiB
TypeScript
193 lines
5.5 KiB
TypeScript
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 <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_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 <Text>{allLines.join("\n")}</Text>;
|
|
});
|
|
|
|
UserMessage.displayName = "UserMessage";
|