From 1a1e65981bd92e0ca4243b5ee79e591421ad5d47 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 17:24:42 -0500 Subject: [PATCH 1/2] feat(tools): Add 11 new MCP tools for agent workflows New pane tools: snapshot_pane (rich capture with cursor/mode/scroll metadata), wait_for_content_change (detect any screen change), select_pane (directional navigation), swap_pane, pipe_pane, display_message (tmux format string queries), enter_copy_mode, exit_copy_mode, paste_text (bracketed paste via tmux buffers). New session tool: select_window (navigate by ID/index/direction). New window tool: move_window (reorder or cross-session moves). Models: PaneSnapshot, ContentChangeResult. Tests: 22 new tests covering all new tools. --- src/libtmux_mcp/models.py | 40 ++ src/libtmux_mcp/tools/pane_tools.py | 615 +++++++++++++++++++++++- src/libtmux_mcp/tools/session_tools.py | 72 +++ src/libtmux_mcp/tools/window_tools.py | 54 +++ tests/docs/_ext/test_fastmcp_autodoc.py | 4 +- tests/test_pane_tools.py | 305 +++++++++++- tests/test_session_tools.py | 52 ++ tests/test_window_tools.py | 37 ++ 8 files changed, 1175 insertions(+), 4 deletions(-) diff --git a/src/libtmux_mcp/models.py b/src/libtmux_mcp/models.py index 563f911..ebde388 100644 --- a/src/libtmux_mcp/models.py +++ b/src/libtmux_mcp/models.py @@ -139,3 +139,43 @@ class WaitForTextResult(BaseModel): pane_id: str = Field(description="Pane ID that was polled") elapsed_seconds: float = Field(description="Time spent waiting in seconds") timed_out: bool = Field(description="Whether the timeout was reached") + + +class PaneSnapshot(BaseModel): + """Rich screen capture with metadata: content, cursor, mode, and scroll state.""" + + pane_id: str = Field(description="Pane ID (e.g. '%1')") + content: str = Field(description="Visible pane text") + cursor_x: int = Field(description="Cursor column (0-based)") + cursor_y: int = Field(description="Cursor row (0-based)") + pane_width: int = Field(description="Pane width in columns") + pane_height: int = Field(description="Pane height in rows") + pane_in_mode: bool = Field(description="True if pane is in copy-mode or view-mode") + pane_mode: str | None = Field( + default=None, description="Mode name (e.g. 'copy-mode') or None if normal" + ) + scroll_position: int | None = Field( + default=None, + description="Lines scrolled back in copy mode (None if not in copy mode)", + ) + history_size: int = Field(description="Total scrollback lines available") + title: str | None = Field(default=None, description="Pane title") + pane_current_command: str | None = Field( + default=None, description="Running command" + ) + pane_current_path: str | None = Field( + default=None, description="Current working directory" + ) + is_caller: bool | None = Field( + default=None, + description="True if this is the MCP caller's own pane", + ) + + +class ContentChangeResult(BaseModel): + """Result of waiting for any screen content change.""" + + changed: bool = Field(description="Whether the content changed before timeout") + pane_id: str = Field(description="Pane ID that was polled") + elapsed_seconds: float = Field(description="Time spent waiting in seconds") + timed_out: bool = Field(description="Whether the timeout was reached") diff --git a/src/libtmux_mcp/tools/pane_tools.py b/src/libtmux_mcp/tools/pane_tools.py index 7fb3cc7..77869c4 100644 --- a/src/libtmux_mcp/tools/pane_tools.py +++ b/src/libtmux_mcp/tools/pane_tools.py @@ -17,10 +17,17 @@ _get_server, _resolve_pane, _resolve_session, + _resolve_window, _serialize_pane, handle_tool_errors, ) -from libtmux_mcp.models import PaneContentMatch, PaneInfo, WaitForTextResult +from libtmux_mcp.models import ( + ContentChangeResult, + PaneContentMatch, + PaneInfo, + PaneSnapshot, + WaitForTextResult, +) if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -617,6 +624,579 @@ def _check() -> bool: ) +@handle_tool_errors +def snapshot_pane( + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> PaneSnapshot: + """Take a rich snapshot of a tmux pane: content + cursor + mode + scroll state. + + Returns everything capture_pane and get_pane_info return, plus cursor + position, copy-mode state, and scroll position — in a single call. + Use this instead of separate capture_pane + get_pane_info calls when + you need to reason about cursor location or pane mode. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneSnapshot + Rich snapshot with content, cursor, mode, and scroll state. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + # Fetch all metadata in a single display-message call using tab separators + fmt = "\t".join( + [ + "#{cursor_x}", + "#{cursor_y}", + "#{pane_width}", + "#{pane_height}", + "#{pane_in_mode}", + "#{pane_mode}", + "#{scroll_position}", + "#{history_size}", + "#{pane_title}", + "#{pane_current_command}", + "#{pane_current_path}", + ] + ) + result = pane.cmd("display-message", "-p", "-t", pane.pane_id, fmt) + parts = result.stdout[0].split("\t") if result.stdout else [""] * 11 + + content = "\n".join(pane.capture_pane()) + + pane_in_mode = parts[4] == "1" + pane_mode_raw = parts[5] + scroll_raw = parts[6] + + caller_pane_id = _get_caller_pane_id() + return PaneSnapshot( + pane_id=pane.pane_id or "", + content=content, + cursor_x=int(parts[0]) if parts[0] else 0, + cursor_y=int(parts[1]) if parts[1] else 0, + pane_width=int(parts[2]) if parts[2] else 0, + pane_height=int(parts[3]) if parts[3] else 0, + pane_in_mode=pane_in_mode, + pane_mode=pane_mode_raw if pane_mode_raw else None, + scroll_position=int(scroll_raw) if scroll_raw else None, + history_size=int(parts[7]) if parts[7] else 0, + title=parts[8] if parts[8] else None, + pane_current_command=parts[9] if parts[9] else None, + pane_current_path=parts[10] if parts[10] else None, + is_caller=(pane.pane_id == caller_pane_id if caller_pane_id else None), + ) + + +@handle_tool_errors +def wait_for_content_change( + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + timeout: float = 8.0, + interval: float = 0.05, + socket_name: str | None = None, +) -> ContentChangeResult: + """Wait for any content change in a tmux pane. + + Captures the current pane content, then polls until the content differs + or the timeout is reached. Use this after send_keys when you don't know + what the output will be — it waits for "something happened" rather than + a specific pattern. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. + window_id : str, optional + Window ID for pane resolution. + timeout : float + Maximum seconds to wait. Default 8.0. + interval : float + Seconds between polls. Default 0.05 (50ms). + socket_name : str, optional + tmux socket name. + + Returns + ------- + ContentChangeResult + Result with changed status and timing info. + """ + import time + + from libtmux.test.retry import retry_until + + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + assert pane.pane_id is not None + initial_content = pane.capture_pane() + start_time = time.monotonic() + + def _check() -> bool: + current = pane.capture_pane() + return current != initial_content + + changed = retry_until( + _check, + seconds=timeout, + interval=interval, + raises=False, + ) + + elapsed = time.monotonic() - start_time + return ContentChangeResult( + changed=changed, + pane_id=pane.pane_id, + elapsed_seconds=round(elapsed, 3), + timed_out=not changed, + ) + + +@handle_tool_errors +def select_pane( + pane_id: str | None = None, + direction: t.Literal["up", "down", "left", "right", "last", "next", "previous"] + | None = None, + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> PaneInfo: + """Select (focus) a tmux pane by ID or direction. + + Use this to navigate between panes. Provide either pane_id for direct + selection, or direction for relative navigation within a window. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1') for direct selection. + direction : str, optional + Relative direction: 'up', 'down', 'left', 'right', 'last' + (previously active), 'next', or 'previous'. + window_id : str, optional + Window ID for directional navigation scope. + window_index : str, optional + Window index for directional navigation scope. + session_name : str, optional + Session name for resolution. + session_id : str, optional + Session ID for resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + The now-active pane. + """ + from fastmcp.exceptions import ToolError + + if pane_id is None and direction is None: + msg = "Provide either pane_id or direction." + raise ToolError(msg) + + server = _get_server(socket_name=socket_name) + + if pane_id is not None: + pane = _resolve_pane(server, pane_id=pane_id) + pane.select() + return _serialize_pane(pane) + + # Directional navigation + _DIRECTION_FLAGS: dict[str, str] = { + "up": "-U", + "down": "-D", + "left": "-L", + "right": "-R", + "last": "-l", + } + + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + + assert direction is not None + if direction in _DIRECTION_FLAGS: + window.select_pane(_DIRECTION_FLAGS[direction]) + elif direction == "next": + window.cmd("select-pane", "-t", "+1") + elif direction == "previous": + window.cmd("select-pane", "-t", "-1") + + # Query the active pane ID directly from tmux to avoid stale cache + target = window.window_id or "" + result = window.cmd("display-message", "-p", "-t", target, "#{pane_id}") + active_pane_id = result.stdout[0] if result.stdout else None + if active_pane_id: + active_pane = server.panes.get(pane_id=active_pane_id, default=None) + if active_pane is not None: + return _serialize_pane(active_pane) + + # Fallback + active_pane = window.active_pane + assert active_pane is not None + return _serialize_pane(active_pane) + + +@handle_tool_errors +def swap_pane( + source_pane_id: str, + target_pane_id: str, + socket_name: str | None = None, +) -> PaneInfo: + """Swap the positions of two panes. + + Exchanges the visual positions of two panes. Both panes must exist. + Use this to rearrange pane layout without changing content. + + Parameters + ---------- + source_pane_id : str + Pane ID of the first pane (e.g. '%1'). + target_pane_id : str + Pane ID of the second pane (e.g. '%2'). + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + The source pane after swap (now in target's position). + """ + 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) + + server.cmd("swap-pane", "-s", source_pane_id, "-t", target_pane_id) + source.refresh() + return _serialize_pane(source) + + +@handle_tool_errors +def pipe_pane( + pane_id: str | None = None, + output_path: str | None = None, + append: bool = True, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Start or stop piping pane output to a file. + + When output_path is given, starts logging all pane output to the file. + When output_path is None, stops any active pipe for the pane. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + output_path : str, optional + File path to write output to. None stops piping. + append : bool + Whether to append to the file. Default True. If False, overwrites. + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Confirmation message. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + if output_path is None: + pane.cmd("pipe-pane") + return f"Piping stopped for pane {pane.pane_id}" + + redirect = ">>" if append else ">" + pane.cmd("pipe-pane", f"cat {redirect} {output_path}") + return f"Piping pane {pane.pane_id} to {output_path}" + + +@handle_tool_errors +def display_message( + format_string: str, + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Query tmux using a format string. + + Expands tmux format variables against a target pane. Use this as a + generic introspection tool to query any tmux variable, e.g. + '#{window_zoomed_flag}', '#{pane_dead}', '#{client_activity}'. + + Parameters + ---------- + format_string : str + tmux format string (e.g. '#{cursor_x} #{cursor_y}'). + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Expanded format string result. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + result = pane.cmd("display-message", "-p", "-t", pane.pane_id, format_string) + return "\n".join(result.stdout) if result.stdout else "" + + +@handle_tool_errors +def enter_copy_mode( + pane_id: str | None = None, + scroll_up: int | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> PaneInfo: + """Enter copy mode in a tmux pane, optionally scrolling up. + + Use to navigate scrollback history. After entering copy mode, use + snapshot_pane to read the scroll_position and content. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + scroll_up : int, optional + Number of lines to scroll up immediately after entering copy mode. + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + Serialized pane info. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + pane.cmd("copy-mode", "-t", pane.pane_id) + if scroll_up is not None and scroll_up > 0: + pane.cmd( + "send-keys", + "-X", + "-N", + str(scroll_up), + "scroll-up", + ) + pane.refresh() + return _serialize_pane(pane) + + +@handle_tool_errors +def exit_copy_mode( + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> PaneInfo: + """Exit copy mode in a tmux pane. + + Returns the pane to normal mode. Use after scrolling through + scrollback history. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + Serialized pane info. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + pane.cmd("send-keys", "-t", pane.pane_id, "-X", "cancel") + pane.refresh() + return _serialize_pane(pane) + + +@handle_tool_errors +def paste_text( + text: str, + pane_id: str | None = None, + bracket: bool = True, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Paste multi-line text into a pane using tmux paste buffers. + + Uses tmux's load-buffer and paste-buffer for clean multi-line input, + avoiding the issues of sending text line-by-line via send_keys. + Supports bracketed paste mode for terminals that handle it. + + Parameters + ---------- + text : str + The text to paste. + pane_id : str, optional + Pane ID (e.g. '%1'). + bracket : bool + Whether to use bracketed paste mode. Default True. + Bracketed paste wraps the text in escape sequences that tell + the terminal "this is pasted text, not typed input". + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Confirmation message. + """ + import subprocess + import tempfile + + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + # Write text to a temp file and load into tmux buffer + # (libtmux's cmd() doesn't support stdin) + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write(text) + tmppath = f.name + + try: + # Build tmux command args for loading buffer + tmux_bin: str = getattr(server, "tmux_bin", None) or "tmux" + load_args: list[str] = [tmux_bin] + if server.socket_name: + load_args.extend(["-L", server.socket_name]) + if server.socket_path: + load_args.extend(["-S", str(server.socket_path)]) + load_args.extend(["load-buffer", tmppath]) + subprocess.run(load_args, check=True, capture_output=True) + + # Paste from buffer into pane + paste_args = ["-d"] # delete buffer after paste + if bracket: + paste_args.append("-p") # bracketed paste mode + paste_args.extend(["-t", pane.pane_id or ""]) + pane.cmd("paste-buffer", *paste_args) + finally: + from pathlib import Path + + Path(tmppath).unlink() + + return f"Text pasted to pane {pane.pane_id}" + + def register(mcp: FastMCP) -> None: """Register pane-level tools with the MCP instance.""" mcp.tool(title="Send Keys", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( @@ -648,3 +1228,36 @@ def register(mcp: FastMCP) -> None: mcp.tool(title="Wait For Text", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( wait_for_text ) + mcp.tool(title="Snapshot Pane", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + snapshot_pane + ) + mcp.tool( + title="Wait For Content Change", + annotations=ANNOTATIONS_RO, + tags={TAG_READONLY}, + )(wait_for_content_change) + mcp.tool( + title="Select Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(select_pane) + mcp.tool(title="Swap Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING})( + swap_pane + ) + mcp.tool(title="Pipe Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING})( + pipe_pane + ) + mcp.tool(title="Display Message", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + display_message + ) + mcp.tool( + title="Enter Copy Mode", + annotations=ANNOTATIONS_MUTATING, + tags={TAG_MUTATING}, + )(enter_copy_mode) + mcp.tool( + title="Exit Copy Mode", + annotations=ANNOTATIONS_MUTATING, + tags={TAG_MUTATING}, + )(exit_copy_mode) + mcp.tool(title="Paste Text", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( + paste_text + ) diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 1fd1b30..761d800 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -219,6 +219,75 @@ def kill_session( return f"Session killed: {name}" +@handle_tool_errors +def select_window( + window_id: str | None = None, + window_index: str | None = None, + direction: t.Literal["next", "previous", "last"] | None = None, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> WindowInfo: + """Select (focus) a tmux window by ID, index, or direction. + + Use to navigate between windows. Provide window_id or window_index + for direct selection, or direction for relative navigation. + + Parameters + ---------- + window_id : str, optional + Window ID (e.g. '@1') for direct selection. + window_index : str, optional + Window index for direct selection. + direction : str, optional + Relative direction: 'next', 'previous', or 'last'. + session_name : str, optional + Session name for resolution. + session_id : str, optional + Session ID for resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + WindowInfo + The now-active window. + """ + from fastmcp.exceptions import ToolError + + if window_id is None and window_index is None and direction is None: + msg = "Provide window_id, window_index, or direction." + raise ToolError(msg) + + server = _get_server(socket_name=socket_name) + + if window_id is not None or window_index is not None: + from libtmux_mcp._utils import _resolve_window + + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + window.select() + return _serialize_window(window) + + # Directional navigation + session = _resolve_session(server, session_name=session_name, session_id=session_id) + _DIR_MAP = {"next": "+", "previous": "-", "last": "!"} + assert direction is not None + flag = _DIR_MAP.get(direction) + if flag is None: + msg = f"Invalid direction: {direction!r}. Valid: next, previous, last" + raise ToolError(msg) + session.cmd("select-window", "-t", flag) + + active_window = session.active_window + return _serialize_window(active_window) + + def register(mcp: FastMCP) -> None: """Register session-level tools with the MCP instance.""" mcp.tool(title="List Windows", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( @@ -235,3 +304,6 @@ def register(mcp: FastMCP) -> None: annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_DESTRUCTIVE}, )(kill_session) + mcp.tool( + title="Select Window", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(select_window) diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 548a04a..165a382 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -368,6 +368,57 @@ def resize_window( return _serialize_window(window) +@handle_tool_errors +def move_window( + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + destination_index: str = "", + destination_session: str | None = None, + socket_name: str | None = None, +) -> WindowInfo: + """Move a window to a different index or session. + + Reorder windows within a session or move a window to another session. + + Parameters + ---------- + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + session_name : str, optional + Source session name. + session_id : str, optional + Source session ID. + destination_index : str + Target window index. Default empty string (next available). + destination_session : str, optional + Target session name or ID. Default is current session. + socket_name : str, optional + tmux socket name. + + Returns + ------- + WindowInfo + Serialized window after move. + """ + server = _get_server(socket_name=socket_name) + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + window.move_window( + destination=destination_index, + session=destination_session, + ) + return _serialize_window(window) + + def register(mcp: FastMCP) -> None: """Register window-level tools with the MCP instance.""" mcp.tool(title="List Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( @@ -390,3 +441,6 @@ def register(mcp: FastMCP) -> None: mcp.tool( title="Resize Window", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} )(resize_window) + mcp.tool( + title="Move Window", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(move_window) diff --git a/tests/docs/_ext/test_fastmcp_autodoc.py b/tests/docs/_ext/test_fastmcp_autodoc.py index d457572..a2ed9fa 100644 --- a/tests/docs/_ext/test_fastmcp_autodoc.py +++ b/tests/docs/_ext/test_fastmcp_autodoc.py @@ -666,7 +666,7 @@ def test_collect_real_tools() -> None: def test_collect_real_tools_total_count() -> None: - """All 27 tools should be collected.""" + """All 38 tools should be collected.""" collector = fastmcp_autodoc._ToolCollector() import importlib @@ -683,4 +683,4 @@ def test_collect_real_tools_total_count() -> None: mod = importlib.import_module(f"libtmux_mcp.tools.{mod_name}") mod.register(collector) - assert len(collector.tools) == 27 + assert len(collector.tools) == 38 diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 640ce1c..315bc46 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -8,16 +8,30 @@ from fastmcp.exceptions import ToolError from libtmux.test.retry import retry_until -from libtmux_mcp.models import PaneContentMatch, WaitForTextResult +from libtmux_mcp.models import ( + ContentChangeResult, + PaneContentMatch, + PaneSnapshot, + WaitForTextResult, +) from libtmux_mcp.tools.pane_tools import ( capture_pane, clear_pane, + display_message, + enter_copy_mode, + exit_copy_mode, get_pane_info, kill_pane, + paste_text, + pipe_pane, resize_pane, search_panes, + select_pane, send_keys, set_pane_title, + snapshot_pane, + swap_pane, + wait_for_content_change, wait_for_text, ) @@ -507,3 +521,292 @@ def test_wait_for_text_invalid_regex(mcp_server: Server, mcp_pane: Pane) -> None pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name, ) + + +# --------------------------------------------------------------------------- +# snapshot_pane tests +# --------------------------------------------------------------------------- + + +def test_snapshot_pane(mcp_server: Server, mcp_pane: Pane) -> None: + """snapshot_pane returns rich metadata alongside content.""" + result = snapshot_pane( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert isinstance(result, PaneSnapshot) + assert result.pane_id == mcp_pane.pane_id + assert isinstance(result.content, str) + assert result.cursor_x >= 0 + assert result.cursor_y >= 0 + assert result.pane_width > 0 + assert result.pane_height > 0 + assert result.pane_in_mode is False + assert result.pane_mode is None + assert result.history_size >= 0 + + +def test_snapshot_pane_cursor_moves(mcp_server: Server, mcp_pane: Pane) -> None: + """snapshot_pane reflects cursor position changes.""" + mcp_pane.send_keys("echo hello_snapshot", enter=True) + retry_until( + lambda: "hello_snapshot" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + result = snapshot_pane( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert "hello_snapshot" in result.content + assert result.pane_current_command is not None + + +# --------------------------------------------------------------------------- +# wait_for_content_change tests +# --------------------------------------------------------------------------- + + +def test_wait_for_content_change_detects_change( + mcp_server: Server, mcp_pane: Pane +) -> None: + """wait_for_content_change detects screen changes.""" + import threading + + # Send a command after a brief delay to trigger a change + def _send_later() -> None: + import time + + time.sleep(0.2) + mcp_pane.send_keys("echo CHANGE_DETECTED_xyz", enter=True) + + thread = threading.Thread(target=_send_later) + thread.start() + + result = wait_for_content_change( + pane_id=mcp_pane.pane_id, + timeout=3.0, + socket_name=mcp_server.socket_name, + ) + thread.join() + assert isinstance(result, ContentChangeResult) + assert result.changed is True + assert result.timed_out is False + assert result.elapsed_seconds > 0 + + +def test_wait_for_content_change_timeout(mcp_server: Server, mcp_pane: Pane) -> None: + """wait_for_content_change times out when no change occurs.""" + # Wait for the shell prompt to settle before testing for "no change" + import time + + time.sleep(0.5) + + result = wait_for_content_change( + pane_id=mcp_pane.pane_id, + timeout=0.5, + socket_name=mcp_server.socket_name, + ) + assert isinstance(result, ContentChangeResult) + assert result.changed is False + assert result.timed_out is True + + +# --------------------------------------------------------------------------- +# select_pane tests +# --------------------------------------------------------------------------- + + +def test_select_pane_by_id(mcp_server: Server, mcp_session: Session) -> None: + """select_pane focuses a specific pane by ID.""" + window = mcp_session.active_window + pane1 = window.active_pane + assert pane1 is not None + window.split() + + # Select the first pane + result = select_pane( + pane_id=pane1.pane_id, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == pane1.pane_id + + +def test_select_pane_directional(mcp_server: Server, mcp_session: Session) -> None: + """select_pane navigates using direction.""" + window = mcp_session.active_window + pane1 = window.active_pane + assert pane1 is not None + pane2 = window.split() # creates pane below; pane1 stays active + + # pane1 is active, select "down" should go to pane2 + result = select_pane( + direction="down", + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == pane2.pane_id + + +def test_select_pane_requires_target(mcp_server: Server) -> None: + """select_pane raises ToolError when neither pane_id nor direction given.""" + with pytest.raises(ToolError, match="Provide either"): + select_pane(socket_name=mcp_server.socket_name) + + +# --------------------------------------------------------------------------- +# swap_pane tests +# --------------------------------------------------------------------------- + + +def test_swap_pane(mcp_server: Server, mcp_session: Session) -> None: + """swap_pane exchanges two pane positions.""" + window = mcp_session.active_window + pane1 = window.active_pane + assert pane1 is not None + pane2 = window.split() + + assert pane1.pane_id is not None + assert pane2.pane_id is not None + + result = swap_pane( + source_pane_id=pane1.pane_id, + target_pane_id=pane2.pane_id, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == pane1.pane_id + + +# --------------------------------------------------------------------------- +# pipe_pane tests +# --------------------------------------------------------------------------- + + +def test_pipe_pane_start_stop( + mcp_server: Server, mcp_pane: Pane, tmp_path: t.Any +) -> None: + """pipe_pane starts and stops piping output to a file.""" + log_file = str(tmp_path / "pane_output.log") + + # Start piping + result = pipe_pane( + pane_id=mcp_pane.pane_id, + output_path=log_file, + socket_name=mcp_server.socket_name, + ) + assert "piping" in result.lower() + + # Stop piping + result = pipe_pane( + pane_id=mcp_pane.pane_id, + output_path=None, + socket_name=mcp_server.socket_name, + ) + assert "stopped" in result.lower() + + +# --------------------------------------------------------------------------- +# display_message tests +# --------------------------------------------------------------------------- + + +def test_display_message(mcp_server: Server, mcp_pane: Pane) -> None: + """display_message expands tmux format strings.""" + result = display_message( + format_string="#{pane_width}x#{pane_height}", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert "x" in result + parts = result.split("x") + assert len(parts) == 2 + assert parts[0].isdigit() + assert parts[1].isdigit() + + +def test_display_message_zoomed_flag(mcp_server: Server, mcp_session: Session) -> None: + """display_message queries arbitrary tmux variables.""" + window = mcp_session.active_window + pane = window.active_pane + assert pane is not None + result = display_message( + format_string="#{window_zoomed_flag}", + pane_id=pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert result in ("0", "1") + + +# --------------------------------------------------------------------------- +# enter_copy_mode / exit_copy_mode tests +# --------------------------------------------------------------------------- + + +def test_enter_and_exit_copy_mode(mcp_server: Server, mcp_pane: Pane) -> None: + """enter_copy_mode enters copy mode, exit_copy_mode leaves it.""" + enter_result = enter_copy_mode( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert enter_result.pane_id == mcp_pane.pane_id + + # Verify pane is in copy mode via snapshot + snap = snapshot_pane( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert snap.pane_in_mode is True + + exit_result = exit_copy_mode( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert exit_result.pane_id == mcp_pane.pane_id + + +def test_enter_copy_mode_with_scroll(mcp_server: Server, mcp_pane: Pane) -> None: + """enter_copy_mode can scroll up immediately.""" + # Generate some scrollback history + for i in range(20): + mcp_pane.send_keys(f"echo scrollback_line_{i}", enter=True) + retry_until( + lambda: "scrollback_line_19" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + enter_result = enter_copy_mode( + pane_id=mcp_pane.pane_id, + scroll_up=5, + socket_name=mcp_server.socket_name, + ) + assert enter_result.pane_id == mcp_pane.pane_id + + # Clean up: exit copy mode + exit_copy_mode( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + + +# --------------------------------------------------------------------------- +# paste_text tests +# --------------------------------------------------------------------------- + + +def test_paste_text(mcp_server: Server, mcp_pane: Pane) -> None: + """paste_text pastes text into a pane via tmux buffer.""" + result = paste_text( + text="echo PASTE_TEST_marker_xyz", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert "pasted" in result.lower() + + # Verify the text appeared in the pane + retry_until( + lambda: "PASTE_TEST_marker_xyz" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) diff --git a/tests/test_session_tools.py b/tests/test_session_tools.py index e3d6b39..6f8997f 100644 --- a/tests/test_session_tools.py +++ b/tests/test_session_tools.py @@ -12,6 +12,7 @@ kill_session, list_windows, rename_session, + select_window, ) if t.TYPE_CHECKING: @@ -192,6 +193,57 @@ def test_list_windows_with_filters( second_session.kill() +# --------------------------------------------------------------------------- +# select_window tests +# --------------------------------------------------------------------------- + + +def test_select_window_by_id(mcp_server: Server, mcp_session: Session) -> None: + """select_window focuses a window by ID.""" + win1 = mcp_session.active_window + mcp_session.new_window(window_name="select_target") + + result = select_window( + window_id=win1.window_id, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == win1.window_id + + +def test_select_window_by_index(mcp_server: Server, mcp_session: Session) -> None: + """select_window focuses a window by index.""" + win1 = mcp_session.active_window + mcp_session.new_window(window_name="select_idx") + + result = select_window( + window_index=win1.window_index, + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == win1.window_id + + +def test_select_window_direction_next(mcp_server: Server, mcp_session: Session) -> None: + """select_window navigates to next window.""" + win1 = mcp_session.active_window + win2 = mcp_session.new_window(window_name="next_win") + + # Make win1 active + win1.select() + result = select_window( + direction="next", + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == win2.window_id + + +def test_select_window_requires_target(mcp_server: Server) -> None: + """select_window raises ToolError without target or direction.""" + with pytest.raises(ToolError, match="Provide"): + select_window(socket_name=mcp_server.socket_name) + + def test_kill_session_requires_target(mcp_server: Server) -> None: """kill_session refuses to kill without an explicit target.""" with pytest.raises(ToolError, match="Refusing to kill"): diff --git a/tests/test_window_tools.py b/tests/test_window_tools.py index a0ff002..929c40c 100644 --- a/tests/test_window_tools.py +++ b/tests/test_window_tools.py @@ -10,6 +10,7 @@ from libtmux_mcp.tools.window_tools import ( kill_window, list_panes, + move_window, rename_window, resize_window, select_layout, @@ -202,6 +203,42 @@ def test_list_panes_with_filters( assert len(result) >= expected_min_count +# --------------------------------------------------------------------------- +# move_window tests +# --------------------------------------------------------------------------- + + +def test_move_window_reorder(mcp_server: Server, mcp_session: Session) -> None: + """move_window changes a window's index.""" + win = mcp_session.new_window(window_name="move_me") + result = move_window( + window_id=win.window_id, + destination_index="99", + socket_name=mcp_server.socket_name, + ) + assert result.window_id == win.window_id + assert result.window_index == "99" + + +def test_move_window_to_another_session( + mcp_server: Server, mcp_session: Session +) -> None: + """move_window moves a window to a different session.""" + target_session = mcp_server.new_session(session_name="move_target") + win = mcp_session.new_window(window_name="move_cross") + window_id = win.window_id + + result = move_window( + window_id=window_id, + destination_session=target_session.session_id, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == window_id + + # Cleanup + target_session.kill() + + def test_kill_window_requires_window_id(mcp_server: Server) -> None: """kill_window requires window_id as a positional argument.""" with pytest.raises(ToolError, match="missing 1 required positional argument"): From f5de77a737d96fedbc940d0d313c493b0a5a8236 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 17:36:50 -0500 Subject: [PATCH 2/2] docs(tools): Add documentation for all 11 new tools Document snapshot_pane, wait_for_content_change, display_message, select_pane, select_window, swap_pane, move_window, pipe_pane, enter_copy_mode, exit_copy_mode, and paste_text with usage guidance, JSON examples, and parameter tables following existing patterns. Update tools/index.md with new grid cards and expanded "Which tool do I want?" decision guide covering navigation, layout, scrollback, and paste workflows. --- docs/tools/index.md | 86 +++++++++- docs/tools/panes.md | 376 +++++++++++++++++++++++++++++++++++++++++ docs/tools/sessions.md | 42 +++++ docs/tools/windows.md | 42 +++++ 4 files changed, 545 insertions(+), 1 deletion(-) diff --git a/docs/tools/index.md b/docs/tools/index.md index c3de5ba..0a8e29f 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -8,18 +8,36 @@ All tools accept an optional `socket_name` parameter for multi-server support. I **Reading terminal content?** - Know which pane? → {tool}`capture-pane` +- Need text + cursor + mode in one call? → {tool}`snapshot-pane` - Don't know which pane? → {tool}`search-panes` -- Need to wait for output? → {tool}`wait-for-text` +- Need to wait for specific output? → {tool}`wait-for-text` +- Need to wait for *any* change? → {tool}`wait-for-content-change` - Only need metadata (PID, path, size)? → {tool}`get-pane-info` +- Need an arbitrary tmux variable? → {tool}`display-message` **Running a command?** - {tool}`send-keys` — then {tool}`wait-for-text` + {tool}`capture-pane` +- Pasting multi-line text? → {tool}`paste-text` **Creating workspace structure?** - New session → {tool}`create-session` - New window → {tool}`create-window` - New pane → {tool}`split-window` +**Navigating?** +- Switch pane → {tool}`select-pane` (by ID or direction) +- Switch window → {tool}`select-window` (by ID, index, or direction) + +**Rearranging layout?** +- Swap two panes → {tool}`swap-pane` +- Move window → {tool}`move-window` +- Change layout → {tool}`select-layout` + +**Scrollback / copy mode?** +- Enter copy mode → {tool}`enter-copy-mode` +- Exit copy mode → {tool}`exit-copy-mode` +- Log output to file → {tool}`pipe-pane` + **Changing settings?** - tmux options → {tool}`show-option` / {tool}`set-option` - Environment vars → {tool}`show-environment` / {tool}`set-environment` @@ -91,6 +109,24 @@ Query a tmux option value. Show tmux environment variables. ::: +:::{grid-item-card} snapshot_pane +:link: snapshot-pane +:link-type: ref +Rich capture: content + cursor + mode + scroll. +::: + +:::{grid-item-card} wait_for_content_change +:link: wait-for-content-change +:link-type: ref +Wait for any screen change. +::: + +:::{grid-item-card} display_message +:link: display-message +:link-type: ref +Query arbitrary tmux format strings. +::: + :::: ## Act @@ -178,6 +214,54 @@ Set a tmux option. Set a tmux environment variable. ::: +:::{grid-item-card} select_pane +:link: select-pane +:link-type: ref +Focus a pane by ID or direction. +::: + +:::{grid-item-card} select_window +:link: select-window +:link-type: ref +Focus a window by ID, index, or direction. +::: + +:::{grid-item-card} swap_pane +:link: swap-pane +:link-type: ref +Exchange positions of two panes. +::: + +:::{grid-item-card} move_window +:link: move-window +:link-type: ref +Move window to another index or session. +::: + +:::{grid-item-card} pipe_pane +:link: pipe-pane +:link-type: ref +Stream pane output to a file. +::: + +:::{grid-item-card} enter_copy_mode +:link: enter-copy-mode +:link-type: ref +Enter copy mode for scrollback. +::: + +:::{grid-item-card} exit_copy_mode +:link: exit-copy-mode +:link-type: ref +Exit copy mode. +::: + +:::{grid-item-card} paste_text +:link: paste-text +:link-type: ref +Paste multi-line text via tmux buffer. +::: + :::: ## Destroy diff --git a/docs/tools/panes.md b/docs/tools/panes.md index 3a7b8f1..73b3202 100644 --- a/docs/tools/panes.md +++ b/docs/tools/panes.md @@ -183,6 +183,133 @@ Response: ```{fastmcp-tool-input} pane_tools.wait_for_text ``` +--- + +```{fastmcp-tool} pane_tools.snapshot_pane +``` + +**Use when** you need a complete picture of a pane in a single call — visible +text plus cursor position, whether the pane is in copy mode, scroll offset, +and scrollback history size. Replaces separate `capture_pane` + +`get_pane_info` calls when you need to reason about cursor location or +terminal mode. + +**Avoid when** you only need raw text — {tooliconl}`capture-pane` is lighter. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "snapshot_pane", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "content": "$ npm test\n\nPASS src/auth.test.ts\nTests: 3 passed\n$", + "cursor_x": 2, + "cursor_y": 4, + "pane_width": 80, + "pane_height": 24, + "pane_in_mode": false, + "pane_mode": null, + "scroll_position": null, + "history_size": 142, + "title": "", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.snapshot_pane +``` + +--- + +```{fastmcp-tool} pane_tools.wait_for_content_change +``` + +**Use when** you've sent a command and need to wait for *something* to happen, +but you don't know what the output will look like. Unlike +{tooliconl}`wait-for-text`, this waits for *any* screen change rather than a +specific pattern. + +**Avoid when** you know the expected output — {tooliconl}`wait-for-text` is more +precise and avoids false positives from unrelated output. + +**Side effects:** None. Readonly. Blocks until content changes or timeout. + +**Example:** + +```json +{ + "tool": "wait_for_content_change", + "arguments": { + "pane_id": "%0", + "timeout": 10 + } +} +``` + +Response: + +```json +{ + "changed": true, + "pane_id": "%0", + "elapsed_seconds": 1.234, + "timed_out": false +} +``` + +```{fastmcp-tool-input} pane_tools.wait_for_content_change +``` + +--- + +```{fastmcp-tool} pane_tools.display_message +``` + +**Use when** you need to query arbitrary tmux variables — zoom state, pane +dead flag, client activity, or any `#{format}` string that isn't covered by +other tools. + +**Avoid when** a dedicated tool already provides the information — e.g. use +{tooliconl}`snapshot-pane` for cursor position and mode, or +{tooliconl}`get-pane-info` for standard metadata. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "display_message", + "arguments": { + "format_string": "zoomed=#{window_zoomed_flag} dead=#{pane_dead}", + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +zoomed=0 dead=0 +``` + +```{fastmcp-tool-input} pane_tools.display_message +``` + ## Act ```{fastmcp-tool} pane_tools.send_keys @@ -333,6 +460,255 @@ Response: ```{fastmcp-tool-input} pane_tools.resize_pane ``` +--- + +```{fastmcp-tool} pane_tools.select_pane +``` + +**Use when** you need to focus a specific pane — by ID for a known target, +or by direction (`up`, `down`, `left`, `right`, `last`, `next`, `previous`) +to navigate a multi-pane layout. + +**Side effects:** Changes the active pane in the window. + +**Example:** + +```json +{ + "tool": "select_pane", + "arguments": { + "direction": "down", + "window_id": "@0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%1", + "pane_index": "1", + "pane_width": "80", + "pane_height": "11", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12400", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.select_pane +``` + +--- + +```{fastmcp-tool} pane_tools.swap_pane +``` + +**Use when** you want to rearrange pane positions without changing content — +e.g. moving a log pane from bottom to top. + +**Side effects:** Exchanges the visual positions of two panes. + +**Example:** + +```json +{ + "tool": "swap_pane", + "arguments": { + "source_pane_id": "%0", + "target_pane_id": "%1" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "1", + "pane_width": "80", + "pane_height": "11", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.swap_pane +``` + +--- + +```{fastmcp-tool} pane_tools.pipe_pane +``` + +**Use when** you need to log pane output to a file — useful for monitoring +long-running processes or capturing output that scrolls past the visible +area. + +**Avoid when** you only need a one-time capture — use {tooliconl}`capture-pane` +with `start`/`end` to read scrollback. + +**Side effects:** Starts or stops piping output to a file. Call with +`output_path=null` to stop. + +**Example:** + +```json +{ + "tool": "pipe_pane", + "arguments": { + "pane_id": "%0", + "output_path": "/tmp/build.log" + } +} +``` + +Response (string): + +```text +Piping pane %0 to /tmp/build.log +``` + +```{fastmcp-tool-input} pane_tools.pipe_pane +``` + +--- + +```{fastmcp-tool} pane_tools.enter_copy_mode +``` + +**Use when** you need to scroll through scrollback history in a pane. +Optionally scroll up immediately after entering. Use +{tooliconl}`snapshot-pane` afterward to read the `scroll_position` and +visible content. + +**Side effects:** Puts the pane into copy mode. The pane stops receiving +new output until you exit copy mode. + +**Example:** + +```json +{ + "tool": "enter_copy_mode", + "arguments": { + "pane_id": "%0", + "scroll_up": 50 + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.enter_copy_mode +``` + +--- + +```{fastmcp-tool} pane_tools.exit_copy_mode +``` + +**Use when** you're done scrolling through scrollback and want the pane to +resume receiving output. + +**Side effects:** Exits copy mode, returning the pane to normal. + +**Example:** + +```json +{ + "tool": "exit_copy_mode", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.exit_copy_mode +``` + +--- + +```{fastmcp-tool} pane_tools.paste_text +``` + +**Use when** you need to paste multi-line text into a pane — e.g. a code +block, a config snippet, or a heredoc. Uses tmux paste buffers for clean +multi-line input instead of sending text line-by-line via +{tooliconl}`send-keys`. + +**Side effects:** Pastes text into the pane. With `bracket=true` (default), +uses bracketed paste mode so the terminal knows this is pasted text. + +**Example:** + +```json +{ + "tool": "paste_text", + "arguments": { + "text": "def hello():\n print('world')\n", + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +Text pasted to pane %0 +``` + +```{fastmcp-tool-input} pane_tools.paste_text +``` + ## Destroy ```{fastmcp-tool} pane_tools.kill_pane diff --git a/docs/tools/sessions.md b/docs/tools/sessions.md index dd7ef23..984f042 100644 --- a/docs/tools/sessions.md +++ b/docs/tools/sessions.md @@ -150,6 +150,48 @@ Response: ```{fastmcp-tool-input} session_tools.rename_session ``` +--- + +```{fastmcp-tool} session_tools.select_window +``` + +**Use when** you need to switch focus to a different window — by ID, index, +or direction (`next`, `previous`, `last`). + +**Side effects:** Changes the active window in the session. + +**Example:** + +```json +{ + "tool": "select_window", + "arguments": { + "direction": "next", + "session_name": "dev" + } +} +``` + +Response: + +```json +{ + "window_id": "@1", + "window_name": "server", + "window_index": "2", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,2", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} session_tools.select_window +``` + ## Destroy ```{fastmcp-tool} session_tools.kill_session diff --git a/docs/tools/windows.md b/docs/tools/windows.md index 4b022f5..91684c0 100644 --- a/docs/tools/windows.md +++ b/docs/tools/windows.md @@ -326,6 +326,48 @@ Response: ```{fastmcp-tool-input} window_tools.resize_window ``` +--- + +```{fastmcp-tool} window_tools.move_window +``` + +**Use when** you need to reorder windows within a session or move a window +to a different session entirely. + +**Side effects:** Changes the window's index or parent session. + +**Example:** + +```json +{ + "tool": "move_window", + "arguments": { + "window_id": "@1", + "destination_index": "1" + } +} +``` + +Response: + +```json +{ + "window_id": "@1", + "window_name": "server", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,2", + "window_active": "0", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.move_window +``` + ## Destroy ```{fastmcp-tool} window_tools.kill_window