Small library for generating claude-code like unicde block mascots, and providing animations when they do stuff
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use clood binary for splash screen header

Shells out to the clood Rust CLI to render saved characters with
their ANSI-colored unicode block art. Supports animated characters
by replicating clood's ping-pong animation logic and rendering
each frame via CLI args.

Configuration at top of file:
- CHARACTER: saved character name (e.g. 'buddy')
- ANIMATION: saved animation name(s) (e.g. 'slowblink')
- FPS: animation speed
- SUBTITLE: optional text below the character

goose.art e06bf7f1 1ad98505

verified
+251 -93
+251 -93
.pi/extensions/clood-header.ts
··· 1 1 /** 2 2 * Clood Header Extension 3 3 * 4 - * Replaces the default pi splash screen with a cloud-themed header. 5 - * Configurable name and animation style. 4 + * Replaces the default pi splash screen with a clood character rendered 5 + * by the `clood` Rust CLI. Supports saved characters and animations. 6 6 * 7 7 * Configuration (set values below): 8 - * - NAME: The name displayed in the header (default: "clood") 9 - * - ANIMATION: Animation style - "float", "pulse", "rain", or "none" 10 - * - SUBTITLE: Optional subtitle text below the cloud 8 + * - CHARACTER: Name of a saved clood character (from ~/.config/clood.json), 9 + * or "" for a random one each session. 10 + * - ANIMATION: Name of a saved animation (e.g. "slowblink", "wave"), 11 + * comma-separated for chained animations, or "" for static. 12 + * - CLOOD_BIN: Path to the clood binary. 13 + * - FPS: Animation frames per second. 14 + * - SUBTITLE: Optional subtitle text below the character. 11 15 */ 12 16 17 + import { execSync } from "node:child_process"; 18 + import path from "node:path"; 13 19 import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent"; 14 20 import type { TUI } from "@mariozechner/pi-tui"; 15 21 16 22 // ═══════════════════════════════════════ 17 23 // CONFIGURATION 18 24 // ═══════════════════════════════════════ 19 - const NAME = "clood"; 20 - const ANIMATION: "float" | "pulse" | "rain" | "none" = "float"; 21 - const SUBTITLE = ""; // e.g. "your friendly cloud coding agent" 22 - const ANIMATION_SPEED = 150; // ms per frame 25 + const CHARACTER = "buddy"; // saved character name, or "" for random 26 + const ANIMATION = "slowblink"; // saved animation name(s), or "" for static 27 + const CLOOD_BIN = path.join(import.meta.dirname ?? __dirname, "../target/release/clood"); 28 + const FPS = 4; 29 + const SUBTITLE = ""; // e.g. "your friendly coding companion" 23 30 24 31 // ═══════════════════════════════════════ 25 - // CLOUD ASCII ART 32 + // CLOOD RENDERING 26 33 // ═══════════════════════════════════════ 27 34 28 - function getCloudLines(theme: Theme, frame: number, animation: string, width: number): string[] { 29 - const accent = (s: string) => theme.fg("accent", s); 30 - const muted = (s: string) => theme.fg("muted", s); 31 - const dim = (s: string) => theme.fg("dim", s); 35 + /** Run clood and capture its ANSI output as an array of lines. */ 36 + function renderClood(character: string, animation: string, frame?: number): string[] { 37 + try { 38 + // Build the command 39 + const args: string[] = []; 32 40 33 - // Cloud shape 34 - const cloudRaw = [ 35 - " .-~~~-. ", 36 - " .- ~ ~-( )_ _ ", 37 - " / \\", 38 - " | |", 39 - " \\ /", 40 - " ~- . _____________ .~ ", 41 - ]; 41 + if (character) { 42 + args.push(character); 43 + } 42 44 43 - // Center the name inside the cloud body (line index 3, the widest) 44 - const nameUpper = NAME.toUpperCase(); 45 - const namePad = Math.max(0, Math.floor((25 - nameUpper.length) / 2)); 46 - const nameLine = " ".repeat(namePad) + nameUpper + " ".repeat(Math.max(0, 25 - namePad - nameUpper.length)); 45 + // For animation frames, we need to generate each frame ourselves 46 + // using the animation system. For static, just render once. 47 + if (animation && frame === undefined) { 48 + // Static render of the character (no animation) 49 + args.push(animation); 50 + } 47 51 48 - const cloudWithName = [ 49 - cloudRaw[0], 50 - cloudRaw[1], 51 - cloudRaw[2], 52 - ` | ${nameLine} |`, 53 - cloudRaw[4], 54 - cloudRaw[5], 55 - ]; 52 + const cmd = `${CLOOD_BIN} ${args.join(" ")}`; 53 + const output = execSync(cmd, { 54 + encoding: "utf-8", 55 + timeout: 2000, 56 + stdio: ["ignore", "pipe", "pipe"], 57 + }); 56 58 57 - // Apply animation transforms 58 - let lines: string[]; 59 - switch (animation) { 60 - case "float": { 61 - // Gentle horizontal floating 62 - const offsets = [0, 1, 2, 3, 3, 2, 1, 0, -1, -2, -3, -3, -2, -1]; 63 - const offset = offsets[frame % offsets.length]; 64 - const pad = Math.max(0, 2 + offset); 65 - lines = cloudWithName.map((l) => " ".repeat(pad) + l); 66 - break; 59 + // Split into lines, remove trailing empty line 60 + const lines = output.split("\n"); 61 + if (lines.length > 0 && lines[lines.length - 1] === "") { 62 + lines.pop(); 67 63 } 68 - case "pulse": { 69 - // Pulse the accent color brightness using different color tokens 70 - const pulseStages = ["dim", "muted", "accent", "accent", "muted", "dim"] as const; 71 - const stage = pulseStages[frame % pulseStages.length]; 72 - const colorFn = (s: string) => theme.fg(stage, s); 73 - lines = ["", ...cloudWithName.map((l) => " " + colorFn(l)), ""]; 74 - return lines; 64 + return lines; 65 + } catch { 66 + return ["(clood not found — run: cargo build --release)"]; 67 + } 68 + } 69 + 70 + /** 71 + * For animation, we run the clood binary once per frame with the animation 72 + * applied at the right tick. Since clood's animation is a terminal loop, 73 + * we replicate the ping-pong logic here and shell out for each static frame. 74 + * 75 + * We parse the saved animation specs from ~/.config/clood.json and apply 76 + * them to generate per-frame CLI args. 77 + */ 78 + 79 + interface AnimSpec { 80 + param: string; 81 + min: number; 82 + max: number; 83 + } 84 + 85 + interface CloodConfig { 86 + characters?: Record<string, Record<string, unknown>>; 87 + animations?: Record<string, AnimSpec[]>; 88 + } 89 + 90 + function loadCloodConfig(): CloodConfig { 91 + try { 92 + const configPath = path.join( 93 + process.env.HOME || "~", 94 + ".config", 95 + "clood.json" 96 + ); 97 + const fs = require("node:fs"); 98 + return JSON.parse(fs.readFileSync(configPath, "utf-8")); 99 + } catch { 100 + return {}; 101 + } 102 + } 103 + 104 + /** Ping-pong interpolation matching clood's Animation::value_at */ 105 + function pingPong(tick: number, min: number, max: number): number { 106 + const range = Math.abs(max - min); 107 + if (range === 0) return min; 108 + const direction = max >= min ? 1 : -1; 109 + const cycleLength = range * 2; 110 + const position = tick % cycleLength; 111 + if (position <= range) { 112 + return min + position * direction; 113 + } else { 114 + return max - (position - range) * direction; 115 + } 116 + } 117 + 118 + /** Map animation param names to clood CLI flags */ 119 + const PARAM_TO_FLAG: Record<string, string> = { 120 + mood: "--mood", 121 + glance: "--glance", 122 + lefteye: "--lefteye", 123 + righteye: "--righteye", 124 + lefteyeclosed: "--lefteyeclosed", 125 + righteyeclosed: "--righteyeclosed", 126 + round: "--round", 127 + leftarm: "--leftarm", 128 + rightarm: "--rightarm", 129 + leftslope: "--leftslope", 130 + rightslope: "--rightslope", 131 + height: "--height", 132 + width: "--width", 133 + armsize: "--armsize", 134 + legsize: "--legsize", 135 + numlegs: "--numlegs", 136 + }; 137 + 138 + /** Render a single animation frame by computing param values and passing as CLI args */ 139 + function renderAnimFrame( 140 + character: string, 141 + animSpecs: AnimSpec[], 142 + tick: number, 143 + baseArgs: string[] 144 + ): string[] { 145 + try { 146 + const args = [...baseArgs]; 147 + 148 + // Compute animated values for this tick 149 + for (const spec of animSpecs) { 150 + const value = pingPong(tick, spec.min, spec.max); 151 + const flag = PARAM_TO_FLAG[spec.param]; 152 + if (flag) { 153 + args.push(flag, String(value)); 154 + } 75 155 } 76 - case "rain": { 77 - // Little rain drops falling under the cloud 78 - const rainFrames = [ 79 - [" ' ' ' ' "], 80 - [" ' ' ' ' "], 81 - [" ' ' ' ' "], 82 - [" ' ' ' ' "], 83 - ]; 84 - const rainLine = rainFrames[frame % rainFrames.length][0]; 85 - lines = ["", ...cloudWithName.map((l) => " " + l), " " + dim(rainLine), ""]; 86 - return lines.map((l) => " " + accent(l.includes(nameUpper) ? l : l)); 156 + 157 + const cmd = `${CLOOD_BIN} ${args.join(" ")}`; 158 + const output = execSync(cmd, { 159 + encoding: "utf-8", 160 + timeout: 2000, 161 + stdio: ["ignore", "pipe", "pipe"], 162 + }); 163 + 164 + const lines = output.split("\n"); 165 + if (lines.length > 0 && lines[lines.length - 1] === "") { 166 + lines.pop(); 87 167 } 88 - case "none": 89 - default: 90 - lines = cloudWithName.map((l) => " " + l); 91 - break; 168 + return lines; 169 + } catch { 170 + return ["(clood render error)"]; 92 171 } 172 + } 93 173 94 - // Colorize 95 - const result = ["", ...lines.map((l) => accent(l)), ""]; 96 - return result; 174 + /** Build base CLI args from a saved character config */ 175 + function characterToArgs(charConfig: Record<string, unknown>): string[] { 176 + const args: string[] = []; 177 + const map: Record<string, string> = { 178 + width: "--width", 179 + height: "--height", 180 + round: "--round", 181 + mood: "--mood", 182 + glance: "--glance", 183 + arm_size: "--armsize", 184 + left_arm_offset: "--leftarm", 185 + right_arm_offset: "--rightarm", 186 + left_arm_slope: "--leftslope", 187 + right_arm_slope: "--rightslope", 188 + num_legs: "--numlegs", 189 + leg_size: "--legsize", 190 + }; 191 + 192 + for (const [key, flag] of Object.entries(map)) { 193 + if (key in charConfig) { 194 + args.push(flag, String(charConfig[key])); 195 + } 196 + } 197 + 198 + // Handle eye states 199 + const leftEye = charConfig.left_eye as 200 + | { offset: number; closed: boolean } 201 + | undefined; 202 + const rightEye = charConfig.right_eye as 203 + | { offset: number; closed: boolean } 204 + | undefined; 205 + if (leftEye) { 206 + args.push("--lefteye", String(leftEye.offset)); 207 + if (leftEye.closed) args.push("--lefteyeclosed", "1"); 208 + } 209 + if (rightEye) { 210 + args.push("--righteye", String(rightEye.offset)); 211 + if (rightEye.closed) args.push("--righteyeclosed", "1"); 212 + } 213 + 214 + // Handle colors 215 + const bodyColor = charConfig.body_color as 216 + | { r: number; g: number; b: number } 217 + | undefined; 218 + const eyeColor = charConfig.eye_color as 219 + | { r: number; g: number; b: number } 220 + | undefined; 221 + if (bodyColor) { 222 + const hex = `#${bodyColor.r.toString(16).padStart(2, "0")}${bodyColor.g.toString(16).padStart(2, "0")}${bodyColor.b.toString(16).padStart(2, "0")}`; 223 + args.push("--color", hex); 224 + } 225 + if (eyeColor) { 226 + const hex = `#${eyeColor.r.toString(16).padStart(2, "0")}${eyeColor.g.toString(16).padStart(2, "0")}${eyeColor.b.toString(16).padStart(2, "0")}`; 227 + args.push("--eyecolor", hex); 228 + } 229 + 230 + return args; 97 231 } 98 232 99 233 // ═══════════════════════════════════════ ··· 104 238 pi.on("session_start", async (_event, ctx) => { 105 239 if (!ctx.hasUI) return; 106 240 241 + const config = loadCloodConfig(); 242 + 243 + // Resolve animation specs 244 + let animSpecs: AnimSpec[] = []; 245 + if (ANIMATION && config.animations) { 246 + // Support comma-separated animation names (merge all specs) 247 + for (const name of ANIMATION.split(",")) { 248 + const trimmed = name.trim(); 249 + if (trimmed && config.animations[trimmed]) { 250 + animSpecs.push(...config.animations[trimmed]); 251 + } 252 + } 253 + } 254 + 255 + // Resolve base character args for animated rendering 256 + let baseArgs: string[] = []; 257 + if (animSpecs.length > 0 && CHARACTER && config.characters?.[CHARACTER]) { 258 + baseArgs = characterToArgs(config.characters[CHARACTER]); 259 + } 260 + 261 + // For static (no animation), just render once 262 + let staticLines: string[] | null = null; 263 + if (animSpecs.length === 0) { 264 + staticLines = renderClood(CHARACTER, ""); 265 + } 266 + 107 267 ctx.ui.setHeader((tui: TUI, theme: Theme) => { 108 268 let frame = 0; 109 269 let timer: ReturnType<typeof setInterval> | null = null; 110 270 111 - if (ANIMATION !== "none") { 271 + if (animSpecs.length > 0) { 272 + const interval = Math.round(1000 / Math.max(1, FPS)); 112 273 timer = setInterval(() => { 113 274 frame++; 114 275 tui.requestRender(); 115 - }, ANIMATION_SPEED); 276 + }, interval); 116 277 } 117 278 118 279 return { 119 - render(width: number): string[] { 120 - const lines = getCloudLines(theme, frame, ANIMATION, width); 280 + render(_width: number): string[] { 281 + let lines: string[]; 282 + 283 + if (staticLines) { 284 + lines = staticLines; 285 + } else { 286 + lines = renderAnimFrame( 287 + CHARACTER, 288 + animSpecs, 289 + frame, 290 + baseArgs 291 + ); 292 + } 293 + 294 + const result = ["", ...lines]; 121 295 122 296 if (SUBTITLE) { 123 - const subtitleLine = ` ${theme.fg("muted", SUBTITLE)}`; 124 - lines.push(subtitleLine); 297 + result.push(` ${theme.fg("muted", SUBTITLE)}`); 125 298 } 126 299 127 - return lines; 300 + result.push(""); 301 + return result; 128 302 }, 129 303 invalidate() {}, 130 304 dispose() { ··· 143 317 handler: async (_args, ctx) => { 144 318 ctx.ui.setHeader(undefined); 145 319 ctx.ui.notify("Default header restored", "info"); 146 - }, 147 - }); 148 - 149 - // Command to switch animation 150 - pi.registerCommand("clood-animation", { 151 - description: "Change the clood header animation (float, pulse, rain, none)", 152 - handler: async (args, ctx) => { 153 - const valid = ["float", "pulse", "rain", "none"]; 154 - if (!args || !valid.includes(args.trim())) { 155 - const choice = await ctx.ui.select("Choose animation:", valid); 156 - if (choice !== undefined) { 157 - ctx.ui.notify(`Animation set to: ${valid[choice]}. Use /reload to apply.`, "info"); 158 - } 159 - return; 160 - } 161 - ctx.ui.notify(`Animation: ${args.trim()}. Edit .pi/extensions/clood-header.ts to persist.`, "info"); 162 320 }, 163 321 }); 164 322 }