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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 0 additions & 32 deletions .pi/extensions/fm-calm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@ import type { TSchema } from "typebox";
import {
calmPresentationHides,
calmPresentationIsActive,
classifyFirstmateSyntheticInput,
deliverFirstmateSyntheticInput,
FIRSTMATE_CALM_PRESENTATION_EVENT,
FIRSTMATE_PI_LAUNCH_BRIEF_ENV,
registerFirstmateSyntheticPresentation,
setCalmPresentation,
setCalmStockExportRendering,
Expand Down Expand Up @@ -76,7 +73,6 @@ const root = resolve(extensionDir, "../..");

export default function (pi: ExtensionAPI) {
let exportRendering = false;
let launchBriefContent: string | undefined;
let removeTerminalInputHandler: (() => void) | undefined;

const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root;
Expand Down Expand Up @@ -104,15 +100,6 @@ export default function (pi: ExtensionAPI) {
}
};

const launchBriefPath = process.env[FIRSTMATE_PI_LAUNCH_BRIEF_ENV];
if (launchBriefPath) {
try {
launchBriefContent = readFileSync(launchBriefPath, "utf8").replace(/\n+$/, "");
} catch {
launchBriefContent = undefined;
}
}

const publishPresentationState = (): void => {
pi.events.emit(FIRSTMATE_CALM_PRESENTATION_EVENT, {
active: calmPresentationIsActive(),
Expand Down Expand Up @@ -230,25 +217,6 @@ export default function (pi: ExtensionAPI) {
registerBuiltIn(createFindToolDefinition);
registerBuiltIn(createLsToolDefinition);

pi.on("input", (event, ctx) => {
if (event.images && event.images.length > 0) return { action: "continue" };
const kind = classifyFirstmateSyntheticInput(event.text, event.source, launchBriefContent);
if (!kind) return { action: "continue" };
if (kind === "launch-brief") launchBriefContent = undefined;

const redrawPresentation = (): void => {
const expanded = ctx.ui.getToolsExpanded();
ctx.ui.setToolsExpanded(!expanded);
ctx.ui.setToolsExpanded(expanded);
};
deliverFirstmateSyntheticInput(pi, event.text, kind, {
deliverAs: event.streamingBehavior ?? "followUp",
redrawPresentation,
triggerTurn: true,
});
return { action: "handled" };
});

pi.on("session_start", (_event, ctx) => {
exportRendering = false;
setCalmPresentation(loadCalmPreference());
Expand Down
81 changes: 3 additions & 78 deletions .pi/extensions/lib/fm-calm-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,6 @@ import {
type ExtensionAPI,
UserMessageComponent,
} from "@earendil-works/pi-coding-agent";
import {
classifyFirstmateOperationalText,
encodeFirstmateOperationalInput,
} from "./fm-operational-input.ts";

export { encodeFirstmateOperationalInput } from "./fm-operational-input.ts";

export const CALM_TRANSCRIPT_CLASSES = [
"genuine-user-prompt",
"genuine-agent-response",
Expand Down Expand Up @@ -41,10 +34,10 @@ const CALM_VISIBLE_CLASSES = new Set<CalmTranscriptClass>([
"working-status",
]);

export const FIRSTMATE_SYNTHETIC_CONTEXT_TYPE = "firstmate-synthetic-input";
// Legacy session entries from Calm versions before 2026-07-23 retain this
// presentation type. New operational input stays user-role and is never rerouted.
export const FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE = "firstmate-synthetic-input-presentation";
export const FIRSTMATE_CALM_PRESENTATION_EVENT = "firstmate:calm-presentation";
export const FIRSTMATE_PI_LAUNCH_BRIEF_ENV = "FM_FIRSTMATE_PI_LAUNCH_BRIEF";

export type CalmPresentationState = {
active: boolean;
Expand All @@ -62,21 +55,12 @@ export const FIRSTMATE_SYNTHETIC_KINDS = [
] as const;

export type FirstmateSyntheticKind = (typeof FIRSTMATE_SYNTHETIC_KINDS)[number];
export type FirstmateInputSource = "interactive" | "rpc" | "extension";

type SyntheticDeliveryOptions = {
deliverAs?: "steer" | "followUp" | "nextTurn";
redrawPresentation?: () => void;
triggerTurn?: boolean;
};

type FirstmateSyntheticPresentation = {
content: string;
kind: FirstmateSyntheticKind;
};

let calm = false;
let mountingSyntheticPresentation = false;
let stockExportRendering = false;

export function calmTranscriptClassIsVisible(itemClass: CalmTranscriptClass): boolean {
Expand All @@ -99,73 +83,14 @@ export function calmPresentationHides(itemClass: CalmTranscriptClass): boolean {
return calm && !stockExportRendering && !calmTranscriptClassIsVisible(itemClass);
}

function isFirstmateSyntheticKind(value: string): value is FirstmateSyntheticKind {
return (FIRSTMATE_SYNTHETIC_KINDS as readonly string[]).includes(value);
}

export function classifyFirstmateSyntheticInput(
content: string,
source: FirstmateInputSource,
launchBriefContent?: string,
): FirstmateSyntheticKind | undefined {
const classified = classifyFirstmateOperationalText(content);
if (classified !== undefined && isFirstmateSyntheticKind(classified)) return classified;

// Keep the exact per-process origin fallback only for positional launch
// commands created before the typed protocol.
if (
source === "interactive" &&
launchBriefContent !== undefined &&
content === launchBriefContent
) {
return "launch-brief";
}
return undefined;
}

export function registerFirstmateSyntheticPresentation(pi: ExtensionAPI): void {
pi.registerEntryRenderer<FirstmateSyntheticPresentation>(
FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE,
(entry) => {
if (
calmPresentationHides("synthetic-user") &&
!mountingSyntheticPresentation
) {
return undefined;
}
if (calmPresentationHides("synthetic-user")) return undefined;
const data = entry.data;
if (!data || typeof data.content !== "string") return undefined;
return new UserMessageComponent(data.content, getMarkdownTheme());
},
);
}

export function deliverFirstmateSyntheticInput(
pi: ExtensionAPI,
content: string,
kind: FirstmateSyntheticKind,
options: SyntheticDeliveryOptions = {},
): void {
const mountForRedraw =
calmPresentationHides("synthetic-user") &&
options.redrawPresentation !== undefined;
mountingSyntheticPresentation = mountForRedraw;
try {
pi.appendEntry<FirstmateSyntheticPresentation>(FIRSTMATE_SYNTHETIC_PRESENTATION_TYPE, {
content,
kind,
});
} finally {
mountingSyntheticPresentation = false;
}
if (mountForRedraw) options.redrawPresentation?.();
pi.sendMessage(
{
customType: FIRSTMATE_SYNTHETIC_CONTEXT_TYPE,
content,
display: false,
details: { kind },
},
options,
);
}
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,13 @@ pi
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.
`/calm` is a conversation-focused transcript toggle whose last choice persists for the effective Firstmate home across Pi session starts and resumes.
While active, it keeps Pi's built-in `Working...` activity visible and uses Pi's supported presentation APIs to hide collapsed thinking labels, all seven built-in tool shells, the Firstmate watcher tool shell, and canonically typed Firstmate operational inputs.
Calm adds no persistent status row, and hidden Firstmate operational rows are removed without reserving vertical space.
Every injected input remains in model context and session storage.
Inputs that ordinarily render as user rows use a TUI-only custom entry so Calm can hide and restore their presentation without changing delivery; the session-start nudge remains on its existing non-displayed custom-message path.
While active, it keeps Pi's built-in `Working...` activity visible and uses Pi's supported presentation APIs to hide collapsed thinking labels, all seven built-in tool shells, the Firstmate watcher tool shell, and compatible presentation entries stored by earlier Calm versions.
Calm adds no persistent status row, and controllable hidden rows are removed without reserving vertical space.
Canonically typed Firstmate operational input remains an ordinary user-role message with its exact origin, ordering, model authority, and session persistence unchanged.
Pi 0.81.1 exposes no supported renderer for ordinary user rows, so those operational rows remain visible rather than being semantically rerouted or risking a duplicate or lost turn; the session-start nudge remains on its existing non-displayed custom-message path.
Toggling off restores ordinary rendering, and `Ctrl+O` expansion behavior stays unchanged.
Tool execution, model context, session storage, diagnostics, and `/export` and `/share` operation remain unchanged.
Exports and shares remain complete session artifacts: their main message transcript may omit hidden operational custom messages, while serialized session data and Pi 0.81.1's sidebar tree retain the full operational text.
Exports and shares remain complete session artifacts, including visible current operational user messages and any legacy hidden custom messages retained in serialized session data and Pi 0.81.1's sidebar tree.
Pi 0.81.1 still exposes no global transcript filter, so expanded reasoning, its reserved spacing, built-in tool images, user-bash rows, skill and summary rows, status notices, and arbitrary custom-tool or extension rows remain supported-API boundaries.
The version-scoped feasibility evidence and complete render taxonomy are recorded in [docs/calm-mode-feasibility.md](docs/calm-mode-feasibility.md).

Expand Down
8 changes: 2 additions & 6 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@
# __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home
# __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home
# __OPINPUT__ absolute path to the canonical operational-input encoder
# __PIBRIEFENV__ shell assignment identifying the unchanged Pi positional brief
# Per-harness turn-end hooks are installed automatically; some live outside the worktree.
# grok uses a firstmate-owned global hook under ${GROK_HOME:-$HOME/.grok}/hooks
# plus a gitignored .fm-grok-turnend worktree pointer and a state token.
Expand Down Expand Up @@ -432,9 +431,9 @@ launch_template() {
opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;;
pi)
if [ "$kind" = secondmate ]; then
printf '%s' '__PIBRIEFENV__ pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
else
printf '%s' '__PIBRIEFENV__ pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
printf '%s' 'pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
fi
;;
# grok (Grok Build TUI): a positional prompt starts the supervised interactive
Expand Down Expand Up @@ -1260,8 +1259,6 @@ sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts")
sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts")
sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts")
sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh")
PIBRIEFENV=
[ "$HARNESS" != pi ] || PIBRIEFENV="FM_FIRSTMATE_PI_LAUNCH_BRIEF=$sq_brief"
MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL")
EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT")
LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG}
Expand All @@ -1272,7 +1269,6 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext}
LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend}
LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch}
LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput}
LAUNCH=${LAUNCH//__PIBRIEFENV__/$PIBRIEFENV}
if [ "$KIND" = secondmate ]; then
sq_home=$(shell_quote "$PROJ_ABS")
LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH"
Expand Down
Loading
Loading