import { UserMessageComponent } from "@earendil-works/pi-coding-agent"; import { loadConfig } from "../config.ts"; import { getPresentationDesign } from "../tools/presentation/state.ts"; import { dropLeadingColumns, fgHex, isHexColor, stripAnsi } from "../theme/ansi.ts"; import { getThemeExtra } from "../theme/theme-extras.ts"; import { safeTruncateToWidth, safeVisibleWidth } from "../render-budget.ts"; let activeTheme: any = null; const PATCHED = Symbol.for("pi-ui.user-prefix.patched"); function usesLegacyQuotePrefix(): boolean { return getThemeExtra(activeTheme, "quoteStyle") === "true" && getThemeExtra(activeTheme, "userPrefix") === "│"; } function colorUserPrefix(text: string): string { const color = usesLegacyQuotePrefix() ? "accent" : getThemeExtra(activeTheme, "userPrefixColor"); if (!activeTheme || !color) return text; if (isHexColor(color)) return fgHex(activeTheme, color, text); try { return typeof activeTheme.fg === "function" ? activeTheme.fg(color, text) : text; } catch { return text; } } function buildPrefixSegment(): string { const configuredChar = getThemeExtra(activeTheme, "userPrefix"); const char = usesLegacyQuotePrefix() ? "❯" : configuredChar; const prefix = colorUserPrefix(char); const design = getPresentationDesign(); if (!design.stripsBackground && typeof activeTheme?.bg === "function") { return activeTheme.bg("userMessageBg", `${prefix} `); } return `${prefix}${design.markerGap}`; } function buildDividerLine(width: number): string { if (width <= 0) return ""; const char = getThemeExtra(activeTheme, "dividerChar"); const color = getThemeExtra(activeTheme, "dividerColor"); const line = char.repeat(width); return activeTheme ? fgHex(activeTheme, color, line) : line; } function stripBackgroundAnsi(text: string): string { return text.replace(/\x1b\[(?:4[0-9]|10[0-7])(?:;[0-9;]*)?m/g, ""); } function stripEmphasisAnsi(text: string): string { return text.replace(/\x1b\[(?:(?:1|3|22|23);)*(?:1|3|22|23)m/g, ""); } function buildContinuationSegment(): string { const char = getThemeExtra(activeTheme, "quoteChar") || "┆"; const prefix = colorUserPrefix(char); const design = getPresentationDesign(); if (!design.stripsBackground && typeof activeTheme?.bg === "function") { return activeTheme.bg("userMessageBg", `${prefix} `); } return `${prefix}${design.markerGap}`; } function alignContinuationLines(lines: string[], targetIndex: number): void { const continuationSegment = buildContinuationSegment(); for (let i = targetIndex + 1; i < lines.length; i++) { const line = lines[i] ?? ""; const clean = stripAnsi(line); if (clean.trim().length === 0) { lines[i] = continuationSegment.trimEnd(); continue; } lines[i] = `${continuationSegment}${dropLeadingColumns(line, 1)}`; } } export function installUserMessagePrefix(theme: any): void { activeTheme = theme; const proto = UserMessageComponent.prototype as any; if (proto[PATCHED] || proto.render?.name === "patchedUserMessageRender") { proto[PATCHED] = true; return; } proto[PATCHED] = true; const baseRender = proto.render; proto.render = function patchedUserMessageRender(width: number): string[] { const lines = baseRender.call(this, width); if (lines.length === 0 || width <= 0) return lines; // Strip leading/trailing blank lines from base render (Spacer + Markdown paddingY) let first = 0; while (first < lines.length && stripAnsi(lines[first] ?? "").trim() === "") first++; let last = lines.length - 1; while (last > first && stripAnsi(lines[last] ?? "").trim() === "") last--; const trimmed = lines.slice(first, last + 1); if (trimmed.length === 0) return lines; const output = [...trimmed]; const design = getPresentationDesign(); // Find first non-empty line to inject prefix let targetIndex = 0; for (let i = 0; i < output.length; i++) { const clean = stripAnsi(output[i] ?? ""); if (clean.trim().length > 0) { targetIndex = i; break; } } const config = loadConfig(); const prefixSegment = config.userPrefix ? buildPrefixSegment() : ""; const line = output[targetIndex] ?? ""; const presentationLine = design.stripsBackground ? stripBackgroundAnsi(line) : line; if (config.userPrefix) { const remainder = stripEmphasisAnsi(dropLeadingColumns(presentationLine, 1)); output[targetIndex] = `${prefixSegment}${remainder}`; alignContinuationLines(output, targetIndex); } else { // No prefix: pad the message inside its background row so the text // doesn't sit on the terminal edge. const padSegment = design.stripsBackground ? " " : typeof activeTheme?.bg === "function" ? activeTheme.bg("userMessageBg", " ") : " "; const padLine = (raw: string) => `${padSegment}${stripEmphasisAnsi(dropLeadingColumns(raw, 1))}`; output[targetIndex] = padLine(presentationLine); for (let i = targetIndex + 1; i < output.length; i++) { const continuation = output[i] ?? ""; if (stripAnsi(continuation).trim().length === 0) continue; output[i] = padLine(continuation); } } const result = output.map((renderedLine) => { const presentationLine = design.stripsBackground ? stripBackgroundAnsi(renderedLine) : renderedLine; const plainLine = stripEmphasisAnsi(presentationLine); return safeVisibleWidth(plainLine) > width ? safeTruncateToWidth(plainLine, width, "") : plainLine; }); if (design.compactLayout) return [...result, ""]; // Vertical padding: background-filled rows above and below the text so // the message reads as a padded card instead of a single strip. const bgRow = typeof activeTheme?.bg === "function" ? activeTheme.bg("userMessageBg", " ".repeat(Math.max(1, width))) : ""; // Turn divider above the user message (off by default). const divider = buildDividerLine(width); return config.userDivider ? [divider, "", bgRow, ...result, bgRow] : [bgRow, ...result, bgRow]; }; }