Skip to content

Increase tmux coverage: Client, typed fields, Native filtering#672

Merged
tony merged 69 commits into
masterfrom
parity-pt-2
May 18, 2026
Merged

Increase tmux coverage: Client, typed fields, Native filtering#672
tony merged 69 commits into
masterfrom
parity-pt-2

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented May 16, 2026

Summary

libtmux 0.57.0 broadens tmux coverage. It introduces Client as a first-class typed object, threads tmux's C-side -f filter through the typed listing methods so callers can push predicates into the tmux server, adds typed access to many more format tokens with scope-and-version-gated -F templates, and propagates subcommand context through LibTmuxException so downstream tools can dispatch on which tmux command produced an error.

What's new

  • Client object and Server.clients accessor. New typed dataclass for attached terminals. client_name is the stable identifier; session_id / window_id / pane_id are attached-view snapshots hydrated via tmux's downward format_defaults cascade. attached_session / attached_window / attached_pane re-read list-clients before resolving; attached_pane is session-scope (cascade), not the client's CLIENT_ACTIVEPANE focus.
  • Server.display_message and Window.display_message. Server reads like #{version} and #{socket_path} work without a pane handle; window reads (#{window_zoomed_flag}, #{window_active_clients_list}) auto-bind to the window's id. All three display_message wrappers (including the existing Pane.display_message) surface tmux stderr via warnings.warn rather than dropping it silently. Callers that want to escalate can wrap in warnings.catch_warnings with filterwarnings("error").
  • C-side -f filter on typed listing methods. Server.search_panes / search_windows / search_sessions plus the Session and Window analogues take a filter= kwarg routed to tmux's -f flag. tmux evaluates the predicate and drops non-matching rows before any Python instance is constructed. Caveat: tmux silently expands a malformed predicate to empty, which the format engine treats as false — a typo looks identical to "no matches".
  • Scope-and-version-aware -F templates. The format string libtmux sends each list-* subcommand is now scope- and version-aware. A Session row hydrates active-window and active-pane fields via tmux's downward cascade; a Client row likewise hydrates the client's attached session, window, and active pane. Tokens introduced after tmux 3.2a are gated through FIELD_VERSION so the -F template stays safe on the project's minimum supported tmux. Unknown tokens stay None on the typed surface — no crash, no warning.
  • Typed Pane / Window / Session / Client format-token fields for the 3.2a set. Pane state (pane_dead, pane_in_mode, pane_marked, pane_synchronized, pane_path, pane_pipe …), window state (window_zoomed_flag, window_silence_flag, window_flags …), session state (session_marked …), and client view (client_session, client_readonly, client_termtype …).
  • Server.run_shell(cwd=, show_stderr=). New kwargs map to tmux's -c (3.4+) and -E (3.6+) flags. Older tmux warns and ignores the kwarg instead of erroring.
  • Pane.send_keys(cmd=None, …) flag-only invocation. Pass cmd=None together with reset=True or repeat=N to invoke tmux's flag-only send-keys -R / send-keys -N <n> form without any trailing key argument.
  • Server.list_buffers(format_string=, filter=). Project a chosen -F template or push a buffer-name match expression through tmux's format engine.
  • Pane.capture_pane(pending=True). Return bytes tmux has read from the pane but not yet committed to the terminal — useful for diagnosing programs whose output stalls mid-sequence.

Fixes

Breaking changes

  • LibTmuxException string form gains a subcommand prefix. str(exc) now begins with the originating tmux subcommand name followed by ": ". An error from Session.last_window() used to render as "can't find window" and now renders as "last-window: can't find window". The wrapped-stderr type also changed: exc.args[0] is now str (joined with "\n" when tmux emitted multiple stderr lines); previously it was list[str]. Substring matches and unanchored re.search patterns continue to work unchanged.
  • Server.sessions, Server.clients, and Server.search_sessions raise on tmux errors. Previously, a tmux command failure was swallowed by a bare except Exception: pass, returning an empty QueryList indistinguishable from "no sessions/clients" or "filter matched nothing". The wrappers now let LibTmuxException propagate. Genuine empty results still return an empty QueryList; "no server running" is still treated as an empty result, preserving the contract that a fresh Server can be introspected before its daemon is up.

Deferred to follow-up shipments

  • Typed Obj fields for tokens tmux added in 3.4 / 3.5 / 3.6 and the forward-looking set from tmux master. Held until tmux 3.7 reaches a tagged release.
  • Per-client active-pane resolution via tmux's CLIENT_ACTIVEPANE flag. Client.attached_pane currently follows the cascade (session's current window's active pane); the two diverge once a client has used select-pane -P to set its own active pane.
  • A predictable error contract for display_message (version-gated raise, per-call raise_on_stderr kwarg, or kept-permanent warn).

Test plan

  • rm -rf docs/_build && uv run ruff format . && uv run ruff check . && uv run mypy src tests && uv run pytest --reruns 0 -vvv && just -f docs/justfile html — passes locally on tmux 3.6a
  • CI matrix (3.2a through master) green via gh pr checks --watch

Refs

@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

❌ Patch coverage is 67.02413% with 123 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.26%. Comparing base (4d5f7f7) to head (61688af).

Files with missing lines Patch % Lines
src/libtmux/neo.py 38.46% 55 Missing and 1 partial ⚠️
src/libtmux/server.py 77.77% 20 Missing and 4 partials ⚠️
src/libtmux/client.py 60.41% 16 Missing and 3 partials ⚠️
src/libtmux/window.py 67.92% 12 Missing and 5 partials ⚠️
src/libtmux/session.py 85.71% 3 Missing ⚠️
src/libtmux/common.py 50.00% 2 Missing ⚠️
src/libtmux/exc.py 75.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #672      +/-   ##
==========================================
+ Coverage   47.02%   51.26%   +4.23%     
==========================================
  Files          23       25       +2     
  Lines        3296     3482     +186     
  Branches      709      686      -23     
==========================================
+ Hits         1550     1785     +235     
- Misses       1384     1403      +19     
+ Partials      362      294      -68     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added a commit that referenced this pull request May 16, 2026
why: PR #672's CI matrix on tmux 3.2a crashed when the -F template
included tokens that don't apply to the calling list-* subcommand or
don't exist in the running tmux's format_table. The empirical crashers
were 11 client_* tokens queried during list-windows (no client context)
and several post-3.2a tokens that contributed cumulative risk.

what:
- src/libtmux/neo.py:
  - Add SCOPES_BY_LIST_CMD dict mapping each list-* to the set of token
    scopes its format engine can resolve (e.g. list-windows reaches
    universal + session + window; list-clients reaches universal +
    session + client).
  - Add FIELD_VERSION dict (initially empty) mapping field name → min
    tmux version; fields absent from the dict default to the project's
    floor (3.2a).
  - Add _SCOPE_PREFIXES table and _token_scope() helper that derive a
    token's scope from its name prefix (pane_*, window_*, session_*,
    client_*, buffer_*, etc.). Runtime-only tokens (mouse_*, cursor_*,
    selection_*, copy_cursor_*, popup_*) resolve to "event" and are
    excluded from all list-* templates.
  - Add _UNIVERSAL_TOKENS frozenset for cross-scope tokens without a
    scope prefix (pid, version, host, host_short, socket_path, etc.).
  - Add _normalize_tmux_version() helper that treats tmux master as a
    sentinel "newer than any tagged release" for comparison.
- Rewrite get_output_format() to take (list_cmd, tmux_version) and
  filter the field set accordingly. Cached via @functools.cache on the
  small number of (list_cmd, version) combinations a process sees.
- Rewrite parse_output() to take the same args so it reads the same
  filtered field order.
- Thread the live tmux version through fetch_objs() via
  get_version(server.tmux_bin) before calling get_output_format(),
  pass through to parse_output() per line.
- Doctests on the helpers and on get_output_format / parse_output
  demonstrate the new contracts.

No Obj field changes in this commit. The 27 fields rolled back during
the prior CI bisect remain absent — they re-enter in follow-up commits
that exercise the new scope/version gating.
tony added a commit that referenced this pull request May 16, 2026
…3.2a

why: with the scope+version gating in place, the format string sent to
older tmux versions automatically excludes tokens that those versions
don't recognize. The 8 tokens below first registered in tmux 3.4-3.6 —
tagging them with FIELD_VERSION makes them appear on supported tmux
releases that include them, and absent on older tmux without sending
unknown tokens that bloat the format string or trigger crashes.

what:
- src/libtmux/neo.py:
  - Re-add 8 fields to Obj alphabetically: pane_key_mode,
    pane_unseen_changes, session_active, session_activity_flag,
    session_alert, session_bell_flag, session_silence_flag, client_theme.
  - Populate FIELD_VERSION with each token's minimum tmux release
    (3.4 for pane_unseen_changes, 3.5 for pane_key_mode, 3.6 for the
    five session_* tokens and client_theme).
- tests/test_pane.py: restore pane_key_mode and pane_unseen_changes in
  PANE_FORMAT_FIELDS (the parametrized declaration+hydration test).
- tests/test_session.py: restore the 5 new session_* entries in
  SESSION_FORMAT_FIELDS.

Verification:
- On tmux 3.6a (local), all 8 tokens hydrate via refresh(); 1182 tests
  pass.
- On tmux 3.2a, FIELD_VERSION skips all 8 — the -F template stays at
  its pre-PR-#672-rollback shape for that version.

Version anchors verified via:
  rg '"<token>"' ~/study/c/tmux-3.<N>a/format.c
across 3.2a, 3.3a, 3.4, 3.5, 3.5a, 3.6, 3.6a.
tony added a commit that referenced this pull request May 16, 2026
why: PR #672's CI matrix on tmux 3.2a crashed when the -F template
included tokens that don't apply to the calling list-* subcommand or
don't exist in the running tmux's format_table. The empirical crashers
were 11 client_* tokens queried during list-windows (no client context)
and several post-3.2a tokens that contributed cumulative risk.

what:
- src/libtmux/neo.py:
  - Add SCOPES_BY_LIST_CMD dict mapping each list-* to the set of token
    scopes its format engine can resolve (e.g. list-windows reaches
    universal + session + window; list-clients reaches universal +
    session + client).
  - Add FIELD_VERSION dict (initially empty) mapping field name → min
    tmux version; fields absent from the dict default to the project's
    floor (3.2a).
  - Add _SCOPE_PREFIXES table and _token_scope() helper that derive a
    token's scope from its name prefix (pane_*, window_*, session_*,
    client_*, buffer_*, etc.). Runtime-only tokens (mouse_*, cursor_*,
    selection_*, copy_cursor_*, popup_*) resolve to "event" and are
    excluded from all list-* templates.
  - Add _UNIVERSAL_TOKENS frozenset for cross-scope tokens without a
    scope prefix (pid, version, host, host_short, socket_path, etc.).
  - Add _normalize_tmux_version() helper that treats tmux master as a
    sentinel "newer than any tagged release" for comparison.
- Rewrite get_output_format() to take (list_cmd, tmux_version) and
  filter the field set accordingly. Cached via @functools.cache on the
  small number of (list_cmd, version) combinations a process sees.
- Rewrite parse_output() to take the same args so it reads the same
  filtered field order.
- Thread the live tmux version through fetch_objs() via
  get_version(server.tmux_bin) before calling get_output_format(),
  pass through to parse_output() per line.
- Doctests on the helpers and on get_output_format / parse_output
  demonstrate the new contracts.

No Obj field changes in this commit. The 27 fields rolled back during
the prior CI bisect remain absent — they re-enter in follow-up commits
that exercise the new scope/version gating.
tony added a commit that referenced this pull request May 16, 2026
…3.2a

why: with the scope+version gating in place, the format string sent to
older tmux versions automatically excludes tokens that those versions
don't recognize. The 8 tokens below first registered in tmux 3.4-3.6 —
tagging them with FIELD_VERSION makes them appear on supported tmux
releases that include them, and absent on older tmux without sending
unknown tokens that bloat the format string or trigger crashes.

what:
- src/libtmux/neo.py:
  - Re-add 8 fields to Obj alphabetically: pane_key_mode,
    pane_unseen_changes, session_active, session_activity_flag,
    session_alert, session_bell_flag, session_silence_flag, client_theme.
  - Populate FIELD_VERSION with each token's minimum tmux release
    (3.4 for pane_unseen_changes, 3.5 for pane_key_mode, 3.6 for the
    five session_* tokens and client_theme).
- tests/test_pane.py: restore pane_key_mode and pane_unseen_changes in
  PANE_FORMAT_FIELDS (the parametrized declaration+hydration test).
- tests/test_session.py: restore the 5 new session_* entries in
  SESSION_FORMAT_FIELDS.

Verification:
- On tmux 3.6a (local), all 8 tokens hydrate via refresh(); 1182 tests
  pass.
- On tmux 3.2a, FIELD_VERSION skips all 8 — the -F template stays at
  its pre-PR-#672-rollback shape for that version.

Version anchors verified via:
  rg '"<token>"' ~/study/c/tmux-3.<N>a/format.c
across 3.2a, 3.3a, 3.4, 3.5, 3.5a, 3.6, 3.6a.
@tony
Copy link
Copy Markdown
Member Author

tony commented May 16, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Scope: commit 972c3149 ("neo(fix[scope-gate]): Widen SCOPES_BY_LIST_CMD to admit downward cascade") — 3 files, +67/-6.

Verified across 5 review passes (CLAUDE.md compliance, shallow bug scan, git history of the scope gate, prior PR comments on neo.py/test_server.py/test_client.py, in-file comment compliance). The widening preserves the original tmux 3.2a defense (upward client_* queries still gated to list-clients only) while restoring downward-cascade hydration on Server.sessions[i]/Server.windows[i]/Server.clients[i] that was silently lost in the prior symmetric narrowing.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony tony changed the title Close 14 wrapper gaps from #670 (libtmux 0.57.0) Close 13 of 14 wrapper gaps from #670 (libtmux 0.57.0) May 16, 2026
@tony
Copy link
Copy Markdown
Member Author

tony commented May 16, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Scope: commit 8e14c0cf ("common(perf[get_version]): Memoize via @functools.cache to eliminate per-call fork") — 3 files, +155.

Verified across 5 review passes (CLAUDE.md compliance, shallow bug scan, git history of get_version and @functools.cache precedent at neo.py:415, prior PR comments on common.py/test_common.py, in-file comment compliance). History shows an independent prior attempt (da877f49 on 2026-04-perf-and-profiling) arrived at the same shape, and the sibling @functools.cache on get_output_format (27d9ad75) has shipped on master without issue. The autouse cache-clear fixture in the new tests/conftest.py correctly anticipates the test-mock pollution hazard for tests/test_common.py and tests/legacy_api/test_common.py parametrized tmux_cmd monkeypatches. The new test_get_version_binary_swap_requires_explicit_cache_clear test explicitly documents the sticky-cache trap with tmux_bin=None and validates the cache_clear() escape hatch.

One scoping nuance was considered (autouse fixture flushes cache for all tests in tests/, not just monkeypatched ones) but scored below the review threshold — it's a test-runtime concern, not a production-perf or correctness one, and not called out in CLAUDE.md.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony tony changed the title Close 13 of 14 wrapper gaps from #670 (libtmux 0.57.0) Increase tmux coverage: Client, typed fields, C-side filter May 16, 2026
tony added a commit that referenced this pull request May 17, 2026
why: PR #672's CI matrix on tmux 3.2a crashed when the -F template
included tokens that don't apply to the calling list-* subcommand or
don't exist in the running tmux's format_table. The empirical crashers
were 11 client_* tokens queried during list-windows (no client context)
and several post-3.2a tokens that contributed cumulative risk.

what:
- src/libtmux/neo.py:
  - Add SCOPES_BY_LIST_CMD dict mapping each list-* to the set of token
    scopes its format engine can resolve (e.g. list-windows reaches
    universal + session + window; list-clients reaches universal +
    session + client).
  - Add FIELD_VERSION dict (initially empty) mapping field name → min
    tmux version; fields absent from the dict default to the project's
    floor (3.2a).
  - Add _SCOPE_PREFIXES table and _token_scope() helper that derive a
    token's scope from its name prefix (pane_*, window_*, session_*,
    client_*, buffer_*, etc.). Runtime-only tokens (mouse_*, cursor_*,
    selection_*, copy_cursor_*, popup_*) resolve to "event" and are
    excluded from all list-* templates.
  - Add _UNIVERSAL_TOKENS frozenset for cross-scope tokens without a
    scope prefix (pid, version, host, host_short, socket_path, etc.).
  - Add _normalize_tmux_version() helper that treats tmux master as a
    sentinel "newer than any tagged release" for comparison.
- Rewrite get_output_format() to take (list_cmd, tmux_version) and
  filter the field set accordingly. Cached via @functools.cache on the
  small number of (list_cmd, version) combinations a process sees.
- Rewrite parse_output() to take the same args so it reads the same
  filtered field order.
- Thread the live tmux version through fetch_objs() via
  get_version(server.tmux_bin) before calling get_output_format(),
  pass through to parse_output() per line.
- Doctests on the helpers and on get_output_format / parse_output
  demonstrate the new contracts.

No Obj field changes in this commit. The 27 fields rolled back during
the prior CI bisect remain absent — they re-enter in follow-up commits
that exercise the new scope/version gating.
tony added a commit that referenced this pull request May 17, 2026
…3.2a

why: with the scope+version gating in place, the format string sent to
older tmux versions automatically excludes tokens that those versions
don't recognize. The 8 tokens below first registered in tmux 3.4-3.6 —
tagging them with FIELD_VERSION makes them appear on supported tmux
releases that include them, and absent on older tmux without sending
unknown tokens that bloat the format string or trigger crashes.

what:
- src/libtmux/neo.py:
  - Re-add 8 fields to Obj alphabetically: pane_key_mode,
    pane_unseen_changes, session_active, session_activity_flag,
    session_alert, session_bell_flag, session_silence_flag, client_theme.
  - Populate FIELD_VERSION with each token's minimum tmux release
    (3.4 for pane_unseen_changes, 3.5 for pane_key_mode, 3.6 for the
    five session_* tokens and client_theme).
- tests/test_pane.py: restore pane_key_mode and pane_unseen_changes in
  PANE_FORMAT_FIELDS (the parametrized declaration+hydration test).
- tests/test_session.py: restore the 5 new session_* entries in
  SESSION_FORMAT_FIELDS.

Verification:
- On tmux 3.6a (local), all 8 tokens hydrate via refresh(); 1182 tests
  pass.
- On tmux 3.2a, FIELD_VERSION skips all 8 — the -F template stays at
  its pre-PR-#672-rollback shape for that version.

Version anchors verified via:
  rg '"<token>"' https://github.com/tmux/tmux/blob/<TAG>/format.c
across 3.2a, 3.3a, 3.4, 3.5, 3.5a, 3.6, 3.6a.
@tony tony force-pushed the parity-pt-2 branch 4 times, most recently from 3b8f6e2 to 0312572 Compare May 17, 2026 13:19
tony added a commit that referenced this pull request May 17, 2026
why: Every 0.57.0 deliverable in CHANGES (and the MIGRATION header)
was tagged `(#670)`. Verified upstream: PR #670 does not exist on
tmux-python/libtmux. The actual branch PR is #672 ("Increase tmux
coverage: Client, typed fields, C-side filter"). The `(#670)` refs
appear to be from an earlier draft of the branch that never opened.

what:
- `sed 's/(#670)/(#672)/g'` across CHANGES and MIGRATION. Verified
  by `gh pr view` that #672 is open on the upstream and matches the
  branch's scope; #670 was a 404. The two pre-existing `(#672)`
  refs under `### Fixes` are unchanged.
tony added a commit that referenced this pull request May 17, 2026
why: Every 0.57.0 deliverable in CHANGES (and the MIGRATION header)
was tagged `(#670)`. Verified upstream: PR #670 does not exist on
tmux-python/libtmux. The actual branch PR is #672 ("Increase tmux
coverage: Client, typed fields, C-side filter"). The `(#670)` refs
appear to be from an earlier draft of the branch that never opened.

what:
- `sed 's/(#670)/(#672)/g'` across CHANGES and MIGRATION. Verified
  by `gh pr view` that #672 is open on the upstream and matches the
  branch's scope; #670 was a 404. The two pre-existing `(#672)`
  refs under `### Fixes` are unchanged.
tony added a commit that referenced this pull request May 17, 2026
why: Every 0.57.0 deliverable in CHANGES (and the MIGRATION header)
was tagged `(#670)`. Verified upstream: PR #670 does not exist on
tmux-python/libtmux. The actual branch PR is #672 ("Increase tmux
coverage: Client, typed fields, C-side filter"). The `(#670)` refs
appear to be from an earlier draft of the branch that never opened.

what:
- `sed 's/(#670)/(#672)/g'` across CHANGES and MIGRATION. Verified
  by `gh pr view` that #672 is open on the upstream and matches the
  branch's scope; #670 was a 404. The two pre-existing `(#672)`
  refs under `### Fixes` are unchanged.
@tony
Copy link
Copy Markdown
Member Author

tony commented May 17, 2026

Code review

Found 1 issue:

  1. Multiple user-facing surfaces describe branch-internal "instead of raising" history for symbols that never shipped publicly (CLAUDE.md says: "Before adding... 'previously' / 'formerly' / 'no longer X' phrasing, 'removed' / 'moved' / 'refactored' / 'fixed' diff paraphrases, or ### Fixes entries to a user-facing surface, ask: Did users of the most recently published release ever experience this old name, old behavior, or bug? If the answer is no, it is branch-internal narrative. Move it to the commit message and describe only the current state in the artifact.")

    libtmux 0.56.0 (the most recently published release) did not ship Server.clients, Server.search_sessions, Server.display_message, or Window.display_message. Pane.display_message shipped, but did not raise on stderr. The LibTmuxException-raising state these artifacts contrast against was entirely branch-internal.

    • CHANGES Breaking changes block — "Previously, a tmux command failure under Server.sessions, Server.clients, or Server.search_sessions was swallowed..." conflates Server.sessions (published, real history) with clients and search_sessions (branch-internal):

      libtmux/CHANGES

      Lines 103 to 113 in 794cd60

      #### `Server.sessions`, `Server.clients`, and `Server.search_sessions` raise on tmux errors (#672)
      Previously, a tmux command failure under
      {attr}`~libtmux.Server.sessions`, {attr}`~libtmux.Server.clients`, or
      {meth}`~libtmux.Server.search_sessions` was swallowed by a bare
      ``except Exception: pass``, returning an empty
      {class}`~libtmux._internal.query_list.QueryList` indistinguishable
      from "no sessions / no clients attached" or "filter matched nothing".
      The wrappers now let {exc}`~libtmux.exc.LibTmuxException` propagate
      via {func}`~libtmux.common.raise_if_stderr`.
    • CHANGES Fixes bullet — "surface tmux stderr via warnings.warn instead of silently returning []" describes a return-shape that no published display_message ever had:

      libtmux/CHANGES

      Lines 249 to 258 in 794cd60

      the history clear silently no-op'd, leaving the scrollback intact
      (#650).
      - {meth}`~libtmux.Server.display_message`,
      {meth}`~libtmux.Window.display_message`, and
      {meth}`~libtmux.Pane.display_message` surface tmux stderr via
      :func:`warnings.warn` instead of silently returning ``[]``. tmux uses
      stderr for both genuine errors and informational messages on some
      versions, so the wrappers warn rather than raise; callers that want
      to escalate can wrap the call in :func:`warnings.catch_warnings` with
      ``filterwarnings("error")`` (#672).
    • Server.display_message .. versionchanged:: 0.57 — Reports stderr via warnings.warn instead of raising — method didn't exist in 0.56:

      libtmux/src/libtmux/server.py

      Lines 1516 to 1520 in 794cd60

      .. versionchanged:: 0.57
      Reports stderr via :func:`warnings.warn` instead of raising.
      Parameters
    • Window.display_message — same, didn't exist in 0.56:
      .. versionchanged:: 0.57
      Reports stderr via :func:`warnings.warn` instead of raising.
      """
      tmux_args: tuple[str, ...] = ()
    • Pane.display_message — existed in 0.56 but didn't raise:

      libtmux/src/libtmux/pane.py

      Lines 788 to 792 in 794cd60

      .. versionchanged:: 0.57
      Reports stderr via :func:`warnings.warn` instead of raising.
      """
      tmux_args: tuple[str, ...] = ()
    • MIGRATION — "now report tmux stderr via warnings.warn rather than raising LibTmuxException":

      libtmux/MIGRATION

      Lines 210 to 215 in 794cd60

      ### `Server.display_message` / `Window.display_message` / `Pane.display_message` warn instead of raise
      The three ``display_message`` wrappers now report tmux stderr via
      :func:`warnings.warn` rather than raising
      {exc}`~libtmux.exc.LibTmuxException`. tmux uses stderr for both genuine
      errors and informational messages, and the right escalation depends on

    Rule body: https://github.com/tmux-python/libtmux/blob/794cd60edca8511ddaafc1d06687345c3d528627/CLAUDE.md#L584-L594

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added a commit that referenced this pull request May 17, 2026
why: Every 0.57.0 deliverable in CHANGES (and the MIGRATION header)
was tagged `(#670)`. Verified upstream: PR #670 does not exist on
tmux-python/libtmux. The actual branch PR is #672 ("Increase tmux
coverage: Client, typed fields, C-side filter"). The `(#670)` refs
appear to be from an earlier draft of the branch that never opened.

what:
- `sed 's/(#670)/(#672)/g'` across CHANGES and MIGRATION. Verified
  by `gh pr view` that #672 is open on the upstream and matches the
  branch's scope; #670 was a 404. The two pre-existing `(#672)`
  refs under `### Fixes` are unchanged.
@tony
Copy link
Copy Markdown
Member Author

tony commented May 17, 2026

Code review

Found 1 issue:

  1. Broken Sphinx cross-reference — {doc} MIGRATION (uppercase) won't resolve. The Sphinx-rendered page is docs/migration.md (anchor (migration)=); the existing reference at the bottom of CHANGES uses lowercase {doc} migration. The docs build emits WARNING: unknown document: 'MIGRATION' [ref.doc] for this line.

    libtmux/CHANGES

    Lines 180 to 182 in 068ce01

    call in :func:`warnings.catch_warnings` with
    ``filterwarnings("error")``. See {doc}`MIGRATION` for the escalation
    pattern.

    Reference page anchor:

    (migration)=
    ```{currentmodule} libtmux

    Existing lowercase usage in the same file (for comparison):

    libtmux/CHANGES

    Lines 651 to 653 in 068ce01

    attribute and {class}`~libtmux.common.QueryList` interface documented in
    {doc}`migration`.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added 27 commits May 17, 2026 21:29
Carry the wrapper-to-tmux-support pattern from the earlier 0.56-section copy-improvements commit into the 0.57.x
section.
why: In 0.57 the typed wrappers migrated to raise_if_stderr, which
attaches a LibTmuxException.subcommand attribute and prefixes
str(exc) with "<subcommand>: ". The release entry framed this purely
as additive — there was no breaking-change subheading for upgraders
who pattern-match on str(exc) exactly or anchor a regex with ^.

The wrapped stderr is still in exc.args[0]; the subcommand name is
exposed as a typed attribute. Substring containment and unanchored
regex matches keep working.

what:
- CHANGES: new ### Breaking changes subsection under 0.57 with three
  migration paths (exc.subcommand, exc.args[0], substring match).
- MIGRATION: new ## libtmux 0.57.0 section covering the same
  contract from the upgrader's perspective, with before/after code
  for each migration path.
why: client.session_id / window_id / pane_id are hydrated from
tmux's downward format cascade at the moment the Client dataclass
is built and go stale as soon as the client switches view. The
existing class-level docstring warning isn't enough on its own —
users iterating over server.clients still reach for the raw fields
and treat them as identity.

what:
- Add Client.attached_session / .attached_window / .attached_pane.
  Each property re-reads list-clients before resolving and returns
  the live typed Session / Window / Pane (or None), mirroring the
  Session.active_window fresh-lookup convention.
- Tighten the Client class-level warning to point at the new
  properties as the safe accessors.
- Tests: typed resolution, fresh window tracking (selects a new
  active window post-hydration and asserts the property reflects
  it — proves the property re-queries rather than returning the
  snapshot), pane resolution, None propagation when session_id is
  absent.
- CHANGES: extend the Client what's-new entry to mention the
  attached_* accessors.
- MIGRATION: 0.57 section gains a "snapshots, not identity"
  subheading covering the snapshot vs. live access pattern.
why: The live attachment helpers promise None when a stored client no longer resolves through tmux list-clients.
what:
- Translate missing client refreshes to None for attached_session
- Cover real control-mode detach behavior for attached_* properties
why: The Client documentation should distinguish attached_* convenience behavior from explicit refresh lookups.
what:
- Clarify None behavior for missing live client rows
- Preserve refresh/from_client_name missing-object semantics
…e for 0.57.0 surface

why: The autodoc layer documents the new public symbols, but Client (view vs. identity), scope+version-gated typed fields, and native filter predicates each introduce a mental model that needs a topic-page home.
what:
- Add docs/topics/clients.md covering the Client view-vs-identity distinction, attached_session/window/pane live lookup, and None-on-detach semantics
- Add docs/topics/format-tokens.md explaining the two gates (scope and tmux version), the downward format_defaults cascade, the per-release compatibility table, and how to introspect via get_output_format
- Expand docs/topics/filtering.md with a (native-filtering)= section covering Python-side vs native trade-offs, predicate shapes, the silent-zero-match diagnostic recipe, and when to prefer which
- Add Client to docs/topics/architecture.md hierarchy diagram, table, core objects, and module map
- Add cross-links from docs/topics/traversal.md to native-filtering and from docs/topics/pane_interaction.md to capture_pane(pending=True) and send_keys(cmd=None)
- Wire the two new pages into docs/topics/index.md grid and toctree
- Rename autodoc anchor (clients)= to (api-clients)= in docs/api/libtmux.client.md so the conceptual page owns the readable {ref}\`clients\`
… docs

why: The 0.57.0 breaking-change docs in CHANGES and MIGRATION
promise str(exc) renders as "<subcommand>: <stderr>" and that
exc.args[0] holds the raw stderr string. raise_if_stderr passed
proc.stderr (a list[str]) directly to LibTmuxException, so
Exception.__str__ rendered the list's repr — yielding
"last-window: ['no last window']" instead of the documented
"last-window: no last window". The documented migration code
`exc.args[0] == "can't find window"` was always False because
exc.args[0] was the list, not the string.

what:
- Pass "\n".join(proc.stderr) to LibTmuxException so the message
  is a string. Multi-line tmux stderr renders as a multi-line
  string, matching how Python typically surfaces subprocess
  errors.
- Add test_raise_if_stderr_str_shape_exact that asserts the FULL
  str(exc) and exc.args shapes (no startswith, no substring) for
  a wrapper flowing through raise_if_stderr, so future drift
  surfaces as a test failure rather than a docs lie.
why: The two-call form raced under busy pane writers: send-keys -R
clears the visible grid (verified at ~/study/c/tmux/cmd-send-keys.c:225
→ input.c:923 → screen-write.c:335) and any output landing between
the two subprocess.Popen invocations could scroll into history via
scroll-on-clear (tmux's default), then be wiped by the second call's
clear-history. That destroyed output the caller produced after the
reset point — `reset()` should wipe state at reset time, not whatever
happens to be in the grid when clear-history runs.

A naïve one-call form `self.cmd("send-keys", "-R", ";",
"clear-history")` doesn't work either: Pane.cmd auto-injects
`-t <pane_id>` only before the first subcommand, so the `;` separator
leaves clear-history routed to tmux's cmdq default pane — empirically
verified on tmux 3.6a to clear the wrong pane.

what:
- Route through self.server.cmd to bypass Pane.cmd's auto-target, and
  pass `-t self.pane_id` explicitly on both subcommands so the `;`
  separator can't misroute clear-history.
- Update the docstring to describe the single-IPC semantics and why
  the explicit double-targeting is necessary.
- Add test_pane_reset_targets_non_active_pane that calls reset() on a
  non-active pane and asserts history_size goes to 0 on the target
  while the active sibling pane's history_size is preserved. Under
  the misroute bug, clear-history would have hit the active sibling
  instead of the target.
…rror

why: `Client` inherits `client_name: str | None` from `Obj`. The
`assert isinstance(self.client_name, str)` line vanishes under
`python -O`, letting `None` flow into `_refresh` and surfacing as
a less-clear downstream error. Keep the failure loud regardless of
optimization level.

what:
- Replace the assertion with an explicit `if self.client_name is
  None: raise ValueError(...)`, with the message documented in the
  Raises section of the docstring.
- Add test_client_refresh_raises_when_client_name_is_none asserting
  the explicit raise.
why: The fall-through `return "universal"` was fail-open — a future
`Obj` field added without a matching prefix, override, or known-token
entry would silently classify as universal and ship under every
`list-*` -F template. That defeats the scope-gating machinery on
exactly the case it's meant to protect: a future token whose class
hasn't been mapped, where emitting on tmux 3.2a may crash the
format engine or hydrate as nonsense.

Pre-flight confirmed: every currently-declared `Obj` field maps to
a known scope, so the flip is a no-op for the runtime template but
turns future drift into a deterministic test failure.

what:
- Change the final return in `_token_scope` from `"universal"` to
  `"unknown"`. `"unknown"` is absent from every SCOPES_BY_LIST_CMD
  entry, so an unclassified field is excluded from every list-cmd
  template.
- Document the fail-closed default in the docstring and show what
  it returns for an unrecognized name.
- Add test_token_scope_unknown_for_unclassified_field asserting the
  default and that `"unknown"` isn't in any allowed scope set.
- Add test_every_obj_field_classifies_to_known_scope as a guard:
  any new field added to Obj without classification breaks this
  test with a message pointing to the right table to update.
…s the triple

why: Code that needs all three of session/window/pane for a client
("where is this client attached *now*") naturally reads
client.attached_session, then client.attached_window, then
client.attached_pane. Each property re-reads tmux on access, so the
sequence costs three list-clients refreshes for one conceptual
read. The new helper shares a single refresh across the triple and
returns the three values together.

The helper catches NoActiveWindow and falls back to
(session, None, None). MultipleActiveWindows propagates — that
indicates a tmux invariant violation that callers should surface,
not absent attachment.

what:
- Add internal Client._resolve_attached returning
  tuple[Session | None, Window | None, Pane | None], with documented
  contract for the (None, None, None), (session, None, None), and
  full-triple cases.
- Update the class docstring to point readers at the helper for
  all-three access.
- Add three regression tests: live attachment → full triple,
  detach → (None, None, None), and a monkeypatch-driven
  NoActiveWindow → (session, None, None).
- attached_session / attached_window / attached_pane stay unchanged
  so per-access live semantics are preserved.
why: The 0.57.0 entry covered the LibTmuxException subcommand prefix
+ subcommand attribute under both `### Breaking changes` and
`### What's new` -> `#### Subcommand-tagged exceptions`. The
breaking-changes subsection already documents the behavior, migration
path, and rationale; the duplicate `####` heading restates the same
content without adding new information. Per CLAUDE.md's "deliverable
test," each `####` heading should be a distinct deliverable in user
vocabulary — this failed it.

what:
- Remove the `#### Subcommand-tagged exceptions (#670)` block from
  `### What's new`. The breaking-changes section at CHANGES:59-101
  is unchanged and remains the canonical reference for this
  deliverable.
why: MIGRATION's "Upcoming Release" header sat above an already-drafted
0.57.0 section without comment-bracket delimiters, so future-release
content didn't have an unambiguous insertion point. CHANGES uses
`<!-- KEEP THIS PLACEHOLDER -->` / `<!-- END PLACEHOLDER -->` to mark
where new entries land; MIGRATION should match for the same reason.

what:
- Wrap the placeholder body in matching HTML comment brackets,
  mirroring the CHANGES convention.
- New release content for the upcoming version lands below the END
  marker.
why: The doctest sent a key sequence then immediately read the pane's
scrollback to assert the marker landed. Without `retry_until`, this
trusted that the shell echoed before capture — a coin-flip under
parallel-test load. The dedicated functional test in
tests/test_pane.py::test_pane_reset_clears_history_and_sends_reset
already exercises the same path with retry_until; the doctest's
responsibility is to demonstrate the API, not to re-test timing.

what:
- Drop the send_keys + immediate capture_pane lines from the doctest.
- Keep the call + return-value check, which is timing-independent.
why: The filtering topic doc already covers when to pick `search_*()`
(native push-down) over `QueryList.filter()` (Python-side, post-fetch)
with a comparison table and "When to prefer which" guidance, but the
six `search_*` API entry points don't reference it. A caller landing
on `Server.search_panes` from autodoc has no path to discover the
comparison or the unfiltered `panes` attribute.

what:
- Add a See Also section to each `search_*` method (Server x3,
  Session x2, Window x1). Each block cross-links to (a) the matching
  unfiltered `panes` / `windows` / `sessions` attribute and (b) the
  `native-filtering` ref label in docs/topics/filtering.md.
…ic helper

why: The typed wrappers (`Server.search_*`, `Session.search_*`,
`Window.search_panes`, `Server.list_buffers`) all carry a warning
that tmux silently expands a malformed `-f` predicate to empty —
indistinguishable from "no matches". `fetch_objs` is the
documented public surface those wrappers route through, but its
docstring didn't carry the same caveat. A caller using
`fetch_objs(filter=...)` directly missed the warning.

what:
- Copy the malformed-filter warning into the `fetch_objs(filter=)`
  parameter docstring with the same wording as the typed wrappers.
- Cross-link to the `native-filtering` topic doc for the broader
  context.
why: Every 0.57.0 deliverable in CHANGES (and the MIGRATION header)
was tagged `(#670)`. Verified upstream: PR #670 does not exist on
tmux-python/libtmux. The actual branch PR is #672 ("Increase tmux
coverage: Client, typed fields, native filter"). The `(#670)` refs
appear to be from an earlier draft of the branch that never opened.

what:
- `sed 's/(#670)/(#672)/g'` across CHANGES and MIGRATION. Verified
  by `gh pr view` that #672 is open on the upstream and matches the
  branch's scope; #670 was a 404. The two pre-existing `(#672)`
  refs under `### Fixes` are unchanged.
…stead of raising

why: tmux's stderr from display-message conflates genuine argument-parser
errors (e.g. -F-with-positional rejection) with operational quirks like
3.2a's control-mode dispatch path silently failing without emitting stderr
at all. Raising LibTmuxException on every stderr forced an in-branch
workaround — pytest.skip patches gated on has_gte_version("3.3") — that
masked the underlying mismatch instead of solving it. Switch to
warnings.warn so callers see the stderr without losing the return value,
and the eventual raise/per-call-opt-in contract can land in a follow-up
shipment that exercises real tmux versions end-to-end.

what:
- src/libtmux/server.py, src/libtmux/window.py, src/libtmux/pane.py:
  replace raise_if_stderr(proc, "display-message") with
  warnings.warn("display-message: …", stacklevel=2). Wrapper return
  value unchanged on success and on warn paths.
- All three display_message docstrings gain a Notes block describing
  the warn-not-raise contract and showing the
  warnings.catch_warnings/filterwarnings("error") escalation pattern.
- tests/test_pane.py, tests/test_window.py, tests/test_server.py:
  rename test_*_display_message_raises_on_tmux_error to
  test_*_display_message_warns_on_tmux_error and switch to
  pytest.warns(UserWarning, match=…). Drop the 3.2a control-mode skip
  added by the prior commit on test_server_display_message_no_text_returns_none —
  with warn-not-raise the 3.2a control-mode stderr no longer fails the
  test (the test only asserts result is None on get_text=False).
- CHANGES: rewrite the display_message Fixes entry to describe the
  warn contract and how to escalate.
- MIGRATION: add a new section under 0.57.0 documenting the warn
  contract and the warnings.catch_warnings escalation pattern.
- MIGRATION: add a section noting that Pane.reset now dispatches via
  self.server.cmd; mocks targeting pane.cmd no longer intercept reset.
- docs/topics/pane_interaction.md: tighten the capture_pane(pending=True)
  wording to describe tmux's parser pending buffer rather than "slow
  consumer / paused program" (the latter framing implies a PTY/app
  buffering issue that pending= doesn't address).
- docs/topics/filtering.md: note that there is no search_clients();
  filter via Server.clients and Python-side QueryList.filter.
why: Server.new_session() called get_output_format() with no args, which
defaults to ("list-panes", "3.2a") and gates out every typed Obj field
whose FIELD_VERSION entry exceeds 3.2a. On tmux 3.3+ the returned Session
silently missed pane_dead_signal and pane_dead_time, and any 3.4+ tokens
that join FIELD_VERSION in a follow-up shipment would have the same
gratuitous gap. Match the pattern used by fetch_objs: thread the live
tmux version through, and use list-sessions as the scope (the format
context for tmux's new-session -P -F is the freshly created session).

what:
- src/libtmux/server.py:
  - Import get_version from libtmux.common.
  - new_session() now derives tmux_version via get_version(tmux_bin=…)
    and passes ("list-sessions", tmux_version) to both get_output_format
    (template build) and parse_output (output parse). Pair must be
    identical or the field order goes out of sync.

Verified: a new_session() on tmux 3.3+ now hydrates pane_dead_signal and
pane_dead_time on the returned Session, where master returned None
unconditionally.
why: With Server.clients and Server.search_sessions now propagating
tmux errors via raise_if_stderr instead of swallowing them via
try/except: pass, leaving Server.sessions on the legacy silent-empty
path is internally inconsistent. A real list-sessions failure
(subprocess crash, malformed output) should surface the same way for
all three accessors — and code that does ``if not server.sessions:``
should be able to distinguish "no sessions" from "tmux crashed."

what:
- src/libtmux/server.py: drop the try/except wrapper around fetch_objs
  in Server.sessions; let errors propagate via the raise_if_stderr
  already inside fetch_objs. Match the list-comprehension shape used
  by Server.clients and Server.windows on this branch.
- src/libtmux/server.py: add a Raises clause to the Server.sessions
  docstring documenting the new contract.
- tests/test_server.py: add test_server_sessions_propagates_errors
  mirroring the existing clients/search_sessions tests.
- CHANGES: fold Server.sessions into the breaking-changes entry so all
  three accessors are advertised together.
…ssion

why: Client.refresh() was already converted to survive
python -O (which strips assert statements). Pane.refresh(),
Window.refresh(), and Session.refresh() carry the same pattern and
are vulnerable to the same -O strip: under optimization the assert
goes away and None flows into _refresh, surfacing as a less-clear
downstream error.

what:
- src/libtmux/pane.py: replace assert isinstance(self.pane_id, str)
  with explicit if self.pane_id is None: raise ValueError(msg). Add
  Raises clause to the docstring.
- src/libtmux/window.py: same for self.window_id.
- src/libtmux/session.py: same for self.session_id.
- tests/test_pane.py, tests/test_window.py, tests/test_session.py:
  add per-class test_<class>_refresh_raises_when_<id>_is_none
  mirroring test_client_refresh_raises_when_client_name_is_none.

The Pane.window and Window.session property navigation asserts are
unchanged — they're in relation-navigation paths, not refresh(),
and out of scope for this targeted -O-safety fix.
why: The recent error-propagation work on Server.{sessions, clients,
search_sessions} raised LibTmuxException on every non-empty tmux
stderr — including tmux's "no server running on <socket>" and
"error connecting to <socket> (No such file or directory)" signals,
which are bootstrap states (socket exists but no daemon / socket
file missing), not error conditions. That regressed the historic
contract that a fresh Server can be safely introspected via
.sessions, .windows, .panes, .clients, .search_sessions before the
tmux daemon is up — surfaced by test_no_server_sessions,
test_no_server_attached_sessions, and the context_managers.md
doctest after the trim.

Real tmux errors (subprocess crash, malformed output,
version-incompatible flags) still propagate.

what:
- src/libtmux/server.py: add module-level _fetch_or_empty helper that
  wraps fetch_objs and returns [] when the LibTmuxException's stderr
  contains either of two daemon-not-up markers ("no server running",
  "error connecting to"). Other LibTmuxException paths re-raise.
- src/libtmux/server.py: route all five Server collection accessors
  (sessions, windows, panes, clients, search_sessions, plus
  search_windows / search_panes for symmetry) through
  _fetch_or_empty.
- src/libtmux/server.py: update Server.sessions Raises docstring to
  note that no-daemon stays empty.
- CHANGES: extend the breaking-changes entry to explicitly call out
  the no-daemon-stays-empty carve-out.
…ANGES

why: "C-side" leaks tmux's implementation language (C) into prose
about an architectural distinction — filter evaluated in tmux's
own format engine vs. re-filtered in Python. "Native" reads
cleanly as "tmux's own", parallels "Python-side", and avoids the
language conflation.

what:
- Rename the MyST anchor (c-side-filtering)= to (native-filtering)=
  in docs/topics/filtering.md; update the 9 {ref} cites across
  server.py, session.py, window.py, neo.py, clients.md,
  traversal.md.
- Replace "tmux's C-side <noun>" with "tmux's native <noun>" in 7
  docstring locations + 1 test docstring.
- Rename the filtering.md section heading "C-Side Filtering" to
  "Native Filtering" and the comparison subsection "Python-side
  vs. C-side" to "Python-side vs. native".
- Rename the CHANGES section heading and lead-paragraph mention to
  match.

Net zero line count (24 inserted, 24 deleted) — pure prose rename.
No functional or type changes; pytest, ruff, ty, and the docs
build are unchanged from pre-rename.
why: PR #672 should describe shipped user behavior without
branch-internal jargon, and real tmux connection failures must
not be hidden as empty listings.

Behavior:
- Treat only missing/not-yet-started tmux sockets as empty
  listing results. Real connection failures (permission denied,
  ECONNRESET on a live socket, malformed daemon response)
  now propagate as LibTmuxException instead of being swallowed
  as an empty QueryList.
- Regression coverage for the missing-socket versus
  permission-denied listing paths.

Prose:
- Replace "C-side filter" with "tmux-native filtering" in
  release notes, MIGRATION, topic docs, and API docstrings.
- Replace "format-token hydration" with "format-token fields"
  in the same locations.
why: Tighten the internal implementation and documentation for daemon-not-up states.
what:
- src/libtmux/server.py: In-line missing-socket marker and simplify _is_daemon_not_up_error logic.
- src/libtmux/server.py: Compress _fetch_or_empty and _is_daemon_not_up_error docstrings for clarity.
why: "hydration" is an internal mental model — users see the
result, not the query mechanic.

what: Replace 'hydration query' with 'initialization query' in
the 0.53.1 entry.
@tony tony merged commit ae892c0 into master May 18, 2026
13 checks passed
@tony tony deleted the parity-pt-2 branch May 18, 2026 02:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp(pane_tools): clear_pane does not reliably clear visible content

1 participant