/** * Weather background — static snapshot mode. * * Captures a short live frame from `weathr`, converts it to a Kitty image, * places it behind text (negative z-layer), then stops updating. * * This avoids dynamic redraw flicker while still matching current weather * at the moment /weather-bg is enabled. */ import { spawn, type ChildProcess } from "node:child_process"; import { appendFileSync, closeSync, constants as fsConstants, openSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { deflateSync } from "node:zlib"; import type { TUI } from "@mariozechner/pi-tui"; import { allocateImageId, deleteKittyImage, getCapabilities } from "@mariozechner/pi-tui"; import { AnsiScreenBuffer, type CellColor } from "./ansi-buffer.js"; const DEBUG_LOG = join(homedir(), ".pi", "agent", "weather-bg-debug.log"); function dbg(msg: string): void { try { appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`); } catch { // ignore } } // ----- Configuration ----- const BG_COLUMNS = 120; const BG_ROWS = 40; const DEFAULT_DIM_FACTOR = 0.25; const CELL_UPSCALE = 4; const BG_Z_INDEX = -2; /** Wait this long after first output before freezing the frame. */ const SNAPSHOT_CAPTURE_DELAY_MS = 900; /** Give up if weathr produces no output by this timeout. */ const SNAPSHOT_START_TIMEOUT_MS = 3500; const CHUNK_SIZE = 4096; function encodeKittyCompressed( compressedBase64: string, options: { widthPx: number; heightPx: number; columns: number; rows: number; imageId: number; placementId: number; zIndex: number; }, ): string { const params = [ "a=T", "f=24", "o=z", "q=2", "C=1", `s=${options.widthPx}`, `v=${options.heightPx}`, `c=${options.columns}`, `r=${options.rows}`, `i=${options.imageId}`, `p=${options.placementId}`, `z=${options.zIndex}`, ]; if (compressedBase64.length <= CHUNK_SIZE) { return `\x1b_G${params.join(",")};${compressedBase64}\x1b\\`; } const chunks: string[] = []; let offset = 0; let isFirst = true; while (offset < compressedBase64.length) { const chunk = compressedBase64.slice(offset, offset + CHUNK_SIZE); const isLast = offset + CHUNK_SIZE >= compressedBase64.length; if (isFirst) { chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`); isFirst = false; } else if (isLast) { chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`); } else { chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`); } offset += CHUNK_SIZE; } return chunks.join(""); } const DEFAULT_BG: CellColor = { r: 0, g: 0, b: 0 }; function cellToColor(cell: { character: string; fg: CellColor | null; bg: CellColor | null }): CellColor { if (cell.bg) { if (cell.character !== " " && cell.fg) { return { r: Math.round(cell.fg.r * 0.6 + cell.bg.r * 0.4), g: Math.round(cell.fg.g * 0.6 + cell.bg.g * 0.4), b: Math.round(cell.fg.b * 0.6 + cell.bg.b * 0.4), }; } return cell.bg; } if (cell.character !== " " && cell.fg) { return { r: Math.round(cell.fg.r * 0.4), g: Math.round(cell.fg.g * 0.4), b: Math.round(cell.fg.b * 0.4), }; } return DEFAULT_BG; } function cellsToUpscaledRgbBuffer( buffer: AnsiScreenBuffer, dimFactor: number, ): { rgb: Uint8Array; width: number; height: number } { const cols = buffer.getColumns(); const rows = buffer.getRows(); const cells = buffer.getCells(); const imgW = cols * CELL_UPSCALE; const imgH = rows * CELL_UPSCALE; const rgb = new Uint8Array(imgW * imgH * 3); for (let row = 0; row < rows; row += 1) { const cellRow = cells[row]; if (!cellRow) continue; for (let col = 0; col < cols; col += 1) { const cell = cellRow[col]; if (!cell) continue; const color = cellToColor(cell); const r = Math.round(color.r * dimFactor); const g = Math.round(color.g * dimFactor); const b = Math.round(color.b * dimFactor); const baseX = col * CELL_UPSCALE; const baseY = row * CELL_UPSCALE; for (let dy = 0; dy < CELL_UPSCALE; dy += 1) { const rowOffset = (baseY + dy) * imgW; for (let dx = 0; dx < CELL_UPSCALE; dx += 1) { const offset = (rowOffset + baseX + dx) * 3; rgb[offset] = r; rgb[offset + 1] = g; rgb[offset + 2] = b; } } } } return { rgb, width: imgW, height: imgH }; } function resolveScriptStdin(): "pipe" | number { try { return openSync("/dev/null", fsConstants.O_RDONLY); } catch { return "pipe"; } } function createWeatherEnv(configHome: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, XDG_CONFIG_HOME: configHome, }; if ("NO_COLOR" in env) { delete env.NO_COLOR; } if (!env.COLORTERM || env.COLORTERM.length === 0) { env.COLORTERM = "truecolor"; } if (!env.TERM || env.TERM.length === 0) { env.TERM = "xterm-256color"; } return env; } function shellQuote(value: string): string { return `'${value.split("'").join(`'"'"'`)}'`; } export interface WeatherBackgroundOptions { scriptPath: string; weathrPath: string; weathrArgs: string[]; configHome: string; dimFactor?: number; } export class WeatherBackground { private readonly imageId = allocateImageId(); private readonly screen: AnsiScreenBuffer; private readonly options: WeatherBackgroundOptions; private readonly dimFactor: number; private process: ChildProcess | null = null; private captureTimer: ReturnType | null = null; private startupTimeout: ReturnType | null = null; private tui: TUI | null = null; private active = false; private hasOutput = false; private snapshotReady = false; private pendingSequence: string | null = null; private placedColumns = 0; private placedRows = 0; private version = 0; private lastError: string | null = null; constructor(options: WeatherBackgroundOptions) { this.options = options; this.dimFactor = options.dimFactor ?? DEFAULT_DIM_FACTOR; this.screen = new AnsiScreenBuffer(BG_COLUMNS, BG_ROWS); } start(tui: TUI): void { if (this.active) return; this.active = true; this.tui = tui; this.hasOutput = false; this.snapshotReady = false; this.pendingSequence = null; this.placedColumns = 0; this.placedRows = 0; this.lastError = null; dbg(`start() static snapshot. imageId=${this.imageId}, cols=${tui.terminal.columns}, rows=${tui.terminal.rows}`); this.startProcess(); this.startupTimeout = setTimeout(() => { if (!this.active || this.snapshotReady) return; if (this.hasOutput) { this.freezeSnapshot("startup-timeout-with-output"); return; } this.lastError = "weathr produced no output"; dbg("snapshot timeout: no output"); }, SNAPSHOT_START_TIMEOUT_MS); } stop(): void { if (!this.active) return; this.active = false; this.clearTimers(); this.stopProcess(); if (this.tui) { const caps = getCapabilities(); if (caps.images === "kitty") { this.tui.terminal.write(deleteKittyImage(this.imageId)); } this.tui.requestRender(); } this.tui = null; this.hasOutput = false; this.snapshotReady = false; this.pendingSequence = null; this.placedColumns = 0; this.placedRows = 0; } isActive(): boolean { return this.active; } getLastError(): string | null { return this.lastError; } renderImageSequence(): string | null { if (!this.active || !this.tui || !this.snapshotReady) { return null; } const cols = this.tui.terminal.columns; const rows = this.tui.terminal.rows; if (cols !== this.placedColumns || rows !== this.placedRows) { const resized = this.buildWrappedSequence(cols, rows); if (resized) { this.pendingSequence = resized; } } if (!this.pendingSequence) { return null; } const seq = this.pendingSequence; this.pendingSequence = null; this.placedColumns = cols; this.placedRows = rows; return seq; } private clearTimers(): void { if (this.captureTimer) { clearTimeout(this.captureTimer); this.captureTimer = null; } if (this.startupTimeout) { clearTimeout(this.startupTimeout); this.startupTimeout = null; } } private scheduleCapture(): void { if (this.captureTimer) return; this.captureTimer = setTimeout(() => { this.captureTimer = null; this.freezeSnapshot("capture-delay"); }, SNAPSHOT_CAPTURE_DELAY_MS); } private freezeSnapshot(reason: string): void { if (!this.active || this.snapshotReady || !this.tui) return; if (!this.hasOutput) { dbg(`freezeSnapshot(${reason}) skipped: no output yet`); return; } this.clearTimers(); this.stopProcess(); const cols = this.tui.terminal.columns; const rows = this.tui.terminal.rows; const sequence = this.buildWrappedSequence(cols, rows); if (!sequence) { this.lastError = "Failed to build Kitty weather image sequence"; return; } this.snapshotReady = true; this.pendingSequence = sequence; this.placedColumns = 0; this.placedRows = 0; dbg(`snapshot frozen (${reason})`); this.tui.requestRender(); } private buildWrappedSequence(columns: number, rows: number): string | null { const caps = getCapabilities(); if (caps.images !== "kitty") return null; const { rgb, width: imgW, height: imgH } = cellsToUpscaledRgbBuffer(this.screen, this.dimFactor); const compressed = deflateSync(Buffer.from(rgb.buffer, rgb.byteOffset, rgb.byteLength)); const base64 = compressed.toString("base64"); this.version += 1; dbg(`buildWrappedSequence v=${this.version}: img=${imgW}x${imgH}, compressed=${compressed.length}, base64=${base64.length}, cols=${columns}, rows=${rows}`); const kittySequence = encodeKittyCompressed(base64, { widthPx: imgW, heightPx: imgH, columns, rows, imageId: this.imageId, placementId: this.imageId, zIndex: BG_Z_INDEX, }); return `\x1b7\x1b[H${kittySequence}\x1b8`; } private startProcess(): void { const escapedBinary = shellQuote(this.options.weathrPath); const escapedArgs = this.options.weathrArgs.map(shellQuote).join(" "); const weatherCommand = escapedArgs.length > 0 ? `${escapedBinary} ${escapedArgs}` : escapedBinary; const shellCommand = `stty cols ${BG_COLUMNS} rows ${BG_ROWS}; exec ${weatherCommand}`; const scriptStdin = resolveScriptStdin(); let child: ChildProcess; try { child = spawn(this.options.scriptPath, ["-q", "/dev/null", "sh", "-c", shellCommand], { env: createWeatherEnv(this.options.configHome), stdio: [scriptStdin, "pipe", "pipe"], }); } catch (error) { if (typeof scriptStdin === "number") { try { closeSync(scriptStdin); } catch { /* ignore */ } } this.lastError = error instanceof Error ? error.message : String(error); return; } if (typeof scriptStdin === "number") { try { closeSync(scriptStdin); } catch { /* ignore */ } } if (!child.stdout || !child.stderr) { this.lastError = "Missing stdio streams from weathr process."; try { child.kill("SIGTERM"); } catch { /* ignore */ } return; } this.process = child; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk: string | Buffer) => { if (!this.active) return; const output = typeof chunk === "string" ? chunk : chunk.toString("utf8"); if (output.length === 0) return; this.screen.feed(output); if (!this.hasOutput) { this.hasOutput = true; dbg(`first stdout chunk: ${output.length} bytes`); } this.scheduleCapture(); }); child.stderr.on("data", (chunk: string | Buffer) => { if (!this.active) return; const output = typeof chunk === "string" ? chunk : chunk.toString("utf8"); const trimmed = output.trim(); if (trimmed.length > 0) { this.lastError = trimmed; dbg(`stderr: ${trimmed}`); } }); child.on("error", (error: Error) => { dbg(`process error: ${error.message}`); if (!this.active) return; this.process = null; this.lastError = error.message; }); child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => { dbg(`process exit: code=${code}, signal=${signal}`); if (!this.active) return; this.process = null; if (!this.snapshotReady && this.hasOutput) { this.freezeSnapshot("process-exit"); } }); } private stopProcess(): void { const activeProcess = this.process; this.process = null; if (!activeProcess) return; try { if (activeProcess.stdin && activeProcess.stdin.writable) { activeProcess.stdin.write("q"); } } catch { // ignore } setTimeout(() => { if (!activeProcess.killed) { activeProcess.kill("SIGTERM"); } }, 100); } }