diff --git a/CHANGES b/CHANGES index 393d3e2..19b0e19 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,10 @@ _Notes on upcoming releases will be added here_ +### Dependencies + +**Minimum `libtmux>=0.56.0`** (was `>=0.55.1`). Unlocks the new tmux-command wrappers shipped in libtmux 0.56.0 — {meth}`~libtmux.Pane.respawn`, {meth}`~libtmux.Pane.copy_mode`, {meth}`~libtmux.Pane.pipe`, {meth}`~libtmux.Pane.swap`, {meth}`~libtmux.Pane.paste_buffer`, {meth}`~libtmux.Pane.clear_history`, {meth}`~libtmux.Pane.display_message`, {meth}`~libtmux.Server.delete_buffer`, and the {meth}`~libtmux.Session.next_window` / {meth}`~libtmux.Session.previous_window` / {meth}`~libtmux.Session.last_window` trio — so the MCP no longer falls back to raw `cmd()` calls for tmux commands the upstream API now covers. (#46) + ## libtmux-mcp 0.1.0a6 (2026-05-09) libtmux-mcp 0.1.0a6 is the activation and registration cleanup release. It makes the server much easier for MCP clients to discover from ordinary "pane", "window", and "session" prompts, standardizes new setup docs around the `tmux` registration slug, and adds migration guidance for existing `libtmux` registrations. Existing installs keep working; the release changes defaults and documentation so new installs line up with the tool prefix users actually see. diff --git a/pyproject.toml b/pyproject.toml index 15bd280..e1ab068 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ include = [ ] dependencies = [ - "libtmux>=0.55.1,<1.0", + "libtmux>=0.56.0,<1.0", "fastmcp>=3.2.4,<4.0.0", ] @@ -118,6 +118,7 @@ build-backend = "hatchling.build" # from any `exclude-newer` constraint (global or per-user) without # committing a date that would age into the lockfile. Mirrors the # pattern at vcspull/pyproject.toml for libvcs. +libtmux = false gp-libs = false gp-furo-theme = false gp-sphinx = false diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index bff7c8a..8bf3d2f 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -261,7 +261,7 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: if not name.startswith(_MCP_BUFFER_PREFIX): continue try: - server.cmd("delete-buffer", "-b", name) + server.delete_buffer(buffer_name=name) except Exception as err: logger.debug("buffer GC: delete-buffer %s failed: %s", name, err) diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index 28f9f41..e702c7f 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -268,11 +268,7 @@ def paste_buffer( session_id=session_id, window_id=window_id, ) - paste_args: list[str] = ["-b", cname] - if bracket: - paste_args.append("-p") - paste_args.extend(["-t", pane.pane_id or ""]) - pane.cmd("paste-buffer", *paste_args) + pane.paste_buffer(buffer_name=cname, bracket=bracket) return f"Buffer {cname!r} pasted to pane {pane.pane_id}" diff --git a/src/libtmux_mcp/tools/pane_tools/copy_mode.py b/src/libtmux_mcp/tools/pane_tools/copy_mode.py index ed776f6..6c9627a 100644 --- a/src/libtmux_mcp/tools/pane_tools/copy_mode.py +++ b/src/libtmux_mcp/tools/pane_tools/copy_mode.py @@ -55,16 +55,13 @@ def enter_copy_mode( session_id=session_id, window_id=window_id, ) - # libtmux's Pane.cmd injects ``-t pane.pane_id``; passing it again - # produced the duplicated ``-t %X -t %X`` shape tmux silently accepted. - pane.cmd("copy-mode") + pane.copy_mode() if scroll_up is not None and scroll_up > 0: - pane.cmd( - "send-keys", - "-X", - "-N", - str(scroll_up), - "scroll-up", + pane.send_keys( + "", + copy_mode_cmd="scroll-up", + repeat=scroll_up, + enter=False, ) pane.refresh() return _serialize_pane(pane) @@ -109,7 +106,6 @@ def exit_copy_mode( session_id=session_id, window_id=window_id, ) - # See enter_copy_mode: Pane.cmd injects ``-t pane.pane_id`` already. - pane.cmd("send-keys", "-X", "cancel") + pane.send_keys("", copy_mode_cmd="cancel", enter=False) pane.refresh() return _serialize_pane(pane) diff --git a/src/libtmux_mcp/tools/pane_tools/io.py b/src/libtmux_mcp/tools/pane_tools/io.py index 27e2cce..15870aa 100644 --- a/src/libtmux_mcp/tools/pane_tools/io.py +++ b/src/libtmux_mcp/tools/pane_tools/io.py @@ -233,12 +233,15 @@ def clear_pane( session_id=session_id, window_id=window_id, ) - # Split into two cmd() calls — pane.reset() in libtmux <= 0.55.0 sends - # `send-keys -R \; clear-history` as one call, but subprocess doesn't - # interpret \; as a tmux command separator so clear-history never runs. + # Two separate calls — pane.reset() in libtmux 0.56.0 still sends + # `send-keys -R \; clear-history` as one call but subprocess doesn't + # interpret \; as a tmux command separator, so clear-history never + # runs. The bare `-R` send is left as a raw cmd() because + # Pane.send_keys requires a cmd string and would emit an extra + # empty key alongside the reset flag. # See: https://github.com/tmux-python/libtmux/issues/650 pane.cmd("send-keys", "-R") - pane.cmd("clear-history") + pane.clear_history() return f"Pane cleared: {pane.pane_id}" @@ -325,13 +328,10 @@ def paste_text( msg = f"load-buffer failed: {stderr or e}" raise ToolError(msg) from e - # Paste from the named buffer. -d deletes only that named buffer, - # leaving any unnamed user buffer intact. - paste_args = ["-b", buffer_name, "-d"] - if bracket: - paste_args.append("-p") # bracketed paste mode - paste_args.extend(["-t", pane.pane_id or ""]) - pane.cmd("paste-buffer", *paste_args) + # Paste from the named buffer. ``delete_after=True`` (``-d``) + # deletes only that named buffer, leaving any unnamed user + # buffer intact. + pane.paste_buffer(buffer_name=buffer_name, bracket=bracket, delete_after=True) finally: if tmppath is not None: pathlib.Path(tmppath).unlink(missing_ok=True) @@ -339,6 +339,6 @@ def paste_text( # deletes it), but if paste-buffer failed before -d took effect # we leak an entry in the tmux server. Best-effort delete. with contextlib.suppress(Exception): - server.cmd("delete-buffer", "-b", buffer_name) + server.delete_buffer(buffer_name=buffer_name) return f"Text pasted to pane {pane.pane_id}" diff --git a/src/libtmux_mcp/tools/pane_tools/layout.py b/src/libtmux_mcp/tools/pane_tools/layout.py index 40a689e..be4c74b 100644 --- a/src/libtmux_mcp/tools/pane_tools/layout.py +++ b/src/libtmux_mcp/tools/pane_tools/layout.py @@ -71,6 +71,9 @@ def resize_pane( ) if zoom is not None: window = pane.window + # ``window_zoomed_flag`` isn't declared on the libtmux Window + # dataclass in 0.56.0, and Window has no ``display_message`` + # wrapper — keep the raw cmd() until upstream surfaces one. result = window.cmd("display-message", "-p", "#{window_zoomed_flag}") is_zoomed = bool(result.stdout) and result.stdout[0] == "1" if zoom and not is_zoomed: @@ -171,7 +174,7 @@ def select_pane( idx = panes.index(active) step = 1 if direction == "next" else -1 target_pane = panes[(idx + step) % len(panes)] - server.cmd("select-pane", target=target_pane.pane_id) + target_pane.select() # Query the active pane ID directly from tmux to avoid stale cache target = window.window_id or "" @@ -216,8 +219,8 @@ def swap_pane( server = _get_server(socket_name=socket_name) # Validate both panes exist source = _resolve_pane(server, pane_id=source_pane_id) - _resolve_pane(server, pane_id=target_pane_id) + target = _resolve_pane(server, pane_id=target_pane_id) - server.cmd("swap-pane", "-s", source_pane_id, "-t", target_pane_id) + source.swap(target=target) source.refresh() return _serialize_pane(source) diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index ee82a16..4eb2e0a 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -150,30 +150,12 @@ def respawn_pane( "Use a manual tmux command if intended." ) raise ToolError(msg) - # Stopgap: ``libtmux>=0.55.1`` has no ``Pane.respawn()`` yet — the - # wrapper exists on the upstream ``tmux-parity`` branch (see - # ``libtmux/pane.py:respawn``) and mirrors this argv shape: ``-k``, - # ``-c