import { readdirSync, existsSync, statSync, readFileSync } from "node:fs"; import { join, basename } from "node:path"; import { homedir as osHomedir } from "node:os"; import { CONFIG_DIR_NAME, getAgentDir, getSettingsPath } from "@selesai/code"; import type { Component } from "@earendil-works/pi-tui"; import { truncateToWidth as tuiTruncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import { ansi, fgOnly, getFgAnsiCode } from "./colors.ts"; import { GUIDE_COMPACT_LINES, GUIDE_DISMISS_HINT, getGuideFeatureLines, type GuideFeature, type GuideMode } from "./guide.ts"; export interface RecentSession { name: string; timeAgo: string; } export interface LoadedCounts { contextFiles: number; extensions: number; skills: number; promptTemplates: number; } // ═══════════════════════════════════════════════════════════════════════════ // Shared rendering utilities // ═══════════════════════════════════════════════════════════════════════════ const PI_LOGO = [ "███ ███ █ ███ ███ ███ █", "█ █ █ █ █ █ █ █", "█ █ █ █ █ █ █ █", "███ ███ █ ███ ███ ███ █", " █ █ █ █ █ █ █ █", " █ █ █ █ █ █ █ █", "███ ███ ███ ███ ███ █ █ █", ]; const GRADIENT_COLORS = [ "\x1b[38;5;199m", "\x1b[38;5;171m", "\x1b[38;5;135m", "\x1b[38;5;99m", "\x1b[38;5;75m", "\x1b[38;5;51m", ]; function bold(text: string): string { return `\x1b[1m${text}\x1b[22m`; } function dim(text: string): string { return getFgAnsiCode("sep") + text + ansi.reset; } function gradientLine(line: string): string { const reset = ansi.reset; let result = ""; let colorIdx = 0; const step = Math.max(1, Math.floor(line.length / GRADIENT_COLORS.length)); for (let i = 0; i < line.length; i++) { if (i > 0 && i % step === 0 && colorIdx < GRADIENT_COLORS.length - 1) colorIdx++; const char = line[i]; if (char !== " ") { result += GRADIENT_COLORS[colorIdx] + char + reset; } else { result += char; } } return result; } function centerText(text: string, width: number): string { const visLen = visibleWidth(text); if (visLen > width) return tuiTruncateToWidth(text, width, "…"); if (visLen === width) return text; const leftPad = Math.floor((width - visLen) / 2); const rightPad = width - visLen - leftPad; return " ".repeat(leftPad) + text + " ".repeat(rightPad); } function fitToWidth(str: string, width: number): string { const visLen = visibleWidth(str); if (visLen > width) return tuiTruncateToWidth(str, width, "…"); return str + " ".repeat(width - visLen); } function wrapLines(lines: string[], width: number): string[] { const continuation = " "; const contentWidth = Math.max(1, width - continuation.length); return lines.flatMap((line) => wrapTextWithAnsi(line, contentWidth) .map((wrapped, index) => index === 0 ? wrapped : continuation + wrapped)); } interface WelcomeData { modelName: string; providerName: string; recentSessions: RecentSession[]; loadedCounts: LoadedCounts; guideMode: GuideMode; newFeatures: GuideFeature[]; } function buildLeftColumn(data: WelcomeData, colWidth: number): string[] { const logoColored = PI_LOGO.map((line) => gradientLine(line)); return [ "", centerText(bold("Welcome back!"), colWidth), "", ...logoColored.map((l) => centerText(l, colWidth)), "", centerText(fgOnly("model", data.modelName), colWidth), centerText(dim(data.providerName), colWidth), ]; } function buildRightColumn(data: WelcomeData, colWidth: number): string[] { const hChar = "─"; const separator = ` ${dim(hChar.repeat(colWidth - 2))}`; const guideLines = data.guideMode === "compact" ? [ ` ${bold(fgOnly("accent", "Guide"))}`, ...(data.newFeatures.length > 0 ? [` ${bold(fgOnly("model", `New: ${data.newFeatures.slice(0, 2).map((feature) => feature.title).join(" · ")}`))}`] : []), ...GUIDE_COMPACT_LINES.map((line) => ` ${dim(line)}`), ] : [ ` ${bold(fgOnly("accent", "Selesai guide"))}`, ...(data.newFeatures.length > 0 ? [ ` ${bold(fgOnly("model", "New since your last guide"))}`, ...data.newFeatures.slice(0, 3).map((feature) => ` ${dim("• ")}${feature.title}`), ] : []), ...getGuideFeatureLines().map((line) => line.kind === "section" ? ` ${bold(fgOnly("accent", line.text))}` : ` ${dim("• ")}${line.feature.title}: ${dim(line.feature.example)}`), ]; // Session lines const sessionLines: string[] = []; if (data.recentSessions.length === 0) { sessionLines.push(` ${dim("No recent sessions")}`); } else { for (const session of data.recentSessions.slice(0, 3)) { sessionLines.push( ` ${dim("• ")}${fgOnly("path", session.name)}${dim(` (${session.timeAgo})`)}`, ); } } // Loaded counts lines const countLines: string[] = []; const { contextFiles, extensions, skills, promptTemplates } = data.loadedCounts; const itemPrefix = dim("- "); if (contextFiles > 0 || extensions > 0 || skills > 0 || promptTemplates > 0) { if (contextFiles > 0) { countLines.push(` ${itemPrefix}${fgOnly("gitClean", `${contextFiles}`)} context file${contextFiles !== 1 ? "s" : ""}`); } if (extensions > 0) { countLines.push(` ${itemPrefix}${fgOnly("gitClean", `${extensions}`)} extension${extensions !== 1 ? "s" : ""}`); } if (skills > 0) { countLines.push(` ${itemPrefix}${fgOnly("gitClean", `${skills}`)} skill${skills !== 1 ? "s" : ""}`); } if (promptTemplates > 0) { countLines.push(` ${itemPrefix}${fgOnly("gitClean", `${promptTemplates}`)} prompt template${promptTemplates !== 1 ? "s" : ""}`); } } else { countLines.push(` ${dim("No extensions loaded")}`); } const dashboardLines = data.guideMode === "full" ? [] : [ separator, ` ${bold(fgOnly("accent", "Loaded"))}`, ...countLines, separator, ` ${bold(fgOnly("accent", "Recent sessions"))}`, ...sessionLines, ]; return [ ` ${bold(fgOnly("accent", "Tips"))}: ${dim("/")} commands · ${dim("/settings")} configure`, ` ${dim("Shift+Tab")} cycle thinking`, separator, ...guideLines, ...dashboardLines, "", ]; } function renderWelcomeBox( data: WelcomeData, termWidth: number, bottomLine: string, ): string[] { // Minimum width for two-column layout: leftCol(26) + separator(3) + minRightCol(15) = 44 const minLayoutWidth = 44; // If terminal is too narrow for the layout, return empty (skip welcome box) if (termWidth < minLayoutWidth) { return []; } const minWidth = 76; const maxWidth = data.guideMode === "full" ? 132 : 96; // Clamp to termWidth to prevent crash on narrow terminals. const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth))); const leftCol = 26; const rightCol = Math.max(1, boxWidth - leftCol - 3); // Ensure rightCol is at least 1 const hChar = "─"; const v = dim("│"); const tl = dim("╭"); const tr = dim("╮"); const bl = dim("╰"); const br = dim("╯"); const leftLines = buildLeftColumn(data, leftCol); const rightLines = wrapLines(buildRightColumn(data, rightCol), rightCol); const lines: string[] = []; // Top border with title const title = "SelesaiCode"; const titlePrefix = dim(hChar.repeat(3)); const titleStyled = titlePrefix + fgOnly("model", title); const titleVisLen = 3 + visibleWidth(title); const afterTitle = boxWidth - 2 - titleVisLen; const afterTitleText = afterTitle > 0 ? dim(hChar.repeat(afterTitle)) : ""; lines.push(tl + titleStyled + afterTitleText + tr); // Content rows const maxRows = Math.max(leftLines.length, rightLines.length); for (let i = 0; i < maxRows; i++) { const left = fitToWidth(leftLines[i] ?? "", leftCol); const right = fitToWidth(rightLines[i] ?? "", rightCol); lines.push(v + left + v + right + v); } // Bottom border lines.push(bl + bottomLine + br); return lines; } // ═══════════════════════════════════════════════════════════════════════════ // Welcome Components // ═══════════════════════════════════════════════════════════════════════════ /** * Welcome overlay component for SelesaiCode. * Displays a branded splash screen with logo, tips, and loaded counts. */ export class WelcomeComponent implements Component { private data: WelcomeData; constructor( modelName: string, providerName: string, recentSessions: RecentSession[] = [], loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 }, guideMode: GuideMode = "full", newFeatures: GuideFeature[] = [], ) { this.data = { modelName, providerName, recentSessions, loadedCounts, guideMode, newFeatures }; } invalidate(): void {} render(termWidth: number): string[] { // Minimum width for two-column layout (must match renderWelcomeBox) const minLayoutWidth = 44; if (termWidth < minLayoutWidth) { return []; } const minWidth = 76; const maxWidth = 132; // Clamp to termWidth to prevent crash on narrow terminals. const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth))); // The guide is intentionally blocking: no timer or implicit dismissal. const promptText = ` ${GUIDE_DISMISS_HINT} `; const promptStyled = dim(promptText); const bottomContentWidth = boxWidth - 2; const promptVisLen = visibleWidth(promptText); const leftPad = Math.floor((bottomContentWidth - promptVisLen) / 2); const rightPad = bottomContentWidth - promptVisLen - leftPad; const hChar = "─"; const bottomLine = dim(hChar.repeat(Math.max(0, leftPad))) + promptStyled + dim(hChar.repeat(Math.max(0, rightPad))); return renderWelcomeBox(this.data, termWidth, bottomLine); } } /** * Compact guide header used when the user selects `/guide compact`. */ export class WelcomeHeader implements Component { private data: WelcomeData; constructor( modelName: string, providerName: string, recentSessions: RecentSession[] = [], loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 }, guideMode: GuideMode = "compact", newFeatures: GuideFeature[] = [], ) { this.data = { modelName, providerName, recentSessions, loadedCounts, guideMode, newFeatures }; } invalidate(): void {} render(termWidth: number): string[] { // Minimum width for two-column layout (must match renderWelcomeBox) const minLayoutWidth = 44; if (termWidth < minLayoutWidth) { return []; } const minWidth = 76; const maxWidth = 96; // Clamp to termWidth to prevent crash on narrow terminals const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth))); const hChar = "─"; // Bottom line with column separator (leftCol=26, rightCol=boxWidth-29) const leftCol = 26; const rightCol = Math.max(1, boxWidth - leftCol - 3); const bottomLine = dim(hChar.repeat(leftCol)) + dim("┴") + dim(hChar.repeat(rightCol)); const lines = renderWelcomeBox(this.data, termWidth, bottomLine); if (lines.length > 0) { lines.push(""); // Add empty line for spacing only if we rendered content } return lines; } } // ═══════════════════════════════════════════════════════════════════════════ // Discovery functions // ═══════════════════════════════════════════════════════════════════════════ const loggedDiscoveryErrors = new Set(); function logDiscoveryError(scope: string, error: unknown): void { const message = error instanceof Error ? error.message : String(error); const key = `${scope}:${message}`; if (loggedDiscoveryErrors.has(key)) { return; } loggedDiscoveryErrors.add(key); if (loggedDiscoveryErrors.size > 500) { loggedDiscoveryErrors.clear(); } console.debug(`[powerline-welcome] ${scope}:`, error); } // ponytail: only .claude paths still need raw home; everything else routes via getAgentDir. function homedirSafe(): string { return process.env.HOME || process.env.USERPROFILE || osHomedir(); } /** * Discover loaded counts by scanning filesystem. */ export function discoverLoadedCounts(): LoadedCounts { const homeDir = getAgentDir(); const cwd = process.cwd(); let contextFiles = 0; let extensions = 0; let skills = 0; let promptTemplates = 0; const agentsMdPaths = [ join(homeDir, "AGENTS.md"), join(homedirSafe(), ".claude", "AGENTS.md"), join(cwd, "AGENTS.md"), join(cwd, CONFIG_DIR_NAME, "AGENTS.md"), join(cwd, ".claude", "AGENTS.md"), ]; for (const path of agentsMdPaths) { if (existsSync(path)) contextFiles++; } const extensionDirs = [ join(homeDir, "extensions"), join(cwd, "extensions"), join(cwd, CONFIG_DIR_NAME, "extensions"), ]; const countedExtensions = new Set(); const settingsPaths = [ getSettingsPath(), join(cwd, CONFIG_DIR_NAME, "settings.json"), ]; for (const settingsPath of settingsPaths) { if (!existsSync(settingsPath)) { continue; } try { const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); let packages: unknown = null; if (typeof settings === "object" && settings !== null && !Array.isArray(settings)) { packages = (settings as { packages?: unknown }).packages; } if (Array.isArray(packages)) { for (const pkg of packages) { let source: unknown = null; let extensionsFilter: unknown = null; if (typeof pkg === "string") { source = pkg; } else if (typeof pkg === "object" && pkg !== null && !Array.isArray(pkg)) { source = (pkg as { source?: unknown }).source; extensionsFilter = (pkg as { extensions?: unknown }).extensions; } if (typeof source !== "string") { continue; } const normalizedSource = source.trim(); if (!normalizedSource.startsWith("npm:")) { continue; } if (Array.isArray(extensionsFilter) && extensionsFilter.length === 0) { continue; } const body = normalizedSource.slice(4); const versionIndex = body.lastIndexOf("@"); const name = versionIndex > 0 ? body.slice(0, versionIndex) : body; if (!name || countedExtensions.has(name)) { continue; } countedExtensions.add(name); extensions++; } } } catch (error) { logDiscoveryError(`Failed to read settings at ${settingsPath}`, error); } } for (const dir of extensionDirs) { if (existsSync(dir)) { try { const entries = readdirSync(dir); for (const entry of entries) { const entryPath = join(dir, entry); try { const stats = statSync(entryPath); if (stats.isDirectory()) { if ( existsSync(join(entryPath, "index.ts")) || existsSync(join(entryPath, "index.js")) || existsSync(join(entryPath, "package.json")) ) { if (!countedExtensions.has(entry)) { countedExtensions.add(entry); extensions++; } } } else if ((entry.endsWith(".ts") || entry.endsWith(".js")) && !entry.startsWith(".")) { const ext = entry.endsWith(".ts") ? ".ts" : ".js"; const name = basename(entry, ext); if (!countedExtensions.has(name)) { countedExtensions.add(name); extensions++; } } } catch (error) { logDiscoveryError(`Failed to inspect extension entry ${entryPath}`, error); } } } catch (error) { logDiscoveryError(`Failed to scan extensions dir ${dir}`, error); } } } const skillDirs = [ join(homeDir, "skills"), join(cwd, CONFIG_DIR_NAME, "skills"), join(cwd, "skills"), ]; const countedSkills = new Set(); for (const dir of skillDirs) { if (existsSync(dir)) { try { const entries = readdirSync(dir); for (const entry of entries) { const entryPath = join(dir, entry); try { if (statSync(entryPath).isDirectory()) { if (existsSync(join(entryPath, "SKILL.md"))) { if (!countedSkills.has(entry)) { countedSkills.add(entry); skills++; } } } } catch (error) { logDiscoveryError(`Failed to inspect skill entry ${entryPath}`, error); } } } catch (error) { logDiscoveryError(`Failed to scan skills dir ${dir}`, error); } } } const templateDirs = [ join(homeDir, "commands"), join(homedirSafe(), ".claude", "commands"), join(cwd, CONFIG_DIR_NAME, "commands"), join(cwd, ".claude", "commands"), ]; const countedTemplates = new Set(); function countTemplatesInDir(dir: string) { if (!existsSync(dir)) return; try { const entries = readdirSync(dir); for (const entry of entries) { const entryPath = join(dir, entry); try { const stats = statSync(entryPath); if (stats.isDirectory()) { countTemplatesInDir(entryPath); } else if (entry.endsWith(".md")) { const name = basename(entry, ".md"); if (!countedTemplates.has(name)) { countedTemplates.add(name); promptTemplates++; } } } catch (error) { logDiscoveryError(`Failed to inspect prompt template entry ${entryPath}`, error); } } } catch (error) { logDiscoveryError(`Failed to scan prompt template dir ${dir}`, error); } } for (const dir of templateDirs) { countTemplatesInDir(dir); } return { contextFiles, extensions, skills, promptTemplates }; } /** * Get recent sessions from the sessions directory. */ export function getRecentSessions(maxCount: number = 3): RecentSession[] { const homeDir = getAgentDir(); const home = homedirSafe(); const sessionsDirs = [ join(homeDir, "sessions"), join(home, CONFIG_DIR_NAME, "sessions"), ]; const sessions: { name: string; mtime: number }[] = []; function scanDir(dir: string) { if (!existsSync(dir)) return; try { const entries = readdirSync(dir); for (const entry of entries) { const entryPath = join(dir, entry); try { const stats = statSync(entryPath); if (stats.isDirectory()) { scanDir(entryPath); } else if (entry.endsWith(".jsonl")) { const parentName = basename(dir); let projectName = parentName; if (parentName.startsWith("--")) { const parts = parentName.split("-").filter(p => p); projectName = parts[parts.length - 1] || parentName; } sessions.push({ name: projectName, mtime: stats.mtimeMs }); } } catch (error) { logDiscoveryError(`Failed to inspect session entry ${entryPath}`, error); } } } catch (error) { logDiscoveryError(`Failed to scan sessions dir ${dir}`, error); } } for (const sessionsDir of sessionsDirs) { scanDir(sessionsDir); } if (sessions.length === 0) return []; sessions.sort((a, b) => b.mtime - a.mtime); const seen = new Set(); const uniqueSessions: typeof sessions = []; for (const s of sessions) { if (!seen.has(s.name)) { seen.add(s.name); uniqueSessions.push(s); } } const now = Date.now(); return uniqueSessions.slice(0, maxCount).map(s => ({ name: s.name.length > 20 ? s.name.slice(0, 17) + "…" : s.name, timeAgo: formatTimeAgo(now - s.mtime), })); } function formatTimeAgo(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d ago`; if (hours > 0) return `${hours}h ago`; if (minutes > 0) return `${minutes}m ago`; return "just now"; }