// Pi Calm - animated working presentation. // // Adapted from the Firstmate project's Calm implementation. // Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. // // Calm replaces Pi's stock working row with a tiny two-row ASCII boat while one // logical agent run is active. This module owns only the sprite geometry, the // bounce track, the two animation cadences, the session-scoped freeze/resume // state, and the temporary TUI widget; ../index.ts owns when the presentation // is installed and removed, and stays the sole caller of setWorkingVisible(). // // Cadence: one scheduler drives two logically independent clocks. Every tick // advances the water phase, and only every CALM_WORKING_SHIP_TICKS_PER_MOVE-th // tick moves the boat, so the water visibly ripples several times between boat // steps and the boat itself reads as calm. Both clocks stop together when the // widget is disposed. Ticks, not wall-clock timestamps, drive every state // change, so tests can seek time exactly. // // Continuity: one extension-owned animation instance survives hide/show within // the same Pi process and Calm extension lifetime. Disposing the widget freezes // column, direction, water phase, and tick cadence without advancing them for // hidden wall time. The next working period resumes from that exact logical // state. A fresh session or new extension lifetime calls reset() and starts at // the normal initial position. State is never a module-level or process-global // singleton. // // Verified against Pi 0.82.0, which exposes ExtensionUIContext.setWidget() with // a component factory, per-widget dispose(), and TUI.requestRender(). Pi renders // a widget through Component.render(width), so this module recomputes its track // from that width on every frame instead of caching a terminal size that a // resize would invalidate. A resize while the boat is hidden is applied on the // first resumed frame through the same clamp path. import type { Component, TUI } from "@earendil-works/pi-tui"; // The hull is symmetric and replaces waves on its row rather than adding a third row. const HULL = "\\__/"; // A mainsail extends aft of the mast, so it trails behind the bow relative to travel. const SAIL_RIGHT = "<|"; const SAIL_LEFT = "|>"; // Centers the two-cell sail over the four-cell hull. const SAIL_OFFSET = 1; const HULL_WIDTH = HULL.length; const SAIL_WIDTH = SAIL_RIGHT.length; // Bounded deterministic fixed-cell water phases. Every entry is exactly one column, so // advancing the phase ripples the surface without changing visible width or row count. const WAVE_CYCLE = ["~", "~", "-", "~"] as const; // Standard ANSI foreground codes only: no theme lookup, bright variant, or 256/RGB. const BLUE = "\u001b[34m"; const YELLOW = "\u001b[33m"; // Restores the default foreground so color never bleeds into padding or later frames. const RESET = "\u001b[39m"; export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship"; /** Scheduler period. One tick advances the water by one phase. */ export const CALM_WORKING_SHIP_TICK_MS = 220; /** Boat moves one column every Nth tick, so it travels at 220 * 4 = 880ms per column. */ export const CALM_WORKING_SHIP_TICKS_PER_MOVE = 4; export type CalmWorkingShipAnimation = { /** Render one frame that exactly fits `width`, clamping the track to it first. */ render(width: number): string[]; /** Advance one scheduler tick: water every tick, boat on its slower cadence. */ tick(): void; restoreLastRendered(): void; /** Restore the normal initial column, direction, water phase, and cadence. */ reset(): void; /** * Clamp the frozen column and direction to `width` without advancing time. * Used when a terminal resize lands while the working presentation is hidden. */ clampToWidth(width: number): void; /** Current hull column, exposed for deterministic motion assertions. */ position(): number; /** Current travel direction: 1 travelling right, -1 travelling left. */ direction(): number; /** Current water phase, exposed for deterministic ripple assertions. */ waterPhase(): number; }; /** Longest hull start column that still fits the sprite in `width` usable cells. */ function trackSpan(width: number): number { if (width >= HULL_WIDTH) return width - HULL_WIDTH; if (width >= SAIL_WIDTH) return width - SAIL_WIDTH; return 0; } export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation { let position = 0; let direction = 1; let span = 0; let phase = 0; let ticks = 0; let renderedPosition = position; let renderedDirection = direction; let renderedSpan = span; let renderedPhase = phase; let renderedTicks = ticks; // Reversing the moment the boat lands on an endpoint means the endpoint frame itself // already shows the new heading, so no frame at or after a bounce shows the old sail. const settleDirectionAtEdges = (): void => { if (span <= 0) return; if (position >= span) direction = -1; else if (position <= 0) direction = 1; }; const applyWidth = (width: number): void => { if (width <= 0) { span = 0; position = 0; return; } span = trackSpan(width); position = Math.min(position, span); settleDirectionAtEdges(); }; const commitRenderedState = (): void => { renderedPosition = position; renderedDirection = direction; renderedSpan = span; renderedPhase = phase; renderedTicks = ticks; }; const restoreLastRenderedState = (): void => { position = renderedPosition; direction = renderedDirection; span = renderedSpan; phase = renderedPhase; ticks = renderedTicks; }; /** One colored run of water covering absolute columns [from, from + count). */ const water = (from: number, count: number): string => { if (count <= 0) return ""; let cells = ""; for (let column = from; column < from + count; column += 1) { cells += WAVE_CYCLE[(column + phase) % WAVE_CYCLE.length]; } return `${BLUE}${cells}${RESET}`; }; const boat = (text: string): string => `${YELLOW}${text}${RESET}`; return { position: () => position, direction: () => direction, waterPhase: () => phase, restoreLastRendered: restoreLastRenderedState, reset(): void { position = 0; direction = 1; span = 0; phase = 0; ticks = 0; commitRenderedState(); }, clampToWidth(width: number): void { applyWidth(width); }, tick(): void { ticks += 1; phase = (phase + 1) % WAVE_CYCLE.length; if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return; if (span <= 0) { position = 0; return; } position = Math.min(span, Math.max(0, position + direction)); settleDirectionAtEdges(); }, render(width: number): string[] { if (width <= 0) return []; // A resize lands here before the next frame, so recompute and clamp the track // immediately rather than trusting a position measured against the old width. applyWidth(width); const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT; let frame: string[]; if (width < SAIL_WIDTH) { // Too narrow for even the sail: a deterministic single row of water. frame = [water(0, width)]; } else if (width < HULL_WIDTH) { // Too narrow for the hull: the sail alone rides the water row. frame = [ water(0, position) + boat(sail) + water(position + SAIL_WIDTH, width - position - SAIL_WIDTH), ]; } else { frame = [ " ".repeat(position + SAIL_OFFSET) + boat(sail), water(0, position) + boat(HULL) + water(position + HULL_WIDTH, width - position - HULL_WIDTH), ]; } commitRenderedState(); return frame; }, }; } /** * Build the temporary Calm working widget bound to one caller-owned animation. * Pi disposes the previous component before installing a replacement under the same * key and when it clears extension widgets, so the single scheduler driving both * cadences cannot outlive the widget or duplicate. Disposing freezes the shared * animation in place; the next widget bound to the same animation resumes without * applying hidden wall time. */ export function createCalmWorkingShipWidget( tui: TUI, animation: CalmWorkingShipAnimation = createCalmWorkingShipAnimation(), ): Component & { dispose(): void } { let disposed = false; const timer = setInterval(() => { if (disposed) return; animation.tick(); tui.requestRender(); }, CALM_WORKING_SHIP_TICK_MS); // The animation must never keep Pi's process alive on its own. timer.unref?.(); return { render: (width) => (disposed ? [] : animation.render(width)), // Every frame is rebuilt from fixed standard ANSI codes, so there is no cache. invalidate: () => {}, dispose: () => { if (disposed) return; disposed = true; clearInterval(timer); animation.restoreLastRendered(); }, }; }