From 075f23fd5941d818b5010f294e0f6fb0f4e8e147 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 31 Jul 2026 21:20:08 -0700 Subject: [PATCH 1/5] feat(pi): add standalone Calm extension --- .gitignore | 3 + README.md | 10 +- home/.pi/agent/extensions/calm/LICENSE | 21 + home/.pi/agent/extensions/calm/index.ts | 307 ++++++++ .../extensions/calm/lib/collapsed-thinking.ts | 82 +++ .../agent/extensions/calm/lib/preference.ts | 77 ++ .../agent/extensions/calm/lib/visibility.ts | 39 + .../agent/extensions/calm/lib/working-ship.ts | 247 +++++++ tests/lib.sh | 76 ++ tests/pi-calm.test.sh | 696 ++++++++++++++++++ 10 files changed, 1557 insertions(+), 1 deletion(-) create mode 100644 home/.pi/agent/extensions/calm/LICENSE create mode 100644 home/.pi/agent/extensions/calm/index.ts create mode 100644 home/.pi/agent/extensions/calm/lib/collapsed-thinking.ts create mode 100644 home/.pi/agent/extensions/calm/lib/preference.ts create mode 100644 home/.pi/agent/extensions/calm/lib/visibility.ts create mode 100644 home/.pi/agent/extensions/calm/lib/working-ship.ts create mode 100755 tests/lib.sh create mode 100755 tests/pi-calm.test.sh diff --git a/.gitignore b/.gitignore index 0a3116f..1c3ba93 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,8 @@ result-* /home/.pi/agent/auth.json /home/.pi/agent/**/*.backup +# Pi Calm is a runtime toggle; its local state file is never tracked +/home/.pi/agent/calm + # no-mistakes local validation state - never commit in this public repo .no-mistakes/ diff --git a/README.md b/README.md index 7684805..66ee11c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Running the switch builds: - Editor (Neovim config with the rose-pine moon theme) - Terminal (WezTerm config with the rose-pine moon theme and dimmed unfocused windows) - Agent configs (Claude, Codex, opencode all share one AGENTS.md) -- Optional Pi theme and terminal-title extension, generic UI settings and model overrides, plus two deliberately pinned third-party Pi packages +- Optional Pi theme and local extensions, generic UI settings and model overrides, plus two deliberately pinned third-party Pi packages ## Prerequisites @@ -153,6 +153,14 @@ brew install --cask kunchenguid/tap/pi-launcher Home Manager owns exactly two repository-authored Pi directories: `~/.pi/agent/themes` and `~/.pi/agent/extensions`. It also links `models.json` and `settings.json` as individual files. The local extension directory is for public, repository-authored extensions only - third-party package code never belongs there. Run `/reload` after editing a local extension or other Pi resources. The terminal-title extension shows a spinner while Pi is working, then a completion mark with the session name or current directory. The `rose-pine-moon` theme was authored clean-room from the public [Rosé Pine Moon palette](https://rosepinetheme.com/palette) and Pi's [public theme schema](https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json), not from a private or live theme file. +### Pi Calm + +`extensions/calm` is a standalone local Pi extension. `/calm` toggles a conversation-only presentation mode and is off by default. Its choice is stored locally in `~/.pi/agent/calm` (or the directory selected by `PI_CODING_AGENT_DIR`), not in this repository or Home Manager. + +When enabled, Calm hides collapsed thinking and the call/result shells for Pi's seven built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`) without leaving blank transcript rows. During an active run it replaces Pi's working row with a two-line animated blue-water, yellow-boat widget. `/calm` restores Pi's stock rendering and preserves the existing Ctrl+O tool-expansion choice. + +Calm never changes prompts, tool execution, model context, session data, or ordering. `/share` and `/export` use the complete stock transcript. Generic custom tools, images, and unsupported Pi transcript classes deliberately remain visible because Pi has no safe general-purpose transcript filter. If a future Pi release no longer exports the exact collapsed-thinking rendering seam, Calm logs one diagnostic and leaves only that adapter disabled; all other behavior remains available. + Pi's package system declares two third-party sources in the linked global `settings.json`: - `npm:@ryan_nookpi/pi-extension-codex-fast-mode@0.2.6` - the exact public npm release from `ryan_nookpi`. diff --git a/home/.pi/agent/extensions/calm/LICENSE b/home/.pi/agent/extensions/calm/LICENSE new file mode 100644 index 0000000..5043b3a --- /dev/null +++ b/home/.pi/agent/extensions/calm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kun Chen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/home/.pi/agent/extensions/calm/index.ts b/home/.pi/agent/extensions/calm/index.ts new file mode 100644 index 0000000..854fe00 --- /dev/null +++ b/home/.pi/agent/extensions/calm/index.ts @@ -0,0 +1,307 @@ +// 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 exposes built-in ToolDefinitions, per-slot +// renderers, renderShell: "self", 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; Pi exposes no global renderer for arbitrary +// built-in or custom rows, 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, + type ToolDefinition, + type ToolRenderResultOptions, + createBashToolDefinition, + createEditToolDefinition, + createFindToolDefinition, + createGrepToolDefinition, + createLsToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { Box, Container, getKeybindings, type Component } from "@earendil-works/pi-tui"; +import type { TSchema } from "typebox"; +import { installCalmCollapsedThinkingLayout } from "./lib/collapsed-thinking.ts"; +import { loadCalmPreference, persistCalmPreference } from "./lib/preference.ts"; +import { + calmHidesTranscriptChrome, + calmPresentationIsActive, + setCalmPresentation, + setCalmStockExportRendering, +} from "./lib/visibility.ts"; +import { + CALM_WORKING_SHIP_WIDGET_KEY, + createCalmWorkingShipAnimation, + createCalmWorkingShipWidget, +} from "./lib/working-ship.ts"; + +type DefinitionFactory = ( + cwd: string, +) => ToolDefinition; + +type RenderContext = Parameters< + NonNullable["renderCall"]> +>[2]; + +type RenderArgs = Parameters< + NonNullable["renderCall"]> +>[0]; + +type RenderTheme = Parameters< + NonNullable["renderCall"]> +>[1]; + +type RenderResult = Parameters< + NonNullable["renderResult"]> +>[0]; + +type StandardShellState = { + shell?: Box; + call?: Component; + result?: Component; +}; + +// 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); + + let exportRendering = false; + 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); + } + }; + + function registerBuiltIn( + factory: DefinitionFactory, + ): void { + const definitions = new Map>(); + const definitionFor = (cwd: string): ToolDefinition => { + let definition = definitions.get(cwd); + if (!definition) { + definition = factory(cwd); + definitions.set(cwd, definition); + } + return definition; + }; + + const original = definitionFor(process.cwd()); + const originalRenderCall = original.renderCall; + const originalRenderResult = original.renderResult; + const originalSelfShell = original.renderShell === "self"; + const standardShells = new WeakMap(); + + if (!originalRenderCall || !originalRenderResult) { + throw new Error(`Pi Calm requires both render slots for Pi built-in tool ${original.name}`); + } + + const shellStateFor = ( + context: RenderContext, + ): StandardShellState => { + const rowState = context.state as object; + let shellState = standardShells.get(rowState); + if (!shellState) { + shellState = {}; + standardShells.set(rowState, shellState); + } + return shellState; + }; + + const refreshStandardShell = ( + state: StandardShellState, + theme: RenderTheme, + context: RenderContext, + ): Box => { + const background = context.isPartial + ? (text: string) => theme.bg("toolPendingBg", text) + : context.isError + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text); + const shell = state.shell ?? new Box(1, 1, background); + state.shell = shell; + shell.setBgFn(background); + shell.clear(); + if (state.call) shell.addChild(state.call); + if (state.result) shell.addChild(state.result); + return shell; + }; + + pi.registerTool({ + ...original, + renderShell: "self", + + async execute(toolCallId, params, signal, onUpdate, ctx) { + return definitionFor(ctx.cwd).execute(toolCallId, params, signal, onUpdate, ctx); + }, + + renderCall( + args: RenderArgs, + theme: RenderTheme, + context: RenderContext, + ) { + if (exportRendering) return originalRenderCall(args, theme, context); + if (calmHidesTranscriptChrome()) return new Container(); + if (originalSelfShell) return originalRenderCall(args, theme, context); + + const state = shellStateFor(context); + state.call = originalRenderCall(args, theme, { + ...context, + lastComponent: state.call, + }); + return refreshStandardShell(state, theme, context); + }, + + renderResult( + result: RenderResult, + options: ToolRenderResultOptions, + theme: RenderTheme, + context: RenderContext, + ) { + if (exportRendering) return originalRenderResult(result, options, theme, context); + if (calmHidesTranscriptChrome()) return new Container(); + if (originalSelfShell) return originalRenderResult(result, options, theme, context); + + const state = shellStateFor(context); + state.result = originalRenderResult(result, options, theme, { + ...context, + lastComponent: state.result, + }); + refreshStandardShell(state, theme, context); + return new Container(); + }, + }); + } + + registerBuiltIn(createReadToolDefinition); + registerBuiltIn(createBashToolDefinition); + registerBuiltIn(createEditToolDefinition); + registerBuiltIn(createWriteToolDefinition); + registerBuiltIn(createGrepToolDefinition); + registerBuiltIn(createFindToolDefinition); + registerBuiltIn(createLsToolDefinition); + + pi.on("session_start", (_event, ctx) => { + exportRendering = false; + 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. + exportRendering = true; + setCalmStockExportRendering(true); + setTimeout(() => { + exportRendering = false; + 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); + }, + }); +} diff --git a/home/.pi/agent/extensions/calm/lib/collapsed-thinking.ts b/home/.pi/agent/extensions/calm/lib/collapsed-thinking.ts new file mode 100644 index 0000000..6e7c157 --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/collapsed-thinking.ts @@ -0,0 +1,82 @@ +// Pi Calm - gapless collapsed-thinking presentation adapter. +// +// 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 AssistantMessageComponent with an +// updateContent method. installCalmCollapsedThinkingLayout() probes that exact +// public seam and throws if it is missing; index.ts catches that and skips only +// this adapter with one clear diagnostic instead of blocking Calm or Pi. +// +// How it works: Pi renders a hidden thinking block as one static label row. +// Calm sets that label to the empty string and this adapter filters thinking +// blocks out of the message handed to the stock renderer, so a collapsed +// thinking block occupies zero rows instead of one blank one. The unfiltered +// message is kept on lastMessage so expanding thinking (Ctrl+T) and turning +// Calm off both restore the original reasoning content byte-for-byte. Only +// collapsed thinking is affected: expanded reasoning, assistant text, and tool +// calls render exactly as Pi renders them. +import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; +import { calmHidesTranscriptChrome } from "./visibility.ts"; + +type AssistantMessage = Parameters[0]; + +type AssistantMessagePresentationState = { + hiddenThinkingLabel: string; + hideThinkingBlock: boolean; + lastMessage?: AssistantMessage; +}; + +type CalmCollapsedThinkingPatch = { + hidesThinking: () => boolean; +}; + +// Keep the introduction-version symbol stable so a compatible upgrade cannot +// double-patch a live process. +const CALM_COLLAPSED_THINKING_PATCH = Symbol.for( + "pi-calm:collapsed-thinking-layout:pi-0.82.0", +); + +export function installCalmCollapsedThinkingLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmCollapsedThinkingPatch | undefined; + }; + const hidesThinking = (): boolean => calmHidesTranscriptChrome(); + const installed = registry[CALM_COLLAPSED_THINKING_PATCH]; + if (installed) { + installed.hidesThinking = hidesThinking; + return; + } + + const patch: CalmCollapsedThinkingPatch = { hidesThinking }; + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Pi Calm requires Pi AssistantMessageComponent"); + } + const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; + if (typeof originalUpdateContent !== "function") { + throw new Error("Pi Calm requires Pi AssistantMessageComponent.updateContent"); + } + + AssistantMessageComponent.prototype.updateContent = function ( + message: AssistantMessage, + ): void { + const state = this as unknown as AssistantMessagePresentationState; + const hideThinking = + state.hiddenThinkingLabel === "" && + state.hideThinkingBlock && + patch.hidesThinking(); + const presentationMessage = hideThinking + ? { + ...message, + content: message.content.filter((block) => block.type !== "thinking"), + } + : message; + + originalUpdateContent.call(this, presentationMessage); + if (presentationMessage !== message) state.lastMessage = message; + }; + + registry[CALM_COLLAPSED_THINKING_PATCH] = patch; +} diff --git a/home/.pi/agent/extensions/calm/lib/preference.ts b/home/.pi/agent/extensions/calm/lib/preference.ts new file mode 100644 index 0000000..153d2c8 --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/preference.ts @@ -0,0 +1,77 @@ +// Pi Calm - persisted on/off preference. +// +// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. +// +// The preference lives in a plain local state file named "calm" directly under +// Pi's agent directory (~/.pi/agent by default, PI_CODING_AGENT_DIR when set). +// That directory is Pi runtime territory: this repository never tracks the +// state file and Home Manager never manages it. The file contains exactly +// "on\n" or "off\n"; anything else, including a missing or unreadable file, +// means off. + +import { randomUUID } from "node:crypto"; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; + +export const CALM_PREFERENCE_FILE_NAME = "calm"; + +/** + * Resolve Pi's agent directory through Pi's exported getAgentDir(), which + * honors PI_CODING_AGENT_DIR and tilde expansion. If a future Pi stops + * exporting it, fall back to the documented environment variable and default + * path instead of failing. + */ +export function calmAgentDir(): string { + if (typeof PiCodingAgent.getAgentDir === "function") return PiCodingAgent.getAgentDir(); + const envDir = process.env.PI_CODING_AGENT_DIR?.trim(); + if (envDir) return envDir; + return join(homedir(), ".pi", "agent"); +} + +export function calmPreferencePath(): string { + return join(calmAgentDir(), CALM_PREFERENCE_FILE_NAME); +} + +/** Load the persisted preference. Calm is off by default and on any read error. */ +export function loadCalmPreference(): boolean { + try { + return readFileSync(calmPreferencePath(), "utf8").trim() === "on"; + } catch { + return false; + } +} + +/** + * Persist the preference atomically (unique temp file plus rename) so a + * crashed write never leaves a truncated state file. A failure throws a clear + * error naming the path so /calm can surface it instead of silently applying + * a toggle that would not survive a restart. + */ +export function persistCalmPreference(active: boolean): void { + const path = calmPreferencePath(); + try { + mkdirSync(dirname(path), { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + writeFileSync(temporaryPath, active ? "on\n" : "off\n", { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporaryPath, path); + } finally { + rmSync(temporaryPath, { force: true }); + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Pi Calm could not persist its preference to ${path}: ${reason}`); + } +} diff --git a/home/.pi/agent/extensions/calm/lib/visibility.ts b/home/.pi/agent/extensions/calm/lib/visibility.ts new file mode 100644 index 0000000..3b13a4e --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/visibility.ts @@ -0,0 +1,39 @@ +// Pi Calm - shared presentation state for the standalone Calm extension. +// +// Adapted from the Firstmate project's Calm implementation. +// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory. +// +// This module owns only the in-memory presentation flags. Presentation filtering +// must never delete or alter semantic, session, or export data, so the export +// path forces stock rendering for the duration of an /export or /share command. + +let active = false; +let stockExportRendering = false; + +/** True while Calm presentation filtering is enabled. */ +export function calmPresentationIsActive(): boolean { + return active; +} + +export function setCalmPresentation(next: boolean): void { + active = next; +} + +/** True while an /export or /share render is in flight and stock output is required. */ +export function calmStockExportRenderingIsActive(): boolean { + return stockExportRendering; +} + +export function setCalmStockExportRendering(next: boolean): void { + stockExportRendering = next; +} + +/** + * True while Calm should hide the supported transcript chrome: collapsed + * thinking labels and the known Pi built-in tool call/result shells. Genuine + * user prompts, assistant text, custom tools, and every other transcript row + * class are never filtered by this flag. + */ +export function calmHidesTranscriptChrome(): boolean { + return active && !stockExportRendering; +} diff --git a/home/.pi/agent/extensions/calm/lib/working-ship.ts b/home/.pi/agent/extensions/calm/lib/working-ship.ts new file mode 100644 index 0000000..57460a6 --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/working-ship.ts @@ -0,0 +1,247 @@ +// 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(); + }, + }; +} diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100755 index 0000000..d436ade --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# tests/lib.sh - shared primitives for dotfiles behavior tests. +# +# Source this from a test file: +# # shellcheck source=tests/lib.sh +# . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# +# ROOT is exported as the repository root (this file lives in tests/). + +if [ -n "${DOTFILES_TEST_LIB_SOURCED:-}" ]; then + return 0 +fi +DOTFILES_TEST_LIB_SOURCED=1 + +# shellcheck disable=SC2034 +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +# --- self-cleaning temp root ------------------------------------------------- + +DOTFILES_TEST_CLEANUP_DIRS=() + +dotfiles_test_cleanup() { + local d + for d in "${DOTFILES_TEST_CLEANUP_DIRS[@]:-}"; do + [ -n "$d" ] && rm -rf "$d" + done +} + +dotfiles_test_tmproot() { + local prefix=${1:-dotfiles-test} root + root=$(mktemp -d "${TMPDIR:-/tmp}/${prefix}.XXXXXX") + if [ "${#DOTFILES_TEST_CLEANUP_DIRS[@]}" -eq 0 ]; then + trap dotfiles_test_cleanup EXIT + fi + DOTFILES_TEST_CLEANUP_DIRS+=("$root") + printf '%s\n' "$root" +} + +# --- assertions --------------------------------------------------------------- + +assert_contains() { + local haystack=$1 needle=$2 message=$3 + case "$haystack" in + *"$needle"*) : ;; + *) fail "$message" ;; + esac +} + +assert_not_contains() { + local haystack=$1 needle=$2 message=$3 + case "$haystack" in + *"$needle"*) fail "$message" ;; + *) : ;; + esac +} + +# --- deterministic git fixtures ------------------------------------------------ + +dotfiles_git_init_commit() { + local dir=$1 + mkdir -p "$dir" + git -C "$dir" init -q + printf '# %s\n' "$(basename "$dir")" > "$dir/README.md" + git -C "$dir" add README.md + git -C "$dir" -c user.name=dotfiles-test -c user.email=dotfiles-test@example.invalid \ + commit -qm "fixture" +} diff --git a/tests/pi-calm.test.sh b/tests/pi-calm.test.sh new file mode 100755 index 0000000..981ff34 --- /dev/null +++ b/tests/pi-calm.test.sh @@ -0,0 +1,696 @@ +#!/usr/bin/env bash +# Deterministic rendering, lifecycle, persistence, and interactive TUI checks +# for the standalone Pi Calm extension (home/.pi/agent/extensions/calm). +# +# Adapted from the Firstmate project's Calm test suite. +# Copyright (c) 2026 Kun Chen. MIT License - see home/.pi/agent/extensions/calm/LICENSE. +# +# Coverage: +# - zero coupling: no forbidden identifiers anywhere in the shipped source, +# tests, or docs, and the runtime state file is never tracked or managed; +# - static wiring: Home Manager auto-load, TypeScript typecheck, JS syntax; +# - preference: off by default, persisted toggle, malformed/unwritable state; +# - filtering: the seven built-in tool shells hide gaplessly while custom tools +# and unsupported transcript classes stay visible, /export and /share render +# stock HTML, session data is untouched; +# - collapsed thinking: gapless hiding, expansion restore, isolated adapter +# degradation with one clear diagnostic; +# - working ship: geometry, cadence, colors, resize, narrow fallback, +# freeze/resume, timer disposal, extension lifecycle; +# - real Pi 0.82 TUI proofs in tmux without credentials or provider calls. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(dotfiles_test_tmproot pi-calm) +CALM_DIR="$ROOT/home/.pi/agent/extensions/calm" +PI_PACKAGE_DIR=${PI_CALM_TEST_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"} +TMUX_SOCKET="pi-calm-test-$$" +TMUX_SESSION="pi-calm-e2e" + +cleanup() { + if command -v tmux >/dev/null 2>&1; then + tmux -L "$TMUX_SOCKET" kill-server 2>/dev/null || true + fi + if [ "${KEEP_TMP:-}" = 1 ]; then + printf 'kept disposable Pi Calm evidence: %s\n' "$TMP_ROOT" >&2 + return + fi + dotfiles_test_cleanup +} +trap cleanup EXIT + +wait_for_text() { + local file=$1 text=$2 i=0 + while [ "$i" -lt 120 ]; do + # Include recent scrollback: expanding a long restored transcript can move + # the asserted tool output above the current viewport while the footer and + # editor remain visible. + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$file" 2>/dev/null || true + grep -Fq "$text" "$file" 2>/dev/null && return 0 + sleep 0.05 + i=$((i + 1)) + done + return 1 +} + +find_chrome() { + local candidate + if [ -n "${PI_CALM_TEST_CHROME_BIN:-}" ] && [ -x "$PI_CALM_TEST_CHROME_BIN" ]; then + printf '%s\n' "$PI_CALM_TEST_CHROME_BIN" + return 0 + fi + for candidate in \ + google-chrome \ + google-chrome-stable \ + chromium \ + chromium-browser \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + return 1 +} + +# Copy the shipped extension into a fixture layout with resolvable node_modules. +# Echoes the fixture root. Requires $1 = fixture directory. +build_node_fixture() { + local fixture=$1 + mkdir -p "$fixture/calm" "$fixture/node_modules/@earendil-works" + cp -R "$CALM_DIR/index.ts" "$CALM_DIR/lib" "$fixture/calm/" + ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/package.json" +} + +have_pi_package() { + [ -f "$PI_PACKAGE_DIR/package.json" ] +} + +test_zero_coupling_and_state_file() { + local source_files license_hits file separator + source_files=$(find "$CALM_DIR" -type f | sort) + [ -n "$source_files" ] || fail "Pi Calm extension files are missing" + # U+2063 (invisible separator) without embedding the literal in this file. + separator=$(printf '\xe2\x81\xa3') + # Forbidden patterns are concatenated so this checker never matches itself. + local pat_fm_home="FM_""HOME" pat_fm_root="FM_""ROOT" pat_config="config/""calm" + local pat_watch="fm_""watch_arm_pi" pat_op="FIRSTMATE""_OP" pat_dash="fm-""calm" + + # The operational marker and upstream runtime surfaces must not exist anywhere. + for file in $source_files "$ROOT/tests/pi-calm.test.sh" "$ROOT/tests/lib.sh" "$ROOT/README.md" "$ROOT/home.nix"; do + + assert_not_contains "$(cat "$file")" "$pat_fm_home" "$file mentions $pat_fm_home" + assert_not_contains "$(cat "$file")" "$pat_fm_root" "$file mentions $pat_fm_root" + assert_not_contains "$(cat "$file")" "$pat_config" "$file mentions $pat_config" + assert_not_contains "$(cat "$file")" "$pat_watch" "$file mentions $pat_watch" + assert_not_contains "$(cat "$file")" "$pat_op" "$file mentions $pat_op" + assert_not_contains "$(cat "$file")" "$pat_dash" "$file mentions $pat_dash" + assert_not_contains "$(cat "$file")" "$separator" "$file contains the operational separator" + done + # The upstream project name may appear only in a license attribution. + local attribution_name="First""mate" + license_hits=$(grep -rni "$attribution_name" "$CALM_DIR" "$ROOT/README.md" "$ROOT/home.nix" 2>/dev/null | grep -v "Adapted from" || true) + [ -z "$license_hits" ] || fail "unexpected upstream references outside license attribution: $license_hits" + grep -q "MIT License" "$CALM_DIR/LICENSE" || fail "calm LICENSE lost the MIT permission text" + grep -q "Copyright (c) 2026 Kun Chen" "$CALM_DIR/LICENSE" || fail "calm LICENSE lost the copyright notice" + for file in "$CALM_DIR/index.ts" "$CALM_DIR/lib/visibility.ts" "$CALM_DIR/lib/preference.ts" "$CALM_DIR/lib/collapsed-thinking.ts" "$CALM_DIR/lib/working-ship.ts"; do + grep -q "Copyright (c) 2026 Kun Chen" "$file" || fail "$file lost its copyright attribution header" + done + + # The runtime preference file must never be tracked or Home Manager managed. + if git -C "$ROOT" ls-files --error-unmatch home/.pi/agent/calm >/dev/null 2>&1; then + fail "the Calm state file is tracked in the repository" + fi + assert_not_contains "$(cat "$ROOT/home.nix")" '.pi/agent/calm' "home.nix manages the Calm state file" + grep -q '^/home/.pi/agent/calm$' "$ROOT/.gitignore" \ + || fail ".gitignore does not guard /home/.pi/agent/calm" + + # The shipped tree never references upstream paths or identifiers in code. + assert_not_contains "$(cat "$CALM_DIR/index.ts")" "pi.events" "index.ts emits on a shared event bus" + assert_not_contains "$(cat "$CALM_DIR/index.ts")" "registerEntryRenderer" "index.ts registers a synthetic entry renderer" + assert_not_contains "$(cat "$CALM_DIR/index.ts")" "InteractiveMode" "index.ts patches user-row layout" + + pass "zero coupling: no forbidden identifiers, attribution limited to license headers, state file untracked and unmanaged" +} + +test_static_typescript_and_repo_wiring() { + # Home Manager links the extensions directory as a whole, so the calm + # subdirectory auto-loads without any new declaration. + grep -q 'home.file.".pi/agent/extensions".source =' "$ROOT/home.nix" \ + || fail "home.nix no longer links ~/.pi/agent/extensions as a directory" + grep -q "mkOutOfStoreSymlink \"\${dotfiles}/home/.pi/agent/extensions\"" "$ROOT/home.nix" \ + || fail "home.nix changed the Pi extensions link target" + [ -f "$CALM_DIR/index.ts" ] || fail "calm extension entry point missing" + [ -f "$CALM_DIR/LICENSE" ] || fail "calm license file missing" + + # JavaScript syntax of the pre-existing extension stays valid. + node --check "$ROOT/home/.pi/agent/extensions/terminal-status-title.js" \ + || fail "terminal-status-title.js has a JavaScript syntax error" + + if ! have_pi_package; then + echo "skip: installed @earendil-works/pi-coding-agent package not found for TypeScript check" + elif ! command -v tsc >/dev/null 2>&1; then + echo "skip: tsc not found for TypeScript check" + else + local fixture="$TMP_ROOT/typecheck" + build_node_fixture "$fixture" + mkdir -p "$fixture/node_modules/@types" + ln -s "$PI_PACKAGE_DIR/node_modules/@types/node" "$fixture/node_modules/@types/node" + cat >"$fixture/tsconfig.json" <<'JSON' +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["esnext"], + "types": ["node"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["calm/**/*.ts"] +} +JSON + (cd "$fixture" && tsc -p tsconfig.json) \ + || fail "Pi Calm extension does not typecheck under strict TypeScript" + fi + + pass "static wiring: Home Manager auto-load intact, TypeScript typechecks, existing JS extension parses" +} + +test_preference_and_command() { + local fixture out status + if ! command -v node >/dev/null 2>&1; then + echo "skip: node not found for Pi Calm preference test" + return 0 + fi + if ! have_pi_package; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + + fixture="$TMP_ROOT/preference" + mkdir -p "$fixture/agent" "$fixture/readonly-agent" + build_node_fixture "$fixture" + + out=$(cd "$fixture" && \ + EXT="$fixture/calm/index.ts" \ + AGENT_DIR="$fixture/agent" \ + READONLY_AGENT_DIR="$fixture/readonly-agent" \ + node --input-type=module 2>&1 <<'JS' +import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +process.env.PI_CODING_AGENT_DIR = process.env.AGENT_DIR; +const extension = await import(`${pathToFileURL(process.env.EXT).href}?pref=${Date.now()}`); +const preference = await import(`${pathToFileURL(`${process.cwd()}/calm/lib/preference.ts`).href}?pref=${Date.now()}`); +const visibility = await import(pathToFileURL(`${process.cwd()}/calm/lib/visibility.ts`).href); + +const check = (condition, message) => { + if (!condition) throw new Error(message); +}; + +check( + preference.calmPreferencePath() === join(process.env.AGENT_DIR, "calm"), + `preference path did not follow PI_CODING_AGENT_DIR: ${preference.calmPreferencePath()}`, +); + +let calmCommand; +const handlers = new Map(); +const pi = { + on(event, handler) { + const existing = handlers.get(event) ?? []; + existing.push(handler); + handlers.set(event, existing); + }, + registerCommand(name, command) { + if (name === "calm") calmCommand = command; + }, + registerTool() {}, +}; +extension.default(pi); +check(!!calmCommand, "Calm command was not registered"); +for (const event of ["session_start", "agent_start", "agent_settled", "session_shutdown"]) { + check(handlers.has(event), `Calm did not register a ${event} handler`); +} +check(!handlers.has("input"), "Calm registered a semantic input interceptor"); +check(!handlers.has("tool_call"), "Calm registered a tool-call interceptor"); +check(!handlers.has("tool_result"), "Calm registered a tool-result interceptor"); +check(!handlers.has("context"), "Calm registered a model-context interceptor"); + +let editorText = ""; +let expanded = false; +let hiddenThinkingLabel = "unset"; +const uiCalls = []; +const ctx = { + ui: { + getEditorText: () => editorText, + getToolsExpanded: () => expanded, + setToolsExpanded(value) { + uiCalls.push(["setToolsExpanded", value]); + expanded = value; + }, + onTerminalInput: () => () => {}, + setHiddenThinkingLabel(value) { + hiddenThinkingLabel = value; + }, + setWidget() {}, + setWorkingVisible() {}, + }, +}; +const fire = async (event, payload = {}) => { + for (const handler of handlers.get(event) ?? []) await handler(payload, ctx); +}; + +// Off by default: no state file means inactive and nothing is written. +await fire("session_start", { reason: "startup" }); +check(!visibility.calmPresentationIsActive(), "Calm was not off by default"); +check(!existsSync(`${process.env.AGENT_DIR}/calm`), "session start created the state file without a toggle"); +check(hiddenThinkingLabel === undefined, "default session did not keep the stock thinking label"); + +// Malformed state content also means off, and is left untouched until a toggle. +writeFileSync(`${process.env.AGENT_DIR}/calm`, "sometimes\n"); +await fire("session_start", { reason: "reload" }); +check(!visibility.calmPresentationIsActive(), "malformed state did not fall back to off"); +check(readFileSync(`${process.env.AGENT_DIR}/calm`, "utf8") === "sometimes\n", "loading a malformed state rewrote it"); + +// Toggle on: persists "on\n" with owner-only permissions and hides the label. +expanded = true; +await calmCommand.handler("", ctx); +check(visibility.calmPresentationIsActive(), "toggle did not activate Calm"); +check(readFileSync(`${process.env.AGENT_DIR}/calm`, "utf8") === "on\n", "toggle did not persist on"); +check((statSync(`${process.env.AGENT_DIR}/calm`).mode & 0o777) === 0o600, "state file is not owner-only"); +check(hiddenThinkingLabel === "", "Calm did not hide the collapsed thinking label"); +check(expanded === true, "toggle changed the Ctrl+O tools-expanded state"); + +// The choice survives new sessions and replacement reasons. +for (const reason of ["startup", "new", "resume", "fork", "reload"]) { + await fire("session_start", { reason }); + check(visibility.calmPresentationIsActive(), `${reason} session lost the persisted on choice`); + check(hiddenThinkingLabel === "", `${reason} session lost the hidden thinking label`); +} + +// Toggle off again: persists "off\n" and restores the stock label. +await calmCommand.handler("", ctx); +check(!visibility.calmPresentationIsActive(), "second toggle did not deactivate Calm"); +check(readFileSync(`${process.env.AGENT_DIR}/calm`, "utf8") === "off\n", "second toggle did not persist off"); +check(hiddenThinkingLabel === undefined, "turning Calm off did not restore the stock thinking label"); +await fire("session_start", { reason: "startup" }); +check(!visibility.calmPresentationIsActive(), "restart did not retain the persisted off choice"); + +// An unwritable state file fails the toggle with a clear error and leaves the +// in-memory state untouched, so the failure is loud instead of silently +// reverting on the next restart. +process.env.PI_CODING_AGENT_DIR = process.env.READONLY_AGENT_DIR; +const readonlyPreferencePath = preference.calmPreferencePath(); +chmodSync(process.env.READONLY_AGENT_DIR, 0o555); +let thrown = ""; +try { + await calmCommand.handler("", ctx); +} catch (error) { + thrown = error instanceof Error ? error.message : String(error); +} +chmodSync(process.env.READONLY_AGENT_DIR, 0o755); +check(thrown.length > 0, "an unwritable state file did not fail the toggle"); +check( + thrown.includes(readonlyPreferencePath), + `toggle error did not name the state path: ${thrown}`, +); +check(!visibility.calmPresentationIsActive(), "a failed persist changed the in-memory state"); +check(!existsSync(readonlyPreferencePath), "a failed persist left a state file"); +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm preference and command contract failed: $out" + [ -z "$out" ] || fail "Pi Calm preference test printed output: $out" + pass "Pi Calm registers /calm with no semantic interceptors, defaults to off, persists the toggle with atomic owner-only writes, treats malformed state as off, and fails loudly without state changes on an unwritable preference file" +} + +test_rendering_adapters_and_tool_shells() { + local fixture out status + if ! command -v node >/dev/null 2>&1 || ! have_pi_package; then + echo "skip: node or installed Pi package not found for rendering contract" + return 0 + fi + + fixture="$TMP_ROOT/rendering" + mkdir -p "$fixture/agent" + build_node_fixture "$fixture" + out=$(cd "$fixture" && EXT="$fixture/calm/index.ts" AGENT_DIR="$fixture/agent" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +process.env.PI_CODING_AGENT_DIR = process.env.AGENT_DIR; +const root = process.env.PI_PACKAGE_DIR; +const [{ AssistantMessageComponent, ToolExecutionComponent }, { initTheme }, { Text }] = await Promise.all([ + import("@earendil-works/pi-coding-agent"), + import(pathToFileURL(`${root}/dist/modes/interactive/theme/theme.js`).href), + import("@earendil-works/pi-tui"), +]); +initTheme("dark"); +const extension = await import(pathToFileURL(process.env.EXT).href); +const visibility = await import(pathToFileURL(`${process.cwd()}/calm/lib/visibility.ts`).href); +const check = (condition, message) => { if (!condition) throw new Error(message); }; + +const tools = []; +const handlers = new Map(); +let command; +const pi = { + on(event, handler) { const list = handlers.get(event) ?? []; list.push(handler); handlers.set(event, list); }, + registerCommand(name, value) { if (name === "calm") command = value; }, + registerTool(tool) { tools.push(tool); }, +}; +extension.default(pi); +const expected = ["read", "bash", "edit", "write", "grep", "find", "ls"]; +check(JSON.stringify(tools.map((tool) => tool.name)) === JSON.stringify(expected), "Calm did not wrap exactly Pi's known built-ins"); +const ui = { + getEditorText: () => "", + getToolsExpanded: () => false, + onTerminalInput: () => () => {}, + setHiddenThinkingLabel() {}, + setToolsExpanded() {}, + setWidget() {}, + setWorkingVisible() {}, +}; +const ctx = { ui }; +for (const handler of handlers.get("session_start")) await handler({ reason: "startup" }, ctx); + +const assistant = new AssistantMessageComponent({ + role: "assistant", api: "test", provider: "test", model: "test", timestamp: 1, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + content: [{ type: "thinking", thinking: "PRIVATE_REASONING" }, { type: "text", text: "VISIBLE_ASSISTANT_TEXT" }], +}, true, undefined, "Thinking..."); +check(assistant.render(100).join("\\n").includes("Thinking..."), "Calm changed collapsed thinking while off"); +await command.handler("", ctx); +check(visibility.calmPresentationIsActive(), "Calm did not activate for rendering coverage"); +assistant.setHiddenThinkingLabel(""); +const hiddenAssistant = assistant.render(100).join("\\n"); +check(!hiddenAssistant.includes("PRIVATE_REASONING") && hiddenAssistant.includes("VISIBLE_ASSISTANT_TEXT"), "collapsed thinking was not removed gaplessly while assistant text stayed visible"); +assistant.setHideThinkingBlock(false); +check(assistant.render(100).join("\\n").includes("PRIVATE_REASONING"), "expanding thinking did not restore its original content"); +assistant.setHideThinkingBlock(true); + +const renderUi = { requestRender() {} }; +for (const tool of tools) { + const row = new ToolExecutionComponent(tool.name, `call-${tool.name}`, {}, { showImages: false }, tool, renderUi, process.cwd()); + row.markExecutionStarted(); + row.setArgsComplete(); + row.updateResult({ content: [{ type: "text", text: `RESULT_${tool.name}` }], details: {}, isError: false }); + check(row.render(100).length === 0, `${tool.name} left a supported call/result shell visible`); + visibility.setCalmStockExportRendering(true); + row.setExpanded(true); + check(row.render(100).length > 0, `${tool.name} was missing from stock export/share rendering`); + visibility.setCalmStockExportRendering(false); +} +const custom = { + name: "custom_boundary", label: "Custom boundary", description: "test", parameters: { type: "object", properties: {} }, renderShell: "self", + async execute() { return { content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {} }; }, + renderCall() { return new Text("CUSTOM_CALL", 0, 0); }, + renderResult() { return new Text("CUSTOM_RESULT", 0, 0); }, +}; +const customRow = new ToolExecutionComponent("custom_boundary", "custom-call", {}, { showImages: false }, custom, renderUi, process.cwd()); +customRow.markExecutionStarted(); customRow.setArgsComplete(); customRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false }); +check(customRow.render(100).join("\\n").includes("CUSTOM_CALL"), "Calm hid a generic custom tool"); +const session = [{ type: "message", message: { role: "user", content: "REAL_USER_PROMPT" } }, { type: "message", message: { role: "assistant", content: "VISIBLE_ASSISTANT_TEXT" } }]; +const before = JSON.stringify(session); +check(JSON.stringify(session) === before, "presentation test changed session data"); +await command.handler("", ctx); +assistant.setHiddenThinkingLabel("Thinking..."); +check(!visibility.calmPresentationIsActive(), "Calm did not deactivate"); +check(assistant.render(100).join("\\n").includes("Thinking..."), "Calm off did not restore collapsed thinking label"); +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm rendering contract failed: $out" + [ -z "$out" ] || fail "Pi Calm rendering contract printed output: $out" + pass "Pi Calm hides only collapsed thinking and known built-in shells, retains custom content, and restores stock export/share rendering" +} + +test_working_ship_and_lifecycle() { + local fixture out status + if ! command -v node >/dev/null 2>&1 || ! have_pi_package; then + echo "skip: node or installed Pi package not found for working-ship contract" + return 0 + fi + + fixture="$TMP_ROOT/working-ship" + mkdir -p "$fixture/agent" + build_node_fixture "$fixture" + out=$(cd "$fixture" && EXT="$fixture/calm/index.ts" AGENT_DIR="$fixture/agent" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +process.env.PI_CODING_AGENT_DIR = process.env.AGENT_DIR; +const root = process.env.PI_PACKAGE_DIR; +const [{ visibleWidth }, { initTheme }] = await Promise.all([ + import("@earendil-works/pi-tui"), + import(pathToFileURL(`${root}/dist/modes/interactive/theme/theme.js`).href), +]); +initTheme("dark"); +const ship = await import(pathToFileURL(`${process.cwd()}/calm/lib/working-ship.ts`).href); +const extension = await import(pathToFileURL(process.env.EXT).href); +const check = (condition, message) => { if (!condition) throw new Error(message); }; +const strip = (line) => line.replace(/\x1b\[[0-9;]*m/g, ""); +const { createCalmWorkingShipAnimation, createCalmWorkingShipWidget, CALM_WORKING_SHIP_TICK_MS, CALM_WORKING_SHIP_TICKS_PER_MOVE, CALM_WORKING_SHIP_WIDGET_KEY } = ship; +check(CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE === 880, "boat cadence changed from 880ms per column"); +const animation = createCalmWorkingShipAnimation(); +for (let width = 1; width <= 100; width += 1) { + for (let frame = 0; frame < 12; frame += 1) { + const lines = animation.render(width); + check(lines.length === (width >= 4 ? 2 : 1), `width ${width} produced wrong row count`); + for (const line of lines) check(visibleWidth(line) <= width, `width ${width} wraps`); + check(visibleWidth(lines.at(-1)) === width, `width ${width} has incomplete water`); + animation.tick(); + } +} +animation.reset(); animation.render(40); +const origin = animation.position(); +for (let tick = 0; tick < CALM_WORKING_SHIP_TICKS_PER_MOVE - 1; tick += 1) animation.tick(); +check(animation.position() === origin && animation.waterPhase() !== 0, "water did not ripple independently before boat movement"); +animation.tick(); check(animation.position() === origin + 1, "boat did not move on its slower cadence"); +while (animation.position() < 36) animation.tick(); +const shrunk = animation.render(12); +check(animation.position() === 8 && animation.direction() === -1 && visibleWidth(shrunk[1]) === 12, "wide-to-narrow resize did not clamp and reverse safely"); +const frozen = { position: animation.position(), direction: animation.direction(), phase: animation.waterPhase() }; +const tui = { requests: 0, requestRender() { this.requests += 1; } }; +const widget = createCalmWorkingShipWidget(tui, animation); +widget.render(12); widget.dispose(); +check(animation.position() === frozen.position && animation.direction() === frozen.direction && animation.waterPhase() === frozen.phase, "disposing a widget advanced frozen animation state"); +const resumed = createCalmWorkingShipWidget(tui, animation); +resumed.render(12); +check(animation.position() === frozen.position && animation.direction() === frozen.direction, "resuming changed frozen position or heading"); +resumed.dispose(); + +const tools = []; const handlers = new Map(); let command; +const pi = { on(event, handler) { const values = handlers.get(event) ?? []; values.push(handler); handlers.set(event, values); }, registerCommand(name, value) { if (name === "calm") command = value; }, registerTool(tool) { tools.push(tool); } }; +extension.default(pi); +const widgets = new Map(); const changes = []; let expanded = true; +const ui = { + getEditorText: () => "", getToolsExpanded: () => expanded, onTerminalInput: () => () => {}, setHiddenThinkingLabel() {}, + setToolsExpanded(value) { expanded = value; }, + setWorkingVisible(value) { changes.push(["working", value]); }, + setWidget(key, value) { changes.push(["widget", key, value === undefined ? "clear" : "set"]); if (value === undefined) { widgets.get(key)?.dispose?.(); widgets.delete(key); } else widgets.set(key, value(tui)); }, +}; +const ctx = { ui }; +const fire = async (event) => { for (const handler of handlers.get(event) ?? []) await handler({}, ctx); }; +await fire("session_start"); changes.length = 0; +await fire("agent_start"); await fire("agent_settled"); +check(changes.length === 0, "Calm off changed Pi's stock working row"); +await command.handler("", ctx); check(expanded, "Calm toggle changed Ctrl+O expansion state"); +await fire("agent_start"); +check(changes.some((change) => change[0] === "widget" && change[1] === CALM_WORKING_SHIP_WIDGET_KEY && change[2] === "set"), "Calm did not install its working widget"); +check(changes.some((change) => change[0] === "working" && change[1] === false), "Calm did not hide stock working row during a run"); +changes.length = 0; await fire("session_shutdown"); +check(changes.some((change) => change[0] === "widget" && change[2] === "clear"), "extension shutdown did not clear working widget"); +check(changes.some((change) => change[0] === "working" && change[1] === true), "extension shutdown did not restore stock working row"); +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm working ship and lifecycle contract failed: $out" + [ -z "$out" ] || fail "Pi Calm working ship test printed output: $out" + pass "Pi Calm working ship has fixed geometry and cadence, survives resize and freeze/resume, and restores stock lifecycle behavior" +} + +test_collapsed_thinking_degradation() { + local fixture out status + if ! command -v node >/dev/null 2>&1 || ! have_pi_package; then + echo "skip: node or installed Pi package not found for adapter degradation" + return 0 + fi + + fixture="$TMP_ROOT/degradation" + build_node_fixture "$fixture" + out=$(cd "$fixture" && EXT="$fixture/calm/index.ts" node --input-type=module 2>&1 <<'JS' +import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import { pathToFileURL } from "node:url"; +const original = AssistantMessageComponent.prototype.updateContent; +const diagnostics = []; +const previousError = console.error; +console.error = (...args) => diagnostics.push(args.join(" ")); +delete AssistantMessageComponent.prototype.updateContent; +let command; +const handlers = new Map(); +try { + const extension = await import(`${pathToFileURL(process.env.EXT).href}?degraded=${Date.now()}`); + extension.default({ + on(event, handler) { handlers.set(event, handler); }, + registerCommand(name, value) { if (name === "calm") command = value; }, + registerTool() {}, + }); +} finally { + AssistantMessageComponent.prototype.updateContent = original; + console.error = previousError; +} +if (!command || !handlers.has("session_start")) throw new Error("a missing collapsed-thinking seam stopped the rest of Calm from registering"); +if (!diagnostics.some((line) => line.includes("collapsed-thinking") && /unavailable.*skipping/i.test(line))) { + throw new Error(`missing clear collapsed-thinking degradation diagnostic: ${JSON.stringify(diagnostics)}`); +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm collapsed-thinking degradation contract failed: $out" + [ -z "$out" ] || fail "Pi Calm collapsed-thinking degradation test printed output: $out" + pass "Pi Calm degrades only its exported-class collapsed-thinking adapter with a clear diagnostic" +} + +test_real_pi_tui_smoke() { + local fixture agent project socket pane i + if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then + echo "skip: pi or tmux not found for isolated real TUI smoke" + return 0 + fi + [ "$(pi --version 2>/dev/null || true)" = "0.82.0" ] \ + || fail "real Pi smoke requires the installed Pi 0.82.0 proof target" + + fixture="$TMP_ROOT/tui-smoke" + agent="$fixture/agent" + project="$fixture/project" + socket="pi-calm-smoke-$$" + mkdir -p "$agent/extensions" "$project" "$fixture/captures" "$fixture/sessions" + capture_tui() { + local label=$1 + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" >"$fixture/captures/$label.current.txt" 2>/dev/null || true + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/captures/$label.scrollback.txt" 2>/dev/null || true + tmux -L "$socket" capture-pane -ep -t "$TMUX_SESSION" >"$fixture/captures/$label.current.ansi.txt" 2>/dev/null || true + tmux -L "$socket" capture-pane -ep -t "$TMUX_SESSION" -S -100 >"$fixture/captures/$label.scrollback.ansi.txt" 2>/dev/null || true + } + cp -R "$CALM_DIR" "$agent/extensions/" + cat >"$project/provider.ts" <<'TS' +import { appendFileSync } from "node:fs"; +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const mark = (event: string) => { + const file = process.env.CALM_SMOKE_MARKERS; + if (file) appendFileSync(file, `${Date.now()} ${event}\n`); +}; + +export default function (pi: ExtensionAPI): void { + pi.registerProvider("calm-smoke", { + baseUrl: "http://127.0.0.1/unused", apiKey: "test-only", api: "openai-completions", + models: [{ id: "deterministic", name: "Calm smoke", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 4096, maxTokens: 128 }], + streamSimple(model) { + mark(`streamSimple ${model.provider}/${model.id}`); + const stream = createAssistantMessageEventStream(); + const output = { role: "assistant" as const, content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop" as const, timestamp: Date.now() }; + queueMicrotask(() => { + mark("stream-start"); + stream.push({ type: "start", partial: output }); + setTimeout(() => { + const text = "CALM_SMOKE_GENUINE_ASSISTANT"; + output.content.push({ type: "text", text }); + stream.push({ type: "text_start", contentIndex: 0, partial: output }); + stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output }); + stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output }); + stream.push({ type: "done", reason: "stop", message: output }); + mark("stream-done"); + stream.end(); + }, 1400); + }); + return stream; + }, + }); + pi.registerCommand("calm-smoke", { + description: "Deterministic local Calm smoke provider.", + handler: async (_args, ctx) => { + mark("command-enter"); + const model = ctx.modelRegistry.find("calm-smoke", "deterministic"); + if (!model || !(await pi.setModel(model))) throw new Error("Calm smoke model unavailable"); + mark("model-selected"); + pi.sendUserMessage("CALM_SMOKE_GENUINE_USER"); + mark("user-message-sent"); + }, + }); +} +TS + + tmux -L "$socket" new-session -d -s "$TMUX_SESSION" -x 100 -y 30 \ + "cd '$project' && env PI_CODING_AGENT_DIR='$agent' PI_CODING_AGENT_SESSION_DIR='$fixture/sessions' PI_OFFLINE=1 CALM_SMOKE_MARKERS='$fixture/provider-markers.txt' pi --approve --no-context-files --no-skills --no-prompt-templates -e ./provider.ts" + for i in $(seq 1 120); do + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/pane" 2>/dev/null || true + grep -Fq 'provider.ts' "$fixture/pane" && break + sleep 0.05 + done + grep -Fq 'provider.ts' "$fixture/pane" || fail "real Pi smoke did not load the disposable provider" + capture_tui ready + tmux -L "$socket" send-keys -t "$TMUX_SESSION" -l '/calm' + tmux -L "$socket" send-keys -t "$TMUX_SESSION" Enter + sleep 0.2 + capture_tui after-calm + tmux -L "$socket" send-keys -t "$TMUX_SESSION" -l '/calm-smoke' + tmux -L "$socket" send-keys -t "$TMUX_SESSION" Enter + for i in $(seq 1 120); do + if [ -f "$fixture/provider-markers.txt" ] && grep -Fq 'stream-start' "$fixture/provider-markers.txt"; then + capture_tui working-wide + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/wide" 2>/dev/null || true + grep -Fq '\__/' "$fixture/wide" && break + fi + sleep 0.02 + done + grep -Fq 'stream-start' "$fixture/provider-markers.txt" || fail "real Pi smoke did not enter the provider stream" + grep -Fq '\__/' "$fixture/wide" || fail "real Pi smoke did not show Calm's wide working boat" + tmux -L "$socket" resize-window -t "$TMUX_SESSION" -x 40 -y 30 + for i in $(seq 1 120); do + capture_tui working-narrow + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/narrow" 2>/dev/null || true + grep -Fq '\__/' "$fixture/narrow" && break + grep -Fq 'stream-done' "$fixture/provider-markers.txt" 2>/dev/null && break + sleep 0.02 + done + grep -Fq '\__/' "$fixture/narrow" || fail "real Pi smoke did not reflow Calm's working boat on resize" + for i in $(seq 1 120); do + tmux -L "$socket" capture-pane -p -t "$TMUX_SESSION" -S -100 >"$fixture/pane" 2>/dev/null || true + grep -Fq 'CALM_SMOKE_GENUINE_ASSISTANT' "$fixture/pane" && break + sleep 0.05 + done + pane=$(cat "$fixture/pane") + assert_contains "$pane" 'CALM_SMOKE_GENUINE_USER' "real Pi smoke hid a genuine user prompt" + assert_contains "$pane" 'CALM_SMOKE_GENUINE_ASSISTANT' "real Pi smoke hid genuine assistant text" + [ "$(cat "$agent/calm")" = on ] || fail "real Pi smoke did not persist Calm on" + tmux -L "$socket" send-keys -t "$TMUX_SESSION" -l '/calm' + tmux -L "$socket" send-keys -t "$TMUX_SESSION" Enter + sleep 0.15 + [ "$(cat "$agent/calm")" = off ] || fail "real Pi smoke did not persist Calm off" + tmux -L "$socket" send-keys -t "$TMUX_SESSION" -l '/quit' + tmux -L "$socket" send-keys -t "$TMUX_SESSION" Enter + sleep 0.1 + tmux -L "$socket" kill-server 2>/dev/null || true + pass "isolated Pi 0.82 TUI proves auto-load, /calm persistence, resize-safe working animation, and genuine transcript text without credentials" +} + +test_zero_coupling_and_state_file +test_static_typescript_and_repo_wiring +test_preference_and_command +test_rendering_adapters_and_tool_shells +test_working_ship_and_lifecycle +test_collapsed_thinking_degradation +test_real_pi_tui_smoke From d9ae3a245485e0db480a4c728c684a0808b53d42 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 31 Jul 2026 21:30:34 -0700 Subject: [PATCH 2/5] no-mistakes(review): Preserve Pi tool semantics in Calm rendering --- home/.pi/agent/extensions/calm/index.ts | 168 +----------------- .../calm/lib/built-in-tool-shells.ts | 80 +++++++++ tests/pi-calm.test.sh | 13 +- 3 files changed, 95 insertions(+), 166 deletions(-) create mode 100644 home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts diff --git a/home/.pi/agent/extensions/calm/index.ts b/home/.pi/agent/extensions/calm/index.ts index 854fe00..cb4d231 100644 --- a/home/.pi/agent/extensions/calm/index.ts +++ b/home/.pi/agent/extensions/calm/index.ts @@ -3,41 +3,28 @@ // 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 exposes built-in ToolDefinitions, per-slot -// renderers, renderShell: "self", session_start replacement reasons, agent_start +// 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; Pi exposes no global renderer for arbitrary -// built-in or custom rows, so generic custom tools and unsupported transcript -// classes deliberately stay visible. +// 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, - type ToolDefinition, - type ToolRenderResultOptions, - createBashToolDefinition, - createEditToolDefinition, - createFindToolDefinition, - createGrepToolDefinition, - createLsToolDefinition, - createReadToolDefinition, - createWriteToolDefinition, -} from "@earendil-works/pi-coding-agent"; -import { Box, Container, getKeybindings, type Component } from "@earendil-works/pi-tui"; -import type { TSchema } from "typebox"; +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 { - calmHidesTranscriptChrome, calmPresentationIsActive, setCalmPresentation, setCalmStockExportRendering, @@ -48,32 +35,6 @@ import { createCalmWorkingShipWidget, } from "./lib/working-ship.ts"; -type DefinitionFactory = ( - cwd: string, -) => ToolDefinition; - -type RenderContext = Parameters< - NonNullable["renderCall"]> ->[2]; - -type RenderArgs = Parameters< - NonNullable["renderCall"]> ->[0]; - -type RenderTheme = Parameters< - NonNullable["renderCall"]> ->[1]; - -type RenderResult = Parameters< - NonNullable["renderResult"]> ->[0]; - -type StandardShellState = { - shell?: Box; - call?: Component; - result?: Component; -}; - // 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. @@ -88,8 +49,8 @@ function installCalmPresentationAdapter(name: string, install: () => void): void export default function (pi: ExtensionAPI) { installCalmPresentationAdapter("collapsed-thinking", installCalmCollapsedThinkingLayout); + installCalmPresentationAdapter("built-in-tool-shells", installCalmBuiltInToolShellLayout); - let exportRendering = false; 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, @@ -124,116 +85,7 @@ export default function (pi: ExtensionAPI) { } }; - function registerBuiltIn( - factory: DefinitionFactory, - ): void { - const definitions = new Map>(); - const definitionFor = (cwd: string): ToolDefinition => { - let definition = definitions.get(cwd); - if (!definition) { - definition = factory(cwd); - definitions.set(cwd, definition); - } - return definition; - }; - - const original = definitionFor(process.cwd()); - const originalRenderCall = original.renderCall; - const originalRenderResult = original.renderResult; - const originalSelfShell = original.renderShell === "self"; - const standardShells = new WeakMap(); - - if (!originalRenderCall || !originalRenderResult) { - throw new Error(`Pi Calm requires both render slots for Pi built-in tool ${original.name}`); - } - - const shellStateFor = ( - context: RenderContext, - ): StandardShellState => { - const rowState = context.state as object; - let shellState = standardShells.get(rowState); - if (!shellState) { - shellState = {}; - standardShells.set(rowState, shellState); - } - return shellState; - }; - - const refreshStandardShell = ( - state: StandardShellState, - theme: RenderTheme, - context: RenderContext, - ): Box => { - const background = context.isPartial - ? (text: string) => theme.bg("toolPendingBg", text) - : context.isError - ? (text: string) => theme.bg("toolErrorBg", text) - : (text: string) => theme.bg("toolSuccessBg", text); - const shell = state.shell ?? new Box(1, 1, background); - state.shell = shell; - shell.setBgFn(background); - shell.clear(); - if (state.call) shell.addChild(state.call); - if (state.result) shell.addChild(state.result); - return shell; - }; - - pi.registerTool({ - ...original, - renderShell: "self", - - async execute(toolCallId, params, signal, onUpdate, ctx) { - return definitionFor(ctx.cwd).execute(toolCallId, params, signal, onUpdate, ctx); - }, - - renderCall( - args: RenderArgs, - theme: RenderTheme, - context: RenderContext, - ) { - if (exportRendering) return originalRenderCall(args, theme, context); - if (calmHidesTranscriptChrome()) return new Container(); - if (originalSelfShell) return originalRenderCall(args, theme, context); - - const state = shellStateFor(context); - state.call = originalRenderCall(args, theme, { - ...context, - lastComponent: state.call, - }); - return refreshStandardShell(state, theme, context); - }, - - renderResult( - result: RenderResult, - options: ToolRenderResultOptions, - theme: RenderTheme, - context: RenderContext, - ) { - if (exportRendering) return originalRenderResult(result, options, theme, context); - if (calmHidesTranscriptChrome()) return new Container(); - if (originalSelfShell) return originalRenderResult(result, options, theme, context); - - const state = shellStateFor(context); - state.result = originalRenderResult(result, options, theme, { - ...context, - lastComponent: state.result, - }); - refreshStandardShell(state, theme, context); - return new Container(); - }, - }); - } - - registerBuiltIn(createReadToolDefinition); - registerBuiltIn(createBashToolDefinition); - registerBuiltIn(createEditToolDefinition); - registerBuiltIn(createWriteToolDefinition); - registerBuiltIn(createGrepToolDefinition); - registerBuiltIn(createFindToolDefinition); - registerBuiltIn(createLsToolDefinition); - pi.on("session_start", (_event, ctx) => { - exportRendering = false; setCalmPresentation(loadCalmPreference()); setCalmStockExportRendering(false); agentRunActive = false; @@ -258,10 +110,8 @@ export default function (pi: ExtensionAPI) { // /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. - exportRendering = true; setCalmStockExportRendering(true); setTimeout(() => { - exportRendering = false; setCalmStockExportRendering(false); const expanded = ctx.ui.getToolsExpanded(); ctx.ui.setToolsExpanded(!expanded); diff --git a/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts b/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts new file mode 100644 index 0000000..4a6b147 --- /dev/null +++ b/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts @@ -0,0 +1,80 @@ +// Pi Calm - gapless built-in tool-shell presentation adapter. +// +// 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 ToolExecutionComponent. This +// adapter changes only the final TUI row layout. Active tool definitions, +// execution, settings, SDK overrides, extension collisions, and stored results +// remain owned by Pi. Image results remain visible without their call/result +// shell, and tools outside Pi's seven known built-in names render unchanged. +import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent"; +import type { Component } from "@earendil-works/pi-tui"; +import { calmHidesTranscriptChrome } from "./visibility.ts"; + +const CALM_BUILT_IN_TOOL_NAMES = new Set([ + "read", + "bash", + "edit", + "write", + "grep", + "find", + "ls", +]); + +type ToolRowPresentationState = { + toolName: string; + builtInToolDefinition?: object; + imageComponents: Component[]; + imageSpacers: Component[]; +}; + +type CalmBuiltInToolShellPatch = { + hidesShell: () => boolean; +}; + +const CALM_BUILT_IN_TOOL_SHELL_PATCH = Symbol.for( + "pi-calm:built-in-tool-shell-layout:pi-0.82.0", +); + +export function installCalmBuiltInToolShellLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmBuiltInToolShellPatch | undefined; + }; + const hidesShell = (): boolean => calmHidesTranscriptChrome(); + const installed = registry[CALM_BUILT_IN_TOOL_SHELL_PATCH]; + if (installed) { + installed.hidesShell = hidesShell; + return; + } + + if (typeof ToolExecutionComponent !== "function") { + throw new Error("Pi Calm requires Pi ToolExecutionComponent"); + } + const originalRender = ToolExecutionComponent.prototype.render; + if (typeof originalRender !== "function") { + throw new Error("Pi Calm requires Pi ToolExecutionComponent.render"); + } + + const patch: CalmBuiltInToolShellPatch = { hidesShell }; + ToolExecutionComponent.prototype.render = function (width: number): string[] { + const state = this as unknown as ToolRowPresentationState; + const isKnownBuiltIn = + CALM_BUILT_IN_TOOL_NAMES.has(state.toolName) && + state.builtInToolDefinition !== undefined; + if (!isKnownBuiltIn || !patch.hidesShell()) { + return originalRender.call(this, width); + } + + const lines: string[] = []; + for (let index = 0; index < state.imageComponents.length; index += 1) { + const spacer = state.imageSpacers[index]; + if (spacer) lines.push(...spacer.render(width)); + const image = state.imageComponents[index]; + if (image) lines.push(...image.render(width)); + } + return lines; + }; + + registry[CALM_BUILT_IN_TOOL_SHELL_PATCH] = patch; +} diff --git a/tests/pi-calm.test.sh b/tests/pi-calm.test.sh index 981ff34..dddb10e 100755 --- a/tests/pi-calm.test.sh +++ b/tests/pi-calm.test.sh @@ -370,8 +370,7 @@ const pi = { registerTool(tool) { tools.push(tool); }, }; extension.default(pi); -const expected = ["read", "bash", "edit", "write", "grep", "find", "ls"]; -check(JSON.stringify(tools.map((tool) => tool.name)) === JSON.stringify(expected), "Calm did not wrap exactly Pi's known built-ins"); +check(tools.length === 0, "Calm replaced active tool definitions"); const ui = { getEditorText: () => "", getToolsExpanded: () => false, @@ -401,15 +400,15 @@ check(assistant.render(100).join("\\n").includes("PRIVATE_REASONING"), "expandin assistant.setHideThinkingBlock(true); const renderUi = { requestRender() {} }; -for (const tool of tools) { - const row = new ToolExecutionComponent(tool.name, `call-${tool.name}`, {}, { showImages: false }, tool, renderUi, process.cwd()); +for (const toolName of ["read", "bash", "edit", "write", "grep", "find", "ls"]) { + const row = new ToolExecutionComponent(toolName, `call-${toolName}`, {}, { showImages: false }, undefined, renderUi, process.cwd()); row.markExecutionStarted(); row.setArgsComplete(); - row.updateResult({ content: [{ type: "text", text: `RESULT_${tool.name}` }], details: {}, isError: false }); - check(row.render(100).length === 0, `${tool.name} left a supported call/result shell visible`); + row.updateResult({ content: [{ type: "text", text: `RESULT_${toolName}` }], details: {}, isError: false }); + check(row.render(100).length === 0, `${toolName} left a supported call/result shell visible`); visibility.setCalmStockExportRendering(true); row.setExpanded(true); - check(row.render(100).length > 0, `${tool.name} was missing from stock export/share rendering`); + check(row.render(100).length > 0, `${toolName} was missing from stock export/share rendering`); visibility.setCalmStockExportRendering(false); } const custom = { From c57ccadf426d72248ce93673aee6eb624b2f9d8a Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 31 Jul 2026 22:20:16 -0700 Subject: [PATCH 3/5] no-mistakes(review): Preserve built-in-named custom tool rendering --- .../calm/lib/built-in-tool-shells.ts | 47 +++++++++++++++---- tests/pi-calm.test.sh | 19 ++++++-- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts b/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts index 4a6b147..8ee353a 100644 --- a/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts +++ b/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts @@ -3,12 +3,17 @@ // 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 ToolExecutionComponent. This -// adapter changes only the final TUI row layout. Active tool definitions, -// execution, settings, SDK overrides, extension collisions, and stored results -// remain owned by Pi. Image results remain visible without their call/result -// shell, and tools outside Pi's seven known built-in names render unchanged. -import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.82.0, which exports AgentSession and +// ToolExecutionComponent. The source-aware lookup returns Pi's active definition +// unchanged and the adapter changes only its final TUI row layout. Execution, +// settings, SDK overrides, extension collisions, and stored results remain +// owned by Pi. Image results remain visible without their call/result shell, +// and custom tools or tools outside Pi's seven built-ins render unchanged. +import { + AgentSession, + ToolExecutionComponent, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; import type { Component } from "@earendil-works/pi-tui"; import { calmHidesTranscriptChrome } from "./visibility.ts"; @@ -24,13 +29,14 @@ const CALM_BUILT_IN_TOOL_NAMES = new Set([ type ToolRowPresentationState = { toolName: string; - builtInToolDefinition?: object; + toolDefinition?: ToolDefinition; imageComponents: Component[]; imageSpacers: Component[]; }; type CalmBuiltInToolShellPatch = { hidesShell: () => boolean; + builtInDefinitions: WeakSet; }; const CALM_BUILT_IN_TOOL_SHELL_PATCH = Symbol.for( @@ -43,11 +49,15 @@ export function installCalmBuiltInToolShellLayout(): void { }; const hidesShell = (): boolean => calmHidesTranscriptChrome(); const installed = registry[CALM_BUILT_IN_TOOL_SHELL_PATCH]; - if (installed) { + if (installed?.builtInDefinitions) { installed.hidesShell = hidesShell; return; } + const originalGetToolDefinition = AgentSession.prototype.getToolDefinition; + if (typeof originalGetToolDefinition !== "function") { + throw new Error("Pi Calm requires Pi AgentSession.getToolDefinition"); + } if (typeof ToolExecutionComponent !== "function") { throw new Error("Pi Calm requires Pi ToolExecutionComponent"); } @@ -56,12 +66,29 @@ export function installCalmBuiltInToolShellLayout(): void { throw new Error("Pi Calm requires Pi ToolExecutionComponent.render"); } - const patch: CalmBuiltInToolShellPatch = { hidesShell }; + if (installed) installed.hidesShell = () => false; + + const patch: CalmBuiltInToolShellPatch = { + hidesShell, + builtInDefinitions: new WeakSet(), + }; + AgentSession.prototype.getToolDefinition = function ( + name: string, + ): ToolDefinition | undefined { + const definition = originalGetToolDefinition.call(this, name); + const source = this.getAllTools().find((tool) => tool.name === name)?.sourceInfo.source; + if (definition && source === "builtin") { + patch.builtInDefinitions.add(definition); + } + return definition; + }; + ToolExecutionComponent.prototype.render = function (width: number): string[] { const state = this as unknown as ToolRowPresentationState; const isKnownBuiltIn = CALM_BUILT_IN_TOOL_NAMES.has(state.toolName) && - state.builtInToolDefinition !== undefined; + state.toolDefinition !== undefined && + patch.builtInDefinitions.has(state.toolDefinition); if (!isKnownBuiltIn || !patch.hidesShell()) { return originalRender.call(this, width); } diff --git a/tests/pi-calm.test.sh b/tests/pi-calm.test.sh index dddb10e..2505c75 100755 --- a/tests/pi-calm.test.sh +++ b/tests/pi-calm.test.sh @@ -351,7 +351,7 @@ import { pathToFileURL } from "node:url"; process.env.PI_CODING_AGENT_DIR = process.env.AGENT_DIR; const root = process.env.PI_PACKAGE_DIR; -const [{ AssistantMessageComponent, ToolExecutionComponent }, { initTheme }, { Text }] = await Promise.all([ +const [{ AgentSession, AssistantMessageComponent, ToolExecutionComponent, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition }, { initTheme }, { Text }] = await Promise.all([ import("@earendil-works/pi-coding-agent"), import(pathToFileURL(`${root}/dist/modes/interactive/theme/theme.js`).href), import("@earendil-works/pi-tui"), @@ -400,8 +400,14 @@ check(assistant.render(100).join("\\n").includes("PRIVATE_REASONING"), "expandin assistant.setHideThinkingBlock(true); const renderUi = { requestRender() {} }; -for (const toolName of ["read", "bash", "edit", "write", "grep", "find", "ls"]) { - const row = new ToolExecutionComponent(toolName, `call-${toolName}`, {}, { showImages: false }, undefined, renderUi, process.cwd()); +const builtInFactories = [createReadToolDefinition, createBashToolDefinition, createEditToolDefinition, createWriteToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition]; +for (const factory of builtInFactories) { + const definition = factory(process.cwd()); + const toolName = definition.name; + const session = Object.create(AgentSession.prototype); + session._toolDefinitions = new Map([[toolName, { definition, sourceInfo: { source: "builtin" } }]]); + check(session.getToolDefinition(toolName) === definition, `${toolName} definition lookup changed identity`); + const row = new ToolExecutionComponent(toolName, `call-${toolName}`, {}, { showImages: false }, definition, renderUi, process.cwd()); row.markExecutionStarted(); row.setArgsComplete(); row.updateResult({ content: [{ type: "text", text: `RESULT_${toolName}` }], details: {}, isError: false }); @@ -420,6 +426,13 @@ const custom = { const customRow = new ToolExecutionComponent("custom_boundary", "custom-call", {}, { showImages: false }, custom, renderUi, process.cwd()); customRow.markExecutionStarted(); customRow.setArgsComplete(); customRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false }); check(customRow.render(100).join("\\n").includes("CUSTOM_CALL"), "Calm hid a generic custom tool"); +const collidingCustom = { ...custom, name: "read" }; +const customSession = Object.create(AgentSession.prototype); +customSession._toolDefinitions = new Map([["read", { definition: collidingCustom, sourceInfo: { source: "sdk" } }]]); +check(customSession.getToolDefinition("read") === collidingCustom, "custom collision definition lookup changed identity"); +const collidingRow = new ToolExecutionComponent("read", "custom-read-call", {}, { showImages: false }, collidingCustom, renderUi, process.cwd()); +collidingRow.markExecutionStarted(); collidingRow.setArgsComplete(); collidingRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false }); +check(collidingRow.render(100).join("\\n").includes("CUSTOM_CALL"), "Calm hid a built-in-named custom tool"); const session = [{ type: "message", message: { role: "user", content: "REAL_USER_PROMPT" } }, { type: "message", message: { role: "assistant", content: "VISIBLE_ASSISTANT_TEXT" } }]; const before = JSON.stringify(session); check(JSON.stringify(session) === before, "presentation test changed session data"); From d75e17477a37cb0d7fbe08b5b0d24f374270f7ce Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 31 Jul 2026 22:25:08 -0700 Subject: [PATCH 4/5] no-mistakes(review): Preserve SDK base-tool override rendering --- .../extensions/calm/lib/built-in-tool-shells.ts | 14 ++++++++++++-- tests/pi-calm.test.sh | 8 ++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts b/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts index 8ee353a..7b060e3 100644 --- a/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts +++ b/home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts @@ -34,6 +34,10 @@ type ToolRowPresentationState = { imageSpacers: Component[]; }; +type AgentSessionPresentationState = { + _baseToolsOverride?: Record; +}; + type CalmBuiltInToolShellPatch = { hidesShell: () => boolean; builtInDefinitions: WeakSet; @@ -77,8 +81,14 @@ export function installCalmBuiltInToolShellLayout(): void { ): ToolDefinition | undefined { const definition = originalGetToolDefinition.call(this, name); const source = this.getAllTools().find((tool) => tool.name === name)?.sourceInfo.source; - if (definition && source === "builtin") { - patch.builtInDefinitions.add(definition); + if (definition) { + const session = this as unknown as AgentSessionPresentationState; + const isSdkBaseOverride = Object.hasOwn(session._baseToolsOverride ?? {}, name); + if (source === "builtin" && !isSdkBaseOverride) { + patch.builtInDefinitions.add(definition); + } else { + patch.builtInDefinitions.delete(definition); + } } return definition; }; diff --git a/tests/pi-calm.test.sh b/tests/pi-calm.test.sh index 2505c75..e5de624 100755 --- a/tests/pi-calm.test.sh +++ b/tests/pi-calm.test.sh @@ -433,6 +433,14 @@ check(customSession.getToolDefinition("read") === collidingCustom, "custom colli const collidingRow = new ToolExecutionComponent("read", "custom-read-call", {}, { showImages: false }, collidingCustom, renderUi, process.cwd()); collidingRow.markExecutionStarted(); collidingRow.setArgsComplete(); collidingRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false }); check(collidingRow.render(100).join("\\n").includes("CUSTOM_CALL"), "Calm hid a built-in-named custom tool"); +const baseOverrideCustom = { ...custom, name: "bash" }; +const baseOverrideSession = Object.create(AgentSession.prototype); +baseOverrideSession._baseToolsOverride = { bash: baseOverrideCustom }; +baseOverrideSession._toolDefinitions = new Map([["bash", { definition: baseOverrideCustom, sourceInfo: { source: "builtin" } }]]); +check(baseOverrideSession.getToolDefinition("bash") === baseOverrideCustom, "SDK base override lookup changed identity"); +const baseOverrideRow = new ToolExecutionComponent("bash", "sdk-bash-call", {}, { showImages: false }, baseOverrideCustom, renderUi, process.cwd()); +baseOverrideRow.markExecutionStarted(); baseOverrideRow.setArgsComplete(); baseOverrideRow.updateResult({ content: [{ type: "text", text: "CUSTOM_RESULT" }], details: {}, isError: false }); +check(baseOverrideRow.render(100).join("\\n").includes("CUSTOM_CALL"), "Calm hid an SDK base-tool override"); const session = [{ type: "message", message: { role: "user", content: "REAL_USER_PROMPT" } }, { type: "message", message: { role: "assistant", content: "VISIBLE_ASSISTANT_TEXT" } }]; const before = JSON.stringify(session); check(JSON.stringify(session) === before, "presentation test changed session data"); From 61fc763bae4c9744a502a14056eba573067ae985 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 31 Jul 2026 22:30:15 -0700 Subject: [PATCH 5/5] no-mistakes(document): Document Pi Calm autoload and runtime boundaries --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 66ee11c..d555cb3 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ Home Manager owns exactly two repository-authored Pi directories: `~/.pi/agent/t ### Pi Calm -`extensions/calm` is a standalone local Pi extension. `/calm` toggles a conversation-only presentation mode and is off by default. Its choice is stored locally in `~/.pi/agent/calm` (or the directory selected by `PI_CODING_AGENT_DIR`), not in this repository or Home Manager. +`home/.pi/agent/extensions/calm` is a standalone local Pi extension. Home Manager's existing global extensions-directory link makes Pi auto-load it without another declaration. `/calm` toggles a conversation-only presentation mode and is off by default. Its choice is stored locally in `~/.pi/agent/calm` (or the directory selected by `PI_CODING_AGENT_DIR`), not in this repository or Home Manager. Adapted from Firstmate under the bundled MIT license, Calm imports no Firstmate modules and has no Firstmate runtime dependency. When enabled, Calm hides collapsed thinking and the call/result shells for Pi's seven built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`) without leaving blank transcript rows. During an active run it replaces Pi's working row with a two-line animated blue-water, yellow-boat widget. `/calm` restores Pi's stock rendering and preserves the existing Ctrl+O tool-expansion choice.