Skip to content

wait_for_text: entry cursor row's content stays suppressed for the whole wait #106

Description

@tony

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions