From 42c42dd0babc37316927e57645cc0633e42d2436 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:14:40 -0700 Subject: [PATCH 1/2] fix(pi): restore Calm /export status and harden follow-up pane captures Pi 0.84+ showStatus replaces the previous status row in place. Calm post-/export setToolsExpanded redraw overwrote Session exported status with Tool output collapsed, so e2e never saw export complete even though the HTML file was written. Drop that tools-expanded toggle from the export/share input path. Arming stock HTML rendering only needs the stockExportRendering flag; /calm still redraws when presentation actually changes. Unit coverage fails if export/share calls setToolsExpanded. The duplicate captain answer failure was a misnamed assertion on count != 1: session data kept one answer, but plain tmux capture-pane under Pi 0.84+ intermittently missed short assistant rows (join capture always saw exactly one). Wait on joined pane captures and require exactly one on-screen captain answer. --- .pi/extensions/fm-calm.ts | 9 ++++-- tests/fm-calm-pi-extension.test.sh | 44 +++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index d6e9e6d011..9c86d992fb 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -161,6 +161,12 @@ export default function (pi: ExtensionAPI) { return; } + // Arm stock HTML rendering only for the export/share pass itself. + // Pi 0.84+ showStatus replaces the previous status row in place, so a + // post-export setToolsExpanded toggle would overwrite "Session exported + // to: ..." with "Tool output: expanded/collapsed". Export does not re- + // render the live transcript, so resetting the flag is enough; /calm + // still uses the tools-expanded redraw when presentation actually changes. exportRendering = true; setCalmStockExportRendering(true); publishPresentationState(); @@ -168,9 +174,6 @@ export default function (pi: ExtensionAPI) { exportRendering = false; setCalmStockExportRendering(false); publishPresentationState(); - const expanded = ctx.ui.getToolsExpanded(); - ctx.ui.setToolsExpanded(!expanded); - ctx.ui.setToolsExpanded(expanded); }, 0); }); }); diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index 98d5187eb2..1e612f7133 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -44,7 +44,9 @@ wait_for_text() { # Include recent scrollback: expanding a long restored transcript can move # the asserted tool output above the current viewport while the footer and # editor remain visible. - tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$file" 2>/dev/null || true + # -J joins soft-wrapped lines. Pi 0.84+ truecolor/layout can leave short + # assistant rows invisible to plain capture-pane while join still sees them. + tmux -L "$TMUX_SOCKET" capture-pane -p -J -t "$TMUX_SESSION" -S -600 >"$file" 2>/dev/null || true grep -Fq "$text" "$file" 2>/dev/null && return 0 sleep 0.05 i=$((i + 1)) @@ -1200,6 +1202,7 @@ let editorText = ""; let terminalInputHandler; let workingVisible; let hiddenThinkingLabel = "unset"; +let toolsExpandedCalls = 0; const statuses = new Map(); const sessionEntries = [{ type: "message", message: { role: "toolResult", content: "kept" } }]; const entriesBefore = JSON.stringify(sessionEntries); @@ -1224,6 +1227,7 @@ const commandContext = { statuses.set(key, value); }, setToolsExpanded(value) { + toolsExpandedCalls += 1; expanded = value; for (const row of rows) row.actual.setExpanded(value); watchActual.setExpanded(value); @@ -1340,6 +1344,7 @@ for (const { name, actual } of rows) { } } async function assertStockHtmlRendering(command, submitData) { + const toolsExpandedBefore = toolsExpandedCalls; editorText = command; terminalInputHandler(submitData); const htmlRenderer = createToolHtmlRenderer({ @@ -1367,6 +1372,11 @@ async function assertStockHtmlRendering(command, submitData) { } editorText = ""; await new Promise((resolve) => setTimeout(resolve, 0)); + // Regression: Pi 0.84+ showStatus replaces the prior status row in place, so a + // post-export setToolsExpanded toggle clobbers "Session exported to: ...". + if (toolsExpandedCalls !== toolsExpandedBefore) { + throw new Error(`${command} export/share path must not call setToolsExpanded (would clobber export status)`); + } } await assertStockHtmlRendering("/export calm.html", "\r"); @@ -1497,7 +1507,7 @@ JS } test_operational_followup_turn_e2e() { - local project home config sessions version label case_name calm_state expected_notifications session_file pane i captain_line handled_line geometry_gap exact_session + local project home config sessions version label case_name calm_state expected_notifications session_file pane i captain_line handled_line geometry_gap exact_session captain_count 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 operational follow-up E2E" return 0 @@ -1674,7 +1684,7 @@ TS "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" i=0 while [ "$i" -lt 120 ]; do - pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -J -t "$TMUX_SESSION" -S - 2>/dev/null || true) printf '%s\n' "$pane" | grep -Fq 'followup-e2e.ts' && break sleep 0.05 i=$((i + 1)) @@ -1697,9 +1707,23 @@ 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) - [ "$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true)" -eq 1 ] \ - || fail "Pi follow-up $label case rendered a duplicate captain answer" + # Wait for the captain answer on the joined pane. Session completion alone is + # not enough: under Pi 0.84+ a plain capture can miss short assistant rows for + # a beat, and the old "duplicate" assertion treated count 0 as a product bug. + i=0 + pane="" + captain_count=0 + while [ "$i" -lt 80 ]; do + pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -J -t "$TMUX_SESSION" -S - 2>/dev/null || true) + captain_count=$(printf '%s\n' "$pane" | grep -Fc "CAPTAIN_ANSWER_$label" || true) + if [ "$captain_count" -ge 1 ] && printf '%s\n' "$pane" | grep -Fq "MONITOR_HANDLED_${label}_ONE"; then + break + fi + sleep 0.05 + i=$((i + 1)) + done + [ "$captain_count" -eq 1 ] \ + || fail "Pi follow-up $label case expected exactly one captain answer on screen, found $captain_count" assert_contains "$pane" "CAPTAIN_PROMPT_$label" "Pi follow-up $label case hid the genuine captain prompt" assert_contains "$pane" "MONITOR_HANDLED_${label}_ONE" "Pi follow-up $label case did not render the intended processing result" if [ "$calm_state" = on ]; then @@ -1802,7 +1826,7 @@ JS "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" i=0 while [ "$i" -lt 120 ]; do - pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - 2>/dev/null || true) + pane=$(tmux -L "$TMUX_SOCKET" capture-pane -p -J -t "$TMUX_SESSION" -S - 2>/dev/null || true) printf '%s\n' "$pane" | grep -Fq 'MONITOR_HANDLED_exact_watcher_ONE' && break sleep 0.05 i=$((i + 1)) @@ -3229,7 +3253,7 @@ JSON while [ "$active_screen_wait" -lt 120 ]; do # Include scrollback so earlier genuine content stays inspectable even when the # transcript is long enough to push the original user prompt above the viewport. - tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$hidden_snapshot" + tmux -L "$TMUX_SOCKET" capture-pane -p -J -t "$TMUX_SESSION" -S -600 >"$hidden_snapshot" # Wait for the redraw this block asserts: collapsed thinking hides, the built-in # tool rows restored before this first-ever activation collapse with it, and the # retained genuine rows are back on screen. @@ -3274,7 +3298,7 @@ JSON tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s active_screen_wait=0 while [ "$active_screen_wait" -lt 120 ]; do - tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$active_before_snapshot" + tmux -L "$TMUX_SOCKET" capture-pane -p -J -t "$TMUX_SESSION" >"$active_before_snapshot" if grep -Fq "Warning: CALM_TRANSIENT_DIAGNOSTIC" "$active_before_snapshot" && ! grep -Fq "/calm-diagnostic-e2e" "$active_before_snapshot"; then break @@ -3350,7 +3374,7 @@ for (const [needle, kind] of expected) { JS active_screen_wait=0 while [ "$active_screen_wait" -lt 120 ]; do - tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$active_hidden_snapshot" + tmux -L "$TMUX_SOCKET" capture-pane -p -J -t "$TMUX_SESSION" >"$active_hidden_snapshot" if grep -Fq " Error:" "$active_hidden_snapshot" && ! grep -Fq "/calm-inject-e2e" "$active_hidden_snapshot"; then break From 7c0d9588eee76f48541ee998043da7a757d9e764 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:30:36 -0700 Subject: [PATCH 2/2] no-mistakes(document): document Calm export/share no-expansion-cycle invariant --- docs/calm-mode-feasibility.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index 72c58894ee..b958bb5f0c 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -203,7 +203,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. +Cycling tool expansion and restoring its original value rebuilds controllable rows and leaves final `Ctrl+O` state unchanged, and only genuine presentation changes such as the `/calm` toggle use that redraw. +The `/export` and `/share` pass arms stock rendering only for the pass itself and never cycles tool expansion: export does not re-render the live transcript, and Pi 0.84+ `showStatus` replaces the prior status row in place, so a post-export toggle would overwrite the exported-file status; `tests/fm-calm-pi-extension.test.sh` fails if the export or share path calls `setToolsExpanded`. 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.