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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

`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.

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`.
Expand Down
21 changes: 21 additions & 0 deletions home/.pi/agent/extensions/calm/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
157 changes: 157 additions & 0 deletions home/.pi/agent/extensions/calm/index.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
}
117 changes: 117 additions & 0 deletions home/.pi/agent/extensions/calm/lib/built-in-tool-shells.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading