diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index ef210463823..d919b61f53c 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -58,6 +58,12 @@ # bare - an agent prompt glyph row with no border at all (claude `❯`, # codex `›`, muse `⟩`, cursor `→`). The agent glyph is itself the container # proof; a bare SHELL glyph (`>` `$` `%` `#`) never is. +# A bare composer's WRAP region (typed input continuing on the +# rows beneath the glyph row) is bounded by blank rows, by +# structural edges, and by the FURNITURE rows a harness draws +# directly below its composer - omp's status row and +# braille-only animation rows (declared once below, next to +# the idle placeholders) - none of which is ever typed input. # left-bar - opencode: rows prefixed by a heavy left bar `┃` with no # closing border, holding the idle hint, blank rows, and a # mode/model footer line. @@ -81,11 +87,21 @@ # otherwise-empty composer with de-emphasized ghost text - claude's rotating # prompt suggestion, codex's idle suggestion, grok's placeholder, or cursor's # idle placeholder - which a -# plain capture cannot tell apart from text a human typed. +# plain capture cannot tell apart from text a human typed. codex-cli 0.154.0 +# draws its `Ask Codex to do anything` placeholder as SGR-2 dim text after the +# bare `›` glyph, which fm_composer_strip_ghost removes. # fm_composer_strip_ghost is the ONE ANSI-aware extractor of "real typed # content": it drops every de-emphasized run - dim/faint (SGR 2) AND a # dark/muted TRUECOLOR foreground - and keeps only normal-intensity, # normally-coloured text. +# Ghost stripping is a STYLE test, so it cannot see furniture a harness draws +# at normal intensity: codex-cli 0.154.0 animates a braille "starfield" around +# its idle composer in greys on both sides of the ghost luminance ceiling, so +# the brighter cells survive the strip and used to read as typed input. Those +# cells are recognised by SHAPE instead (fm_composer_strip_braille, declared +# next to the idle placeholders below), and only +# where a bare composer's furniture can sit: behind the glyph row's content +# and on the rows that bound its wrap region. # # UNICODE WHITESPACE (issue #1988; open PRs #1995/#2047 target the same # defect and #1995's naming is adopted here so the implementations converge): @@ -426,6 +442,43 @@ FM_COMPOSER_LEFTBAR_FOOTER_RE_DEFAULT='^(Build|Plan)[[:space:]]+·[[:space:]]+' # a middle dot. It is consulted only as the boundary BELOW a bare composer, # never on the composer row itself. FM_COMPOSER_OMP_STATUS_RE_DEFAULT='^[[:space:]]*(π|󰵗)[[:space:]]+·[[:space:]]|^[[:space:]]*'"$FM_OMP_SPINNER_FRAMES_RE"'[[:space:]]+[0-9]+[smh]([[:space:]]|$)|[[:space:]]·[[:space:]].*[0-9]+(\.[0-9]+)?%/[0-9]+K' +# Braille-pattern cells (U+2800..U+28FF) are animation furniture: codex-cli +# 0.154.0 draws an idle "starfield" of them on the row above its `›` prompt +# row, on the `›` row itself after the dim `Ask Codex to do anything` +# placeholder, and on the row below it (verified live through Herdr on +# codex-cli 0.154.0, gpt-6-astra, fast mode). The cells are truecolor greys +# whose luminance straddles FM_COMPOSER_GHOST_LUMA_MAX, so the brighter ones +# survive ghost stripping. The rule, applied by shape rather than style: +# - a row whose non-whitespace content is entirely braille cells is screen +# furniture; it never counts as wrapped typed content and it bounds a bare +# composer's wrap region exactly as the status rows above do; +# - braille cells behind the glyph row's content are stripped before that +# row's emptiness decision when NOTHING else follows the glyph; +# - a row that mixes braille with any other non-whitespace text stays typed +# content, because a human can type a braille character. +# fm_composer_strip_braille is the ONE byte-exact remover: under LC_ALL=C awk +# walks bytes and drops every UTF-8 sequence E2 A0..A3 80..BF. It is +# deliberately not a grep bracket range over the block, for the reason +# FM_OMP_SPINNER_FRAMES_RE records (GNU grep rejects a range between multibyte +# endpoints). Reads stdin, prints the line with its braille cells removed. +fm_composer_strip_braille() { + LC_ALL=C awk ' + { + line = $0; out = ""; n = length(line); i = 1 + while (i <= n) { + c = substr(line, i, 1) + if (c == "\342" && i + 2 <= n) { + c2 = substr(line, i + 1, 1); c3 = substr(line, i + 2, 1) + if (c2 >= "\240" && c2 <= "\243" && c3 >= "\200" && c3 <= "\277") { + i += 3; continue + } + } + out = out c; i++ + } + print out + } + ' +} # The bounded row window adapters should capture for a composer read. One # shared policy (previously three per-backend variables that had drifted to @@ -1001,6 +1054,8 @@ _fm_composer_classify_bare_row() { # raw=$(_fm_composer_screen_row "$row" "$screen") content=$(_fm_composer_row_content "$raw" "$styled") plain=$(_fm_composer_row_content "$raw" 0) + _fm_composer_bare_row_strip_furniture_var content + _fm_composer_bare_row_strip_furniture_var plain state=$(fm_composer_classify_content 0 "$content" \ "${FM_COMPOSER_IDLE_RE:-$FM_COMPOSER_IDLE_RE_DEFAULT}" insensitive "$plain" 0 "$styled") if [ "$styled" != 1 ] && [ "$state" = pending ]; then @@ -1017,6 +1072,35 @@ _fm_composer_row_is_omp_status() { # fm_composer_idle_matches "$1" "${FM_COMPOSER_OMP_STATUS_RE:-$FM_COMPOSER_OMP_STATUS_RE_DEFAULT}" sensitive } +# _fm_composer_row_is_braille_furniture: 0 when the row is non-blank and its +# non-whitespace content is entirely braille cells (fm_composer_strip_braille +# above) - an animation row that never counts as typed content and bounds a +# bare composer's wrap region. A blank row is not furniture (the blank-row +# rules own it), and a row mixing braille with anything else is not either. +_fm_composer_row_is_braille_furniture() { # + local row=$1 rest + fm_composer_normalize_trim_var row + [ -n "$row" ] || return 1 + rest=$(printf '%s\n' "$row" | fm_composer_strip_braille) + fm_composer_normalize_trim_var rest + [ -z "$rest" ] +} + +# _fm_composer_bare_row_strip_furniture_var: on a bare agent-glyph row, reduce +# the row to its glyph when everything behind the glyph is braille furniture, +# in place through the named variable; a row whose tail carries anything else, +# and a row with no agent glyph, are left untouched. This is the glyph-row half +# of the braille rule: codex 0.154's starfield cells behind its (stripped) +# placeholder must not stand in for typed input. +_fm_composer_bare_row_strip_furniture_var() { # + local __fmbf_name=$1 __fmbf_text=${!1} __fmbf_glyph='' __fmbf_body + fm_composer_leading_agent_glyph_var __fmbf_glyph "$__fmbf_text" || return 0 + __fmbf_body=${__fmbf_text#*"$__fmbf_glyph"} + if _fm_composer_row_is_braille_furniture "$__fmbf_body"; then + printf -v "$__fmbf_name" '%s' "$__fmbf_glyph" + fi +} + # _fm_composer_wrap_region_ok: 0 when every row STRICTLY BELOW # through is non-blank and carries no structural edge - the # contiguity proof that those rows are the bare composer's wrapped input @@ -1031,6 +1115,7 @@ _fm_composer_wrap_region_ok() { # [ -n "$trimmed" ] || return 1 if fm_composer_row_has_edge "$trimmed"; then return 1; fi if _fm_composer_row_is_omp_status "$trimmed"; then return 1; fi + if _fm_composer_row_is_braille_furniture "$trimmed"; then return 1; fi if fm_composer_leading_shell_glyph_var glyph "$trimmed"; then return 1; fi row=$((row + 1)) done @@ -1049,8 +1134,11 @@ _fm_composer_classify_bare_wrap() { # --ansi` was verified at zellij 0.44.0 to preserve ANSI styling (real Claude Code rendered inside a zellij pane dumped `ESC[m` `❯` U+00A0 for its idle composer row), which is the capability the zellij composer classifier reads. +### 2026-09-15 codex-cli 0.154.0 idle starfield and status footer through Herdr + +Verified on 2026-09-15 on macOS arm64 (Darwin 25.5.0) against codex-cli 0.154.0 (model gpt-6-astra, fast mode) running as a Codex second mate inside a Herdr pane, read through Herdr's ANSI capture with its exact capability descriptor (`styled=1`, `cursor=0`, `identity=1`, `rows=20`). +Idle, codex 0.154 animates a braille starfield on the row above its bold `›` prompt row, on the `›` row behind the SGR-2 dim `Ask Codex to do anything` placeholder, and on the row below it, then draws a status footer reading `gpt-6-astra high fast · ~/Projects/purser · Launch Purser desk brief`. +The starfield cells are truecolor greys whose luminance runs from roughly 66 to 165, so the cells above the 128 ghost ceiling survive ghost stripping, and the footer is bright, non-blank, and carries no structural edge. + +The capture is a read-only `herdr pane read --format ansi` of the live pane; its 20-row tail is fed to the shared classifier with the descriptor above: + +```sh +herdr pane read w4Z:p2 --format ansi > codex-0.154-idle-herdr.ansi +bash -c '. bin/fm-composer-lib.sh + caps=$(printf "styled=1\ncursor=0\nidentity=1\nrows=20") + fm_composer_classify_screen "$caps" "$(tail -n 20 codex-0.154-idle-herdr.ansi)"' +``` + +Observed output on the same capture before the fix (`bin/fm-composer-lib.sh` at b85e28b5) and then after it: + +```text +pending +empty +``` + +Before the fix the bare `›` shape extended its wrap region over the two rows beneath the glyph (`kind=bare first=17 last=19` within the 20-row tail), read the surviving starfield cells and the footer as wrapped typed input, and answered `pending`. +The steering doorbell (`fm_task_inbox_ring` in `bin/fm-task-inbox-lib.sh`) defers on exactly that verdict, so every ring for the pane was recorded as skipped and the marked request was reported as a missed delivery. +After the fix, braille-only rows bound the wrap region (the status footer sits beneath the starfield row, so the region never reaches it), starfield cells behind the placeholder are stripped from the glyph row, and the same capture reads `empty` under the Herdr and Zellij styled profiles and with a tmux cursor on the glyph row, while a plain (`styled=0`) capture still reads `unknown`, never `pending`. +A second read-only capture of the same pane, taken during the fix with a bright starfield cell drawn between the `›` and the placeholder, read `pending` before and `empty` after as well. +`test_matrix_codex_idle_starfield_furniture` in `tests/fm-composer-lib.test.sh` carries both samples byte-for-byte, the divergence (the same screen with letters in place of the starfield reads `pending`), and the over-stripping negatives (wrapped typed input, braille mixed with text, a typed row with a middle dot, and the footer or a starfield row alone). + +The live guard that refreshes this entry launches the installed codex idle in an isolated tmux server and asserts `empty` through both the cursor-anchored tmux read and the cursorless styled read Herdr and Zellij use, naming codex and `codex --version` on failure; it is default-on wherever codex and tmux are installed and spends no tokens: + +```sh +tests/fm-composer-codex-idle-live-e2e.test.sh +``` + +The verification machine runs its fleet on Herdr and has no tmux installed, so on 2026-09-15 that guard reported `skip: live: tmux absent` there, and the Herdr capture above is this entry's live evidence. +The guard also notes whether the starfield and the placeholder were actually drawn during its read, because codex need not animate them under every model or mode; a refresh on a tmux host should record that note beside the verdict rather than assume the starfield was exercised. + ## Steering-inbox doorbell The steering channel's one behavioral assumption - a real worker agent follows the constant self-describing doorbell line (list the inbox, read and act on its records in numeric order, then `mv` each into `handled/`) - was verified on 2026-08-23 against every installed verified harness, on tmux 3.6a, macOS arm64, on an isolated private socket, driving the REAL `bin/fm-send.sh` end to end (durable record plus doorbell, with one mid-wait re-ring playing the watcher's role). @@ -1154,6 +1191,7 @@ Real captures verified these active distinctions: - Dim or faint suggestion text is ghost content, while normally styled text is pending input. - Grok dark truecolor placeholders are ghost content, while bright truecolor typed input remains pending. - A bare shell prompt has no safe agent-composer container and is unknown. +- Codex 0.154's idle braille starfield rows are composer furniture, with the dated Herdr evidence and refresh command in [Composer classification matrix](#composer-classification-matrix). `tests/fm-composer-ghost.test.sh`, `tests/fm-composer-lib.test.sh`, and the Herdr composer cases pin the exact captured ANSI bytes. The U+2063 operational and routed-request separators were exercised through a real Pi-on-Herdr path; the byte-exact active regression is: diff --git a/tests/fm-composer-codex-idle-live-e2e.test.sh b/tests/fm-composer-codex-idle-live-e2e.test.sh new file mode 100755 index 00000000000..35e86eb9a28 --- /dev/null +++ b/tests/fm-composer-codex-idle-live-e2e.test.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# tests/fm-composer-codex-idle-live-e2e.test.sh - the live codex idle-screen +# guard (live-harness-optin family; task fm-composer-codex-idle-furniture). +# +# codex-cli 0.154.0 draws animation furniture around its idle composer: a +# braille "starfield" on the rows around the bare `›` prompt (and behind its +# dim `Ask Codex to do anything` placeholder), with a bright model/path/title +# status footer beneath it. The shared classifier (bin/fm-composer-lib.sh) +# must read those rows as furniture, not typed input, or every steering +# doorbell into an idle codex pane is deferred as "pending text". Those rows +# are vendor-rendered, so per .agents/skills/firstmate-coding-guidelines the +# byte fixture in tests/fm-composer-lib.test.sh is not enough on its own: this +# guard launches the INSTALLED codex idle in an isolated tmux server, captures +# its screen with styling preserved, and requires the classifier to reach +# `empty` through BOTH capability profiles that read it in production - the +# cursor-anchored tmux read (fm_tmux_composer_state) and the cursorless styled +# read that Herdr and Zellij use, which is the profile that failed live. It +# fails naming codex and `codex --version`. +# +# Reading an idle screen submits no prompt, so no model tokens are spent and +# the gate is default-on wherever codex and tmux are installed (fm_live_gate): +# FM_COMPOSER_CODEX_IDLE_LIVE=1 forces it (an absent codex then fails instead +# of skipping) and =0 disables it. A run that verified nothing fails rather +# than passing vacuously. Whether the starfield was actually drawn during the +# read is reported as a note, because codex need not animate it under every +# model or mode; the `empty` verdict is required either way. +# Refresh docs/verification/runtime-backends.md ("Composer classification +# matrix") from this guard's output after any codex upgrade. +# +# Folder trust: codex is launched with the repo root as cwd, which the +# operator's machine has normally already trusted; a trust dialog is a real +# unreadable-composer state and correctly fails the check. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +fm_live_gate default-on FM_COMPOSER_CODEX_IDLE_LIVE codex tmux + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +SOCKET="fm-codex-idle-$$" +SESSION="codexidle" +WIN="codex" +CHECKED=0 + +fail() { printf 'not ok - %s\n' "$1" >&2; cleanup; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } +note() { printf '# %s\n' "$1"; } + +cleanup() { + tmux -L "$SOCKET" kill-server 2>/dev/null || true +} +trap cleanup EXIT + +# The library under test, driven against the private socket through a PATH +# shim so its bare `tmux` calls stay isolated from any live fleet. +SHIM_DIR=$(mktemp -d "${TMPDIR:-/tmp}/fm-codex-idle-live.XXXXXX") +REAL_TMUX=$(command -v tmux) +cat > "$SHIM_DIR/tmux" </dev/null | head -1) +[ -n "$VERSION" ] || VERSION='version-unknown' + +tmux -L "$SOCKET" new-session -d -s "$SESSION" -x 160 -y 45 -c "$ROOT" +tmux -L "$SOCKET" new-window -d -t "$SESSION:" -n "$WIN" -c "$ROOT" -- codex \ + || fail "codex ($VERSION): could not launch in the isolated tmux server" + +# The cursorless styled read exactly as bin/backends/herdr.sh describes its +# ANSI capture: a bounded styled tail plus the shared capability facts, with +# the lazy identity pass answered `probe-absent` because no identity probe is +# needed for a bare composer. +CAPS_CURSORLESS=$(printf 'styled=1\ncursor=0\nidentity=1\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES") +classify_cursorless() { # + local verdict + verdict=$(fm_composer_classify_screen "$CAPS_CURSORLESS" "$1") + if [ "$verdict" = need-identity ]; then + verdict=$(fm_composer_classify_screen "$CAPS_CURSORLESS" "$1" '' probe-absent) + [ "$verdict" != need-identity ] || verdict=unknown + fi + printf '%s' "$verdict" +} + +budget=${FM_COMPOSER_CODEX_IDLE_LIVE_POLLS:-45} +i=0 +tmux_verdict='' +cursorless_verdict='' +styled='' +dismissed=0 +while [ "$i" -lt "$budget" ]; do + tmux_verdict=$(fm_tmux_composer_state "$SESSION:$WIN") + styled=$(tmux capture-pane -e -p -t "$SESSION:$WIN" 2>/dev/null | tail -n "$FM_COMPOSER_CAPTURE_LINES") + cursorless_verdict=$(classify_cursorless "$styled") + if [ "$tmux_verdict" = empty ] && [ "$cursorless_verdict" = empty ]; then + break + fi + i=$((i + 1)) + # A fresh codex may park on a vendor update-available modal (observed live + # on codex 0.146.0), which the strict classifier correctly refuses to call a + # composer. Dismiss it once, mid-budget, with a single Escape - the one key + # that submits nothing. Never Enter: on codex's dialog Enter would RUN the + # upgrade. A trust prompt also accepts Escape, but there it exits codex and + # erases the actionable failure surface, so it is left alone. + if [ "$dismissed" -eq 0 ] && [ "$i" -ge $((budget / 3)) ]; then + if ! tmux capture-pane -p -t "$SESSION:$WIN" 2>/dev/null | grep -qi 'trust'; then + tmux send-keys -t "$SESSION:$WIN" Escape 2>/dev/null || true + fi + dismissed=1 + fi + sleep 1 +done + +# Report what codex actually drew, so a refreshed verification record can say +# whether the starfield was exercised rather than assuming it. +plain=$(printf '%s\n' "$styled" | fm_composer_strip_ansi) +starfield=no +while IFS= read -r row; do + if _fm_composer_row_is_braille_furniture "$row"; then starfield=yes; break; fi +done <&2 + printf '%s\n' "$plain" | grep '[^[:space:]]' | tail -8 | sed 's/^/# /' >&2 + fail "codex ($VERSION): idle screen never classified empty (tmux read: ${tmux_verdict:-unreadable}, cursorless styled read: ${cursorless_verdict:-unreadable})" +fi + +[ "$CHECKED" -gt 0 ] || fail "live codex idle-screen guard verified nothing; refusing a vacuous pass" +pass "live codex idle-screen guard verified $CHECKED live surface(s)" diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 3ebfbe3b7c5..da7b7138afe 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -141,7 +141,8 @@ test_real_text_is_pending() { # # Fixtures are the audit's byte-level captures of six REAL idle harnesses: # claude 2.1.226 (bare `❯` + U+00A0 NO-BREAK SPACE), codex 0.146.0 (bold `›` -# + SGR-2 dim hint), muse (truecolor `⟩`, 38;2;90;160;255), pi (blank row +# + SGR-2 dim hint), codex 0.154.0 (the same `›` amid a braille starfield over +# a status footer, captured through Herdr on 2026-09-15), muse (truecolor `⟩`, 38;2;90;160;255), pi (blank row # between solid `─` rules), opencode 1.14.46 (left-bar `┃` rows), and grok # 1.0.0 (bordered box with a TITLED bottom border), plus claude captured # inside zellij through `dump-screen --ansi` (`ESC[m` `❯` U+00A0). @@ -351,6 +352,100 @@ test_matrix_omp_status_row_bounds_bare_composer() { pass "matrix: omp's status row bounds the bare composer's wrap region" } +# codex_cell : one codex 0.154 starfield cell exactly as the +# harness draws it - a truecolor grey foreground, the composer's grey +# background, the braille glyph, then a reset. +codex_cell() { + printf '%s[38;2;%s;%s;%sm%s[48;2;57;57;57m%s%s[0m' "$ESC" "$1" "$1" "$1" "$ESC" "$2" "$ESC" +} + +test_matrix_codex_idle_starfield_furniture() { + # Real idle codex-cli 0.154.0 (gpt-6-astra, fast mode) captured byte-for-byte + # through Herdr (`pane read --format ansi`) from the first codex second mate: + # an animated braille "starfield" on the row above the bold `›`, on the `›` + # row behind the SGR-2 dim `Ask Codex to do anything` placeholder, and on + # the row below, then a bright model/path/title status footer. The cells are + # truecolor greys on BOTH sides of the 128 ghost-luma ceiling, so the + # brighter ones survive the ghost strip, and the rows below the glyph carry + # no structural edge. The bare shape therefore extended its wrap region over + # the two rows beneath the glyph and read the survivors as wrapped typed + # input: `pending`, which deferred every steering doorbell for that pane. + local bg="${ESC}[48;2;57;57;57m" above glyph glyph2 below footer + local screen screen2 plain plain2 ascii_screen stripped out + above="${ESC}[0m${bg} ${ESC}[0m$(codex_cell 82 ⢀)${bg} ${ESC}[0m$(codex_cell 136 ⠂)${bg} ${ESC}[0m$(codex_cell 163 ⠄)${bg} ${ESC}[0m$(codex_cell 118 ⠈)" + glyph="${ESC}[0m${ESC}[1m${bg}›${ESC}[0m${bg} ${ESC}[0m${ESC}[2m${bg}Ask Codex to do anything${ESC}[0m$(codex_cell 117 ⡀)${bg} ${ESC}[0m$(codex_cell 88 ⠈)${bg} ${ESC}[0m$(codex_cell 156 ⠂)${bg} ${ESC}[0m$(codex_cell 71 ⠁)$(codex_cell 161 ⠐)${bg} ${ESC}[0m$(codex_cell 165 ⠁)" + # A second live sample of the same pane, minutes later: the animation had + # placed a bright cell BETWEEN the glyph and the placeholder. + glyph2="${ESC}[0m${ESC}[1m${bg}›${ESC}[0m$(codex_cell 138 ⠁)${ESC}[2m${bg}Ask Codex to do anything${ESC}[0m$(codex_cell 163 ⡀)${bg} ${ESC}[0m$(codex_cell 132 ⠈)" + below="${ESC}[0m${bg} ${ESC}[0m$(codex_cell 101 ⠐)${bg} ${ESC}[0m$(codex_cell 111 ⠄)${bg} ${ESC}[0m$(codex_cell 165 ⠠)${bg} ${ESC}[0m$(codex_cell 121 ⢀)$(codex_cell 122 ⠠)$(codex_cell 81 ⡀)$(codex_cell 150 ⠄⠂)" + footer=" ${ESC}[0m${ESC}[38;2;246;226;183mgpt-6-astra high fast${ESC}[0m${ESC}[2m · ${ESC}[0m${ESC}[38;2;171;223;167m~/Projects/purser${ESC}[0m${ESC}[2m · ${ESC}[0m${ESC}[38;2;156;222;211mLaunch Purser desk brief${ESC}[0m" + screen=$'transcript line\n\n'"$above"$'\n'"$glyph"$'\n'"$below"$'\n'"$footer" + screen2=$'transcript line\n\n'"$above"$'\n'"$glyph2"$'\n'"$below"$'\n'"$footer" + plain=$(printf '%s\n' "$screen" | fm_composer_strip_ansi) + plain2=$(printf '%s\n' "$screen2" | fm_composer_strip_ansi) + + # NON-VACUOUSNESS: the ghost strip really leaves braille survivors behind the + # placeholder and on the row below (cells above the luma ceiling), and the + # footer really is non-blank, edge-free content the wrap region would take. + stripped=$(printf '%s\n' "$glyph" | fm_composer_strip_ghost) + fm_composer_normalize_trim_var stripped + [ "$stripped" != '›' ] \ + || fail "the glyph row's starfield cells must survive ghost stripping, or the furniture case is vacuous" + stripped=$(printf '%s\n' "$stripped" | fm_composer_strip_braille) + fm_composer_normalize_trim_var stripped + [ "$stripped" = '›' ] \ + || fail "everything surviving ghost stripping behind the glyph must be braille, got '$stripped'" + stripped=$(printf '%s\n' "$below" | fm_composer_strip_ghost) + fm_composer_normalize_trim_var stripped + [ -n "$stripped" ] \ + || fail "the row below the glyph must keep starfield cells after ghost stripping" + _fm_composer_row_is_braille_furniture "$stripped" \ + || fail "the row below the glyph must be recognized as braille furniture" + fm_composer_row_has_edge ' gpt-6-astra high fast · ~/Projects/purser · Launch Purser desk brief' \ + && fail "fixture drift: the footer must carry no structural edge, or the boundary rule is untested" + + # The verdicts: empty wherever styling can prove the placeholder ghost, on + # both live samples, in both locales; unknown (never pending) on a plain + # capture, exactly as the codex dim-hint row above. + assert_screen "codex 0.154 idle on herdr" empty "$CAPS_STYLED" "$screen" + assert_screen "codex 0.154 idle on zellij" empty "$CAPS_STYLED_NOID" "$screen" + assert_screen "codex 0.154 idle on tmux (cursor on the glyph row)" empty "$CAPS_TMUX" "$screen" 3 + assert_screen "codex 0.154 idle on cmux/orca" unknown "$CAPS_PLAIN" "$plain" + assert_screen "codex 0.154 idle (second sample) on herdr" empty "$CAPS_STYLED" "$screen2" + assert_screen "codex 0.154 idle (second sample) on tmux" empty "$CAPS_TMUX" "$screen2" 3 + assert_screen "codex 0.154 idle (second sample) on cmux/orca" unknown "$CAPS_PLAIN" "$plain2" + # A cursor parked on the starfield row below the glyph is not inside a wrap + # region, so the strict blank-row posture keeps it unknown. + assert_screen "codex 0.154 cursor on the starfield row" unknown "$CAPS_TMUX" "$screen" 4 + + # DIVERGENCE: the same screen with every starfield cell replaced by a letter + # is wrapped typed input and must stay pending, so the furniture verdict + # above cannot come from anything but the braille rule. + ascii_screen=$(printf '%s\n' "$screen" | LC_ALL=C sed 's/⢀/x/g; s/⠂/x/g; s/⠄/x/g; s/⠈/x/g; s/⡀/x/g; s/⠁/x/g; s/⠐/x/g; s/⠠/x/g') + case "$ascii_screen" in *'⠂'*|*'⠁'*) fail "fixture drift: the divergence screen still carries braille" ;; esac + assert_screen "starfield replaced by letters on herdr" pending "$CAPS_STYLED" "$ascii_screen" + assert_screen "starfield replaced by letters on tmux" pending "$CAPS_TMUX" "$ascii_screen" 3 + + # NEGATIVES that keep the rule from over-stripping: + # (i) a real message wrapped below the `›` row, footer beneath, stays pending. + out=$'transcript line\n\n› please run the suite and then\ncontinue with the docs\n'"$footer" + assert_screen "wrapped typed input above the codex footer on herdr" pending "$CAPS_STYLED" "$out" + assert_screen "wrapped typed input above the codex footer on tmux" pending "$CAPS_TMUX" "$out" 3 + # (ii) braille mixed with typed text is typed text, on the glyph row and on + # a wrapped row alike. + assert_screen "braille mixed into the glyph row" pending "$CAPS_STYLED" $'transcript line\n\n› fix ⠂ the tests' + assert_screen "braille mixed into a wrapped row" pending "$CAPS_STYLED" $'transcript line\n\n› please\nfix ⠂ the tests' + # (iii) a typed row carrying a spaced middle dot is composer input. + assert_screen "wrapped typed row with a middle dot on herdr" pending "$CAPS_STYLED" $'transcript line\n\n› deploy\nfix · tests before pushing' + assert_screen "wrapped typed row with a middle dot on tmux" pending "$CAPS_TMUX" $'transcript line\n\n› deploy\nfix · tests before pushing' 3 + # (iv) the footer or a starfield row alone, with no bare glyph above, gains + # no new verdict: still no container proof. + assert_screen "codex footer alone on herdr" unknown "$CAPS_STYLED" $'transcript line\n\n'"$footer" + assert_screen "codex footer alone on tmux" unknown "$CAPS_TMUX" $'transcript line\n\n'"$footer" 2 + assert_screen "starfield row alone on herdr" unknown "$CAPS_STYLED" $'transcript line\n\n'"$below" + pass "matrix: codex 0.154's starfield rows are furniture; typed, mixed, and unanchored rows keep their verdicts" +} + test_matrix_pi_separated_needs_identity() { # Real idle pi: a blank row between two solid rules. The blank row alone is # exactly what the strict rule refuses; only structure PLUS a live @@ -693,6 +788,7 @@ test_matrix_muse_truecolor_glyph_survives_signal_loss test_matrix_cursor_reverse_video_placeholder_remnant test_matrix_herdr_halfblock_rule_bounds_bare_wrap test_matrix_omp_status_row_bounds_bare_composer +test_matrix_codex_idle_starfield_furniture test_matrix_pi_separated_needs_identity test_matrix_opencode_leftbar_signals test_matrix_grok_titled_bottom_border