diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index 1fb9cf12c4..4c52620e93 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -38,8 +38,16 @@ import { } from "@earendil-works/pi-coding-agent"; import { Box, Container, getKeybindings, type Component } from "@earendil-works/pi-tui"; import type { TSchema } from "typebox"; -import { installCalmAssistantLayout } from "./lib/fm-calm-assistant-layout.ts"; -import { installCalmOperationalUserLayout } from "./lib/fm-calm-operational-user-layout.ts"; +import { + installCalmAssistantLayout, + noteCalmTranscriptRunSettled, + noteCalmTranscriptRunStart, + resetCalmTranscriptOrigin, +} from "./lib/fm-calm-assistant-layout.ts"; +import { + installCalmOperationalUserLayout, + installCalmTranscriptReplayWindow, +} from "./lib/fm-calm-operational-user-layout.ts"; import { CALM_WORKING_SHIP_WIDGET_KEY, createCalmWorkingShipAnimation, @@ -98,6 +106,7 @@ function installCalmPresentationAdapter(name: string, install: () => void): void export default function (pi: ExtensionAPI) { installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); + installCalmPresentationAdapter("transcript-replay-window", installCalmTranscriptReplayWindow); let exportRendering = false; let removeTerminalInputHandler: (() => void) | undefined; @@ -275,6 +284,7 @@ export default function (pi: ExtensionAPI) { registerBuiltIn(createLsToolDefinition); pi.on("session_start", (_event, ctx) => { + resetCalmTranscriptOrigin(); exportRendering = false; setCalmPresentation(loadCalmPreference()); setCalmStockExportRendering(false); @@ -314,17 +324,20 @@ export default function (pi: ExtensionAPI) { }); pi.on("agent_start", (_event, ctx) => { + noteCalmTranscriptRunStart(); 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) => { + noteCalmTranscriptRunSettled(); agentRunActive = false; applyWorkingPresentation(ctx.ui); }); pi.on("session_shutdown", (_event, ctx) => { + noteCalmTranscriptRunSettled(); agentRunActive = false; applyWorkingPresentation(ctx.ui); }); diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts index 33be71095e..7b98828261 100644 --- a/.pi/extensions/lib/fm-calm-assistant-layout.ts +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -1,7 +1,23 @@ -// Verified against Pi 0.81.1 and 0.82.0, which export AssistantMessageComponent with an -// updateContent method. installCalmAssistantLayout() probes that exact method and throws -// if it is missing; fm-calm.ts catches that and skips only this adapter with a diagnostic -// instead of blocking Calm or Pi. +// Pi exports AssistantMessageComponent with an updateContent method. +// installCalmAssistantLayout() probes that exact method and throws if it is missing; +// fm-calm.ts catches that and skips only this adapter with a diagnostic instead of +// blocking Calm or Pi. +// The adapter owns both collapsed-thinking layout and the presentation-only exact +// operational acknowledgement rule. +// Acknowledgement origin is scoped to one agent run rather than to the most recent user +// row: a run counts as operational only while every Firstmate input it carries is +// canonically operational, so a wake steered into a still-running captain turn keeps that +// run's replies visible. +// Pi opens a run with agent_start before the run's initiating user message_start, drains +// steered and queued inputs into that same run, and settles it from a finally block. The +// accumulator records the first input of a run whichever order those two arrive in, so a +// future Pi that emits them the other way round still accumulates instead of overwriting. +// Pi also rebuilds the whole transcript through InteractiveMode.renderSessionItems, +// including the rebuild it performs when it auto-compacts inside a run. Rows replayed in +// the window the separately probed transcript-replay adapter marks are scored per row +// against their own preceding input and never disturb the run scope. Without that +// adapter no window is ever opened and replayed rows fall back to run scoping. Every +// unresolved case resolves to visible. import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; @@ -15,27 +31,128 @@ type AssistantMessagePresentationState = { }; type CalmAssistantLayoutPatch = { + assistantOperationalOrigins: WeakMap; + replayDepth: number; + replayOriginIsOperational: boolean; + runIsActive: boolean; + runOriginIsOperational: boolean; + runOriginRecorded: boolean; + hidesOperationalAcknowledgement: () => boolean; hidesThinking: () => boolean; }; -// Keep the introduction-version symbol stable so a compatible upgrade cannot -// double-patch a live process. +// The symbol changes only when the patch shape changes, so a compatible upgrade cannot +// double-patch a live process and an incompatible one cannot keep a stale closure +// installed under the same key. const CALM_ASSISTANT_LAYOUT_PATCH = Symbol.for( - "firstmate:calm-assistant-layout:pi-0.81.1", + "firstmate:calm-assistant-layout:operational-ack-v2", ); +const FIRSTMATE_NO_ACTION_ACKNOWLEDGEMENT = "Captain, shipshape."; -export function installCalmAssistantLayout(): void { - const registry = globalThis as typeof globalThis & { +function registry(): typeof globalThis & { + [key: symbol]: CalmAssistantLayoutPatch | undefined; +} { + return globalThis as typeof globalThis & { [key: symbol]: CalmAssistantLayoutPatch | undefined; }; +} + +export function noteCalmTranscriptUserMessage(isOperational: boolean): void { + const patch = registry()[CALM_ASSISTANT_LAYOUT_PATCH]; + if (!patch) return; + if (patch.replayDepth > 0) { + patch.replayOriginIsOperational = isOperational; + return; + } + if (patch.runIsActive && patch.runOriginRecorded) { + patch.runOriginIsOperational = patch.runOriginIsOperational && isOperational; + return; + } + patch.runOriginIsOperational = isOperational; + patch.runOriginRecorded = true; +} + +export function beginCalmTranscriptReplay(): void { + const patch = registry()[CALM_ASSISTANT_LAYOUT_PATCH]; + if (!patch) return; + if (patch.replayDepth === 0) patch.replayOriginIsOperational = false; + patch.replayDepth += 1; +} + +export function endCalmTranscriptReplay(): void { + const patch = registry()[CALM_ASSISTANT_LAYOUT_PATCH]; + if (!patch || patch.replayDepth === 0) return; + patch.replayDepth -= 1; +} + +export function noteCalmTranscriptRunStart(): void { + const patch = registry()[CALM_ASSISTANT_LAYOUT_PATCH]; + if (patch) patch.runIsActive = true; +} + +export function noteCalmTranscriptRunSettled(): void { + const patch = registry()[CALM_ASSISTANT_LAYOUT_PATCH]; + if (!patch) return; + patch.runIsActive = false; + patch.runOriginRecorded = false; +} + +export function resetCalmTranscriptOrigin(): void { + const patch = registry()[CALM_ASSISTANT_LAYOUT_PATCH]; + if (!patch) return; + patch.replayDepth = 0; + patch.replayOriginIsOperational = false; + patch.runIsActive = false; + patch.runOriginIsOperational = false; + patch.runOriginRecorded = false; +} + +function withoutOperationalAcknowledgement( + message: AssistantMessage, + isOperational: boolean, + hidesAcknowledgement: boolean, +): AssistantMessage { + if (!isOperational || !hidesAcknowledgement) return message; + if (message.content.some((block) => block.type === "toolCall")) return message; + + const text = message.content + .map((block) => (block.type === "text" ? block.text : "")) + .join(""); + const isStreamingPrefix = + message.stopReason === "pending" && + FIRSTMATE_NO_ACTION_ACKNOWLEDGEMENT.startsWith(text); + const isFinalAcknowledgement = + message.stopReason === "stop" && + text === FIRSTMATE_NO_ACTION_ACKNOWLEDGEMENT; + if (!isStreamingPrefix && !isFinalAcknowledgement) return message; + + return { + ...message, + content: message.content.filter((block) => block.type !== "text"), + }; +} + +export function installCalmAssistantLayout(): void { const hidesThinking = (): boolean => calmPresentationHides("assistant-thinking"); - const installed = registry[CALM_ASSISTANT_LAYOUT_PATCH]; + const hidesOperationalAcknowledgement = (): boolean => + calmPresentationHides("synthetic-assistant"); + const installed = registry()[CALM_ASSISTANT_LAYOUT_PATCH]; if (installed) { installed.hidesThinking = hidesThinking; + installed.hidesOperationalAcknowledgement = hidesOperationalAcknowledgement; return; } - const patch: CalmAssistantLayoutPatch = { hidesThinking }; + const patch: CalmAssistantLayoutPatch = { + assistantOperationalOrigins: new WeakMap(), + replayDepth: 0, + replayOriginIsOperational: false, + runIsActive: false, + runOriginIsOperational: false, + runOriginRecorded: false, + hidesOperationalAcknowledgement, + hidesThinking, + }; const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; if (typeof AssistantMessageComponent !== "function") { throw new Error("Firstmate Calm requires Pi AssistantMessageComponent"); @@ -49,20 +166,35 @@ export function installCalmAssistantLayout(): void { message: AssistantMessage, ): void { const state = this as unknown as AssistantMessagePresentationState; + let isOperational = patch.assistantOperationalOrigins.get(this); + if (isOperational === undefined) { + isOperational = + patch.replayDepth > 0 + ? patch.replayOriginIsOperational + : patch.runOriginIsOperational; + patch.assistantOperationalOrigins.set(this, isOperational); + } const hideThinking = state.hiddenThinkingLabel === "" && state.hideThinkingBlock && patch.hidesThinking(); + const acknowledgementPresentation = withoutOperationalAcknowledgement( + message, + isOperational, + patch.hidesOperationalAcknowledgement(), + ); const presentationMessage = hideThinking ? { - ...message, - content: message.content.filter((block) => block.type !== "thinking"), + ...acknowledgementPresentation, + content: acknowledgementPresentation.content.filter( + (block) => block.type !== "thinking", + ), } - : message; + : acknowledgementPresentation; originalUpdateContent.call(this, presentationMessage); if (presentationMessage !== message) state.lastMessage = message; }; - registry[CALM_ASSISTANT_LAYOUT_PATCH] = patch; + registry()[CALM_ASSISTANT_LAYOUT_PATCH] = patch; } diff --git a/.pi/extensions/lib/fm-calm-operational-user-layout.ts b/.pi/extensions/lib/fm-calm-operational-user-layout.ts index ca9b0bbcc0..7f29c343af 100644 --- a/.pi/extensions/lib/fm-calm-operational-user-layout.ts +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -1,10 +1,21 @@ -// Verified against Pi 0.81.1 and 0.82.0, which add the ordinary-user spacer and row -// together via InteractiveMode.addMessageToChat. This adapter probes that exact method -// and throws if it is missing; fm-calm.ts catches that and skips only this adapter with a -// diagnostic instead of blocking Calm or Pi. It changes only that presentation and never -// message delivery. +// Pi adds the ordinary-user spacer and row together through +// InteractiveMode.addMessageToChat, and replays or rebuilds the whole transcript through +// InteractiveMode.renderSessionItems. +// This file installs those as two independently probed adapters, each of which throws +// when its own exact method is missing; fm-calm.ts catches that and skips only the +// affected adapter with a diagnostic instead of blocking Calm or Pi. +// The operational-user-row adapter owns the zero-height row and the canonical user-row +// origin the assistant layout adapter consumes. The transcript-replay adapter only marks +// the replay window; without it, replayed rows fall back to run scoping, which resolves +// to visible. +// Neither adapter changes message delivery. import type { UserMessageComponent as PiUserMessageComponent } from "@earendil-works/pi-coding-agent"; import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; +import { + beginCalmTranscriptReplay, + endCalmTranscriptReplay, + noteCalmTranscriptUserMessage, +} from "./fm-calm-assistant-layout.ts"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; import { classifyFirstmateCurrentOperationalText } from "./fm-operational-input.ts"; @@ -34,16 +45,24 @@ type InteractiveModePrototype = { message: UserMessageLike, options?: AddMessageOptions, ): void; + renderSessionItems(this: unknown, items: unknown[], options?: unknown): void; }; type CalmOperationalUserLayoutPatch = { hidesOperationalInput: () => boolean; isOperationalInput: (text: string) => boolean; }; +type CalmTranscriptReplayPatch = { + wrapped: true; +}; -// Keep the introduction-version symbol stable so a compatible upgrade cannot -// double-patch a live process. +// Each symbol changes only when the closure it guards changes, so a compatible upgrade +// cannot double-patch a live process and an incompatible one cannot keep a stale closure +// installed under the same key. const CALM_OPERATIONAL_USER_LAYOUT_PATCH = Symbol.for( - "firstmate:calm-operational-user-layout:pi-0.81.1", + "firstmate:calm-operational-user-layout:operational-ack-v2", +); +const CALM_TRANSCRIPT_REPLAY_PATCH = Symbol.for( + "firstmate:calm-transcript-replay:v1", ); const LEGACY_CALM_OPERATIONAL_PREFIX = "\u2063Supervisor escalate ("; @@ -120,13 +139,20 @@ export function installCalmOperationalUserLayout(): void { message: UserMessageLike, options?: AddMessageOptions, ): void { - if (message.role !== "user" || !contentIsTextOnly(message.content)) { + if (message.role !== "user") { originalAddMessageToChat.call(this, message, options); return; } + if (!contentIsTextOnly(message.content)) { + noteCalmTranscriptUserMessage(false); + originalAddMessageToChat.call(this, message, options); + return; + } const text = this.getUserMessageText(message); - if (!text || !patch.isOperationalInput(text)) { + const isOperational = Boolean(text && patch.isOperationalInput(text)); + noteCalmTranscriptUserMessage(isOperational); + if (!isOperational) { originalAddMessageToChat.call(this, message, options); return; } @@ -143,3 +169,35 @@ export function installCalmOperationalUserLayout(): void { registry[CALM_OPERATIONAL_USER_LAYOUT_PATCH] = patch; } + +export function installCalmTranscriptReplayWindow(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmTranscriptReplayPatch | undefined; + }; + if (registry[CALM_TRANSCRIPT_REPLAY_PATCH]) return; + + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } + const prototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; + const originalRenderSessionItems = prototype.renderSessionItems; + if (typeof originalRenderSessionItems !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode.renderSessionItems"); + } + + prototype.renderSessionItems = function ( + this: unknown, + items: unknown[], + options?: unknown, + ): void { + beginCalmTranscriptReplay(); + try { + originalRenderSessionItems.call(this, items, options); + } finally { + endCalmTranscriptReplay(); + } + }; + + registry[CALM_TRANSCRIPT_REPLAY_PATCH] = { wrapped: true }; +} diff --git a/README.md b/README.md index ac54cf7025..9e11bb7387 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ For Grok, `--trust` is needed once per clone so project hooks and the turn-end g For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, and uses a Calm-only animated working boat during active runs while preserving all model context and session data. The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. +Calm also hides the agent's exact no-action `Captain, shipshape.` acknowledgement when a run's inputs were all operational, with the exact rule and its limits owned by [docs/calm.md](docs/calm.md). The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. [Calm's current behavior and supported limits](docs/calm.md) are separate from its [version-scoped maintainer evidence](docs/calm-mode-feasibility.md). diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index 3893b60c76..9d5d4c540e 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -191,7 +191,7 @@ The test fixture enumerates every class below through the centralized policy, an | Policy class | Pi transcript path | Calm result (verified on Pi 0.81.1 through 0.82.0) | | --- | --- | --- | | `genuine-user-prompt` | `UserMessageComponent` | Visible, including every tested operational near miss. | -| `genuine-agent-response` | Assistant text in `AssistantMessageComponent` | Visible. | +| `genuine-agent-response` | Assistant text in `AssistantMessageComponent` | Visible, subject only to the exact operational acknowledgement rule owned by [`calm.md`](calm.md). | | `assistant-thinking` | Thinking content in `AssistantMessageComponent` | Collapsed reasoning is removed from the shallow presentation copy before layout and occupies zero rows; explicit expansion renders the original reasoning. | | `assistant-tool-call` | `ToolExecutionComponent` | Seven built-ins and `fm_watch_arm_pi` hidden; arbitrary custom tools remain an unsupported boundary. | | `tool-result` | `ToolExecutionComponent` | Text results for the controlled tools hidden; arbitrary custom results remain an unsupported boundary. | @@ -208,11 +208,11 @@ The test fixture enumerates every class below through the centralized policy, an | `cache-notice` | Non-persisted cache-miss `Text` row | Unsupported boundary; remains visible. | | `project-trust-warning` | Non-persisted startup `Text` row | Unsupported boundary; remains visible. | | `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Canonically classified text-only operational user messages stay ordinary semantic user messages but render through the zero-height adapter (verified on Pi 0.81.1 through 0.82.0) under Calm; legacy entries stay gaplessly controllable, and the session-start nudge retains its existing non-displayed custom-message path. | -| `synthetic-assistant` | No authoritative Firstmate source found | Policy-hidden, but Pi exposes no generic assistant-role renderer. | +| `synthetic-assistant` | Exact no-action acknowledgement belonging to an agent run whose every Firstmate input is a canonical operational user row | The scoped rule is owned by [`calm.md`](calm.md); the API-probed assistant layout adapter supplies zero-height live and replay presentation without changing the message, and while the separately probed transcript-replay adapter is installed, rows replayed through a rebuild stay scored per row even when the rebuild happens inside a run. Without that adapter a rebuild inside a run falls back to run scoping, which only makes more replies visible. | | `unknown` | Future or unclassified transcript component | Policy-hidden, but no generic renderer exists; never claimed as covered. | The installed extension API has no supported global transcript filter, user-message renderer, assistant-message renderer, chat-container API, or generic custom-tool wrapper. -Pi 0.81.1 through 0.82.0 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses separate idempotent, API-probed adapters for assistant thinking layout and the complete operational-user transcript row while leaving all message data and non-Calm rendering unchanged; see the [compatibility contract](calm.md#pi-compatibility) for how a future Pi lacking one of those exports is handled. +Pi 0.81.1 through 0.82.0 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses three separate idempotent, API-probed adapters for assistant layout, the complete operational-user transcript row, and the transcript replay window while leaving all message data and non-Calm rendering unchanged; see the [compatibility contract](calm.md#pi-compatibility) for how a future Pi lacking one of those exports or seams is handled. General component replacement, ANSI cursor erasure, provider-context mutation, and installed-file patching remain rejected as unsupported or preservation-breaking workarounds. ## Cross-harness verification record @@ -237,7 +237,7 @@ grok 0.2.106 (bde89716f679) | Claude Code 2.1.218 | Not feasible through the inspected supported project surface. | Project hooks can observe lifecycle and tool events, while the plugin CLI packages supported components; neither inspected surface exposes a transcript-row renderer or transcript-wide redraw API. | | Codex CLI 0.144.6 | Not feasible through the inspected supported project surface. | The tracked hooks expose session, pre-tool, and stop handling, while the plugin and feature inventories expose no TUI tool-row renderer or transcript redraw control. | | OpenCode 1.17.18 | Not feasible without violating the preservation boundary. | Plugins expose events and tool execution hooks, not a built-in transcript-row renderer; same-name tool replacement changes execution rather than presentation alone. | -| Pi (verified 0.81.1 through 0.82.0) | Partially feasible with two API-probed exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the collapsed-thinking and operational-user layout boundaries, gated on the exact method's presence rather than a version number, while generic user, tool, and status filtering remains unavailable. | +| Pi (verified 0.81.1 through 0.82.0) | Partially feasible with three API-probed exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the collapsed-thinking, operational-user layout, and transcript replay boundaries, each gated on the exact method's presence rather than a version number, while generic user, tool, and status filtering remains unavailable. | | Grok CLI 0.2.106 | Not feasible through the inspected supported project surface. | Project hooks expose lifecycle and tool interception, while the plugin CLI exposes no row-renderer contract; `--minimal` changes the whole screen mode rather than selected transcript rows. | These conclusions are deliberately limited to the named versions and supported surfaces. @@ -249,6 +249,10 @@ Only Pi's Calm presentation implementation changed; every producer and non-Pi tr ## Regression coverage `tests/fm-calm-pi-extension.test.sh` compares wrapped and stock renderers, verifies all seven built-ins plus `fm_watch_arm_pi`, exercises redraw of already-rendered tool, thinking, current operational-user, and legacy synthetic rows, and covers every policy class. +Its deterministic assistant-layout matrix covers the exact operational acknowledgement, Calm off, genuine-user collision, punctuation, prefix, suffix, Markdown, explanation, capitalization, whitespace, streaming divergence, queued operational inputs, intervening tools, interruption, and every session-start replay reason. +It also drives Pi's real run lifecycle to prove that an operational wake steered into a still-running captain turn keeps that run's replies visible, that an operational-only run still hides the acknowledgement, and that a settled run does not carry its captain origin into the next wake. +It drives Pi's own transcript rebuild inside an active operational run to prove that replayed rows keep per-row origin, that a previously hidden acknowledgement stays hidden while a replayed captain reply stays visible, and that the continuation of the surrounding run is unaffected. +It also covers both transcript-replay seam paths: a Pi exposing the seam wraps it while the operational-user row adapter installs normally, and a Pi without it degrades that adapter alone by name while operational-row hiding stays installed. It covers persisted preference restoration across every session-start reason and a real restart, proves the working-ship presentation and Calm-off stock `Working...` row through a delayed deterministic provider, asserts no Calm status row, verifies operational messages remain exact ordinary user-role session entries and complete exports, and drives genuine 100 by 44, 160 by 36, and 180 by 44 terminal fixtures. A native deterministic `/skill:ahoy` turn produces thinking, tool-call, and tool-result blocks, asserts that the collapsed skill-to-final gap equals the two-row visible-only baseline, expands and re-collapses original thinking, restores Calm-off rendering, verifies persisted hidden history, and repeats the geometry assertion after restart with `terminal.clearOnShrink` explicitly off. The operational provider path covers Calm loaded on, loaded off, default preference, extension absent, exact watcher delivery, narrow bare-marker legacy input, persisted restart replay, a genuine captain prompt, and adjacent notifications coalesced into one intended processing turn. diff --git a/docs/calm.md b/docs/calm.md index 1018b818b9..816923c77b 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -15,6 +15,11 @@ Very narrow terminals fall back to a smaller deterministic sprite. While Calm is off, Pi's stock working row is left exactly as Pi renders it. Calm hides collapsed thinking labels, the shells for Pi's seven built-in tools, the `fm_watch_arm_pi` tool shell, and canonically classified Firstmate operational user rows. The operational inputs remain ordinary user-role messages, while Pi's transcript layout renders their complete rows at zero height. +Calm also hides the exact whole assistant text `Captain, shipshape.` only when the assistant component belongs to an agent run whose every Firstmate input is canonically classified operational. +A genuine captain message anywhere in that run, including one steered in while the run is still under way, keeps every later reply in the run visible. +The same text after a genuine captain message, any near match, any tool-calling response, any interrupted or failed response, and every substantive operational response remain visible. +During streaming, Calm holds only text that is still a prefix of the exact acknowledgement and renders it immediately if the stream diverges, preventing an acknowledgement flash without withholding a disambiguated reply. +A transcript replay or rebuild, including the rebuild Pi performs when it compacts inside a run, scores each replayed row against its own preceding input and leaves the surrounding run unchanged, so an acknowledgement hidden before the rebuild stays hidden after it. The session-start nudge remains on its existing non-displayed custom-message path. Calm changes presentation only. @@ -30,12 +35,13 @@ These are supported-API boundaries rather than hidden-content failures. ## Pi compatibility Calm has no numeric Pi version minimum or maximum and never refuses Pi solely because its version is newer than a previously verified version. -The collapsed-thinking and operational-user-row presentation adapters probe the exact Pi API seam they patch when Calm loads. -If Pi removes one of those seams, Calm logs a diagnostic naming the unavailable adapter and skips only that adapter; `/calm`, the other adapter, and unrelated Pi extensions remain available. +The collapsed-thinking, operational-user-row, and transcript-replay presentation adapters each probe the exact Pi API seam they patch when Calm loads. +If Pi removes one of those seams, Calm logs a diagnostic naming the unavailable adapter and skips only that adapter; `/calm`, the other adapters, and unrelated Pi extensions remain available. +Losing only the transcript-replay adapter keeps operational user rows, acknowledgement origin, and every other Calm rule active, and a rebuild inside a run then only makes more replies visible. [`calm-mode-feasibility.md`](calm-mode-feasibility.md) owns the version-scoped renderer taxonomy and empirical evidence. [`configuration.md`](configuration.md#pi-calm-preference-configcalm) owns the persisted preference file and resolution rules. -`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row adapter, and `.pi/extensions/lib/fm-calm-working-ship.ts` owns the animated working presentation. +`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row, the assistant-origin association, and the separately probed transcript replay window, `.pi/extensions/lib/fm-calm-assistant-layout.ts` owns collapsed-thinking and exact-acknowledgement layout, and `.pi/extensions/lib/fm-calm-working-ship.ts` owns the animated working presentation. Regression entry points: diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index f1109e787e..3bb70a4af5 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -329,6 +329,7 @@ const operational = await import("./.pi/extensions/lib/fm-calm-operational-user- for (const [name, install, expected] of [ ["collapsed-thinking", assistant.installCalmAssistantLayout, "AssistantMessageComponent"], ["operational-user-row", operational.installCalmOperationalUserLayout, "InteractiveMode"], + ["transcript-replay-window", operational.installCalmTranscriptReplayWindow, "InteractiveMode"], ]) { let reason; try { @@ -347,7 +348,115 @@ JS status=$? [ "$status" -eq 0 ] || fail "Pi calm missing-adapter-export path failed: $out" [ -z "$out" ] || fail "Pi calm missing-adapter-export test printed output: $out" - pass "missing Pi presentation class exports reach the independent adapter degradation path" + + fixture="$TMP_ROOT/missing-replay-seam" + mkdir -p \ + "$fixture/project/.pi/extensions/lib" \ + "$fixture/project/node_modules/@earendil-works/pi-coding-agent" + cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" + printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" + printf '%s\n' \ + '{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}' \ + >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/package.json" + printf '%s\n' \ + 'export function getMarkdownTheme() { return {}; }' \ + 'export class UserMessageComponent {}' \ + 'export class InteractiveMode {}' \ + 'InteractiveMode.prototype.addMessageToChat = function () {};' \ + >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/index.js" + + out=$(cd "$fixture/project" && node --input-type=module 2>&1 <<'JS' +const operational = await import("./.pi/extensions/lib/fm-calm-operational-user-layout.ts"); +const { InteractiveMode } = await import("@earendil-works/pi-coding-agent"); + +const stockAddMessageToChat = InteractiveMode.prototype.addMessageToChat; +operational.installCalmOperationalUserLayout(); +if (InteractiveMode.prototype.addMessageToChat === stockAddMessageToChat) { + throw new Error( + "the missing transcript replay seam also disabled the shipped operational-user-row adapter", + ); +} + +let reason; +try { + operational.installCalmTranscriptReplayWindow(); +} catch (error) { + reason = error instanceof Error ? error.message : String(error); +} +if (!reason?.includes("renderSessionItems")) { + throw new Error( + `the transcript-replay adapter did not name its missing seam: ${String(reason)}`, + ); +} +if (typeof InteractiveMode.prototype.renderSessionItems !== "undefined") { + throw new Error( + "the transcript-replay adapter installed a wrapper despite the missing seam", + ); +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm missing-replay-seam path failed: $out" + [ -z "$out" ] || fail "Pi calm missing-replay-seam test printed output: $out" + + fixture="$TMP_ROOT/present-replay-seam" + mkdir -p \ + "$fixture/project/.pi/extensions/lib" \ + "$fixture/project/node_modules/@earendil-works/pi-coding-agent" + cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" + printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" + printf '%s\n' \ + '{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}' \ + >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/package.json" + printf '%s\n' \ + 'export function getMarkdownTheme() { return {}; }' \ + 'export class UserMessageComponent {}' \ + 'export class InteractiveMode {}' \ + 'InteractiveMode.prototype.addMessageToChat = function () {};' \ + 'InteractiveMode.prototype.renderSessionItems = function () { globalThis.stockReplayCalls += 1; };' \ + >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/index.js" + + out=$(cd "$fixture/project" && node --input-type=module 2>&1 <<'JS' +globalThis.stockReplayCalls = 0; +const operational = await import("./.pi/extensions/lib/fm-calm-operational-user-layout.ts"); +const { InteractiveMode } = await import("@earendil-works/pi-coding-agent"); + +const stockAddMessageToChat = InteractiveMode.prototype.addMessageToChat; +const stockRenderSessionItems = InteractiveMode.prototype.renderSessionItems; +operational.installCalmOperationalUserLayout(); +operational.installCalmTranscriptReplayWindow(); +if (InteractiveMode.prototype.addMessageToChat === stockAddMessageToChat) { + throw new Error("the operational-user-row adapter did not install with both seams present"); +} +if (InteractiveMode.prototype.renderSessionItems === stockRenderSessionItems) { + throw new Error("the transcript-replay adapter did not wrap the available seam"); +} + +operational.installCalmTranscriptReplayWindow(); +const wrappedOnce = InteractiveMode.prototype.renderSessionItems; +operational.installCalmTranscriptReplayWindow(); +if (InteractiveMode.prototype.renderSessionItems !== wrappedOnce) { + throw new Error("the transcript-replay adapter re-wrapped an already patched seam"); +} + +InteractiveMode.prototype.renderSessionItems.call({}, []); +if (globalThis.stockReplayCalls !== 1) { + throw new Error("the transcript-replay wrapper did not delegate to the stock replay path"); +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm present-replay-seam path failed: $out" + [ -z "$out" ] || fail "Pi calm present-replay-seam test printed output: $out" + pass "missing Pi presentation class exports and both transcript replay seam paths reach the independent adapter degradation path" } test_rendering_and_session_lifecycle() { @@ -704,10 +813,26 @@ if (!assistantThinkingText.render(100).join("\n").includes("Thinking...")) { } const assistantComponents = [assistantTextOnly, assistantThinkingText, assistantThinkingTool]; +const assistantMessage = (text, stopReason = "stop") => ({ + ...assistantBase, + content: [{ type: "text", text }], + stopReason, +}); +const addTranscriptUser = (content) => { + InteractiveMode.prototype.addMessageToChat.call( + { ...operationalMode, chatContainer: { children: [], addChild() {} } }, + { role: "user", content }, + ); +}; +const operationalAssistant = (content) => { + addTranscriptUser(watcherMessage); + return new AssistantMessageComponent(content, true); +}; let expanded = true; let editorText = ""; let terminalInputHandler; let workingVisible; +const layoutWidgets = new Map(); let hiddenThinkingLabel = "unset"; const statuses = new Map(); const sessionEntries = [{ type: "message", message: { role: "toolResult", content: "kept" } }]; @@ -739,6 +864,10 @@ const commandContext = { customRow.setExpanded(value); imageRow.setExpanded(value); }, + setWidget(key, factory) { + if (factory === undefined) layoutWidgets.delete(key); + else layoutWidgets.set(key, factory); + }, setWorkingVisible(value) { workingVisible = value; }, @@ -781,6 +910,221 @@ if (operationalComponent.render(100).length !== 0) { if (legacyOperationalComponent.render(100).length !== 0) { throw new Error("Calm left the supported bare-marker legacy user row visible"); } +const exactOperationalAckMessage = assistantMessage("Captain, shipshape."); +const exactOperationalAckBefore = JSON.stringify(exactOperationalAckMessage); +const exactOperationalAck = operationalAssistant(exactOperationalAckMessage); +if (exactOperationalAck.render(100).length !== 0) { + throw new Error("Calm rendered the exact no-action acknowledgement for an operational input"); +} +if (JSON.stringify(exactOperationalAckMessage) !== exactOperationalAckBefore) { + throw new Error("Calm changed the exact acknowledgement message used by context or persistence"); +} +const streamingOperationalAck = operationalAssistant(undefined); +for (const prefix of ["C", "Captain,", "Captain, shipshape", "Captain, shipshape."]) { + streamingOperationalAck.updateContent(assistantMessage(prefix, "pending")); + if (streamingOperationalAck.render(100).length !== 0) { + throw new Error(`Calm flashed an in-flight no-action acknowledgement prefix: ${prefix}`); + } +} +streamingOperationalAck.updateContent(assistantMessage("Captain, shipshape.")); +if (streamingOperationalAck.render(100).length !== 0) { + throw new Error("Calm rendered the finalized streamed no-action acknowledgement"); +} +const streamingNearMatch = operationalAssistant(undefined); +streamingNearMatch.updateContent(assistantMessage("Captain, shipshape.", "pending")); +if (streamingNearMatch.render(100).length !== 0) { + throw new Error("Calm flashed an exact acknowledgement before the stream was complete"); +} +streamingNearMatch.updateContent( + assistantMessage("Captain, shipshape. The queue still needs review.", "pending"), +); +if (!streamingNearMatch.render(100).join("\n").includes("queue still needs review")) { + throw new Error("Calm delayed a substantive reply after its stream diverged from the acknowledgement"); +} +addTranscriptUser("Captain-authored message"); +const humanCollision = new AssistantMessageComponent( + assistantMessage("Captain, shipshape."), + true, +); +if (!humanCollision.render(100).join("\n").includes("Captain, shipshape.")) { + throw new Error("Calm hid the exact phrase after a genuine human user message"); +} +if (!handlers.has("agent_start") || !handlers.has("agent_settled")) { + throw new Error("Calm did not register the run lifecycle handlers that scope acknowledgement origin"); +} +const fireRunLifecycle = async (event) => { + for (const handler of handlers.get(event)) await handler({}, commandContext); +}; +// Pi emits agent_start before the initiating user row and drains steering messages into +// the same run, so a wake steered into a captain turn must never hide replies in that run. +await fireRunLifecycle("agent_start"); +addTranscriptUser("Captain-authored request that opened this run"); +addTranscriptUser(watcherMessage); +const steeredIntoCaptainRun = new AssistantMessageComponent( + assistantMessage("Captain, shipshape."), + true, +); +if (!steeredIntoCaptainRun.render(100).join("\n").includes("Captain, shipshape.")) { + throw new Error("Calm hid a reply from a run that carried a genuine captain message"); +} +await fireRunLifecycle("agent_settled"); +await fireRunLifecycle("agent_start"); +addTranscriptUser(watcherMessage); +addTranscriptUser(watcherMessage); +const operationalOnlyRunAck = new AssistantMessageComponent( + assistantMessage("Captain, shipshape."), + true, +); +if (operationalOnlyRunAck.render(100).length !== 0) { + throw new Error("Calm rendered the acknowledgement for a run carrying only operational inputs"); +} +addTranscriptUser("Captain steers a real question into the operational run"); +const captainJoinedRunAck = new AssistantMessageComponent( + assistantMessage("Captain, shipshape."), + true, +); +if (!captainJoinedRunAck.render(100).join("\n").includes("Captain, shipshape.")) { + throw new Error("Calm hid a reply after a captain message joined an operational run"); +} +await fireRunLifecycle("agent_settled"); +addTranscriptUser(watcherMessage); +const nextRunOperationalAck = new AssistantMessageComponent( + assistantMessage("Captain, shipshape."), + true, +); +if (nextRunOperationalAck.render(100).length !== 0) { + throw new Error("Calm carried a settled captain run origin into the next operational wake"); +} +// Pi auto-compacts from inside the run and rebuilds the whole transcript through +// renderSessionItems before continuing, so replayed rows must keep per-row origin while +// the surrounding run scope survives untouched. +await fireRunLifecycle("agent_start"); +addTranscriptUser(watcherMessage); +const rebuiltChat = { + children: [], + addChild(component) { + this.children.push(component); + }, +}; +const rebuildMode = { + ...operationalMode, + addMessageToChat: InteractiveMode.prototype.addMessageToChat, + chatContainer: rebuiltChat, + hideThinkingBlock: true, + hiddenThinkingLabel: "", + pendingTools: new Map(), + settingsManager: { getShowCacheMissNotices: () => false }, + ui: { requestRender() {} }, +}; +InteractiveMode.prototype.renderSessionItems.call(rebuildMode, [ + { role: "user", content: "Captain-authored history entry" }, + assistantMessage("Captain, shipshape."), + { role: "user", content: [{ type: "text", text: watcherMessage }] }, + assistantMessage("Captain, shipshape."), +]); +const rebuiltAssistants = rebuiltChat.children.filter( + (child) => child instanceof AssistantMessageComponent, +); +if (rebuiltAssistants.length !== 2) { + throw new Error(`transcript rebuild fixture produced ${rebuiltAssistants.length} assistant rows`); +} +if (!rebuiltAssistants[0].render(100).join("\n").includes("Captain, shipshape.")) { + throw new Error("a transcript rebuild inside a run hid a reply that followed a captain row"); +} +if (rebuiltAssistants[1].render(100).length !== 0) { + throw new Error("a transcript rebuild inside a run revealed a previously hidden acknowledgement"); +} +const continuationAfterRebuild = new AssistantMessageComponent( + assistantMessage("Captain, shipshape."), + true, +); +if (continuationAfterRebuild.render(100).length !== 0) { + throw new Error("a transcript rebuild collapsed the operational origin of the surrounding run"); +} +await fireRunLifecycle("agent_settled"); +if (workingVisible !== true || layoutWidgets.size !== 0) { + throw new Error("run lifecycle regressions left the Calm working presentation unbalanced"); +} +const acknowledgementNearMatches = [ + "Captain, shipshape..", + "Note: Captain, shipshape.", + "Captain, shipshape. Thanks.", + "**Captain, shipshape.**", + "Captain, shipshape.\n\nAdditional explanation.", + "captain, shipshape.", + "Captain, shipshape.", + " Captain, shipshape.", + "Captain, shipshape. ", +]; +for (const nearMatch of acknowledgementNearMatches) { + const component = operationalAssistant(assistantMessage(nearMatch)); + if (!component.render(100).join("\n").includes(nearMatch.split("\n")[0].replaceAll("**", ""))) { + throw new Error(`Calm hid an acknowledgement near match: ${nearMatch}`); + } +} +const substantiveOperationalReply = operationalAssistant( + assistantMessage("Captain, the watcher failed and needs credentials."), +); +if (!substantiveOperationalReply.render(100).join("\n").includes("watcher failed")) { + throw new Error("Calm hid a substantive operational reply"); +} +addTranscriptUser(watcherMessage); +addTranscriptUser(watcherMessage); +const queuedOperationalAck = new AssistantMessageComponent( + assistantMessage("Captain, shipshape."), + true, +); +if (queuedOperationalAck.render(100).length !== 0) { + throw new Error("Calm rendered the acknowledgement after queued operational inputs"); +} +addTranscriptUser(watcherMessage); +const operationalToolTurn = new AssistantMessageComponent({ + ...assistantBase, + content: [{ type: "toolCall", id: "ack-tool", name: "read", arguments: { path: "sample.txt" } }], + stopReason: "toolUse", +}, true); +if (operationalToolTurn.render(100).length !== 0) { + throw new Error("tool-only operational assistant fixture unexpectedly rendered content"); +} +const postToolOperationalAck = new AssistantMessageComponent( + assistantMessage("Captain, shipshape."), + true, +); +if (postToolOperationalAck.render(100).length !== 0) { + throw new Error("Calm rendered the acknowledgement after intervening tool activity"); +} +const textAndToolOperationalReply = operationalAssistant({ + ...assistantBase, + content: [ + { type: "text", text: "Captain, shipshape." }, + { type: "toolCall", id: "ack-text-tool", name: "read", arguments: { path: "sample.txt" } }, + ], + stopReason: "toolUse", +}); +if (!textAndToolOperationalReply.render(100).join("\n").includes("Captain, shipshape.")) { + throw new Error("Calm hid assistant text from a tool-calling operational turn"); +} +const expandedReasoningWithOperationalAck = operationalAssistant({ + ...assistantBase, + content: [ + { type: "thinking", thinking: "VISIBLE_EXPANDED_OPERATIONAL_REASONING" }, + { type: "text", text: "Captain, shipshape." }, + ], +}); +expandedReasoningWithOperationalAck.setHideThinkingBlock(false); +const expandedReasoningRows = expandedReasoningWithOperationalAck.render(100).join("\n"); +if (!expandedReasoningRows.includes("VISIBLE_EXPANDED_OPERATIONAL_REASONING")) { + throw new Error("Calm acknowledgement hiding changed expanded reasoning visibility"); +} +if (expandedReasoningRows.includes("Captain, shipshape.")) { + throw new Error("Calm left the exact acknowledgement beside expanded reasoning"); +} +const interruptedOperationalAck = operationalAssistant(undefined); +interruptedOperationalAck.updateContent(assistantMessage("Captain,", "pending")); +interruptedOperationalAck.updateContent(assistantMessage("Captain,", "aborted")); +if (!interruptedOperationalAck.render(100).join("\n").includes("Operation aborted")) { + throw new Error("Calm hid an interrupted operational turn"); +} const operationalNearMisses = [ { content: `Captain quote: ${watcherMessage}`, @@ -944,6 +1288,10 @@ if (JSON.stringify(operationalComponent.render(100)) !== JSON.stringify(expected if (!legacyOperationalComponent.render(100).join("\n").includes("legacy presentation compatibility")) { throw new Error("turning Calm off did not restore the supported legacy operational row"); } +const calmOffOperationalAck = operationalAssistant(assistantMessage("Captain, shipshape.")); +if (!calmOffOperationalAck.render(100).join("\n").includes("Captain, shipshape.")) { + throw new Error("Calm off hid the operational no-action acknowledgement"); +} for (const { name, baseline, actual } of rows) { if (JSON.stringify(actual.render(100)) !== JSON.stringify(baseline.render(100))) { throw new Error(`${name} did not restore the expanded standard renderer`); @@ -984,6 +1332,10 @@ for (const reason of ["startup", "new", "resume", "fork", "reload"]) { if (workingVisible !== true || hiddenThinkingLabel !== "" || statuses.get("firstmate-calm") !== undefined) { throw new Error(`${reason} session did not retain gapless Calm presentation with native working visibility`); } + const replayedOperationalAck = operationalAssistant(assistantMessage("Captain, shipshape.")); + if (replayedOperationalAck.render(100).length !== 0) { + throw new Error(`${reason} session replay rendered the operational no-action acknowledgement`); + } } await calmCommand.handler("", commandContext);