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
28 changes: 20 additions & 8 deletions .pi/extensions/fm-calm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,28 +135,40 @@ export default function (pi: ExtensionAPI) {
// continuations, retries, or compaction that stay inside the same run.
let agentRunActive = false;
let workingShipShown = false;
let workingShipWidgetDisposed = false;
// One animation instance per extension lifetime. Hiding the working widget freezes
// this state; the next working period resumes it. session_start resets it so a fresh
// Pi session starts at the normal initial position. Never module-global.
const workingShipAnimation = createCalmWorkingShipAnimation();

// Single owner of Calm's working-row presentation choice. The widget is only created
// or removed on a real transition, so repeated starts cannot duplicate its timer.
// The slot is shared with standalone Pi Calm; the dispose signal prevents turning
// Firstmate Calm off from clearing a widget that the other extension installed.
const applyWorkingPresentation = (
ui: ExtensionUIContext,
forceStockVisibility = false,
): void => {
const showShip = agentRunActive && calmPresentationIsActive();
if (showShip !== workingShipShown) {
workingShipShown = showShip;
ui.setWidget(
CALM_WORKING_SHIP_WIDGET_KEY,
showShip
? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation)
: undefined,
);
ui.setWorkingVisible(!showShip);
} else if (forceStockVisibility && !showShip) {
if (showShip) {
ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, (tui) => {
workingShipWidgetDisposed = false;
const widget = createCalmWorkingShipWidget(tui, workingShipAnimation);
const dispose = widget.dispose;
widget.dispose = () => {
workingShipWidgetDisposed = true;
dispose();
};
return widget;
});
ui.setWorkingVisible(false);
} else if (!workingShipWidgetDisposed) {
ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, undefined);
ui.setWorkingVisible(true);
}
} else if (forceStockVisibility && !showShip && !workingShipWidgetDisposed) {
ui.setWorkingVisible(true);
}
};
Expand Down
7 changes: 6 additions & 1 deletion .pi/extensions/lib/fm-calm-working-ship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ const ANSI_FOREGROUND: Record<Exclude<CalmWorkingShipColor, "plain">, string> =
// Restores the default foreground so color never bleeds into padding or later frames.
const RESET = "\u001b[39m";

export const CALM_WORKING_SHIP_WIDGET_KEY = "firstmate-calm-working-ship";
// The working-row widget slot is deliberately shared with the standalone Pi Calm
// extension, which installs its boat under the same "calm-working-ship" key. Pi
// replaces widgets under one key, so a session that loads both Calms renders a
// single boat and a session loading either alone is unchanged. Rename the slot
// in both implementations together, or dual-install sessions duplicate the boat.
export const CALM_WORKING_SHIP_WIDGET_KEY = "calm-working-ship";

export type CalmWorkingShipAnimation = Omit<CalmWorkingShipSprite, "frame"> & {
/** Render one frame that exactly fits `width`, clamping the track to it first. */
Expand Down
4 changes: 2 additions & 2 deletions docs/calm-mode-feasibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ Calm replaces Pi's stock working row with a small animated boat while Calm is on
This path uses only public extension API and patches nothing: `ExtensionUIContext.setWorkingVisible(false)` hides the stock row, and `setWidget()` installs a temporary component factory above the editor.
Pi's documented custom working-indicator frames are static and width-blind, so they cannot own responsive geometry; a widget component receives `render(width)` and can.

`.pi/extensions/fm-calm.ts` remains the sole owner of the presentation choice and the only caller of `setWorkingVisible()`, while `.pi/extensions/lib/fm-calm-working-ship.ts` owns Pi's ANSI painting and the widget over the sprite geometry, bounce track, cadences, and freeze/resume state in `.claude/mods/firstmate-calm/lib/fm-calm-working-ship-sprite.ts`, the harness-neutral core the Claude Code mod also draws from (reached from the Pi tree through a tracked symlink, because Claude Code refuses a hooks-module import from outside the plugin folder).
Within Firstmate Calm, `.pi/extensions/fm-calm.ts` remains the sole owner of its presentation choice and the only caller of `setWorkingVisible()`, while `.pi/extensions/lib/fm-calm-working-ship.ts` owns Pi's ANSI painting and the widget over the sprite geometry, bounce track, cadences, and freeze/resume state in `.claude/mods/firstmate-calm/lib/fm-calm-working-ship-sprite.ts`, the harness-neutral core the Claude Code mod also draws from (reached from the Pi tree through a tracked symlink, because Claude Code refuses a hooks-module import from outside the plugin folder).
Visibility follows `agent_start` through `agent_settled` rather than turns or tool calls.
Pi emits `agent_settled` from a `finally` block once a run will not continue automatically, so retries, automatic continuations, queued follow-ups, and compaction inside one run never remove the boat, while settle, abort, and failure all reach the same cleanup.
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.
Repeated `agent_start` events inside one run are idempotent, and both Calm extensions use the shared `calm-working-ship` widget key, so Pi disposes the previous component before installing a replacement under that key and when it clears extension widgets, while Firstmate Calm's disposal signal prevents its cleanup from clearing a standalone Calm widget that replaced its own 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: an asymmetric three-cell `◿│◣` sail centered over a five-cell `╲▁▁▁╱` hull that sits inside the water row rather than adding a third row.
Expand Down
1 change: 1 addition & 0 deletions docs/calm.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Streaming text and the genuine reply that ends a response remain visible.

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.
No separate Calm status row is added.
The boat claims the same Pi working-row widget slot as the standalone Pi Calm extension, so a session that loads both Calms shows one boat rather than two, and turning Firstmate Calm off does not clear the standalone boat.
While Calm is off, Pi's stock working row is left exactly as Pi renders it.

The boat looks like this:
Expand Down
72 changes: 70 additions & 2 deletions tests/fm-calm-pi-extension.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2956,7 +2956,7 @@ TS
}

test_working_ship_geometry_and_lifecycle() {
local fixture out status version
local fixture out status version standalone_ship
if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
echo "skip: node or npm not found for Pi Calm working-ship test"
return 0
Expand All @@ -2968,6 +2968,17 @@ test_working_ship_geometry_and_lifecycle() {
version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version")
record_pi_version_evidence "$version" "Pi Calm working-ship assumptions"

# The standalone Pi Calm extension is a separate project that installs its own boat
# in the same Pi working-row slot, so the dual-install check below reads its real
# module when it is installed: a rename on either side then renders two boats and
# fails there, instead of passing against a key this test invented. The pinned slot
# contract inside the program covers a machine without that extension.
standalone_ship=${FM_STANDALONE_CALM_SHIP:-}
if [ -z "$standalone_ship" ] && [ -f "${HOME:-}/.pi/agent/extensions/calm/lib/working-ship.ts" ]; then
standalone_ship=${HOME:-}/.pi/agent/extensions/calm/lib/working-ship.ts
fi
[ -f "$standalone_ship" ] || standalone_ship=

fixture="$TMP_ROOT/working-ship"
mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works"
cp "$EXT" "$fixture/fm-calm.ts"
Expand All @@ -2984,7 +2995,7 @@ test_working_ship_geometry_and_lifecycle() {
ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox"
printf '%s\n' '{"type":"module"}' >"$fixture/package.json"

out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS'
out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" STANDALONE_CALM_SHIP="$standalone_ship" node --input-type=module 2>&1 <<'JS'
import { pathToFileURL } from "node:url";

const packageRoot = process.env.PI_PACKAGE_DIR;
Expand Down Expand Up @@ -3642,6 +3653,39 @@ const reset = () => {
};
const shipWidget = () => ui.widgets.get(CALM_WORKING_SHIP_WIDGET_KEY);

let standaloneDisposed = false;
const standaloneWidget = {
render: () => ["standalone boat"],
dispose: () => { standaloneDisposed = true; },
};
const firstmateWidget = {
render: () => ["firstmate boat"],
dispose: () => {},
};
// The standalone Pi Calm extension installs its boat in the same Pi working-row
// widget slot. Where that extension is installed, the slot key comes from its own
// module, so a rename on either side registers two widgets and fails here rather
// than passing against a key this test invented; the pinned slot is the shared
// contract both implementations must keep.
const STANDALONE_SLOT = "calm-working-ship";
let standaloneSlot = STANDALONE_SLOT;
if (process.env.STANDALONE_CALM_SHIP) {
const standaloneShip = await import(
`${pathToFileURL(process.env.STANDALONE_CALM_SHIP).href}?standalone=${Date.now()}`
);
standaloneSlot = standaloneShip.CALM_WORKING_SHIP_WIDGET_KEY;
}
ui.setWidget(standaloneSlot, () => standaloneWidget);
ui.setWidget(CALM_WORKING_SHIP_WIDGET_KEY, () => firstmateWidget);
const renderedDualInstallWidgets = [...ui.widgets.values()].map((widget) => widget.render(80));
check(
standaloneDisposed &&
renderedDualInstallWidgets.length === 1 &&
renderedDualInstallWidgets[0][0] === "firstmate boat",
`dual Calm install rendered ${renderedDualInstallWidgets.length} working widgets instead of one`,
);
ui.setWidget(standaloneSlot, undefined);

// --- Calm off leaves Pi's stock working behavior completely untouched -------------
await fire("session_start", { reason: "startup" });
reset();
Expand Down Expand Up @@ -3849,6 +3893,25 @@ check(liveTimers === 1, "a later run did not use the boat after an idle Calm tog
await fire("agent_settled");
check(liveTimers === 0, "the later run did not clean up");

await fire("agent_start");
let survivingStandaloneDisposed = false;
const survivingStandaloneWidget = {
render: () => ["standalone boat"],
dispose: () => { survivingStandaloneDisposed = true; },
};
ui.setWidget(standaloneSlot, () => survivingStandaloneWidget);
reset();
await calmCommand.handler("", ctx);
check(
!survivingStandaloneDisposed &&
ui.widgets.size === 1 &&
ui.widgets.get(standaloneSlot) === survivingStandaloneWidget &&
ui.widgetOps.length === 0 &&
ui.workingVisible.length === 0,
"turning Firstmate Calm off cleared or exposed the standalone working ship",
);
ui.setWidget(standaloneSlot, undefined);

// --- The visual-only widget never touches session, transcript, or export data ------
check(
sessionWrites.length === 0,
Expand All @@ -3863,6 +3926,11 @@ JS
[ "$status" -eq 0 ] || fail "Pi Calm working-ship checks failed: $out"
[ -z "$out" ] || fail "Pi Calm working-ship test printed output: $out"
pass "Pi Calm working ship keeps its centered two-row asymmetric Unicode boat inside a deterministic long-wave trough, paints all water standard blue and the whole boat standard yellow with balanced resets, keeps ANSI-stripped width exact, reverses cleanly at both edges and every width, clamps visible and hidden resizes, falls back deterministically when narrow, freezes and resumes across settle/start without hidden-time jumps or duplicate timers, resets only on a fresh session, and leaves Calm-off visibility untouched"
if [ -n "$standalone_ship" ]; then
pass "Pi Calm dual-install coverage read the installed standalone Pi Calm extension's own working-ship slot from $standalone_ship"
else
pass "SKIP: no standalone Pi Calm extension is installed, so dual-install coverage used the pinned shared-slot contract; set FM_STANDALONE_CALM_SHIP to check one"
fi
}

# The rendered-DOM assertions below depend on a real browser, so the render step
Expand Down
Loading