Switch firstmate to OpenCode 2 and refresh nix-darwin pins
Point fm at the isolated oc2 wrapper, upgrade Homebrew packages on rebuild, and declare ffmpeg, docker-desktop, puremac, utm, node, and tea. Update nixpkgs, home-manager, and nix-homebrew locks. Track Pi agent config, crew-dispatch, and the Gitea PR helper that home.nix already expected.
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,157 @@
|
||||
// Pi Calm - a standalone conversation-presentation toggle for Pi.
|
||||
//
|
||||
// Adapted from the Firstmate project's Calm implementation.
|
||||
// Copyright (c) 2026 Kun Chen. MIT License - see the LICENSE file in this directory.
|
||||
//
|
||||
// Verified against Pi 0.82.0, which exports its shared tool-row component,
|
||||
// session_start replacement reasons, agent_start
|
||||
// and agent_settled, ExtensionUIContext.setToolsExpanded(), setWorkingVisible(),
|
||||
// setWidget() with a disposable component factory, and setHiddenThinkingLabel().
|
||||
// ./lib/working-ship.ts owns the animated working presentation this file
|
||||
// installs. ./lib/preference.ts owns the local state file. The collapsed-thinking
|
||||
// presentation adapter probes the exact public API seam it patches and degrades
|
||||
// independently with one clear diagnostic (see installCalmPresentationAdapter
|
||||
// below) if a future Pi removes it. The shared tool-row adapter is limited to
|
||||
// Pi's seven known built-in names, so generic custom tools and unsupported
|
||||
// transcript classes deliberately stay visible.
|
||||
//
|
||||
// Calm changes presentation only. It never intercepts, transforms, reroutes,
|
||||
// removes, or reorders semantic input, tool execution, model context, session
|
||||
// storage, or export data; /export and /share render the complete stock
|
||||
// transcript.
|
||||
import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
||||
import { getKeybindings } from "@earendil-works/pi-tui";
|
||||
import { installCalmBuiltInToolShellLayout } from "./lib/built-in-tool-shells.ts";
|
||||
import { installCalmCollapsedThinkingLayout } from "./lib/collapsed-thinking.ts";
|
||||
import { loadCalmPreference, persistCalmPreference } from "./lib/preference.ts";
|
||||
import {
|
||||
calmPresentationIsActive,
|
||||
setCalmPresentation,
|
||||
setCalmStockExportRendering,
|
||||
} from "./lib/visibility.ts";
|
||||
import {
|
||||
CALM_WORKING_SHIP_WIDGET_KEY,
|
||||
createCalmWorkingShipAnimation,
|
||||
createCalmWorkingShipWidget,
|
||||
} from "./lib/working-ship.ts";
|
||||
|
||||
// Each presentation adapter probes the exact Pi API it patches. If a future Pi
|
||||
// removes that API, only the affected adapter degrades; the rest of Calm keeps
|
||||
// working.
|
||||
function installCalmPresentationAdapter(name: string, install: () => void): void {
|
||||
try {
|
||||
install();
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Pi Calm: ${name} presentation adapter unavailable, skipping. ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
installCalmPresentationAdapter("collapsed-thinking", installCalmCollapsedThinkingLayout);
|
||||
installCalmPresentationAdapter("built-in-tool-shells", installCalmBuiltInToolShellLayout);
|
||||
|
||||
let removeTerminalInputHandler: (() => void) | undefined;
|
||||
// One logical agent run, tracked from agent_start through agent_settled rather
|
||||
// than from turns or tool calls, so the boat never flickers between tool calls,
|
||||
// automatic continuations, retries, or compaction that stay inside the same run.
|
||||
let agentRunActive = false;
|
||||
let workingShipShown = false;
|
||||
// One animation instance per extension lifetime. Hiding the working widget
|
||||
// freezes this state; the next working period resumes it. session_start resets
|
||||
// it so a fresh Pi session starts at the normal initial position. Never
|
||||
// module-global.
|
||||
const workingShipAnimation = createCalmWorkingShipAnimation();
|
||||
|
||||
// Single owner of Calm's working-row presentation choice. The widget is only
|
||||
// created or removed on a real transition, so repeated starts cannot duplicate
|
||||
// its timer.
|
||||
const applyWorkingPresentation = (
|
||||
ui: ExtensionUIContext,
|
||||
forceStockVisibility = false,
|
||||
): void => {
|
||||
const showShip = agentRunActive && calmPresentationIsActive();
|
||||
if (showShip !== workingShipShown) {
|
||||
workingShipShown = showShip;
|
||||
ui.setWidget(
|
||||
CALM_WORKING_SHIP_WIDGET_KEY,
|
||||
showShip
|
||||
? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation)
|
||||
: undefined,
|
||||
);
|
||||
ui.setWorkingVisible(!showShip);
|
||||
} else if (forceStockVisibility && !showShip) {
|
||||
ui.setWorkingVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
setCalmPresentation(loadCalmPreference());
|
||||
setCalmStockExportRendering(false);
|
||||
agentRunActive = false;
|
||||
workingShipShown = false;
|
||||
// A genuine new session lifetime starts the boat at the normal initial position.
|
||||
workingShipAnimation.reset();
|
||||
applyWorkingPresentation(ctx.ui, true);
|
||||
ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined);
|
||||
removeTerminalInputHandler?.();
|
||||
removeTerminalInputHandler = ctx.ui.onTerminalInput((data) => {
|
||||
if (!getKeybindings().matches(data, "tui.input.submit")) return;
|
||||
|
||||
const input = ctx.ui.getEditorText().trim();
|
||||
if (
|
||||
input !== "/share" &&
|
||||
input !== "/export" &&
|
||||
!input.startsWith("/export ")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// /export and /share render through the same tool renderers the transcript
|
||||
// uses, so force stock output for the duration of the command. Session and
|
||||
// export data are never filtered; this only concerns the visual components.
|
||||
setCalmStockExportRendering(true);
|
||||
setTimeout(() => {
|
||||
setCalmStockExportRendering(false);
|
||||
const expanded = ctx.ui.getToolsExpanded();
|
||||
ctx.ui.setToolsExpanded(!expanded);
|
||||
ctx.ui.setToolsExpanded(expanded);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("agent_start", (_event, ctx) => {
|
||||
agentRunActive = true;
|
||||
applyWorkingPresentation(ctx.ui);
|
||||
});
|
||||
|
||||
// agent_settled is emitted from a finally block, so it also covers abort and failure.
|
||||
pi.on("agent_settled", (_event, ctx) => {
|
||||
agentRunActive = false;
|
||||
applyWorkingPresentation(ctx.ui);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", (_event, ctx) => {
|
||||
agentRunActive = false;
|
||||
applyWorkingPresentation(ctx.ui);
|
||||
});
|
||||
|
||||
pi.registerCommand("calm", {
|
||||
description: "Toggle Calm: hide collapsed thinking and built-in tool shells from the transcript (presentation only).",
|
||||
handler: async (_args, ctx) => {
|
||||
const active = !calmPresentationIsActive();
|
||||
// Persist first: if the state file cannot be written, the toggle fails
|
||||
// with a clear error instead of silently reverting on the next restart.
|
||||
persistCalmPreference(active);
|
||||
setCalmPresentation(active);
|
||||
applyWorkingPresentation(ctx.ui, true);
|
||||
ctx.ui.setHiddenThinkingLabel(active ? "" : undefined);
|
||||
|
||||
// Flip expansion twice to force a transcript redraw while preserving the
|
||||
// user's exact Ctrl+O tools-expanded state.
|
||||
const expanded = ctx.ui.getToolsExpanded();
|
||||
ctx.ui.setToolsExpanded(!expanded);
|
||||
ctx.ui.setToolsExpanded(expanded);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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 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";
|
||||
|
||||
const CALM_BUILT_IN_TOOL_NAMES = new Set([
|
||||
"read",
|
||||
"bash",
|
||||
"edit",
|
||||
"write",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
]);
|
||||
|
||||
type ToolRowPresentationState = {
|
||||
toolName: string;
|
||||
toolDefinition?: ToolDefinition;
|
||||
imageComponents: Component[];
|
||||
imageSpacers: Component[];
|
||||
};
|
||||
|
||||
type AgentSessionPresentationState = {
|
||||
_baseToolsOverride?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type CalmBuiltInToolShellPatch = {
|
||||
hidesShell: () => boolean;
|
||||
builtInDefinitions: WeakSet<ToolDefinition>;
|
||||
};
|
||||
|
||||
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?.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");
|
||||
}
|
||||
const originalRender = ToolExecutionComponent.prototype.render;
|
||||
if (typeof originalRender !== "function") {
|
||||
throw new Error("Pi Calm requires Pi ToolExecutionComponent.render");
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
};
|
||||
|
||||
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.toolDefinition !== undefined &&
|
||||
patch.builtInDefinitions.has(state.toolDefinition);
|
||||
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;
|
||||
}
|
||||
@@ -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<PiAssistantMessageComponent["updateContent"]>[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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
const DEFAULT_TITLE = "π";
|
||||
const PREFIX = "π";
|
||||
const MAX_TITLE_LENGTH = 40;
|
||||
const SPINNER_INTERVAL_MS = 120;
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
function truncateTitle(title) {
|
||||
if (title.length <= MAX_TITLE_LENGTH) return title;
|
||||
return title.slice(0, MAX_TITLE_LENGTH - 3) + "...";
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
if (!path) return DEFAULT_TITLE;
|
||||
|
||||
const trimmed = path.replace(/[\\/]+$/, "");
|
||||
if (!trimmed) return DEFAULT_TITLE;
|
||||
|
||||
return trimmed.split(/[\\/]/).pop() || DEFAULT_TITLE;
|
||||
}
|
||||
|
||||
function getSessionName(pi) {
|
||||
const name = pi.getSessionName?.();
|
||||
return typeof name === "string" ? name.trim() : "";
|
||||
}
|
||||
|
||||
function getRawTitle(pi, ctx) {
|
||||
return getSessionName(pi) || basename(ctx.cwd);
|
||||
}
|
||||
|
||||
function isSpinningStatus(status) {
|
||||
return status === "working";
|
||||
}
|
||||
|
||||
function statusIndicator(status, spinnerFrame) {
|
||||
if (isSpinningStatus(status)) {
|
||||
if (SPINNER_FRAMES.length === 0) return "◉";
|
||||
return SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
|
||||
}
|
||||
|
||||
if (status === "done") return "✓";
|
||||
if (status === "error") return "✗";
|
||||
return "○";
|
||||
}
|
||||
|
||||
function formatTitle(pi, ctx, status, spinnerFrame) {
|
||||
const rawTitle = getRawTitle(pi, ctx);
|
||||
const suffix = rawTitle === DEFAULT_TITLE ? DEFAULT_TITLE : `${PREFIX} | ${truncateTitle(rawTitle)}`;
|
||||
|
||||
return `${statusIndicator(status, spinnerFrame)} | ${suffix}`;
|
||||
}
|
||||
|
||||
export default function terminalStatusTitle(pi) {
|
||||
let status = "idle";
|
||||
let spinnerFrame = 0;
|
||||
let spinnerInterval;
|
||||
let deferredWrite;
|
||||
let lastCtx;
|
||||
|
||||
function clearDeferredWrite() {
|
||||
if (!deferredWrite) return;
|
||||
|
||||
clearTimeout(deferredWrite);
|
||||
deferredWrite = undefined;
|
||||
}
|
||||
|
||||
function writeTitle(ctx = lastCtx) {
|
||||
if (!ctx?.hasUI) return;
|
||||
|
||||
lastCtx = ctx;
|
||||
ctx.ui.setTitle(formatTitle(pi, ctx, status, spinnerFrame));
|
||||
}
|
||||
|
||||
function stopSpinner() {
|
||||
if (!spinnerInterval) return;
|
||||
|
||||
clearInterval(spinnerInterval);
|
||||
spinnerInterval = undefined;
|
||||
spinnerFrame = 0;
|
||||
}
|
||||
|
||||
function startSpinner(ctx) {
|
||||
if (!ctx?.hasUI || spinnerInterval) return;
|
||||
|
||||
spinnerFrame = 0;
|
||||
spinnerInterval = setInterval(() => {
|
||||
if (!isSpinningStatus(status)) {
|
||||
stopSpinner();
|
||||
return;
|
||||
}
|
||||
|
||||
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
||||
writeTitle();
|
||||
}, SPINNER_INTERVAL_MS);
|
||||
spinnerInterval.unref?.();
|
||||
}
|
||||
|
||||
function setStatus(nextStatus, ctx) {
|
||||
clearDeferredWrite();
|
||||
status = nextStatus;
|
||||
lastCtx = ctx;
|
||||
|
||||
if (isSpinningStatus(status)) {
|
||||
startSpinner(ctx);
|
||||
} else {
|
||||
stopSpinner();
|
||||
}
|
||||
|
||||
writeTitle(ctx);
|
||||
}
|
||||
|
||||
function scheduleWrite(ctx) {
|
||||
clearDeferredWrite();
|
||||
deferredWrite = setTimeout(() => {
|
||||
deferredWrite = undefined;
|
||||
writeTitle(ctx);
|
||||
}, 0);
|
||||
deferredWrite.unref?.();
|
||||
}
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
setStatus("idle", ctx);
|
||||
scheduleWrite(ctx);
|
||||
});
|
||||
|
||||
pi.on("agent_start", async (_event, ctx) => {
|
||||
setStatus("working", ctx);
|
||||
});
|
||||
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
setStatus("done", ctx);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
clearDeferredWrite();
|
||||
stopSpinner();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user