1028 lines
30 KiB
TypeScript
1028 lines
30 KiB
TypeScript
// Vendored from ../zellij-modal/index.ts to keep pi-tool-display standalone.
|
|
// Keep this module in sync when upstream zellij-modal primitives change.
|
|
import { getSettingsListTheme, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
Box,
|
|
Container,
|
|
SettingsList,
|
|
Spacer,
|
|
Text,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
type SettingItem,
|
|
} from "@earendil-works/pi-tui";
|
|
|
|
const ANSI_RESET = "\x1b[0m";
|
|
|
|
/**
|
|
* Border character set used to render a modal frame.
|
|
*/
|
|
export interface BorderCharacters {
|
|
/** Top-left corner. */
|
|
topLeft: string;
|
|
/** Top-right corner. */
|
|
topRight: string;
|
|
/** Bottom-left corner. */
|
|
bottomLeft: string;
|
|
/** Bottom-right corner. */
|
|
bottomRight: string;
|
|
/** Horizontal line character. */
|
|
horizontal: string;
|
|
/** Vertical line character. */
|
|
vertical: string;
|
|
/** Optional left tee junction. */
|
|
verticalLeft?: string;
|
|
/** Optional right tee junction. */
|
|
verticalRight?: string;
|
|
}
|
|
|
|
/**
|
|
* Predefined border character sets aligned with Zellij styles.
|
|
*/
|
|
export const BORDER_STYLES = {
|
|
rounded: {
|
|
topLeft: "╭",
|
|
topRight: "╮",
|
|
bottomLeft: "╰",
|
|
bottomRight: "╯",
|
|
horizontal: "─",
|
|
vertical: "│",
|
|
verticalLeft: "├",
|
|
verticalRight: "┤",
|
|
},
|
|
square: {
|
|
topLeft: "┌",
|
|
topRight: "┐",
|
|
bottomLeft: "└",
|
|
bottomRight: "┘",
|
|
horizontal: "─",
|
|
vertical: "│",
|
|
verticalLeft: "├",
|
|
verticalRight: "┤",
|
|
},
|
|
double: {
|
|
topLeft: "╔",
|
|
topRight: "╗",
|
|
bottomLeft: "╚",
|
|
bottomRight: "╝",
|
|
horizontal: "═",
|
|
vertical: "║",
|
|
},
|
|
none: {
|
|
topLeft: " ",
|
|
topRight: " ",
|
|
bottomLeft: " ",
|
|
bottomRight: " ",
|
|
horizontal: " ",
|
|
vertical: " ",
|
|
},
|
|
} as const satisfies Record<string, BorderCharacters>;
|
|
|
|
/**
|
|
* Name of a supported border style.
|
|
*/
|
|
export type BorderStyle = keyof typeof BORDER_STYLES;
|
|
|
|
/**
|
|
* Supported palette color formats.
|
|
*/
|
|
export type PaletteColor =
|
|
| { type: "rgb"; r: number; g: number; b: number }
|
|
| { type: "8bit"; code: number }
|
|
| { type: "named"; name: string };
|
|
|
|
/**
|
|
* Semantic color slots for a Zellij-style modal.
|
|
*/
|
|
export interface ZellijColorPalette {
|
|
/** Primary foreground text. */
|
|
fg: PaletteColor;
|
|
/** Modal background. */
|
|
bg: PaletteColor;
|
|
/** Accent / selection color. */
|
|
accent: PaletteColor;
|
|
/** Secondary text color. */
|
|
muted: PaletteColor;
|
|
/** Tertiary text color. */
|
|
dim: PaletteColor;
|
|
/** Success state color. */
|
|
success: PaletteColor;
|
|
/** Error state color. */
|
|
error: PaletteColor;
|
|
/** Warning state color. */
|
|
warning: PaletteColor;
|
|
/** Default border color. */
|
|
border: PaletteColor;
|
|
/** Border color when focused. */
|
|
borderFocused: PaletteColor;
|
|
/** Border color when unfocused. */
|
|
borderUnfocused: PaletteColor;
|
|
}
|
|
|
|
/**
|
|
* Default Zellij-inspired palette.
|
|
*/
|
|
export const DEFAULT_ZELLIJ_PALETTE: ZellijColorPalette = {
|
|
fg: { type: "named", name: "white" },
|
|
bg: { type: "named", name: "black" },
|
|
accent: { type: "8bit", code: 36 },
|
|
muted: { type: "8bit", code: 245 },
|
|
dim: { type: "8bit", code: 238 },
|
|
success: { type: "8bit", code: 154 },
|
|
error: { type: "8bit", code: 124 },
|
|
warning: { type: "8bit", code: 166 },
|
|
border: { type: "8bit", code: 238 },
|
|
borderFocused: { type: "8bit", code: 154 },
|
|
borderUnfocused: { type: "8bit", code: 238 },
|
|
};
|
|
|
|
/**
|
|
* A title segment in the top border.
|
|
*/
|
|
export interface TitleSegment {
|
|
/** Segment text. */
|
|
text: string;
|
|
/** Segment foreground color slot or explicit color. */
|
|
color: keyof ZellijColorPalette | PaletteColor;
|
|
/** Optional segment background color. */
|
|
bgColor?: PaletteColor;
|
|
/** Enables bold style. */
|
|
bold?: boolean;
|
|
/** Truncation strategy when segment text is too long. */
|
|
truncate?: "start" | "middle" | "end" | "none";
|
|
/** Maximum visible width for text content (0 means unlimited). */
|
|
maxWidth?: number;
|
|
}
|
|
|
|
/**
|
|
* Three-part title bar configuration.
|
|
*/
|
|
export interface TitleBarConfig {
|
|
/** Left segment (usually title). */
|
|
left?: TitleSegment | string;
|
|
/** Center segment (usually status). */
|
|
center?: TitleSegment | string;
|
|
/** Right segment (usually counters/actions). */
|
|
right?: TitleSegment | string;
|
|
/** Optional textual separator (reserved for custom renderers). */
|
|
separator?: string;
|
|
}
|
|
|
|
/**
|
|
* Help text line rendered in the bottom border.
|
|
*/
|
|
export interface HelpUndertitleConfig {
|
|
/** Static help text. */
|
|
text?: string;
|
|
/** Dynamic help text generator. */
|
|
textGenerator?: (width: number) => string;
|
|
/** Progressive truncation variants from longest to shortest. */
|
|
variants?: string[];
|
|
/** Structured key hints for help text generation. */
|
|
keyHints?: Array<{
|
|
key: string;
|
|
description: string;
|
|
}>;
|
|
/** Separator between key hints. */
|
|
keyHintSeparator?: string;
|
|
/** Palette slot for help text color. */
|
|
color?: keyof ZellijColorPalette;
|
|
}
|
|
|
|
/**
|
|
* Full modal configuration.
|
|
*/
|
|
export interface ZellijModalConfig {
|
|
/** Border style preset. */
|
|
borderStyle: BorderStyle;
|
|
/** Active color palette. */
|
|
palette: ZellijColorPalette;
|
|
/** Focus state for frame highlighting. */
|
|
focused: boolean;
|
|
/** Internal content padding. */
|
|
padding: number;
|
|
/** Top title bar config. */
|
|
titleBar: TitleBarConfig;
|
|
/** Optional bottom help line config. */
|
|
helpUndertitle?: HelpUndertitleConfig;
|
|
/** Minimum preferred modal width. */
|
|
minWidth: number;
|
|
/** Maximum modal width (0 means no explicit max). */
|
|
maxWidth: number;
|
|
/** Overlay options for `ctx.ui.custom()`. */
|
|
overlay: {
|
|
anchor: "center" | "top" | "bottom";
|
|
width: number | string;
|
|
maxHeight: number | string;
|
|
margin: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Partial modal configuration used by consumers.
|
|
*/
|
|
export type ZellijModalConfigPartial = Partial<ZellijModalConfig> & {
|
|
/** Shorthand for `titleBar.left`. */
|
|
title?: string;
|
|
/** Shorthand for help text. */
|
|
helpText?: string | HelpUndertitleConfig;
|
|
};
|
|
|
|
/**
|
|
* Modal rendering metadata.
|
|
*/
|
|
export interface ZellijModalRenderOutput {
|
|
/** Fully rendered lines. */
|
|
lines: string[];
|
|
/** Visible frame width. */
|
|
visibleWidth: number;
|
|
/** Width of content area inside borders and padding. */
|
|
contentWidth: number;
|
|
/** Inclusive index of first content line. */
|
|
contentStartLine: number;
|
|
/** Inclusive index of last content line. */
|
|
contentEndLine: number;
|
|
}
|
|
|
|
/**
|
|
* Minimal content renderer contract for modal content.
|
|
*/
|
|
export interface ZellijModalContentRenderer {
|
|
/** Render content into lines for the given width. */
|
|
render(width: number): string[];
|
|
/** Invalidate internal caches. */
|
|
invalidate(): void;
|
|
/** Optional input handler. */
|
|
handleInput?(data: string): void;
|
|
}
|
|
|
|
/**
|
|
* Full modal component contract.
|
|
*/
|
|
export interface ZellijModalComponent extends ZellijModalContentRenderer {
|
|
/** Effective modal configuration. */
|
|
config: ZellijModalConfig;
|
|
/** Wrapped content renderer. */
|
|
content: ZellijModalContentRenderer;
|
|
/** Render complete modal output. */
|
|
renderModal(width: number): ZellijModalRenderOutput;
|
|
/** Release resources. */
|
|
dispose(): void;
|
|
}
|
|
|
|
/**
|
|
* Theme helper for modal-specific color resolution and ANSI formatting.
|
|
*/
|
|
export interface ZellijModalTheme {
|
|
/** Active palette used by this theme helper. */
|
|
palette: ZellijColorPalette;
|
|
/** Resolve color slot or explicit color into ANSI foreground/background codes. */
|
|
resolveColor: (color: PaletteColor | keyof ZellijColorPalette) => { fg: string; bg: string };
|
|
/** Apply foreground color to text. */
|
|
colorizeForeground: (color: PaletteColor | keyof ZellijColorPalette, text: string) => string;
|
|
/** Apply background color to text. */
|
|
colorizeBackground: (color: PaletteColor | keyof ZellijColorPalette, text: string) => string;
|
|
}
|
|
|
|
/**
|
|
* Resolve a `PaletteColor` into ANSI foreground/background escape codes.
|
|
*/
|
|
export function resolveColor(color: PaletteColor): { fg: string; bg: string } {
|
|
if (color.type === "rgb") {
|
|
const r = clampInt(color.r, 0, 255);
|
|
const g = clampInt(color.g, 0, 255);
|
|
const b = clampInt(color.b, 0, 255);
|
|
return {
|
|
fg: `\x1b[38;2;${r};${g};${b}m`,
|
|
bg: `\x1b[48;2;${r};${g};${b}m`,
|
|
};
|
|
}
|
|
|
|
if (color.type === "8bit") {
|
|
const code = clampInt(color.code, 0, 255);
|
|
return {
|
|
fg: `\x1b[38;5;${code}m`,
|
|
bg: `\x1b[48;5;${code}m`,
|
|
};
|
|
}
|
|
|
|
const namedMap: Record<string, number> = {
|
|
black: 16,
|
|
white: 255,
|
|
red: 196,
|
|
green: 46,
|
|
blue: 45,
|
|
yellow: 226,
|
|
cyan: 51,
|
|
magenta: 201,
|
|
gray: 245,
|
|
grey: 245,
|
|
orange: 166,
|
|
};
|
|
const code = namedMap[color.name.toLowerCase()] ?? 255;
|
|
return {
|
|
fg: `\x1b[38;5;${code}m`,
|
|
bg: `\x1b[48;5;${code}m`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a `ZellijModalTheme` helper from a palette.
|
|
*/
|
|
export function createZellijModalTheme(palette: ZellijColorPalette): ZellijModalTheme {
|
|
return {
|
|
palette,
|
|
resolveColor: (color) => resolveColor(resolvePaletteColor(color, palette)),
|
|
colorizeForeground: (color, text) => `${resolveColor(resolvePaletteColor(color, palette)).fg}${text}${ANSI_RESET}`,
|
|
colorizeBackground: (color, text) => `${resolveColor(resolvePaletteColor(color, palette)).bg}${text}${ANSI_RESET}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert Pi `Theme` values to a Zellij modal palette.
|
|
*/
|
|
export function themeToZellijPalette(theme: Theme): ZellijColorPalette {
|
|
const extract = (colorName: string, fallback: PaletteColor): PaletteColor => {
|
|
const provider = theme as unknown as {
|
|
getFgAnsi?: (name: string) => string;
|
|
};
|
|
if (!provider.getFgAnsi) {
|
|
return fallback;
|
|
}
|
|
|
|
try {
|
|
const ansi = provider.getFgAnsi(colorName);
|
|
const parsed = parseAnsiForegroundColor(ansi);
|
|
return parsed ?? fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
return {
|
|
fg: extract("fg", DEFAULT_ZELLIJ_PALETTE.fg),
|
|
bg: extract("bg", DEFAULT_ZELLIJ_PALETTE.bg),
|
|
accent: extract("accent", DEFAULT_ZELLIJ_PALETTE.accent),
|
|
muted: extract("muted", DEFAULT_ZELLIJ_PALETTE.muted),
|
|
dim: extract("dim", DEFAULT_ZELLIJ_PALETTE.dim),
|
|
success: extract("success", DEFAULT_ZELLIJ_PALETTE.success),
|
|
error: extract("error", DEFAULT_ZELLIJ_PALETTE.error),
|
|
warning: extract("warning", DEFAULT_ZELLIJ_PALETTE.warning),
|
|
border: extract("borderMuted", DEFAULT_ZELLIJ_PALETTE.border),
|
|
borderFocused: extract("accent", DEFAULT_ZELLIJ_PALETTE.borderFocused),
|
|
borderUnfocused: extract("borderMuted", DEFAULT_ZELLIJ_PALETTE.borderUnfocused),
|
|
};
|
|
}
|
|
|
|
interface PositionedTitleSegment {
|
|
start: number;
|
|
end: number;
|
|
text: string;
|
|
color: keyof ZellijColorPalette | PaletteColor;
|
|
bold: boolean;
|
|
}
|
|
|
|
/**
|
|
* Core frame renderer for Zellij-style borders, title bar, and undertitle.
|
|
*/
|
|
export class ZellijModalFrame {
|
|
private config: ZellijModalConfig;
|
|
private borders: BorderCharacters;
|
|
private theme: ZellijModalTheme;
|
|
|
|
constructor(config: ZellijModalConfig, modalTheme?: ZellijModalTheme) {
|
|
this.config = config;
|
|
this.borders = BORDER_STYLES[config.borderStyle] ?? BORDER_STYLES.rounded;
|
|
this.theme = modalTheme ?? createZellijModalTheme(config.palette);
|
|
}
|
|
|
|
/**
|
|
* Update frame configuration (used when modal config changes).
|
|
*/
|
|
setConfig(config: ZellijModalConfig): void {
|
|
this.config = config;
|
|
this.borders = BORDER_STYLES[config.borderStyle] ?? BORDER_STYLES.rounded;
|
|
this.theme = createZellijModalTheme(config.palette);
|
|
}
|
|
|
|
/**
|
|
* Render one content line with left/right borders.
|
|
*/
|
|
renderContentLine(content: string, width: number, palette: ZellijColorPalette): string {
|
|
const frameWidth = Math.max(2, width);
|
|
const innerWidth = Math.max(0, frameWidth - 2);
|
|
const borderColor = this.config.focused ? palette.borderFocused : palette.borderUnfocused;
|
|
const vertical = this.theme.colorizeForeground(borderColor, this.borders.vertical);
|
|
const paddedContent = truncateToWidth(content, innerWidth, "", true);
|
|
return `${vertical}${paddedContent}${vertical}`;
|
|
}
|
|
|
|
/**
|
|
* Render complete frame around provided content lines.
|
|
*/
|
|
renderFrame(contentLines: string[], width: number, palette: ZellijColorPalette): ZellijModalRenderOutput {
|
|
const frameWidth = Math.max(4, width);
|
|
const safeContent = contentLines.length > 0 ? contentLines : [""];
|
|
const lines: string[] = [];
|
|
|
|
lines.push(this.renderTitleBar(frameWidth, palette));
|
|
|
|
const contentStartLine = lines.length;
|
|
for (const line of safeContent) {
|
|
lines.push(this.renderContentLine(line, frameWidth, palette));
|
|
}
|
|
const contentEndLine = lines.length - 1;
|
|
|
|
lines.push(this.renderBottomLine(frameWidth, palette));
|
|
|
|
return {
|
|
lines,
|
|
visibleWidth: frameWidth,
|
|
contentWidth: Math.max(1, frameWidth - 2 - this.config.padding * 2),
|
|
contentStartLine,
|
|
contentEndLine,
|
|
};
|
|
}
|
|
|
|
private getBorderPaint(width: number, palette: ZellijColorPalette): {
|
|
innerWidth: number;
|
|
borderPaint: (text: string) => string;
|
|
} {
|
|
const innerWidth = Math.max(0, width - 2);
|
|
const borderColor = this.config.focused ? palette.borderFocused : palette.borderUnfocused;
|
|
const borderPaint = (text: string) => this.theme.colorizeForeground(borderColor, text);
|
|
return { innerWidth, borderPaint };
|
|
}
|
|
|
|
private renderBorderedRow(
|
|
width: number,
|
|
palette: ZellijColorPalette,
|
|
corners: { left: string; right: string },
|
|
renderInner: (innerWidth: number, borderPaint: (text: string) => string) => string,
|
|
): string {
|
|
const { innerWidth, borderPaint } = this.getBorderPaint(width, palette);
|
|
if (innerWidth === 0) {
|
|
return `${borderPaint(corners.left)}${borderPaint(corners.right)}`;
|
|
}
|
|
return `${borderPaint(corners.left)}${renderInner(innerWidth, borderPaint)}${borderPaint(corners.right)}`;
|
|
}
|
|
|
|
private renderTitleBar(width: number, palette: ZellijColorPalette): string {
|
|
return this.renderBorderedRow(width, palette, { left: this.borders.topLeft, right: this.borders.topRight }, (innerWidth, borderPaint) => {
|
|
const segments = this.positionTitleSegments(innerWidth);
|
|
let inner = "";
|
|
let cursor = 0;
|
|
|
|
for (const segment of segments) {
|
|
if (segment.start > cursor) {
|
|
inner += borderPaint(this.borders.horizontal.repeat(segment.start - cursor));
|
|
}
|
|
const text = segment.bold ? `\x1b[1m${segment.text}${ANSI_RESET}` : segment.text;
|
|
inner += this.theme.colorizeForeground(segment.color, text);
|
|
cursor = segment.end;
|
|
}
|
|
|
|
if (cursor < innerWidth) {
|
|
inner += borderPaint(this.borders.horizontal.repeat(innerWidth - cursor));
|
|
}
|
|
|
|
return inner;
|
|
});
|
|
}
|
|
|
|
private renderBottomLine(width: number, palette: ZellijColorPalette): string {
|
|
return this.renderBorderedRow(width, palette, { left: this.borders.bottomLeft, right: this.borders.bottomRight }, (innerWidth, borderPaint) => {
|
|
const helpText = this.resolveHelpText(Math.max(0, innerWidth - 3));
|
|
if (!helpText) {
|
|
return borderPaint(this.borders.horizontal.repeat(innerWidth));
|
|
}
|
|
|
|
const helpSlot = this.config.helpUndertitle?.color ?? "dim";
|
|
const safeHelp = truncateToWidth(helpText, Math.max(0, innerWidth - 3), "…");
|
|
const helpWidth = visibleWidth(safeHelp);
|
|
const rightFill = Math.max(0, innerWidth - helpWidth - 3);
|
|
|
|
return `${borderPaint(this.borders.horizontal)} ${this.theme.colorizeForeground(helpSlot, safeHelp)} ${borderPaint(this.borders.horizontal.repeat(rightFill))}`;
|
|
});
|
|
}
|
|
|
|
private positionTitleSegments(innerWidth: number): PositionedTitleSegment[] {
|
|
if (innerWidth <= 0) {
|
|
return [];
|
|
}
|
|
|
|
const left = this.resolveTitleSegment(this.config.titleBar.left, "left");
|
|
const center = this.resolveTitleSegment(this.config.titleBar.center, "center");
|
|
const right = this.resolveTitleSegment(this.config.titleBar.right, "right");
|
|
|
|
const placements: PositionedTitleSegment[] = [];
|
|
|
|
if (left) {
|
|
const leftText = this.fitTextToWidth(left.text, Math.min(innerWidth, left.maxWidth ?? innerWidth), left.truncate);
|
|
if (leftText) {
|
|
placements.push({
|
|
start: 0,
|
|
end: Math.min(innerWidth, visibleWidth(leftText)),
|
|
text: leftText,
|
|
color: left.color,
|
|
bold: left.bold ?? false,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (right) {
|
|
const reservedLeft = placements[0]?.end ?? 0;
|
|
const available = Math.max(0, innerWidth - reservedLeft);
|
|
const rightText = this.fitTextToWidth(right.text, Math.min(available, right.maxWidth ?? available), right.truncate);
|
|
const rightWidth = visibleWidth(rightText);
|
|
if (rightText && rightWidth > 0) {
|
|
placements.push({
|
|
start: innerWidth - rightWidth,
|
|
end: innerWidth,
|
|
text: rightText,
|
|
color: right.color,
|
|
bold: right.bold ?? false,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (center) {
|
|
const leftLimit = placements.find((placement) => placement.start === 0)?.end ?? 0;
|
|
const rightStart = placements.find((placement) => placement.end === innerWidth)?.start ?? innerWidth;
|
|
const freeWidth = Math.max(0, rightStart - leftLimit);
|
|
if (freeWidth > 0) {
|
|
const centerText = this.fitTextToWidth(center.text, Math.min(freeWidth, center.maxWidth ?? freeWidth), center.truncate);
|
|
const centerWidth = visibleWidth(centerText);
|
|
if (centerText && centerWidth > 0) {
|
|
const centeredStart = Math.floor((innerWidth - centerWidth) / 2);
|
|
const start = clampInt(centeredStart, leftLimit, Math.max(leftLimit, rightStart - centerWidth));
|
|
placements.push({
|
|
start,
|
|
end: start + centerWidth,
|
|
text: centerText,
|
|
color: center.color,
|
|
bold: center.bold ?? false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return placements.sort((a, b) => a.start - b.start);
|
|
}
|
|
|
|
private resolveTitleSegment(
|
|
segment: TitleSegment | string | undefined,
|
|
position: "left" | "center" | "right",
|
|
): (TitleSegment & { text: string }) | null {
|
|
if (!segment) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof segment === "string") {
|
|
const color: keyof ZellijColorPalette = position === "left" ? "accent" : position === "center" ? "muted" : "dim";
|
|
return {
|
|
text: ` ${segment} `,
|
|
color,
|
|
bold: position === "left",
|
|
truncate: "end",
|
|
maxWidth: 0,
|
|
};
|
|
}
|
|
|
|
const clean = segment.text.trim();
|
|
if (!clean) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...segment,
|
|
text: ` ${clean} `,
|
|
truncate: segment.truncate ?? "end",
|
|
bold: segment.bold ?? false,
|
|
};
|
|
}
|
|
|
|
private fitTextToWidth(text: string, maxWidth: number, mode: TitleSegment["truncate"]): string {
|
|
if (maxWidth <= 0) {
|
|
return "";
|
|
}
|
|
if (visibleWidth(text) <= maxWidth) {
|
|
return text;
|
|
}
|
|
|
|
switch (mode) {
|
|
case "none":
|
|
return truncateToWidth(text, maxWidth, "");
|
|
case "start":
|
|
return truncateStart(text, maxWidth);
|
|
case "middle":
|
|
return truncateMiddle(text, maxWidth);
|
|
case "end":
|
|
default:
|
|
return truncateToWidth(text, maxWidth, "…");
|
|
}
|
|
}
|
|
|
|
private resolveHelpText(maxWidth: number): string | null {
|
|
const config = this.config.helpUndertitle;
|
|
if (!config || maxWidth <= 0) {
|
|
return null;
|
|
}
|
|
|
|
if (config.textGenerator) {
|
|
try {
|
|
const generated = config.textGenerator(maxWidth);
|
|
if (generated && generated.trim()) {
|
|
return generated;
|
|
}
|
|
} catch {
|
|
return config.text?.trim() ? config.text : null;
|
|
}
|
|
}
|
|
|
|
if (config.variants && config.variants.length > 0) {
|
|
for (const variant of config.variants) {
|
|
if (visibleWidth(variant) <= maxWidth) {
|
|
return variant;
|
|
}
|
|
}
|
|
return config.variants[config.variants.length - 1] ?? null;
|
|
}
|
|
|
|
if (config.keyHints && config.keyHints.length > 0) {
|
|
const separator = config.keyHintSeparator ?? " • ";
|
|
return config.keyHints
|
|
.map((hint) => `${hint.key} ${hint.description}`)
|
|
.join(separator);
|
|
}
|
|
|
|
return config.text?.trim() ? config.text : null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main Zellij-style modal component wrapper.
|
|
*/
|
|
export class ZellijModal implements ZellijModalComponent {
|
|
config: ZellijModalConfig;
|
|
content: ZellijModalContentRenderer;
|
|
|
|
private frame: ZellijModalFrame;
|
|
private palette: ZellijColorPalette;
|
|
|
|
constructor(content: ZellijModalContentRenderer, config: ZellijModalConfigPartial = {}, theme?: Theme) {
|
|
if (!content || typeof content.render !== "function") {
|
|
throw new Error("ZellijModal requires a valid content renderer.");
|
|
}
|
|
|
|
this.config = this.buildConfig(config);
|
|
this.palette = theme ? themeToZellijPalette(theme) : this.config.palette;
|
|
this.content = content;
|
|
this.frame = new ZellijModalFrame({ ...this.config, palette: this.palette });
|
|
}
|
|
|
|
/**
|
|
* Render content only (without frame).
|
|
*/
|
|
render(width: number): string[] {
|
|
const contentWidth = Math.max(1, width - 2 - this.config.padding * 2);
|
|
const paddedWidth = contentWidth + this.config.padding * 2;
|
|
const sidePadding = " ".repeat(this.config.padding);
|
|
const lines: string[] = [];
|
|
|
|
try {
|
|
const rawLines = this.content.render(contentWidth);
|
|
const normalized = rawLines.length > 0 ? rawLines : [""];
|
|
|
|
pushPaddingLines(lines, this.config.padding, paddedWidth);
|
|
|
|
for (const line of normalized) {
|
|
const fitted = truncateToWidth(line, contentWidth, "", true);
|
|
lines.push(`${sidePadding}${fitted}${sidePadding}`);
|
|
}
|
|
|
|
pushPaddingLines(lines, this.config.padding, paddedWidth);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const safe = truncateToWidth(` Render error: ${message} `, paddedWidth, "…", true);
|
|
lines.push(safe);
|
|
}
|
|
|
|
return lines.length > 0 ? lines : [" ".repeat(paddedWidth)];
|
|
}
|
|
|
|
/**
|
|
* Render complete frame + content.
|
|
*/
|
|
renderModal(width: number): ZellijModalRenderOutput {
|
|
const frameWidth = this.resolveModalWidth(width);
|
|
const contentLines = this.render(frameWidth);
|
|
return this.frame.renderFrame(contentLines, frameWidth, this.palette);
|
|
}
|
|
|
|
/**
|
|
* Invalidate child renderer state.
|
|
*/
|
|
invalidate(): void {
|
|
this.content.invalidate();
|
|
}
|
|
|
|
/**
|
|
* Delegate input to child renderer.
|
|
*/
|
|
handleInput(data: string): void {
|
|
this.content.handleInput?.(data);
|
|
}
|
|
|
|
/**
|
|
* Get overlay options for `ctx.ui.custom()`.
|
|
*/
|
|
getOverlayOptions(): { overlay: true; overlayOptions: ZellijModalConfig["overlay"] } {
|
|
return {
|
|
overlay: true,
|
|
overlayOptions: this.config.overlay,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Dispose modal resources.
|
|
*/
|
|
dispose(): void {
|
|
this.content.invalidate();
|
|
}
|
|
|
|
private buildConfig(partial: ZellijModalConfigPartial): ZellijModalConfig {
|
|
const borderStyle = partial.borderStyle && BORDER_STYLES[partial.borderStyle] ? partial.borderStyle : "rounded";
|
|
const padding = Math.max(0, partial.padding ?? 1);
|
|
const minWidth = Math.max(4, partial.minWidth ?? 40);
|
|
const maxWidth = Math.max(0, partial.maxWidth ?? 0);
|
|
const helpUndertitle = normalizeHelpUndertitle(partial.helpText, partial.helpUndertitle);
|
|
|
|
return {
|
|
borderStyle,
|
|
palette: partial.palette ?? DEFAULT_ZELLIJ_PALETTE,
|
|
focused: partial.focused ?? true,
|
|
padding,
|
|
titleBar: partial.titleBar ?? { left: partial.title ?? "Modal" },
|
|
helpUndertitle,
|
|
minWidth,
|
|
maxWidth,
|
|
overlay: {
|
|
anchor: partial.overlay?.anchor ?? "center",
|
|
width: partial.overlay?.width ?? 70,
|
|
maxHeight: partial.overlay?.maxHeight ?? "80%",
|
|
margin: Math.max(0, partial.overlay?.margin ?? 1),
|
|
},
|
|
};
|
|
}
|
|
|
|
private resolveModalWidth(availableWidth: number): number {
|
|
const width = Math.max(4, availableWidth);
|
|
const boundedMax = this.config.maxWidth > 0 ? Math.min(width, this.config.maxWidth) : width;
|
|
if (boundedMax >= this.config.minWidth) {
|
|
return boundedMax;
|
|
}
|
|
return Math.max(4, boundedMax);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Options for the pre-built settings modal content renderer.
|
|
*/
|
|
export interface SettingsModalOptions {
|
|
/** Modal heading. */
|
|
title: string;
|
|
/** Optional descriptive subtitle shown above settings. */
|
|
description?: string;
|
|
/** Settings list items. */
|
|
settings: SettingItem[];
|
|
/** Called when a setting value changes. */
|
|
onChange: (id: string, value: string) => void;
|
|
/** Called when modal should close. */
|
|
onClose: () => void;
|
|
/** Optional help text shown below settings. */
|
|
helpText?: string;
|
|
/** Enables in-list search (`/` and typing behavior from SettingsList). */
|
|
enableSearch?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Pre-built Zellij content renderer for configuration modals.
|
|
*/
|
|
export class ZellijSettingsModal implements ZellijModalContentRenderer {
|
|
private container: Container;
|
|
private contentBox: Box;
|
|
private settingsList: SettingsList;
|
|
private options: SettingsModalOptions;
|
|
private theme: Theme;
|
|
|
|
constructor(options: SettingsModalOptions, theme: Theme) {
|
|
if (!options.title || !options.title.trim()) {
|
|
throw new Error("ZellijSettingsModal requires a non-empty title.");
|
|
}
|
|
|
|
this.options = options;
|
|
this.theme = theme;
|
|
this.container = new Container();
|
|
this.contentBox = new Box(0, 0);
|
|
|
|
this.contentBox.addChild(new Text(this.theme.fg("accent", this.theme.bold(options.title)), 0, 0));
|
|
|
|
if (options.description) {
|
|
this.contentBox.addChild(new Spacer(1));
|
|
this.contentBox.addChild(new Text(this.theme.fg("muted", options.description), 0, 0));
|
|
}
|
|
|
|
this.contentBox.addChild(new Spacer(1));
|
|
this.settingsList = new SettingsList(
|
|
options.settings,
|
|
Math.min(Math.max(options.settings.length + 2, 6), 18),
|
|
getSettingsListTheme(),
|
|
(id, value) => {
|
|
this.options.onChange(id, value);
|
|
},
|
|
() => {
|
|
this.options.onClose();
|
|
},
|
|
{ enableSearch: options.enableSearch ?? true },
|
|
);
|
|
this.contentBox.addChild(this.settingsList);
|
|
|
|
if (options.helpText) {
|
|
this.contentBox.addChild(new Spacer(1));
|
|
this.contentBox.addChild(new Text(this.theme.fg("dim", options.helpText), 0, 0));
|
|
}
|
|
|
|
this.container.addChild(this.contentBox);
|
|
}
|
|
|
|
/**
|
|
* Render settings modal content.
|
|
*/
|
|
render(width: number): string[] {
|
|
const safeWidth = Math.max(1, width);
|
|
try {
|
|
return this.container.render(safeWidth);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return [this.theme.fg("error", truncateToWidth(`Settings render error: ${message}`, safeWidth, "…"))];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invalidate internal caches.
|
|
*/
|
|
invalidate(): void {
|
|
this.container.invalidate();
|
|
}
|
|
|
|
/**
|
|
* Forward key input to SettingsList.
|
|
*/
|
|
handleInput(data: string): void {
|
|
if (isEnterActivationInput(data)) {
|
|
return;
|
|
}
|
|
this.settingsList.handleInput(data);
|
|
}
|
|
|
|
/**
|
|
* Programmatically update one setting value in the list.
|
|
*/
|
|
updateValue(id: string, value: string): void {
|
|
this.settingsList.updateValue(id, value);
|
|
}
|
|
}
|
|
|
|
function pushPaddingLines(lines: string[], count: number, paddedWidth: number): void {
|
|
for (let i = 0; i < count; i++) {
|
|
lines.push(" ".repeat(paddedWidth));
|
|
}
|
|
}
|
|
|
|
function isEnterActivationInput(data: string): boolean {
|
|
return data === "\r" || data === "\n" || data === "\r\n";
|
|
}
|
|
|
|
function normalizeHelpUndertitle(
|
|
helpText: ZellijModalConfigPartial["helpText"],
|
|
helpUndertitle: HelpUndertitleConfig | undefined,
|
|
): HelpUndertitleConfig | undefined {
|
|
if (helpUndertitle) {
|
|
return helpUndertitle;
|
|
}
|
|
if (typeof helpText === "string") {
|
|
return helpText ? { text: helpText } : undefined;
|
|
}
|
|
return helpText;
|
|
}
|
|
|
|
function resolvePaletteColor(color: PaletteColor | keyof ZellijColorPalette, palette: ZellijColorPalette): PaletteColor {
|
|
if (typeof color === "string") {
|
|
return palette[color];
|
|
}
|
|
return color;
|
|
}
|
|
|
|
function parseAnsiForegroundColor(ansi: string): PaletteColor | null {
|
|
const rgbMatch = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(ansi);
|
|
if (rgbMatch) {
|
|
const [, r, g, b] = rgbMatch;
|
|
return {
|
|
type: "rgb",
|
|
r: clampInt(Number.parseInt(r ?? "0", 10), 0, 255),
|
|
g: clampInt(Number.parseInt(g ?? "0", 10), 0, 255),
|
|
b: clampInt(Number.parseInt(b ?? "0", 10), 0, 255),
|
|
};
|
|
}
|
|
|
|
const bit8Match = /\x1b\[38;5;(\d+)m/.exec(ansi);
|
|
if (bit8Match) {
|
|
const [, code] = bit8Match;
|
|
return {
|
|
type: "8bit",
|
|
code: clampInt(Number.parseInt(code ?? "255", 10), 0, 255),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function earlyTruncate(text: string, maxWidth: number): string | undefined {
|
|
if (visibleWidth(text) <= maxWidth) {
|
|
return text;
|
|
}
|
|
if (maxWidth <= 1) {
|
|
return "…".slice(0, maxWidth);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function withEarlyTruncation(
|
|
text: string,
|
|
maxWidth: number,
|
|
compute: () => string,
|
|
): string {
|
|
const early = earlyTruncate(text, maxWidth);
|
|
if (early !== undefined) {
|
|
return early;
|
|
}
|
|
return compute();
|
|
}
|
|
|
|
function truncateStart(text: string, maxWidth: number): string {
|
|
return withEarlyTruncation(text, maxWidth, () => {
|
|
const chars = Array.from(text);
|
|
let current = "";
|
|
for (let index = chars.length - 1; index >= 0; index--) {
|
|
const candidate = `${chars[index]}${current}`;
|
|
if (visibleWidth(candidate) >= maxWidth - 1) {
|
|
current = candidate;
|
|
break;
|
|
}
|
|
current = candidate;
|
|
}
|
|
return `…${truncateToWidth(current, Math.max(0, maxWidth - 1), "")}`;
|
|
});
|
|
}
|
|
|
|
function truncateMiddle(text: string, maxWidth: number): string {
|
|
return withEarlyTruncation(text, maxWidth, () => {
|
|
const headTarget = Math.floor((maxWidth - 1) / 2);
|
|
const tailTarget = Math.max(0, maxWidth - 1 - headTarget);
|
|
const head = truncateToWidth(text, headTarget, "");
|
|
|
|
const chars = Array.from(text);
|
|
let tail = "";
|
|
for (let index = chars.length - 1; index >= 0; index--) {
|
|
const candidate = `${chars[index]}${tail}`;
|
|
if (visibleWidth(candidate) > tailTarget) {
|
|
continue;
|
|
}
|
|
tail = candidate;
|
|
if (visibleWidth(tail) === tailTarget) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return `${head}…${tail}`;
|
|
});
|
|
}
|
|
|
|
function clampInt(value: number, min: number, max: number): number {
|
|
if (Number.isNaN(value) || !Number.isFinite(value)) {
|
|
return min;
|
|
}
|
|
return Math.min(max, Math.max(min, Math.round(value)));
|
|
}
|
|
|
|
/**
|
|
* Extension factory entrypoint for Pi extension loader.
|
|
*
|
|
* This extension intentionally registers no commands/events and only exposes
|
|
* reusable modal primitives for sibling extensions.
|
|
*/
|
|
export default function zellijModalExtension(_pi: ExtensionAPI): void {
|
|
// no-op
|
|
}
|