wait_for_text suppresses the entry cursor row's content for the whole wait, so any later row that renders identically is unmatchable. On a shell prompt that is the prompt itself — the single most common thing a caller waits for after sending a command.
Where
wait.py:639-642 — the entry snapshot: entry_rows is captured from entry.cursor_y down and frozen into entry_below_cursor.
wait.py:766 — the filter: new_lines = [line for line in rows if line not in entry_below_cursor], applied on every tick for the entire wait.
Suppression is by VALUE and unbounded in time. It was deliberately chosen over suppression by index in #100, because skipping the entry row by index is what made single-line output on a quiescent pane unmatchable. The fix was correct; this is the residue of it.
Why it bites in practice
tmux does not render trailing spaces, so a shell prompt like $ captures as $. grid_string_cells stops at gl->cellused rather than cellsize unless GRID_STRING_EMPTY_CELLS is set, and capture-pane does not set it — see grid.c#L1109-L1112 and the call site at cmd-capture-pane.c#L252 (locally: ~/study/c/tmux/grid.c and ~/study/c/tmux/cmd-capture-pane.c, checkout at tag 3.7b). That trimming is what makes byte-identical collisions common rather than exotic: every returning prompt renders to exactly the string the entry row already had.
Verified on the live server: printf 'PROMPT: ' captures back as PROMPT:.
Recreation
Self-proving: the bug case must MISS, and two controls on the identical pane and timing must both HIT. Run from a checkout at main.
env -u VIRTUAL_ENV uv run python repro_entryrow.py
repro_entryrow.py
"""Reproduction: entry-cursor-row content is suppressed for the whole wait.
wait_for_text snapshots the entry cursor row's CONTENT and filters every
later row equal to it, for the entire wait. On a shell prompt the entry
row is the prompt, so any later row that renders byte-identically to it
is unmatchable -- including the prompt returning, which is the single
most common thing a caller waits for after sending a command.
Run against a throwaway socket; the script creates and destroys it.
"""
from __future__ import annotations
import asyncio
import json
import os
import subprocess
import sys
SOCK = "repro-entryrow"
def sh(*args: str) -> str:
return subprocess.run(
["tmux", "-L", SOCK, *args], capture_output=True, text=True, check=False
).stdout.rstrip("\n")
async def case(name: str, patterns: list[str] | None, payload_row: str = "$") -> dict:
from libtmux_mcp.tools.pane_tools.wait import wait_for_text
sh("kill-server")
# A pane parked on a prompt-shaped row that never moves on its own.
subprocess.run(
["tmux", "-L", SOCK, "new-session", "-d", "-s", "s", "-x", "80", "-y", "24",
"sh", "-c", "printf '$ '; sleep 120"],
check=True,
)
pane = sh("list-panes", "-t", "s", "-F", "#{pane_id}")
tty = sh("display-message", "-p", "-t", pane, "#{pane_tty}")
await asyncio.sleep(1.0)
entry_row = sh("capture-pane", "-p", "-t", pane).splitlines()[0]
async def emit() -> None:
# Land four rows whose rendered content is byte-identical to the
# entry row. The leading newline is what puts them on FRESH rows
# instead of appending to the entry row.
await asyncio.sleep(1.0)
fd = os.open(tty, os.O_WRONLY)
try:
os.write(fd, ("\n" + "\n".join([payload_row] * 4) + "\n").encode())
finally:
os.close(fd)
task = asyncio.create_task(emit())
result = await wait_for_text(
pane_id=pane, patterns=patterns, timeout=5.0, socket_name=SOCK
)
await task
after = sh("capture-pane", "-p", "-t", pane)
sh("kill-server")
return {
"case": name,
"entry_row_content": repr(entry_row),
"rows_added": len([ln for ln in after.splitlines() if ln.strip()]) - 1,
"found": result.found,
"outcome": result.outcome,
"elapsed_seconds": result.elapsed_seconds,
"saw_new_output": result.saw_new_output,
"matched_at_entry": result.matched_at_entry,
"tail": result.tail[-2:],
}
async def main() -> int:
rows = [
# BUG: the pattern matches text that IS on screen and IS new.
await case("patterns=['\\\\$'] matching the entry row's content", [r"\$"]),
# CONTROL: identical setup, but the arriving rows differ from the
# entry row -- this MUST match, proving the harness can succeed and
# that the miss above is about the content equality, nothing else.
await case("control: rows differ from the entry row", ["READY"], "READY"),
# CONTROL: patterns=null still sees the rows.
await case("control: patterns=null", None),
]
print(json.dumps(rows, indent=2))
bug, ctrl_match, ctrl_null = rows
print()
ok = (not bug["found"]) and ctrl_match["found"] and ctrl_null["found"]
print("REPRODUCED (and both controls behaved)" if ok else "NOT reproduced / control failed")
return 0
if __name__ == "__main__":
sys.path.insert(0, "/home/d/work/python/libtmux-mcp-trunk/src")
sys.exit(asyncio.run(main()))
Measured at a6e4117d39ffe633d50eb1ad3a6fcd55a71e5e60:
| case |
outcome |
elapsed |
rows that arrived |
patterns=["\\$"] — matches the entry row's content |
timeout |
5.019 s |
4 |
| control: arriving rows differ from the entry row |
matched |
1.006 s |
4 |
control: patterns=null |
any_output |
1.022 s |
4 |
The first row is the bug: four rows containing $ arrived during the wait and none matched. The controls prove the harness can succeed and that the rows genuinely arrived.
Severity
Not silent, and not a regression against the previous behaviour in the common direction. saw_new_output is true and tail shows the rows, so the caller lands on the documented "output arrived and did not match — read tail, fix the pattern" path. The trade is also favourable overall: before #100, a marker arriving on a quiescent pane was missed entirely (measured: not found after 8.06 s, versus found in 1.18 s now).
What is wrong is the scope. The entry row's content should stop being suppressed once the cursor has moved past that row — after that point, anything rendering there is genuinely new.
Suggested fix
Scope the suppression to the entry row's absolute grid index, or drop it as soon as cursor_y advances past the entry row, instead of filtering that string for the whole wait. Rows below the entry cursor keep the existing content filter; only the entry row itself needs the narrower rule.
Two existing tests depend on entry-row value suppression and encode the behaviour that must not regress:
test_wait_for_text_does_not_match_the_prompt_on_the_entry_row — a broad pattern must not self-match the prompt already on the row at t=0.
test_wait_for_text_ignores_stale_below_cursor — stale paint below the cursor must stay suppressed.
A fix should add the case above and keep both of those green.
wait_for_textsuppresses the entry cursor row's content for the whole wait, so any later row that renders identically is unmatchable. On a shell prompt that is the prompt itself — the single most common thing a caller waits for after sending a command.Where
wait.py:639-642— the entry snapshot:entry_rowsis captured fromentry.cursor_ydown and frozen intoentry_below_cursor.wait.py:766— the filter:new_lines = [line for line in rows if line not in entry_below_cursor], applied on every tick for the entire wait.Suppression is by VALUE and unbounded in time. It was deliberately chosen over suppression by index in #100, because skipping the entry row by index is what made single-line output on a quiescent pane unmatchable. The fix was correct; this is the residue of it.
Why it bites in practice
tmux does not render trailing spaces, so a shell prompt like
$captures as$.grid_string_cellsstops atgl->cellusedrather thancellsizeunlessGRID_STRING_EMPTY_CELLSis set, andcapture-panedoes not set it — see grid.c#L1109-L1112 and the call site at cmd-capture-pane.c#L252 (locally:~/study/c/tmux/grid.cand~/study/c/tmux/cmd-capture-pane.c, checkout at tag3.7b). That trimming is what makes byte-identical collisions common rather than exotic: every returning prompt renders to exactly the string the entry row already had.Verified on the live server:
printf 'PROMPT: 'captures back asPROMPT:.Recreation
Self-proving: the bug case must MISS, and two controls on the identical pane and timing must both HIT. Run from a checkout at
main.repro_entryrow.pyMeasured at
a6e4117d39ffe633d50eb1ad3a6fcd55a71e5e60:patterns=["\\$"]— matches the entry row's contenttimeoutmatchedpatterns=nullany_outputThe first row is the bug: four rows containing
$arrived during the wait and none matched. The controls prove the harness can succeed and that the rows genuinely arrived.Severity
Not silent, and not a regression against the previous behaviour in the common direction.
saw_new_outputistrueandtailshows the rows, so the caller lands on the documented "output arrived and did not match — readtail, fix the pattern" path. The trade is also favourable overall: before #100, a marker arriving on a quiescent pane was missed entirely (measured: not found after 8.06 s, versus found in 1.18 s now).What is wrong is the scope. The entry row's content should stop being suppressed once the cursor has moved past that row — after that point, anything rendering there is genuinely new.
Suggested fix
Scope the suppression to the entry row's absolute grid index, or drop it as soon as
cursor_yadvances past the entry row, instead of filtering that string for the whole wait. Rows below the entry cursor keep the existing content filter; only the entry row itself needs the narrower rule.Two existing tests depend on entry-row value suppression and encode the behaviour that must not regress:
test_wait_for_text_does_not_match_the_prompt_on_the_entry_row— a broad pattern must not self-match the prompt already on the row at t=0.test_wait_for_text_ignores_stale_below_cursor— stale paint below the cursor must stay suppressed.A fix should add the case above and keep both of those green.