From 2cc3878dedb504693a6cfe4178427499e2e8308f Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 25 Aug 2026 14:14:39 +0800 Subject: [PATCH 1/7] fix(pi): clear regular TUI after transcript shrink --- .agents/skills/harness-adapters/SKILL.md | 1 + README.md | 4 +- bin/fm-spawn.sh | 2 +- bin/fm-test-run.sh | 3 +- docs/configuration.md | 6 + tests/fm-pi-clear-on-shrink-live-e2e.test.sh | 212 +++++++++++++++++++ tests/fm-spawn-dispatch-profile.test.sh | 12 +- 7 files changed, 230 insertions(+), 10 deletions(-) create mode 100755 tests/fm-pi-clear-on-shrink-live-e2e.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 1b3c36ecc49..283f9c63f78 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -286,6 +286,7 @@ Firstmate records `pi-signed` without normalization and refuses rather than fall The observed signed process tree is an exact `pi-signed` wrapper parent with the Pi application as its child, while tmux reports the foreground command as the exact `pi-launcher` name for both selected executables. The installed plain `pi` command also execs that signed launcher, so `FM_PI_HARNESS=pi-signed` is the authoritative selection marker and shared unmarked ancestry remains `pi`. Firstmate sets `FM_PI_HARNESS` explicitly for both worker launch identities, and a signed primary uses the README launch command to establish the same boundary. +Firstmate's Pi-family launch commands also set `PI_CLEAR_ON_SHRINK=1`; [`docs/configuration.md`](../../../docs/configuration.md#pi-calm-preference-configcalm) owns why Calm's transcript shrink requires it, the operator override, the redraw tradeoff, and why Pi 0.84.3 alone is insufficient. Keep the brief as one positional argument. Multiple positional args become separate queued messages; `fm-spawn`'s template already does this correctly. diff --git a/README.md b/README.md index 43c1c9e1b10..a0216e3de9b 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ grok --trust **Pi** ```sh -pi +PI_CLEAR_ON_SHRINK=1 pi # or, when the signed wrapper is installed -FM_PI_HARNESS=pi-signed pi-signed +FM_PI_HARNESS=pi-signed PI_CLEAR_ON_SHRINK=1 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. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index d5ea0fcf2b0..25f782d5f8e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1238,7 +1238,7 @@ case "$HARNESS" in PI_TUI_MODE=' --tui-mode regular' fi LAUNCH=${LAUNCH//__PITUIMODE__/$PI_TUI_MODE} - LAUNCH="FM_PI_HARNESS=$HARNESS $LAUNCH" + LAUNCH="FM_PI_HARNESS=$HARNESS PI_CLEAR_ON_SHRINK=1 $LAUNCH" ;; cursor) # `cursor` is not the CLI name, and the legacy alias `agent` is far too diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 22e5db0797f..b2b03435a2a 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -193,7 +193,8 @@ family_for_basename() { fm-grok-stop-live-e2e.test.sh|fm-harness-liveness-drift-live-e2e.test.sh|\ fm-muse-signals-live-e2e.test.sh|\ fm-herdr-version-floor-live-e2e.test.sh|\ - fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ + fm-opencode-primary-live-e2e.test.sh|fm-pi-clear-on-shrink-live-e2e.test.sh|\ + fm-pi-primary-live-e2e.test.sh|\ fm-sessionstart-hook-live-e2e.test.sh|fm-sessionstart-instruction-refresh-live-e2e.test.sh|\ fm-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-e2e.test.sh|\ fm-herdr-submit-confirm-live-e2e.test.sh|fm-spawn-worktree-identity-live.test.sh) diff --git a/docs/configuration.md b/docs/configuration.md index 128d3cec3b5..086a2aba085 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,12 @@ The values it writes are `on` and `off`, each followed by one newline; an absent 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. This preference is local to each Firstmate home and is not part of secondmate inherited configuration. +Firstmate launches Pi and `pi-signed` workers, scouts, and secondmates with `PI_CLEAR_ON_SHRINK=1`, and the documented primary commands set the same environment variable. +Calm and ordinary tool-collapse transitions can shrink Pi's regular-TUI transcript, so full clearing prevents removed rows from remaining as large empty terminal regions. +An explicit Pi `terminal.clearOnShrink` setting takes precedence over the launch environment, preserving the operator's settings-level override. +Pi 0.84.3 does not fix this behavior by itself. +Full clear-on-shrink can flicker and clears superseded terminal scrollback before Pi redraws the current component tree. +The opt-in native regression is `FM_PI_CLEAR_ON_SHRINK_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-pi-clear-on-shrink-live-e2e.test.sh`. ## Backlog backend (.tasks.toml / config/backlog-backend) diff --git a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh new file mode 100755 index 00000000000..a584ea1f6df --- /dev/null +++ b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# Opt-in real-Pi regular-TUI regression for transcript shrink after a +# viewport-filling tool result is expanded and collapsed. +set -u + +if [ "${FM_PI_CLEAR_ON_SHRINK_LIVE_E2E:-0}" != 1 ]; then + echo "skip: set FM_PI_CLEAR_ON_SHRINK_LIVE_E2E=1 to run the isolated Pi shrink regression" + exit 0 +fi + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v pi >/dev/null 2>&1 || fail "pi not found" +command -v tmux >/dev/null 2>&1 || fail "tmux not found" + +LAB=$(fm_test_tmproot fm-pi-clear-on-shrink-live-e2e) +PROJECT="$LAB/project" +HOME_DIR="$LAB/home" +CONFIG_DIR="$LAB/pi-config" +SESSIONS_DIR="$LAB/sessions" +SOCKET="fm-pi-shrink-$$" +SESSION=pi-shrink +SNAPSHOT="$LAB/pane.txt" + +cleanup() { + tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true + fm_test_cleanup +} +trap cleanup EXIT INT TERM + +capture_viewport() { + tmux -L "$SOCKET" capture-pane -p -t "$SESSION" >"$SNAPSHOT" 2>/dev/null || true +} + +capture_history() { + tmux -L "$SOCKET" capture-pane -p -t "$SESSION" -S -500 >"$SNAPSHOT" 2>/dev/null || true +} + +wait_for_text() { + local text=$1 attempt=0 + while [ "$attempt" -lt 240 ]; do + capture_viewport + grep -Fq "$text" "$SNAPSHOT" && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + return 1 +} + +wait_for_history_text() { + local text=$1 attempt=0 + while [ "$attempt" -lt 240 ]; do + capture_history + grep -Fq "$text" "$SNAPSHOT" && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + return 1 +} + +send_line() { + tmux -L "$SOCKET" send-keys -t "$SESSION" -l "$1" + tmux -L "$SOCKET" send-keys -t "$SESSION" Enter +} + +assert_no_empty_region_before() { + local marker=$1 label=$2 blank_run + blank_run=$(awk -v marker="$marker" ' + index($0, marker) { print run; found=1; exit } + /^[[:space:]]*$/ { run += 1; next } + { run = 0 } + END { if (!found) exit 2 } + ' "$SNAPSHOT") || fail "$label did not render $marker in the visible viewport" + [ "$blank_run" -le 3 ] \ + || fail "$label left a $blank_run-row empty region above $marker" +} + +mkdir -p \ + "$PROJECT/.pi/extensions/lib" \ + "$PROJECT/node_modules/@earendil-works" \ + "$HOME_DIR/config" \ + "$CONFIG_DIR" \ + "$SESSIONS_DIR" +fm_git_init_commit "$PROJECT" +cp "$ROOT/.pi/extensions/fm-calm.ts" "$PROJECT/.pi/extensions/fm-calm.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-operational-user-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-operational-user-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-calm-working-ship.ts" "$PROJECT/.pi/extensions/lib/fm-calm-working-ship.ts" +cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$PROJECT/.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"} +[ -f "$PI_PACKAGE_DIR/package.json" ] || fail "installed @earendil-works/pi-coding-agent package not found" +ln -s "$PI_PACKAGE_DIR" "$PROJECT/node_modules/@earendil-works/pi-coding-agent" +ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$PROJECT/node_modules/@earendil-works/pi-tui" +ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$PROJECT/node_modules/typebox" +printf '%s\n' '{"type":"module"}' >"$PROJECT/package.json" +printf '%s\n' on >"$HOME_DIR/config/calm" +printf '%s\n' '{"hideThinkingBlock":true,"compaction":{"keepRecentTokens":200}}' >"$CONFIG_DIR/settings.json" + +cat >"$PROJECT/shrink-provider.ts" <<'TS' +import { + createFauxCore, + fauxAssistantMessage, + fauxText, + fauxToolCall, +} from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const tallResult = (label: string): string => Array.from( + { length: 96 }, + (_, index) => `${label}_ROW_${String(index + 1).padStart(3, "0")}`, +).join("\n"); + +export default function (pi: ExtensionAPI): void { + const faux = createFauxCore({ + api: "pi-shrink-e2e-api", + provider: "pi-shrink-e2e", + models: [{ + id: "deterministic", + name: "Pi clear-on-shrink E2E", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100000, + maxTokens: 10000, + }], + tokenSize: { min: 1, max: 1 }, + }); + faux.setResponses([ + fauxAssistantMessage( + fauxToolCall("viewport_fill", { label: "BEFORE_COMPACT" }, { id: "viewport_before" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage(fauxText(`${"context prose ".repeat(450)}\nAFTER_TOOL_RESULT`)), + fauxAssistantMessage(fauxText("NEXT_VISIBLE_CONTENT")), + fauxAssistantMessage(fauxText("COMPACTED_SHRINK_FIXTURE")), + fauxAssistantMessage( + fauxToolCall("viewport_fill", { label: "AFTER_COMPACT" }, { id: "viewport_after" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage(fauxText("AFTER_COMPACT_TOOL_RESULT")), + fauxAssistantMessage(fauxText("NEXT_VISIBLE_AFTER_COMPACT")), + ]); + pi.registerProvider("pi-shrink-e2e", { + baseUrl: "http://127.0.0.1/unused", + apiKey: "test-only", + api: faux.api, + models: faux.models, + streamSimple: faux.streamSimple, + }); + pi.registerTool({ + name: "viewport_fill", + label: "Viewport fill", + description: "Return a deterministic viewport-filling result.", + parameters: Type.Object({ label: Type.String() }), + async execute(_toolCallId, params) { + return { + content: [{ type: "text", text: tallResult(params.label) }], + details: {}, + }; + }, + }); + pi.registerCommand("pi-shrink-e2e", { + description: "Select the deterministic shrink-regression model.", + handler: async (_args, ctx) => { + const model = ctx.modelRegistry.find("pi-shrink-e2e", "deterministic"); + if (!model || !(await pi.setModel(model))) { + throw new Error("Pi shrink-regression model unavailable"); + } + }, + }); +} +TS + +tmux -L "$SOCKET" new-session -d -s "$SESSION" -x 100 -y 36 \ + "cd '$PROJECT' && env FM_HOME='$HOME_DIR' PI_CODING_AGENT_DIR='$CONFIG_DIR' PI_OFFLINE=1 PI_CLEAR_ON_SHRINK=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions --tui-mode regular -e ./.pi/extensions/fm-calm.ts -e ./shrink-provider.ts --session-dir '$SESSIONS_DIR'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" + +wait_for_text "shrink-provider.ts" || fail "Pi shrink E2E did not reach the ready composer" +send_line /pi-shrink-e2e +sleep 0.1 +send_line "Run viewport_fill once, then finish." +wait_for_text AFTER_TOOL_RESULT || fail "Pi shrink E2E did not complete the pre-compaction tool turn" + +tmux -L "$SOCKET" send-keys -t "$SESSION" C-o +wait_for_history_text BEFORE_COMPACT_ROW_096 || { + cat "$SNAPSHOT" >&2 + fail "Ctrl+O did not expand the viewport-filling tool result" +} +tmux -L "$SOCKET" send-keys -t "$SESSION" C-o +wait_for_text AFTER_TOOL_RESULT || fail "collapsed pre-compaction result hid the next visible reply" +send_line "Reply after the collapsed tool result." +wait_for_text NEXT_VISIBLE_CONTENT || fail "Pi shrink E2E did not render content after the pre-compaction collapse" +assert_no_empty_region_before NEXT_VISIBLE_CONTENT "pre-compaction collapse" + +send_line /compact +wait_for_text "Compacted from" || fail "Pi shrink E2E did not complete a real compaction rebuild" +send_line "Run viewport_fill once after compaction, then finish." +wait_for_text AFTER_COMPACT_TOOL_RESULT || fail "Pi shrink E2E did not complete the post-compaction tool turn" +tmux -L "$SOCKET" send-keys -t "$SESSION" C-o +wait_for_history_text AFTER_COMPACT_ROW_096 || { + cat "$SNAPSHOT" >&2 + fail "Ctrl+O did not expand the post-compaction tool result" +} +tmux -L "$SOCKET" send-keys -t "$SESSION" C-o +wait_for_text AFTER_COMPACT_TOOL_RESULT || fail "collapsed post-compaction result hid the next visible reply" +send_line "Reply after the post-compaction collapsed tool result." +wait_for_text NEXT_VISIBLE_AFTER_COMPACT || fail "Pi shrink E2E did not render content after the post-compaction collapse" +assert_no_empty_region_before NEXT_VISIBLE_AFTER_COMPACT "post-compaction collapse" + +printf 'ok - Pi %s regular TUI clears viewport-filling tool-result shrink before and after compaction\n' "$(pi --version 2>/dev/null | head -n 1)" diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index d1f1effb41a..44ddd164978 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -623,8 +623,8 @@ test_pi_threads_model_and_max_effort() { expect_code 0 "$status" "pi spawn with max effort should succeed" assert_meta_profile "$HOME_DIR/state/$id.meta" pi openai-codex/gpt-5.6-sol max launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "FM_PI_HARNESS=pi '$FAKEBIN_DIR/pi' --tui-mode regular --model 'openai-codex/gpt-5.6-sol' --thinking 'max' -e" \ - "pi launch did not force the regular TUI while threading the requested model and max thinking level" + assert_contains "$launch" "FM_PI_HARNESS=pi PI_CLEAR_ON_SHRINK=1 '$FAKEBIN_DIR/pi' --tui-mode regular --model 'openai-codex/gpt-5.6-sol' --thinking 'max' -e" \ + "pi launch did not enable clear-on-shrink while forcing the regular TUI and threading the requested model and max thinking level" assert_not_contains "$launch" "FM_FIRSTMATE_PI_LAUNCH_BRIEF=" \ "pi launch still exports the removed Calm input-reroute binding" assert_contains "$launch" "fm-operational-input.sh' encode launch-brief" \ @@ -645,8 +645,8 @@ test_pi_signed_threads_shared_pi_profile_and_preserves_identity() { assert_contains "$out" "spawned $id harness=pi-signed" "pi-signed spawn did not preserve its visible identity" assert_meta_profile "$HOME_DIR/state/$id.meta" pi-signed openai-codex/gpt-5.6-sol max launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "FM_PI_HARNESS=pi-signed '$FAKEBIN_DIR/pi-signed' --tui-mode regular --model 'openai-codex/gpt-5.6-sol' --thinking 'max' -e" \ - "pi-signed launch did not force the regular TUI with Pi's model, thinking, and extension semantics" + assert_contains "$launch" "FM_PI_HARNESS=pi-signed PI_CLEAR_ON_SHRINK=1 '$FAKEBIN_DIR/pi-signed' --tui-mode regular --model 'openai-codex/gpt-5.6-sol' --thinking 'max' -e" \ + "pi-signed launch did not enable clear-on-shrink with Pi's regular-TUI, model, thinking, and extension semantics" assert_contains "$launch" "fm-operational-input.sh' encode launch-brief" \ "pi-signed launch lost the canonical typed launch-brief envelope" assert_present "$HOME_DIR/state/$id.pi-ext.ts" "pi-signed launch did not install Pi's turn-end extension" @@ -735,8 +735,8 @@ test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity() { "pi-signed secondmate spawn did not preserve its runtime identity" assert_meta_profile "$HOME_DIR/state/$id.meta" pi-signed default default launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "FM_PI_HARNESS=pi-signed '$FAKEBIN_DIR/pi-signed' --tui-mode regular -e '$sm/.pi/extensions/fm-primary-turnend-guard.ts' -e '$sm/.pi/extensions/fm-primary-pi-watch.ts'" \ - "pi-signed secondmate did not force the regular TUI with Pi's primary extension launch shape" + assert_contains "$launch" "FM_PI_HARNESS=pi-signed PI_CLEAR_ON_SHRINK=1 '$FAKEBIN_DIR/pi-signed' --tui-mode regular -e '$sm/.pi/extensions/fm-primary-turnend-guard.ts' -e '$sm/.pi/extensions/fm-primary-pi-watch.ts'" \ + "pi-signed secondmate did not enable clear-on-shrink with Pi's regular-TUI primary-extension launch shape" pass "pi-signed is a distinct persistent secondmate runtime with shared Pi supervision semantics" } From c21c67149e191bd54edd707d56efb2a5e9a2dd34 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 25 Aug 2026 14:22:52 +0800 Subject: [PATCH 2/7] no-mistakes(review): settle Pi viewport and init awk blank-run counter --- tests/fm-pi-clear-on-shrink-live-e2e.test.sh | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh index a584ea1f6df..8da463dc3e9 100755 --- a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh +++ b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh @@ -22,6 +22,7 @@ SESSIONS_DIR="$LAB/sessions" SOCKET="fm-pi-shrink-$$" SESSION=pi-shrink SNAPSHOT="$LAB/pane.txt" +PREV_SNAPSHOT="$LAB/pane-prev.txt" cleanup() { tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true @@ -64,9 +65,28 @@ send_line() { tmux -L "$SOCKET" send-keys -t "$SESSION" Enter } +settle_viewport() { + local marker=$1 label=$2 attempt=0 + : >"$PREV_SNAPSHOT" + while [ "$attempt" -lt 240 ]; do + cp "$SNAPSHOT" "$PREV_SNAPSHOT" 2>/dev/null || true + sleep 0.05 + capture_viewport + if grep -Fq "$marker" "$SNAPSHOT" \ + && ! tail -12 "$SNAPSHOT" | grep -Fq "Working..." \ + && cmp -s "$SNAPSHOT" "$PREV_SNAPSHOT"; then + return 0 + fi + attempt=$((attempt + 1)) + done + cat "$SNAPSHOT" >&2 + fail "$label did not settle into a stable frame with $marker visible and the transient status row cleared" +} + assert_no_empty_region_before() { local marker=$1 label=$2 blank_run blank_run=$(awk -v marker="$marker" ' + BEGIN { run = 0 } index($0, marker) { print run; found=1; exit } /^[[:space:]]*$/ { run += 1; next } { run = 0 } @@ -192,6 +212,7 @@ tmux -L "$SOCKET" send-keys -t "$SESSION" C-o wait_for_text AFTER_TOOL_RESULT || fail "collapsed pre-compaction result hid the next visible reply" send_line "Reply after the collapsed tool result." wait_for_text NEXT_VISIBLE_CONTENT || fail "Pi shrink E2E did not render content after the pre-compaction collapse" +settle_viewport NEXT_VISIBLE_CONTENT "pre-compaction collapse" assert_no_empty_region_before NEXT_VISIBLE_CONTENT "pre-compaction collapse" send_line /compact @@ -207,6 +228,7 @@ tmux -L "$SOCKET" send-keys -t "$SESSION" C-o wait_for_text AFTER_COMPACT_TOOL_RESULT || fail "collapsed post-compaction result hid the next visible reply" send_line "Reply after the post-compaction collapsed tool result." wait_for_text NEXT_VISIBLE_AFTER_COMPACT || fail "Pi shrink E2E did not render content after the post-compaction collapse" +settle_viewport NEXT_VISIBLE_AFTER_COMPACT "post-compaction collapse" assert_no_empty_region_before NEXT_VISIBLE_AFTER_COMPACT "post-compaction collapse" printf 'ok - Pi %s regular TUI clears viewport-filling tool-result shrink before and after compaction\n' "$(pi --version 2>/dev/null | head -n 1)" From 793ce67c6ffe6832f854e3e8b21706e6dc3f93d1 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 25 Aug 2026 14:29:52 +0800 Subject: [PATCH 3/7] no-mistakes(review): gate live-e2e settle on Calm ship across ship tick --- tests/fm-pi-clear-on-shrink-live-e2e.test.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh index 8da463dc3e9..07b8ff479e7 100755 --- a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh +++ b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh @@ -65,22 +65,29 @@ send_line() { tmux -L "$SOCKET" send-keys -t "$SESSION" Enter } +CALM_SHIP_HULL='\__/' +SETTLE_SAMPLE_SECONDS=0.06 +SETTLE_STABLE_INTERVALS=4 + settle_viewport() { - local marker=$1 label=$2 attempt=0 + local marker=$1 label=$2 attempt=0 stable=0 : >"$PREV_SNAPSHOT" - while [ "$attempt" -lt 240 ]; do + while [ "$attempt" -lt 400 ]; do cp "$SNAPSHOT" "$PREV_SNAPSHOT" 2>/dev/null || true - sleep 0.05 + sleep "$SETTLE_SAMPLE_SECONDS" capture_viewport if grep -Fq "$marker" "$SNAPSHOT" \ - && ! tail -12 "$SNAPSHOT" | grep -Fq "Working..." \ + && ! tail -12 "$SNAPSHOT" | grep -Fq "$CALM_SHIP_HULL" \ && cmp -s "$SNAPSHOT" "$PREV_SNAPSHOT"; then - return 0 + stable=$((stable + 1)) + [ "$stable" -ge "$SETTLE_STABLE_INTERVALS" ] && return 0 + else + stable=0 fi attempt=$((attempt + 1)) done cat "$SNAPSHOT" >&2 - fail "$label did not settle into a stable frame with $marker visible and the transient status row cleared" + fail "$label did not hold $marker in a viewport that stayed unchanged across $SETTLE_STABLE_INTERVALS samples with the Calm working ship gone" } assert_no_empty_region_before() { From 2d929ea719b13d8bd6c7f2d54a19705dafe890a9 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 25 Aug 2026 14:38:08 +0800 Subject: [PATCH 4/7] no-mistakes(review): re-anchor bare-executable guard after clear-on-shrink assignment --- tests/fm-spawn-dispatch-profile.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 44ddd164978..2167b5b1787 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -681,7 +681,7 @@ test_pi_tui_mode_probe_is_safe_for_old_and_new_pi() { launch=$(cat "$LAUNCH_LOG") assert_contains "$launch" "'$FAKEBIN_DIR/$harness'" \ "$harness $version launch must use the executable selected for probing" - assert_not_contains "$launch" "FM_PI_HARNESS=$harness $harness" \ + assert_not_contains "$launch" "PI_CLEAR_ON_SHRINK=1 $harness " \ "$harness $version launch must not re-resolve a bare executable in the worker" if [ "$version" = 0.82.0 ]; then assert_not_contains "$launch" "--tui-mode" \ From b0874632964d0941d5e1ea0e87a5184fb7a31ce0 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 25 Aug 2026 14:47:35 +0800 Subject: [PATCH 5/7] no-mistakes(review): drop inert hidden-thinking setting from live shrink test --- tests/fm-pi-clear-on-shrink-live-e2e.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh index 07b8ff479e7..dd11f50b0b4 100755 --- a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh +++ b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh @@ -123,7 +123,7 @@ ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$PROJECT/node_modul ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$PROJECT/node_modules/typebox" printf '%s\n' '{"type":"module"}' >"$PROJECT/package.json" printf '%s\n' on >"$HOME_DIR/config/calm" -printf '%s\n' '{"hideThinkingBlock":true,"compaction":{"keepRecentTokens":200}}' >"$CONFIG_DIR/settings.json" +printf '%s\n' '{"compaction":{"keepRecentTokens":200}}' >"$CONFIG_DIR/settings.json" cat >"$PROJECT/shrink-provider.ts" <<'TS' import { From 7c0f0043075442bbc6faf43a23361178e5fc2ffe Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 25 Aug 2026 15:18:39 +0800 Subject: [PATCH 6/7] no-mistakes(test): make Pi clear-on-shrink live e2e reproduce stale rows --- tests/fm-pi-clear-on-shrink-live-e2e.test.sh | 83 ++++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh index dd11f50b0b4..276c6f07b02 100755 --- a/tests/fm-pi-clear-on-shrink-live-e2e.test.sh +++ b/tests/fm-pi-clear-on-shrink-live-e2e.test.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Opt-in real-Pi regular-TUI regression for transcript shrink after a -# viewport-filling tool result is expanded and collapsed. +# Opt-in real-Pi regular-TUI regression for transcript shrink: a viewport-filling +# tool result is expanded and collapsed, and a render that is taller before the +# shrink than after must not leave stale empty rows below the new content. set -u if [ "${FM_PI_CLEAR_ON_SHRINK_LIVE_E2E:-0}" != 1 ]; then @@ -23,6 +24,8 @@ SOCKET="fm-pi-shrink-$$" SESSION=pi-shrink SNAPSHOT="$LAB/pane.txt" PREV_SNAPSHOT="$LAB/pane-prev.txt" +PANE_ROWS=36 +PANE_COLUMNS=100 cleanup() { tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true @@ -49,6 +52,17 @@ wait_for_text() { return 1 } +wait_for_text_gone() { + local text=$1 attempt=0 + while [ "$attempt" -lt 240 ]; do + capture_viewport + grep -Fq "$text" "$SNAPSHOT" || return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + return 1 +} + wait_for_history_text() { local text=$1 attempt=0 while [ "$attempt" -lt 240 ]; do @@ -68,6 +82,11 @@ send_line() { CALM_SHIP_HULL='\__/' SETTLE_SAMPLE_SECONDS=0.06 SETTLE_STABLE_INTERVALS=4 +DRAFT_TOKEN=draftfill +# 130 repeats wrap to about ten composer rows in a 100-column pane, so clearing +# the draft shrinks the render far past the tolerated blank-row slack. +DRAFT_TEXT=$(awk 'BEGIN { for (i = 0; i < 130; i++) printf "draftfill " }') +DRAFT_MIN_ROWS=6 settle_viewport() { local marker=$1 label=$2 attempt=0 stable=0 @@ -103,6 +122,53 @@ assert_no_empty_region_before() { || fail "$label left a $blank_run-row empty region above $marker" } +# The transcript is taller than the pane throughout, so a correctly cleared +# shrink always redraws content down to the last terminal row. Rows left blank +# under the final rendered row are the stale region the fix must prevent. +assert_no_stale_rows_below() { + local label=$1 last_row stale + last_row=$(awk '!/^[[:space:]]*$/ { row = NR } END { print row + 0 }' "$SNAPSHOT") + [ "$last_row" -gt 0 ] || { + cat "$SNAPSHOT" >&2 + fail "$label left the whole viewport blank" + } + stale=$((PANE_ROWS - last_row)) + [ "$stale" -le 2 ] || { + cat "$SNAPSHOT" >&2 + fail "$label left a $stale-row empty region below the last rendered row" + } +} + +# Grow the render with a wrapped composer draft, then clear it. The shrink is +# confined to the bottom of the viewport, which is the case Pi renders +# differentially instead of redrawing in full. +shrink_render_and_assert() { + local label=$1 anchor=$2 draft_rows attempt=0 + # The shrink only proves anything while the transcript is taller than the + # pane, so require a full viewport before growing the render. + assert_no_stale_rows_below "$label baseline" + tmux -L "$SOCKET" send-keys -t "$SESSION" -l "$DRAFT_TEXT" + wait_for_text "$DRAFT_TOKEN" || fail "$label draft never reached the composer" + while [ "$attempt" -lt 120 ]; do + capture_viewport + draft_rows=$(grep -c "$DRAFT_TOKEN" "$SNAPSHOT") + [ "$draft_rows" -ge "$DRAFT_MIN_ROWS" ] && break + sleep 0.05 + attempt=$((attempt + 1)) + done + [ "$draft_rows" -ge "$DRAFT_MIN_ROWS" ] || { + cat "$SNAPSHOT" >&2 + fail "$label draft only grew the render by $draft_rows rows, so the shrink would prove nothing" + } + for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do + tmux -L "$SOCKET" send-keys -t "$SESSION" C-u + sleep 0.05 + done + wait_for_text_gone "$DRAFT_TOKEN" || fail "$label could not clear the composer draft" + settle_viewport "$anchor" "$label" + assert_no_stale_rows_below "$label" +} + mkdir -p \ "$PROJECT/.pi/extensions/lib" \ "$PROJECT/node_modules/@earendil-works" \ @@ -156,13 +222,16 @@ export default function (pi: ExtensionAPI): void { tokenSize: { min: 1, max: 1 }, }); faux.setResponses([ + fauxAssistantMessage(fauxText(`${"context prose ".repeat(450)}\nTRANSCRIPT_TALLER_THAN_PANE`)), fauxAssistantMessage( fauxToolCall("viewport_fill", { label: "BEFORE_COMPACT" }, { id: "viewport_before" }), { stopReason: "toolUse" }, ), - fauxAssistantMessage(fauxText(`${"context prose ".repeat(450)}\nAFTER_TOOL_RESULT`)), + fauxAssistantMessage(fauxText("AFTER_TOOL_RESULT")), fauxAssistantMessage(fauxText("NEXT_VISIBLE_CONTENT")), + // A real /compact rebuild consumes two model calls before the next turn. fauxAssistantMessage(fauxText("COMPACTED_SHRINK_FIXTURE")), + fauxAssistantMessage(fauxText("COMPACTION_REBUILD_FILLER")), fauxAssistantMessage( fauxToolCall("viewport_fill", { label: "AFTER_COMPACT" }, { id: "viewport_after" }), { stopReason: "toolUse" }, @@ -201,12 +270,14 @@ export default function (pi: ExtensionAPI): void { } TS -tmux -L "$SOCKET" new-session -d -s "$SESSION" -x 100 -y 36 \ +tmux -L "$SOCKET" new-session -d -s "$SESSION" -x "$PANE_COLUMNS" -y "$PANE_ROWS" \ "cd '$PROJECT' && env FM_HOME='$HOME_DIR' PI_CODING_AGENT_DIR='$CONFIG_DIR' PI_OFFLINE=1 PI_CLEAR_ON_SHRINK=1 pi --approve --no-context-files --no-skills --no-prompt-templates --no-extensions --tui-mode regular -e ./.pi/extensions/fm-calm.ts -e ./shrink-provider.ts --session-dir '$SESSIONS_DIR'; rc=\$?; printf '\nPI_EXIT=%s\n' \"\$rc\"; sleep 20" wait_for_text "shrink-provider.ts" || fail "Pi shrink E2E did not reach the ready composer" send_line /pi-shrink-e2e sleep 0.1 +send_line "Fill the transcript past the pane height." +wait_for_text TRANSCRIPT_TALLER_THAN_PANE || fail "Pi shrink E2E did not fill the transcript past the pane height" send_line "Run viewport_fill once, then finish." wait_for_text AFTER_TOOL_RESULT || fail "Pi shrink E2E did not complete the pre-compaction tool turn" @@ -221,6 +292,7 @@ send_line "Reply after the collapsed tool result." wait_for_text NEXT_VISIBLE_CONTENT || fail "Pi shrink E2E did not render content after the pre-compaction collapse" settle_viewport NEXT_VISIBLE_CONTENT "pre-compaction collapse" assert_no_empty_region_before NEXT_VISIBLE_CONTENT "pre-compaction collapse" +shrink_render_and_assert "pre-compaction shrink" NEXT_VISIBLE_CONTENT send_line /compact wait_for_text "Compacted from" || fail "Pi shrink E2E did not complete a real compaction rebuild" @@ -237,5 +309,6 @@ send_line "Reply after the post-compaction collapsed tool result." wait_for_text NEXT_VISIBLE_AFTER_COMPACT || fail "Pi shrink E2E did not render content after the post-compaction collapse" settle_viewport NEXT_VISIBLE_AFTER_COMPACT "post-compaction collapse" assert_no_empty_region_before NEXT_VISIBLE_AFTER_COMPACT "post-compaction collapse" +shrink_render_and_assert "post-compaction shrink" NEXT_VISIBLE_AFTER_COMPACT -printf 'ok - Pi %s regular TUI clears viewport-filling tool-result shrink before and after compaction\n' "$(pi --version 2>/dev/null | head -n 1)" +printf 'ok - Pi %s regular TUI leaves no stale rows when the render shrinks, before and after compaction\n' "$(pi --version 2>/dev/null | head -n 1)" From 82b8f90e35b53442e89f21b0e472c9ced79e7402 Mon Sep 17 00:00:00 2001 From: Ivan Li Date: Tue, 25 Aug 2026 15:26:03 +0800 Subject: [PATCH 7/7] no-mistakes(document): point README Pi launch at clear-on-shrink rationale --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a0216e3de9b..94c6c2f7a24 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ FM_PI_HARNESS=pi-signed PI_CLEAR_ON_SHRINK=1 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_CLEAR_ON_SHRINK=1` keeps a shrinking Pi transcript from leaving empty terminal regions; [`docs/configuration.md`](docs/configuration.md#pi-calm-preference-configcalm) owns that rationale and its tradeoffs. Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, and uses a Calm-only animated working boat during active runs while preserving all model context and session data. The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering.