- Added real-time LSP server status updates in welcome banner when background warmup completes. - Changed welcome banner status icon from warning to muted pending symbol for improved visual clarity. - Extracted LSP server conversion logic into dedicated helper methods for better maintainability. - Added comprehensive test coverage for interactive mode LSP startup welcome banner updates.
249 lines
8.0 KiB
TypeScript
249 lines
8.0 KiB
TypeScript
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
import { APP_NAME } from "@oh-my-pi/pi-utils";
|
|
import { theme } from "../../modes/theme/theme";
|
|
|
|
export interface RecentSession {
|
|
name: string;
|
|
timeAgo: string;
|
|
}
|
|
|
|
export interface LspServerInfo {
|
|
name: string;
|
|
status: "ready" | "error" | "connecting";
|
|
fileTypes: string[];
|
|
}
|
|
|
|
/**
|
|
* Premium welcome screen with block-based OMP logo and two-column layout.
|
|
*/
|
|
export class WelcomeComponent implements Component {
|
|
constructor(
|
|
private readonly version: string,
|
|
private modelName: string,
|
|
private providerName: string,
|
|
private recentSessions: RecentSession[] = [],
|
|
private lspServers: LspServerInfo[] = [],
|
|
) {}
|
|
|
|
invalidate(): void {}
|
|
|
|
setModel(modelName: string, providerName: string): void {
|
|
this.modelName = modelName;
|
|
this.providerName = providerName;
|
|
}
|
|
|
|
setRecentSessions(sessions: RecentSession[]): void {
|
|
this.recentSessions = sessions;
|
|
}
|
|
|
|
setLspServers(servers: LspServerInfo[]): void {
|
|
this.lspServers = servers;
|
|
}
|
|
|
|
render(termWidth: number): string[] {
|
|
// Box dimensions - responsive with max width and small-terminal support
|
|
const maxWidth = 100;
|
|
const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
|
|
if (boxWidth < 4) {
|
|
return [];
|
|
}
|
|
const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
|
|
const preferredLeftCol = 26;
|
|
const minLeftCol = 14; // logo width
|
|
const minRightCol = 20;
|
|
const leftMinContentWidth = Math.max(
|
|
minLeftCol,
|
|
visibleWidth("Welcome back!"),
|
|
visibleWidth(this.modelName),
|
|
visibleWidth(this.providerName),
|
|
);
|
|
const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35)));
|
|
const dualLeftCol =
|
|
dualContentWidth >= minRightCol + 1
|
|
? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
|
|
: Math.max(1, dualContentWidth - 1);
|
|
const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
|
|
const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
|
|
const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
|
|
const rightCol = showRightColumn ? dualRightCol : 0;
|
|
|
|
// Block-based OMP logo (gradient: magenta → cyan)
|
|
// biome-ignore format: preserve ASCII art layout
|
|
const piLogo = ["▀████████████▀", " ╘███ ███ ", " ███ ███ ", " ███ ███ ", " ▄███▄ ▄███▄ "];
|
|
|
|
// Apply gradient to logo
|
|
const logoColored = piLogo.map(line => this.#gradientLine(line));
|
|
|
|
// Left column - centered content
|
|
const leftLines = [
|
|
"",
|
|
this.#centerText(theme.bold("Welcome back!"), leftCol),
|
|
"",
|
|
...logoColored.map(l => this.#centerText(l, leftCol)),
|
|
"",
|
|
this.#centerText(theme.fg("muted", this.modelName), leftCol),
|
|
this.#centerText(theme.fg("borderMuted", this.providerName), leftCol),
|
|
];
|
|
|
|
// Right column separator
|
|
const separatorWidth = Math.max(0, rightCol - 2); // padding on each side
|
|
const separator = ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(separatorWidth))}`;
|
|
|
|
// Recent sessions content
|
|
const sessionLines: string[] = [];
|
|
if (this.recentSessions.length === 0) {
|
|
sessionLines.push(` ${theme.fg("dim", "No recent sessions")}`);
|
|
} else {
|
|
for (const session of this.recentSessions.slice(0, 3)) {
|
|
sessionLines.push(
|
|
` ${theme.fg("dim", `${theme.md.bullet} `)}${theme.fg("muted", session.name)}${theme.fg("dim", ` (${session.timeAgo})`)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// LSP servers content
|
|
const lspLines: string[] = [];
|
|
if (this.lspServers.length === 0) {
|
|
lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
|
|
} else {
|
|
for (const server of this.lspServers) {
|
|
const icon =
|
|
server.status === "ready"
|
|
? theme.styledSymbol("status.success", "success")
|
|
: server.status === "connecting"
|
|
? theme.styledSymbol("status.pending", "muted")
|
|
: theme.styledSymbol("status.error", "error");
|
|
const exts = server.fileTypes.slice(0, 3).join(" ");
|
|
lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
|
|
}
|
|
}
|
|
|
|
// Right column
|
|
const rightLines = [
|
|
` ${theme.bold(theme.fg("accent", "Tips"))}`,
|
|
` ${theme.fg("dim", "?")}${theme.fg("muted", " for keyboard shortcuts")}`,
|
|
` ${theme.fg("dim", "#")}${theme.fg("muted", " for prompt actions")}`,
|
|
` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
|
|
` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
|
|
` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
|
|
separator,
|
|
` ${theme.bold(theme.fg("accent", "LSP Servers"))}`,
|
|
...lspLines,
|
|
separator,
|
|
` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
|
|
...sessionLines,
|
|
"",
|
|
];
|
|
|
|
// Border characters (dim)
|
|
const hChar = theme.boxRound.horizontal;
|
|
const h = theme.fg("dim", hChar);
|
|
const v = theme.fg("dim", theme.boxRound.vertical);
|
|
const tl = theme.fg("dim", theme.boxRound.topLeft);
|
|
const tr = theme.fg("dim", theme.boxRound.topRight);
|
|
const bl = theme.fg("dim", theme.boxRound.bottomLeft);
|
|
const br = theme.fg("dim", theme.boxRound.bottomRight);
|
|
|
|
const lines: string[] = [];
|
|
|
|
// Top border with embedded title
|
|
const title = ` ${APP_NAME} v${this.version} `;
|
|
const titlePrefixRaw = hChar.repeat(3);
|
|
const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
|
|
const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
|
|
const titleSpace = boxWidth - 2;
|
|
if (titleVisLen >= titleSpace) {
|
|
lines.push(tl + truncateToWidth(titleStyled, titleSpace) + tr);
|
|
} else {
|
|
const afterTitle = titleSpace - titleVisLen;
|
|
lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
|
|
}
|
|
|
|
// Content rows
|
|
const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
|
|
for (let i = 0; i < maxRows; i++) {
|
|
const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
|
|
if (showRightColumn) {
|
|
const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
|
|
lines.push(v + left + v + right + v);
|
|
} else {
|
|
lines.push(v + left + v);
|
|
}
|
|
}
|
|
// Bottom border
|
|
if (showRightColumn) {
|
|
lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
|
|
} else {
|
|
lines.push(bl + h.repeat(leftCol) + br);
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
/** Center text within a given width */
|
|
#centerText(text: string, width: number): string {
|
|
const visLen = visibleWidth(text);
|
|
if (visLen >= width) {
|
|
return truncateToWidth(text, width);
|
|
}
|
|
const leftPad = Math.floor((width - visLen) / 2);
|
|
const rightPad = width - visLen - leftPad;
|
|
return padding(leftPad) + text + padding(rightPad);
|
|
}
|
|
|
|
/** Apply magenta→cyan gradient to a string */
|
|
#gradientLine(line: string): string {
|
|
const colors = [
|
|
"\x1b[38;5;199m", // bright magenta
|
|
"\x1b[38;5;171m", // magenta-purple
|
|
"\x1b[38;5;135m", // purple
|
|
"\x1b[38;5;99m", // purple-blue
|
|
"\x1b[38;5;75m", // cyan-blue
|
|
"\x1b[38;5;51m", // bright cyan
|
|
];
|
|
const reset = "\x1b[0m";
|
|
|
|
let result = "";
|
|
let colorIdx = 0;
|
|
const step = Math.max(1, Math.floor(line.length / colors.length));
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
if (i > 0 && i % step === 0 && colorIdx < colors.length - 1) {
|
|
colorIdx++;
|
|
}
|
|
const char = line[i];
|
|
if (char !== " ") {
|
|
result += colors[colorIdx] + char + reset;
|
|
} else {
|
|
result += char;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Fit string to exact width with ANSI-aware truncation/padding */
|
|
#fitToWidth(str: string, width: number): string {
|
|
const visLen = visibleWidth(str);
|
|
if (visLen > width) {
|
|
const ellipsis = "…";
|
|
const ellipsisWidth = visibleWidth(ellipsis);
|
|
const maxWidth = Math.max(0, width - ellipsisWidth);
|
|
let truncated = "";
|
|
let currentWidth = 0;
|
|
let inEscape = false;
|
|
for (const char of str) {
|
|
if (char === "\x1b") inEscape = true;
|
|
if (inEscape) {
|
|
truncated += char;
|
|
if (char === "m") inEscape = false;
|
|
} else if (currentWidth < maxWidth) {
|
|
truncated += char;
|
|
currentWidth++;
|
|
}
|
|
}
|
|
return `${truncated}${ellipsis}`;
|
|
}
|
|
return str + padding(width - visLen);
|
|
}
|
|
}
|