Point fm at the isolated oc2 wrapper, upgrade Homebrew packages on rebuild, and declare ffmpeg, docker-desktop, puremac, utm, node, and tea. Update nixpkgs, home-manager, and nix-homebrew locks. Track Pi agent config, crew-dispatch, and the Gitea PR helper that home.nix already expected.
248 lines
8.9 KiB
TypeScript
248 lines
8.9 KiB
TypeScript
// 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();
|
|
},
|
|
};
|
|
}
|