diff --git a/.gitignore b/.gitignore index 36135cc148..ac8c7c41eb 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ config/tg-mode.env config/cmux-socket-password config/wedge-alarm config/herdr-presentation-spaces +.pi-live-e2e.* diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index f78c1b5acd..f67427c7a0 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -1,13 +1,13 @@ // Firstmate's home-persistent Pi transcript presentation toggle. // -// Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot -// renderers, renderShell: "self", session_start replacement reasons, +// Verified against Pi 0.81.1 through 0.82.1, which expose built-in ToolDefinitions, +// per-slot renderers, renderShell: "self", session_start replacement reasons, // ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and // setHiddenThinkingLabel(). The focused tests pin those assumptions but never reject a -// newer Pi solely for its version. The collapsed-thinking and operational-user -// presentation adapters probe the exact API they patch and degrade independently with a -// diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi -// still exposes no global renderer for arbitrary built-in or custom rows. +// newer Pi solely for its version. The assistant, tool-row, operational-user, and +// non-conversation row presentation adapters probe the exact API they patch and degrade +// independently with a diagnostic (see installCalmPresentationAdapter below) if a future Pi +// removes it. // docs/configuration.md owns the home-local Calm preference contract. import { randomUUID } from "node:crypto"; import { @@ -21,6 +21,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, + ExtensionContext, ToolDefinition, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; @@ -36,7 +37,20 @@ import { 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 { + installCalmBranchSummaryLayout, + installCalmCacheNoticeLayout, + installCalmCompactionSummaryLayout, + installCalmCustomEntryLayout, + installCalmCustomMessageLayout, + installCalmLeadingSpacerLayout, + installCalmSkillInvocationLayout, +} from "./lib/fm-calm-nonconversation-layout.ts"; import { installCalmOperationalUserLayout } from "./lib/fm-calm-operational-user-layout.ts"; +import { + installCalmToolErrorTurnBoundary, + installCalmToolLayout, +} from "./lib/fm-calm-tool-layout.ts"; import { calmPresentationHides, calmPresentationIsActive, @@ -87,9 +101,24 @@ function installCalmPresentationAdapter(name: string, install: () => void): void } } +function isTrustedInteractiveContext( + ctx: Pick, +): boolean { + return ctx.mode === "tui" && ctx.isProjectTrusted(); +} + export default function (pi: ExtensionAPI) { installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); + installCalmPresentationAdapter("tool-row", installCalmToolLayout); + installCalmPresentationAdapter("tool-error-turn", installCalmToolErrorTurnBoundary); installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); + installCalmPresentationAdapter("skill-invocation-row", installCalmSkillInvocationLayout); + installCalmPresentationAdapter("compaction-summary-row", installCalmCompactionSummaryLayout); + installCalmPresentationAdapter("branch-summary-row", installCalmBranchSummaryLayout); + installCalmPresentationAdapter("custom-message-row", installCalmCustomMessageLayout); + installCalmPresentationAdapter("custom-entry-row", installCalmCustomEntryLayout); + installCalmPresentationAdapter("cache-notice-row", installCalmCacheNoticeLayout); + installCalmPresentationAdapter("hidden-row-spacing", installCalmLeadingSpacerLayout); let exportRendering = false; let removeTerminalInputHandler: (() => void) | undefined; @@ -125,6 +154,17 @@ export default function (pi: ExtensionAPI) { stockExportRendering: exportRendering, }); }; + const redrawTranscript = (ctx: ExtensionContext): void => { + const expanded = ctx.ui.getToolsExpanded(); + ctx.ui.setToolsExpanded(!expanded); + ctx.ui.setToolsExpanded(expanded); + }; + const applyPresentation = (ctx: ExtensionContext, active: boolean): void => { + ctx.ui.setWorkingVisible(!active); + ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); + ctx.ui.setStatus("firstmate-calm", undefined); + redrawTranscript(ctx); + }; registerFirstmateSyntheticPresentation(pi); @@ -238,13 +278,16 @@ export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { exportRendering = false; - setCalmPresentation(loadCalmPreference()); setCalmStockExportRendering(false); - publishPresentationState(); - ctx.ui.setWorkingVisible(true); - ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined); - ctx.ui.setStatus("firstmate-calm", undefined); removeTerminalInputHandler?.(); + removeTerminalInputHandler = undefined; + + const trustedInteractive = isTrustedInteractiveContext(ctx); + setCalmPresentation(trustedInteractive && loadCalmPreference()); + publishPresentationState(); + if (!trustedInteractive) return; + + applyPresentation(ctx, calmPresentationIsActive()); removeTerminalInputHandler = ctx.ui.onTerminalInput((data) => { if (!getKeybindings().matches(data, "tui.input.submit")) return; @@ -264,9 +307,7 @@ export default function (pi: ExtensionAPI) { exportRendering = false; setCalmStockExportRendering(false); publishPresentationState(); - const expanded = ctx.ui.getToolsExpanded(); - ctx.ui.setToolsExpanded(!expanded); - ctx.ui.setToolsExpanded(expanded); + redrawTranscript(ctx); }, 0); }); }); @@ -274,17 +315,16 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("calm", { description: "Toggle Firstmate's supported conversation-only transcript presentation.", handler: async (_args, ctx) => { + if (!isTrustedInteractiveContext(ctx)) { + ctx.ui.notify("Calm is available only in a trusted interactive Pi session.", "warning"); + return; + } + const active = !calmPresentationIsActive(); persistCalmPreference(active); setCalmPresentation(active); publishPresentationState(); - ctx.ui.setWorkingVisible(true); - ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); - ctx.ui.setStatus("firstmate-calm", undefined); - - const expanded = ctx.ui.getToolsExpanded(); - ctx.ui.setToolsExpanded(!expanded); - ctx.ui.setToolsExpanded(expanded); + applyPresentation(ctx, active); }, }); } diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts index 33be71095e..ea62258ae2 100644 --- a/.pi/extensions/lib/fm-calm-assistant-layout.ts +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -1,5 +1,5 @@ -// Verified against Pi 0.81.1 and 0.82.0, which export AssistantMessageComponent with an -// updateContent method. installCalmAssistantLayout() probes that exact method and throws +// Verified against Pi 0.81.1 through 0.82.1, 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. import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; @@ -9,8 +9,6 @@ import { calmPresentationHides } from "./fm-calm-visibility.ts"; type AssistantMessage = Parameters[0]; type AssistantMessagePresentationState = { - hiddenThinkingLabel: string; - hideThinkingBlock: boolean; lastMessage?: AssistantMessage; }; @@ -49,10 +47,7 @@ export function installCalmAssistantLayout(): void { message: AssistantMessage, ): void { const state = this as unknown as AssistantMessagePresentationState; - const hideThinking = - state.hiddenThinkingLabel === "" && - state.hideThinkingBlock && - patch.hidesThinking(); + const hideThinking = patch.hidesThinking(); const presentationMessage = hideThinking ? { ...message, diff --git a/.pi/extensions/lib/fm-calm-nonconversation-layout.ts b/.pi/extensions/lib/fm-calm-nonconversation-layout.ts new file mode 100644 index 0000000000..76a5e9bca8 --- /dev/null +++ b/.pi/extensions/lib/fm-calm-nonconversation-layout.ts @@ -0,0 +1,308 @@ +// Verified against Pi 0.82.1, which exports +// SkillInvocationMessageComponent, CompactionSummaryMessageComponent, +// BranchSummaryMessageComponent, and CustomMessageComponent, and which builds every +// remaining non-conversation transcript row through InteractiveMode.addMessageToChat, +// InteractiveMode.addCustomEntryToChat, and InteractiveMode.addCacheMissNotice. Each +// installer below probes the exact export or prototype method it patches and throws if one +// is missing; fm-calm.ts catches that and skips only that adapter with a diagnostic instead +// of blocking Calm or Pi. +// +// These rows are policy-hidden in fm-calm-visibility.ts (skill-invocation, +// compaction-summary, branch-summary, custom-message, custom-entry, cache-notice) because +// they are neither a genuine captain prompt nor a normal assistant reply. A `!bash` row stays +// visible: the captain typed that command, so it is conversation. The adapters read +// that policy at render time, so a loaded transcript redraws immediately on /calm, stock +// export and share rendering keeps every row, and Calm off restores Pi's own output. +// +// A skill invocation carries the captain's own trailing message as a separate +// UserMessageComponent, which stays visible; only the expanded skill block is removed. +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; +import * as PiTui from "@earendil-works/pi-tui"; +import { calmPresentationHides, type CalmTranscriptClass } from "./fm-calm-visibility.ts"; + +type CalmRow = { + render(width: number): string[]; + invalidate(): void; + setExpanded?: (expanded: boolean) => void; +}; + +type CalmRowConstructor = new (...args: never[]) => CalmRow; + +type CalmComponentPatch = { + hides: () => boolean; +}; + +type CalmHostRowPatch = { + hides: () => boolean; +}; + +// Every render adapter publishes the class it hides here so the leading-spacer adapter can +// suppress the spacer Pi adds beside a hidden row without probing those exports twice, and +// so a render adapter that failed to install leaves its own spacer alone. +type CalmHiddenRowRegistry = { + classes: Map boolean }>; +}; + +type CalmChatContainer = { + children: CalmRow[]; +}; + +type InteractiveModeChat = { + chatContainer: CalmChatContainer; +}; + +// Keep the introduction-version symbols stable so a compatible upgrade cannot double-patch +// a live process. +const CALM_HIDDEN_ROW_CLASSES = Symbol.for("firstmate:calm-hidden-row-classes:pi-0.82.1"); +const CALM_LEADING_SPACER_PATCH = Symbol.for("firstmate:calm-leading-spacer:pi-0.82.1"); +const CALM_CUSTOM_ENTRY_PATCH = Symbol.for("firstmate:calm-custom-entry:pi-0.82.1"); +const CALM_CACHE_NOTICE_PATCH = Symbol.for("firstmate:calm-cache-notice:pi-0.82.1"); + +function calmRegistry(key: symbol): { get: () => T | undefined; set: (value: T) => void } { + const registry = globalThis as typeof globalThis & { [entry: symbol]: T | undefined }; + return { + get: () => registry[key], + set: (value: T) => { + registry[key] = value; + }, + }; +} + +function calmHiddenRowClasses(): CalmHiddenRowRegistry { + const registry = calmRegistry(CALM_HIDDEN_ROW_CLASSES); + let entry = registry.get(); + if (!entry) { + entry = { classes: new Map() }; + registry.set(entry); + } + return entry; +} + +function calmHiddenRowExportName(row: CalmRow): string | undefined { + for (const [exportName, entry] of calmHiddenRowClasses().classes) { + if (row instanceof entry.rowClass) return exportName; + } + return undefined; +} + +// The registry lives on globalThis, so reading the row policy by export name on every render keeps +// a spacer wrapped by an earlier module instance following the live policy after a reload. +function calmRegisteredRowHides(exportName: string): boolean { + const entry = calmHiddenRowClasses().classes.get(exportName); + return entry ? entry.hides() : false; +} + +function calmConditionalRow(row: CalmRow, hides: () => boolean): CalmRow { + const wrapper: CalmRow = { + render: (width: number) => (hides() ? [] : row.render(width)), + invalidate: () => { + row.invalidate(); + }, + }; + if (typeof row.setExpanded === "function") { + wrapper.setExpanded = (expanded: boolean) => row.setExpanded?.(expanded); + } + return wrapper; +} + +// Replaces the transcript rows a host method just contributed - appended, or spliced in ahead +// of a streaming component - with Calm-aware wrappers, so the same rows redraw on each toggle. +function wrapAddedRows( + chat: CalmChatContainer, + before: ReadonlySet, + hides: () => boolean, +): void { + const { children } = chat; + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (!child || before.has(child)) continue; + children[index] = calmConditionalRow(child, hides); + } +} + +function installCalmRowRender( + exportName: string, + itemClass: CalmTranscriptClass, + patchKey: symbol, +): void { + const registry = calmRegistry(patchKey); + const hides = (): boolean => calmPresentationHides(itemClass); + const exported = (PiCodingAgent as unknown as Record)[exportName]; + if (typeof exported !== "function") { + throw new Error(`Firstmate Calm requires Pi ${exportName}`); + } + const rowClass = exported as unknown as CalmRowConstructor; + + const installed = registry.get(); + if (installed) { + installed.hides = hides; + calmHiddenRowClasses().classes.set(exportName, { rowClass, hides }); + return; + } + + const prototype = rowClass.prototype as unknown as Record; + const originalRender = prototype.render; + if (typeof originalRender !== "function") { + throw new Error(`Firstmate Calm requires Pi ${exportName}.render`); + } + const render = originalRender as (this: CalmRow, width: number) => string[]; + + const patch: CalmComponentPatch = { hides }; + prototype.render = function (this: CalmRow, width: number): string[] { + if (patch.hides()) return []; + return render.call(this, width); + }; + + registry.set(patch); + calmHiddenRowClasses().classes.set(exportName, { rowClass, hides }); +} + +export function installCalmSkillInvocationLayout(): void { + installCalmRowRender( + "SkillInvocationMessageComponent", + "skill-invocation", + Symbol.for("firstmate:calm-skill-invocation-layout:pi-0.82.1"), + ); +} + +export function installCalmCompactionSummaryLayout(): void { + installCalmRowRender( + "CompactionSummaryMessageComponent", + "compaction-summary", + Symbol.for("firstmate:calm-compaction-summary-layout:pi-0.82.1"), + ); +} + +export function installCalmBranchSummaryLayout(): void { + installCalmRowRender( + "BranchSummaryMessageComponent", + "branch-summary", + Symbol.for("firstmate:calm-branch-summary-layout:pi-0.82.1"), + ); +} + +export function installCalmCustomMessageLayout(): void { + installCalmRowRender( + "CustomMessageComponent", + "custom-message", + Symbol.for("firstmate:calm-custom-message-layout:pi-0.82.1"), + ); +} + +// Pi adds a standalone Spacer beside a compaction summary, a branch summary, and a skill +// invocation, so hiding the row alone would leave a stray blank line. That spacer is replaced +// with a Calm-aware one that follows the row it belongs to. Rows that own their spacer, such +// as custom messages, need no adjustment. +export function installCalmLeadingSpacerLayout(): void { + const registry = calmRegistry<{ installed: true }>(CALM_LEADING_SPACER_PATCH); + if (registry.get()) return; + + const Spacer = PiTui.Spacer; + if (typeof Spacer !== "function") { + throw new Error("Firstmate Calm requires Pi TUI Spacer"); + } + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } + const prototype = InteractiveMode.prototype as unknown as Record; + const originalAddMessageToChat = prototype.addMessageToChat; + if (typeof originalAddMessageToChat !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode.addMessageToChat"); + } + const addMessageToChat = originalAddMessageToChat as ( + this: InteractiveModeChat, + message: unknown, + options?: unknown, + ) => void; + + prototype.addMessageToChat = function ( + this: InteractiveModeChat, + message: unknown, + options?: unknown, + ): void { + const before = this.chatContainer.children.length; + addMessageToChat.call(this, message, options); + const { children } = this.chatContainer; + for (let index = before; index < children.length - 1; index += 1) { + const spacer = children[index]; + const row = children[index + 1]; + if (!spacer || !row || !(spacer instanceof Spacer)) continue; + const exportName = calmHiddenRowExportName(row); + if (!exportName) continue; + children[index] = calmConditionalRow(spacer, () => calmRegisteredRowHides(exportName)); + } + }; + + registry.set({ installed: true }); +} + +// Custom session entries from unrelated extensions render through a host component Pi does +// not export, so the rows themselves are wrapped as the host contributes them. +export function installCalmCustomEntryLayout(): void { + const registry = calmRegistry(CALM_CUSTOM_ENTRY_PATCH); + const hides = (): boolean => calmPresentationHides("custom-entry"); + const installed = registry.get(); + if (installed) { + installed.hides = hides; + return; + } + + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } + const prototype = InteractiveMode.prototype as unknown as Record; + const originalAddCustomEntryToChat = prototype.addCustomEntryToChat; + if (typeof originalAddCustomEntryToChat !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode.addCustomEntryToChat"); + } + const addCustomEntryToChat = originalAddCustomEntryToChat as ( + this: InteractiveModeChat, + entry: unknown, + ) => void; + + const patch: CalmHostRowPatch = { hides }; + prototype.addCustomEntryToChat = function (this: InteractiveModeChat, entry: unknown): void { + const before = new Set(this.chatContainer.children); + addCustomEntryToChat.call(this, entry); + wrapAddedRows(this.chatContainer, before, () => patch.hides()); + }; + + registry.set(patch); +} + +// Cache-miss notices are plain host text rather than a component class, so the adapter wraps +// exactly the rows this one host method contributes instead of touching shared text rows. +export function installCalmCacheNoticeLayout(): void { + const registry = calmRegistry(CALM_CACHE_NOTICE_PATCH); + const hides = (): boolean => calmPresentationHides("cache-notice"); + const installed = registry.get(); + if (installed) { + installed.hides = hides; + return; + } + + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } + const prototype = InteractiveMode.prototype as unknown as Record; + const originalAddCacheMissNotice = prototype.addCacheMissNotice; + if (typeof originalAddCacheMissNotice !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode.addCacheMissNotice"); + } + const addCacheMissNotice = originalAddCacheMissNotice as ( + this: InteractiveModeChat, + miss: unknown, + ) => void; + + const patch: CalmHostRowPatch = { hides }; + prototype.addCacheMissNotice = function (this: InteractiveModeChat, miss: unknown): void { + const before = new Set(this.chatContainer.children); + addCacheMissNotice.call(this, miss); + wrapAddedRows(this.chatContainer, before, () => patch.hides()); + }; + + registry.set(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..31878ca166 100644 --- a/.pi/extensions/lib/fm-calm-operational-user-layout.ts +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -1,4 +1,4 @@ -// Verified against Pi 0.81.1 and 0.82.0, which add the ordinary-user spacer and row +// Verified against Pi 0.81.1 through 0.82.1, 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 diff --git a/.pi/extensions/lib/fm-calm-tool-layout.ts b/.pi/extensions/lib/fm-calm-tool-layout.ts new file mode 100644 index 0000000000..a078665be1 --- /dev/null +++ b/.pi/extensions/lib/fm-calm-tool-layout.ts @@ -0,0 +1,269 @@ +// Verified against Pi 0.82.1, which exports ToolExecutionComponent with declared render +// and updateResult methods and pi-tui's visibleWidth, truncateToWidth, and +// wrapTextWithAnsi column helpers. installCalmToolLayout() probes those exact members and +// throws if one is missing; fm-calm.ts catches that and skips only this adapter with a +// diagnostic instead of blocking Calm or Pi. It changes only transcript presentation and +// never tool execution. +// +// Pi routes an assistant turn's abort and provider-failure text exclusively through the +// tool row whenever that turn contains a tool call +// (AssistantMessageComponent.updateContent skips its own error branch when hasToolCalls is +// true, and InteractiveMode attaches the stop-reason text to every pending tool row on both +// the live and rebuilt-transcript paths). Only that turn-level class is actionable for the +// captain, so Calm surfaces an errored row's text only while its assistant turn stopped on +// "aborted" or "error" and keeps every routine failure - a non-zero bash exit, an unmatched +// edit, a missing read - hidden with the rest of the tool row. Visible lines are measured in +// terminal columns because pi-tui treats an over-wide rendered line as a fatal render error. +import type { + AssistantMessageComponent as PiAssistantMessageComponent, + ToolExecutionComponent as PiToolExecutionComponent, +} from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; +import * as PiTui from "@earendil-works/pi-tui"; +import { calmPresentationHides } from "./fm-calm-visibility.ts"; + +type CalmToolLayoutPatch = { + hidesToolRows: () => boolean; +}; + +type CalmErrorTurn = { + component: object | undefined; + actionable: boolean; + owners: Map; +}; + +type CalmTurnBoundaryPatch = { + turn: CalmErrorTurn; +}; + +type CalmToolResult = { + content?: Array<{ type?: string; text?: string }>; + isError?: boolean; +}; + +type CalmColumnHelpers = { + visibleWidth: (text: string) => number; + truncateToWidth: (text: string, maxWidth: number, ellipsis?: string) => string; + wrapTextWithAnsi: (text: string, width: number) => string[]; +}; + +// Keep the introduction-version symbol stable so a compatible upgrade cannot +// double-patch a live process. +const CALM_TOOL_LAYOUT_PATCH = Symbol.for( + "firstmate:calm-tool-layout:pi-0.82.1", +); +const CALM_TOOL_ERROR_TURN_PATCH = Symbol.for( + "firstmate:calm-tool-error-turn:pi-0.82.1", +); + +const CALM_ERROR_MAX_LINES = 6; +// The only stop reasons Pi fans out to tool rows as turn-level failure text. +const CALM_ACTIONABLE_STOP_REASONS = new Set(["aborted", "error"]); +const ANSI_SEQUENCE = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; +const TAB_COLUMNS = " "; + +const calmErrorTexts = new WeakMap(); + +// The turn scope lives on the shared registry entry, never in module scope: Pi re-evaluates +// this module on every extension reload while the prototype wrappers installed by the first +// evaluation survive, so any module-scoped copy would diverge from the live one. +function calmTurnScope(): CalmTurnBoundaryPatch | undefined { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmTurnBoundaryPatch | undefined; + }; + return registry[CALM_TOOL_ERROR_TURN_PATCH]; +} + +function calmEmptyTurn(): CalmErrorTurn { + return { component: undefined, actionable: false, owners: new Map() }; +} + +function calmActionableTurn(message: { stopReason?: string } | undefined): boolean { + return CALM_ACTIONABLE_STOP_REASONS.has(message?.stopReason ?? ""); +} + +function calmResultText(result: CalmToolResult): string { + return (result.content ?? []) + .filter((block) => block?.type === "text") + .map((block) => (block.text ?? "").replace(ANSI_SEQUENCE, "").replace(/\r/g, "")) + .join("\n") + .trim(); +} + +// A row's error text is actionable only while its assistant turn stopped on "aborted" or +// "error"; every other errored result is a routine per-tool failure the agent handles itself, +// so it stays hidden with the rest of the row. Pi copies one actionable text onto every +// pending row of that turn, so the first row to record a given text within the turn owns it +// and identical siblings stay silent while a genuinely distinct text still gets its own row. +// Without the turn-boundary adapter no turn can be classified, so Calm stays conversation-only +// rather than guessing that a routine failure needs the captain. +function calmActionableErrorOwner(text: string, component: object): object | undefined { + const scope = calmTurnScope(); + if (!scope || !scope.turn.actionable) return undefined; + const owner = scope.turn.owners.get(text); + if (owner) return owner; + scope.turn.owners.set(text, component); + return component; +} + +function calmErrorLines( + component: object, + width: number, + columns: CalmColumnHelpers, +): string[] { + if (width <= 0) return []; + const text = calmErrorTexts.get(component); + if (!text) return []; + + const pad = width >= 2 ? " " : ""; + const usable = Math.max(1, width - pad.length); + const clamp = (line: string): string => { + if (!line) return ""; + const fitted = + columns.visibleWidth(line) > usable + ? columns.truncateToWidth(line, usable, "") + : line; + return `${pad}${fitted}`; + }; + + const wrapped: string[] = []; + for (const logical of text.split("\n")) { + if (!logical) { + wrapped.push(""); + continue; + } + for (const line of columns.wrapTextWithAnsi( + logical.replace(/\t/g, TAB_COLUMNS), + usable, + )) { + wrapped.push(line); + } + } + + const skipped = Math.max(0, wrapped.length - CALM_ERROR_MAX_LINES); + const shown = skipped > 0 ? wrapped.slice(-CALM_ERROR_MAX_LINES) : wrapped; + const lines = shown.map(clamp); + if (skipped > 0) { + lines.unshift( + clamp(`... ${skipped} earlier error line${skipped === 1 ? "" : "s"} hidden`), + ); + } + return ["", ...lines]; +} + +function requireColumnHelpers(): CalmColumnHelpers { + const { truncateToWidth, visibleWidth, wrapTextWithAnsi } = PiTui; + if ( + typeof visibleWidth !== "function" || + typeof truncateToWidth !== "function" || + typeof wrapTextWithAnsi !== "function" + ) { + throw new Error( + "Firstmate Calm requires Pi TUI visibleWidth, truncateToWidth, and wrapTextWithAnsi", + ); + } + return { truncateToWidth, visibleWidth, wrapTextWithAnsi }; +} + +// Pi builds exactly one AssistantMessageComponent per assistant message, on the live +// streaming path and once per replayed history message, and calls updateContent on it +// repeatedly: from the constructor, from every streaming delta, from message_end once the +// stop reason is known, and again from invalidate(), setHideThinkingBlock(), +// setHiddenThinkingLabel(), and setOutputPad() - the last of which Calm itself triggers. +// So the reset is keyed on the calling component rather than the call: a new component opens +// a new turn, and every repeat call on the same component only refreshes that turn's stop +// reason, leaving established ownership intact. Pi never interleaves those repeats with a +// turn's updateResult batch, which stays synchronous on both paths. +export function installCalmToolErrorTurnBoundary(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmTurnBoundaryPatch | undefined; + }; + const installed = registry[CALM_TOOL_ERROR_TURN_PATCH]; + if (installed) { + installed.turn = calmEmptyTurn(); + return; + } + + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent"); + } + const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; + if (typeof originalUpdateContent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent.updateContent"); + } + + AssistantMessageComponent.prototype.updateContent = function ( + this: PiAssistantMessageComponent, + message: Parameters[0], + ): void { + const scope = calmTurnScope(); + if (scope) { + const actionable = calmActionableTurn(message); + if (scope.turn.component === this) { + scope.turn.actionable = actionable; + } else { + scope.turn = { component: this, actionable, owners: new Map() }; + } + } + originalUpdateContent.call(this, message); + }; + + registry[CALM_TOOL_ERROR_TURN_PATCH] = { turn: calmEmptyTurn() }; +} + +export function installCalmToolLayout(): void { + const registry = globalThis as typeof globalThis & { + [key: symbol]: CalmToolLayoutPatch | undefined; + }; + const hidesToolRows = (): boolean => + calmPresentationHides("assistant-tool-call") && + calmPresentationHides("tool-result"); + const installed = registry[CALM_TOOL_LAYOUT_PATCH]; + if (installed) { + installed.hidesToolRows = hidesToolRows; + return; + } + + const patch: CalmToolLayoutPatch = { hidesToolRows }; + const ToolExecutionComponent = PiCodingAgent.ToolExecutionComponent; + if (typeof ToolExecutionComponent !== "function") { + throw new Error("Firstmate Calm requires Pi ToolExecutionComponent"); + } + const originalRender = ToolExecutionComponent.prototype.render; + if (typeof originalRender !== "function") { + throw new Error("Firstmate Calm requires Pi ToolExecutionComponent.render"); + } + const originalUpdateResult = ToolExecutionComponent.prototype.updateResult; + if (typeof originalUpdateResult !== "function") { + throw new Error("Firstmate Calm requires Pi ToolExecutionComponent.updateResult"); + } + const columns = requireColumnHelpers(); + + ToolExecutionComponent.prototype.updateResult = function ( + this: PiToolExecutionComponent, + result: Parameters[0], + isPartial?: boolean, + ): void { + const errorResult = result as CalmToolResult; + const errorText = errorResult?.isError && !isPartial ? calmResultText(errorResult) : ""; + if (errorText && calmActionableErrorOwner(errorText, this) === this) { + calmErrorTexts.set(this, errorText); + } else { + calmErrorTexts.delete(this); + } + originalUpdateResult.call(this, result, isPartial); + }; + + ToolExecutionComponent.prototype.render = function ( + this: PiToolExecutionComponent, + width: number, + ): string[] { + if (patch.hidesToolRows()) { + return calmErrorLines(this, width, columns); + } + return originalRender.call(this, width); + }; + + registry[CALM_TOOL_LAYOUT_PATCH] = patch; +} diff --git a/.pi/extensions/lib/fm-calm-visibility.ts b/.pi/extensions/lib/fm-calm-visibility.ts index 27a03f04c1..d577b3a45f 100644 --- a/.pi/extensions/lib/fm-calm-visibility.ts +++ b/.pi/extensions/lib/fm-calm-visibility.ts @@ -28,10 +28,12 @@ export const CALM_TRANSCRIPT_CLASSES = [ export type CalmTranscriptClass = (typeof CALM_TRANSCRIPT_CLASSES)[number]; +// A `!bash` row is the captain's own typed command and the output they asked for, so Calm +// keeps it: Calm hides the agent's machinery, not a direct captain action. const CALM_VISIBLE_CLASSES = new Set([ "genuine-user-prompt", "genuine-agent-response", - "working-status", + "user-bash", ]); // Legacy session entries from Calm versions before 2026-07-23 retain this diff --git a/README.md b/README.md index dfa03cdaf8..08f4b0b1a5 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ FM_PI_HARNESS=pi-signed pi-signed For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. 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, while retaining native working activity and all model context and session data. +In a trusted interactive Pi TUI, `/calm` hides supported transcript chrome, including tool rows, Pi's working activity, and canonically classified Firstmate operational user rows, while retaining all model context and session data. The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. 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 b94b6a6aef..cf501bc605 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -1,289 +1,115 @@ # Calm-mode harness feasibility -This document owns the version-scoped feasibility evidence, Pi transcript taxonomy, and supported-API boundaries for Firstmate calm mode. -[`calm.md`](calm.md) owns the current user-facing `/calm` usage and limitation contract. - -## Required extension surface - -A qualifying implementation must auto-load from the trusted project, persist the toggle choice for the effective Firstmate home across Pi session starts and resumes, keep Pi's built-in working activity visible, emit no Calm status row, redraw already-rendered controllable rows, remove supported hidden rows without gaps, restore ordinary rendering, and leave delivery, tool execution, model context, session storage, export and share operation, diagnostics, and expansion state unchanged. -The governing presentation policy allows genuine original user prompts, genuine user-facing assistant text, and Pi's native working activity. -Changing persisted context to remove hidden content, filtering provider context, patching installed harness code, or claiming coverage outside a supported renderer does not satisfy that boundary. - -## Compatibility evidence - -[`calm.md`](calm.md#pi-compatibility) owns the current Pi compatibility contract. -Pi 0.81.1 was installed when Calm was first built, and Pi 0.82.0 was the later reverification target. -The inspected Pi CHANGELOG shows no relevant presentation API introduced at either version, so those versions remain verification evidence rather than compatibility bounds. -The exported classes used by the adapters (`AssistantMessageComponent` and `InteractiveMode`) are undocumented internals with no stated version guarantee. -`tests/fm-calm-pi-extension.test.sh` records the installed Pi version as evidence without gating on it and covers both newer synthetic versions and an unavailable adapter seam. - -## Pi 0.81.1 end-to-end reproduction - -The Pi version installed at the time was verified on 2026-07-22. - -```text -$ pi --version -0.81.1 -``` - -### Original transcript cleanup - -The pre-cleanup reproduction used a real isolated Pi TUI at 180 columns by 44 rows with the tracked Calm and watcher extensions, an isolated `FM_HOME`, and a live home-owned watcher cycle. -The model called `fm_watch_arm_pi`, the real tool returned `watcher: started Pi extension arm child 1`, and a `done:` status write caused the watcher extension to inject `FIRSTMATE WATCHER WAKE: signal: ...` followed by the stable drain instruction. -With Calm off, the captured transcript contained the genuine user prompt, the full watcher tool shell, the synthetic user-role wake, four collapsed `Thinking...` labels, built-in tool rows from wake handling, and the final assistant response. -With the pre-cleanup implementation's Calm mode on, the existing seven built-in tool rows disappeared, but the watcher tool shell, synthetic wake, and all four `Thinking...` labels remained. -The final screenshot-scale regression reproduced the same transcript after the cleanup and verified that Calm removed those remaining controlled rows while retaining the genuine prompt, a watcher-shaped genuine near-miss prompt, and the genuine assistant responses. - -The original proven comparison path was a built-in text tool. -Calm owned both of that tool's supported renderer slots and switched its shell to `renderShell: "self"`, so returning empty components removed the complete row and `setToolsExpanded` redrew existing tool components. -Adding supported empty renderer slots to a scratch copy of `fm_watch_arm_pi` likewise removed its row while the real watcher still started and the model still returned `PROBE_COMPLETE`. -Legacy synthetic presentation entries use `CustomEntryComponent`, whose host adds spacing only when its renderer returns content, so an undefined Calm renderer result removes the complete row and can later restore it through the ordinary expansion redraw. -The later duplicate-turn evidence below supersedes custom-message rerouting as an acceptable implementation for current operational input. - -### Hidden-block height regression - -The 2026-07-23 end-user-aligned reproduction used the installed Pi 0.81.1 TUI at 100 columns by 44 rows, an isolated project and `FM_HOME`, the real `/skill:ahoy` command path, and a deterministic provider that produced five thinking-bearing read calls, five tool results, final hidden thinking, and a visible final response. -With Calm on and Pi's thinking display collapsed, the completed turn left 14 empty rows between the visible collapsed `[skill] ahoy` content row and the first final assistant row. -With Calm off, the same sequence rendered all six `Thinking...` labels and all five read rows instead of an empty field. -A controlled baseline containing only the skill row and final response had two standard visible-row separators. -Adding one final thinking block increased that gap from two rows to four, while adding a tool call without a result or a completed tool call and result left it at two. -Removing only all six thinking blocks from the failing persisted session left all five tool calls and results intact and reduced the gap from 14 rows to the two-row baseline. -Enabling Pi's `terminal.clearOnShrink` on the unchanged failing session left the gap at 14 rows, which rules out stale terminal allocation as the cause. - -The initiating trigger was a non-empty thinking block in an assistant message that Pi rendered through `AssistantMessageComponent`. -The masking condition was the combination of Calm being active and Pi's thinking display being collapsed, because Calm replaced the visible label with an empty string while Calm off or explicit thinking expansion filled those rows with visible content. -The visible symptom was the large empty vertical field between the intentionally visible collapsed skill row and final assistant response. - -The earliest divergent layout path was `AssistantMessageComponent.updateContent`, before terminal differential rendering or tool-result composition. -Pi computed `hasVisibleContent` from the original thinking data and added a leading `Spacer` before applying the hidden-thinking presentation. -Pi then styled the empty label before constructing `Text`, so the resulting ANSI-only string occupied one rendered row, and a thinking block followed by assistant text also added its ordinary inter-block spacer. -Each thinking-only tool turn therefore retained two empty rows, while the final thinking-plus-text turn retained two extra rows beyond the final response's normal leading separator. -The proven tool path diverged through `ToolExecutionComponent`, where the Calm self-render shell returned zero lines for both call and result slots and contributed no residual height. - -The smallest counterfactual was the thinking-only removal from the same persisted session, which preserved the skill, tools, results, final response, session ordering, and terminal settings while eliminating every unwanted row. -The single-thinking, tool-call-only, tool-result, Calm-off, and `clearOnShrink` controls deliberately sought disconfirming evidence and isolated collapsed thinking layout from skill, tool, result, and terminal-cache candidates. -PR 927 made Calm persistent and described controlled rows as gapless while retaining a documented unsupported boundary for collapsed-thinking spacing. -PR 936 removed the unsafe operational-input reroute and preserved legacy zero-height entries but did not change assistant-message layout. - -The fix installs one idempotent presentation adapter, verified on Pi 0.81.1 through 0.82.0, on the exported `AssistantMessageComponent.updateContent` method. -The adapter probes for that exact method and, per the [compatibility contract](calm.md#pi-compatibility), degrades independently with a diagnostic rather than gating on a version number. -Only while Calm is active and Pi has collapsed thinking does the adapter pass a shallow thinking-free presentation copy into Pi's ordinary layout calculation, then retain the original message on the component for invalidation and thinking expansion. -The persisted assistant message, provider context, tool execution, export data, and expansion history remain unchanged. -Collapsed thinking-only assistant messages now render zero rows, thinking before visible assistant text adds no spacing beyond the text-only baseline, and expanding thinking still renders the original reasoning. - -The disconfirming checks deliberately retain supported boundaries. -An arbitrary third-party custom tool and a built-in read image remain visible because Pi exposes neither a global tool renderer nor image-row control. -Expanded thinking remains visible by design, while re-collapsing it returns to zero-height Calm presentation. -Ordinary user-role near misses remain visible, including quoted current markers, ASCII-only labels, unrelated text before a marker, unrelated text after U+2063, and image-bearing input. - -## Duplicate-turn regression and semantic boundary - -The captain-visible regression reproduced three consecutive times in a persisted Pi session under `~/.pi/agent/sessions/`. -Assistant `bb83873b` was followed by hidden custom input `9d087b52` and distinct duplicate assistant `f4232aa3`. -Assistant `3a388d8c` was followed by adjacent hidden custom inputs `e1914f28` and `cfdefb09` and distinct duplicate assistant `47c81eeb`. -Distinct provider response identifiers and signatures prove separate model turns rather than duplicate TUI paint. - -The initiating trigger was `pi.sendUserMessage(..., { deliverAs: "followUp" })` from the watcher or turn-end adapter after a captain-facing response. -The exposure condition was Calm's loaded `input` handler from commit `6db3b09`, which ran whether the persisted toggle was on or off, returned `handled`, replaced the user message with `pi.sendMessage`, and triggered a nested custom-message turn. -The visible symptom was a second assistant row repeating the prior captain answer. -The earliest persisted divergence was the operational entry type: Calm loaded produced `custom_message` with role `custom` before provider conversion, while Calm absent produced a normal `message` with role `user`. -The earliest lifecycle divergence was that the replacement path bypassed Pi's normal user-prompt processing after the `input` event. - -A native deterministic Pi TUI reproduction on landed PR 927 produced `CAPTAIN_VISIBLE_ANSWER` twice with Calm loaded and explicitly on, and produced the same duplicate with Calm loaded and explicitly off. -The same exact typed notification with Calm absent produced one captain answer followed by `MONITOR_NOTIFICATION_HANDLED`. -Removing only the input reroute from a scratch copy while leaving Calm loaded and on produced the same proven result and restored the operational entry to role `user`. -This is the smallest counterfactual and proves extension loading, not the active toggle, was the required exposure condition. -The extension-absent success path is evidence against an independent Pi-core duplicate-turn cause for the same sequence, but it does not claim Pi core could never contain a separate duplication bug. - -PR 936 removed Calm's semantic input handler and custom-message delivery path because Pi 0.81.1 exposes no supported ordinary-user renderer and that replacement duplicated model turns. -That correction preserved current operational input as an exact ordinary user-role message with its ordering and authority unchanged, but deliberately left the row visible until a presentation-only boundary was proven. -Legacy `firstmate-synthetic-input-presentation` entries remained renderable so existing sessions preserved their stored presentation and zero-height hidden-row behavior. - -## Operational user-row zero-height regression - -The 2026-07-23 end-user-aligned reproduction used the installed Pi 0.81.1 TUI at 160 columns by 36 rows, the tracked Calm extension persisted on, an isolated home and session directory, and a deterministic in-process provider. -The injected user message began with exact U+2063 plus `FIRSTMATE_OP:` and carried the watcher status path from the durable captain screenshot followed by the blank line and stable drain instruction. -The exact U+2063 bytes, both payload lines, user role, and ordering survived live delivery and process restart. -The provider observed one matching user message, returned `OPERATIONAL_PROCESSED occurrences=1`, and the session contained one matching user entry and one matching assistant entry. - -The failing viewport rendered the operational input as a five-cell-high user box on rows 1 through 5 and placed the assistant text on row 7 after Pi's normal assistant separator. -The same persisted session reproduced those coordinates after restart. -Calm off rendered the same user component geometry, proving the active toggle had no presentation effect on this path. -The initiating trigger was the exact watcher-generated user message. -The exposure condition was PR 936's safe ordinary-user delivery path combined with the absence of a user-row presentation adapter, not marker loss, event-source drift, failed classification, persistence, replay, or duplicate delivery. -The visible symptom was the complete two-line synthetic user box and its five rows of terminal height. - -The earliest meaningful layout divergence from proven hidden presentation entries was `InteractiveMode.addMessageToChat`. -Its ordinary-user branch added a leading `Spacer` when applicable and then a `UserMessageComponent`, whose `Box` contributes vertical padding around the three Markdown lines. -The legacy custom-entry path instead checks renderer content before mounting a transcript child, and the completed assistant-thinking fix removes hidden thinking before assistant layout. -Those behaviors have different owners and remain separate. - -The smallest counterfactual returned only from the transcript owner's ordinary-user branch for that exact watcher input. -The real Pi viewport moved the unchanged assistant text from row 7 to row 2, rendered no operational text, and still persisted one exact user entry and one exact response. -The leading cause would have been falsified if the row or height remained, the provider lost or duplicated the message, or the persisted role or bytes changed. -None occurred. - -The fix installs a separate idempotent presentation adapter, verified on Pi 0.81.1 through 0.82.0, on the exported `InteractiveMode.addMessageToChat` method. -The adapter probes for that exact method and, per the [compatibility contract](calm.md#pi-compatibility), degrades independently with a diagnostic rather than gating on a version number. -It delegates current recognition to `bin/fm-operational-input.sh`, adds only the evidence-backed bare-U+2063 `Supervisor escalate (` presentation compatibility shape, mounts a `UserMessageComponent` subclass that preserves Pi's stock row plus leading spacer while Calm is off, and returns zero rendered lines while Calm is on. -It never intercepts the input event, rewrites the message, changes its role, filters model context, or changes session data. -Messages containing an image are left on Pi's ordinary path even when their text equals an operational envelope because Firstmate's authoritative producers are text-only. - -A native exact-watcher run and its process-restart replay kept the neighboring assistant text at the two-row visible-only spacing while retaining one exact user entry and one processing response. -An adjacent two-notification run retained the same two-row neighboring-assistant coordinates, proving both operational components contributed zero height. -Calm off, an absent Calm preference, and an absent Calm extension retained ordinary rows. -The current exact marker and the narrow bare-U+2063 `Supervisor escalate (` compatibility shape hid under Calm, while quoted markers, ASCII `FIRSTMATE_OP:` without U+2063, ordinary text before the current marker, unrelated text after U+2063, and image-bearing input remained visible. - -## Central visibility and input policy - -`.pi/extensions/lib/fm-calm-visibility.ts` owns only the allowlist-style transcript presentation policy. -`bin/fm-operational-input.sh` owns current cross-language operational-input construction and parsing, while the thin Pi adapter lives at `.pi/extensions/lib/fm-operational-input.ts`. -Only `genuine-user-prompt`, `genuine-agent-response`, and `working-status` are policy-visible. -Every other audited class is policy-hidden when Pi exposes a supported presentation boundary, but semantic input is never transformed to enforce that preference. -The home-local persistence schema is owned by [`docs/configuration.md`](configuration.md#pi-calm-preference-configcalm). - -Current session-start, watcher, turn-end guard, away supervisor, and launch-brief inputs retain their versioned U+2063 static envelopes. -The established leading `[fm-from-firstmate]` plus U+2063 routing carrier remains current so running secondmate charters remain compatible. -An exact current static envelope remains sufficient provenance without nonce, source-authentication, replay-prevention, secondary-token, blocking, redaction, or private-retrieval machinery. -Calm classifies only at Pi's transcript-presentation owner through the canonical parser and never replaces, reorders, or weakens those messages. - -The session-start nudge already originates as a non-displayed custom message, so it remains on that existing path while retaining model context and session persistence. -Legacy Calm custom entries and messages remain in existing session artifacts, and their presentation entry still uses the supported zero-height renderer while active. -Cycling tool expansion and restoring its original value rebuilds controllable rows and leaves final `Ctrl+O` state unchanged. -Exported and shared HTML retain genuine user prompts, genuine assistant responses, current operational user messages, ordinary tool rendering, and the complete session artifact. -Serialized session data and Pi 0.81.1's sidebar tree also retain legacy hidden operational custom messages. - -## Complete currently reachable Pi transcript taxonomy - -The taxonomy was derived from Pi 0.81.1's installed public declarations, documentation, examples, `interactive-mode.js`, and its exported component implementations. -The test fixture enumerates every class below through the centralized policy, and the interactive fixture exercises the screenshot classes, current user-role operational input, and legacy synthetic presentation entries. - -| Policy class | Pi transcript path | Calm result (verified on Pi 0.81.1 through 0.82.0) | +This maintainer-verification record owns the version-scoped Pi renderer taxonomy, mechanism boundaries, and current empirical evidence for Firstmate Calm mode. +[`calm.md`](calm.md) owns current operator-facing behavior and usage. + +## Required presentation boundary + +A qualifying Calm implementation must auto-load from the trusted project and read the effective Firstmate home's persisted choice on every Pi session start. +While active, it must leave only genuine captain prompts, normal assistant replies, the captain's own `!bash` command rows with their output, interactive dialogs, and explicit errors that need a response in the visible conversation area. +It must remove thinking, complete tool rows and shells, tool images, Pi working activity, canonically classified Firstmate operational inputs, and the remaining non-conversation rows Pi renders, without changing their delivery or model context. +It must redraw loaded transcript rows, preserve tool expansion state, add no replacement status UI, restore stock rendering when disabled, and leave exports and shares complete. +Presentation must remain inactive in RPC, JSON, print, and untrusted contexts. + +## Implementation ownership + +`.pi/extensions/fm-calm.ts` is the single extension owner for Calm lifecycle, persistence integration, stock-export rendering, and `/calm`. +`.pi/extensions/lib/fm-calm-visibility.ts` owns the allowlist-style transcript policy. +`genuine-user-prompt`, `genuine-agent-response`, and `user-bash` are the policy-visible classes while Calm is active; `user-bash` is visible because a `!bash` row is the captain's direct action rather than agent machinery. +`.pi/extensions/lib/fm-calm-assistant-layout.ts` removes thinking blocks from the shallow presentation copy before Pi calculates assistant layout. +`.pi/extensions/lib/fm-calm-tool-layout.ts` returns zero rows from Pi's exported `ToolExecutionComponent` while Calm is active, which removes calls, results, framing, and image children together, and keeps only the width-clamped error text of a row whose assistant turn stopped on `aborted` or `error`. +Routine per-tool failures carry the same `isError` flag but are not turn-level failures, so they stay hidden with the rest of the row. +`.pi/extensions/lib/fm-calm-tool-layout.ts` also owns the tool-error turn boundary that classifies each assistant turn and scopes identical-error ownership to that turn, holding the turn state on the shared patch registry so an extension reload cannot split it from the surviving prototype wrappers. +`.pi/extensions/lib/fm-calm-operational-user-layout.ts` renders canonically classified text-only Firstmate operational user rows at zero height. +`.pi/extensions/lib/fm-calm-nonconversation-layout.ts` renders the remaining non-conversation rows at zero height through one probed seam each: `SkillInvocationMessageComponent`, `CompactionSummaryMessageComponent`, `BranchSummaryMessageComponent`, and `CustomMessageComponent` prototype renders, plus `InteractiveMode.addCustomEntryToChat` and `InteractiveMode.addCacheMissNotice` for the rows Pi builds without an exported class. +It also replaces the standalone spacer `InteractiveMode.addMessageToChat` adds beside a compaction summary, a branch summary, or a skill invocation, so a hidden row leaves no blank line; a render adapter that failed to install keeps its own spacer. +Each replaced spacer resolves its row's hide policy from the shared registry by export name on every render, so a spacer wrapped by an earlier module instance still follows the live policy after an extension reload. +`bin/fm-operational-input.sh` remains the single owner of operational-input construction and parsing. +The seven built-in definitions and `fm_watch_arm_pi` retain per-renderer zero-height behavior as an independent fallback if the complete tool-row adapter is unavailable. + +Every class adapter is idempotent and probes its exact Pi export and prototype method before patching. +The tool-error turn boundary uses `AssistantMessageComponent.updateContent`, which Pi calls at least once per assistant message before any of that message's tool rows receive results, and repeatedly afterwards from streaming deltas, `message_end`, `invalidate()`, `setHideThinkingBlock()`, `setHiddenThinkingLabel()`, and `setOutputPad()`. +The reset is therefore idempotent per turn: it keys on the calling component, so a new component opens a turn while every repeat call on the same component only refreshes that turn's stop reason and leaves established ownership intact. +While that seam is unavailable no turn can be classified, so tool rows stay conversation-only instead of surfacing routine failures. +A missing seam produces one adapter-specific diagnostic and leaves the remaining Calm behavior and unrelated Pi extensions operational. +The adapters consult presentation state at render time, so the patches are inert outside a trusted interactive TUI and while Calm is off. +No input event is intercepted, no message role is rewritten, and no provider context is filtered. + +## Current transcript taxonomy + +| Policy class | Pi transcript path | Calm result verified through Pi 0.82.1 | | --- | --- | --- | -| `genuine-user-prompt` | `UserMessageComponent` | Visible, including every tested operational near miss. | +| `genuine-user-prompt` | `UserMessageComponent` | Visible, including operational-marker near misses. | | `genuine-agent-response` | Assistant text in `AssistantMessageComponent` | Visible. | -| `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. | -| `tool-image` | Image children appended outside tool renderer slots | Unsupported boundary; remains visible. | -| `user-bash` | `BashExecutionComponent` for `!` and `!!` | Unsupported boundary; remains visible. | -| `skill-invocation` | `SkillInvocationMessageComponent` plus parsed user text | Unsupported boundary; remains visible. | -| `custom-message` | `CustomMessageComponent` when `display` is true | The session-start nudge and legacy Calm context messages use `display: false`; arbitrary extension messages remain an unsupported boundary. | -| `custom-entry` | `CustomEntryComponent` with a registered renderer | Legacy Calm presentation entries rebuild to zero children without a residual spacer and restore through ordinary expansion redraw when mounted; arbitrary extension entries remain an unsupported boundary. | -| `compaction-summary` | `CompactionSummaryMessageComponent` | Unsupported boundary; remains visible. | -| `branch-summary` | `BranchSummaryMessageComponent` | Unsupported boundary; remains visible. | -| `working-status` | `WorkingStatusIndicator` | Visible through Pi's unchanged built-in row while Calm is active. | -| `command-status` | Interactive command result and status rows | Calm emits no enable notice, but generic Pi command rows remain an unsupported boundary. | -| `system-notice` | `showStatus`, `showError`, compaction, retry, and startup warning rows | Unsupported boundary; remains visible. | -| `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. | -| `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. -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 - -The original five-harness inspection was performed on 2026-07-22, with every integration surface rechecked and Pi reverified at 0.81.1 on 2026-07-23 for the latest Calm presentation change. - -```text -$ claude --version -2.1.218 (Claude Code) -$ codex --version -codex-cli 0.144.6 -$ opencode --version -1.17.18 -$ pi --version -0.81.1 -$ grok --version -grok 0.2.106 (bde89716f679) -``` - -| Harness | Conclusion | Evidence | -| --- | --- | --- | -| 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. | -| 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. -They do not claim that a harness can never add the missing renderer API. -For the duplicate-turn fix and the latest presentation change, the launch templates for Claude, Codex, OpenCode, Pi, and Grok and the watcher, turn-end, session-start, away-supervisor, and from-firstmate producers were re-inspected. -The canonical encoder and every non-Pi delivery path remain unchanged, and the tmux, Herdr, Zellij, Orca, and cmux runtime surfaces continue to transport the same input selected by the harness adapter. -Only Pi's Calm presentation implementation changed; every producer and non-Pi transport remains unchanged. +| `assistant-thinking` | Thinking content in `AssistantMessageComponent` | Zero height whether Pi's thinking display is collapsed or expanded. | +| `assistant-tool-call`, `tool-result`, `tool-image` | `ToolExecutionComponent` | Complete row is zero height for built-in and custom tools, including a routine per-tool failure such as a non-zero `bash` exit, an unmatched `edit`, or a missing `read`. A row whose assistant turn stopped on `aborted` or `error` keeps only that plain text, capped at six lines with an explicit hidden-line count, and identical text attached to several rows of the same turn is surfaced once, live and during a full synchronous history replay. | +| `working-status` | Pi working status indicator | Hidden through `ExtensionUIContext.setWorkingVisible(false)`. | +| `synthetic-user` | Firstmate session-start, watcher, turn-end, away-supervisor, from-firstmate, and launch-brief input | Exact user-role content and ordering are retained, while the TUI row is zero height. | +| Legacy Calm operational entries | Registered custom-entry renderer | Retained in session data and rendered at zero height. | +| Interactive dialogs | Extension and built-in focused UI | Visible. | +| Explicit errors and warnings | Pi status, assistant error rendering, and tool rows of an interrupted turn | Visible. Pi routes an aborted or provider-failed turn's text through its tool rows whenever that turn has a tool call, so Calm surfaces that text as plain lines instead of hiding it. Routine per-tool failures need no captain response and stay hidden. | +| `user-bash` | `BashExecutionComponent` | Visible, including the command header, borders, and streamed output, because the captain typed that command. | +| `skill-invocation` | `SkillInvocationMessageComponent` | Zero height collapsed or expanded, with its host spacer. The captain's own trailing message renders separately as `genuine-user-prompt` and stays visible. | +| `compaction-summary`, `branch-summary` | `CompactionSummaryMessageComponent`, `BranchSummaryMessageComponent` | Zero height with their host spacers. | +| `custom-message`, `custom-entry` | `CustomMessageComponent` and the host custom-entry component | Zero height for unrelated extensions, retained in session data. | +| `cache-notice` | `InteractiveMode.addCacheMissNotice` rows | Zero height on both the live and replayed paths. | +| `command-status` | Pi status text | Visible, because a command notice is a direct response to a captain action. | + +Stock HTML export and share rendering are enabled only around the matching terminal submit action and then presentation is redrawn immediately. +Serialized session entries are never modified by a presentation toggle. + +## Compatibility review + +Pi 0.82.1 additionally exposes the `SkillInvocationMessageComponent`, `CompactionSummaryMessageComponent`, `BranchSummaryMessageComponent`, and `CustomMessageComponent` exports and the `InteractiveMode.addMessageToChat`, `addCustomEntryToChat`, and `addCacheMissNotice` seams the non-conversation adapters patch, along with pi-tui's `Spacer` class the spacer adapter needs to recognize a standalone spacer. +Pi's `BashExecutionComponent` export is deliberately left unpatched, so no Calm adapter probes or installs on it. +Pi 0.81.1 introduced the evidence baseline, Pi 0.82.0 preserved the original assistant and operational-user seams, and Pi 0.82.1 preserves those seams plus the exported `ToolExecutionComponent.render`, `ToolExecutionComponent.updateResult`, and `AssistantMessageComponent.updateContent` seams used for complete tool-row suppression and its actionable-error surface, and pi-tui's `visibleWidth`, `truncateToWidth`, and `wrapTextWithAnsi` column helpers used to keep every emitted error line inside the terminal width Pi enforces. +Version strings are evidence rather than compatibility gates. +A future version with a missing method degrades only that adapter. + +The other supported primary harnesses do not load `.pi/extensions/`, so this change is not applicable to their transcript rendering. +The tmux, Herdr, Zellij, Orca, and cmux runtime transports continue to deliver the same operational input because Calm changes only Pi component rendering. +Watcher, turn-end, session-start, away-supervisor, and from-firstmate producers remain unchanged. ## 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. -It covers persisted preference restoration across every session-start reason and a real restart, proves Pi's native `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. -It asserts one persisted and rendered captain answer, exact user-role operational envelopes in order, no replacement custom messages, one processing result, zero operational transcript rows, and the two-row neighboring-assistant geometry for live, adjacent, and restart paths. -Quoted current markers, ASCII-only labels, ordinary text before a marker, unrelated U+2063 placement, and image-bearing input remain visible in component and native transcript checks. -`tests/fm-pi-primary-live-e2e.test.sh` also proves the unchanged built-in `Working...` row while Calm is active on the credentialed provider path before continuing its ordinary watcher lifecycle. -`tests/fm-pi-primary-types.test.sh` performs strict no-emit TypeScript checking against the installed Pi declarations, currently package version 0.81.1. - -The relevant commands are: - -```sh -tests/fm-calm-pi-extension.test.sh -FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh -tests/fm-pi-primary-types.test.sh -``` +`tests/fm-calm-pi-extension.test.sh` covers the centralized visibility policy, all seven built-ins, custom tool rows, image output, thinking in both Pi display states, working suppression, operational provenance, near misses, persistence, reload and restart redraws, trusted-interactive scoping, Calm-off restoration, exports, shares, and exact captain and assistant conversation preservation. +It also covers the non-conversation rows end to end: a hidden skill-invocation block whose captain message stays visible, and unrelated custom message and entry, compaction summary, and branch summary rows hidden with Calm on, restored with Calm off, and redrawn at zero height when Calm is re-enabled on an already loaded transcript, while the captain's own `!bash` command and its output stay visible through every toggle. +The cache-notice adapter is covered by the static seam contract and the independent-degradation fixture, because a real prompt-cache miss notice needs a credentialed provider. +`tests/fm-pi-primary-types.test.sh` performs strict no-emit checking against the installed Pi declarations when TypeScript is available. +`tests/fm-pi-primary-live-e2e.test.sh` keeps the credentialed provider and watcher integration path opt-in. -## 2026-07-23 verification record +## 2026-07-29 Pi 0.82.1 verification -The deterministic provider preserves the complete real Pi TUI rendering path without using credentials. -The credentialed live regression remains opt-in and was not required because this change does not alter watcher delivery or provider integration. +The deterministic suite exercises the real Pi TUI without provider credentials. +The credentialed live suite remains opt-in and was not required for this presentation-only change. ```text $ pi --version -0.81.1 +0.82.1 $ tests/fm-calm-pi-extension.test.sh -ok - Pi calm extension is presentation-only with one persisted visibility choice, no Calm status row, native working visibility, supported redraw controls, and the Firstmate watcher-tool integration +ok - Pi calm extension is presentation-only with one persisted visibility choice, no Calm status row, hidden working activity, supported redraw controls, and complete tool-row presentation ok - Pi calm resolves its persistent home independently of Pi's launch directory -ok - Pi calm centralizes transcript visibility, preserves execution/export data, keeps native working visible, and persists its choice across session starts +ok - Pi calm compatibility evidence never rejects a Pi version for being newer than 0.82.0, and still fails closed on a missing or malformed version +ok - a missing collapsed-thinking presentation API degrades only that Calm adapter with a clear skip reason, while the rest of Calm still registers +ok - missing Pi presentation class exports and error-surface seams reach the independent adapter degradation path +ok - reloading the Calm adapters keeps actionable tool errors scoped to one assistant turn without stacking Pi wrappers +ok - a hidden row's leading spacer follows the live Calm policy across extension reloads +ok - Pi calm centralizes transcript visibility, preserves execution/export data, hides working activity, and persists its choice across session starts ok - Pi operational follow-up E2E processes exact user-role notifications once while Calm hides current and adjacent rows, Calm off and absent render them, and restart preserves semantics -ok - Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering -ok - Pi calm native E2E keeps Working and captain turns visible, hides exact operational user rows without changing persistence, restores them Calm-off, survives restart, and preserves export plus Ctrl+O behavior +ok - Pi Calm native /skill:ahoy geometry keeps every thinking, tool, and skill-invocation block at zero height while preserving the captain's own message, history, restart, and Calm-off rendering +ok - Pi Calm renders unrelated custom message and entry and compaction and branch summary rows at zero height, keeps the captain's own !bash command and output visible, restores hidden rows Calm-off, and redraws loaded rows on each toggle +ok - Pi calm native E2E hides working activity, keeps captain turns visible, hides exact operational user rows without changing persistence, restores them Calm-off, survives restart, and preserves export plus Ctrl+O behavior -$ tests/fm-pi-primary-types.test.sh -ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1 +$ npm exec --yes --package=typescript -- bash -c 'tests/fm-pi-primary-types.test.sh' +ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.82.1 -$ bin/fm-lint.sh -fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) +$ bin/fm-doc-audience-check.sh +fm-doc-audience-check: ok surfaces=57 local_links=160 $ bin/fm-test-run.sh --changed --base origin/main -FM_TEST_SUMMARY total=38 failed=0 skipped_gate=7 duration_ms=166881 -FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=7 duration_ms=192 failed=0 -FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=31 duration_ms=165384 failed=0 - -$ tests/fm-pi-primary-live-e2e.test.sh -skip: set FM_PI_LIVE_E2E=1 to run the isolated interactive Pi regression -``` - -## 2026-07-26 Pi 0.82.0 compatibility verification - -Pi 0.82.0 preserved both API-probed presentation seams and every deterministic Calm TUI guarantee. -The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that earlier declaration-evidence version while the real CLI exercised 0.82.0. - -```text -$ pi --version -0.82.0 - -$ tests/fm-calm-pi-extension.test.sh -ok - Pi calm extension is presentation-only with one persisted visibility choice, no Calm status row, native working visibility, supported redraw controls, and the Firstmate watcher-tool integration -ok - Pi calm resolves its persistent home independently of Pi's launch directory -ok - Pi calm centralizes transcript visibility, preserves execution/export data, keeps native working visible, and persists its choice across session starts -ok - Pi operational follow-up E2E processes exact user-role notifications once while Calm hides current and adjacent rows, Calm off and absent render them, and restart preserves semantics -ok - Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering -ok - Pi calm native E2E keeps Working and captain turns visible, hides exact operational user rows without changing persistence, restores them Calm-off, survives restart, and preserves export plus Ctrl+O behavior - -$ tests/fm-pi-primary-types.test.sh -ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1 +FM_TEST_SUMMARY total=48 failed=0 skipped_gate=8 duration_ms=417838 +FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=7 duration_ms=193 failed=0 +FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=31 duration_ms=154446 failed=0 +FM_TEST_SUMMARY_FAMILY family=watcher-wake-lock count=10 duration_ms=261558 failed=0 ``` diff --git a/docs/calm.md b/docs/calm.md index 8d63b6d0b5..f80b64bc82 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -1,32 +1,41 @@ # Pi Calm mode -Calm is a Pi-only conversation presentation toggle. -It is off by default, and the last `/calm` choice persists for the effective Firstmate home across Pi session starts and resumes. - -While Calm is active, Pi's built-in `Working...` activity remains visible and no separate Calm status row is added. -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. -The session-start nudge remains on its existing non-displayed custom-message path. +Calm is Firstmate's Pi-only conversation presentation toggle. +The last `/calm` choice persists for the effective Firstmate home across Pi startup, reload, new-session, resume, and fork flows. +An absent or unrecognized preference remains off, while a home with `config/calm` set to `on` opens directly in Calm presentation. + +While Calm is active, Pi's transcript shows genuine captain prompts, normal assistant replies, and the captain's own `!bash` commands with their output. +It removes thinking blocks, tool call and result rows, tool images and shells, Pi's working row, canonically classified Firstmate operational user rows, and legacy Calm operational presentation entries. +The hidden operational kinds are session start, watcher, turn-end guard, away supervisor, from-firstmate routing, and launch briefs. +It also removes the remaining non-conversation transcript rows: skill-invocation blocks, compaction and branch summaries, prompt-cache miss notices, and custom messages and entries from unrelated extensions. +A `!bash` execution block is the captain's own typed command and the output they asked for, so Calm keeps it visible; Calm hides the agent's machinery, not a direct captain action. +A skill invocation keeps the captain's own message that accompanied it; only the expanded skill block is removed. +Calm adds no enable banner, footer chip, replacement status, or other presentation row. +Interactive dialogs and explicit Pi errors remain visible so the captain can respond. +A tool row whose assistant turn stopped on an abort or a provider failure keeps only that plain error text, so the failures that need a captain response stay visible without exposing routine tool call, result, image, or shell content. +Routine per-tool failures the agent handles itself, such as a non-zero `bash` exit, an unmatched `edit`, or a missing `read`, stay hidden with the rest of the row. +When one interrupted turn attaches the same text to several tool rows, Calm shows that message once and keeps distinct actionable errors separate, and each separately interrupted turn keeps its own message after a reload or resume. Calm changes presentation only. -Tool execution, input delivery, ordering, model context, session storage, diagnostics, and `/export` and `/share` operation remain unchanged. +Tool execution, operational input delivery, ordering, model context, session storage, diagnostics, and `/export` and `/share` data remain unchanged. Every hidden Firstmate input remains available to the model and in serialized session data and exported artifacts. -Legacy operational custom messages remain in session data and Pi's sidebar tree, although the main HTML transcript may omit them. -Toggling Calm off restores ordinary rendering, and `Ctrl+O` expansion state is preserved. +Toggling Calm off restores Pi's ordinary rendering, and the existing tool-expansion choice is preserved. -Pi's supported presentation API does not expose a global transcript filter. -Expanded reasoning and its reserved spacing, built-in tool images, user-bash rows, skill and summary rows, generic status notices, and arbitrary custom-tool or extension rows remain visible. -These are supported-API boundaries rather than hidden-content failures. +Calm presentation activates only in a trusted interactive Pi TUI. +RPC, JSON, print, and untrusted contexts keep stock presentation even when the home preference is on. +In those contexts `/calm` declines with a warning and leaves the stored preference unchanged. ## 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. +Pi 0.81.1 through 0.82.1 are current empirical evidence. +The assistant-thinking, complete-tool-row, tool-error-turn, operational-user-row, and non-conversation row adapters probe the exact exported Pi methods they patch, including the tool-row result seam and column helpers behind the actionable-error surface. +If Pi removes one of those seams, Calm logs a diagnostic naming the unavailable adapter and skips only that adapter while `/calm`, the remaining adapters, and unrelated Pi extensions continue to load. +The seven built-in tool renderers remain independently wrapped as a supported-API fallback for text tool rows if the complete-tool-row adapter is unavailable. [`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, and `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row adapter. +`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy. Regression entry points: diff --git a/docs/configuration.md b/docs/configuration.md index 61368d6394..132c658469 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,7 +28,7 @@ Ordinary dead-direct-report recovery is owned by `stuck-crewmate-recovery`, whil The Pi Calm extension stores the captain's home-local presentation choice in gitignored `config/calm` under the effective Firstmate home, resolved from `FM_HOME`, then `FM_ROOT_OVERRIDE`, then the tracked code root derived from the extension path, or under `FM_CONFIG_OVERRIDE` when that test and specialized-setup override is present. The only values it writes are `on` and `off`, each followed by one newline; an absent, unreadable, or unrecognized value defaults to off. The `/calm` command replaces the file atomically before changing live presentation, so a failed write leaves the current choice unchanged rather than claiming persistence. -The extension reloads this preference on every Pi `session_start`, including startup, new, resume, fork, and reload reasons. +The extension reloads this preference on every Pi `session_start` of a trusted interactive TUI, including startup, new, resume, fork, and reload reasons; other Pi modes keep stock presentation without consulting it, and [`calm.md`](calm.md) owns that scoping. This preference is local to each Firstmate home and is not part of secondmate inherited configuration. ## Backlog backend (.tasks.toml / config/backlog-backend) diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 46b945e23f..cde41b785b 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -9,6 +9,8 @@ TMP_ROOT=$(fm_test_tmproot fm-calm-pi-extension) EXT="$ROOT/.pi/extensions/fm-calm.ts" ASSISTANT_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" OPERATIONAL_USER_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" +TOOL_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-tool-layout.ts" +NONCONVERSATION_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" VISIBILITY="$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" WATCH_EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" OPERATIONAL_INPUT="$ROOT/bin/fm-operational-input.sh" @@ -16,7 +18,7 @@ PI_OPERATIONAL_INPUT="$ROOT/.pi/extensions/lib/fm-operational-input.ts" PI_PACKAGE_DIR=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"} TMUX_SOCKET="fm-calm-$$" TMUX_SESSION="fm-calm-e2e" -# Verified against Pi 0.81.1 and 0.82.0 (docs/calm-mode-feasibility.md). This is +# Verified against Pi 0.81.1 through 0.82.1 (docs/calm-mode-feasibility.md). This is # known-good evidence, not a support ceiling: the fixtures below run against whatever # Pi is actually installed, and record_pi_version_evidence never rejects a newer # version. The tracked presentation adapters probe the exact API they patch (see @@ -34,6 +36,54 @@ cleanup() { fm_test_cleanup } trap cleanup EXIT +mkdir -p "$TMP_ROOT" +FM_TEST_CLEANUP_DIRS+=("$TMP_ROOT") +cp "$OPERATIONAL_INPUT" "$TMP_ROOT/fm-operational-input.sh" +chmod +x "$TMP_ROOT/fm-operational-input.sh" + +# The E2E cases below reuse one tmux session name across sequential Pi launches. tmux refuses +# a duplicate name, and this file runs without `set -e`, so a launch that raced a still-exiting +# predecessor would silently keep driving the dying pane. Panes are kept after their command +# exits so each launch can wait for the previous Pi to be gone and assert it left cleanly first. +# A long-lived holder session keeps the tmux server up so the global window option is in place +# before any Pi window exists, rather than racing each launch. +TMUX_HOLDER_SESSION="fmhold-$$" +init_pi_tmux_server() { + if tmux -L "$TMUX_SOCKET" has-session -t "$TMUX_HOLDER_SESSION" 2>/dev/null; then + return 0 + fi + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_HOLDER_SESSION" 'sleep 100000' 2>/dev/null \ + || return 1 + tmux -L "$TMUX_SOCKET" set-option -wg remain-on-exit on 2>/dev/null || return 1 +} + +retire_pi_session() { + local context=$1 i=0 dead status + while [ "$i" -lt 400 ]; do + if ! tmux -L "$TMUX_SOCKET" has-session -t "$TMUX_SESSION" 2>/dev/null; then + return 0 + fi + dead=$(tmux -L "$TMUX_SOCKET" display-message -p -t "$TMUX_SESSION" '#{pane_dead}' 2>/dev/null || printf '') + if [ "$dead" = 1 ]; then + status=$(tmux -L "$TMUX_SOCKET" display-message -p -t "$TMUX_SESSION" '#{pane_dead_status}' 2>/dev/null || printf '') + tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true + i=0 + while tmux -L "$TMUX_SOCKET" has-session -t "$TMUX_SESSION" 2>/dev/null && [ "$i" -lt 200 ]; do + sleep 0.05 + i=$((i + 1)) + done + if tmux -L "$TMUX_SOCKET" has-session -t "$TMUX_SESSION" 2>/dev/null; then + fail "$context did not release the tmux session name after /quit" + fi + [ "$status" = 0 ] \ + || fail "$context exited with status ${status:-unknown} instead of exiting cleanly" + return 0 + fi + sleep 0.05 + i=$((i + 1)) + done + fail "$context did not exit after /quit" +} wait_for_text() { local file=$1 text=$2 i=0 @@ -68,14 +118,18 @@ find_chrome() { } test_static_contract() { - local text assistant_layout operational_user_layout visibility watch operational + local text assistant_layout operational_user_layout tool_layout nonconversation_layout visibility watch operational assert_present "$EXT" "tracked Pi calm extension is missing" assert_present "$ASSISTANT_LAYOUT" "tracked Pi Calm assistant-layout adapter is missing" assert_present "$OPERATIONAL_USER_LAYOUT" "tracked Pi Calm operational-user layout adapter is missing" + assert_present "$TOOL_LAYOUT" "tracked Pi Calm tool-layout adapter is missing" + assert_present "$NONCONVERSATION_LAYOUT" "tracked Pi Calm non-conversation row adapter is missing" assert_present "$VISIBILITY" "tracked Pi calm visibility policy is missing" text=$(cat "$EXT") assistant_layout=$(cat "$ASSISTANT_LAYOUT") operational_user_layout=$(cat "$OPERATIONAL_USER_LAYOUT") + tool_layout=$(cat "$TOOL_LAYOUT") + nonconversation_layout=$(cat "$NONCONVERSATION_LAYOUT") visibility=$(cat "$VISIBILITY") watch=$(cat "$WATCH_EXT") operational=$(cat "$PI_OPERATIONAL_INPUT") @@ -89,15 +143,34 @@ test_static_contract() { assert_not_contains "$text" 'ctx.navigateTree' "Pi calm extension reconstructs the transcript and drops transient diagnostics" assert_not_contains "$visibility" 'deliverFirstmateSyntheticInput' "Pi calm visibility policy can still replace operational input semantics" assert_not_contains "$visibility" 'classifyFirstmateSyntheticInput' "Pi calm visibility policy still classifies operational input for interception" - assert_contains "$text" 'ctx.ui.setWorkingVisible(true)' "Pi calm extension does not preserve Pi's live working row" - assert_not_contains "$text" 'ctx.ui.setWorkingVisible(!active)' "Pi calm extension still hides Pi's live working row" + assert_contains "$text" 'ctx.ui.setWorkingVisible(!active)' "Pi calm extension does not hide the live working row only while Calm is active" assert_contains "$text" 'ctx.ui.setHiddenThinkingLabel(active ? "" : undefined)' "Pi calm extension does not hide collapsed thinking labels" assert_contains "$text" 'installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout)' "Pi Calm extension does not install its zero-height assistant layout" + assert_contains "$text" 'installCalmPresentationAdapter("tool-row", installCalmToolLayout)' "Pi Calm extension does not install its complete tool-row layout" assert_contains "$text" 'installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout)' "Pi Calm extension does not install its operational-user layout" assert_contains "$text" 'function installCalmPresentationAdapter' "Pi Calm extension does not degrade a missing presentation adapter independently with a diagnostic" assert_contains "$assistant_layout" 'import * as PiCodingAgent' "Pi Calm assistant layout still requires its optional runtime class as a named import" assert_contains "$assistant_layout" 'AssistantMessageComponent.prototype.updateContent' "Pi Calm assistant layout does not control the exported component presentation path" assert_contains "$assistant_layout" 'block.type !== "thinking"' "Pi Calm assistant layout does not remove thinking from its presentation copy" + assert_not_contains "$assistant_layout" 'state.hideThinkingBlock' "Pi Calm assistant layout still reveals internal thinking when expanded" + assert_contains "$tool_layout" 'ToolExecutionComponent.prototype.render' "Pi Calm tool layout does not own complete tool-row presentation" + assert_contains "$tool_layout" 'return calmErrorLines(this, width, columns)' "Pi Calm tool layout does not remove complete tool rows" + assert_contains "$tool_layout" 'errorResult?.isError && !isPartial ? calmResultText(errorResult) : ""' "Pi Calm tool layout does not restrict its visible tool text to actionable errors" + assert_contains "$tool_layout" 'calmActionableErrorOwner(errorText, this) === this' "Pi Calm tool layout repeats one turn's actionable error across sibling tool rows" + assert_contains "$tool_layout" 'CALM_ACTIONABLE_STOP_REASONS = new Set(["aborted", "error"])' "Pi Calm tool layout does not restrict its visible tool text to turn-level abort and provider failures" + assert_contains "$tool_layout" 'if (!scope || !scope.turn.actionable) return undefined' "Pi Calm tool layout surfaces tool errors it cannot classify as actionable" + assert_contains "$tool_layout" 'scope.turn.owners.get(text)' "Pi Calm tool layout does not scope its dedup to one turn's identical fan-out text" + assert_contains "$tool_layout" 'return registry[CALM_TOOL_ERROR_TURN_PATCH]' "Pi Calm tool layout keeps turn state outside the registry shared across extension reloads" + assert_not_contains "$tool_layout" 'const calmErrorTurn' "Pi Calm tool layout still holds reload-split turn state in module scope" + assert_contains "$tool_layout" 'AssistantMessageComponent.prototype.updateContent' "Pi Calm tool-error turn boundary does not use the one-component-per-assistant-message Pi seam" + assert_contains "$tool_layout" 'if (scope.turn.component === this)' "Pi Calm tool-error turn boundary is not idempotent across Pi's repeated updateContent calls" + assert_not_contains "$tool_layout" 'queueMicrotask' "Pi Calm tool layout still infers turn boundaries from the scheduler that spans transcript replay" + assert_contains "$text" 'installCalmPresentationAdapter("tool-error-turn", installCalmToolErrorTurnBoundary)' "Pi Calm extension does not install its degradable tool-error turn boundary" + assert_contains "$tool_layout" 'ToolExecutionComponent.prototype.updateResult' "Pi Calm tool layout does not read errors through a declared Pi seam" + assert_contains "$tool_layout" 'Firstmate Calm requires Pi ToolExecutionComponent.updateResult' "Pi Calm tool layout does not probe the seam carrying actionable tool errors" + assert_contains "$tool_layout" 'Firstmate Calm requires Pi TUI visibleWidth, truncateToWidth, and wrapTextWithAnsi' "Pi Calm tool layout does not probe the Pi column helpers it measures with" + assert_contains "$tool_layout" 'columns.truncateToWidth(line, usable, "")' "Pi Calm tool layout can emit a line wider than Pi's fatal terminal-column limit" + assert_not_contains "$tool_layout" 'state.result' "Pi Calm tool layout still depends on an unprobed private Pi field" assert_contains "$operational_user_layout" 'import * as PiCodingAgent' "Pi Calm operational-user layout still requires its optional runtime class as a named import" assert_contains "$operational_user_layout" 'InteractiveMode.prototype' "Pi Calm operational-user layout does not control the transcript owner" assert_contains "$operational_user_layout" 'classifyFirstmateCurrentOperationalText(text)' "Pi Calm operational-user layout bypasses canonical current classification" @@ -105,6 +178,35 @@ test_static_contract() { assert_contains "$operational_user_layout" '"\u2063Supervisor escalate ("' "Pi Calm operational-user layout lost the narrow legacy marker" assert_contains "$operational_user_layout" 'hidesOperationalInput()' "Pi Calm operational-user row does not use presentation-only hiding" assert_not_contains "$operational_user_layout" 'FIRSTMATE_OP: ' "Pi Calm operational-user layout duplicates the canonical marker grammar" + assert_not_contains "$text" 'installCalmUserBashLayout' "Pi Calm extension still suppresses the captain's own !bash row" + assert_not_contains "$nonconversation_layout" 'BashExecutionComponent' "Pi Calm non-conversation adapter still owns the captain's own !bash row" + for adapter in \ + 'installCalmPresentationAdapter("skill-invocation-row", installCalmSkillInvocationLayout)' \ + 'installCalmPresentationAdapter("compaction-summary-row", installCalmCompactionSummaryLayout)' \ + 'installCalmPresentationAdapter("branch-summary-row", installCalmBranchSummaryLayout)' \ + 'installCalmPresentationAdapter("custom-message-row", installCalmCustomMessageLayout)' \ + 'installCalmPresentationAdapter("custom-entry-row", installCalmCustomEntryLayout)' \ + 'installCalmPresentationAdapter("cache-notice-row", installCalmCacheNoticeLayout)' \ + 'installCalmPresentationAdapter("hidden-row-spacing", installCalmLeadingSpacerLayout)' + do + assert_contains "$text" "$adapter" "Pi Calm extension does not install a degradable $adapter" + done + for exported in \ + SkillInvocationMessageComponent \ + CompactionSummaryMessageComponent \ + BranchSummaryMessageComponent \ + CustomMessageComponent + do + assert_contains "$nonconversation_layout" "\"$exported\"," "Pi Calm non-conversation adapter does not own the $exported row" + done + assert_contains "$nonconversation_layout" 'Firstmate Calm requires Pi InteractiveMode.addCustomEntryToChat' "Pi Calm non-conversation adapter does not probe the unrelated custom-entry seam" + assert_contains "$nonconversation_layout" 'Firstmate Calm requires Pi InteractiveMode.addCacheMissNotice' "Pi Calm non-conversation adapter does not probe the cache-notice seam" + assert_contains "$nonconversation_layout" 'Firstmate Calm requires Pi InteractiveMode.addMessageToChat' "Pi Calm non-conversation adapter does not probe the hidden-row spacing seam" + assert_contains "$nonconversation_layout" 'calmPresentationHides(itemClass)' "Pi Calm non-conversation adapter does not read the centralized visibility policy" + assert_contains "$nonconversation_layout" 'row instanceof entry.rowClass' "Pi Calm non-conversation adapter drops the spacer beside a row whose adapter degraded" + assert_contains "$nonconversation_layout" 'calmConditionalRow(spacer, () => calmRegisteredRowHides(exportName))' "Pi Calm hidden-row spacer bakes in a hides closure instead of reading the shared registry per render" + assert_contains "$nonconversation_layout" 'calmHiddenRowClasses().classes.get(exportName)' "Pi Calm hidden-row spacer does not resolve its row policy from the registry shared across extension reloads" + assert_not_contains "$nonconversation_layout" 'chatContainer.clear' "Pi Calm non-conversation adapter rebuilds the transcript instead of rendering at zero height" assert_not_contains "$text" 'calm transcript' "Pi calm extension still adds a persistent Calm status row" assert_not_contains "$text" 'pi.on("input"' "Pi calm extension still intercepts semantic input" assert_not_contains "$text" 'sendMessage' "Pi calm extension still replaces user-role input with custom context" @@ -112,7 +214,8 @@ test_static_contract() { assert_contains "$text" 'getKeybindings().matches(data, "tui.input.submit")' "Pi calm export boundary ignores the active submit keybinding" assert_contains "$text" 'input !== "/share"' "Pi calm export boundary does not cover /share" assert_not_contains "$text" 'FIRSTMATE_PI_LAUNCH_BRIEF_ENV' "Pi calm presentation still depends on launch-input provenance" - assert_contains "$text" 'renderShell: "self"' "Pi calm extension cannot remove complete built-in tool shells" + assert_contains "$text" 'renderShell: "self"' "Pi calm extension cannot remove complete built-in tool shells when the generic adapter degrades" + assert_contains "$text" 'ctx.mode === "tui" && ctx.isProjectTrusted()' "Pi Calm presentation is not scoped to trusted interactive sessions" assert_contains "$visibility" 'CALM_VISIBLE_CLASSES' "Pi calm policy does not centralize its visibility allowlist" assert_contains "$operational" 'fm-operational-input.sh' "Pi adapter does not delegate to the canonical cross-language owner" assert_not_contains "$visibility" 'FIRSTMATE WATCHER WAKE:' "current Calm classification still matches watcher payload prose" @@ -125,7 +228,7 @@ test_static_contract() { for name in Read Bash Edit Write Grep Find Ls; do assert_contains "$text" "create${name}ToolDefinition" "Pi calm extension does not wrap the $name built-in" done - pass "Pi calm extension is presentation-only with one persisted visibility choice, no Calm status row, native working visibility, supported redraw controls, and the Firstmate watcher-tool integration" + pass "Pi calm extension is presentation-only with one persisted visibility choice, no Calm status row, hidden working activity, supported redraw controls, and complete tool-row presentation" } test_home_resolution() { @@ -150,6 +253,8 @@ test_home_resolution() { cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts" 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 "$TOOL_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent" @@ -192,6 +297,8 @@ function registerCalm() { } const context = { + mode: "tui", + isProjectTrusted: () => true, ui: { getEditorText() { return ""; @@ -267,6 +374,8 @@ test_pi_compat_degraded_adapter() { cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts" 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 "$TOOL_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent" @@ -334,6 +443,14 @@ if (typeof AssistantMessageComponent.prototype.updateContent !== "undefined") { "the degraded adapter path patched updateContent anyway despite the missing API, which would claim false success", ); } +const sawTurnBoundarySkipReason = diagnostics.some( + (line) => line.includes("tool-error-turn") && /unavailable|skip/i.test(line), +); +if (!sawTurnBoundarySkipReason) { + throw new Error( + `missing a clear skip reason for the degraded tool-error-turn adapter; saw: ${JSON.stringify(diagnostics)}`, + ); +} const sawClearSkipReason = diagnostics.some( (line) => line.includes("collapsed-thinking") && /unavailable|skip/i.test(line), ); @@ -353,7 +470,7 @@ JS } test_pi_compat_missing_adapter_exports() { - local fixture out status + local fixture out status seam variant if ! command -v node >/dev/null 2>&1; then echo "skip: node not found for Pi calm missing-adapter-export test" return 0 @@ -362,9 +479,20 @@ test_pi_compat_missing_adapter_exports() { fixture="$TMP_ROOT/missing-adapter-exports" mkdir -p \ "$fixture/project/.pi/extensions/lib" \ - "$fixture/project/node_modules/@earendil-works/pi-coding-agent" + "$fixture/project/node_modules/@earendil-works/pi-coding-agent" \ + "$fixture/project/node_modules/@earendil-works/pi-tui" + printf '%s\n' \ + '{"name":"@earendil-works/pi-tui","type":"module","exports":"./index.js"}' \ + >"$fixture/project/node_modules/@earendil-works/pi-tui/package.json" + printf '%s\n' \ + 'export function visibleWidth(text) { return text.length; }' \ + 'export function truncateToWidth(text, maxWidth) { return text.slice(0, maxWidth); }' \ + 'export function wrapTextWithAnsi(text) { return [text]; }' \ + >"$fixture/project/node_modules/@earendil-works/pi-tui/index.js" 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 "$TOOL_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" @@ -379,10 +507,21 @@ test_pi_compat_missing_adapter_exports() { out=$(cd "$fixture/project" && node --input-type=module 2>&1 <<'JS' const assistant = await import("./.pi/extensions/lib/fm-calm-assistant-layout.ts"); const operational = await import("./.pi/extensions/lib/fm-calm-operational-user-layout.ts"); +const tool = await import("./.pi/extensions/lib/fm-calm-tool-layout.ts"); +const rows = await import("./.pi/extensions/lib/fm-calm-nonconversation-layout.ts"); for (const [name, install, expected] of [ ["collapsed-thinking", assistant.installCalmAssistantLayout, "AssistantMessageComponent"], + ["tool-row", tool.installCalmToolLayout, "ToolExecutionComponent"], + ["tool-error-turn", tool.installCalmToolErrorTurnBoundary, "AssistantMessageComponent"], ["operational-user-row", operational.installCalmOperationalUserLayout, "InteractiveMode"], + ["skill-invocation-row", rows.installCalmSkillInvocationLayout, "SkillInvocationMessageComponent"], + ["compaction-summary-row", rows.installCalmCompactionSummaryLayout, "CompactionSummaryMessageComponent"], + ["branch-summary-row", rows.installCalmBranchSummaryLayout, "BranchSummaryMessageComponent"], + ["custom-message-row", rows.installCalmCustomMessageLayout, "CustomMessageComponent"], + ["custom-entry-row", rows.installCalmCustomEntryLayout, "InteractiveMode"], + ["cache-notice-row", rows.installCalmCacheNoticeLayout, "InteractiveMode"], + ["hidden-row-spacing", rows.installCalmLeadingSpacerLayout, "Spacer"], ]) { let reason; try { @@ -401,7 +540,338 @@ 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" + + for seam in updateResult wrapTextWithAnsi; do + variant="$TMP_ROOT/missing-error-seam-$seam" + mkdir -p \ + "$variant/.pi/extensions/lib" \ + "$variant/node_modules/@earendil-works/pi-coding-agent" \ + "$variant/node_modules/@earendil-works/pi-tui" + cp "$TOOL_LAYOUT" "$variant/.pi/extensions/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$variant/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" + cp "$VISIBILITY" "$variant/.pi/extensions/lib/fm-calm-visibility.ts" + printf '%s\n' '{"type":"module"}' >"$variant/package.json" + printf '%s\n' \ + '{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}' \ + >"$variant/node_modules/@earendil-works/pi-coding-agent/package.json" + printf '%s\n' \ + '{"name":"@earendil-works/pi-tui","type":"module","exports":"./index.js"}' \ + >"$variant/node_modules/@earendil-works/pi-tui/package.json" + { + printf '%s\n' \ + 'export function getMarkdownTheme() { return {}; }' \ + 'export class UserMessageComponent {}' + if [ "$seam" = updateResult ]; then + printf '%s\n' 'export class ToolExecutionComponent { render() { return []; } }' + else + printf '%s\n' \ + 'export class ToolExecutionComponent { render() { return []; } updateResult() {} }' + fi + } >"$variant/node_modules/@earendil-works/pi-coding-agent/index.js" + { + printf '%s\n' \ + 'export function visibleWidth(text) { return text.length; }' \ + 'export function truncateToWidth(text, maxWidth) { return text.slice(0, maxWidth); }' + if [ "$seam" != wrapTextWithAnsi ]; then + printf '%s\n' 'export function wrapTextWithAnsi(text) { return [text]; }' + fi + } >"$variant/node_modules/@earendil-works/pi-tui/index.js" + + out=$(cd "$variant" && FM_CALM_MISSING_SEAM="$seam" node --input-type=module 2>&1 <<'JS' +const seam = process.env.FM_CALM_MISSING_SEAM; +const tool = await import("./.pi/extensions/lib/fm-calm-tool-layout.ts"); +const { ToolExecutionComponent } = await import("@earendil-works/pi-coding-agent"); +const originalRender = ToolExecutionComponent.prototype.render; +const originalUpdateResult = ToolExecutionComponent.prototype.updateResult; +let reason; +try { + tool.installCalmToolLayout(); +} catch (error) { + reason = error instanceof Error ? error.message : String(error); +} +if (!reason?.includes(seam)) { + throw new Error(`the tool-row adapter did not name its missing ${seam} seam: ${String(reason)}`); +} +if ( + ToolExecutionComponent.prototype.render !== originalRender || + ToolExecutionComponent.prototype.updateResult !== originalUpdateResult +) { + throw new Error(`the tool-row adapter patched Pi anyway despite the missing ${seam} seam`); +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm missing $seam seam path failed: $out" + [ -z "$out" ] || fail "Pi calm missing $seam seam test printed output: $out" + done + pass "missing Pi presentation class exports and error-surface seams reach the independent adapter degradation path" +} + +test_adapter_reload_turn_scope() { + local fixture out status + if ! command -v node >/dev/null 2>&1; then + echo "skip: node not found for Pi calm adapter reload test" + return 0 + fi + if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + + fixture="$TMP_ROOT/adapter-reload" + mkdir -p "$fixture/lib" "$fixture/node_modules/@earendil-works" + cp "$TOOL_LAYOUT" "$fixture/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$fixture/lib/fm-calm-nonconversation-layout.ts" + cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts" + ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/package.json" + + out=$(cd "$fixture" && PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +const packageRoot = process.env.PI_PACKAGE_DIR; +const [{ AssistantMessageComponent }, { ToolExecutionComponent }, { initTheme }, { setCapabilities }] = await Promise.all([ + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/assistant-message.js`).href), + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/tool-execution.js`).href), + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href), + import(pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href), +]); +initTheme("dark"); +setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + +const layoutUrl = pathToFileURL(`${process.cwd()}/lib/fm-calm-tool-layout.ts`).href; +const visibility = await import(pathToFileURL(`${process.cwd()}/lib/fm-calm-visibility.ts`).href); +visibility.setCalmPresentation(true); + +const renderUi = { requestRender() {} }; +const turnMessage = { + role: "assistant", + api: "calm-reload-test", + provider: "calm-reload-test", + model: "deterministic", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "aborted", + timestamp: 1, + content: [{ type: "text", text: "CALM_RELOAD_TURN" }], +}; + +// Two separately interrupted turns replayed back to back with no scheduler gap, exactly as +// renderSessionItems rebuilds a transcript right after a reload. +function replayInterruptedTurns(generation) { + return [0, 1].map((turn) => { + const turnComponent = new AssistantMessageComponent(turnMessage, true); + // Pi calls updateContent again on the same component from invalidate(), setOutputPad(), + // and the thinking-label controls, so the reset must not re-open the turn. + turnComponent.setOutputPad(2); + const rows = ["a", "b", "c"].map((slot) => { + const row = new ToolExecutionComponent("bash", `reload-${generation}-${turn}-${slot}`, { command: "printf CALM_RELOAD_ARGS" }, { showImages: false }, undefined, renderUi, process.cwd()); + row.markExecutionStarted(); + return row; + }); + for (const row of rows.slice(0, 2)) { + row.updateResult({ content: [{ type: "text", text: "Operation aborted" }], details: {}, isError: true }); + } + turnComponent.setOutputPad(1); + rows[2].updateResult({ content: [{ type: "text", text: "Operation aborted" }], details: {}, isError: true }); + return rows; + }); +} + +function assertRoutineFailureHidden(generation) { + new AssistantMessageComponent({ ...turnMessage, stopReason: "toolUse" }, true); + const row = new ToolExecutionComponent("bash", `reload-${generation}-routine`, { command: "printf CALM_RELOAD_ARGS" }, { showImages: false }, undefined, renderUi, process.cwd()); + row.markExecutionStarted(); + row.setArgsComplete(); + row.updateResult({ content: [{ type: "text", text: "CALM_RELOAD_ROUTINE_FAILURE" }], details: {}, isError: true }); + if (row.render(100).length !== 0) { + throw new Error(`generation ${generation} surfaced a routine per-tool failure from a completed turn`); + } +} + +function assertOneErrorPerTurn(generation) { + const turns = replayInterruptedTurns(generation); + for (const [turn, rows] of turns.entries()) { + const rendered = rows.map((row) => row.render(100)); + const surfaced = rendered.filter((lines) => lines.join("\n").includes("Operation aborted")); + if (surfaced.length !== 1) { + throw new Error( + `generation ${generation} turn ${turn} surfaced its abort text ${surfaced.length} times instead of once`, + ); + } + if (rendered.filter((lines) => lines.length !== 0).length !== 1) { + throw new Error(`generation ${generation} turn ${turn} kept a residual duplicate abort row`); + } + if (rendered.flat().join("\n").includes("CALM_RELOAD_ARGS")) { + throw new Error(`generation ${generation} turn ${turn} exposed routine tool call content`); + } + } +} + +let wrappers; +for (const generation of [1, 2, 3]) { + const layout = await import(`${layoutUrl}?generation=${generation}`); + layout.installCalmToolLayout(); + layout.installCalmToolErrorTurnBoundary(); + const current = { + render: ToolExecutionComponent.prototype.render, + updateResult: ToolExecutionComponent.prototype.updateResult, + updateContent: AssistantMessageComponent.prototype.updateContent, + }; + if (wrappers) { + for (const key of Object.keys(current)) { + if (current[key] !== wrappers[key]) { + throw new Error(`reload generation ${generation} stacked another ${key} wrapper on Pi`); + } + } + } + wrappers = current; + assertOneErrorPerTurn(generation); + assertRoutineFailureHidden(generation); +} + +visibility.setCalmPresentation(false); +const stockRow = new ToolExecutionComponent("bash", "reload-stock", { command: "printf CALM_RELOAD_ARGS" }, { showImages: false }, undefined, renderUi, process.cwd()); +stockRow.markExecutionStarted(); +stockRow.setArgsComplete(); +stockRow.updateResult({ content: [{ type: "text", text: "Operation aborted" }], details: {}, isError: true }); +if (!stockRow.render(100).join("\n").includes("Operation aborted")) { + throw new Error("Calm-off rendering lost the stock errored tool row after adapter reloads"); +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm adapter reload turn scope failed: $out" + [ -z "$out" ] || fail "Pi calm adapter reload test printed output: $out" + pass "reloading the Calm adapters keeps actionable tool errors scoped to one assistant turn without stacking Pi wrappers" +} + +test_hidden_row_spacer_reload() { + local fixture out status generation + if ! command -v node >/dev/null 2>&1; then + echo "skip: node not found for Pi calm hidden-row spacer reload test" + return 0 + fi + if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + + fixture="$TMP_ROOT/spacer-reload" + mkdir -p "$fixture/node_modules/@earendil-works" + # A reload re-evaluates the whole Calm lib graph, so each generation gets its own module copies + # and therefore its own visibility state, exactly as Pi reloads the extension. + for generation in 1 2; do + mkdir -p "$fixture/lib-gen$generation" + cp "$NONCONVERSATION_LAYOUT" "$fixture/lib-gen$generation/fm-calm-nonconversation-layout.ts" + cp "$VISIBILITY" "$fixture/lib-gen$generation/fm-calm-visibility.ts" + done + ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/package.json" + + out=$(cd "$fixture" && PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +const packageRoot = process.env.PI_PACKAGE_DIR; +const [{ InteractiveMode }, { initTheme }, { setCapabilities }] = await Promise.all([ + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/interactive-mode.js`).href), + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href), + import(pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href), +]); +initTheme("dark"); +setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + +const compactionMessage = { + role: "compactionSummary", + tokensBefore: 1234, + summary: "CALM_SPACER_RELOAD_SUMMARY", +}; +const visibleText = "Compacted from 1,234 tokens"; + +async function loadGeneration(generation) { + const dir = `${process.cwd()}/lib-gen${generation}`; + const layout = await import(pathToFileURL(`${dir}/fm-calm-nonconversation-layout.ts`).href); + const visibility = await import(pathToFileURL(`${dir}/fm-calm-visibility.ts`).href); + layout.installCalmCompactionSummaryLayout(); + layout.installCalmLeadingSpacerLayout(); + return { layout, visibility }; +} + +function addCompactionRow() { + const chat = { + children: [], + addChild(component) { + this.children.push(component); + }, + }; + InteractiveMode.prototype.addMessageToChat.call( + { + chatContainer: chat, + editor: { addToHistory() {} }, + getMarkdownThemeWithSettings: () => undefined, + getUserMessageText: (message) => message.content, + outputPad: 1, + toolOutputExpanded: false, + }, + compactionMessage, + ); + if (chat.children.length !== 2) { + throw new Error("Pi no longer pairs a compaction summary with one leading spacer"); + } + return chat; +} + +const gen1 = await loadGeneration(1); +gen1.visibility.setCalmPresentation(true); +const chat = addCompactionRow(); +const renderChat = () => chat.children.flatMap((component) => component.render(100)); +if (renderChat().length !== 0) { + throw new Error("Calm left the compaction summary or its leading spacer visible"); +} + +// The reload installs a fresh module instance whose visibility state is the only one the +// extension drives from here on; the old instance keeps its now stale flag forever. +const gen2 = await loadGeneration(2); +gen2.visibility.setCalmPresentation(false); +const restored = renderChat(); +if (!restored.join("\n").includes(visibleText)) { + throw new Error("Calm off after a reload did not restore the compaction summary row"); +} +if (restored[0] !== "") { + throw new Error("Calm off after a reload restored the compaction row without its leading spacer"); +} + +gen2.visibility.setCalmPresentation(true); +if (renderChat().length !== 0) { + throw new Error("Calm on after a reload did not hide the compaction summary and its spacer again"); +} + +// A row wrapped after the reload must follow the live policy just as an older one does. +const reloadedChat = addCompactionRow(); +if (reloadedChat.children.flatMap((component) => component.render(100)).length !== 0) { + throw new Error("Calm did not hide a compaction row appended after the reload"); +} +gen2.visibility.setCalmPresentation(false); +const reloadedRestored = reloadedChat.children.flatMap((component) => component.render(100)); +if (!reloadedRestored.join("\n").includes(visibleText) || reloadedRestored[0] !== "") { + throw new Error("Calm off did not restore a compaction row appended after the reload"); +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm hidden-row spacer reload failed: $out" + [ -z "$out" ] || fail "Pi calm hidden-row spacer reload test printed output: $out" + pass "a hidden row's leading spacer follows the live Calm policy across extension reloads" } test_rendering_and_session_lifecycle() { @@ -422,6 +892,8 @@ test_rendering_and_session_lifecycle() { cp "$EXT" "$fixture/fm-calm.ts" cp "$ASSISTANT_LAYOUT" "$fixture/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$fixture/lib/fm-calm-operational-user-layout.ts" + cp "$TOOL_LAYOUT" "$fixture/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$fixture/lib/fm-calm-nonconversation-layout.ts" cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$fixture/lib/fm-operational-input.ts" cp "$WATCH_EXT" "$fixture/fm-primary-pi-watch.ts" @@ -441,7 +913,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const packageRoot = process.env.PI_PACKAGE_DIR; -const [{ AssistantMessageComponent }, { CustomEntryComponent }, { ToolExecutionComponent }, { UserMessageComponent }, { InteractiveMode }, { initTheme, theme }, { Text, getKeybindings, setCapabilities }, { createToolHtmlRenderer }] = await Promise.all([ +const [{ AssistantMessageComponent }, { CustomEntryComponent }, { ToolExecutionComponent }, { UserMessageComponent }, { InteractiveMode }, { initTheme, theme }, { Text, getKeybindings, setCapabilities, visibleWidth }, { createToolHtmlRenderer }] = await Promise.all([ import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/assistant-message.js`).href), import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/custom-entry.js`).href), import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/tool-execution.js`).href), @@ -513,7 +985,7 @@ for (const itemClass of visibility.CALM_TRANSCRIPT_CLASSES) { const expected = itemClass === "genuine-user-prompt" || itemClass === "genuine-agent-response" || - itemClass === "working-status"; + itemClass === "user-bash"; if (visible !== expected) { throw new Error(`Calm allowlist classified ${itemClass} as visible=${visible}`); } @@ -717,6 +1189,139 @@ if (!imageVisibleBefore.join("\n").includes("\x1b]1337;File=")) { throw new Error("image-capable Pi fixture did not render the built-in read image boundary"); } +const routineArgs = { command: "printf 'CALM_ROUTINE_ARGS\\n'" }; +const bashDefinition = tools.find((tool) => tool.name === "bash"); +// Only a turn that stopped on an abort or a provider failure carries text the captain has to +// answer. A routine per-tool failure lands on a turn that stopped on "toolUse", so it stays +// hidden with the rest of the row even though Pi flags it with the same isError. +const errorCases = [ + { + key: "aborted-turn", + stopReason: "aborted", + result: { content: [{ type: "text", text: "Operation aborted" }], details: {}, isError: true }, + visible: ["Operation aborted"], + }, + { + key: "provider-error", + stopReason: "error", + result: { content: [{ type: "text", text: "Error: provider stream failed" }], details: {}, isError: true }, + visible: ["Error: provider stream failed"], + }, + { + key: "routine-tool-failure", + stopReason: "toolUse", + result: { + content: [ + { + type: "text", + text: "\x1b[31mCommand failed with exit code 1\x1b[0m\nno such file: missing.txt", + }, + ], + details: {}, + isError: true, + }, + visible: [], + }, +]; +// Identical error text from a single interrupted turn is surfaced once, so every group below +// opens a new assistant turn exactly as Pi does. Nothing here awaits: this whole block is one +// synchronous pass, matching how Pi replays a full session history. +const turnBoundaryMessage = { + role: "assistant", + api: "calm-render-test", + provider: "calm-render-test", + model: "deterministic", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + content: [{ type: "text", text: "CALM_TURN_BOUNDARY" }], +}; +const beginErrorTurn = (stopReason = "aborted") => + new AssistantMessageComponent({ ...turnBoundaryMessage, stopReason }, true); +const newErrorRow = (id, definition = bashDefinition) => + new ToolExecutionComponent("bash", `error-${id}`, routineArgs, { showImages: false }, definition, renderUi, process.cwd()); +const errorFixtures = []; +for (const errorCase of errorCases) { + beginErrorTurn(errorCase.stopReason); + const actual = newErrorRow(`actual-${errorCase.key}`); + beginErrorTurn(errorCase.stopReason); + const baseline = newErrorRow(`baseline-${errorCase.key}`, undefined); + for (const row of [actual, baseline]) { + row.markExecutionStarted(); + row.setArgsComplete(); + row.updateResult(errorCase.result); + } + if (JSON.stringify(actual.render(100)) !== JSON.stringify(baseline.render(100))) { + throw new Error(`${errorCase.key} error rendering changed while calm mode was off`); + } + errorFixtures.push({ ...errorCase, baseline, actual }); +} +beginErrorTurn(); +const pendingAbortRow = newErrorRow("actual-pending"); +pendingAbortRow.markExecutionStarted(); +pendingAbortRow.updateResult({ content: [{ type: "text", text: "Operation aborted" }], isError: true }); +beginErrorTurn(); +const parallelAbortRows = ["parallel-a", "parallel-b", "parallel-c"].map((id) => newErrorRow(`actual-${id}`)); +for (const row of parallelAbortRows) row.markExecutionStarted(); +for (const row of parallelAbortRows) { + row.updateResult({ content: [{ type: "text", text: "Operation aborted" }], isError: true }); +} +beginErrorTurn(); +const parallelDistinctRows = ["distinct-a", "distinct-b"].map((id) => newErrorRow(`actual-${id}`)); +for (const row of parallelDistinctRows) row.markExecutionStarted(); +parallelDistinctRows.forEach((row, index) => { + row.updateResult({ content: [{ type: "text", text: `Error: CALM_DISTINCT_FAILURE_${index}` }], isError: true }); +}); +// Four separately interrupted turns replayed back to back, exactly as renderSessionItems +// rebuilds a resumed or reloaded transcript with no scheduler gap between turns. +const replayedAbortTurns = [0, 1, 2, 3].map((turn) => { + beginErrorTurn(); + const rows = ["a", "b"].map((slot) => newErrorRow(`replay-${turn}-${slot}`)); + for (const row of rows) row.markExecutionStarted(); + for (const row of rows) { + row.updateResult({ content: [{ type: "text", text: "Operation aborted" }], isError: true }); + } + return rows; +}); +beginErrorTurn(); +const noisyErrorRow = new ToolExecutionComponent("bash", "error-actual-noisy", routineArgs, { showImages: false }, bashDefinition, renderUi, process.cwd()); +noisyErrorRow.markExecutionStarted(); +noisyErrorRow.setArgsComplete(); +noisyErrorRow.updateResult({ + content: [{ type: "text", text: Array.from({ length: 12 }, (_, index) => `error line ${index + 1}`).join("\n") }], + details: {}, + isError: true, +}); +const widthErrorTexts = [ + `\tmake: *** ${"deeply/nested/build/target ".repeat(12)}Error 1`, + `エラー: ${"幅の広い文字を含む失敗出力".repeat(8)}`, + `❌ ${"🚀🔥 build step failed ".repeat(10)}`, + `Error: ${"x".repeat(4000)}`, + Array.from({ length: 9 }, (_, index) => `\t${"wide 漢字 stderr ".repeat(6)}${index}`).join("\n"), +]; +const widthErrorRows = widthErrorTexts.map((text, index) => { + const row = new ToolExecutionComponent("bash", `error-actual-width-${index}`, routineArgs, { showImages: false }, bashDefinition, renderUi, process.cwd()); + row.markExecutionStarted(); + row.setArgsComplete(); + row.updateResult({ content: [{ type: "text", text }], details: {}, isError: true }); + return row; +}); +const partialErrorRow = new ToolExecutionComponent("bash", "error-actual-partial", routineArgs, { showImages: false }, bashDefinition, renderUi, process.cwd()); +partialErrorRow.markExecutionStarted(); +partialErrorRow.updateResult({ content: [{ type: "text", text: "CALM_PARTIAL_STREAM" }], details: {}, isError: true }, true); +const clearedErrorRow = new ToolExecutionComponent("bash", "error-actual-cleared", routineArgs, { showImages: false }, bashDefinition, renderUi, process.cwd()); +clearedErrorRow.markExecutionStarted(); +clearedErrorRow.setArgsComplete(); +clearedErrorRow.updateResult({ content: [{ type: "text", text: "CALM_TRANSIENT_ERROR" }], details: {}, isError: true }); +clearedErrorRow.updateResult({ content: [{ type: "text", text: "CALM_RECOVERED_OUTPUT" }], details: {}, isError: false }); + const assistantBase = { role: "assistant", api: "calm-render-test", @@ -762,14 +1367,21 @@ let editorText = ""; let terminalInputHandler; let workingVisible; let hiddenThinkingLabel = "unset"; +let trusted = true; +let notification; const statuses = new Map(); const sessionEntries = [{ type: "message", message: { role: "toolResult", content: "kept" } }]; const entriesBefore = JSON.stringify(sessionEntries); const commandContext = { + mode: "tui", + isProjectTrusted: () => trusted, sessionManager: { getEntries: () => sessionEntries }, ui: { getEditorText: () => editorText, getToolsExpanded: () => expanded, + notify(message, type) { + notification = { message, type }; + }, onTerminalInput(handler) { terminalInputHandler = handler; return () => { @@ -818,8 +1430,8 @@ if ( } await calmCommand.handler("", commandContext); -if (expanded !== true || workingVisible !== true || hiddenThinkingLabel !== "" || statuses.get("firstmate-calm") !== undefined) { - throw new Error("Calm did not preserve working visibility or apply its thinking and footer presentation controls"); +if (expanded !== true || workingVisible !== false || hiddenThinkingLabel !== "" || statuses.get("firstmate-calm") !== undefined) { + throw new Error("Calm did not hide working activity or apply its thinking and footer presentation controls"); } if (readFileSync(`${process.env.FM_HOME}/config/calm`, "utf8") !== "on\n") { throw new Error("Calm did not persist the active choice in the effective Firstmate home"); @@ -951,19 +1563,113 @@ for (const { name, actual } of rows) { throw new Error(`${name} left residual tool rows while calm mode was on: ${JSON.stringify(rendered)}`); } } -const calmImageOutput = imageRow.render(100).join("\n"); -if (!calmImageOutput.includes("\x1b]1337;File=")) { - throw new Error("calm mode hid the disclosed built-in read image boundary"); +if (imageRow.render(100).length !== 0) { + throw new Error("calm mode left a built-in image tool row visible"); } -if (calmImageOutput.includes("pixel.png")) { - throw new Error("calm mode left the built-in read call shell beside the disclosed image output"); -} -if (!customRow.render(100).join("\n").includes("CUSTOM_CALL")) { - throw new Error("calm mode incorrectly claimed or applied generic custom-tool coverage"); +if (customRow.render(100).length !== 0) { + throw new Error("calm mode left a custom tool row visible"); } if (watchActual.render(100).length !== 0) { throw new Error("Calm left the fm_watch_arm_pi call/result shell visible"); } +for (const fixture of errorFixtures) { + const rendered = fixture.actual.render(100); + const text = rendered.join("\n"); + if (fixture.visible.length === 0) { + if (rendered.length !== 0) { + throw new Error(`Calm surfaced the routine ${fixture.key} tool row instead of hiding it: ${text}`); + } + continue; + } + for (const visible of fixture.visible) { + if (!text.includes(visible)) { + throw new Error(`Calm hid the actionable ${fixture.key} tool error text: ${visible}`); + } + } + if (text.includes("CALM_ROUTINE_ARGS")) { + throw new Error(`Calm exposed routine tool call content on the ${fixture.key} error row`); + } + if (text.includes("\x1b[")) { + throw new Error(`Calm left raw terminal escapes in the ${fixture.key} error row`); + } + if (rendered[0] !== "" || rendered.length < 2) { + throw new Error(`Calm rendered the ${fixture.key} error row without its single leading spacer`); + } +} +const pendingAbortRendered = pendingAbortRow.render(100).join("\n"); +if (!pendingAbortRendered.includes("Operation aborted")) { + throw new Error("Calm hid the abort text attached to a still-pending tool call"); +} +if (pendingAbortRendered.includes("CALM_ROUTINE_ARGS")) { + throw new Error("Calm exposed routine tool call content on a pending aborted row"); +} +const noisyRendered = noisyErrorRow.render(100); +if (noisyRendered.length !== 8) { + throw new Error(`Calm did not bound a noisy tool error to its capped surface: ${noisyRendered.length}`); +} +if (!noisyRendered.join("\n").includes("6 earlier error lines hidden")) { + throw new Error("Calm dropped noisy tool error lines without saying so"); +} +if (!noisyRendered.join("\n").includes("error line 12") || noisyRendered.join("\n").includes("error line 6")) { + throw new Error("Calm did not keep the trailing lines of a noisy tool error"); +} +const parallelAbortRendered = parallelAbortRows.map((row) => row.render(100)); +if (parallelAbortRendered.filter((rendered) => rendered.length !== 0).length !== 1) { + throw new Error("Calm repeated shared abort text across its parallel pending tool rows"); +} +if (!parallelAbortRendered.flat().join("\n").includes("Operation aborted")) { + throw new Error("Calm dropped the abort text shared by parallel pending tool rows"); +} +for (const [index, row] of parallelDistinctRows.entries()) { + if (!row.render(100).join("\n").includes(`Error: CALM_DISTINCT_FAILURE_${index}`)) { + throw new Error(`Calm hid distinct actionable error ${index} from a parallel tool row`); + } +} +for (const [turn, rows] of replayedAbortTurns.entries()) { + const rendered = rows.map((row) => row.render(100)); + const surfaced = rendered.filter((lines) => lines.join("\n").includes("Operation aborted")); + if (surfaced.length !== 1) { + throw new Error( + `replayed interrupted turn ${turn} surfaced its abort text ${surfaced.length} times instead of once`, + ); + } + if (rendered.filter((lines) => lines.length !== 0).length !== 1) { + throw new Error(`replayed interrupted turn ${turn} kept a residual duplicate abort row`); + } +} +if (partialErrorRow.render(100).length !== 0) { + throw new Error("Calm exposed a streaming partial tool result as an actionable error"); +} +if (clearedErrorRow.render(100).length !== 0) { + throw new Error("Calm kept a superseded tool error visible after a successful result"); +} +const widthProbeRows = [...errorFixtures.map((fixture) => fixture.actual), noisyErrorRow, ...widthErrorRows]; +for (const probeWidth of [1, 2, 8, 12, 40, 100, 180]) { + for (const row of widthProbeRows) { + for (const line of row.render(probeWidth)) { + if (visibleWidth(line) > probeWidth) { + throw new Error( + `Calm emitted a ${visibleWidth(line)}-column error line at terminal width ${probeWidth}`, + ); + } + } + } +} +for (const row of widthProbeRows) { + if (row.render(0).length !== 0) { + throw new Error("Calm emitted an error line with no terminal columns available"); + } +} +const wideRendered = widthErrorRows[1].render(40).join("\n"); +if (!wideRendered.includes("エラー")) { + throw new Error("Calm dropped the wide-character tool error text it must surface"); +} +if (!widthErrorRows[4].render(40).some((line) => line.includes("earlier error lines hidden"))) { + throw new Error("Calm dropped its capped-error notice on a wrapped multi-line error"); +} +if (!widthErrorRows[4].render(12).some((line) => line.startsWith(" ..."))) { + throw new Error("Calm dropped its capped-error notice on a narrow terminal"); +} if (assistantThinkingTool.render(100).length !== 0) { throw new Error("Calm-hidden thinking beside a tool call retained vertical height"); } @@ -971,8 +1677,8 @@ if (JSON.stringify(assistantThinkingText.render(100)) !== JSON.stringify(assista throw new Error("Calm-hidden thinking changed final assistant row geometry"); } assistantThinkingTool.setHideThinkingBlock(false); -if (!assistantThinkingTool.render(100).join("\n").includes("HIDDEN_TOOL_THINKING")) { - throw new Error("expanding thinking did not restore the original reasoning content"); +if (assistantThinkingTool.render(100).length !== 0) { + throw new Error("expanding thinking exposed internal reasoning while Calm was active"); } assistantThinkingTool.setHideThinkingBlock(true); if (assistantThinkingTool.render(100).length !== 0) { @@ -1005,6 +1711,16 @@ for (const { name, baseline, actual } of rows) { if (JSON.stringify(imageRow.render(100)) !== JSON.stringify(imageVisibleBefore)) { throw new Error("built-in read image row did not restore its ordinary call shell and image output"); } +for (const fixture of errorFixtures) { + if (JSON.stringify(fixture.actual.render(100)) !== JSON.stringify(fixture.baseline.render(100))) { + throw new Error(`${fixture.key} error row did not restore stock Pi rendering when Calm was turned off`); + } +} +for (const row of parallelAbortRows) { + if (!row.render(100).join("\n").includes("Operation aborted")) { + throw new Error("turning Calm off did not restore every parallel pending tool row"); + } +} if (JSON.stringify(watchActual.render(100)) !== JSON.stringify(watchBaseline.render(100))) { throw new Error("fm_watch_arm_pi did not restore its stock call/result shell"); } @@ -1034,8 +1750,8 @@ for (const reason of ["startup", "new", "resume", "fork", "reload"]) { throw new Error(`${reason} session did not retain the active Calm choice for ${name}`); } } - if (workingVisible !== true || hiddenThinkingLabel !== "" || statuses.get("firstmate-calm") !== undefined) { - throw new Error(`${reason} session did not retain gapless Calm presentation with native working visibility`); + if (workingVisible !== false || hiddenThinkingLabel !== "" || statuses.get("firstmate-calm") !== undefined) { + throw new Error(`${reason} session did not retain gapless Calm presentation with hidden working activity`); } } await calmCommand.handler("", commandContext); @@ -1051,12 +1767,50 @@ const [originalResult, wrappedResult] = await Promise.all([ if (JSON.stringify(wrappedResult) !== JSON.stringify(originalResult)) { throw new Error("calm wrapper changed built-in read execution or result data"); } + +writeFileSync(`${process.env.FM_HOME}/config/calm`, "on\n"); +commandContext.mode = "rpc"; +workingVisible = "unchanged"; +hiddenThinkingLabel = "unchanged"; +await handlers.get("session_start")[0]({ reason: "reload" }, commandContext); +if (workingVisible !== "unchanged" || hiddenThinkingLabel !== "unchanged") { + throw new Error("Calm changed presentation outside interactive TUI mode"); +} +for (const { name, actual } of rows) { + if (actual.render(100).length === 0) { + throw new Error(`non-interactive reload activated Calm presentation for ${name}`); + } +} +await calmCommand.handler("", commandContext); +if (notification?.type !== "warning" || !notification.message.includes("trusted interactive")) { + throw new Error("non-interactive /calm did not refuse with a visible explanation"); +} +commandContext.mode = "tui"; +trusted = false; +notification = undefined; +await handlers.get("session_start")[0]({ reason: "reload" }, commandContext); +for (const { name, actual } of rows) { + if (actual.render(100).length === 0) { + throw new Error(`untrusted reload activated Calm presentation for ${name}`); + } +} +trusted = true; +await handlers.get("session_start")[0]({ reason: "reload" }, commandContext); +if (workingVisible !== false) { + throw new Error("trusted interactive reload did not immediately apply the persisted Calm preference"); +} +for (const { name, actual } of rows) { + if (actual.render(100).length !== 0) { + throw new Error(`trusted interactive reload did not immediately hide ${name}`); + } +} +await calmCommand.handler("", commandContext); JS ) status=$? [ "$status" -eq 0 ] || fail "Pi calm renderer and lifecycle contract failed: $out" [ -z "$out" ] || fail "Pi calm renderer test printed output: $out" - pass "Pi calm centralizes transcript visibility, preserves execution/export data, keeps native working visible, and persists its choice across session starts" + pass "Pi calm centralizes transcript visibility, preserves execution/export data, hides working activity, and persists its choice across session starts" } test_operational_followup_turn_e2e() { @@ -1067,6 +1821,8 @@ test_operational_followup_turn_e2e() { fi version=$(pi --version 2>/dev/null || true) record_pi_version_evidence "$version" "Pi operational follow-up E2E" + init_pi_tmux_server \ + || fail "Pi operational follow-up E2E could not prepare a tmux server that keeps exited Pi panes" project="$TMP_ROOT/followup-project" home="$TMP_ROOT/followup-home" @@ -1077,6 +1833,8 @@ test_operational_followup_turn_e2e() { cp "$EXT" "$project/.pi/extensions/fm-calm.ts" cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$TOOL_LAYOUT" "$project/.pi/extensions/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$project/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts" printf '%s\n' '{"followUpMode":"all"}' >"$config/settings.json" @@ -1203,38 +1961,52 @@ export default function (pi: ExtensionAPI): void { }); } TS + cat >"$project/followup-launch.sh" <<'SH' +#!/usr/bin/env bash +set -u +mode=$1 +session_flag=$2 +session_target=$3 +extensions=(-e ./followup-e2e.ts) +if [ "$mode" != absent ]; then + extensions=(-e ./.pi/extensions/fm-calm.ts "${extensions[@]}") +fi +exec env \ + FM_HOME=../followup-home \ + PI_CODING_AGENT_DIR=../followup-config \ + FM_OPERATIONAL_INPUT_SCRIPT=../fm-operational-input.sh \ + PI_OFFLINE=1 \ + pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions \ + "${extensions[@]}" "$session_flag" "$session_target" +SH + chmod +x "$project/followup-launch.sh" run_followup_case() { case_name=$1 calm_state=$2 label=$3 expected_notifications=$4 - local session_arg=${5:-} + local session_path=${5:-} local shape=${6:-single} - local extensions - - tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true - if [ "$calm_state" = absent ]; then - rm -f "$home/config/calm" - extensions='-e ./followup-e2e.ts' - elif [ "$calm_state" = default ]; then + local session_flag session_target + if [ "$calm_state" = absent ] || [ "$calm_state" = default ]; then rm -f "$home/config/calm" - extensions='-e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts' else printf '%s\n' "$calm_state" >"$home/config/calm" - extensions='-e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts' fi - if [ -z "$session_arg" ]; then - session_arg="--session-dir '$sessions/$label'" + if [ -z "$session_path" ]; then + session_flag=--session-dir + session_target="../followup-sessions/$label" mkdir -p "$sessions/$label" else - session_arg="--session '$session_arg'" + session_flag=--session + session_target="../followup-sessions/${session_path#"$sessions/"}" fi - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions $extensions $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -c "$project" -x 160 -y 36 \ + "./followup-launch.sh $calm_state $session_flag $session_target" i=0 - while [ "$i" -lt 120 ]; do + while [ "$i" -lt 240 ]; do pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) printf '%s\n' "$pane" | grep -Fq 'followup-e2e.ts' && break sleep 0.05 @@ -1258,7 +2030,7 @@ TS fail "Pi follow-up $label case did not process the monitoring notification" fi - pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" 2>/dev/null || true) [ "$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true)" -eq 1 ] \ || fail "Pi follow-up $label case rendered a duplicate captain answer" assert_contains "$pane" "CAPTAIN_PROMPT_$label" "Pi follow-up $label case hid the genuine captain prompt" @@ -1352,15 +2124,13 @@ JS fi tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit' tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter - sleep 0.2 - tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true + retire_pi_session "Pi follow-up $label case" } replay_exact_case() { - tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true printf '%s\n' on >"$home/config/calm" - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 160 -y 36 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./followup-e2e.ts --session '$exact_session'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -c "$project" -x 160 -y 36 \ + "./followup-launch.sh on --session ../followup-sessions/${exact_session#"$sessions/"}" i=0 while [ "$i" -lt 120 ]; do pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) @@ -1392,8 +2162,7 @@ if (users.length !== 1 || responses.length !== 1) { JS tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit' tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter - sleep 0.2 - tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true + retire_pi_session "Pi exact watcher replay" } run_followup_case loaded-on on loaded_on 1 @@ -1413,13 +2182,15 @@ JS test_hidden_block_geometry_e2e() { local project home config sessions session_file snapshot expanded_snapshot calm_off_snapshot restarted_snapshot - local version skill_line final_line gap i + local version prompt_line final_line gap i if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then echo "skip: pi or tmux not found for Pi Calm hidden-block geometry E2E" return 0 fi version=$(pi --version 2>/dev/null || true) record_pi_version_evidence "$version" "Pi Calm hidden-block geometry E2E" + init_pi_tmux_server \ + || fail "Pi Calm hidden-block geometry E2E could not prepare a tmux server that keeps exited Pi panes" project="$TMP_ROOT/geometry-project" home="$TMP_ROOT/geometry-home" @@ -1439,6 +2210,8 @@ test_hidden_block_geometry_e2e() { cp "$EXT" "$project/.pi/extensions/fm-calm.ts" cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$TOOL_LAYOUT" "$project/.pi/extensions/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$project/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts" printf '%s\n' on >"$home/config/calm" @@ -1515,9 +2288,8 @@ TS start_geometry_pi() { local session_arg=$1 - tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 100 -y 44 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' PI_OFFLINE=1 pi --approve --no-context-files --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./geometry-provider.ts $session_arg; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -c "$project" -x 100 -y 44 \ + "env FM_HOME='../geometry-home' PI_CODING_AGENT_DIR='../geometry-config' PI_OFFLINE=1 pi --approve --no-context-files --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./geometry-provider.ts $session_arg" } capture_geometry_viewport() { @@ -1553,22 +2325,22 @@ TS assert_geometry_gap() { local file=$1 label=$2 - skill_line=$(grep -n -m1 '\[skill\] ahoy' "$file" | cut -d: -f1) + prompt_line=$(grep -n -m1 'CALM_GEOMETRY_PROMPT' "$file" | cut -d: -f1) final_line=$(grep -n -m1 'CALM_GEOMETRY_FINAL' "$file" | cut -d: -f1) - [ -n "$skill_line" ] && [ -n "$final_line" ] \ - || fail "$label did not render the collapsed skill row and final assistant response" - gap=$((final_line - skill_line - 1)) + [ -n "$prompt_line" ] && [ -n "$final_line" ] \ + || fail "$label did not render the genuine captain prompt and final assistant response" + gap=$((final_line - prompt_line - 1)) [ "$gap" -eq 2 ] \ - || fail "$label left $gap rows between the collapsed skill row and final response instead of the two standard visible-row separators" + || fail "$label left $gap rows between the captain prompt and final response instead of the two standard visible-row separators" } - start_geometry_pi "--session-dir '$sessions'" + start_geometry_pi "--session-dir '../geometry-sessions'" wait_for_geometry_text "$snapshot" "geometry-provider.ts" \ || fail "Pi Calm hidden-block geometry E2E did not reach the ready composer" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/calm-geometry-e2e' tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter sleep 0.1 - tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/skill:ahoy' + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/skill:ahoy CALM_GEOMETRY_PROMPT' tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter wait_for_geometry_text "$snapshot" "visible row two" \ || fail "Pi Calm hidden-block geometry E2E did not complete the /skill:ahoy turn" @@ -1579,7 +2351,8 @@ TS sleep 0.05 i=$((i + 1)) done - assert_contains "$(cat "$snapshot")" "[skill] ahoy" "Calm hid the collapsed skill header" + assert_not_contains "$(cat "$snapshot")" "[skill] ahoy" "Calm left the skill invocation block visible" + assert_contains "$(cat "$snapshot")" "CALM_GEOMETRY_PROMPT" "Calm hid the captain message carried by the skill invocation" assert_contains "$(cat "$snapshot")" "CALM_GEOMETRY_FINAL" "Calm hid the final assistant response" assert_not_contains "$(cat "$snapshot")" "Thinking..." "Calm left a collapsed thinking label visible" assert_not_contains "$(cat "$snapshot")" "probe-one.txt" "Calm left a tool-call row visible" @@ -1603,9 +2376,11 @@ TS assert_geometry_gap "$snapshot" "reloaded native Calm transcript" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" C-t - wait_for_geometry_text "$expanded_snapshot" "CALM_GEOMETRY_THINKING_ONE" \ - || fail "thinking expansion did not restore Calm-hidden reasoning" + sleep 0.1 + capture_geometry_viewport "$expanded_snapshot" + assert_not_contains "$(cat "$expanded_snapshot")" "CALM_GEOMETRY_THINKING_ONE" "thinking expansion exposed internal reasoning under Calm" assert_not_contains "$(cat "$expanded_snapshot")" "probe-one.txt" "thinking expansion restored Calm-hidden tool rows" + assert_not_contains "$(cat "$expanded_snapshot")" "[skill] ahoy" "thinking expansion restored the Calm-hidden skill invocation block" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" C-t i=0 while [ "$i" -lt 120 ]; do @@ -1622,12 +2397,15 @@ TS wait_for_geometry_text "$calm_off_snapshot" "probe-one.txt" \ || fail "turning Calm off did not restore the tool-call row" assert_contains "$(cat "$calm_off_snapshot")" "Thinking..." "turning Calm off did not restore collapsed thinking labels" + assert_contains "$(cat "$calm_off_snapshot")" "[skill] ahoy" "turning Calm off did not restore the skill invocation block" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/calm' tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter i=0 while [ "$i" -lt 120 ]; do capture_geometry_viewport "$snapshot" - if ! grep -Fq "probe-one.txt" "$snapshot" && ! grep -Fq "Thinking..." "$snapshot"; then + if ! grep -Fq "probe-one.txt" "$snapshot" && + ! grep -Fq "[skill] ahoy" "$snapshot" && + ! grep -Fq "Thinking..." "$snapshot"; then break fi sleep 0.05 @@ -1637,19 +2415,161 @@ TS tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit' tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter - sleep 0.2 - tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true - start_geometry_pi "--session '$session_file'" + retire_pi_session "Pi Calm hidden-block geometry E2E" + start_geometry_pi "--session '../geometry-sessions/${session_file#"$sessions/"}'" wait_for_geometry_text "$restarted_snapshot" "visible row two" \ || fail "Pi did not restore the Calm hidden-block geometry session" assert_not_contains "$(cat "$restarted_snapshot")" "Thinking..." "restart restored a collapsed thinking label under Calm" assert_not_contains "$(cat "$restarted_snapshot")" "probe-one.txt" "restart restored a tool-call row under Calm" + assert_not_contains "$(cat "$restarted_snapshot")" "[skill] ahoy" "restart restored the skill invocation block under Calm" assert_geometry_gap "$restarted_snapshot" "restarted native Calm transcript" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit' tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter - sleep 0.2 - tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true - pass "Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering" + retire_pi_session "restarted Pi Calm hidden-block geometry E2E" + pass "Pi Calm native /skill:ahoy geometry keeps every thinking, tool, and skill-invocation block at zero height while preserving the captain's own message, history, restart, and Calm-off rendering" +} + +test_nonconversation_rows_e2e() { + local project home config session_file version now snapshot restored_snapshot rehidden_snapshot i + local marker prompt_line reply_line gap + if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then + echo "skip: pi or tmux not found for Pi Calm non-conversation row E2E" + return 0 + fi + version=$(pi --version 2>/dev/null || true) + record_pi_version_evidence "$version" "Pi Calm non-conversation row E2E" + init_pi_tmux_server \ + || fail "Pi Calm non-conversation row E2E could not prepare a tmux server that keeps exited Pi panes" + + project="$TMP_ROOT/rows-project" + home="$TMP_ROOT/rows-home" + config="$TMP_ROOT/rows-config" + session_file="$TMP_ROOT/rows-session.jsonl" + snapshot="$TMP_ROOT/rows-calm-on.txt" + restored_snapshot="$TMP_ROOT/rows-calm-off.txt" + rehidden_snapshot="$TMP_ROOT/rows-calm-rehidden.txt" + mkdir -p "$project/.pi/extensions/lib" "$home/config" "$config" + fm_git_init_commit "$project" + cp "$EXT" "$project/.pi/extensions/fm-calm.ts" + cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$TOOL_LAYOUT" "$project/.pi/extensions/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$project/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" + cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts" + printf '%s\n' on >"$home/config/calm" + printf '%s\n' '{"terminal":{"clearOnShrink":false}}' >"$config/settings.json" + cat >"$project/rows-probe.ts" <<'TS' +import { + getMarkdownTheme, + type ExtensionAPI, + UserMessageComponent, +} from "@earendil-works/pi-coding-agent"; + +export default function (pi: ExtensionAPI): void { + pi.registerEntryRenderer<{ text?: string }>( + "calm-unrelated-entry-e2e", + (entry) => new UserMessageComponent(entry.data?.text ?? "", getMarkdownTheme()), + ); +} +TS + now=$(date -u +%Y-%m-%dT%H:%M:%S.000Z) + cat >"$session_file" <"$1" 2>/dev/null + } + + wait_for_rows_text() { + local file=$1 text=$2 attempt=0 + while [ "$attempt" -lt 200 ]; do + capture_rows_viewport "$file" || true + grep -Fq "$text" "$file" 2>/dev/null && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + return 1 + } + + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -c "$project" -x 120 -y 90 \ + "env FM_HOME='../rows-home' PI_CODING_AGENT_DIR='../rows-config' PI_OFFLINE=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions -e ./.pi/extensions/fm-calm.ts -e ./rows-probe.ts --session '../rows-session.jsonl'" + wait_for_rows_text "$snapshot" "CALM_ROWS_BASH_OUTPUT" \ + || fail "Pi Calm non-conversation row E2E did not restore the session transcript" + assert_contains "$(cat "$snapshot")" "CALM_ROWS_PROMPT" "Calm hid the genuine captain prompt" + assert_contains "$(cat "$snapshot")" "CALM_ROWS_REPLY" "Calm hid the genuine assistant reply" + assert_contains "$(cat "$snapshot")" "CALM_ROWS_BASH_COMMAND" "Calm hid the captain's own !bash command" + assert_contains "$(cat "$snapshot")" "CALM_ROWS_BASH_OUTPUT" "Calm hid the output of the captain's own !bash command" + for marker in \ + CALM_ROWS_CUSTOM_MESSAGE \ + CALM_ROWS_CUSTOM_ENTRY \ + "[branch]" \ + "[compaction]" + do + assert_not_contains "$(cat "$snapshot")" "$marker" "Calm rendered the non-conversation row $marker" + done + prompt_line=$(grep -n -m1 'CALM_ROWS_PROMPT' "$snapshot" | cut -d: -f1) + reply_line=$(grep -n -m1 'CALM_ROWS_REPLY' "$snapshot" | cut -d: -f1) + [ -n "$prompt_line" ] && [ -n "$reply_line" ] \ + || fail "Pi Calm non-conversation row E2E lost its conversation anchors" + gap=$((reply_line - prompt_line - 1)) + [ "$gap" -eq 2 ] \ + || fail "Calm left $gap rows between the captain prompt and reply instead of the two standard visible-row separators" + + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/calm' + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter + wait_for_rows_text "$restored_snapshot" "CALM_ROWS_CUSTOM_ENTRY" \ + || fail "turning Calm off did not restore the unrelated custom entry row" + for marker in \ + CALM_ROWS_CUSTOM_MESSAGE \ + CALM_ROWS_CUSTOM_ENTRY \ + "[branch]" \ + "[compaction]" + do + assert_contains "$(cat "$restored_snapshot")" "$marker" "turning Calm off did not restore the non-conversation row $marker" + done + assert_contains "$(cat "$restored_snapshot")" "CALM_ROWS_BASH_COMMAND" "turning Calm off lost the captain's own !bash command" + assert_contains "$(cat "$restored_snapshot")" "CALM_ROWS_BASH_OUTPUT" "turning Calm off lost the output of the captain's own !bash command" + [ "$(cat "$home/config/calm")" = off ] || fail "/calm did not persist the inactive choice" + + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/calm' + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter + i=0 + while [ "$i" -lt 120 ]; do + capture_rows_viewport "$rehidden_snapshot" || true + if ! grep -Fq "CALM_ROWS_CUSTOM_ENTRY" "$rehidden_snapshot" && + ! grep -Fq "/calm" "$rehidden_snapshot"; then + break + fi + sleep 0.05 + i=$((i + 1)) + done + for marker in \ + CALM_ROWS_CUSTOM_MESSAGE \ + CALM_ROWS_CUSTOM_ENTRY \ + "[branch]" \ + "[compaction]" + do + assert_not_contains "$(cat "$rehidden_snapshot")" "$marker" "re-enabling Calm did not redraw the loaded non-conversation row $marker at zero height" + done + assert_contains "$(cat "$rehidden_snapshot")" "CALM_ROWS_PROMPT" "re-enabling Calm removed the genuine captain prompt" + assert_contains "$(cat "$rehidden_snapshot")" "CALM_ROWS_REPLY" "re-enabling Calm removed the genuine assistant reply" + assert_contains "$(cat "$rehidden_snapshot")" "CALM_ROWS_BASH_COMMAND" "re-enabling Calm removed the captain's own !bash command" + assert_contains "$(cat "$rehidden_snapshot")" "CALM_ROWS_BASH_OUTPUT" "re-enabling Calm removed the output of the captain's own !bash command" + [ "$(cat "$home/config/calm")" = on ] || fail "the second /calm did not persist the active choice" + + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l '/quit' + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Enter + retire_pi_session "Pi Calm non-conversation row E2E" + pass "Pi Calm renders unrelated custom message and entry and compaction and branch summary rows at zero height, keeps the captain's own !bash command and output visible, restores hidden rows Calm-off, and redraws loaded rows on each toggle" } test_interactive_terminal_e2e() { @@ -1660,6 +2580,8 @@ test_interactive_terminal_e2e() { fi version=$(pi --version 2>/dev/null || true) record_pi_version_evidence "$version" "Pi calm interactive E2E" + init_pi_tmux_server \ + || fail "Pi calm interactive E2E could not prepare a tmux server that keeps exited Pi panes" project="$TMP_ROOT/e2e-project" config="$TMP_ROOT/e2e-config" @@ -1684,6 +2606,8 @@ test_interactive_terminal_e2e() { cp "$EXT" "$project/.pi/extensions/fm-calm.ts" cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$TOOL_LAYOUT" "$project/.pi/extensions/lib/fm-calm-tool-layout.ts" + cp "$NONCONVERSATION_LAYOUT" "$project/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$project/.pi/extensions/lib/fm-operational-input.ts" cp "$WATCH_EXT" "$project/.pi/extensions/fm-primary-pi-watch.ts" @@ -1840,8 +2764,8 @@ TS {"type":"message","id":"a0000016","parentId":"a0000015","timestamp":"$now","message":{"role":"assistant","content":[{"type":"text","text":"The deterministic tool example is complete."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":2,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":3,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":16}} JSON - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -c "$project" -x 180 -y 44 \ + "env FM_HOME='../e2e-home' PI_CODING_AGENT_DIR='../e2e-config' FM_OPERATIONAL_INPUT_SCRIPT='../fm-operational-input.sh' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '../calm-session.jsonl'" wait_for_text "$default_snapshot" "The deterministic tool example is complete." \ || fail "Pi calm E2E did not reach the restored session transcript" assert_contains "$(cat "$default_snapshot")" "CALM_E2E_OUTPUT" "calm mode was not off by default" @@ -2097,13 +3021,15 @@ JS active_screen_wait=0 while [ "$active_screen_wait" -lt 120 ]; do tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$working_snapshot" - if grep -Fq "Working..." "$working_snapshot"; then + if grep -Fq "CALM_WORKING_E2E_PROMPT" "$working_snapshot" && + ! grep -Fq "/calm-working-e2e" "$working_snapshot"; then break fi - sleep 0.025 + sleep 0.01 active_screen_wait=$((active_screen_wait + 1)) done - assert_contains "$(cat "$working_snapshot")" "Working..." "Calm hid Pi's built-in Working row during a real provider wait" + assert_contains "$(cat "$working_snapshot")" "CALM_WORKING_E2E_PROMPT" "the real provider wait hid the genuine captain prompt" + assert_not_contains "$(cat "$working_snapshot")" "Working..." "Calm rendered Pi's built-in Working row during a real provider wait" assert_not_contains "$(cat "$working_snapshot")" "calm transcript" "the real provider wait showed a persistent Calm status row" assert_not_contains "$(cat "$working_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "the real provider wait restored a hidden operational row" wait_for_text "$working_response_snapshot" "CALM_WORKING_E2E_RESPONSE" \ @@ -2111,12 +3037,10 @@ JS tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s - wait_for_text "$working_response_snapshot" "PI_EXIT=0" \ - || fail "Pi did not exit cleanly before the Calm persistence restart" - tmux -L "$TMUX_SOCKET" kill-session -t "$TMUX_SESSION" 2>/dev/null || true + retire_pi_session "Pi calm interactive E2E" - tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -x 180 -y 44 \ - "cd '$project' && env FM_HOME='$home' PI_CODING_AGENT_DIR='$config' FM_OPERATIONAL_INPUT_SCRIPT='$OPERATIONAL_INPUT' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '$session_file'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 30" + tmux -L "$TMUX_SOCKET" new-session -d -s "$TMUX_SESSION" -c "$project" -x 180 -y 44 \ + "env FM_HOME='../e2e-home' PI_CODING_AGENT_DIR='../e2e-config' FM_OPERATIONAL_INPUT_SCRIPT='../fm-operational-input.sh' PI_OFFLINE=1 pi --approve --no-skills --no-prompt-templates --no-context-files --session '../calm-session.jsonl'" wait_for_text "$restarted_snapshot" "CALM_WORKING_E2E_RESPONSE" \ || fail "Pi did not restore the persisted session after restart" assert_not_contains "$(cat "$restarted_snapshot")" "CALM_E2E_OUTPUT" "restart/resume reset Calm and restored a tool row" @@ -2143,7 +3067,8 @@ JS [ "$(cat "$home/config/calm")" = off ] || fail "/calm after restart did not persist the inactive choice" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s - pass "Pi calm native E2E keeps Working and captain turns visible, hides exact operational user rows without changing persistence, restores them Calm-off, survives restart, and preserves export plus Ctrl+O behavior" + retire_pi_session "restarted Pi calm interactive E2E" + pass "Pi calm native E2E hides working activity, keeps captain turns visible, hides exact operational user rows without changing persistence, restores them Calm-off, survives restart, and preserves export plus Ctrl+O behavior" } test_static_contract @@ -2151,7 +3076,10 @@ test_home_resolution test_pi_compat_no_upper_bound test_pi_compat_degraded_adapter test_pi_compat_missing_adapter_exports +test_adapter_reload_turn_scope +test_hidden_row_spacer_reload test_rendering_and_session_lifecycle test_operational_followup_turn_e2e test_hidden_block_geometry_e2e +test_nonconversation_rows_e2e test_interactive_terminal_e2e diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index a365f06b9c..b32fb803d6 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -71,6 +71,24 @@ wait_for_exact_line() { return 1 } +wait_for_exact_line_without() { + local expected=$1 forbidden=$2 attempts=${3:-240} i=0 pane + while [ "$i" -lt "$attempts" ]; do + pane=$(capture) + if printf '%s\n' "$pane" | grep -Fq "$forbidden"; then + printf '%s\n' "$pane" >&2 + return 2 + fi + if printf '%s\n' "$pane" | grep -Fxq " $expected"; then + return 0 + fi + sleep 0.25 + i=$((i + 1)) + done + capture >&2 + return 1 +} + lab_pid_is_safe() { local pid=$1 command command=$(ps -p "$pid" -o command= 2>/dev/null || true) @@ -252,7 +270,9 @@ mkdir -p "$PROJECT/.pi/extensions/lib" cp "$ROOT/.pi/extensions/fm-calm.ts" "$PROJECT/.pi/extensions/fm-calm.ts" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$PROJECT/.pi/extensions/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-assistant-layout.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-tool-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-tool-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$PROJECT/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$PROJECT/.pi/extensions/lib/fm-operational-input.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$PROJECT/.pi/extensions/fm-primary-turnend-guard.ts" @@ -273,25 +293,21 @@ while [ "$i" -lt 120 ]; do done [ -f "$HOME_DIR/state/.pi-turnend-extension-loaded" ] || fail "Pi turn-end extension did not load" [ -f "$HOME_DIR/state/.pi-watch-extension-loaded" ] || fail "Pi watcher extension did not load" -wait_for_text "(openai-codex)" 120 || fail "Pi did not reach its ready composer" +# Pi's ready composer footer names the pinned model; 0.82.x prints it without the +# provider prefix this probe used to match, so pin the model id itself. +wait_for_text "gpt-5.6-sol" 120 || fail "Pi did not reach its ready composer" sleep 1 send_prompt "/calm" sleep 0.2 -send_prompt "Reply exactly CALM_LIVE_WORKING_VISIBLE" -i=0 -while [ "$i" -lt 240 ]; do - pane=$(capture) - if printf '%s\n' "$pane" | grep -Fq "Working..."; then - break - fi - sleep 0.05 - i=$((i + 1)) -done -printf '%s\n' "$pane" | grep -Fq "Working..." \ - || fail "Calm hid Pi's built-in Working row on the credentialed provider path" -wait_for_exact_line "CALM_LIVE_WORKING_VISIBLE" 120 \ - || fail "Pi did not settle the Calm Working-row provider probe" +send_prompt "Reply exactly CALM_LIVE_REPLY" +calm_working_rc=0 +wait_for_exact_line_without "CALM_LIVE_REPLY" "Working..." 240 || calm_working_rc=$? +case "$calm_working_rc" in + 0) ;; + 2) fail "Calm rendered Pi's built-in Working row on the credentialed provider path" ;; + *) fail "Pi did not settle the Calm hidden-working provider probe" ;; +esac pane=$(capture) printf '%s\n' "$pane" | grep -Fq "calm transcript" \ && fail "Calm added a persistent Calm status row on the credentialed provider path" @@ -336,4 +352,4 @@ wait_for_text "PI_EXIT=0" 60 || fail "Pi did not exit cleanly" wait_pid_dead "$watcher_pid" || fail "watcher child survived clean Pi exit" wait_pid_dead "$arm_pid" || fail "arm child survived clean Pi exit" -printf 'ok - Pi %s live E2E covered native Calm Working visibility, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION" +printf 'ok - Pi %s live E2E covered native Calm working suppression, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION" diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index 3ff81f63ef..5c8af94017 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -30,7 +30,9 @@ cp "$ROOT/.pi/extensions/fm-calm.ts" "$TMP_ROOT/fm-calm.ts" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$TMP_ROOT/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$TMP_ROOT/fm-primary-turnend-guard.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$TMP_ROOT/lib/fm-calm-assistant-layout.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-nonconversation-layout.ts" "$TMP_ROOT/lib/fm-calm-nonconversation-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$TMP_ROOT/lib/fm-calm-operational-user-layout.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-tool-layout.ts" "$TMP_ROOT/lib/fm-calm-tool-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$TMP_ROOT/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$TMP_ROOT/lib/fm-operational-input.ts" ln -s "$PI_PACKAGE_DIR" "$TMP_ROOT/node_modules/@earendil-works/pi-coding-agent"