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
33 changes: 30 additions & 3 deletions .pi/extensions/fm-calm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,22 @@ export default function (pi: ExtensionAPI) {

registerFirstmateSyntheticPresentation(pi);

// Every on-screen tool row Calm currently presents, keyed by the row-local state Pi
// hands its render slots, so Calm can repaint exactly those rows without touching
// Pi's transcript. Pi can re-render a row at any time - the built-in edit row
// invalidates itself once its diff is ready - so a row can be redrawn during the
// window where /export forces stock rendering and keep that stock content
// afterwards. Rows Pi's exporter renders are excluded: those use throwaway state
// and never appear on screen. Cleared per session lifetime, which rebuilds the rows.
const calmToolRowRepaints = new Map<object, () => void>();
const rememberCalmToolRow = (state: object, invalidate: unknown): void => {
if (exportRendering || typeof invalidate !== "function") return;
calmToolRowRepaints.set(state, invalidate as () => void);
};
const repaintCalmToolRows = (): void => {
for (const invalidate of calmToolRowRepaints.values()) invalidate();
};

function wrapBuiltIn<TParams extends TSchema, TDetails, TState>(
factory: DefinitionFactory<TParams, TDetails, TState>,
): ToolDefinition<TParams, TDetails, TState> {
Expand Down Expand Up @@ -262,6 +278,7 @@ export default function (pi: ExtensionAPI) {
theme: RenderTheme<TParams, TDetails, TState>,
context: RenderContext<TParams, TDetails, TState>,
) {
rememberCalmToolRow(context.state as object, context.invalidate);
if (exportRendering) return originalRenderCall(args, theme, context);
if (calmPresentationHides("assistant-tool-call")) return new Container();
if (originalSelfShell) return originalRenderCall(args, theme, context);
Expand All @@ -280,6 +297,7 @@ export default function (pi: ExtensionAPI) {
theme: RenderTheme<TParams, TDetails, TState>,
context: RenderContext<TParams, TDetails, TState>,
) {
rememberCalmToolRow(context.state as object, context.invalidate);
if (exportRendering) return originalRenderResult(result, options, theme, context);
if (calmPresentationHides("tool-result")) return new Container();
if (originalSelfShell) return originalRenderResult(result, options, theme, context);
Expand Down Expand Up @@ -392,6 +410,7 @@ export default function (pi: ExtensionAPI) {

pi.on("session_start", (_event, ctx) => {
reportBuiltInLosses();
calmToolRowRepaints.clear();
exportRendering = false;
setCalmPresentation(loadCalmPreference());
setCalmStockExportRendering(false);
Expand Down Expand Up @@ -423,9 +442,17 @@ export default function (pi: ExtensionAPI) {
exportRendering = false;
setCalmStockExportRendering(false);
publishPresentationState();
const expanded = ctx.ui.getToolsExpanded();
ctx.ui.setToolsExpanded(!expanded);
ctx.ui.setToolsExpanded(expanded);
// Repaint the rows Calm presents, never the whole transcript. Pi's export
// prints "Session exported to: <path>" immediately before this runs, and
// since Pi 0.83.0 setToolsExpanded() emits its own status line; consecutive
// status lines coalesce, so a tools-expanded round-trip here silently
// overwrote the confirmation and left the captain no record of where their
// export landed. Invalidating the rows individually repaints the same
// content with no status line of its own, and setStatus adds the redraw the
// rows that consult Calm live in render(), such as operational user rows,
// need without appending anything to the transcript.
repaintCalmToolRows();
ctx.ui.setStatus("firstmate-calm", undefined);
}, 0);
});
});
Expand Down
65 changes: 64 additions & 1 deletion docs/calm-mode-feasibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ Calm classifies only at Pi's transcript-presentation owner through the canonical

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.
Toggling Calm cycles tool expansion and restores its original value, which rebuilds controllable rows and leaves final `Ctrl+O` state unchanged.
Returning from stock export rendering instead invalidates only the tool rows Calm currently presents: Pi 0.83.0 made every expansion change emit its own status line, and Pi coalesces consecutive status lines, so an expansion cycle there overwrote the `Session exported to:` confirmation the export had just printed.
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.

Expand Down Expand Up @@ -437,3 +438,65 @@ right-heading: <| over \__/~~-~~~-~

At 3 columns the sprite fell back to a single exact-width row, `<|~`.
Escape aborted the run leaving `Operation aborted`, no boat, and no stale sprite rows, and the trial exited 0 after deleting its temporary state.

## 2026-08-15 Pi 0.84.1 export-confirmation verification

Pi 0.83.0 added a status line to every tool-expansion change, which silently broke the `/export` confirmation under Calm on Pi 0.83.0 and newer.
Pi appends `Session exported to: <path>` through `showStatus`, which updates the previous status line in place whenever two status messages arrive back to back with nothing else added to the chat.
Calm's post-export redraw cycled tool expansion on the macrotask right after that, so both of its expansion status lines coalesced over the confirmation and left no record of where the export landed.
Calm now invalidates only the tool rows it presents and requests the redraw through `setStatus`, neither of which appends to the transcript.

Pi source evidence, from the installed release's own changelog and interactive mode:

```text
$ pi --version
0.84.1

CHANGELOG.md, 0.83.0 "Fixed":
- Added a status line when the tool output expansion is toggled ([#7180](https://github.com/earendil-works/pi/issues/7180)).

interactive-mode setToolsExpanded:
setToolsExpanded(expanded) {
if (expanded === this.toolOutputExpanded)
return;
...
this.showStatus(`Tool output: ${expanded ? "expanded" : "collapsed"}`);
}
```

The regression is pinned by the real-terminal `/export` case in `tests/fm-calm-pi-extension.test.sh`, which now asserts the confirmation is still on screen after Calm's redraw has settled and that the redraw restored every Calm-hidden row.
Reverting only the extension fix fails that assertion deterministically rather than racing the roughly 50ms window the confirmation used to survive:

```text
not ok - Calm's post-export repaint overwrote Pi's export confirmation (missing: 'Session exported to: .../calm-export.html')
```

```text
$ tests/fm-calm-pi-extension.test.sh
ok - Pi calm resolves its persistent home independently of Pi's launch directory
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 reach the independent adapter degradation path
ok - Calm registers none of its 7 built-in tool wrappers at load while config/calm is off, and all 7 synchronously at load while config/calm is on
ok - Calm's first same-session /calm activation claims every uncontested built-in, leaves a foreign bash tool fully intact and callable, warns prominently and logs the contested name, and only rows constructed before that activation - the documented bound - fail to retroactively collapse
ok - Pi calm centralizes transcript visibility, preserves execution/export data, keeps Pi's stock working row visible while no run is active, and persists its choice across session starts
ok - Pi calm on collapses mid-turn assistant working notes to zero height while Calm off keeps them, leaves streaming, truncated-final, and genuine final replies untouched, never mutates the messages, ignores every /calm argument, and restores a legacy persisted max as ordinary Calm on
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 working ship moves on a slow independent cadence over faster fixed-cell blue water, paints the complete boat standard yellow with balanced resets, keeps ANSI-stripped width exact, flips the directional sail on the exact bounce at both edges and every width, clamps visible and hidden resizes, falls back deterministically when narrow, freezes and resumes column/direction across settle/start without hidden-time jumps or duplicate timers, resets only on a fresh session, and installs and removes one scheduler-owning widget across starts, settle, abort, failure, shutdown, reload, replacement, and Calm toggles while leaving Calm-off visibility untouched
ok - Pi calm native E2E replaces the stock working row with a moving, resize-clamped working ship that freezes and resumes across two working periods in one Pi session, clears on abort, keeps captain turns visible, hides exact operational user rows without changing persistence, restores stock rendering 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.80.10

$ 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=68 local_links=253

$ bin/fm-test-run.sh --changed --base origin/main
FM_TEST_SUMMARY total=46 failed=0 skipped_gate=16 duration_ms=279390
FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=16 duration_ms=431 failed=0
FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=30 duration_ms=277700 failed=0
```
35 changes: 34 additions & 1 deletion tests/fm-calm-pi-extension.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3079,7 +3079,7 @@ JS
}

test_interactive_terminal_e2e() {
local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot export_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait active_wait active_screen_wait boat_frame_one boat_frame_two boat_resized_snapshot boat_focus_snapshot boat_cleared_snapshot boat_hull_line boat_sail_line boat_column_one boat_column_two boat_line boat_color_snapshot boat_color_line boat_water_snapshot boat_water_line boat_water_first boat_water_changed boat_narrow_snapshot boat_narrow_sails boat_freeze_snapshot boat_resume_snapshot boat_freeze_column boat_freeze_sail boat_resume_column boat_resume_sail
local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot export_snapshot export_settled_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait active_wait active_screen_wait boat_frame_one boat_frame_two boat_resized_snapshot boat_focus_snapshot boat_cleared_snapshot boat_hull_line boat_sail_line boat_column_one boat_column_two boat_line boat_color_snapshot boat_color_line boat_water_snapshot boat_water_line boat_water_first boat_water_changed boat_narrow_snapshot boat_narrow_sails boat_freeze_snapshot boat_resume_snapshot boat_freeze_column boat_freeze_sail boat_resume_column boat_resume_sail
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 interactive E2E"
return 0
Expand All @@ -3099,6 +3099,7 @@ test_interactive_terminal_e2e() {
active_before_snapshot="$TMP_ROOT/active-before.txt"
active_hidden_snapshot="$TMP_ROOT/active-hidden.txt"
export_snapshot="$TMP_ROOT/export.txt"
export_settled_snapshot="$TMP_ROOT/export-settled.txt"
restored_snapshot="$TMP_ROOT/restored.txt"
working_snapshot="$TMP_ROOT/working.txt"
working_response_snapshot="$TMP_ROOT/working-response.txt"
Expand Down Expand Up @@ -3556,6 +3557,38 @@ for (const current of ["CURRENT_WATCHER_E2E", "CURRENT_TURN_END_E2E", "CURRENT_A
}
if (!tree.includes("firstmate-synthetic-input") || !tree.includes("/tmp/probe.status")) process.exit(1);
JS
# Calm returns the transcript to its own presentation once the export has been
# rendered. That repaint runs on the macrotask right after Pi prints the export
# confirmation, so it must not overwrite it: the captain has to keep seeing where
# their export landed. The export-data assertions above take seconds of real time,
# so this snapshot is taken well after that repaint has settled rather than racing it.
tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$export_settled_snapshot"
assert_contains "$(cat "$export_settled_snapshot")" "Session exported to: $export_file" \
"Calm's post-export repaint overwrote Pi's export confirmation"
assert_not_contains "$(cat "$export_settled_snapshot")" "fm_watch_arm_pi" \
"/export left the Firstmate watcher tool call shell in the Calm transcript"
assert_not_contains "$(cat "$export_settled_snapshot")" "watcher: started Pi extension arm child" \
"/export left the Firstmate watcher tool result in the Calm transcript"
assert_not_contains "$(cat "$export_settled_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" \
"/export left a synthetic Firstmate user-role presentation in the Calm transcript"
assert_not_contains "$(cat "$export_settled_snapshot")" "Thinking..." \
"/export left collapsed thinking labels in the Calm transcript"
assert_not_contains "$(cat "$export_settled_snapshot")" "I will run one command." \
"/export left a mid-turn assistant working note in the Calm transcript"
for hidden in \
CURRENT_WATCHER_E2E \
CURRENT_TURN_END_E2E \
CURRENT_AWAY_E2E \
CURRENT_FROM_FIRSTMATE_E2E \
CURRENT_LAUNCH_BRIEF_E2E
do
assert_not_contains "$(cat "$export_settled_snapshot")" "$hidden" \
"/export left operational input $hidden in the Calm transcript"
done
assert_contains "$(cat "$export_settled_snapshot")" "Show a deterministic tool example." \
"/export removed a genuine user prompt from the Calm transcript"
assert_contains "$(cat "$export_settled_snapshot")" "The deterministic tool example is complete." \
"/export removed genuine assistant conversation from the Calm transcript"

tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm"
tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s
Expand Down
Loading