/** * Screen renderer — ANSI-based TUI-like UX with 2-column layout. * Left: project tree. Right: resource detail panel (when selected). * * @module */ import boxen from "boxen"; import chalk from "chalk"; import type { ICoolifyInfrastructureTree, ICoolifyApplication, ICoolifyResource, } from "../../coolify/types.js"; import { loadConfig, getCachedAppSettings } from "../../coolify/config.js"; import { getMiniLogoLines } from "./banner.js"; // ─── ANSI ──────────────────────────────────────────────────────────────────── export function clearScreen(): void { process.stdout.write("\x1b[2J\x1b[H"); } function termCols(): number { return process.stdout.columns || 80; } function stripAnsi(str: string): string { // Strip CSI sequences (colors), OSC 8 hyperlinks, and other escapes return str .replace(/\x1b\[[0-9;]*m/g, "") // CSI color/style .replace(/\x1b\]8;;[^\x07]*\x07/g, ""); // OSC 8 hyperlink open/close } // ─── Icons ─────────────────────────────────────────────────────────────────── function statusDot(status: string): string { if (status.includes("healthy") && !status.includes("unhealthy")) return chalk.green("●"); if (status.includes("unhealthy")) return chalk.red("●"); if (status.startsWith("running")) return chalk.yellow("○"); if (status.includes("exited")) return chalk.red("✗"); return chalk.gray("○"); } function kindTag(kind: string): string { if (kind === "database") return chalk.blue("[db]"); if (kind === "service") return chalk.magenta("[svc]"); return ""; } function stripProtocol(url: string): string { return url.replace(/^https?:\/\//, "").split(",")[0].trim(); } /** * Make a domain/URL clickable using OSC 8 hyperlink escape sequences. * Supported by most modern terminals (iTerm2, Ghostty, Wezterm, etc). */ /** * OSC 8 hyperlink. IMPORTANT: always emit close sequence on same line. * Do NOT use inside box/panel content that gets padded or truncated. * Safe for: tree resource lines, resource headers, standalone log lines. */ function hyperlink(url: string, text: string): string { const fullUrl = url.startsWith("http") ? url : `https://${url}`; // Open link, render text, close link — all inline, no line breaks return `\x1b]8;;${fullUrl}\x07${text}\x1b]8;;\x07`; } /** * Render a domain as a clickable link with color. * Only use on standalone lines — NOT inside boxes or padded columns. */ function clickableDomain(fqdn: string, color: string = "#88bbff"): string { const firstFqdn = fqdn.split(",")[0].trim(); const domain = stripProtocol(firstFqdn); const url = firstFqdn.startsWith("http") ? firstFqdn : `https://${domain}`; return hyperlink(url, chalk.hex(color)(domain)); } /** * Render a domain as plain colored text (safe for boxes/padded columns). */ function plainDomain(fqdn: string, color: string = "#88bbff"): string { return chalk.hex(color)(stripProtocol(fqdn)); } function projectStatusSummary(statuses: string[]): string { const h = statuses.filter((s) => s.includes("healthy") && !s.includes("unhealthy")).length; const x = statuses.filter((s) => s.includes("exited")).length; const u = statuses.filter((s) => s.includes("unhealthy")).length; const p: string[] = []; if (h > 0) p.push(chalk.green(`●${h}`)); if (x > 0) p.push(chalk.red(`✗${x}`)); if (u > 0) p.push(chalk.red(`!${u}`)); return p.join(" "); } // ─── Context ───────────────────────────────────────────────────────────────── export interface IScreenContext { tree: ICoolifyInfrastructureTree; cwdProjectUuid?: string; focusedProjectUuid?: string; activeResourceUuid?: string; breadcrumb?: string[]; /** Full app data for the detail panel (fetched with getApplication). */ activeAppDetail?: ICoolifyApplication; /** Log preview lines to show in a bottom panel. */ logPreview?: string[]; /** * When true, the expanded project shows only its header (▾ name), * NOT the resource list — @clack/prompts renders them inline below. */ promptInline?: boolean; } // ─── Header ────────────────────────────────────────────────────────────────── let cachedMiniLogo: string[] | null = null; function miniLogo(): string[] { if (!cachedMiniLogo) cachedMiniLogo = getMiniLogoLines(); return cachedMiniLogo; } function renderHeader(tree: ICoolifyInfrastructureTree): void { const c = tree.counts; const config = loadConfig(); const coolifyUrl = config.url || process.env.COOLIFY_URL || ""; let serverDisplay: string; try { const url = new URL(coolifyUrl); const host = url.hostname; serverDisplay = /^\d+\.\d+\.\d+\.\d+$/.test(host) ? `${tree.server.name} (${host})` : host; } catch { serverDisplay = tree.server.ip ? `${tree.server.name} (${tree.server.ip})` : tree.server.name; } const logo = miniLogo(); const logoW = logo.reduce((m, l) => Math.max(m, stripAnsi(l).length), 0); const textLines = [ "", `${chalk.bold.hex("#a875ff")("Coolify CLI")} ${chalk.hex("#777777")("v0.9.0")}`, "", `${chalk.hex("#cccccc")(serverDisplay)}`, "", `${chalk.green("●")} ${chalk.hex("#cccccc")(String(c.healthy))} healthy ${chalk.yellow("○")} ${chalk.hex("#cccccc")(String(c.running))} running ${chalk.red("✗")} ${chalk.hex("#cccccc")(String(c.stopped))} stopped`, `${chalk.hex("#777777")(`${c.apps} apps ${c.databases} databases ${c.services} services`)}`, "", ]; const maxLines = Math.max(logo.length, textLines.length); const textStart = Math.max(0, Math.floor((logo.length - textLines.length) / 2)); for (let i = 0; i < maxLines; i++) { const lp = i < logo.length ? logo[i] : ""; const pad = " ".repeat(Math.max(0, logoW - stripAnsi(lp).length)); const ti = i - textStart; const tp = ti >= 0 && ti < textLines.length ? " " + textLines[ti] : ""; logLine(` ${lp}${pad}${tp}`); } } // ─── Main render ───────────────────────────────────────────────────────────── /** Line counter — tracks how many lines we've output. */ let _lineCount = 0; function logLine(text: string): void { console.log(text); _lineCount++; } /** * Render the screen. Returns layout info for positioning the inline selector. */ export function renderScreen(ctx: IScreenContext): { promptRow: number; promptCol: number; } { clearScreen(); _lineCount = 0; const w = Math.min(termCols() - 2, 78); renderHeader(ctx.tree); // updates _lineCount via logLine renderBreadcrumb(ctx.breadcrumb); logLine(chalk.hex("#444444")("─".repeat(w))); // Track tree start row for inline selector positioning const treeStartRow = _lineCount + 1; // 1-based row where tree begins if (ctx.activeResourceUuid && ctx.activeAppDetail && !ctx.promptInline) { // Full 2-col layout only when NOT showing inline selector (e.g. non-interactive) renderTwoColumnLayout(ctx, w); } else if (ctx.activeResourceUuid && ctx.activeAppDetail && ctx.promptInline) { // Tree only — menu will render in right col via inlineSelect renderTreeCounted(ctx, w); } else if (ctx.promptInline && !ctx.focusedProjectUuid && !ctx.cwdProjectUuid) { // No tree to show — the inline selector IS the navigation // Just leave space for the selector to render } else { renderTreeCounted(ctx, w); } // Log preview — full-width bottom section if (ctx.logPreview && ctx.logPreview.length > 0) { logLine(chalk.hex("#666666")(` ╶─ ${chalk.hex("#888888")("recent logs")} ${"─".repeat(Math.max(0, w - 20))}`)); for (const line of ctx.logPreview) { logLine(` ${chalk.hex("#444444")("│")} ${line}`); } } if (!ctx.promptInline) { logLine(chalk.hex("#444444")("─".repeat(w))); } // Inline selector starts at the first tree row, right column const promptRow = treeStartRow; const promptCol = 38; return { promptRow, promptCol }; } /** * Render the tree and return the 1-based row of the expanded project header. */ function renderTreeCounted(ctx: IScreenContext, w: number): number { const { tree, cwdProjectUuid, focusedProjectUuid, activeResourceUuid, promptInline } = ctx; const expandUuid = focusedProjectUuid || cwdProjectUuid; let expandedRow = 0; for (const project of tree.projects) { const shouldExpand = project.uuid === expandUuid; const isCwd = project.uuid === cwdProjectUuid; const totalRes = project.environments.reduce((s, e) => s + e.resources.length, 0); const statuses = project.environments.flatMap((e) => e.resources.map((r) => r.status)); const marker = isCwd ? chalk.cyan(" ←") : ""; if (shouldExpand) { const ns = isCwd ? chalk.bold.cyan(project.name) : chalk.bold(project.name); expandedRow = _lineCount + 1; // 1-based logLine(` ${chalk.white("▾")} ${ns}${marker}`); if (!promptInline) { for (const env of project.environments) { if (project.environments.length > 1) logLine(` ${chalk.gray(env.name)}`); const indent = project.environments.length > 1 ? " " : " "; for (let i = 0; i < env.resources.length; i++) { const res = env.resources[i]; const isLast = i === env.resources.length - 1; const isActive = res.uuid === activeResourceUuid; const conn = isLast ? "└─" : "├─"; const dot = statusDot(res.status); const tag = kindTag(res.kind); const tagStr = tag ? `${tag} ` : ""; let name = res.name.length > 20 ? res.name.slice(0, 19) + "…" : res.name; if (isActive) name = chalk.bold.underline(name); const avail = w - indent.length - 10 - (tag ? 6 : 0) - 20; const domain = res.fqdn && avail > 10 ? ` ${plainDomain(res.fqdn, "#668899")}` : ""; const am = isActive ? chalk.cyan(" ◂") : ""; logLine(`${indent}${chalk.gray(conn)} ${dot} ${tagStr}${name}${domain}${am}`); } } } } else { const summary = projectStatusSummary(statuses); logLine(` ${chalk.gray("▸")} ${chalk.hex("#aaaaaa")(project.name)} ${chalk.gray(`(${totalRes})`)} ${summary}${marker}`); } } return expandedRow; } function renderBreadcrumb(bc?: string[]): void { if (!bc || bc.length === 0) return; logLine( chalk.gray(" ") + bc.map((b) => chalk.white(b)).join(chalk.gray(" › ")), ); } // ─── Two-column layout ─────────────────────────────────────────────────────── /** * Renders tree on left (~35 chars) and detail panel on right. */ function renderTwoColumnLayout(ctx: IScreenContext, totalWidth: number): void { const treeLines = buildTreeLines(ctx); const detailLines = buildDetailPanel(ctx.activeAppDetail!, totalWidth); // Build right column: detail + log preview (if available) const rightW = totalWidth - 40; const rightLines = [...detailLines]; if (ctx.logPreview && ctx.logPreview.length > 0) { rightLines.push(""); rightLines.push(chalk.hex("#666666")("╶─ recent logs ─────────────")); for (const raw of ctx.logPreview) { // Preserve colors: don't slice raw string (breaks ANSI). // Instead, just let long lines overflow — the terminal clips them. rightLines.push(raw); } } const leftW = 38; const maxLines = Math.max(treeLines.length, rightLines.length); for (let i = 0; i < maxLines; i++) { const left = i < treeLines.length ? treeLines[i] : ""; const right = i < rightLines.length ? rightLines[i] : ""; const leftVisible = stripAnsi(left).length; const pad = " ".repeat(Math.max(1, leftW - leftVisible)); logLine(`${left}${pad}${chalk.hex("#444444")("│")} ${right}`); } } /** * Build tree as string lines (for 2-col layout). */ function buildTreeLines(ctx: IScreenContext): string[] { const lines: string[] = []; const { tree, cwdProjectUuid, focusedProjectUuid, activeResourceUuid } = ctx; const expandUuid = focusedProjectUuid || cwdProjectUuid; for (const project of tree.projects) { const shouldExpand = project.uuid === expandUuid; const isCwd = project.uuid === cwdProjectUuid; const totalRes = project.environments.reduce((s, e) => s + e.resources.length, 0); const statuses = project.environments.flatMap((e) => e.resources.map((r) => r.status)); const marker = isCwd ? chalk.cyan(" ←") : ""; if (shouldExpand) { const ns = isCwd ? chalk.bold.cyan(project.name) : chalk.bold(project.name); lines.push(` ${chalk.white("▾")} ${ns}${marker}`); for (const env of project.environments) { const indent = " "; for (let i = 0; i < env.resources.length; i++) { const res = env.resources[i]; const isLast = i === env.resources.length - 1; const isActive = res.uuid === activeResourceUuid; const conn = isLast ? "└─" : "├─"; const dot = statusDot(res.status); const tag = kindTag(res.kind); const tagStr = tag ? `${tag} ` : ""; let name = res.name.length > 16 ? res.name.slice(0, 15) + "…" : res.name; if (isActive) name = chalk.bold.underline(name); const am = isActive ? chalk.cyan(" ◂") : ""; lines.push(`${indent}${chalk.gray(conn)} ${dot} ${tagStr}${name}${am}`); } } } else { const summary = projectStatusSummary(statuses); lines.push(` ${chalk.gray("▸")} ${chalk.gray(project.name)} ${chalk.gray(`(${totalRes})`)} ${summary}${marker}`); } } return lines; } /** * Build detail panel lines for the right column. */ function buildDetailPanel( app: ICoolifyApplication, totalWidth: number, ): string[] { const lines: string[] = []; const rightW = totalWidth - 40; const dot = statusDot(app.status); lines.push(`${dot} ${chalk.bold.hex("#cccccc")(app.name)}`); lines.push(chalk.hex("#888888")(app.status)); lines.push(""); if (app.fqdn) { lines.push(`${chalk.hex("#888888")("Domain:")} ${plainDomain(app.fqdn)}`); } const fields: Array<[string, string | undefined | null]> = [ ["Repo", app.git_repository], ["Branch", app.git_branch], ["Build", app.build_pack], ["Ports", app.ports_exposes], ["Dockerfile", app.dockerfile_location], ]; for (const [label, value] of fields) { if (value) { const truncated = value.length > rightW - 12 ? value.slice(0, rightW - 15) + "…" : value; lines.push(`${chalk.hex("#888888")(label + ":")} ${chalk.hex("#cccccc")(truncated)}`); } } if (app.destination?.server) { lines.push(""); lines.push( `${chalk.hex("#888888")("Server:")} ${chalk.hex("#cccccc")(app.destination.server.name)} ${chalk.hex("#668899")("(" + app.destination.server.ip + ")")}`, ); } // Deploy settings (auto-deploy from API settings or local cache, watch_paths from API) lines.push(""); const autoDeployFromApi = app.settings?.is_auto_deploy_enabled; // getCachedAppSettings is async but we need sync here — use a pre-fetched value // passed via the context, or show API value / fallback if (autoDeployFromApi !== undefined) { lines.push( `${chalk.hex("#888888")("Auto-deploy:")} ${autoDeployFromApi ? chalk.green("ON") : chalk.red("OFF")}`, ); } if (app.watch_paths) { const paths = app.watch_paths.split("\n").filter(Boolean); lines.push(`${chalk.hex("#888888")("Watch:")} ${chalk.hex("#cccccc")(paths[0])}`); for (const p of paths.slice(1, 3)) { lines.push(` ${chalk.hex("#cccccc")(p)}`); } if (paths.length > 3) lines.push(chalk.hex("#555555")(` +${paths.length - 3} more`)); } if (app.install_command || app.build_command || app.start_command) { lines.push(""); if (app.install_command) lines.push(`${chalk.hex("#888888")("Install:")} ${chalk.hex("#aaaaaa")(app.install_command.slice(0, rightW - 10))}`); if (app.build_command) lines.push(`${chalk.hex("#888888")("Build:")} ${chalk.hex("#aaaaaa")(app.build_command.slice(0, rightW - 10))}`); if (app.start_command) lines.push(`${chalk.hex("#888888")("Start:")} ${chalk.hex("#aaaaaa")(app.start_command.slice(0, rightW - 10))}`); } return lines; } // ─── Single-column tree (when no detail panel) ────────────────────────────── // ─── Live preview panel (rendered at a position, updated on highlight) ─────── /** * Render a compact detail panel at a specific row/col. * Called by the onHighlight callback when navigating the tree selector. * Clears previous content and writes new detail lines. */ export function renderPreviewAt( row: number, col: number, lines: string[], maxLines: number = 18, ): void { const w = Math.max(0, termCols() - col - 2); // Clear at least as many lines as the content OR maxLines (whichever is bigger) const clearCount = Math.max(maxLines, lines.length + 1); for (let i = 0; i < clearCount; i++) { process.stdout.write(`\x1b[${row + i};${col}H\x1b[K`); // move + clear to EOL if (i < lines.length) { const line = lines[i]; const visible = stripAnsi(line); if (visible.length > w) { process.stdout.write(line.slice(0, w + (line.length - visible.length))); } else { process.stdout.write(line); } } } } /** * Build preview lines for a project — stats, health, environments. */ /** Fixed box dimensions for the preview panel. */ const PREVIEW_BOX_W = 36; const PREVIEW_BOX_H = 13; export function buildProjectPreview( project: { name: string; uuid: string; description?: string | null; environments: Array<{ name: string; resources: ICoolifyResource[] }> }, maxWidth: number = 40, ): string[] { const allRes = project.environments.flatMap((e) => e.resources); const apps = allRes.filter((r) => r.kind === "app"); const dbs = allRes.filter((r) => r.kind === "database"); const svcs = allRes.filter((r) => r.kind === "service"); const healthyN = allRes.filter((r) => r.status.includes("healthy") && !r.status.includes("unhealthy")).length; const stoppedN = allRes.filter((r) => r.status.includes("exited")).length; const unhealthyN = allRes.filter((r) => r.status.includes("unhealthy")).length; const cw = Math.min(PREVIEW_BOX_W, maxWidth - 4); const inner: string[] = []; // Centered title const titleText = project.name; const titlePad = Math.max(0, Math.floor((cw - titleText.length) / 2)); inner.push(" ".repeat(titlePad) + chalk.bold.hex("#a875ff")(titleText)); inner.push(chalk.hex("#444444")("─".repeat(cw))); // Description if exists if (project.description && project.description.trim()) { const desc = project.description.trim(); inner.push(chalk.hex("#999999")(desc.length > cw ? desc.slice(0, cw - 1) + "…" : desc)); } // Stats line — compact const statParts = [ apps.length > 0 ? `${chalk.hex("#cccccc").bold(String(apps.length))} ${chalk.hex("#777777")("apps")}` : "", dbs.length > 0 ? `${chalk.hex("#cccccc").bold(String(dbs.length))} ${chalk.hex("#777777")("dbs")}` : "", svcs.length > 0 ? `${chalk.hex("#cccccc").bold(String(svcs.length))} ${chalk.hex("#777777")("svcs")}` : "", ].filter(Boolean).join(chalk.hex("#555555")(" · ")); inner.push(statParts); // Health — single line const hp: string[] = []; if (healthyN > 0) hp.push(`${chalk.green("●")} ${healthyN}`); if (stoppedN > 0) hp.push(`${chalk.red("✗")} ${stoppedN}`); if (unhealthyN > 0) hp.push(`${chalk.red("!")} ${unhealthyN}`); inner.push(hp.join(" ")); inner.push(""); // Environment inner.push(`${chalk.hex("#777777")("env")} ${chalk.hex("#cccccc")(project.environments.map((e) => e.name).join(", "))}`); // Domains — plain text inside box (OSC 8 breaks box padding) const fqdns = allRes .map((r) => r.fqdn) .filter((f): f is string => !!f && f.trim().length > 0); if (fqdns.length > 0) { inner.push(""); for (const fqdn of fqdns.slice(0, 3)) { const d = stripProtocol(fqdn); const short = d.length > cw - 4 ? d.slice(0, cw - 5) + "…" : d; inner.push(`${chalk.hex("#555555")("→")} ${chalk.hex("#88bbff")(short)}`); } if (fqdns.length > 3) inner.push(chalk.hex("#555555")(` +${fqdns.length - 3} more`)); } return wrapInBox(inner, cw, PREVIEW_BOX_H); } /** * Wrap content in a fixed-size box. Content is padded/truncated to fit. */ function wrapInBox(content: string[], width: number, height: number): string[] { const border = chalk.hex("#444444"); const lines: string[] = []; lines.push(border(`╭${"─".repeat(width + 2)}╮`)); for (let i = 0; i < height; i++) { const raw = i < content.length ? content[i] : ""; const visLen = stripAnsi(raw).length; let display = raw; if (visLen > width) { // Truncate — keep ANSI but cut visible chars display = raw.slice(0, width + (raw.length - visLen) - 1) + "…"; const newVisLen = Math.min(visLen, width); const pad = " ".repeat(Math.max(0, width - newVisLen)); lines.push(`${border("│")} ${display}${pad} ${border("│")}`); } else { const pad = " ".repeat(width - visLen); lines.push(`${border("│")} ${display}${pad} ${border("│")}`); } } lines.push(border(`╰${"─".repeat(width + 2)}╯`)); return lines; } /** * Build preview lines for a resource (app detail). */ export function buildResourcePreview(app: ICoolifyApplication): string[] { const lines: string[] = [ `${statusDot(app.status)} ${chalk.bold.hex("#cccccc")(app.name)}`, chalk.hex("#888888")(app.status), "", ]; const fields: [string, string | undefined | null][] = [ ["Domain", app.fqdn ? stripProtocol(app.fqdn) : null], ["Repo", app.git_repository], ["Branch", app.git_branch], ["Build", app.build_pack], ["Ports", app.ports_exposes], ["Dockerfile", app.dockerfile_location], ]; for (const [label, value] of fields) { if (value) lines.push(`${chalk.hex("#888888")(label + ":")} ${chalk.hex("#cccccc")(value)}`); } if (app.watch_paths) { const paths = app.watch_paths.split("\n").filter(Boolean); lines.push(`${chalk.hex("#888888")("Watch:")} ${chalk.hex("#cccccc")(paths[0])}`); if (paths.length > 1) lines.push(chalk.hex("#555555")(` +${paths.length - 1} more`)); } if (app.destination?.server) { lines.push(""); lines.push(`${chalk.hex("#888888")("Server:")} ${chalk.hex("#cccccc")(app.destination.server.name)}`); } return lines; } // ─── Resource header (no detail panel fallback) ────────────────────────────── export function renderResourceHeader( name: string, kind: string, status: string, fqdn?: string | null, ): void { const dot = statusDot(status); const tag = kindTag(kind); const tagStr = tag ? `${tag} ` : ""; const domain = fqdn ? ` ${clickableDomain(fqdn)}` : ""; console.log(` ${dot} ${tagStr}${chalk.bold.hex("#cccccc")(name)}${domain}`); console.log(chalk.hex("#888888")(` ${status}\n`)); } // ─── Wait for key ──────────────────────────────────────────────────────────── export function waitForKey(message?: string): Promise { return new Promise((resolve) => { const msg = message || chalk.gray("\n Press Enter to continue..."); process.stdout.write(msg); if (!process.stdin.isTTY) { resolve(); return; } const wasRaw = process.stdin.isRaw; process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdin.once("data", () => { process.stdin.setRawMode?.(wasRaw ?? false); process.stdin.pause(); resolve(); }); }); }