/** * matrix-welcome: a Matrix-style start screen for Oh My Pi (omp). * * Draws full-width digital rain with a block banner and an info panel showing * the omp version, the selected model and recent sessions. * * It replaces the built-in welcome screen, so turn the built-in one off: * * /settings -> Interaction -> Startup & Updates -> Quiet Startup = on * * (equivalently `startup.quiet: true` in ~/.omp/agent/config.yml) * * Settings live in a JSON file outside this package; see config.ts and README. */ import { CONFIG_FILENAME, DEFAULT_CONFIG, getConfigCandidates, loadConfig, type WelcomeConfig, } from "./config.js"; // ─── Colours (24-bit ANSI) ──────────────────────────────────────────────────── const RESET = "\x1b[0m"; const fg = (r: number, g: number, b: number) => `\x1b[38;2;${r};${g};${b}m`; const C = { head: `\x1b[1m${fg(220, 255, 225)}`, bright: fg(120, 255, 150), mid: fg(30, 210, 95), dim: fg(0, 140, 60), faint: fg(0, 80, 35), ghost: fg(0, 45, 20), banner: `\x1b[1m${fg(150, 255, 175)}`, bannerEdge: fg(30, 190, 90), label: fg(0, 200, 100), value: `\x1b[1m${fg(200, 255, 210)}`, hint: fg(0, 130, 60), }; const CHARSETS: Record = { katakana: "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789", ascii: "abcdefghijklmnopqrstuvwxyz0123456789<>|/\\=+*#$%&@", }; // ─── Block font ─────────────────────────────────────────────────────────────── const GLYPH_ROWS = 6; const GLYPHS: Record = { A: [" ████ ", "██ ██", "██████", "██ ██", "██ ██", "██ ██"], B: ["██████ ", "██ ██", "██████ ", "██ ██", "██ ██", "██████ "], C: [" █████", "██ ", "██ ", "██ ", "██ ", " █████"], D: ["██████ ", "██ ██", "██ ██", "██ ██", "██ ██", "██████ "], E: ["██████", "██ ", "█████ ", "██ ", "██ ", "██████"], F: ["██████", "██ ", "█████ ", "██ ", "██ ", "██ "], G: [" █████", "██ ", "██ ███", "██ ██", "██ ██", " █████"], H: ["██ ██", "██ ██", "██████", "██ ██", "██ ██", "██ ██"], I: ["██████", " ██ ", " ██ ", " ██ ", " ██ ", "██████"], J: ["██████", " ██", " ██", " ██", "██ ██", " ████ "], K: ["██ ██", "██ ██ ", "████ ", "████ ", "██ ██ ", "██ ██"], L: ["██ ", "██ ", "██ ", "██ ", "██ ", "██████"], M: ["██ ██", "███ ███", "███████", "██ █ ██", "██ ██", "██ ██"], N: ["██ ██", "███ ██", "██ █ ██", "██ ███", "██ ██", "██ ██"], O: [" ████ ", "██ ██", "██ ██", "██ ██", "██ ██", " ████ "], P: ["██████ ", "██ ██", "██ ██", "██████ ", "██ ", "██ "], Q: [" ████ ", "██ ██ ", "██ ██ ", "██ ███ ", "██ ██ ", " ██████"], R: ["██████ ", "██ ██", "██████ ", "████ ", "██ ██ ", "██ ██ "], S: [" █████", "██ ", " ████ ", " ██", " ██", "█████ "], T: ["██████", " ██ ", " ██ ", " ██ ", " ██ ", " ██ "], U: ["██ ██", "██ ██", "██ ██", "██ ██", "██ ██", " ████ "], V: ["██ ██", "██ ██", "██ ██", " ██ ██ ", " ██ ██ ", " ███ "], W: ["██ ██", "██ ██", "██ █ ██", "███████", "███ ███", "██ ██"], X: ["██ ██", " ██ ██ ", " ███ ", " ███ ", " ██ ██ ", "██ ██"], Y: ["██ ██", " ██ ██ ", " ███ ", " ██ ", " ██ ", " ██ "], Z: ["██████", " ██", " ██ ", " ██ ", " ██ ", "██████"], "0": [" ████ ", "██ ██", "██ ██", "██ ██", "██ ██", " ████ "], "1": [" ██ ", " ███ ", " ██ ", " ██ ", " ██ ", "██████"], "2": [" ████ ", "██ ██", " ██ ", " ██ ", " ██ ", "██████"], "3": [" ████ ", "██ ██", " ███", " █", "██ ██", " ████ "], "4": ["██ ██", "██ ██", "██████", " ██", " ██", " ██"], "5": ["██████", "██ ", "█████ ", " ██", "██ ██", " ████ "], "6": [" ████ ", "██ ", "█████ ", "██ ██", "██ ██", " ████ "], "7": ["██████", " ██", " ██ ", " ██ ", " ██ ", " ██ "], "8": [" ████ ", "██ ██", " ████ ", "██ ██", "██ ██", " ████ "], "9": [" ████ ", "██ ██", "██ ██", " █████", " ██", " ████ "], "-": [" ", " ", "████", "████", " ", " "], ".": [" ", " ", " ", " ", "██", "██"], " ": [" ", " ", " ", " ", " ", " "], "!": ["██", "██", "██", "██", " ", "██"], "?": [" ████ ", "██ ██", " ██ ", " ██ ", " ", " ██ "], ":": [" ", "██", " ", " ", "██", " "], ";": [" ", " ██", " ", " ", " ██", "██ "], ",": [" ", " ", " ", " ", " ██", "██ "], "'": ["██", "██", " ", " ", " ", " "], '"': ["██ ██", "██ ██", " ", " ", " ", " "], "(": [" ██", " ██ ", " ██ ", " ██ ", " ██ ", " ██"], ")": ["██ ", " ██ ", " ██ ", " ██ ", " ██ ", "██ "], "[": ["████", "██ ", "██ ", "██ ", "██ ", "████"], "]": ["████", " ██", " ██", " ██", " ██", "████"], "{": [" ██", " ██ ", "██ ", "██ ", " ██ ", " ██"], "}": ["██ ", " ██ ", " ██", " ██", " ██ ", "██ "], "/": [" ██", " ██", " ██ ", " ██ ", "██ ", "██ "], "\\": ["██ ", "██ ", " ██ ", " ██ ", " ██", " ██"], "|": ["██", "██", "██", "██", "██", "██"], "+": [" ", " ██ ", "██████", "██████", " ██ ", " "], "=": [" ", " ", "██████", " ", "██████", " "], "*": ["██ ██", " ████ ", "██████", " ████ ", "██ ██", " "], "#": [" ██ ██ ", "███████", " ██ ██ ", " ██ ██ ", "███████", " ██ ██ "], "@": [" █████ ", "██ ██", "██ ███ ", "██ ██ ", "██ ", " █████ "], "&": [" ████ ", "██ ██ ", " ████ ", "██ ██ ", "██ ██", " ████ █"], "%": ["██ ██", "██ ██ ", " ██ ", " ██ ", " ██ ██", "██ ██"], "$": [" ██ ", " █████", "██ ", " ████ ", " ██", "█████ "], "_": [" ", " ", " ", " ", " ", "██████"], "<": [" ██", " ██ ", "██ ", "██ ", " ██ ", " ██"], ">": ["██ ", " ██ ", " ██", " ██", " ██ ", "██ "], "~": [" ", " ", " ██ ██", "██ ██ ", " ", " "], "^": [" ██ ", " ████ ", "██ ██", " ", " ", " "], }; export function buildBanner(text: string): string[] { const rows: string[] = new Array(GLYPH_ROWS).fill(""); let placed = 0; for (const ch of text.toUpperCase()) { const glyph = GLYPHS[ch]; if (!glyph) continue; for (let r = 0; r < GLYPH_ROWS; r++) { rows[r] += (placed > 0 ? " " : "") + glyph[r]; } placed++; } return placed > 0 ? rows : []; } /** Characters of `text` the block font cannot draw, deduped, in input order. */ export function unsupportedBannerChars(text: string): string[] { const missing: string[] = []; for (const ch of text.toUpperCase()) { if (GLYPHS[ch] || missing.includes(ch)) continue; missing.push(ch); } return missing; } // ─── Types ──────────────────────────────────────────────────────────────────── export interface WelcomeInfo { version: string; modelName: string; providerName: string; sessions: Array<{ name: string; timeAgo: string }>; cwd: string; } export interface Timers { setInterval(cb: () => void, ms: number): unknown; clearTimer(handle: unknown): void; } type Align = "left" | "center"; interface OverlayLine { text: string; colors: string[]; align: Align; } // ─── The screen component ───────────────────────────────────────────────────── export class MatrixWelcome { #config: WelcomeConfig; #info: WelcomeInfo; #done: () => void; #timers: Timers; #tui: { requestRender(): void } | null = null; #banner: string[]; #chars: string[]; #width = 0; #rows = 0; #heads: number[] = []; #speeds: number[] = []; #tails: number[] = []; #grid: string[][] = []; #countdown: number; #dismissed = false; #animHandle: unknown = null; #tickHandle: unknown = null; constructor( info: WelcomeInfo, done: () => void, timers: Timers, config: WelcomeConfig = DEFAULT_CONFIG, ) { this.#config = config; this.#info = info; this.#done = done; this.#timers = timers; this.#banner = buildBanner(config.bannerText); this.#chars = (CHARSETS[config.charset] ?? CHARSETS.katakana).split(""); this.#countdown = config.countdown; } start(tui: { requestRender(): void }): void { this.#tui = tui; this.#animHandle = this.#timers.setInterval(() => { this.#step(); this.#tui?.requestRender(); }, this.#config.frameDelayMs); if (this.#config.countdown > 0) { this.#tickHandle = this.#timers.setInterval(() => { this.#countdown--; if (this.#countdown <= 0) this.dismiss(); }, 1000); } } handleInput(_data: string): void { this.dismiss(); } dismiss(): void { if (this.#dismissed) return; this.#dismissed = true; this.dispose(); this.#done(); } dispose(): void { if (this.#animHandle) this.#timers.clearTimer(this.#animHandle); if (this.#tickHandle) this.#timers.clearTimer(this.#tickHandle); this.#animHandle = null; this.#tickHandle = null; this.#tui = null; } invalidate(): void { this.#width = 0; } // ─── Rain state ───────────────────────────────────────────────────────── #resize(width: number, rows: number): void { this.#width = width; this.#rows = rows; this.#heads = []; this.#speeds = []; this.#tails = []; for (let x = 0; x < width; x++) { this.#spawn(x, true); } this.#grid = []; for (let y = 0; y < rows; y++) { const row: string[] = []; for (let x = 0; x < width; x++) row.push(this.#rand()); this.#grid.push(row); } } #rand(): string { return this.#chars[Math.floor(Math.random() * this.#chars.length)]; } #spawn(x: number, initial: boolean): void { const active = Math.random() < this.#config.density; this.#heads[x] = active ? initial ? Math.random() * this.#rows : -Math.random() * this.#rows * 0.6 : Number.NEGATIVE_INFINITY; this.#speeds[x] = 0.35 + Math.random() * 0.95; this.#tails[x] = 6 + Math.floor(Math.random() * 16); } #step(): void { if (!this.#width) return; for (let x = 0; x < this.#width; x++) { if (this.#heads[x] === Number.NEGATIVE_INFINITY) { if (Math.random() < 0.01) this.#spawn(x, false); continue; } this.#heads[x] += this.#speeds[x]; if (this.#heads[x] - this.#tails[x] > this.#rows) this.#spawn(x, false); } // Mutate a slice of the glyph grid so the rain shimmers. const mutations = Math.max(8, Math.floor((this.#width * this.#rows) / 60)); for (let i = 0; i < mutations; i++) { const y = Math.floor(Math.random() * this.#rows); const x = Math.floor(Math.random() * this.#width); this.#grid[y][x] = this.#rand(); } } // ─── Overlay content ──────────────────────────────────────────────────── /** * Lines stamped on top of the rain. The whole block is drawn opaque, so * the rain flows around a cleared panel instead of through the letters. */ #buildOverlay(width: number, compact: boolean): OverlayLine[] { const out: OverlayLine[] = []; const bannerWidth = this.#banner[0]?.length ?? 0; const wide = !compact && bannerWidth > 0 && width >= bannerWidth + 8; const push = (text: string, color: string, align: Align = "center") => { out.push({ text, colors: new Array(text.length).fill(color), align }); }; if (wide) { for (const line of this.#banner) { const colors = line.split("").map(ch => (ch === " " ? "" : C.banner)); out.push({ text: line, colors, align: "center" }); } } else { push(this.#config.bannerText, C.banner); } out.push({ text: "", colors: [], align: "left" }); const rows: Array<[string, string]> = [ ["OMP", this.#info.version], ["MODEL", `${this.#info.modelName} · ${this.#info.providerName}`], ["CWD", this.#info.cwd], ]; if (!compact) { this.#info.sessions.forEach((s, i) => { rows.push([i === 0 ? "RECENT" : "", `${s.name} (${s.timeAgo})`]); }); } const labelWidth = 9; for (const [label, value] of rows) { const text = label.padEnd(labelWidth) + value; const colors = text .split("") .map((_, i) => (i < labelWidth ? C.label : C.value)); out.push({ text, colors, align: "left" }); } out.push({ text: "", colors: [], align: "left" }); const hint = this.#config.countdown > 0 ? `press any key to continue ${this.#countdown}` : "press any key to continue"; push(hint, C.hint); return out; } // ─── Render ───────────────────────────────────────────────────────────── render(width: number): string[] { if (this.#dismissed || width < 24) return []; const termRows = typeof process !== "undefined" && process.stdout?.rows ? process.stdout.rows : 30; const rows = this.#config.height === "full" ? Math.max(8, termRows - 1) : Math.max(8, Math.min(this.#config.height, termRows - 1)); if (width !== this.#width || rows !== this.#rows) { this.#resize(width, rows); } let overlay = this.#buildOverlay(width, false); if (overlay.length + 2 > rows) overlay = this.#buildOverlay(width, true); if (overlay.length > rows) overlay = overlay.slice(0, rows); // The panel is one opaque rectangle: rain is cleared inside it, and // every line is positioned relative to the same block so labels align. const PAD_X = 4; const PAD_Y = 1; const contentWidth = Math.max(...overlay.map(l => l.text.length)); const blockWidth = Math.min(width, contentWidth + PAD_X * 2); const blockLeft = Math.max(0, Math.floor((width - blockWidth) / 2)); const contentLeft = blockLeft + Math.floor((blockWidth - contentWidth) / 2); const blockTop = Math.max(0, Math.floor((rows - overlay.length) / 2)); const clearFrom = blockTop - PAD_Y; const clearTo = blockTop + overlay.length + PAD_Y; const lines: string[] = []; for (let y = 0; y < rows; y++) { const ov = overlay[y - blockTop]; const start = ov && ov.align === "center" ? contentLeft + Math.floor((contentWidth - ov.text.length) / 2) : contentLeft; lines.push( this.#renderRow(y, width, ov, start, { active: y >= clearFrom && y < clearTo, left: blockLeft, right: blockLeft + blockWidth, }), ); } return lines; } #renderRow( y: number, width: number, ov: OverlayLine | undefined, ovStart: number, clear: { active: boolean; left: number; right: number }, ): string { let out = ""; let current = ""; for (let x = 0; x < width; x++) { let ch = " "; let color = ""; const oi = ov ? x - ovStart : -1; const ovCh = ov && oi >= 0 && oi < ov.text.length ? ov.text[oi] : undefined; if (ovCh !== undefined && ovCh !== " " && ov?.colors[oi]) { // Banner and info text win over the rain. if (ovCh === "█" && Math.random() < this.#config.bannerFlicker) { ch = this.#grid[y][x]; color = C.bannerEdge; } else { ch = ovCh; color = ov.colors[oi]; } } else if (clear.active && x >= clear.left && x < clear.right) { // Inside the panel: leave it blank so the text stays readable. ch = " "; } else { const head = this.#heads[x]; const dist = head - y; if (head !== Number.NEGATIVE_INFINITY && dist >= 0 && dist <= this.#tails[x]) { ch = this.#grid[y][x]; const t = dist / this.#tails[x]; if (dist < 1) color = C.head; else if (t < 0.15) color = C.bright; else if (t < 0.35) color = C.mid; else if (t < 0.6) color = C.dim; else if (t < 0.82) color = C.faint; else color = C.ghost; } } if (ch === " ") { if (current) { out += RESET; current = ""; } out += " "; continue; } if (color !== current) { out += RESET + color; current = color; } out += ch; } return current ? out + RESET : out; } } // ─── Data collection ────────────────────────────────────────────────────────── async function collectInfo( exports: unknown, ctx: ExtensionContextLike, config: WelcomeConfig, ): Promise { let version = "unknown"; if (exports && typeof exports === "object" && "VERSION" in exports) { const raw = Reflect.get(exports, "VERSION"); if (typeof raw === "string" || typeof raw === "number") version = String(raw); } let sessions: Array<{ name: string; timeAgo: string }> = []; try { const dir = ctx.sessionManager?.getSessionDir?.(); const getRecent = exports && typeof exports === "object" && "getRecentSessions" in exports ? Reflect.get(exports, "getRecentSessions") : undefined; if (dir && typeof getRecent === "function" && config.maxSessions > 0) { const recent: unknown = await getRecent(dir, config.maxSessions); if (Array.isArray(recent)) { sessions = recent.map(entry => { const row = entry && typeof entry === "object" ? entry : {}; const name = "name" in row ? Reflect.get(row, "name") : undefined; const timeAgo = "timeAgo" in row ? Reflect.get(row, "timeAgo") : undefined; return { name: String(name ?? "session").slice(0, 28), timeAgo: String(timeAgo ?? ""), }; }); } } } catch { sessions = []; } const cwd = String(ctx.cwd ?? process.cwd()); const home = process.env.HOME ?? ""; const shortCwd = home && cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd; return { version, modelName: ctx.model?.name ?? ctx.model?.id ?? "no model selected", providerName: ctx.model?.provider ?? "unknown", sessions, cwd: shortCwd.length > 44 ? "…" + shortCwd.slice(-43) : shortCwd, }; } // ─── Extension entry point ──────────────────────────────────────────────────── /** * Structural view of the bits of omp's ExtensionContext this plugin touches. * Declared locally so the package needs no dependency on the host types. */ interface ExtensionContextLike { hasUI: boolean; cwd?: string; model?: { name?: string; id?: string; provider?: string }; sessionManager?: { getSessionDir?(): string | undefined }; setInterval(cb: () => void, ms: number): unknown; clearTimer(handle: unknown): void; ui: { custom( factory: ( tui: { requestRender(): void }, theme: unknown, keybindings: unknown, done: () => void, ) => MatrixWelcome, options?: { overlay?: boolean }, ): Promise; notify(message: string, level?: string): void; }; } interface ExtensionApiLike { pi?: unknown; logger?: { warn?(message: string, meta?: unknown): void }; on(event: string, handler: (event: unknown, ctx: ExtensionContextLike) => unknown): void; registerCommand( name: string, definition: { description: string; handler: (args: string, ctx: ExtensionContextLike) => Promise | void; }, ): void; } export default function matrixWelcome(pi: ExtensionApiLike): void { let active: MatrixWelcome | undefined; let generation = 0; function loadConfigWithFontWarnings(cwd: string) { const loaded = loadConfig(cwd); const missing = unsupportedBannerChars(loaded.config.bannerText); if (missing.length > 0) { loaded.warnings.push( `bannerText: no block glyph for ${missing.join(" ")}, skipped in the banner`, ); } return loaded; } async function show(ctx: ExtensionContextLike): Promise { if (!ctx.hasUI) return; const mine = ++generation; const { config, source, warnings } = loadConfigWithFontWarnings(ctx.cwd ?? process.cwd()); for (const warning of warnings) { pi.logger?.warn?.(`[matrix-welcome] ${CONFIG_FILENAME}: ${warning}`, { source }); } const info = await collectInfo(pi.pi, ctx, config); if (mine !== generation) return; const timers: Timers = { setInterval: (cb, ms) => ctx.setInterval(cb, ms), clearTimer: handle => ctx.clearTimer(handle), }; const mounted = ctx.ui.custom( (tui, _theme, _keys, done) => { const screen = new MatrixWelcome(info, done, timers, config); active = screen; screen.start(tui); return screen; }, { overlay: true }, ); void Promise.resolve(mounted).catch((error: unknown) => { pi.logger?.warn?.("[matrix-welcome] failed to show", { error: String(error) }); }); } function hide(): void { active?.dismiss(); active = undefined; } pi.on("session_start", async (_event, ctx) => { hide(); await show(ctx); }); pi.on("session_shutdown", async () => { generation++; hide(); }); // Get out of the way the moment the agent actually starts working. pi.on("agent_start", async () => hide()); pi.on("input", async () => hide()); pi.registerCommand("matrix", { description: "Replay the Matrix welcome screen", handler: async (_args, ctx) => { hide(); await show(ctx); }, }); pi.registerCommand("matrix-config", { description: "Show where matrix-welcome reads its settings from", handler: async (_args, ctx) => { if (!ctx.hasUI) return; const cwd = ctx.cwd ?? process.cwd(); const { config, source, warnings } = loadConfigWithFontWarnings(cwd); const lines = [ source ? `config: ${source}` : "config: built-in defaults (no file found)", ...(source ? [] : [`create one at: ${getConfigCandidates(cwd)[1]}`]), `countdown=${config.countdown} frameDelayMs=${config.frameDelayMs} height=${config.height}`, `charset=${config.charset} density=${config.density} bannerFlicker=${config.bannerFlicker}`, `maxSessions=${config.maxSessions} bannerText=${config.bannerText}`, ...warnings.map(w => `warning: ${w}`), ]; ctx.ui.notify(lines.join("\n"), warnings.length > 0 ? "warning" : "info"); }, }); }