382 lines
9.3 KiB
TypeScript
382 lines
9.3 KiB
TypeScript
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
import {
|
|
type FooterKeymapContext,
|
|
renderFooterKeymaps,
|
|
} from "../constants/keymaps.ts";
|
|
import { NO_PREVIEW_TEXT } from "../constants/text.ts";
|
|
import { UI_DIMENSIONS, UI_TEXT } from "../constants/ui.ts";
|
|
import { clamp } from "../math.ts";
|
|
import { wrapText } from "../text.ts";
|
|
|
|
type Theme = ExtensionContext["ui"]["theme"];
|
|
type ThemeColor =
|
|
| "accent"
|
|
| "muted"
|
|
| "text"
|
|
| "dim"
|
|
| "success"
|
|
| "warning"
|
|
| "syntaxString";
|
|
|
|
const EDITOR_BORDER_PATTERN = /^[┌┐└┘─]+$/;
|
|
const EDITOR_SCROLL_BORDER_PATTERN = /^─── [↑↓] \d+ more ─*$/;
|
|
const ANSI_CONTROL_SEQUENCE = "\u001b[";
|
|
const ANSI_TERMINATOR = "m";
|
|
|
|
export function pushWrappedText(
|
|
lines: string[],
|
|
text: string,
|
|
width: number,
|
|
theme: Theme,
|
|
color: ThemeColor,
|
|
prefix = "",
|
|
continuationPrefix = prefix
|
|
) {
|
|
const availableWidth = Math.max(1, width - visibleWidth(prefix));
|
|
const wrapped = wrapText(text, availableWidth);
|
|
for (let index = 0; index < wrapped.length; index++) {
|
|
const line = wrapped[index];
|
|
const currentPrefix = index === 0 ? prefix : continuationPrefix;
|
|
lines.push(
|
|
truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width)
|
|
);
|
|
}
|
|
if (wrapped.length === 0) {
|
|
lines.push(truncateToWidth(prefix, width));
|
|
}
|
|
}
|
|
|
|
export function renderInputLine(
|
|
line: string,
|
|
availableWidth: number,
|
|
theme: Theme,
|
|
color: ThemeColor = "text"
|
|
): string {
|
|
const innerWidth = Math.max(4, availableWidth - 2);
|
|
const truncated = truncateToWidth(line, innerWidth);
|
|
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
|
return theme.bg("selectedBg", ` ${theme.fg(color, truncated)}${padding} `);
|
|
}
|
|
|
|
export function renderEditorBlock(args: {
|
|
lines: string[];
|
|
editorLines: string[];
|
|
width: number;
|
|
theme: Theme;
|
|
indent: string;
|
|
availableWidth: number;
|
|
placeholder?: string;
|
|
placeholderColor?: ThemeColor;
|
|
contentColor?: ThemeColor;
|
|
isEmpty?: boolean;
|
|
}) {
|
|
const {
|
|
lines,
|
|
editorLines,
|
|
width,
|
|
theme,
|
|
indent,
|
|
availableWidth,
|
|
placeholder,
|
|
placeholderColor = "muted",
|
|
isEmpty = false,
|
|
} = args;
|
|
const innerLines = getEditorContentLines(editorLines);
|
|
|
|
if (isEmpty && placeholder) {
|
|
lines.push(
|
|
truncateToWidth(
|
|
`${indent}${renderInputLine(
|
|
placeholder,
|
|
availableWidth,
|
|
theme,
|
|
placeholderColor
|
|
)}`,
|
|
width
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const editorLine of innerLines) {
|
|
lines.push(
|
|
truncateToWidth(
|
|
`${indent}${renderEditorLine(editorLine, availableWidth, theme)}`,
|
|
width
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
export function renderLabeledEditorBlock(args: {
|
|
lines: string[];
|
|
label: string;
|
|
editorLines: string[];
|
|
width: number;
|
|
theme: Theme;
|
|
indent: string;
|
|
availableWidth: number;
|
|
placeholder?: string;
|
|
placeholderColor?: ThemeColor;
|
|
isEmpty?: boolean;
|
|
}) {
|
|
const {
|
|
lines,
|
|
label,
|
|
editorLines,
|
|
width,
|
|
theme,
|
|
indent,
|
|
availableWidth,
|
|
placeholder,
|
|
placeholderColor = "muted",
|
|
isEmpty = false,
|
|
} = args;
|
|
const contentLines = getEditorContentLines(editorLines);
|
|
const labelText = theme.fg("accent", label);
|
|
const contentIndent = `${indent}${" ".repeat(visibleWidth(label) + 1)}`;
|
|
const editorWidth = Math.max(4, availableWidth - visibleWidth(label) - 1);
|
|
const firstLine =
|
|
isEmpty && placeholder
|
|
? renderInputLine(placeholder, editorWidth, theme, placeholderColor)
|
|
: renderEditorLine(contentLines[0] ?? "", editorWidth, theme);
|
|
|
|
lines.push(truncateToWidth(`${indent}${labelText} ${firstLine}`, width));
|
|
|
|
for (const editorLine of contentLines.slice(1)) {
|
|
lines.push(
|
|
truncateToWidth(
|
|
`${contentIndent}${renderEditorLine(editorLine, availableWidth, theme)}`,
|
|
width
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
function getEditorContentLines(editorLines: string[]): string[] {
|
|
if (editorLines.length <= 2) {
|
|
return editorLines;
|
|
}
|
|
|
|
const contentLines = editorLines.slice(1);
|
|
const trailingBorderIndex = contentLines.findIndex(isEditorBorderLine);
|
|
if (trailingBorderIndex === -1) {
|
|
return contentLines;
|
|
}
|
|
|
|
return contentLines.filter((_, index) => index !== trailingBorderIndex);
|
|
}
|
|
|
|
function isEditorBorderLine(line: string): boolean {
|
|
const plainText = stripAnsiColorCodes(line).trim();
|
|
if (plainText.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
EDITOR_BORDER_PATTERN.test(plainText) ||
|
|
EDITOR_SCROLL_BORDER_PATTERN.test(plainText)
|
|
);
|
|
}
|
|
|
|
function stripAnsiColorCodes(text: string): string {
|
|
let result = text;
|
|
while (true) {
|
|
const start = result.indexOf(ANSI_CONTROL_SEQUENCE);
|
|
if (start === -1) {
|
|
return result;
|
|
}
|
|
|
|
const end = result.indexOf(
|
|
ANSI_TERMINATOR,
|
|
start + ANSI_CONTROL_SEQUENCE.length
|
|
);
|
|
if (end === -1) {
|
|
return result;
|
|
}
|
|
|
|
result =
|
|
result.slice(0, start) + result.slice(end + ANSI_TERMINATOR.length);
|
|
}
|
|
}
|
|
|
|
function renderEditorLine(
|
|
line: string,
|
|
availableWidth: number,
|
|
theme: Theme
|
|
): string {
|
|
const innerWidth = Math.max(4, availableWidth - 2);
|
|
const truncated = truncateToWidth(line, innerWidth);
|
|
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
|
return renderPersistentBackground(
|
|
`${truncated}${padding}`,
|
|
theme,
|
|
"selectedBg"
|
|
);
|
|
}
|
|
|
|
function renderPersistentBackground(
|
|
text: string,
|
|
theme: Theme,
|
|
background: "selectedBg"
|
|
): string {
|
|
const marker = "__PI_BG_MARKER__";
|
|
const wrappedMarker = theme.bg(background, marker);
|
|
const markerIndex = wrappedMarker.indexOf(marker);
|
|
if (markerIndex === -1) {
|
|
return theme.bg(background, ` ${text} `);
|
|
}
|
|
|
|
const prefix = wrappedMarker.slice(0, markerIndex);
|
|
const suffix = wrappedMarker.slice(markerIndex + marker.length);
|
|
const ansiReset = "\u001b[0m";
|
|
const reopenedText = text.split(ansiReset).join(`${ansiReset}${prefix}`);
|
|
return `${prefix} ${reopenedText} ${suffix}`;
|
|
}
|
|
|
|
export function renderBox(
|
|
content: Array<{ text: string; color: ThemeColor }>,
|
|
width: number,
|
|
theme: Theme
|
|
): string[] {
|
|
const boxWidth = Math.max(UI_DIMENSIONS.boxMinWidth, width);
|
|
const innerWidth = Math.max(4, boxWidth - 2);
|
|
const top = theme.fg("accent", `┌${"─".repeat(innerWidth)}┐`);
|
|
const bottom = theme.fg("accent", `└${"─".repeat(innerWidth)}┘`);
|
|
const lines = [top];
|
|
for (const item of content) {
|
|
for (const rawLine of wrapText(item.text, innerWidth)) {
|
|
const line = theme.fg(item.color, rawLine);
|
|
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
|
|
lines.push(
|
|
theme.fg("accent", "│") + line + padding + theme.fg("accent", "│")
|
|
);
|
|
}
|
|
}
|
|
lines.push(bottom);
|
|
return lines;
|
|
}
|
|
|
|
export function renderPreviewPaneContent(
|
|
selectedOption:
|
|
| {
|
|
label: string;
|
|
description?: string;
|
|
preview?: string;
|
|
}
|
|
| undefined,
|
|
theme: Theme,
|
|
width: number
|
|
): string[] {
|
|
if (!selectedOption) {
|
|
return renderBox([{ text: NO_PREVIEW_TEXT, color: "dim" }], width, theme);
|
|
}
|
|
|
|
const content: Array<{ text: string; color: ThemeColor }> = [
|
|
{ text: selectedOption.label, color: "accent" },
|
|
];
|
|
if (selectedOption.description) {
|
|
content.push({ text: selectedOption.description, color: "muted" });
|
|
}
|
|
content.push({ text: "", color: "dim" });
|
|
|
|
for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split(
|
|
"\n"
|
|
)) {
|
|
content.push({
|
|
text: previewLine,
|
|
color: selectedOption.preview ? "text" : "dim",
|
|
});
|
|
}
|
|
return renderBox(content, width, theme);
|
|
}
|
|
|
|
export function mergeColumns(
|
|
left: string[],
|
|
right: string[],
|
|
leftWidth: number,
|
|
width: number
|
|
): string[] {
|
|
const lines: string[] = [];
|
|
const rowCount = Math.max(left.length, right.length);
|
|
for (let index = 0; index < rowCount; index++) {
|
|
const leftLine = left[index] ?? "";
|
|
const rightLine = right[index] ?? "";
|
|
const paddedLeft = padToVisibleWidth(leftLine, leftWidth);
|
|
lines.push(truncateToWidth(`${paddedLeft} ${rightLine}`, width));
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
export function measurePreviewLeftWidth(
|
|
options: Array<{ label: string; description?: string }>,
|
|
width: number
|
|
): number {
|
|
let widest = 0;
|
|
for (let index = 0; index < options.length; index++) {
|
|
const option = options[index];
|
|
widest = Math.max(
|
|
widest,
|
|
visibleWidth(`${index + 1}. ${option.label}`),
|
|
option.description ? visibleWidth(option.description) : 0
|
|
);
|
|
}
|
|
|
|
const preferred = widest + 4;
|
|
const maxWidth = Math.min(
|
|
UI_DIMENSIONS.previewLeftMaxWidth,
|
|
Math.floor(width * UI_DIMENSIONS.previewLeftRatio)
|
|
);
|
|
return clamp(
|
|
preferred,
|
|
UI_DIMENSIONS.previewLeftMinWidth,
|
|
Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth)
|
|
);
|
|
}
|
|
|
|
export function getSavedNotePrefixes(
|
|
theme: Theme,
|
|
args: { indent: string; label?: string }
|
|
) {
|
|
const title = args.label
|
|
? `${args.label} ${UI_TEXT.questionNoteTitle}`
|
|
: UI_TEXT.questionNoteTitle;
|
|
return {
|
|
prefix: `${args.indent}${theme.fg("syntaxString", title)} `,
|
|
continuationPrefix: `${args.indent}${" ".repeat(visibleWidth(title) + 1)}`,
|
|
};
|
|
}
|
|
|
|
export function pushSavedNote(args: {
|
|
lines: string[];
|
|
note: string;
|
|
width: number;
|
|
theme: Theme;
|
|
indent: string;
|
|
label?: string;
|
|
}) {
|
|
const { lines, note, width, theme, indent, label } = args;
|
|
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
|
indent,
|
|
label,
|
|
});
|
|
pushWrappedText(
|
|
lines,
|
|
note,
|
|
width,
|
|
theme,
|
|
"muted",
|
|
prefix,
|
|
continuationPrefix
|
|
);
|
|
}
|
|
|
|
export function renderFooterText(mode: FooterKeymapContext): string {
|
|
return renderFooterKeymaps(mode);
|
|
}
|
|
|
|
function padToVisibleWidth(text: string, width: number): string {
|
|
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
|
}
|