Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 115 additions & 38 deletions .pi/extensions/lib/fm-calm-working-ship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
// installed and removed, and stays the sole caller of setWorkingVisible().
// docs/calm.md owns the captain-facing contract.
//
// 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.
// Cadence: one scheduler drives two linked cadences. Every tick advances the wave by
// one quarter-cell, and every CALM_WORKING_SHIP_TICKS_PER_MOVE-th tick moves the boat
// one whole cell, so the trough stays phase-locked to a deliberately calm boat.
// Both cadences 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,
Expand All @@ -26,25 +26,37 @@
// 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.
import { visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";

// The asymmetric three-cell sail is centered over a five-cell hull. The one-cell
// quarter triangle keeps the yellow left sail lighter than the full red right sail.
// The hull's inner cells retain zero-height water glyphs instead of interrupting the trough.
const LEFT_SAIL = "◿";
const MAST = "│";
const RIGHT_SAIL = "◣";
const SAIL = `${LEFT_SAIL}${MAST}${RIGHT_SAIL}`;
const HULL_LEFT = "╲";
const HULL_WATER = "▁▁▁";
const HULL_RIGHT = "╱";
const HULL = `${HULL_LEFT}${HULL_WATER}${HULL_RIGHT}`;
const SAIL_OFFSET = 1;
const HULL_WIDTH = HULL.length;
const SAIL_WIDTH = SAIL_RIGHT.length;
const HULL_WIDTH = visibleWidth(HULL);
const SAIL_WIDTH = visibleWidth(SAIL);

// 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;
// Pi Dictation uses these bottom-aligned one-cell bars for truthful level history.
// Calm deliberately keeps only its lower half: a long, low ocean swell rather than an
// audio-sized waveform. Every glyph is one terminal column under Pi TUI's width rules.
const WAVE_BARS = ["▁", "▂", "▃", "▄"] as const;
const WAVE_MAX_LEVEL = WAVE_BARS.length - 1;
const WAVE_HALF_LENGTH_MIN = 9;
const WAVE_HALF_LENGTH_SPAN = 5;
const WAVE_TROUGH_RADIUS = 5;

// Standard ANSI foreground codes only: no theme lookup, bright variant, or 256/RGB.
const BLUE = "\u001b[34m";
const CYAN = "\u001b[36m";
const YELLOW = "\u001b[33m";
const RED = "\u001b[31m";
// Restores the default foreground so color never bleeds into padding or later frames.
const RESET = "\u001b[39m";

Expand All @@ -71,7 +83,7 @@ export type CalmWorkingShipAnimation = {
position(): number;
/** Current travel direction: 1 travelling right, -1 travelling left. */
direction(): number;
/** Current water phase, exposed for deterministic ripple assertions. */
/** Current quarter-cell wave phase, exposed for deterministic swell assertions. */
waterPhase(): number;
};

Expand All @@ -82,6 +94,62 @@ function trackSpan(width: number): number {
return 0;
}

/** Stable bounded variation for successive half-waves on either side of the trough. */
function halfWaveLength(index: number, negative: boolean): number {
let value =
((negative ? 0xc411 : 0x5ea1) + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
value ^= value >>> 16;
value = Math.imul(value, 0x7feb352d) >>> 0;
value ^= value >>> 15;
value >>>= 0;
return WAVE_HALF_LENGTH_MIN + (value % WAVE_HALF_LENGTH_SPAN);
}

function smoothstep(value: number): number {
const bounded = Math.max(0, Math.min(1, value));
return bounded * bounded * (3 - 2 * bounded);
}

/** Smooth amplitude at one fractional cell in the deterministic variable wave field. */
function waveAmplitude(coordinate: number): number {
const negative = coordinate < 0;
let distance = Math.abs(coordinate);
let rising = true;
for (let index = 0; ; index += 1) {
const length = halfWaveLength(index, negative);
if (distance <= length) {
const eased = smoothstep(distance / length);
return (rising ? eased : 1 - eased) * WAVE_MAX_LEVEL;
}
distance -= length;
rising = !rising;
}
}

/**
* One bottom-aligned bar at an absolute column.
*
* The wave advances one quarter-cell on every water tick and exactly one cell on the
* boat's slower movement tick. Anchoring that displacement to the hull center keeps
* the boat inside the same broad trough without per-frame randomness or jitter.
*/
function waveLevel(
column: number,
hullCenter: number,
direction: number,
phase: number,
): number {
const displacement =
hullCenter + (direction * phase) / CALM_WORKING_SHIP_TICKS_PER_MOVE;
const coordinate = column - displacement;
if (Math.abs(coordinate) <= WAVE_TROUGH_RADIUS) return 0;
const beyondTrough = coordinate - Math.sign(coordinate) * WAVE_TROUGH_RADIUS;
return Math.max(
0,
Math.min(WAVE_MAX_LEVEL, Math.round(waveAmplitude(beyondTrough))),
);
}

export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
let position = 0;
let direction = 1;
Expand All @@ -94,8 +162,8 @@ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
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.
// Reversing the moment the boat lands on an endpoint means the endpoint frame already
// carries the new wave direction, so the trough follows the next boat movement.
const settleDirectionAtEdges = (): void => {
if (span <= 0) return;
if (position >= span) direction = -1;
Expand Down Expand Up @@ -129,17 +197,22 @@ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
ticks = renderedTicks;
};

/** One colored run of water covering absolute columns [from, from + count). */
const water = (from: number, count: number): string => {
if (count <= 0) return "";
/** One colored run of low water covering absolute columns [from, from + count). */
const water = (from: number, count: number, hullCenter: number): string => {
let cells = "";
for (let column = from; column < from + count; column += 1) {
cells += WAVE_CYCLE[(column + phase) % WAVE_CYCLE.length];
const level = waveLevel(column, hullCenter, direction, phase);
const color = level >= 2 ? CYAN : BLUE;
cells += `${color}${WAVE_BARS[level]}${RESET}`;
}
return `${BLUE}${cells}${RESET}`;
return cells;
};

const boat = (text: string): string => `${YELLOW}${text}${RESET}`;
const sail = (): string =>
`${YELLOW}${LEFT_SAIL}${MAST}${RESET}${RED}${RIGHT_SAIL}${RESET}`;
const hull = (): string =>
`${boat(HULL_LEFT)}${BLUE}${HULL_WATER}${RESET}${boat(HULL_RIGHT)}`;

return {
position: () => position,
Expand All @@ -163,7 +236,7 @@ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {

tick(): void {
ticks += 1;
phase = (phase + 1) % WAVE_CYCLE.length;
phase = (phase + 1) % CALM_WORKING_SHIP_TICKS_PER_MOVE;
if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return;
if (span <= 0) {
position = 0;
Expand All @@ -180,25 +253,29 @@ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
// immediately rather than trusting a position measured against the old width.
applyWidth(width);

const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT;
const hullCenter =
position +
(width >= HULL_WIDTH
? Math.floor(HULL_WIDTH / 2)
: Math.floor(SAIL_WIDTH / 2));

let frame: string[];
if (width < SAIL_WIDTH) {
// Too narrow for even the sail: a deterministic single row of water.
frame = [water(0, width)];
// Too narrow for even the sail: a deterministic single row of low water.
frame = [water(0, width, hullCenter)];
} else if (width < HULL_WIDTH) {
// Too narrow for the hull: the sail alone rides the water row.
// Too narrow for the hull: the sail alone rides inside the water row.
frame = [
water(0, position) +
boat(sail) +
water(position + SAIL_WIDTH, width - position - SAIL_WIDTH),
water(0, position, hullCenter) +
sail() +
water(position + SAIL_WIDTH, width - position - SAIL_WIDTH, hullCenter),
];
} else {
frame = [
" ".repeat(position + SAIL_OFFSET) + boat(sail),
water(0, position) +
boat(HULL) +
water(position + HULL_WIDTH, width - position - HULL_WIDTH),
" ".repeat(position + SAIL_OFFSET) + sail(),
water(0, position, hullCenter) +
hull() +
water(position + HULL_WIDTH, width - position - HULL_WIDTH, hullCenter),
];
}

Expand Down
18 changes: 10 additions & 8 deletions docs/calm-mode-feasibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,19 @@ Pi emits `agent_settled` from a `finally` block once a run will not continue aut
Repeated `agent_start` events inside one run are idempotent, and Pi disposes the previous component before installing a replacement under the same key and when it clears extension widgets, so the frame timer cannot duplicate or outlive the widget.
Pi's above-editor widget container reserves one spacer row whether or not a widget is present, so removing the boat leaves no residual blank row.

The sprite is two rows when the usable width admits the complete hull: a two-cell mainsail centered over a symmetric `\__/` hull that replaces water on its row rather than adding a third row.
The sail is directional because a mainsail extends aft of the mast, so it renders `<|` while travelling right and `|>` while travelling left.
Direction reverses the moment the boat lands on an endpoint, so the endpoint frame itself already shows the new heading and no frame at or after a bounce shows the previous sail.
The sprite is two rows when the usable width admits the complete hull: an asymmetric three-cell `◿│◣` sail centered over a five-cell `╲▁▁▁╱` hull that sits inside the water row rather than adding a third row.
The sail is the same in both travel directions, and its one-cell quarter triangle keeps the left sail visibly smaller than the full right sail.
The hull's three inner cells are zero-height water glyphs, so the swell reads as continuous beneath the boat instead of being interrupted by it.
Direction reverses the moment the boat lands on an endpoint, so the endpoint frame itself already carries the new heading and the trough follows the next boat movement without a discontinuity.
The water row fills the complete supplied width, the track is recomputed and clamped from that width on every frame so a resize cannot wrap or strand the boat offscreen, and widths too narrow for the hull fall back to a deterministic single row.

One scheduler drives two logically independent clocks.
Every tick advances a bounded fixed-cell water phase, and only every fourth tick moves the boat, so at a 220ms tick the water ripples several times between boat steps and the boat travels one column every 880ms.
Ticks rather than wall-clock timestamps drive every state change, so tests seek animation time exactly, and disposing the widget stops both clocks together.
Water phases are single-column ASCII, so advancing them never changes visible width, adds a row, or moves the hull column.
One scheduler drives two linked cadences.
Every tick advances the wave by one quarter-cell, and only every fourth tick moves the boat one whole cell, so at a 220ms tick the swell advances one cell per 880ms boat step and the boat stays phase-locked inside the same trough.
Ticks rather than wall-clock timestamps drive every state change, so tests seek animation time exactly, and disposing the widget stops both cadences together.
The water is the lower half of the bottom-aligned one-cell bars that Pi Dictation uses for its level history, `▁▂▃▄`, so advancing the phase never changes visible width, adds a row, or moves the hull column.
The swell is a deterministic field of smoothstep half-waves whose lengths vary between nine and thirteen cells from a fixed hash, surrounding a broad zero-height trough five cells either side of the hull center, so the boat never rides a crest and the surface still avoids a mechanical fixed period.

Colors are standard ANSI foreground codes rather than theme lookups: blue for every water cell and yellow for the complete boat, with no bright variant, 256-color, or RGB escape.
Colors are standard ANSI foreground codes rather than theme lookups: blue for troughs and low water, cyan for crests, yellow for the left sail, mast, and hull edges, red for the right sail, and blue for the hull's interior water, with no bright variant, 256-color, or RGB escape.
Each colored run is closed with a default-foreground reset so styling cannot bleed into the sail row's padding, neighbouring UI, or a later frame, and geometry is always computed from visible cells rather than escape bytes.

The presentation is TUI-only and visual-only.
Expand Down
7 changes: 4 additions & 3 deletions docs/calm.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Calm is a Pi-only conversation presentation toggle.
It is off by default, and the last `/calm` choice persists for the effective Firstmate home across Pi session starts and resumes.

While Calm is active and an agent run is under way, Calm hides Pi's built-in `Working...` row and shows a small two-row animated boat in its place, and no separate Calm status row is added.
The water fills the usable width in standard ANSI blue and the complete boat is standard ANSI yellow.
The boat is deliberately calm: it moves one column every 880ms, while the water ripples on its own faster cadence so the surface stays alive between boat steps.
Its mainsail is directional, showing `<|` while travelling right and `|>` while travelling left, and it flips on the exact frame the boat turns at either edge.
The water fills the usable width with low one-cell Unicode bars, using standard ANSI blue for troughs and cyan for crests.
The asymmetric three-cell `◿│◣` sail is centered over the five-cell `╲▁▁▁╱` hull, with a smaller standard ANSI yellow quarter sail, a larger standard ANSI red right sail, and a blue zero-height interior that keeps the water visible through the boat.
The boat is deliberately calm: it moves one column every 880ms, while the long smooth wave advances one quarter-cell every 220ms so the surface stays alive between boat steps.
Deterministically varied half-waves stay between nine and thirteen cells, and the boat remains phase-locked inside a broad zero-height trough through movement and edge reversals.
Every resize reflows the sprite without wrapping, and it disappears when the run settles, aborts, or fails.
Within one Pi session and Calm extension lifetime, the next working period resumes the boat from its last rendered column and travel direction rather than restarting at the left edge.
Hidden elapsed time does not advance the animation, and a resize while hidden clamps the frozen boat to the new width without changing its valid travel direction.
Expand Down
Loading
Loading