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

Open
#106 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
74/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
python, shell
Domain
cli, testing-qa

Research direction

Start in src/libtmux_mcp/tools/pane_tools/wait.py at the entry snapshot around lines 639-642 and filtering around line 766. Run repro_entryrow.py, then read test_wait_for_text_does_not_match_the_prompt_on_the_entry_row and test_wait_for_text_ignores_stale_below_cursor. Done means newly arriving content matching the former entry row can match after the cursor advances, while both existing behaviors remain green.

Written by the indexing model from the issue text.

Description

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.

Dominant language
Python
Stars
13
Forks
0
Avg merge
13h 52m
Merged PRs (30d)
4

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from tmux-python/libtmux-mcp

All issues in tmux-python/libtmux-mcp

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.