tmux-python / tmux-python/libtmux
Wrapper gaps surfaced by libtmux-mcp adoption of 0.56.0 (14 sites)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.2k
- Forks
- 127
- Avg merge
- 2h 13m
- Merged PRs (30d)
- 1
Description
Summary
Adopting libtmux 0.56.0's new wrappers across tmux-python/libtmux-mcp (15 raw cmd() call sites → typed wrappers) surfaced 9 spots where the wrapper either doesn't exist or doesn't quite fit a tmux invocation that the underlying command supports. A follow-up sweep against tmux 3.6a's full cmd_entry flag schemas and format_table[] registry surfaced 4 more gap categories (gaps #10–#13) plus an 8-token forward-looking set from tmux master post-3.6a (gap #14). tmux source citations below reference local copies at ~/study/c/tmux-3.6a/ (matching the upstream tmux/tmux@3.6a tag — the latest tmux release as of 2026-05-12) and link to the same files on GitHub at tmux/tmux/tree/3.6a.
Each gap is independently fixable; they cluster into five shapes (see end of issue).
1. Server.display_message doesn't exist
Site: Only Pane.display_message is wrapped (pane.py:625-774). Server has show_messages (message log) but no format-string evaluator.
Why tmux supports it: cmd-display-message.c:38-50 (local: ~/study/c/tmux-3.6a/cmd-display-message.c:38-50) — the entry's target is CMD_FIND_PANE with CMD_FIND_CANFAIL, so omitting -t falls back to the current/default pane and the format engine still resolves server-scoped variables like #{version} and #{socket_path}.
Where it bites callers: libtmux-mcp's get_server_info and _effective_socket_path need #{version} / #{socket_path} reads with no specific pane handle; they have to drop down to server.cmd("display-message", "-p", "#{…}"). Three sites in the MCP carry this workaround.
Proposed: add Server.display_message(format_string, *, get_text=True, target_client=…) returning list[str]. Same shape as Pane.display_message but no -t injection.
2. Window.display_message doesn't exist
Site: No wrapper at the Window level either.
Why tmux supports it: Same CMD_FIND_PANE | CMD_FIND_CANFAIL target as gap #1 (cmd-display-message.c:46); callers commonly pass -t @<window> for window-scoped reads like #{window_zoomed_flag}.
Where it bites callers: libtmux-mcp's resize_pane(zoom=…) needs the zoom state before deciding to toggle; with no Window.display_message, it must call window.cmd("display-message", "-p", "#{window_zoomed_flag}") directly.
Proposed: add Window.display_message (same signature as Pane.display_message), or generalize the Pane method to accept arbitrary targets.
3. Window.window_zoomed_flag is not a typed attribute on the dataclass
Site: window.py:53 — the Window dataclass declares many window_* fields but not window_zoomed_flag. mypy rejects window.window_zoomed_flag even after window.refresh().
Why tmux supports it: the variable is a first-class format token with a dedicated callback at format.c:2854-2864 (local: ~/study/c/tmux-3.6a/format.c:2854), formally registered in the format table at format.c:3557.
Where it bites callers: the umbrella issue libtmux-mcp#46 suggested swapping a display-message call for the ORM read — turned out it doesn't type-check, so the MCP reverted to cmd() with a comment.
Proposed: add window_zoomed_flag: str | None (and other common window_* format vars) as declared fields populated via refresh(). See gap #10 for the full undeclared token set across all object scopes.
4. Pane.reset() is still broken in 0.56.0 (open as #650)
Site: pane.py:2150-2153:
def reset(self) -> Pane:
self.cmd("send-keys", r"-R \; clear-history")
return self
The \; is tmux's interactive command separator. subprocess.run argv doesn't expand it — tmux sees a single argv "-R \; clear-history" and interprets the trailing \; clear-history as literal keys to send, never running clear-history. Pre-existing tracking issue: #650.
Why tmux supports both individually: send-keys -R is handled at cmd-send-keys.c:217-220 (local: ~/study/c/tmux-3.6a/cmd-send-keys.c:217); clear-history is its own entry in cmd-capture-pane.c.
Where it bites callers: libtmux-mcp's clear_pane (see tools/pane_tools/io.py) keeps a two-call workaround. Comment in the MCP cites this issue.
Proposed: split into two cmd() calls inside Pane.reset (the same workaround the MCP carries), or use tmux's \; separator via shell=True.
5. Pane.send_keys has no flag-only invocation
Site: pane.py:499-622. The wrapper always appends prefix + cmd (or copy_mode_cmd) as a trailing argv:
else:
self.cmd("send-keys", *tmux_args, prefix + cmd)
There's no path that emits just tmux send-keys -R (reset flag, no key argument).
Why tmux supports it: cmd-send-keys.c:223-227 (local: ~/study/c/tmux-3.6a/cmd-send-keys.c:223) — when count == 0 and either -N or -R is set, the command returns CMD_RETURN_NORMAL without sending any keys. tmux's authors explicitly handle the flag-only case.
Where it bites callers: libtmux-mcp's clear_pane needs tmux send-keys -R (no keys). pane.send_keys("", reset=True, enter=False) produces tmux send-keys -R "" — likely a no-op in practice, but the trailing empty arg isn't what the underlying tmux command expects for a flag-only invocation. The MCP keeps pane.cmd("send-keys", "-R") for that reason.
Proposed: make the cmd parameter Optional[str] with default None; when None and a flag like reset or repeat is set, emit tmux send-keys <flags> with no trailing arg. Backward-compatible because cmd is currently positional-required.
6. Server.list_buffers() returns default-formatted lines, not raw names
Site: server.py:1643-1663 — calls tmux list-buffers with no -F, so the output is tmux's default template (buf_name: N bytes: …). No format= kwarg on the wrapper.
Why tmux supports it: cmd-list-buffers.c:35-44 (local: ~/study/c/tmux-3.6a/cmd-list-buffers.c:35) — entry usage is [-F format] [-f filter]; the -F template is read at line 56 with a builtin fallback, the -f filter at line 58.
Where it bites callers: libtmux-mcp's buffer GC needs just the names matching libtmux_mcp_* prefix. The wrapper returns lines like libtmux_mcp_abc: 5 bytes: "…"; parsing requires regex. The MCP keeps server.cmd("list-buffers", "-F", "#{buffer_name}") instead.
Proposed: add format: str | None = None and filter: str | None = None kwargs to Server.list_buffers. When format is set, pass -F <fmt>; otherwise keep the default tmux output for backward compat.
7. server.panes / QueryList doesn't expose tmux's -f C-filter
Site: [Server.panes at server.py:2060-2076](https://github.com/tmux-python/libtmux/blob/v0.56.0/src/libtmux/server.py#L2060) returns a QueryList[Pane] that filters in Python. Callers who want the **C-level filter** with format-expression predicates have no wrapper path; [fetch_objsin neo.py](https://github.com/tmux-python/libtmux/blob/v0.56.0/src/libtmux/neo.py#L248) always emits libtmux's own-F{format_string}` template.
Why tmux supports it: cmd-list-panes.c:37-42 (local: ~/study/c/tmux-3.6a/cmd-list-panes.c:37) — entry usage is [-as] [-F format] [-f filter]; the filter is read at line 125 and evaluated by format_true(expanded) at line 134, gating which panes appear in the output.
Where it bites callers: libtmux-mcp's search_panes fast path uses tmux list-panes -a -f '#{C/i:pattern}' -F '#{pane_id}' — pushes a regex match into tmux's C code, returning only matching pane IDs without capturing every pane in Python. With QueryList, the only option is "capture all panes in Python and filter post-hoc" — orders of magnitude slower for the common case. The MCP keeps server.cmd("list-panes", ...) for this site.
Proposed: add a filter: str | None = None kwarg to Server.panes (and the analogous Session.panes, Window.panes) that round-trips a tmux format expression through -f. Alternatively, a Server.list_panes(filter=…, format=…) low-level method.
8. LibTmuxException translation loses subcommand context
Site: wrappers like Session.last_window raise exc.LibTmuxException(proc.stderr) (session.py:298-318). The exception (exc.py:18-19 — a bare Exception subclass with no payload fields) carries the raw stderr list but not the subcommand name that produced it.
Why this matters: downstream wrappers (libtmux-mcp's handle_tool_errors translates LibTmuxException → ToolError(f"tmux error: {e}")) and any agent-facing error string lose the "which tmux command failed" context. Pre-0.56.0, the MCP built f"tmux {subcommand} failed: {stderr}" manually; the wrapper version surfaces only "tmux error: ['no last window']". One MCP test had to be updated to match the new shape.
Proposed: extend LibTmuxException (and friends) with an optional subcommand: str | None attribute. Format __str__ as f"{subcommand}: {stderr_text}" when present. Wrappers populate it implicitly (e.g., a _run_cmd("last-window", *args) helper that catches and re-raises with the subcommand).
9. Pane.cmd always injects -t <pane_id>, no warning on duplicate
Site: Pane.cmd at pane.py:178-212](https://github.com/tmux-python/libtmux/blob/v0.56.0/src/libtmux/pane.py#L178) always sets target = self.pane_idbefore delegating toServer.cmd. [Server.cmd at server.py:326 then emits -t <target> at the front of cmd_args regardless of what the caller passes in *args. If the caller also adds -t %X, tmux sees tmux <subcmd> -t %1 -t %1 and silently accepts the duplicate.
Why tmux tolerates it: tmux's args.c args_get() returns the last value of a repeated flag — so -t %1 -t %1 is functionally -t %1. Safe but masks caller bugs.
Where it bites callers: libtmux-mcp had two sites doing this (buffer_tools.py:275 and copy_mode.py:60-68) before the 0.56.0 wrappers were adopted; both were quietly accepted by tmux despite being incorrect Python. Discovered only because the new wrappers (which inject -t correctly) made the duplicate impossible to keep.
Proposed: when Pane.cmd (and Session.cmd, Window.cmd, Server.cmd) detect a -t already in *args, either (a) raise a clear TypeError, or (b) emit warnings.warn("Pane.cmd auto-injects -t; redundant -t in args") once per call site. Option (a) is safer; option (b) is gentler if the existing wild ecosystem relies on the no-op.
10. ~37 first-class format tokens aren't typed on Pane/Window/Session dataclasses
Site: neo.py:139-159 declares a hand-curated allowlist of format tokens as str | None = None fields on Obj. Sweeping tmux's format_table[] array at format.c:3010-3563 against neo.py's declared fields shows 37 scope-relevant tokens that ship in tmux 3.6a but are not declared. Gap #3 is one instance; the full set:
Pane (13) — registered in format.c between line 3254 and 3341:
| Token | format.c line |
|---|---|
pane_dead |
3254 |
pane_format |
3269 |
pane_in_mode |
3278 |
pane_input_off |
3284 |
pane_key_mode |
3287 |
pane_last |
3290 |
pane_marked |
3296 |
pane_marked_set |
3299 |
pane_mode |
3302 |
pane_path |
3305 |
pane_pipe |
3311 |
pane_synchronized |
3326 |
pane_unseen_changes |
3341 |
Window (13) — registered in format.c between line 3464 and 3557:
| Token | format.c line |
|---|---|
window_active_clients_list |
3464 |
window_active_sessions_list |
3470 |
window_activity_flag |
3476 |
window_bell_flag |
3479 |
window_bigger |
3482 |
window_end_flag |
3491 |
window_flags |
3494 |
window_format |
3497 |
window_last_flag |
3509 |
window_silence_flag |
3542 |
window_start_flag |
3548 |
window_visible_layout |
3551 |
window_zoomed_flag |
3557 — already in gap #3 |
Session (11) — registered in format.c between line 3359 and 3428:
| Token | format.c line |
|---|---|
session_active |
3359 |
session_activity_flag |
3365 |
session_alert |
3368 |
session_bell_flag |
3380 |
session_format |
3386 |
session_group_attached_list |
3395 |
session_group_many_attached |
3401 |
session_grouped |
3407 |
session_many_attached |
3416 |
session_marked |
3419 |
session_silence_flag |
3428 |
Why tmux supports them: every entry above appears verbatim in the format_table[] array — they're registered first-class format tokens with FORMAT_TABLE_STRING callbacks. tmux's format_create_defaults path populates them whenever the relevant scope is bound (pane→format_defaults_pane, window→format_defaults_winlink, session→format_defaults_session).
Where it bites callers: every undeclared token forces typed-Python callers back to pane.cmd("display-message", "-p", "#{…}"). libtmux-mcp's resize_pane(zoom=…) (window_zoomed_flag), copy-mode detection (pane_in_mode, pane_mode), and pane-death checks (pane_dead) all carry these workarounds.
Proposed: declare the full scope-relevant set on Obj (or split into per-scope mixins). Better: generate the field declarations from format.c parsing at build time so future tmux releases don't drift the typed surface — that closes gap #14 (HEAD tokens) for free.
11. No Client class — client_* format tokens have nowhere to land
Site: Server.list_clients at server.py:1392 returns raw output lines. No Client dataclass exists in src/libtmux/. Compare with Session/Window/Pane, which each have an Obj-backed dataclass and a QueryList-returning collection accessor.
Why tmux supports it: twelve client_* format tokens are registered in format.c:
| Token | format.c line |
|---|---|
client_activity |
3041 |
client_control_mode |
3050 |
client_created |
3053 |
client_last_session |
3068 |
client_mode_format |
3071 |
client_prefix |
3080 |
client_readonly |
3083 |
client_session |
3086 |
client_termfeatures |
3089 |
client_termtype |
3095 |
client_theme |
3098 |
client_utf8 |
3110 |
The matching tmux subcommand is cmd-list-clients.c, which accepts [-F format] [-t target-session] for arbitrary projection.
Where it bites callers: anything inspecting attached clients (multi-client coordination, "is this client read-only?", per-client status drawing) must call server.cmd("list-clients", "-F", "#{…}") and hand-parse output. The typed-ORM ergonomics that exist for sessions/windows/panes simply aren't available for clients.
Proposed: add src/libtmux/client.py with a Client(Obj) dataclass declaring all twelve client_* fields, plus Server.clients -> QueryList[Client] mirroring Server.sessions. Reuse fetch_objs with list_cmd="list-clients".
12. Server.run_shell drops -c (cwd) and -E (show_stderr) flags
Site: server.py:427-486 exposes background (-b), delay (-d), as_tmux_command (-C), and target_pane (-t). The full flag schema in tmux is "bd:Ct:Es:c:".
Why tmux supports them: cmd-run-shell.c:47 declares the args; cmd-run-shell.c:156-159 reads -c to override the working directory (cdata->cwd = xstrdup(args_get(args, 'c'))), and cmd-run-shell.c:161-162 reads -E to enable JOB_SHOWSTDERR — the latter is the difference between "stdout only" and "stdout + stderr merged into the captured output."
Where it bites callers: any caller that needs the shell command to run in a specific directory (independent of any target pane's cwd) or wants to surface stderr in the result must drop to server.cmd("run-shell", …). libtmux-mcp's diagnostic shell-outs (e.g., probing helper binaries) fall into this bucket.
Proposed: add cwd: StrPath | None = None and show_stderr: bool | None = None kwargs, emitting -c <cwd> and -E respectively.
13. Pane.capture_pane drops -P (pending input) flag
Site: pane.py:354-498 exposes 12 of the 13 flags from tmux's "ab:CeE:JMNpPqS:Tt:" schema. -P is the missing one.
Why tmux supports it: cmd-capture-pane.c:42 declares the args; cmd-capture-pane.c:231-232 branches: when -P is set, cmd_capture_pane_pending(args, wp, &len) returns bytes tmux has buffered as input for the pane but the program hasn't consumed yet — distinct from -p (lowercase, "print to stdout") and from the default history capture.
Where it bites callers: diagnostic tooling that needs to inspect "what input is queued but unprocessed?" (debugging hung programs, copy-mode race conditions, paste-buffer drains) has no typed access; callers must do pane.cmd("capture-pane", "-P", "-p").
Proposed: add pending: bool = False kwarg. When set, emit -P (and keep -p for stdout return). Document that this returns pending input bytes, not history.
14. Forward-looking: 8 new format tokens in tmux master post-3.6a
Site: None of these are declared on libtmux dataclasses (same shape as gap #10), and they don't yet exist in tmux 3.6a — they were added between the 3.6a tag and tmux master. Tracking them now means libtmux is ready when the next tmux tag lands.
Why tmux is adding them: present in tmux master (tmux/tmux/blob/master/format.c), absent from the 3.6a tag. The full diff:
| Token | Scope | Purpose |
|---|---|---|
pane_zoomed_flag |
pane | Pane-level zoom indicator (companion to window_zoomed_flag) |
pane_floating_flag |
pane | Pane is a floating/popup pane |
pane_flags |
pane | Combined pane flag string (analogous to window_flags) |
pane_pb_state |
pane | Paste-buffer transfer state |
pane_pb_progress |
pane | Paste-buffer transfer progress |
pane_pipe_pid |
pane | PID of the pipe-pane child process |
synchronized_output_flag |
terminal | DEC mode 2026 synchronized-output state |
bracket_paste_flag |
terminal | Bracketed-paste mode active |
Where it bites callers: none yet — these only become reachable when callers upgrade to the next tmux release. But the Obj allowlist will need to be re-walked when that happens. Treating gap #10's fix as "auto-generated from tmux's format.c" makes gap #14 free.
Proposed: roll into the same generator described under gap #10 — when tmux ships these in a tagged release, regenerate; no manual list to maintain.
Suggested groupings for fix PRs
These cluster into five shapes — each cluster is a coherent PR:
Cluster A — display_message parity (gaps #1, #2)
Add Server.display_message and Window.display_message with the same signature as Pane.display_message. Single-commit PR; mirrors the symmetry the rest of the 0.56.0 release set up.
Cluster B — typed format-token coverage (gaps #3, #10, #14)
Expand the declared field set on Pane/Window/Session to cover all scope-relevant tokens from format.c's format_table[]. Prefer a build-time generator that reads format.c so the typed surface tracks tmux automatically; that closes gap #14 (HEAD tokens) for free when the next tmux release tag lands.
Cluster C — flag-only / format-aware wrappers (gaps #5, #6, #7, #12, #13)
Make cmd optional on Pane.send_keys to enable flag-only invocations; add format= / filter= kwargs to Server.list_buffers and Server.panes (et al.) to expose tmux's C-level filtering; add cwd= / show_stderr= to Server.run_shell; add pending= to Pane.capture_pane. These all expose existing tmux capability that the wrapper currently hides.
Cluster D — new Client object hierarchy (gap #11)
Add Client(Obj) dataclass with the twelve client_* fields and Server.clients -> QueryList[Client]. Mirrors the Session/Window/Pane shape and brings list-clients into the typed API.
Cluster E — diagnostics and safety (gaps #4, #8, #9)
Fix Pane.reset per #650; thread subcommand context through LibTmuxException; add duplicate--t detection on the cmd methods. Quality-of-life cluster; each item is small but compounds across the wider ecosystem.
Evidence / Motivation
The MCP-side adoption PR is at tmux-python/libtmux-mcp#48. Gaps #1–#9 are documented inline in commit messages or code comments there — pointers in the PR description and the umbrella issue libtmux-mcp#46. Gaps #10–#14 surfaced from a follow-up sweep of tmux/tmux@3.6a's cmd_entry flag strings and format_table[] cross-referenced against src/libtmux/neo.py field declarations and src/libtmux/*.py wrapper signatures.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading the gap sections alongside the referenced entry points in pane.py, server.py, window.py, session.py, exc.py, and neo.py. Compare each proposed wrapper with the cited tmux command usage, then inspect existing tests before selecting one independently fixable gap. Done means the selected invocation is exposed without breaking existing behavior and its workaround can be removed or avoided.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, cli, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100