Files
oh-my-pi/packages/coding-agent/src/modes/components/welcome.ts
T
can1357 f10349a2f9 tweak(welcome): slim logo, diagonal gradient
- Trim logo from 14w to 12w (2-wide legs).
- Diagonal BL→TR gradient instead of per-line LTR.
- Truecolor: 3-stop magenta→violet→cyan path that skips the deep-blue valley.
- 256-color: same 6-stop ramp as fallback.
- Compute the colored logo once at module load instead of per render.
2026-05-10 04:53:53 +02:00

335 lines
12 KiB
TypeScript

import { type Component, padding, TERMINAL, 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 {
#animStart: number | null = null;
#animTimer: ReturnType<typeof setInterval> | null = null;
constructor(
private readonly version: string,
private modelName: string,
private providerName: string,
private recentSessions: RecentSession[] = [],
private lspServers: LspServerInfo[] = [],
) {}
invalidate(): void {}
/**
* Play a one-shot intro that sweeps the gradient through every phase
* before settling on the resting frame. Safe to call multiple times —
* subsequent calls reset and replay.
*/
playIntro(requestRender: () => void): void {
this.#stopAnimation();
this.#animStart = performance.now();
requestRender();
this.#animTimer = setInterval(() => {
const elapsed = performance.now() - (this.#animStart ?? 0);
if (elapsed >= INTRO_MS) {
this.#stopAnimation();
}
requestRender();
}, INTRO_MS / INTRO_PHASES);
}
#stopAnimation(): void {
if (this.#animTimer != null) {
clearInterval(this.#animTimer);
this.#animTimer = null;
}
this.#animStart = null;
}
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 = 12; // 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;
// Logo: pick a frame from the intro animation if active, else the resting frame.
const logoColored = this.#currentLogoFrame();
// 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 {
// Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
// so the relative time is never the part that gets truncated. The name
// absorbs whatever space is left.
const bulletPrefix = ` ${theme.md.bullet} `;
const prefixWidth = visibleWidth(bulletPrefix);
for (const session of this.recentSessions.slice(0, 3)) {
const timeSuffixRaw = ` (${session.timeAgo})`;
const timeWidth = visibleWidth(timeSuffixRaw);
const nameBudget = Math.max(1, rightCol - prefixWidth - timeWidth);
const nameVis = visibleWidth(session.name);
const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
sessionLines.push(
`${theme.fg("dim", bulletPrefix)}${theme.fg("muted", name)}${theme.fg("dim", timeSuffixRaw)}`,
);
}
}
// 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);
}
/** 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);
}
/** Pick the logo frame for the current intro phase, or the resting frame. */
#currentLogoFrame(): readonly string[] {
if (this.#animStart == null) return LOGO_FRAMES[0];
const elapsed = performance.now() - this.#animStart;
if (elapsed >= INTRO_MS) return LOGO_FRAMES[0];
// Ease-out cubic so the sweep settles into the resting frame instead of
// stopping abruptly. Sweeps backward through the phase ring → lands on 0.
const progress = elapsed / INTRO_MS;
const eased = 1 - (1 - progress) ** 3;
const stepsDone = Math.min(INTRO_PHASES - 1, Math.floor(eased * INTRO_PHASES));
const idx = (INTRO_PHASES - stepsDone) % INTRO_PHASES;
return LOGO_FRAMES[idx];
}
}
// biome-ignore format: preserve ASCII art layout
const PI_LOGO = ["▀██████████▀", " ╘██ ██ ", " ██ ██ ", " ██ ██ ", " ▄██▄ ▄██▄ "];
/**
* Apply magenta→cyan diagonal gradient (bottom-left → top-right) across multi-line art.
* `phase` (0..1) shifts the gradient along the diagonal, wrapping at 1.
*/
function gradientLogo(lines: readonly string[], phase = 0): string[] {
const reset = "\x1b[0m";
const rows = lines.length;
const cols = Math.max(...lines.map(l => l.length));
// span+1 so `base` stays strictly < 1: avoids the wrap-around at the
// far corner mapping back to t=0 (magenta) on the resting frame.
const span = Math.max(1, cols + rows - 1);
const colorAt = TERMINAL.trueColor
? (t: number): string => {
// Multi-stop gradient: hot magenta → light violet → bright cyan.
// Picked stops avoid the deep-blue valley a naive HSL lerp falls into.
const stops: [number, number, number][] = [
[255, 62, 201], // hot magenta-pink
[180, 120, 255], // light violet
[62, 230, 255], // bright cyan
];
const seg = t * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(seg));
const f = seg - i;
const a = stops[i];
const b = stops[i + 1];
const r = Math.round(a[0] + (b[0] - a[0]) * f);
const g = Math.round(a[1] + (b[1] - a[1]) * f);
const bl = Math.round(a[2] + (b[2] - a[2]) * f);
return `\x1b[38;2;${r};${g};${bl}m`;
}
: (t: number): string => {
const ramp = [199, 171, 135, 99, 75, 51];
const idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
return `\x1b[38;5;${ramp[idx]}m`;
};
return lines.map((line, y) => {
let result = "";
for (let x = 0; x < line.length; x++) {
const char = line[x];
if (char === " ") {
result += char;
continue;
}
// Diagonal: bottom-left (x=0, y=rows-1) → top-right (x=cols-1, y=0)
const base = (x + (rows - 1 - y)) / span;
const t = (((base + phase) % 1) + 1) % 1;
result += colorAt(t) + char + reset;
}
return result;
});
}
/** Intro animation: how many discrete gradient phases and total duration. */
const INTRO_PHASES = 60;
const INTRO_MS = 2000;
/**
* Pre-rendered logo frames, one per phase. Frame 0 is the resting state;
* the intro sweeps frames in reverse so it lands on frame 0.
*/
const LOGO_FRAMES: readonly (readonly string[])[] = Array.from({ length: INTRO_PHASES }, (_, i) =>
gradientLogo(PI_LOGO, i / INTRO_PHASES),
);