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 ``, repeated ``-e=`` (single-arg form, NOT - # split ``-e KEY=VAL`` — tmux's args parser accepts both but - # upstream emits the joined form), then optional trailing shell. - # When the release line picks it up, swap ``pane.cmd("respawn-pane", - # *argv)`` for ``pane.respawn(kill=kill, start_directory= - # start_directory, environment=environment, shell=shell)`` and drop - # the stderr branch — ``Pane.respawn`` raises ``LibTmuxException``. - argv: list[str] = [] - if kill: - argv.append("-k") - if start_directory is not None: - argv.extend(["-c", start_directory]) - if environment: - argv.extend(f"-e{k}={v}" for k, v in environment.items()) - if shell is not None: - argv.append(shell) - result = pane.cmd("respawn-pane", *argv) - if result.stderr: - stderr = " ".join(result.stderr).strip() - msg = f"tmux respawn-pane failed: {stderr}" - raise ToolError(msg) + pane.respawn( + kill=kill, + start_directory=start_directory, + environment=environment, + shell=shell, + ) # Pick up fresh pane_pid and any command/path updates; tmux does # not invalidate the underlying object on respawn. pane.refresh() diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index 8bde535..1ef8467 100644 --- a/src/libtmux_mcp/tools/pane_tools/meta.py +++ b/src/libtmux_mcp/tools/pane_tools/meta.py @@ -63,8 +63,8 @@ def display_message( session_id=session_id, window_id=window_id, ) - result = pane.cmd("display-message", "-p", format_string) - return "\n".join(result.stdout) if result.stdout else "" + stdout = pane.display_message(format_string, get_text=True) + return "\n".join(stdout) if stdout else "" @handle_tool_errors @@ -159,8 +159,8 @@ def snapshot_pane( "#{pane_tty}", ] fmt = _SEP.join(_FMT_VARS) - result = pane.cmd("display-message", "-p", fmt) - raw = result.stdout[0] if result.stdout else "" + stdout = pane.display_message(fmt, get_text=True) + raw = stdout[0] if stdout else "" # Pad defensively to guarantee one slot per format var even if tmux # drops an unknown variable on older versions. parts = (raw.split(_SEP) + [""] * len(_FMT_VARS))[: len(_FMT_VARS)] diff --git a/src/libtmux_mcp/tools/pane_tools/pipe.py b/src/libtmux_mcp/tools/pane_tools/pipe.py index 6f43a77..d443ac7 100644 --- a/src/libtmux_mcp/tools/pane_tools/pipe.py +++ b/src/libtmux_mcp/tools/pane_tools/pipe.py @@ -75,7 +75,7 @@ def pipe_pane( ) if output_path is None: - pane.cmd("pipe-pane") + pane.pipe() return f"Piping stopped for pane {pane.pane_id}" if not output_path.strip(): @@ -83,5 +83,5 @@ def pipe_pane( raise ToolError(msg) redirect = ">>" if append else ">" - pane.cmd("pipe-pane", f"cat {redirect} {shlex.quote(output_path)}") + pane.pipe(f"cat {redirect} {shlex.quote(output_path)}") return f"Piping pane {pane.pane_id} to {output_path}" diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index b2398e5..ca841ab 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -305,28 +305,22 @@ def select_window( window.select() return _serialize_window(window) - # Directional navigation: use the dedicated tmux subcommands so that - # libtmux's Session.cmd injects `-t $session_id` and the navigation - # stays scoped to this session (a bare `-t +` resolves against the - # attached client, not the target session). + # Directional navigation. Each Session method injects `-t + # $session_id`, returns the new active Window, and raises + # LibTmuxException on stderr — so the dispatch reduces to a + # straight lookup with no manual stderr handling. session = _resolve_session(server, session_name=session_name, session_id=session_id) - _CMD_MAP = { - "next": "next-window", - "previous": "previous-window", - "last": "last-window", + _NAV = { + "next": session.next_window, + "previous": session.previous_window, + "last": session.last_window, } assert direction is not None - subcommand = _CMD_MAP.get(direction) - if subcommand is None: + fn = _NAV.get(direction) + if fn is None: msg = f"Invalid direction: {direction!r}. Valid: next, previous, last" raise ToolError(msg) - proc = session.cmd(subcommand) - if proc.stderr: - stderr = " ".join(proc.stderr).strip() - msg = f"tmux {subcommand} failed: {stderr}" - raise ToolError(msg) - - active_window = session.active_window + active_window = fn() return _serialize_window(active_window) diff --git a/tests/test_session_tools.py b/tests/test_session_tools.py index 0fcc8af..fe3a284 100644 --- a/tests/test_session_tools.py +++ b/tests/test_session_tools.py @@ -271,15 +271,15 @@ def test_select_window_last_on_single_window_session_raises( ) -> None: """select_window last with no prior window must surface the tmux error. - Regression guard: session.cmd("last-window") on a session that has - never had a previously-active window emits "no last window" on - stderr, but the tool previously discarded the return value and - returned the unchanged active window as if the navigation had - worked. + Regression guard: ``Session.last_window`` on a session that has + never had a previously-active window raises ``LibTmuxException`` + with tmux's "no last window" stderr; the tool previously discarded + the return value and returned the unchanged active window as if + the navigation had worked. """ # The fixture session is freshly created: there is no previously- # active window for last-window to jump back to. - with pytest.raises(ToolError, match="last-window"): + with pytest.raises(ToolError, match="no last window"): select_window( direction="last", session_name=mcp_session.session_name, diff --git a/uv.lock b/uv.lock index 27b2baa..5365be3 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P3D" [options.exclude-newer-package] +libtmux = false gp-sphinx = false sphinx-autodoc-sphinx = false sphinx-autodoc-api-style = false @@ -1187,7 +1188,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=3.2.4,<4.0.0" }, - { name = "libtmux", specifier = ">=0.55.1,<1.0" }, + { name = "libtmux", specifier = ">=0.56.0,<1.0" }, ] [package.metadata.requires-dev]