Files
dotfiles/home/.pi/agent/extensions/calm/index.ts
T
Lap Tran 4c6e85c97c Switch firstmate to OpenCode 2 and refresh nix-darwin pins
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.
2026-09-18 13:58:49 -04:00

158 lines
6.6 KiB
TypeScript

// Pi Calm - a standalone conversation-presentation toggle for Pi.
//
// Adapted from the Firstmate project's Calm implementation.
// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory.
//
// Verified against Pi 0.82.0, which exports its shared tool-row component,
// session_start replacement reasons, agent_start
// and agent_settled, ExtensionUIContext.setToolsExpanded(), setWorkingVisible(),
// setWidget() with a disposable component factory, and setHiddenThinkingLabel().
// ./lib/working-ship.ts owns the animated working presentation this file
// installs. ./lib/preference.ts owns the local state file. The collapsed-thinking
// presentation adapter probes the exact public API seam it patches and degrades
// independently with one clear diagnostic (see installCalmPresentationAdapter
// below) if a future Pi removes it. The shared tool-row adapter is limited to
// Pi's seven known built-in names, so generic custom tools and unsupported
// transcript classes deliberately stay visible.
//
// Calm changes presentation only. It never intercepts, transforms, reroutes,
// removes, or reorders semantic input, tool execution, model context, session
// storage, or export data; /export and /share render the complete stock
// transcript.
import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import { getKeybindings } from "@earendil-works/pi-tui";
import { installCalmBuiltInToolShellLayout } from "./lib/built-in-tool-shells.ts";
import { installCalmCollapsedThinkingLayout } from "./lib/collapsed-thinking.ts";
import { loadCalmPreference, persistCalmPreference } from "./lib/preference.ts";
import {
calmPresentationIsActive,
setCalmPresentation,
setCalmStockExportRendering,
} from "./lib/visibility.ts";
import {
CALM_WORKING_SHIP_WIDGET_KEY,
createCalmWorkingShipAnimation,
createCalmWorkingShipWidget,
} from "./lib/working-ship.ts";
// Each presentation adapter probes the exact Pi API it patches. If a future Pi
// removes that API, only the affected adapter degrades; the rest of Calm keeps
// working.
function installCalmPresentationAdapter(name: string, install: () => void): void {
try {
install();
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.error(`Pi Calm: ${name} presentation adapter unavailable, skipping. ${reason}`);
}
}
export default function (pi: ExtensionAPI) {
installCalmPresentationAdapter("collapsed-thinking", installCalmCollapsedThinkingLayout);
installCalmPresentationAdapter("built-in-tool-shells", installCalmBuiltInToolShellLayout);
let removeTerminalInputHandler: (() => void) | undefined;
// One logical agent run, tracked from agent_start through agent_settled rather
// than from turns or tool calls, so the boat never flickers between tool calls,
// automatic continuations, retries, or compaction that stay inside the same run.
let agentRunActive = false;
let workingShipShown = 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.
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) {
ui.setWorkingVisible(true);
}
};
pi.on("session_start", (_event, ctx) => {
setCalmPresentation(loadCalmPreference());
setCalmStockExportRendering(false);
agentRunActive = false;
workingShipShown = false;
// A genuine new session lifetime starts the boat at the normal initial position.
workingShipAnimation.reset();
applyWorkingPresentation(ctx.ui, true);
ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined);
removeTerminalInputHandler?.();
removeTerminalInputHandler = ctx.ui.onTerminalInput((data) => {
if (!getKeybindings().matches(data, "tui.input.submit")) return;
const input = ctx.ui.getEditorText().trim();
if (
input !== "/share" &&
input !== "/export" &&
!input.startsWith("/export ")
) {
return;
}
// /export and /share render through the same tool renderers the transcript
// uses, so force stock output for the duration of the command. Session and
// export data are never filtered; this only concerns the visual components.
setCalmStockExportRendering(true);
setTimeout(() => {
setCalmStockExportRendering(false);
const expanded = ctx.ui.getToolsExpanded();
ctx.ui.setToolsExpanded(!expanded);
ctx.ui.setToolsExpanded(expanded);
}, 0);
});
});
pi.on("agent_start", (_event, ctx) => {
agentRunActive = true;
applyWorkingPresentation(ctx.ui);
});
// agent_settled is emitted from a finally block, so it also covers abort and failure.
pi.on("agent_settled", (_event, ctx) => {
agentRunActive = false;
applyWorkingPresentation(ctx.ui);
});
pi.on("session_shutdown", (_event, ctx) => {
agentRunActive = false;
applyWorkingPresentation(ctx.ui);
});
pi.registerCommand("calm", {
description: "Toggle Calm: hide collapsed thinking and built-in tool shells from the transcript (presentation only).",
handler: async (_args, ctx) => {
const active = !calmPresentationIsActive();
// Persist first: if the state file cannot be written, the toggle fails
// with a clear error instead of silently reverting on the next restart.
persistCalmPreference(active);
setCalmPresentation(active);
applyWorkingPresentation(ctx.ui, true);
ctx.ui.setHiddenThinkingLabel(active ? "" : undefined);
// Flip expansion twice to force a transcript redraw while preserving the
// user's exact Ctrl+O tools-expanded state.
const expanded = ctx.ui.getToolsExpanded();
ctx.ui.setToolsExpanded(!expanded);
ctx.ui.setToolsExpanded(expanded);
},
});
}