From 1a1e65981bd92e0ca4243b5ee79e591421ad5d47 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 17:24:42 -0500 Subject: [PATCH 1/6] 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/6] 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 From f0ee3d0a12ad24a5718cb2bf881cffa318249d6d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 18:04:40 -0500 Subject: [PATCH 3/6] feat(docs): Add resource and model autodoc directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix _MODEL_CLASSES staleness — replace hardcoded 10-model set with dynamic discovery via _discover_model_classes() using Pydantic introspection + __module__ filtering. PaneSnapshot and ContentChangeResult now get correct cross-references. Add 5 new directives: fastmcp-resource, fastmcp-resourcesummary, fastmcp-model, fastmcp-model-fields, fastmcp-modelsummary. Add 4 new roles: {resource}, {resourceref}, {model}, {modelref}. Add 4 new custom nodes with blue (resource) and purple (model) badges. Resource collection uses _ResourceCollector (mirrors _ToolCollector) to capture URI templates, titles, and params from hierarchy.py without executing tmux code. Model collection introspects BaseModel subclasses via model_fields with proper default_factory handling. Replace automodule dumps in reference/api/resources.md and models.md with structured autodoc output. Add resource and model role demos. Tests: 44 new tests across 3 files (251 total). --- docs/_ext/fastmcp_autodoc.py | 944 +++++++++++++++++++++- docs/_static/css/fastmcp_autodoc.css | 25 + docs/demo.md | 20 + docs/reference/api/models.md | 42 +- docs/reference/api/resources.md | 30 +- tests/docs/_ext/test_fastmcp_autodoc.py | 23 + tests/docs/_ext/test_fastmcp_models.py | 280 +++++++ tests/docs/_ext/test_fastmcp_resources.py | 202 +++++ 8 files changed, 1542 insertions(+), 24 deletions(-) create mode 100644 docs/_static/css/fastmcp_autodoc.css create mode 100644 tests/docs/_ext/test_fastmcp_models.py create mode 100644 tests/docs/_ext/test_fastmcp_resources.py diff --git a/docs/_ext/fastmcp_autodoc.py b/docs/_ext/fastmcp_autodoc.py index a21f185..cf493ce 100644 --- a/docs/_ext/fastmcp_autodoc.py +++ b/docs/_ext/fastmcp_autodoc.py @@ -65,18 +65,35 @@ TAG_DESTRUCTIVE = "destructive" _MODEL_MODULE = "libtmux_mcp.models" -_MODEL_CLASSES: set[str] = { - "SessionInfo", - "WindowInfo", - "PaneInfo", - "PaneContentMatch", - "ServerInfo", - "OptionResult", - "OptionSetResult", - "EnvironmentResult", - "EnvironmentSetResult", - "WaitForTextResult", -} +_model_classes_cache: set[str] | None = None + + +def _discover_model_classes() -> set[str]: + """Discover all BaseModel subclasses in libtmux_mcp.models. + + Results are cached after first call. Only discovers classes whose + ``__module__`` matches ``_MODEL_MODULE`` to prevent third-party leakage. + """ + global _model_classes_cache + if _model_classes_cache is not None: + return _model_classes_cache + import inspect as _inspect + + from pydantic import BaseModel + + try: + mod = importlib.import_module(_MODEL_MODULE) + except ImportError: + logger.warning("fastmcp_autodoc: could not import %s", _MODEL_MODULE) + _model_classes_cache = set() + return _model_classes_cache + _model_classes_cache = { + name + for name, obj in _inspect.getmembers(mod, _inspect.isclass) + if issubclass(obj, BaseModel) + and getattr(obj, "__module__", "") == _MODEL_MODULE + } + return _model_classes_cache # --------------------------------------------------------------------------- @@ -111,6 +128,40 @@ class ToolInfo: return_annotation: str +@dataclass +class ResourceInfo: + """Collected metadata for a single MCP resource.""" + + name: str + qualified_name: str + title: str + uri_template: str + docstring: str + params: list[ParamInfo] + return_annotation: str + + +@dataclass +class ModelFieldInfo: + """Extracted field information for a Pydantic model.""" + + name: str + type_str: str + required: bool + default: str + description: str + + +@dataclass +class ModelInfo: + """Collected metadata for a single Pydantic model.""" + + name: str + qualified_name: str + docstring: str + fields: list[ModelFieldInfo] + + # --------------------------------------------------------------------------- # Docstring + signature parsing # --------------------------------------------------------------------------- @@ -302,7 +353,7 @@ def _single_type_xref(name: str) -> addnodes.pending_xref: Known model classes are qualified to ``libtmux_mcp.models.X``. Builtins (``str``, ``list``, ``int``, etc.) target the Python domain. """ - target = f"{_MODEL_MODULE}.{name}" if name in _MODEL_CLASSES else name + target = f"{_MODEL_MODULE}.{name}" if name in _discover_model_classes() else name return addnodes.pending_xref( "", nodes.literal("", name), @@ -459,6 +510,76 @@ def _safety_badge(safety: str) -> _safety_badge_node: return badge +class _resource_badge_node(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Custom node for resource badges with ARIA attributes in HTML output.""" + + +def _visit_resource_badge_html(self: t.Any, node: _resource_badge_node) -> None: + """Emit opening ```` with classes, role, and aria-label.""" + classes = " ".join(node.get("classes", [])) + self.body.append( + f'' + ) + + +def _depart_resource_badge_html(self: t.Any, node: _resource_badge_node) -> None: + """Close the ````.""" + self.body.append("") + + +def _resource_badge() -> _resource_badge_node: + """Create a blue resource badge node with ARIA attributes.""" + _base = ["sd-sphinx-override", "sd-badge"] + badge = _resource_badge_node( + "", + nodes.Text("resource"), + classes=[*_base, "sd-bg-info", "sd-bg-text-info"], + ) + return badge + + +class _model_badge_node(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Custom node for model badges with ARIA attributes in HTML output.""" + + +def _visit_model_badge_html(self: t.Any, node: _model_badge_node) -> None: + """Emit opening ```` with classes, role, and aria-label.""" + classes = " ".join(node.get("classes", [])) + self.body.append(f'') + + +def _depart_model_badge_html(self: t.Any, node: _model_badge_node) -> None: + """Close the ````.""" + self.body.append("") + + +def _model_badge() -> _model_badge_node: + """Create a purple model badge node with ARIA attributes.""" + _base = ["sd-sphinx-override", "sd-badge"] + badge = _model_badge_node( + "", + nodes.Text("model"), + classes=[*_base, "sd-bg-primary", "sd-bg-text-primary"], + ) + return badge + + +class _resource_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Placeholder node for ``{resource}`` and ``{resourceref}`` roles. + + Resolved at ``doctree-resolved`` by ``_resolve_resource_refs``. + The ``show_badge`` attribute controls whether the resource badge is appended. + """ + + +class _model_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Placeholder node for ``{model}`` and ``{modelref}`` roles. + + Resolved at ``doctree-resolved`` by ``_resolve_model_refs``. + The ``show_badge`` attribute controls whether the model badge is appended. + """ + + # --------------------------------------------------------------------------- # Tool collection (runs at builder-inited) # --------------------------------------------------------------------------- @@ -512,6 +633,38 @@ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: return decorator +class _ResourceCollector: + """Mock FastMCP that captures resource registrations.""" + + def __init__(self) -> None: + self.resources: list[ResourceInfo] = [] + self._current_module: str = "" + + def resource( + self, + uri_template: str, + title: str = "", + **kwargs: t.Any, + ) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]: + def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + self.resources.append( + ResourceInfo( + name=func.__name__, + qualified_name=f"{self._current_module}.{func.__name__}", + title=title or func.__name__.replace("_", " ").title(), + uri_template=uri_template, + docstring=func.__doc__ or "", + params=_extract_params(func), + return_annotation=_format_annotation( + inspect.signature(func).return_annotation, + ), + ) + ) + return func + + return decorator + + def _collect_tools(app: Sphinx) -> None: """Collect tool metadata from libtmux_mcp source at build time.""" collector = _ToolCollector() @@ -541,6 +694,111 @@ def _collect_tools(app: Sphinx) -> None: app.env.fastmcp_tools = {tool.name: tool for tool in collector.tools} # type: ignore[attr-defined] +def _collect_resources(app: Sphinx) -> None: + """Collect resource metadata from libtmux_mcp source at build time.""" + collector = _ResourceCollector() + + resource_modules = ["hierarchy"] + + for mod_name in resource_modules: + collector._current_module = mod_name + try: + mod = importlib.import_module(f"libtmux_mcp.resources.{mod_name}") + if hasattr(mod, "register"): + mod.register(collector) + except Exception: + logger.warning( + "fastmcp_autodoc: failed to load resource module %s", + mod_name, + exc_info=True, + ) + + app.env.fastmcp_resources = {r.name: r for r in collector.resources} # type: ignore[attr-defined] + + +def _collect_models(app: Sphinx) -> None: + """Collect Pydantic model metadata from libtmux_mcp.models at build time.""" + from pydantic import BaseModel + + try: + mod = importlib.import_module(_MODEL_MODULE) + except ImportError: + logger.warning("fastmcp_autodoc: could not import %s", _MODEL_MODULE) + app.env.fastmcp_models = {} # type: ignore[attr-defined] + return + + models: dict[str, ModelInfo] = {} + for name, obj in inspect.getmembers(mod, inspect.isclass): + if not issubclass(obj, BaseModel): + continue + if getattr(obj, "__module__", "") != _MODEL_MODULE: + continue + + fields: list[ModelFieldInfo] = [] + for field_name, field_info in obj.model_fields.items(): + # Determine type string + ann = obj.__annotations__.get(field_name, "") + type_str = _format_annotation(ann) + + # Determine required / default + has_default_factory = ( + hasattr(field_info, "default_factory") + and field_info.default_factory is not None + ) + has_default = not field_info.is_required() and not has_default_factory + + if has_default_factory: + required = False + factory = field_info.default_factory + # Show factory name for common factories + default_str = f"{factory.__name__}()" if factory else "" + elif has_default: + required = False + default_val = field_info.default + if default_val is None: + default_str = "None" + elif isinstance(default_val, bool): + default_str = str(default_val) + elif isinstance(default_val, str): + default_str = repr(default_val) + else: + default_str = str(default_val) + else: + required = True + default_str = "" + + # Extract description from Field(description=...) + description = "" + if hasattr(field_info, "description") and field_info.description: + description = field_info.description + + fields.append( + ModelFieldInfo( + name=field_name, + type_str=type_str, + required=required, + default=default_str, + description=description, + ) + ) + + models[name] = ModelInfo( + name=name, + qualified_name=f"{_MODEL_MODULE}.{name}", + docstring=obj.__doc__ or "", + fields=fields, + ) + + app.env.fastmcp_models = models # type: ignore[attr-defined] + + +def _collect_all(app: Sphinx) -> None: + """Collect tools, resources, and models at build time.""" + _collect_tools(app) + _collect_resources(app) + _collect_models(app) + + # --------------------------------------------------------------------------- # Directives # --------------------------------------------------------------------------- @@ -790,6 +1048,410 @@ def run(self) -> list[nodes.Node]: return result_nodes +class FastMCPResourceDirective(SphinxDirective): + """Autodocument a single MCP resource as a proper section. + + Creates a section node (visible in ToC) containing: + - Resource badge + one-line description + - URI template literal block + - Optional parameter table + - Return type + + Usage:: + + ```{fastmcp-resource} hierarchy.get_sessions + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build resource section nodes.""" + arg = self.arguments[0] + func_name = arg.split(".")[-1] if "." in arg else arg + + resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) + resource = resources.get(func_name) + + if resource is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-resource: resource '{func_name}' not found. " + f"Available: {', '.join(sorted(resources.keys()))}", + line=self.lineno, + ) + ] + + return self._build_resource_section(resource) + + def _build_resource_section(self, resource: ResourceInfo) -> list[nodes.Node]: + """Build section: title, badge, description, URI template, params.""" + document = self.state.document + + # Section with anchor ID + section_id = f"resource-{resource.name.replace('_', '-')}" + section = nodes.section() + section["ids"].append(section_id) + document.note_explicit_target(section) + + # Title: resource name + resource badge + title_node = nodes.title("", "") + title_node += nodes.literal("", resource.name) + title_node += nodes.Text(" ") + title_node += _resource_badge() + section += title_node + + # Description paragraph + first_para = _first_paragraph(resource.docstring) + if first_para: + desc_para = _parse_rst_inline(first_para, self.state, self.lineno) + section += desc_para + + # URI template as literal block + uri_block = nodes.literal_block("", resource.uri_template) + uri_block["language"] = "none" + uri_block["classes"].append("fastmcp-uri-template") + section += uri_block + + # Returns + if resource.return_annotation: + returns_para = nodes.paragraph("") + returns_para += nodes.strong("", "Returns: ") + type_para = _make_type_xref(resource.return_annotation) + for child in type_para.children: + returns_para += child.deepcopy() + section += returns_para + + # Parameter table + if resource.params: + section += _make_para(nodes.strong("", "Parameters")) + headers = ["Parameter", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for p in resource.params: + desc_node = ( + _parse_rst_inline(p.description, self.state, self.lineno) + if p.description + else nodes.paragraph("", "\u2014") + ) + + type_cell, _is_enum = _make_type_cell_smart(p.type_str) + + default_cell: str | nodes.Node = "\u2014" + if p.default and p.default != "None": + default_cell = _make_para(_make_literal(p.default)) + + rows.append( + [ + _make_para(_make_literal(p.name)), + type_cell, + "yes" if p.required else "no", + default_cell, + desc_node, + ] + ) + section += _make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]) + + return [section] + + +class FastMCPResourceSummaryDirective(SphinxDirective): + """Generate a summary table of all resources. + + Produces a single table with URI Template, Title, and Description columns. + + Usage:: + + ```{fastmcp-resourcesummary} + ``` + """ + + required_arguments = 0 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build resource summary table.""" + resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) + + if not resources: + return [ + self.state.document.reporter.warning( + "fastmcp-resourcesummary: no resources found.", + line=self.lineno, + ) + ] + + headers = ["URI Template", "Title", "Description"] + rows: list[list[str | nodes.Node]] = [] + for resource in sorted(resources.values(), key=lambda r: r.uri_template): + first_line = _first_paragraph(resource.docstring) + ref = nodes.reference("", "", internal=True) + section_id = f"resource-{resource.name.replace('_', '-')}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", resource.uri_template) + rows.append( + [ + _make_para(ref), + resource.title, + _parse_rst_inline(first_line, self.state, self.lineno), + ] + ) + + return [_make_table(headers, rows, col_widths=[35, 15, 50])] + + +class FastMCPModelDirective(SphinxDirective): + """Autodocument a single Pydantic model as a proper section. + + Creates a section node (visible in ToC) containing: + - Model badge + docstring + - Field table (Field, Type, Required, Default, Description) + + Options: + - ``:fields:`` — comma-separated allowlist of fields to include + - ``:exclude:`` — comma-separated denylist of fields to exclude + + Usage:: + + ```{fastmcp-model} SessionInfo + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "fields": lambda x: x, + "exclude": lambda x: x, + } + + def run(self) -> list[nodes.Node]: + """Build model section nodes.""" + model_name = self.arguments[0].strip() + + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + model = models.get(model_name) + + if model is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-model: model '{model_name}' not found. " + f"Available: {', '.join(sorted(models.keys()))}", + line=self.lineno, + ) + ] + + return self._build_model_section(model) + + def _build_model_section(self, model: ModelInfo) -> list[nodes.Node]: + """Build section: title, badge, docstring, field table.""" + document = self.state.document + + # Section with anchor ID + section_id = f"model-{model.name}" + section = nodes.section() + section["ids"].append(section_id) + document.note_explicit_target(section) + + # Title: model name + model badge + title_node = nodes.title("", "") + title_node += nodes.literal("", model.name) + title_node += nodes.Text(" ") + title_node += _model_badge() + section += title_node + + # Docstring + first_para = _first_paragraph(model.docstring) + if first_para: + desc_para = _parse_rst_inline(first_para, self.state, self.lineno) + section += desc_para + + # Field table + fields = self._filter_fields(model.fields) + if fields: + section += self._build_field_table(fields) + + return [section] + + def _filter_fields(self, fields: list[ModelFieldInfo]) -> list[ModelFieldInfo]: + """Apply :fields: and :exclude: options.""" + result = list(fields) + fields_opt = self.options.get("fields") + if fields_opt: + allow = {f.strip() for f in fields_opt.split(",")} + result = [f for f in result if f.name in allow] + exclude_opt = self.options.get("exclude") + if exclude_opt: + deny = {f.strip() for f in exclude_opt.split(",")} + result = [f for f in result if f.name not in deny] + return result + + def _build_field_table(self, fields: list[ModelFieldInfo]) -> nodes.table: + """Build a field table.""" + headers = ["Field", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for f in fields: + type_cell, _is_enum = _make_type_cell_smart(f.type_str) + + default_cell: str | nodes.Node = "\u2014" + if f.default and f.default != "None": + default_cell = _make_para(_make_literal(f.default)) + + desc = f.description or "\u2014" + + rows.append( + [ + _make_para(_make_literal(f.name)), + type_cell, + "yes" if f.required else "no", + default_cell, + desc, + ] + ) + return _make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]) + + +class FastMCPModelFieldsDirective(SphinxDirective): + """Emit the field table for a model without a section wrapper. + + Useful for embedding model fields inline in other content. + + Options: + - ``:fields:`` — comma-separated allowlist of fields to include + - ``:exclude:`` — comma-separated denylist of fields to exclude + - ``:link-header:`` — if set, adds a header linking to the model section + + Usage:: + + ```{fastmcp-model-fields} SessionInfo + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "fields": lambda x: x, + "exclude": lambda x: x, + "link-header": lambda x: x, + } + + def run(self) -> list[nodes.Node]: + """Build field table nodes.""" + model_name = self.arguments[0].strip() + + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + model = models.get(model_name) + + if model is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-model-fields: model '{model_name}' not found.", + line=self.lineno, + ) + ] + + result: list[nodes.Node] = [] + + # Optional link header + link_header = self.options.get("link-header") + if link_header is not None: + ref = nodes.reference("", "", internal=True) + section_id = f"model-{model.name}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", model.name) + result.append(_make_para(ref)) + + # Filter and build table + fields = self._filter_fields(model.fields) + if fields: + headers = ["Field", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for f in fields: + type_cell, _is_enum = _make_type_cell_smart(f.type_str) + + default_cell: str | nodes.Node = "\u2014" + if f.default and f.default != "None": + default_cell = _make_para(_make_literal(f.default)) + + desc = f.description or "\u2014" + + rows.append( + [ + _make_para(_make_literal(f.name)), + type_cell, + "yes" if f.required else "no", + default_cell, + desc, + ] + ) + result.append(_make_table(headers, rows, col_widths=[15, 15, 8, 10, 52])) + + return result + + def _filter_fields(self, fields: list[ModelFieldInfo]) -> list[ModelFieldInfo]: + """Apply :fields: and :exclude: options.""" + result = list(fields) + fields_opt = self.options.get("fields") + if fields_opt: + allow = {f.strip() for f in fields_opt.split(",")} + result = [f for f in result if f.name in allow] + exclude_opt = self.options.get("exclude") + if exclude_opt: + deny = {f.strip() for f in exclude_opt.split(",")} + result = [f for f in result if f.name not in deny] + return result + + +class FastMCPModelSummaryDirective(SphinxDirective): + """Generate a summary table of all models. + + Produces a single table with Model and Description columns. + + Usage:: + + ```{fastmcp-modelsummary} + ``` + """ + + required_arguments = 0 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build model summary table.""" + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + + if not models: + return [ + self.state.document.reporter.warning( + "fastmcp-modelsummary: no models found.", + line=self.lineno, + ) + ] + + headers = ["Model", "Description"] + rows: list[list[str | nodes.Node]] = [] + for model in sorted(models.values(), key=lambda m: m.name): + first_line = _first_paragraph(model.docstring) + ref = nodes.reference("", "", internal=True) + section_id = f"model-{model.name}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", model.name) + rows.append( + [ + _make_para(ref), + _parse_rst_inline(first_line, self.state, self.lineno), + ] + ) + + return [_make_table(headers, rows, col_widths=[30, 70])] + + # --------------------------------------------------------------------------- # Extension setup # --------------------------------------------------------------------------- @@ -1059,16 +1721,248 @@ def _badge_role( return [_safety_badge(text.strip())], [] +def _resource_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:resource:`get-sessions``` → linked name + resource badge.""" + target = text.strip().replace("_", "-") + node = _resource_ref_placeholder(rawtext, reftarget=target, show_badge=True) + return [node], [] + + +def _resourceref_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:resourceref:`get-sessions``` → code-linked, no badge.""" + target = text.strip().replace("_", "-") + node = _resource_ref_placeholder(rawtext, reftarget=target, show_badge=False) + return [node], [] + + +def _model_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:model:`SessionInfo``` → linked name + model badge.""" + target = text.strip() + node = _model_ref_placeholder(rawtext, reftarget=target, show_badge=True) + return [node], [] + + +def _modelref_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:modelref:`SessionInfo``` → code-linked, no badge.""" + target = text.strip() + node = _model_ref_placeholder(rawtext, reftarget=target, show_badge=False) + return [node], [] + + +def _register_resource_labels(app: Sphinx, doctree: nodes.document) -> None: + """Register resource sections with StandardDomain for site-wide {ref} links. + + Same pattern as ``_register_tool_labels`` but for sections with + ``resource-`` prefixed IDs. + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + docname = app.env.docname + for section in doctree.findall(nodes.section): + if not section["ids"]: + continue + section_id = section["ids"][0] + if not section_id.startswith("resource-"): + continue + if section.children and isinstance(section[0], nodes.title): + title_node = section[0] + resource_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + resource_name = child.astext() + break + if not resource_name: + continue + domain.anonlabels[section_id] = (docname, section_id) + domain.labels[section_id] = (docname, section_id, resource_name) + + +def _register_model_labels(app: Sphinx, doctree: nodes.document) -> None: + """Register model sections with StandardDomain for site-wide {ref} links. + + Same pattern as ``_register_tool_labels`` but for sections with + ``model-`` prefixed IDs. + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + docname = app.env.docname + for section in doctree.findall(nodes.section): + if not section["ids"]: + continue + section_id = section["ids"][0] + if not section_id.startswith("model-"): + continue + if section.children and isinstance(section[0], nodes.title): + title_node = section[0] + model_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + model_name = child.astext() + break + if not model_name: + continue + domain.anonlabels[section_id] = (docname, section_id) + domain.labels[section_id] = (docname, section_id, model_name) + + +def _resolve_resource_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``{resource}`` and ``{resourceref}`` placeholders. + + ``{resource}`` renders as ``code`` + resource badge. + ``{resourceref}`` renders as ``code`` only (no badge). + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + builder = app.builder + + for node in list(doctree.findall(_resource_ref_placeholder)): + target = node.get("reftarget", "") + show_badge = node.get("show_badge", True) + # Try resource-prefixed label + label_key = f"resource-{target}" + label_info = domain.labels.get(label_key) + if label_info is None: + node.replace_self(nodes.literal("", target.replace("-", "_"))) + continue + + todocname, labelid, _title = label_info + resource_name = target.replace("-", "_") + + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "fastmcp_autodoc: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + + newnode += nodes.literal("", resource_name) + if show_badge: + newnode += nodes.Text(" ") + newnode += _resource_badge() + + node.replace_self(newnode) + + +def _resolve_model_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``{model}`` and ``{modelref}`` placeholders. + + ``{model}`` renders as ``code`` + model badge. + ``{modelref}`` renders as ``code`` only (no badge). + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + builder = app.builder + + for node in list(doctree.findall(_model_ref_placeholder)): + target = node.get("reftarget", "") + show_badge = node.get("show_badge", True) + # Try model-prefixed label + label_key = f"model-{target}" + label_info = domain.labels.get(label_key) + if label_info is None: + node.replace_self(nodes.literal("", target)) + continue + + todocname, labelid, _title = label_info + + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "fastmcp_autodoc: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + + newnode += nodes.literal("", target) + if show_badge: + newnode += nodes.Text(" ") + newnode += _model_badge() + + node.replace_self(newnode) + + def setup(app: Sphinx) -> ExtensionMetadata: """Register the fastmcp_autodoc extension.""" + # Nodes app.add_node( _safety_badge_node, html=(_visit_safety_badge_html, _depart_safety_badge_html), ) - app.connect("builder-inited", _collect_tools) + app.add_node( + _resource_badge_node, + html=(_visit_resource_badge_html, _depart_resource_badge_html), + ) + app.add_node( + _model_badge_node, + html=(_visit_model_badge_html, _depart_model_badge_html), + ) + + # Collection + app.connect("builder-inited", _collect_all) + + # Label registration app.connect("doctree-read", _register_tool_labels) + app.connect("doctree-read", _register_resource_labels) + app.connect("doctree-read", _register_model_labels) + + # Ref resolution app.connect("doctree-resolved", _add_section_badges) app.connect("doctree-resolved", _resolve_tool_refs) + app.connect("doctree-resolved", _resolve_resource_refs) + app.connect("doctree-resolved", _resolve_model_refs) + + # Tool roles app.add_role("tool", _tool_role) app.add_role("toolref", _toolref_role) app.add_role("toolicon", _toolicon_role) @@ -1077,10 +1971,32 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_role("tooliconil", _tooliconil_role) app.add_role("tooliconir", _tooliconir_role) app.add_role("badge", _badge_role) + + # Resource roles + app.add_role("resource", _resource_role) + app.add_role("resourceref", _resourceref_role) + + # Model roles + app.add_role("model", _model_role) + app.add_role("modelref", _modelref_role) + + # Tool directives app.add_directive("fastmcp-tool", FastMCPToolDirective) app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) app.add_directive("fastmcp-toolsummary", FastMCPToolSummaryDirective) + # Resource directives + app.add_directive("fastmcp-resource", FastMCPResourceDirective) + app.add_directive("fastmcp-resourcesummary", FastMCPResourceSummaryDirective) + + # Model directives + app.add_directive("fastmcp-model", FastMCPModelDirective) + app.add_directive("fastmcp-model-fields", FastMCPModelFieldsDirective) + app.add_directive("fastmcp-modelsummary", FastMCPModelSummaryDirective) + + # CSS + app.add_css_file("css/fastmcp_autodoc.css") + return { "version": "0.1.0", "parallel_read_safe": True, diff --git a/docs/_static/css/fastmcp_autodoc.css b/docs/_static/css/fastmcp_autodoc.css new file mode 100644 index 0000000..14461e2 --- /dev/null +++ b/docs/_static/css/fastmcp_autodoc.css @@ -0,0 +1,25 @@ +/* fastmcp_autodoc.css — resource badge, model badge, URI template, table polish */ + +/* Resource badge (blue) — additional ARIA styling */ +span.sd-badge[aria-label*="resource"] { + user-select: none; +} + +/* Model badge (purple) — additional ARIA styling */ +span.sd-badge[aria-label*="model"] { + user-select: none; +} + +/* URI template block — left-border accent, monospace */ +.fastmcp-uri-template { + border-left: 3px solid var(--sd-color-info, #0dcaf0); + padding: 0.5em 1em; + background: var(--sd-color-info-bg, #f0f9ff); + font-family: var(--sd-fontfamily-monospace, monospace); +} + +/* Field/param table polish — tighter padding */ +.fastmcp-autodoc-table td, +.fastmcp-autodoc-table th { + padding: 0.35em 0.6em; +} diff --git a/docs/demo.md b/docs/demo.md index 2147547..7b45cfd 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -72,6 +72,26 @@ Use {tooliconl}`search-panes` to find text across all panes. If you know which p The fundamental pattern: {toolref}`send-keys` → {toolref}`wait-for-text` → {toolref}`capture-pane`. For discovery: {toolref}`list-sessions` → {toolref}`list-panes` → {toolref}`get-pane-info`. +## Resource references + +### `{resource}` — code-linked with badge + +{resource}`get-sessions` · {resource}`get-session` · {resource}`get-session-windows` · {resource}`get-window` · {resource}`get-pane` · {resource}`get-pane-content` + +### `{resourceref}` — code-linked, no badge + +{resourceref}`get-sessions` · {resourceref}`get-session` · {resourceref}`get-session-windows` · {resourceref}`get-window` · {resourceref}`get-pane` · {resourceref}`get-pane-content` + +## Model references + +### `{model}` — code-linked with badge + +{model}`SessionInfo` · {model}`WindowInfo` · {model}`PaneInfo` · {model}`PaneSnapshot` · {model}`ServerInfo` · {model}`WaitForTextResult` + +### `{modelref}` — code-linked, no badge + +{modelref}`SessionInfo` · {modelref}`WindowInfo` · {modelref}`PaneInfo` · {modelref}`PaneSnapshot` · {modelref}`ServerInfo` · {modelref}`WaitForTextResult` + ## Environment variable references {envvar}`LIBTMUX_SOCKET` · {envvar}`LIBTMUX_SAFETY` · {envvar}`LIBTMUX_SOCKET_PATH` · {envvar}`LIBTMUX_TMUX_BIN` diff --git a/docs/reference/api/models.md b/docs/reference/api/models.md index 57b63b9..34954af 100644 --- a/docs/reference/api/models.md +++ b/docs/reference/api/models.md @@ -1,8 +1,40 @@ # Models -```{eval-rst} -.. automodule:: libtmux_mcp.models - :members: - :undoc-members: - :show-inheritance: +```{fastmcp-modelsummary} +``` + +```{fastmcp-model} SessionInfo +``` + +```{fastmcp-model} WindowInfo +``` + +```{fastmcp-model} PaneInfo +``` + +```{fastmcp-model} PaneContentMatch +``` + +```{fastmcp-model} ServerInfo +``` + +```{fastmcp-model} OptionResult +``` + +```{fastmcp-model} OptionSetResult +``` + +```{fastmcp-model} EnvironmentResult +``` + +```{fastmcp-model} EnvironmentSetResult +``` + +```{fastmcp-model} WaitForTextResult +``` + +```{fastmcp-model} PaneSnapshot +``` + +```{fastmcp-model} ContentChangeResult ``` diff --git a/docs/reference/api/resources.md b/docs/reference/api/resources.md index 245d916..7f99e3f 100644 --- a/docs/reference/api/resources.md +++ b/docs/reference/api/resources.md @@ -1,8 +1,28 @@ # Resources -```{eval-rst} -.. automodule:: libtmux_mcp.resources.hierarchy - :members: - :undoc-members: - :show-inheritance: +```{fastmcp-resourcesummary} +``` + +## Session Resources + +```{fastmcp-resource} hierarchy.get_sessions +``` + +```{fastmcp-resource} hierarchy.get_session +``` + +```{fastmcp-resource} hierarchy.get_session_windows +``` + +## Window Resources + +```{fastmcp-resource} hierarchy.get_window +``` + +## Pane Resources + +```{fastmcp-resource} hierarchy.get_pane +``` + +```{fastmcp-resource} hierarchy.get_pane_content ``` diff --git a/tests/docs/_ext/test_fastmcp_autodoc.py b/tests/docs/_ext/test_fastmcp_autodoc.py index a2ed9fa..1876999 100644 --- a/tests/docs/_ext/test_fastmcp_autodoc.py +++ b/tests/docs/_ext/test_fastmcp_autodoc.py @@ -665,6 +665,29 @@ def test_collect_real_tools() -> None: assert socket_param.required is False +# --------------------------------------------------------------------------- +# _discover_model_classes +# --------------------------------------------------------------------------- + + +def test_discover_model_classes_finds_all_12() -> None: + """_discover_model_classes finds all 12 Pydantic models.""" + classes = fastmcp_autodoc._discover_model_classes() + assert len(classes) == 12 + + +def test_discover_model_classes_includes_pane_snapshot() -> None: + """_discover_model_classes includes PaneSnapshot (previously missing).""" + classes = fastmcp_autodoc._discover_model_classes() + assert "PaneSnapshot" in classes + + +def test_discover_model_classes_includes_content_change_result() -> None: + """_discover_model_classes includes ContentChangeResult (previously missing).""" + classes = fastmcp_autodoc._discover_model_classes() + assert "ContentChangeResult" in classes + + def test_collect_real_tools_total_count() -> None: """All 38 tools should be collected.""" collector = fastmcp_autodoc._ToolCollector() diff --git a/tests/docs/_ext/test_fastmcp_models.py b/tests/docs/_ext/test_fastmcp_models.py new file mode 100644 index 0000000..03abfa4 --- /dev/null +++ b/tests/docs/_ext/test_fastmcp_models.py @@ -0,0 +1,280 @@ +"""Tests for fastmcp_autodoc model collection and nodes.""" + +from __future__ import annotations + +import typing as t +from unittest.mock import MagicMock + +import fastmcp_autodoc +import pytest + +# --------------------------------------------------------------------------- +# _collect_models +# --------------------------------------------------------------------------- + + +def _collect_models_result() -> dict[str, fastmcp_autodoc.ModelInfo]: + """Run _collect_models and return the result dict.""" + app = MagicMock() + app.env = MagicMock() + fastmcp_autodoc._collect_models(app) + result: dict[str, fastmcp_autodoc.ModelInfo] = app.env.fastmcp_models + return result + + +def test_collect_models_discovers_all_12() -> None: + """_collect_models finds all 12 Pydantic models in libtmux_mcp.models.""" + models = _collect_models_result() + assert len(models) == 12 + + +class ModelNameFixture(t.NamedTuple): + """Test fixture for verifying model names.""" + + test_id: str + name: str + + +MODEL_NAME_FIXTURES: list[ModelNameFixture] = [ + ModelNameFixture(test_id="SessionInfo", name="SessionInfo"), + ModelNameFixture(test_id="WindowInfo", name="WindowInfo"), + ModelNameFixture(test_id="PaneInfo", name="PaneInfo"), + ModelNameFixture(test_id="PaneContentMatch", name="PaneContentMatch"), + ModelNameFixture(test_id="ServerInfo", name="ServerInfo"), + ModelNameFixture(test_id="OptionResult", name="OptionResult"), + ModelNameFixture(test_id="OptionSetResult", name="OptionSetResult"), + ModelNameFixture(test_id="EnvironmentResult", name="EnvironmentResult"), + ModelNameFixture(test_id="EnvironmentSetResult", name="EnvironmentSetResult"), + ModelNameFixture(test_id="WaitForTextResult", name="WaitForTextResult"), + ModelNameFixture(test_id="PaneSnapshot", name="PaneSnapshot"), + ModelNameFixture(test_id="ContentChangeResult", name="ContentChangeResult"), +] + + +@pytest.mark.parametrize( + MODEL_NAME_FIXTURES[0]._fields, + MODEL_NAME_FIXTURES, + ids=[f.test_id for f in MODEL_NAME_FIXTURES], +) +def test_collect_models_includes_model(test_id: str, name: str) -> None: + """_collect_models includes each expected model.""" + models = _collect_models_result() + assert name in models + + +# --------------------------------------------------------------------------- +# Field counts +# --------------------------------------------------------------------------- + + +class FieldCountFixture(t.NamedTuple): + """Test fixture for field count verification.""" + + test_id: str + model_name: str + expected_count: int + + +FIELD_COUNT_FIXTURES: list[FieldCountFixture] = [ + FieldCountFixture( + test_id="SessionInfo_5", + model_name="SessionInfo", + expected_count=5, + ), + FieldCountFixture( + test_id="PaneSnapshot_14", + model_name="PaneSnapshot", + expected_count=14, + ), + FieldCountFixture( + test_id="WindowInfo_10", + model_name="WindowInfo", + expected_count=10, + ), + FieldCountFixture( + test_id="PaneInfo_12", + model_name="PaneInfo", + expected_count=12, + ), + FieldCountFixture( + test_id="ContentChangeResult_4", + model_name="ContentChangeResult", + expected_count=4, + ), + FieldCountFixture( + test_id="WaitForTextResult_5", + model_name="WaitForTextResult", + expected_count=5, + ), +] + + +@pytest.mark.parametrize( + FIELD_COUNT_FIXTURES[0]._fields, + FIELD_COUNT_FIXTURES, + ids=[f.test_id for f in FIELD_COUNT_FIXTURES], +) +def test_model_field_count( + test_id: str, + model_name: str, + expected_count: int, +) -> None: + """Models have the expected number of fields.""" + models = _collect_models_result() + model = models[model_name] + assert len(model.fields) == expected_count + + +# --------------------------------------------------------------------------- +# Field description extraction +# --------------------------------------------------------------------------- + + +def test_field_description_extraction() -> None: + """Field(description=...) values are extracted correctly.""" + models = _collect_models_result() + session = models["SessionInfo"] + field_map = {f.name: f for f in session.fields} + + assert "session_id" in field_map + assert field_map["session_id"].description == "Session ID (e.g. '$1')" + + assert "window_count" in field_map + assert field_map["window_count"].description == "Number of windows" + + +# --------------------------------------------------------------------------- +# Required vs optional detection +# --------------------------------------------------------------------------- + + +def test_required_vs_optional_detection() -> None: + """Required fields and optional fields are distinguished correctly.""" + models = _collect_models_result() + session = models["SessionInfo"] + field_map = {f.name: f for f in session.fields} + + # session_id has no default → required + assert field_map["session_id"].required is True + assert field_map["session_id"].default == "" + + # session_name has default=None → optional + assert field_map["session_name"].required is False + assert field_map["session_name"].default == "None" + + # window_count has no default → required + assert field_map["window_count"].required is True + + +# --------------------------------------------------------------------------- +# default_factory handling +# --------------------------------------------------------------------------- + + +def test_default_factory_handling() -> None: + """WaitForTextResult.matched_lines uses default_factory=list.""" + models = _collect_models_result() + wait_result = models["WaitForTextResult"] + field_map = {f.name: f for f in wait_result.fields} + + matched_lines = field_map["matched_lines"] + assert matched_lines.required is False + assert matched_lines.default == "list()" + + +# --------------------------------------------------------------------------- +# _model_badge_node +# --------------------------------------------------------------------------- + + +def test_model_badge_classes() -> None: + """_model_badge creates badge node with correct CSS classes.""" + badge = fastmcp_autodoc._model_badge() + assert isinstance(badge, fastmcp_autodoc._model_badge_node) + assert "sd-bg-primary" in badge["classes"] + assert "sd-bg-text-primary" in badge["classes"] + assert "sd-sphinx-override" in badge["classes"] + assert "sd-badge" in badge["classes"] + assert badge.astext() == "model" + + +# --------------------------------------------------------------------------- +# Model roles +# --------------------------------------------------------------------------- + + +def test_model_role_creates_placeholder() -> None: + """_model_role creates _model_ref_placeholder with show_badge=True.""" + result_nodes, _messages = fastmcp_autodoc._model_role( + "model", ":model:`SessionInfo`", "SessionInfo", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._model_ref_placeholder) + assert node["reftarget"] == "SessionInfo" + assert node["show_badge"] is True + + +def test_modelref_role_creates_placeholder() -> None: + """_modelref_role creates _model_ref_placeholder with show_badge=False.""" + result_nodes, _messages = fastmcp_autodoc._modelref_role( + "modelref", ":modelref:`SessionInfo`", "SessionInfo", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._model_ref_placeholder) + assert node["reftarget"] == "SessionInfo" + assert node["show_badge"] is False + + +# --------------------------------------------------------------------------- +# :fields: and :exclude: filtering +# --------------------------------------------------------------------------- + + +def test_model_directive_fields_allowlist() -> None: + """FastMCPModelDirective :fields: option filters to allowed fields.""" + directive = fastmcp_autodoc.FastMCPModelDirective.__new__( + fastmcp_autodoc.FastMCPModelDirective + ) + directive.options = {"fields": "session_id, window_count"} + + all_fields = [ + fastmcp_autodoc.ModelFieldInfo("session_id", "str", True, "", ""), + fastmcp_autodoc.ModelFieldInfo("session_name", "str | None", False, "None", ""), + fastmcp_autodoc.ModelFieldInfo("window_count", "int", True, "", ""), + ] + + filtered = directive._filter_fields(all_fields) + names = [f.name for f in filtered] + assert names == ["session_id", "window_count"] + + +def test_model_directive_exclude_denylist() -> None: + """FastMCPModelDirective :exclude: option removes denied fields.""" + directive = fastmcp_autodoc.FastMCPModelDirective.__new__( + fastmcp_autodoc.FastMCPModelDirective + ) + directive.options = {"exclude": "session_name"} + + all_fields = [ + fastmcp_autodoc.ModelFieldInfo("session_id", "str", True, "", ""), + fastmcp_autodoc.ModelFieldInfo("session_name", "str | None", False, "None", ""), + fastmcp_autodoc.ModelFieldInfo("window_count", "int", True, "", ""), + ] + + filtered = directive._filter_fields(all_fields) + names = [f.name for f in filtered] + assert names == ["session_id", "window_count"] + + +# --------------------------------------------------------------------------- +# Qualified names +# --------------------------------------------------------------------------- + + +def test_model_qualified_name() -> None: + """Model qualified_name includes full module path.""" + models = _collect_models_result() + assert models["SessionInfo"].qualified_name == "libtmux_mcp.models.SessionInfo" + assert models["PaneSnapshot"].qualified_name == "libtmux_mcp.models.PaneSnapshot" diff --git a/tests/docs/_ext/test_fastmcp_resources.py b/tests/docs/_ext/test_fastmcp_resources.py new file mode 100644 index 0000000..e4b66d4 --- /dev/null +++ b/tests/docs/_ext/test_fastmcp_resources.py @@ -0,0 +1,202 @@ +"""Tests for fastmcp_autodoc resource collection and nodes.""" + +from __future__ import annotations + +import typing as t + +import fastmcp_autodoc +import pytest + +# --------------------------------------------------------------------------- +# _ResourceCollector +# --------------------------------------------------------------------------- + + +def test_resource_collector_captures_registrations() -> None: + """_ResourceCollector captures resource metadata from register() calls.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + @collector.resource("tmux://sessions{?socket_name}", title="All Sessions") + def get_sessions(socket_name: str | None = None) -> str: + """List all tmux sessions. + + Parameters + ---------- + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + """ + return "" + + assert len(collector.resources) == 1 + resource = collector.resources[0] + assert resource.name == "get_sessions" + assert resource.qualified_name == "hierarchy.get_sessions" + assert resource.title == "All Sessions" + assert resource.uri_template == "tmux://sessions{?socket_name}" + assert len(resource.params) == 1 + assert resource.params[0].name == "socket_name" + assert resource.params[0].required is False + + +def test_resource_collector_default_title() -> None: + """_ResourceCollector uses func name as default title.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + @collector.resource("tmux://sessions") + def get_sessions() -> str: + """List sessions.""" + return "" + + assert collector.resources[0].title == "Get Sessions" + + +# --------------------------------------------------------------------------- +# Real resource collection +# --------------------------------------------------------------------------- + + +def test_collect_real_resources_total_count() -> None: + """All 6 resources should be collected from hierarchy.py.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + import importlib + + mod = importlib.import_module("libtmux_mcp.resources.hierarchy") + mod.register(collector) + + assert len(collector.resources) == 6 + + +class RealResourceFixture(t.NamedTuple): + """Test fixture for real resource verification.""" + + test_id: str + name: str + uri_template: str + title: str + + +REAL_RESOURCE_FIXTURES: list[RealResourceFixture] = [ + RealResourceFixture( + test_id="get_sessions", + name="get_sessions", + uri_template="tmux://sessions{?socket_name}", + title="All Sessions", + ), + RealResourceFixture( + test_id="get_session", + name="get_session", + uri_template="tmux://sessions/{session_name}{?socket_name}", + title="Session Detail", + ), + RealResourceFixture( + test_id="get_session_windows", + name="get_session_windows", + uri_template="tmux://sessions/{session_name}/windows{?socket_name}", + title="Session Windows", + ), + RealResourceFixture( + test_id="get_window", + name="get_window", + uri_template="tmux://sessions/{session_name}/windows/{window_index}{?socket_name}", + title="Window Detail", + ), + RealResourceFixture( + test_id="get_pane", + name="get_pane", + uri_template="tmux://panes/{pane_id}{?socket_name}", + title="Pane Detail", + ), + RealResourceFixture( + test_id="get_pane_content", + name="get_pane_content", + uri_template="tmux://panes/{pane_id}/content{?socket_name}", + title="Pane Content", + ), +] + + +@pytest.mark.parametrize( + REAL_RESOURCE_FIXTURES[0]._fields, + REAL_RESOURCE_FIXTURES, + ids=[f.test_id for f in REAL_RESOURCE_FIXTURES], +) +def test_collect_real_resource_details( + test_id: str, + name: str, + uri_template: str, + title: str, +) -> None: + """Real resources have correct URI templates and titles.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + import importlib + + mod = importlib.import_module("libtmux_mcp.resources.hierarchy") + mod.register(collector) + + resources = {r.name: r for r in collector.resources} + resource = resources[name] + assert resource.uri_template == uri_template + assert resource.title == title + + +# --------------------------------------------------------------------------- +# _resource_badge_node +# --------------------------------------------------------------------------- + + +def test_resource_badge_classes() -> None: + """_resource_badge creates badge node with correct CSS classes.""" + badge = fastmcp_autodoc._resource_badge() + assert isinstance(badge, fastmcp_autodoc._resource_badge_node) + assert "sd-bg-info" in badge["classes"] + assert "sd-bg-text-info" in badge["classes"] + assert "sd-sphinx-override" in badge["classes"] + assert "sd-badge" in badge["classes"] + assert badge.astext() == "resource" + + +# --------------------------------------------------------------------------- +# Resource roles +# --------------------------------------------------------------------------- + + +def test_resource_role_creates_placeholder() -> None: + """_resource_role creates _resource_ref_placeholder with show_badge=True.""" + result_nodes, _messages = fastmcp_autodoc._resource_role( + "resource", ":resource:`get-sessions`", "get-sessions", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._resource_ref_placeholder) + assert node["reftarget"] == "get-sessions" + assert node["show_badge"] is True + + +def test_resourceref_role_creates_placeholder() -> None: + """_resourceref_role creates _resource_ref_placeholder with show_badge=False.""" + result_nodes, _messages = fastmcp_autodoc._resourceref_role( + "resourceref", ":resourceref:`get-sessions`", "get-sessions", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._resource_ref_placeholder) + assert node["reftarget"] == "get-sessions" + assert node["show_badge"] is False + + +def test_resource_role_normalizes_underscores() -> None: + """_resource_role converts underscores to hyphens in target.""" + result_nodes, _ = fastmcp_autodoc._resource_role( + "resource", ":resource:`get_sessions`", "get_sessions", 1, None + ) + assert result_nodes[0]["reftarget"] == "get-sessions" From c097c53ba41d7aa506031105b856ffdd514e07ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 18:29:07 -0500 Subject: [PATCH 4/6] docs(tools): Split tools into individual pages with grid navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move each of the 38 tools from 4 area pages (sessions.md, windows.md, panes.md, options.md) into individual pages under docs/tools/. Fix FastMCPToolSummaryDirective link generation to use anchor-only refs instead of hardcoded area paths, making it page-layout-agnostic. Add sphinxcontrib-rediraffe redirects for the 4 removed area pages pointing to tools/index where the grid cards provide navigation. Grid cards, {tool}/{toolref}/{tooliconl} roles, and all cross-references continue working unchanged — Sphinx resolves labels globally via StandardDomain regardless of which page contains the section. --- docs/_ext/fastmcp_autodoc.py | 4 +- docs/redirects.txt | 4 + docs/tools/capture-pane.md | 40 ++ docs/tools/clear-pane.md | 26 + docs/tools/create-session.md | 36 ++ docs/tools/create-window.md | 38 ++ docs/tools/display-message.md | 33 ++ docs/tools/enter-copy-mode.md | 44 ++ docs/tools/exit-copy-mode.md | 40 ++ docs/tools/get-pane-info.md | 42 ++ docs/tools/get-server-info.md | 33 ++ docs/tools/index.md | 42 +- docs/tools/kill-pane.md | 29 + docs/tools/kill-server.md | 27 + docs/tools/kill-session.md | 29 + docs/tools/kill-window.md | 28 + docs/tools/list-panes.md | 56 ++ docs/tools/list-sessions.md | 36 ++ docs/tools/list-windows.md | 54 ++ docs/tools/move-window.md | 39 ++ docs/tools/options.md | 138 ----- docs/tools/panes.md | 742 -------------------------- docs/tools/paste-text.md | 31 ++ docs/tools/pipe-pane.md | 33 ++ docs/tools/rename-session.md | 33 ++ docs/tools/rename-window.md | 38 ++ docs/tools/resize-pane.md | 40 ++ docs/tools/resize-window.md | 39 ++ docs/tools/search-panes.md | 47 ++ docs/tools/select-layout.md | 39 ++ docs/tools/select-pane.md | 42 ++ docs/tools/select-window.md | 39 ++ docs/tools/send-keys.md | 32 ++ docs/tools/sessions.md | 255 --------- docs/tools/set-environment.md | 31 ++ docs/tools/set-option.md | 32 ++ docs/tools/set-pane-title.md | 40 ++ docs/tools/show-environment.md | 32 ++ docs/tools/show-option.md | 30 ++ docs/tools/snapshot-pane.md | 47 ++ docs/tools/split-window.md | 41 ++ docs/tools/swap-pane.md | 41 ++ docs/tools/wait-for-content-change.md | 38 ++ docs/tools/wait-for-text.md | 42 ++ docs/tools/windows.md | 400 -------------- 45 files changed, 1461 insertions(+), 1541 deletions(-) create mode 100644 docs/tools/capture-pane.md create mode 100644 docs/tools/clear-pane.md create mode 100644 docs/tools/create-session.md create mode 100644 docs/tools/create-window.md create mode 100644 docs/tools/display-message.md create mode 100644 docs/tools/enter-copy-mode.md create mode 100644 docs/tools/exit-copy-mode.md create mode 100644 docs/tools/get-pane-info.md create mode 100644 docs/tools/get-server-info.md create mode 100644 docs/tools/kill-pane.md create mode 100644 docs/tools/kill-server.md create mode 100644 docs/tools/kill-session.md create mode 100644 docs/tools/kill-window.md create mode 100644 docs/tools/list-panes.md create mode 100644 docs/tools/list-sessions.md create mode 100644 docs/tools/list-windows.md create mode 100644 docs/tools/move-window.md delete mode 100644 docs/tools/options.md delete mode 100644 docs/tools/panes.md create mode 100644 docs/tools/paste-text.md create mode 100644 docs/tools/pipe-pane.md create mode 100644 docs/tools/rename-session.md create mode 100644 docs/tools/rename-window.md create mode 100644 docs/tools/resize-pane.md create mode 100644 docs/tools/resize-window.md create mode 100644 docs/tools/search-panes.md create mode 100644 docs/tools/select-layout.md create mode 100644 docs/tools/select-pane.md create mode 100644 docs/tools/select-window.md create mode 100644 docs/tools/send-keys.md delete mode 100644 docs/tools/sessions.md create mode 100644 docs/tools/set-environment.md create mode 100644 docs/tools/set-option.md create mode 100644 docs/tools/set-pane-title.md create mode 100644 docs/tools/show-environment.md create mode 100644 docs/tools/show-option.md create mode 100644 docs/tools/snapshot-pane.md create mode 100644 docs/tools/split-window.md create mode 100644 docs/tools/swap-pane.md create mode 100644 docs/tools/wait-for-content-change.md create mode 100644 docs/tools/wait-for-text.md delete mode 100644 docs/tools/windows.md diff --git a/docs/_ext/fastmcp_autodoc.py b/docs/_ext/fastmcp_autodoc.py index cf493ce..c6fddcb 100644 --- a/docs/_ext/fastmcp_autodoc.py +++ b/docs/_ext/fastmcp_autodoc.py @@ -1031,9 +1031,9 @@ def run(self) -> list[nodes.Node]: rows: list[list[str | nodes.Node]] = [] for tool in sorted(tier_tools, key=lambda t: t.name): first_line = _first_paragraph(tool.docstring) - # Link to the tool's section on its area page + section_id = tool.name.replace("_", "-") ref = nodes.reference("", "", internal=True) - ref["refuri"] = f"{tool.area}/#{tool.name.replace('_', '-')}" + ref["refuri"] = f"#{section_id}" ref += nodes.literal("", tool.name) rows.append( [ diff --git a/docs/redirects.txt b/docs/redirects.txt index 1baa24d..4ac1aee 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -12,3 +12,7 @@ "concepts" "topics/concepts" "safety" "topics/safety" "guides/troubleshooting" "topics/troubleshooting" +"tools/sessions" "tools/index" +"tools/windows" "tools/index" +"tools/panes" "tools/index" +"tools/options" "tools/index" diff --git a/docs/tools/capture-pane.md b/docs/tools/capture-pane.md new file mode 100644 index 0000000..2b949d3 --- /dev/null +++ b/docs/tools/capture-pane.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.capture_pane +``` + +**Use when** you need to read what's currently displayed in a terminal — +after running a command, checking output, or verifying state. + +**Avoid when** you need to search across multiple panes at once — use +{tooliconl}`search-panes`. If you only need pane metadata (not content), use +{tooliconl}`get-pane-info`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "capture_pane", + "arguments": { + "pane_id": "%0", + "start": -50 + } +} +``` + +Response (string): + +```text +$ echo "Running tests..." +Running tests... +$ echo "PASS: test_auth (0.3s)" +PASS: test_auth (0.3s) +$ echo "FAIL: test_upload (AssertionError)" +FAIL: test_upload (AssertionError) +$ echo "3 tests: 2 passed, 1 failed" +3 tests: 2 passed, 1 failed +$ +``` + +```{fastmcp-tool-input} pane_tools.capture_pane +``` diff --git a/docs/tools/clear-pane.md b/docs/tools/clear-pane.md new file mode 100644 index 0000000..f670544 --- /dev/null +++ b/docs/tools/clear-pane.md @@ -0,0 +1,26 @@ +```{fastmcp-tool} pane_tools.clear_pane +``` + +**Use when** you want a clean terminal before capturing output. + +**Side effects:** Clears the pane's visible content. + +**Example:** + +```json +{ + "tool": "clear_pane", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +Pane cleared: %0 +``` + +```{fastmcp-tool-input} pane_tools.clear_pane +``` diff --git a/docs/tools/create-session.md b/docs/tools/create-session.md new file mode 100644 index 0000000..fdc1a49 --- /dev/null +++ b/docs/tools/create-session.md @@ -0,0 +1,36 @@ +```{fastmcp-tool} server_tools.create_session +``` + +**Use when** you need a new isolated workspace. Sessions are the top-level +container — create one before creating windows or panes. + +**Avoid when** a session with the target name already exists — check with +{tooliconl}`list-sessions` first, or the command will fail. + +**Side effects:** Creates a new tmux session with one window and one pane. + +**Example:** + +```json +{ + "tool": "create_session", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +{ + "session_id": "$1", + "session_name": "dev", + "window_count": 1, + "session_attached": "0", + "session_created": "1774521872" +} +``` + +```{fastmcp-tool-input} server_tools.create_session +``` diff --git a/docs/tools/create-window.md b/docs/tools/create-window.md new file mode 100644 index 0000000..db6602a --- /dev/null +++ b/docs/tools/create-window.md @@ -0,0 +1,38 @@ +```{fastmcp-tool} session_tools.create_window +``` + +**Use when** you need a new terminal workspace within an existing session. + +**Side effects:** Creates a new window. Attaches to it if `attach` is true. + +**Example:** + +```json +{ + "tool": "create_window", + "arguments": { + "session_name": "dev", + "window_name": "logs" + } +} +``` + +Response: + +```json +{ + "window_id": "@2", + "window_name": "logs", + "window_index": "3", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,5", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} session_tools.create_window +``` diff --git a/docs/tools/display-message.md b/docs/tools/display-message.md new file mode 100644 index 0000000..1c7c0f6 --- /dev/null +++ b/docs/tools/display-message.md @@ -0,0 +1,33 @@ +```{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 +``` diff --git a/docs/tools/enter-copy-mode.md b/docs/tools/enter-copy-mode.md new file mode 100644 index 0000000..a720e35 --- /dev/null +++ b/docs/tools/enter-copy-mode.md @@ -0,0 +1,44 @@ +```{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 +``` diff --git a/docs/tools/exit-copy-mode.md b/docs/tools/exit-copy-mode.md new file mode 100644 index 0000000..174dcd2 --- /dev/null +++ b/docs/tools/exit-copy-mode.md @@ -0,0 +1,40 @@ +```{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 +``` diff --git a/docs/tools/get-pane-info.md b/docs/tools/get-pane-info.md new file mode 100644 index 0000000..85ce43b --- /dev/null +++ b/docs/tools/get-pane-info.md @@ -0,0 +1,42 @@ +```{fastmcp-tool} pane_tools.get_pane_info +``` + +**Use when** you need pane dimensions, PID, current working directory, or +other metadata without reading the terminal content. + +**Avoid when** you need the actual text — use {tooliconl}`capture-pane`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "get_pane_info", + "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.get_pane_info +``` diff --git a/docs/tools/get-server-info.md b/docs/tools/get-server-info.md new file mode 100644 index 0000000..2e1727d --- /dev/null +++ b/docs/tools/get-server-info.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} server_tools.get_server_info +``` + +**Use when** you need to verify the tmux server is running, check its PID, +or inspect server-level state before creating sessions. + +**Avoid when** you only need session names — use {tooliconl}`list-sessions`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "get_server_info", + "arguments": {} +} +``` + +Response: + +```json +{ + "is_alive": true, + "socket_name": null, + "socket_path": null, + "session_count": 2, + "version": "3.6a" +} +``` + +```{fastmcp-tool-input} server_tools.get_server_info +``` diff --git a/docs/tools/index.md b/docs/tools/index.md index 0a8e29f..8c3e1ec 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -300,8 +300,42 @@ Kill the entire tmux server. ```{toctree} :hidden: -sessions -windows -panes -options +capture-pane +clear-pane +create-session +create-window +display-message +enter-copy-mode +exit-copy-mode +get-pane-info +get-server-info +kill-pane +kill-server +kill-session +kill-window +list-panes +list-sessions +list-windows +move-window +paste-text +pipe-pane +rename-session +rename-window +resize-pane +resize-window +search-panes +select-layout +select-pane +select-window +send-keys +set-environment +set-option +set-pane-title +show-environment +show-option +snapshot-pane +split-window +swap-pane +wait-for-content-change +wait-for-text ``` diff --git a/docs/tools/kill-pane.md b/docs/tools/kill-pane.md new file mode 100644 index 0000000..7f6158b --- /dev/null +++ b/docs/tools/kill-pane.md @@ -0,0 +1,29 @@ +```{fastmcp-tool} pane_tools.kill_pane +``` + +**Use when** you're done with a specific terminal and want to remove it +without affecting sibling panes. + +**Avoid when** you want to remove the entire window — use {tooliconl}`kill-window`. + +**Side effects:** Destroys the pane. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_pane", + "arguments": { + "pane_id": "%1" + } +} +``` + +Response (string): + +```text +Pane killed: %1 +``` + +```{fastmcp-tool-input} pane_tools.kill_pane +``` diff --git a/docs/tools/kill-server.md b/docs/tools/kill-server.md new file mode 100644 index 0000000..c4a0194 --- /dev/null +++ b/docs/tools/kill-server.md @@ -0,0 +1,27 @@ +```{fastmcp-tool} server_tools.kill_server +``` + +**Use when** you need to tear down the entire tmux server. This kills every +session, window, and pane. + +**Avoid when** you only need to remove one session — use {tooliconl}`kill-session`. + +**Side effects:** Destroys everything. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_server", + "arguments": {} +} +``` + +Response (string): + +```text +Server killed successfully +``` + +```{fastmcp-tool-input} server_tools.kill_server +``` diff --git a/docs/tools/kill-session.md b/docs/tools/kill-session.md new file mode 100644 index 0000000..9f27f1e --- /dev/null +++ b/docs/tools/kill-session.md @@ -0,0 +1,29 @@ +```{fastmcp-tool} session_tools.kill_session +``` + +**Use when** you're done with a workspace and want to clean up. Kills all +windows and panes in the session. + +**Avoid when** you only want to close one window — use {tooliconl}`kill-window`. + +**Side effects:** Destroys the session and all its contents. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_session", + "arguments": { + "session_name": "old-workspace" + } +} +``` + +Response (string): + +```text +Session killed: old-workspace +``` + +```{fastmcp-tool-input} session_tools.kill_session +``` diff --git a/docs/tools/kill-window.md b/docs/tools/kill-window.md new file mode 100644 index 0000000..1f84c80 --- /dev/null +++ b/docs/tools/kill-window.md @@ -0,0 +1,28 @@ +```{fastmcp-tool} window_tools.kill_window +``` + +**Use when** you're done with a window and all its panes. + +**Avoid when** you only want to remove one pane — use {tooliconl}`kill-pane`. + +**Side effects:** Destroys the window and all its panes. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_window", + "arguments": { + "window_id": "@1" + } +} +``` + +Response (string): + +```text +Window killed: @1 +``` + +```{fastmcp-tool-input} window_tools.kill_window +``` diff --git a/docs/tools/list-panes.md b/docs/tools/list-panes.md new file mode 100644 index 0000000..a093ed4 --- /dev/null +++ b/docs/tools/list-panes.md @@ -0,0 +1,56 @@ +```{fastmcp-tool} window_tools.list_panes +``` + +**Use when** you need to discover which panes exist in a window before +sending keys or capturing output. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_panes", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "15", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null + }, + { + "pane_id": "%1", + "pane_index": "1", + "pane_width": "80", + "pane_height": "8", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12400", + "pane_title": "", + "pane_active": "0", + "window_id": "@0", + "session_id": "$0", + "is_caller": null + } +] +``` + +```{fastmcp-tool-input} window_tools.list_panes +``` diff --git a/docs/tools/list-sessions.md b/docs/tools/list-sessions.md new file mode 100644 index 0000000..adca66e --- /dev/null +++ b/docs/tools/list-sessions.md @@ -0,0 +1,36 @@ +```{fastmcp-tool} server_tools.list_sessions +``` + +**Use when** you need session names, IDs, or attached status before deciding +which session to target. + +**Avoid when** you need window or pane details — use {tooliconl}`list-windows` or +{tooliconl}`list-panes` instead. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_sessions", + "arguments": {} +} +``` + +Response: + +```json +[ + { + "session_id": "$0", + "session_name": "myproject", + "window_count": 2, + "session_attached": "0", + "session_created": "1774521871" + } +] +``` + +```{fastmcp-tool-input} server_tools.list_sessions +``` diff --git a/docs/tools/list-windows.md b/docs/tools/list-windows.md new file mode 100644 index 0000000..240931b --- /dev/null +++ b/docs/tools/list-windows.md @@ -0,0 +1,54 @@ +```{fastmcp-tool} session_tools.list_windows +``` + +**Use when** you need window names, indices, or layout metadata within a +session before selecting a window to work with. + +**Avoid when** you need pane-level detail — use {tooliconl}`list-panes`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_windows", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "c195,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" + }, + { + "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": "0", + "window_width": "80", + "window_height": "24" + } +] +``` + +```{fastmcp-tool-input} session_tools.list_windows +``` diff --git a/docs/tools/move-window.md b/docs/tools/move-window.md new file mode 100644 index 0000000..db687e2 --- /dev/null +++ b/docs/tools/move-window.md @@ -0,0 +1,39 @@ +```{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 +``` diff --git a/docs/tools/options.md b/docs/tools/options.md deleted file mode 100644 index 5c3c1b8..0000000 --- a/docs/tools/options.md +++ /dev/null @@ -1,138 +0,0 @@ -# Options & Environment - -## Inspect - -```{fastmcp-tool} option_tools.show_option -``` - -**Use when** you need to check a tmux configuration value — buffer limits, -history size, status bar settings, etc. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "show_option", - "arguments": { - "option": "history-limit" - } -} -``` - -Response: - -```json -{ - "option": "history-limit", - "value": "2000" -} -``` - -```{fastmcp-tool-input} option_tools.show_option -``` - ---- - -```{fastmcp-tool} env_tools.show_environment -``` - -**Use when** you need to inspect tmux environment variables. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "show_environment", - "arguments": {} -} -``` - -Response: - -```json -{ - "variables": { - "SHELL": "/bin/zsh", - "TERM": "xterm-256color", - "HOME": "/home/user", - "USER": "user", - "LANG": "C.UTF-8" - } -} -``` - -```{fastmcp-tool-input} env_tools.show_environment -``` - -## Act - -```{fastmcp-tool} option_tools.set_option -``` - -**Use when** you need to change tmux behavior — adjusting history limits, -enabling mouse support, changing status bar format. - -**Side effects:** Changes the tmux option value. - -**Example:** - -```json -{ - "tool": "set_option", - "arguments": { - "option": "history-limit", - "value": "50000" - } -} -``` - -Response: - -```json -{ - "option": "history-limit", - "value": "50000", - "status": "set" -} -``` - -```{fastmcp-tool-input} option_tools.set_option -``` - ---- - -```{fastmcp-tool} env_tools.set_environment -``` - -**Use when** you need to set a tmux environment variable. - -**Side effects:** Sets the variable in the tmux server. - -**Example:** - -```json -{ - "tool": "set_environment", - "arguments": { - "name": "MY_VAR", - "value": "hello" - } -} -``` - -Response: - -```json -{ - "name": "MY_VAR", - "value": "hello", - "status": "set" -} -``` - -```{fastmcp-tool-input} env_tools.set_environment -``` diff --git a/docs/tools/panes.md b/docs/tools/panes.md deleted file mode 100644 index 73b3202..0000000 --- a/docs/tools/panes.md +++ /dev/null @@ -1,742 +0,0 @@ -# Panes - -## Inspect - -```{fastmcp-tool} pane_tools.capture_pane -``` - -**Use when** you need to read what's currently displayed in a terminal — -after running a command, checking output, or verifying state. - -**Avoid when** you need to search across multiple panes at once — use -{tooliconl}`search-panes`. If you only need pane metadata (not content), use -{tooliconl}`get-pane-info`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "capture_pane", - "arguments": { - "pane_id": "%0", - "start": -50 - } -} -``` - -Response (string): - -```text -$ echo "Running tests..." -Running tests... -$ echo "PASS: test_auth (0.3s)" -PASS: test_auth (0.3s) -$ echo "FAIL: test_upload (AssertionError)" -FAIL: test_upload (AssertionError) -$ echo "3 tests: 2 passed, 1 failed" -3 tests: 2 passed, 1 failed -$ -``` - -```{fastmcp-tool-input} pane_tools.capture_pane -``` - ---- - -```{fastmcp-tool} pane_tools.get_pane_info -``` - -**Use when** you need pane dimensions, PID, current working directory, or -other metadata without reading the terminal content. - -**Avoid when** you need the actual text — use {tooliconl}`capture-pane`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "get_pane_info", - "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.get_pane_info -``` - ---- - -```{fastmcp-tool} pane_tools.search_panes -``` - -**Use when** you need to find specific text across multiple panes — locating -which pane has an error, finding a running process, or checking output -without knowing which pane to look in. - -**Avoid when** you already know the target pane — use {tooliconl}`capture-pane` -directly. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "search_panes", - "arguments": { - "pattern": "FAIL", - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "pane_id": "%0", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "window_id": "@0", - "window_name": "editor", - "session_id": "$0", - "session_name": "dev", - "matched_lines": [ - "FAIL: test_upload (AssertionError)", - "3 tests: 2 passed, 1 failed" - ], - "is_caller": null - } -] -``` - -```{fastmcp-tool-input} pane_tools.search_panes -``` - ---- - -```{fastmcp-tool} pane_tools.wait_for_text -``` - -**Use when** you need to block until specific output appears — waiting for a -server to start, a build to complete, or a prompt to return. - -**Avoid when** the expected text may never appear — always set a reasonable -`timeout`. For known output, {tooliconl}`capture-pane` after a known delay -may suffice, but `wait_for_text` is preferred because it adapts to variable -timing. - -**Side effects:** None. Readonly. Blocks until text appears or timeout. - -**Example:** - -```json -{ - "tool": "wait_for_text", - "arguments": { - "pattern": "Server listening", - "pane_id": "%2", - "timeout": 30 - } -} -``` - -Response: - -```json -{ - "found": true, - "matched_lines": [ - "Server listening on port 8000" - ], - "pane_id": "%2", - "elapsed_seconds": 0.002, - "timed_out": false -} -``` - -```{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 -``` - -**Use when** you need to type commands, press keys, or interact with a -terminal. This is the primary way to execute commands in tmux panes. - -**Avoid when** you need to run something and immediately capture the result — -send keys first, then use {tooliconl}`capture-pane` or {tooliconl}`wait-for-text`. - -**Side effects:** Sends keystrokes to the pane. If `enter` is true (default), -the command executes. - -**Example:** - -```json -{ - "tool": "send_keys", - "arguments": { - "keys": "npm start", - "pane_id": "%2" - } -} -``` - -Response (string): - -```text -Keys sent to pane %2 -``` - -```{fastmcp-tool-input} pane_tools.send_keys -``` - ---- - -```{fastmcp-tool} pane_tools.set_pane_title -``` - -**Use when** you want to label a pane for identification. - -**Side effects:** Changes the pane title. - -**Example:** - -```json -{ - "tool": "set_pane_title", - "arguments": { - "pane_id": "%0", - "title": "build" - } -} -``` - -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": "build", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.set_pane_title -``` - ---- - -```{fastmcp-tool} pane_tools.clear_pane -``` - -**Use when** you want a clean terminal before capturing output. - -**Side effects:** Clears the pane's visible content. - -**Example:** - -```json -{ - "tool": "clear_pane", - "arguments": { - "pane_id": "%0" - } -} -``` - -Response (string): - -```text -Pane cleared: %0 -``` - -```{fastmcp-tool-input} pane_tools.clear_pane -``` - ---- - -```{fastmcp-tool} pane_tools.resize_pane -``` - -**Use when** you need to adjust pane dimensions. - -**Side effects:** Changes pane size. May affect adjacent panes. - -**Example:** - -```json -{ - "tool": "resize_pane", - "arguments": { - "pane_id": "%0", - "height": 15 - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "15", - "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.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 -``` - -**Use when** you're done with a specific terminal and want to remove it -without affecting sibling panes. - -**Avoid when** you want to remove the entire window — use {tooliconl}`kill-window`. - -**Side effects:** Destroys the pane. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_pane", - "arguments": { - "pane_id": "%1" - } -} -``` - -Response (string): - -```text -Pane killed: %1 -``` - -```{fastmcp-tool-input} pane_tools.kill_pane -``` diff --git a/docs/tools/paste-text.md b/docs/tools/paste-text.md new file mode 100644 index 0000000..aa974bc --- /dev/null +++ b/docs/tools/paste-text.md @@ -0,0 +1,31 @@ +```{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 +``` diff --git a/docs/tools/pipe-pane.md b/docs/tools/pipe-pane.md new file mode 100644 index 0000000..48f4261 --- /dev/null +++ b/docs/tools/pipe-pane.md @@ -0,0 +1,33 @@ +```{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 +``` diff --git a/docs/tools/rename-session.md b/docs/tools/rename-session.md new file mode 100644 index 0000000..b335a39 --- /dev/null +++ b/docs/tools/rename-session.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} session_tools.rename_session +``` + +**Use when** a session name no longer reflects its purpose. + +**Side effects:** Renames the session. Existing references by old name will break. + +**Example:** + +```json +{ + "tool": "rename_session", + "arguments": { + "session_name": "old-name", + "new_name": "new-name" + } +} +``` + +Response: + +```json +{ + "session_id": "$0", + "session_name": "new-name", + "window_count": 2, + "session_attached": "0", + "session_created": "1774521871" +} +``` + +```{fastmcp-tool-input} session_tools.rename_session +``` diff --git a/docs/tools/rename-window.md b/docs/tools/rename-window.md new file mode 100644 index 0000000..c2c8c6d --- /dev/null +++ b/docs/tools/rename-window.md @@ -0,0 +1,38 @@ +```{fastmcp-tool} window_tools.rename_window +``` + +**Use when** a window name no longer reflects its purpose. + +**Side effects:** Renames the window. + +**Example:** + +```json +{ + "tool": "rename_window", + "arguments": { + "session_name": "dev", + "new_name": "build" + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "build", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "7f9f,80x24,0,0[80x15,0,0,0,80x8,0,16,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.rename_window +``` diff --git a/docs/tools/resize-pane.md b/docs/tools/resize-pane.md new file mode 100644 index 0000000..2cc7fcd --- /dev/null +++ b/docs/tools/resize-pane.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.resize_pane +``` + +**Use when** you need to adjust pane dimensions. + +**Side effects:** Changes pane size. May affect adjacent panes. + +**Example:** + +```json +{ + "tool": "resize_pane", + "arguments": { + "pane_id": "%0", + "height": 15 + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "15", + "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.resize_pane +``` diff --git a/docs/tools/resize-window.md b/docs/tools/resize-window.md new file mode 100644 index 0000000..b59ca70 --- /dev/null +++ b/docs/tools/resize-window.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} window_tools.resize_window +``` + +**Use when** you need to adjust the window dimensions. + +**Side effects:** Changes window size. + +**Example:** + +```json +{ + "tool": "resize_window", + "arguments": { + "session_name": "dev", + "width": 120, + "height": 40 + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "baaa,120x40,0,0[120x20,0,0,0,120x19,0,21,1]", + "window_active": "1", + "window_width": "120", + "window_height": "40" +} +``` + +```{fastmcp-tool-input} window_tools.resize_window +``` diff --git a/docs/tools/search-panes.md b/docs/tools/search-panes.md new file mode 100644 index 0000000..6dd8f16 --- /dev/null +++ b/docs/tools/search-panes.md @@ -0,0 +1,47 @@ +```{fastmcp-tool} pane_tools.search_panes +``` + +**Use when** you need to find specific text across multiple panes — locating +which pane has an error, finding a running process, or checking output +without knowing which pane to look in. + +**Avoid when** you already know the target pane — use {tooliconl}`capture-pane` +directly. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "search_panes", + "arguments": { + "pattern": "FAIL", + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "pane_id": "%0", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "window_id": "@0", + "window_name": "editor", + "session_id": "$0", + "session_name": "dev", + "matched_lines": [ + "FAIL: test_upload (AssertionError)", + "3 tests: 2 passed, 1 failed" + ], + "is_caller": null + } +] +``` + +```{fastmcp-tool-input} pane_tools.search_panes +``` diff --git a/docs/tools/select-layout.md b/docs/tools/select-layout.md new file mode 100644 index 0000000..8a2146f --- /dev/null +++ b/docs/tools/select-layout.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} window_tools.select_layout +``` + +**Use when** you want to rearrange panes — `even-horizontal`, +`even-vertical`, `main-horizontal`, `main-vertical`, or `tiled`. + +**Side effects:** Rearranges all panes in the window. + +**Example:** + +```json +{ + "tool": "select_layout", + "arguments": { + "session_name": "dev", + "layout": "even-vertical" + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "even-vertical,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.select_layout +``` diff --git a/docs/tools/select-pane.md b/docs/tools/select-pane.md new file mode 100644 index 0000000..7495aa6 --- /dev/null +++ b/docs/tools/select-pane.md @@ -0,0 +1,42 @@ +```{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 +``` diff --git a/docs/tools/select-window.md b/docs/tools/select-window.md new file mode 100644 index 0000000..0ebe289 --- /dev/null +++ b/docs/tools/select-window.md @@ -0,0 +1,39 @@ +```{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 +``` diff --git a/docs/tools/send-keys.md b/docs/tools/send-keys.md new file mode 100644 index 0000000..4bdaa75 --- /dev/null +++ b/docs/tools/send-keys.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} pane_tools.send_keys +``` + +**Use when** you need to type commands, press keys, or interact with a +terminal. This is the primary way to execute commands in tmux panes. + +**Avoid when** you need to run something and immediately capture the result — +send keys first, then use {tooliconl}`capture-pane` or {tooliconl}`wait-for-text`. + +**Side effects:** Sends keystrokes to the pane. If `enter` is true (default), +the command executes. + +**Example:** + +```json +{ + "tool": "send_keys", + "arguments": { + "keys": "npm start", + "pane_id": "%2" + } +} +``` + +Response (string): + +```text +Keys sent to pane %2 +``` + +```{fastmcp-tool-input} pane_tools.send_keys +``` diff --git a/docs/tools/sessions.md b/docs/tools/sessions.md deleted file mode 100644 index 984f042..0000000 --- a/docs/tools/sessions.md +++ /dev/null @@ -1,255 +0,0 @@ -# Sessions - -## Inspect - -```{fastmcp-tool} server_tools.list_sessions -``` - -**Use when** you need session names, IDs, or attached status before deciding -which session to target. - -**Avoid when** you need window or pane details — use {tooliconl}`list-windows` or -{tooliconl}`list-panes` instead. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_sessions", - "arguments": {} -} -``` - -Response: - -```json -[ - { - "session_id": "$0", - "session_name": "myproject", - "window_count": 2, - "session_attached": "0", - "session_created": "1774521871" - } -] -``` - -```{fastmcp-tool-input} server_tools.list_sessions -``` - ---- - -```{fastmcp-tool} server_tools.get_server_info -``` - -**Use when** you need to verify the tmux server is running, check its PID, -or inspect server-level state before creating sessions. - -**Avoid when** you only need session names — use {tooliconl}`list-sessions`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "get_server_info", - "arguments": {} -} -``` - -Response: - -```json -{ - "is_alive": true, - "socket_name": null, - "socket_path": null, - "session_count": 2, - "version": "3.6a" -} -``` - -```{fastmcp-tool-input} server_tools.get_server_info -``` - -## Act - -```{fastmcp-tool} server_tools.create_session -``` - -**Use when** you need a new isolated workspace. Sessions are the top-level -container — create one before creating windows or panes. - -**Avoid when** a session with the target name already exists — check with -{tooliconl}`list-sessions` first, or the command will fail. - -**Side effects:** Creates a new tmux session with one window and one pane. - -**Example:** - -```json -{ - "tool": "create_session", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -{ - "session_id": "$1", - "session_name": "dev", - "window_count": 1, - "session_attached": "0", - "session_created": "1774521872" -} -``` - -```{fastmcp-tool-input} server_tools.create_session -``` - ---- - -```{fastmcp-tool} session_tools.rename_session -``` - -**Use when** a session name no longer reflects its purpose. - -**Side effects:** Renames the session. Existing references by old name will break. - -**Example:** - -```json -{ - "tool": "rename_session", - "arguments": { - "session_name": "old-name", - "new_name": "new-name" - } -} -``` - -Response: - -```json -{ - "session_id": "$0", - "session_name": "new-name", - "window_count": 2, - "session_attached": "0", - "session_created": "1774521871" -} -``` - -```{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 -``` - -**Use when** you're done with a workspace and want to clean up. Kills all -windows and panes in the session. - -**Avoid when** you only want to close one window — use {tooliconl}`kill-window`. - -**Side effects:** Destroys the session and all its contents. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_session", - "arguments": { - "session_name": "old-workspace" - } -} -``` - -Response (string): - -```text -Session killed: old-workspace -``` - -```{fastmcp-tool-input} session_tools.kill_session -``` - ---- - -```{fastmcp-tool} server_tools.kill_server -``` - -**Use when** you need to tear down the entire tmux server. This kills every -session, window, and pane. - -**Avoid when** you only need to remove one session — use {tooliconl}`kill-session`. - -**Side effects:** Destroys everything. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_server", - "arguments": {} -} -``` - -Response (string): - -```text -Server killed successfully -``` - -```{fastmcp-tool-input} server_tools.kill_server -``` diff --git a/docs/tools/set-environment.md b/docs/tools/set-environment.md new file mode 100644 index 0000000..4d50c8a --- /dev/null +++ b/docs/tools/set-environment.md @@ -0,0 +1,31 @@ +```{fastmcp-tool} env_tools.set_environment +``` + +**Use when** you need to set a tmux environment variable. + +**Side effects:** Sets the variable in the tmux server. + +**Example:** + +```json +{ + "tool": "set_environment", + "arguments": { + "name": "MY_VAR", + "value": "hello" + } +} +``` + +Response: + +```json +{ + "name": "MY_VAR", + "value": "hello", + "status": "set" +} +``` + +```{fastmcp-tool-input} env_tools.set_environment +``` diff --git a/docs/tools/set-option.md b/docs/tools/set-option.md new file mode 100644 index 0000000..a27d5be --- /dev/null +++ b/docs/tools/set-option.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} option_tools.set_option +``` + +**Use when** you need to change tmux behavior — adjusting history limits, +enabling mouse support, changing status bar format. + +**Side effects:** Changes the tmux option value. + +**Example:** + +```json +{ + "tool": "set_option", + "arguments": { + "option": "history-limit", + "value": "50000" + } +} +``` + +Response: + +```json +{ + "option": "history-limit", + "value": "50000", + "status": "set" +} +``` + +```{fastmcp-tool-input} option_tools.set_option +``` diff --git a/docs/tools/set-pane-title.md b/docs/tools/set-pane-title.md new file mode 100644 index 0000000..65ec2d8 --- /dev/null +++ b/docs/tools/set-pane-title.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.set_pane_title +``` + +**Use when** you want to label a pane for identification. + +**Side effects:** Changes the pane title. + +**Example:** + +```json +{ + "tool": "set_pane_title", + "arguments": { + "pane_id": "%0", + "title": "build" + } +} +``` + +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": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.set_pane_title +``` diff --git a/docs/tools/show-environment.md b/docs/tools/show-environment.md new file mode 100644 index 0000000..66171ce --- /dev/null +++ b/docs/tools/show-environment.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} env_tools.show_environment +``` + +**Use when** you need to inspect tmux environment variables. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "show_environment", + "arguments": {} +} +``` + +Response: + +```json +{ + "variables": { + "SHELL": "/bin/zsh", + "TERM": "xterm-256color", + "HOME": "/home/user", + "USER": "user", + "LANG": "C.UTF-8" + } +} +``` + +```{fastmcp-tool-input} env_tools.show_environment +``` diff --git a/docs/tools/show-option.md b/docs/tools/show-option.md new file mode 100644 index 0000000..4aa597a --- /dev/null +++ b/docs/tools/show-option.md @@ -0,0 +1,30 @@ +```{fastmcp-tool} option_tools.show_option +``` + +**Use when** you need to check a tmux configuration value — buffer limits, +history size, status bar settings, etc. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "show_option", + "arguments": { + "option": "history-limit" + } +} +``` + +Response: + +```json +{ + "option": "history-limit", + "value": "2000" +} +``` + +```{fastmcp-tool-input} option_tools.show_option +``` diff --git a/docs/tools/snapshot-pane.md b/docs/tools/snapshot-pane.md new file mode 100644 index 0000000..3ee2747 --- /dev/null +++ b/docs/tools/snapshot-pane.md @@ -0,0 +1,47 @@ +```{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 +``` diff --git a/docs/tools/split-window.md b/docs/tools/split-window.md new file mode 100644 index 0000000..07acdec --- /dev/null +++ b/docs/tools/split-window.md @@ -0,0 +1,41 @@ +```{fastmcp-tool} window_tools.split_window +``` + +**Use when** you need side-by-side or stacked terminals within the same +window. + +**Side effects:** Creates a new pane by splitting an existing one. + +**Example:** + +```json +{ + "tool": "split_window", + "arguments": { + "session_name": "dev", + "direction": "right" + } +} +``` + +Response: + +```json +{ + "pane_id": "%4", + "pane_index": "1", + "pane_width": "39", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "3732", + "pane_title": "", + "pane_active": "0", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} window_tools.split_window +``` diff --git a/docs/tools/swap-pane.md b/docs/tools/swap-pane.md new file mode 100644 index 0000000..d179952 --- /dev/null +++ b/docs/tools/swap-pane.md @@ -0,0 +1,41 @@ +```{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 +``` diff --git a/docs/tools/wait-for-content-change.md b/docs/tools/wait-for-content-change.md new file mode 100644 index 0000000..da84111 --- /dev/null +++ b/docs/tools/wait-for-content-change.md @@ -0,0 +1,38 @@ +```{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 +``` diff --git a/docs/tools/wait-for-text.md b/docs/tools/wait-for-text.md new file mode 100644 index 0000000..91aaaf6 --- /dev/null +++ b/docs/tools/wait-for-text.md @@ -0,0 +1,42 @@ +```{fastmcp-tool} pane_tools.wait_for_text +``` + +**Use when** you need to block until specific output appears — waiting for a +server to start, a build to complete, or a prompt to return. + +**Avoid when** the expected text may never appear — always set a reasonable +`timeout`. For known output, {tooliconl}`capture-pane` after a known delay +may suffice, but `wait_for_text` is preferred because it adapts to variable +timing. + +**Side effects:** None. Readonly. Blocks until text appears or timeout. + +**Example:** + +```json +{ + "tool": "wait_for_text", + "arguments": { + "pattern": "Server listening", + "pane_id": "%2", + "timeout": 30 + } +} +``` + +Response: + +```json +{ + "found": true, + "matched_lines": [ + "Server listening on port 8000" + ], + "pane_id": "%2", + "elapsed_seconds": 0.002, + "timed_out": false +} +``` + +```{fastmcp-tool-input} pane_tools.wait_for_text +``` diff --git a/docs/tools/windows.md b/docs/tools/windows.md deleted file mode 100644 index 91684c0..0000000 --- a/docs/tools/windows.md +++ /dev/null @@ -1,400 +0,0 @@ -# Windows - -## Inspect - -```{fastmcp-tool} session_tools.list_windows -``` - -**Use when** you need window names, indices, or layout metadata within a -session before selecting a window to work with. - -**Avoid when** you need pane-level detail — use {tooliconl}`list-panes`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_windows", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "c195,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" - }, - { - "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": "0", - "window_width": "80", - "window_height": "24" - } -] -``` - -```{fastmcp-tool-input} session_tools.list_windows -``` - ---- - -```{fastmcp-tool} window_tools.list_panes -``` - -**Use when** you need to discover which panes exist in a window before -sending keys or capturing output. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_panes", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "15", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "build", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null - }, - { - "pane_id": "%1", - "pane_index": "1", - "pane_width": "80", - "pane_height": "8", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12400", - "pane_title": "", - "pane_active": "0", - "window_id": "@0", - "session_id": "$0", - "is_caller": null - } -] -``` - -```{fastmcp-tool-input} window_tools.list_panes -``` - -## Act - -```{fastmcp-tool} session_tools.create_window -``` - -**Use when** you need a new terminal workspace within an existing session. - -**Side effects:** Creates a new window. Attaches to it if `attach` is true. - -**Example:** - -```json -{ - "tool": "create_window", - "arguments": { - "session_name": "dev", - "window_name": "logs" - } -} -``` - -Response: - -```json -{ - "window_id": "@2", - "window_name": "logs", - "window_index": "3", - "session_id": "$0", - "session_name": "dev", - "pane_count": 1, - "window_layout": "b25f,80x24,0,0,5", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} session_tools.create_window -``` - ---- - -```{fastmcp-tool} window_tools.split_window -``` - -**Use when** you need side-by-side or stacked terminals within the same -window. - -**Side effects:** Creates a new pane by splitting an existing one. - -**Example:** - -```json -{ - "tool": "split_window", - "arguments": { - "session_name": "dev", - "direction": "right" - } -} -``` - -Response: - -```json -{ - "pane_id": "%4", - "pane_index": "1", - "pane_width": "39", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "3732", - "pane_title": "", - "pane_active": "0", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} window_tools.split_window -``` - ---- - -```{fastmcp-tool} window_tools.rename_window -``` - -**Use when** a window name no longer reflects its purpose. - -**Side effects:** Renames the window. - -**Example:** - -```json -{ - "tool": "rename_window", - "arguments": { - "session_name": "dev", - "new_name": "build" - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "build", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "7f9f,80x24,0,0[80x15,0,0,0,80x8,0,16,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} window_tools.rename_window -``` - ---- - -```{fastmcp-tool} window_tools.select_layout -``` - -**Use when** you want to rearrange panes — `even-horizontal`, -`even-vertical`, `main-horizontal`, `main-vertical`, or `tiled`. - -**Side effects:** Rearranges all panes in the window. - -**Example:** - -```json -{ - "tool": "select_layout", - "arguments": { - "session_name": "dev", - "layout": "even-vertical" - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "even-vertical,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} window_tools.select_layout -``` - ---- - -```{fastmcp-tool} window_tools.resize_window -``` - -**Use when** you need to adjust the window dimensions. - -**Side effects:** Changes window size. - -**Example:** - -```json -{ - "tool": "resize_window", - "arguments": { - "session_name": "dev", - "width": 120, - "height": 40 - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "baaa,120x40,0,0[120x20,0,0,0,120x19,0,21,1]", - "window_active": "1", - "window_width": "120", - "window_height": "40" -} -``` - -```{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 -``` - -**Use when** you're done with a window and all its panes. - -**Avoid when** you only want to remove one pane — use {tooliconl}`kill-pane`. - -**Side effects:** Destroys the window and all its panes. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_window", - "arguments": { - "window_id": "@1" - } -} -``` - -Response (string): - -```text -Window killed: @1 -``` - -```{fastmcp-tool-input} window_tools.kill_window -``` From 84c2cfd11453b830509f0939b5b14d41c8ca110a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 18:37:05 -0500 Subject: [PATCH 5/6] docs(tools): Organize tool pages by tmux hierarchy Move tool pages from flat docs/tools/.md to hierarchy-based subdirectories: server/, session/, window/, pane/. - server/ (8): list-sessions, get-server-info, create-session, kill-server, show-option, set-option, show-environment, set-environment - session/ (5): list-windows, create-window, rename-session, select-window, kill-session - window/ (7): list-panes, split-window, rename-window, select-layout, resize-window, move-window, kill-window - pane/ (18): all pane interaction tools Update toctree paths to match new directory structure. --- docs/redirects.txt | 38 +++++ docs/tools/index.md | 42 +---- docs/tools/{ => pane}/capture-pane.md | 0 docs/tools/{ => pane}/clear-pane.md | 0 docs/tools/{ => pane}/display-message.md | 0 docs/tools/{ => pane}/enter-copy-mode.md | 0 docs/tools/{ => pane}/exit-copy-mode.md | 0 docs/tools/{ => pane}/get-pane-info.md | 0 docs/tools/pane/index.md | 155 ++++++++++++++++++ docs/tools/{ => pane}/kill-pane.md | 0 docs/tools/{ => pane}/paste-text.md | 0 docs/tools/{ => pane}/pipe-pane.md | 0 docs/tools/{ => pane}/resize-pane.md | 0 docs/tools/{ => pane}/search-panes.md | 0 docs/tools/{ => pane}/select-pane.md | 0 docs/tools/{ => pane}/send-keys.md | 0 docs/tools/{ => pane}/set-pane-title.md | 0 docs/tools/{ => pane}/snapshot-pane.md | 0 docs/tools/{ => pane}/swap-pane.md | 0 .../{ => pane}/wait-for-content-change.md | 0 docs/tools/{ => pane}/wait-for-text.md | 0 docs/tools/{ => server}/create-session.md | 0 docs/tools/{ => server}/get-server-info.md | 0 docs/tools/server/index.md | 69 ++++++++ docs/tools/{ => server}/kill-server.md | 0 docs/tools/{ => server}/list-sessions.md | 0 docs/tools/{ => server}/set-environment.md | 0 docs/tools/{ => server}/set-option.md | 0 docs/tools/{ => server}/show-environment.md | 0 docs/tools/{ => server}/show-option.md | 0 docs/tools/{ => session}/create-window.md | 0 docs/tools/session/index.md | 48 ++++++ docs/tools/{ => session}/kill-session.md | 0 docs/tools/{ => session}/list-windows.md | 0 docs/tools/{ => session}/rename-session.md | 0 docs/tools/{ => session}/select-window.md | 0 docs/tools/window/index.md | 62 +++++++ docs/tools/{ => window}/kill-window.md | 0 docs/tools/{ => window}/list-panes.md | 0 docs/tools/{ => window}/move-window.md | 0 docs/tools/{ => window}/rename-window.md | 0 docs/tools/{ => window}/resize-window.md | 0 docs/tools/{ => window}/select-layout.md | 0 docs/tools/{ => window}/split-window.md | 0 44 files changed, 376 insertions(+), 38 deletions(-) rename docs/tools/{ => pane}/capture-pane.md (100%) rename docs/tools/{ => pane}/clear-pane.md (100%) rename docs/tools/{ => pane}/display-message.md (100%) rename docs/tools/{ => pane}/enter-copy-mode.md (100%) rename docs/tools/{ => pane}/exit-copy-mode.md (100%) rename docs/tools/{ => pane}/get-pane-info.md (100%) create mode 100644 docs/tools/pane/index.md rename docs/tools/{ => pane}/kill-pane.md (100%) rename docs/tools/{ => pane}/paste-text.md (100%) rename docs/tools/{ => pane}/pipe-pane.md (100%) rename docs/tools/{ => pane}/resize-pane.md (100%) rename docs/tools/{ => pane}/search-panes.md (100%) rename docs/tools/{ => pane}/select-pane.md (100%) rename docs/tools/{ => pane}/send-keys.md (100%) rename docs/tools/{ => pane}/set-pane-title.md (100%) rename docs/tools/{ => pane}/snapshot-pane.md (100%) rename docs/tools/{ => pane}/swap-pane.md (100%) rename docs/tools/{ => pane}/wait-for-content-change.md (100%) rename docs/tools/{ => pane}/wait-for-text.md (100%) rename docs/tools/{ => server}/create-session.md (100%) rename docs/tools/{ => server}/get-server-info.md (100%) create mode 100644 docs/tools/server/index.md rename docs/tools/{ => server}/kill-server.md (100%) rename docs/tools/{ => server}/list-sessions.md (100%) rename docs/tools/{ => server}/set-environment.md (100%) rename docs/tools/{ => server}/set-option.md (100%) rename docs/tools/{ => server}/show-environment.md (100%) rename docs/tools/{ => server}/show-option.md (100%) rename docs/tools/{ => session}/create-window.md (100%) create mode 100644 docs/tools/session/index.md rename docs/tools/{ => session}/kill-session.md (100%) rename docs/tools/{ => session}/list-windows.md (100%) rename docs/tools/{ => session}/rename-session.md (100%) rename docs/tools/{ => session}/select-window.md (100%) create mode 100644 docs/tools/window/index.md rename docs/tools/{ => window}/kill-window.md (100%) rename docs/tools/{ => window}/list-panes.md (100%) rename docs/tools/{ => window}/move-window.md (100%) rename docs/tools/{ => window}/rename-window.md (100%) rename docs/tools/{ => window}/resize-window.md (100%) rename docs/tools/{ => window}/select-layout.md (100%) rename docs/tools/{ => window}/split-window.md (100%) diff --git a/docs/redirects.txt b/docs/redirects.txt index 4ac1aee..a5a3d7d 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -16,3 +16,41 @@ "tools/windows" "tools/index" "tools/panes" "tools/index" "tools/options" "tools/index" +"tools/capture-pane" "tools/pane/capture-pane" +"tools/clear-pane" "tools/pane/clear-pane" +"tools/create-session" "tools/server/create-session" +"tools/create-window" "tools/session/create-window" +"tools/display-message" "tools/pane/display-message" +"tools/enter-copy-mode" "tools/pane/enter-copy-mode" +"tools/exit-copy-mode" "tools/pane/exit-copy-mode" +"tools/get-pane-info" "tools/pane/get-pane-info" +"tools/get-server-info" "tools/server/get-server-info" +"tools/kill-pane" "tools/pane/kill-pane" +"tools/kill-server" "tools/server/kill-server" +"tools/kill-session" "tools/session/kill-session" +"tools/kill-window" "tools/window/kill-window" +"tools/list-panes" "tools/window/list-panes" +"tools/list-sessions" "tools/server/list-sessions" +"tools/list-windows" "tools/session/list-windows" +"tools/move-window" "tools/window/move-window" +"tools/paste-text" "tools/pane/paste-text" +"tools/pipe-pane" "tools/pane/pipe-pane" +"tools/rename-session" "tools/session/rename-session" +"tools/rename-window" "tools/window/rename-window" +"tools/resize-pane" "tools/pane/resize-pane" +"tools/resize-window" "tools/window/resize-window" +"tools/search-panes" "tools/pane/search-panes" +"tools/select-layout" "tools/window/select-layout" +"tools/select-pane" "tools/pane/select-pane" +"tools/select-window" "tools/session/select-window" +"tools/send-keys" "tools/pane/send-keys" +"tools/set-environment" "tools/server/set-environment" +"tools/set-option" "tools/server/set-option" +"tools/set-pane-title" "tools/pane/set-pane-title" +"tools/show-environment" "tools/server/show-environment" +"tools/show-option" "tools/server/show-option" +"tools/snapshot-pane" "tools/pane/snapshot-pane" +"tools/split-window" "tools/window/split-window" +"tools/swap-pane" "tools/pane/swap-pane" +"tools/wait-for-content-change" "tools/pane/wait-for-content-change" +"tools/wait-for-text" "tools/pane/wait-for-text" diff --git a/docs/tools/index.md b/docs/tools/index.md index 8c3e1ec..c3c7b98 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -300,42 +300,8 @@ Kill the entire tmux server. ```{toctree} :hidden: -capture-pane -clear-pane -create-session -create-window -display-message -enter-copy-mode -exit-copy-mode -get-pane-info -get-server-info -kill-pane -kill-server -kill-session -kill-window -list-panes -list-sessions -list-windows -move-window -paste-text -pipe-pane -rename-session -rename-window -resize-pane -resize-window -search-panes -select-layout -select-pane -select-window -send-keys -set-environment -set-option -set-pane-title -show-environment -show-option -snapshot-pane -split-window -swap-pane -wait-for-content-change -wait-for-text +server/index +session/index +window/index +pane/index ``` diff --git a/docs/tools/capture-pane.md b/docs/tools/pane/capture-pane.md similarity index 100% rename from docs/tools/capture-pane.md rename to docs/tools/pane/capture-pane.md diff --git a/docs/tools/clear-pane.md b/docs/tools/pane/clear-pane.md similarity index 100% rename from docs/tools/clear-pane.md rename to docs/tools/pane/clear-pane.md diff --git a/docs/tools/display-message.md b/docs/tools/pane/display-message.md similarity index 100% rename from docs/tools/display-message.md rename to docs/tools/pane/display-message.md diff --git a/docs/tools/enter-copy-mode.md b/docs/tools/pane/enter-copy-mode.md similarity index 100% rename from docs/tools/enter-copy-mode.md rename to docs/tools/pane/enter-copy-mode.md diff --git a/docs/tools/exit-copy-mode.md b/docs/tools/pane/exit-copy-mode.md similarity index 100% rename from docs/tools/exit-copy-mode.md rename to docs/tools/pane/exit-copy-mode.md diff --git a/docs/tools/get-pane-info.md b/docs/tools/pane/get-pane-info.md similarity index 100% rename from docs/tools/get-pane-info.md rename to docs/tools/pane/get-pane-info.md diff --git a/docs/tools/pane/index.md b/docs/tools/pane/index.md new file mode 100644 index 0000000..dab3682 --- /dev/null +++ b/docs/tools/pane/index.md @@ -0,0 +1,155 @@ +# Pane + +Tools for pane-level operations: reading content, sending input, navigation, scrollback, and lifecycle. + +## Inspect + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} capture_pane +:link: capture-pane +:link-type: ref +Read visible content of a pane. +::: + +:::{grid-item-card} get_pane_info +:link: get-pane-info +:link-type: ref +Get detailed pane metadata. +::: + +:::{grid-item-card} search_panes +:link: search-panes +:link-type: ref +Search text across panes. +::: + +:::{grid-item-card} wait_for_text +:link: wait-for-text +:link-type: ref +Wait for text to appear in a pane. +::: + +:::{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 + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} send_keys +:link: send-keys +:link-type: ref +Send commands or keystrokes to a pane. +::: + +:::{grid-item-card} set_pane_title +:link: set-pane-title +:link-type: ref +Set pane title. +::: + +:::{grid-item-card} clear_pane +:link: clear-pane +:link-type: ref +Clear pane content. +::: + +:::{grid-item-card} resize_pane +:link: resize-pane +:link-type: ref +Adjust pane dimensions. +::: + +:::{grid-item-card} select_pane +:link: select-pane +:link-type: ref +Focus a pane by ID or direction. +::: + +:::{grid-item-card} swap_pane +:link: swap-pane +:link-type: ref +Exchange positions of two panes. +::: + +:::{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 + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} kill_pane +:link: kill-pane +:link-type: ref +Destroy a pane. +::: + +:::: + +```{toctree} +:hidden: + +capture-pane +get-pane-info +search-panes +wait-for-text +snapshot-pane +wait-for-content-change +display-message +send-keys +set-pane-title +clear-pane +resize-pane +select-pane +swap-pane +pipe-pane +enter-copy-mode +exit-copy-mode +paste-text +kill-pane +``` diff --git a/docs/tools/kill-pane.md b/docs/tools/pane/kill-pane.md similarity index 100% rename from docs/tools/kill-pane.md rename to docs/tools/pane/kill-pane.md diff --git a/docs/tools/paste-text.md b/docs/tools/pane/paste-text.md similarity index 100% rename from docs/tools/paste-text.md rename to docs/tools/pane/paste-text.md diff --git a/docs/tools/pipe-pane.md b/docs/tools/pane/pipe-pane.md similarity index 100% rename from docs/tools/pipe-pane.md rename to docs/tools/pane/pipe-pane.md diff --git a/docs/tools/resize-pane.md b/docs/tools/pane/resize-pane.md similarity index 100% rename from docs/tools/resize-pane.md rename to docs/tools/pane/resize-pane.md diff --git a/docs/tools/search-panes.md b/docs/tools/pane/search-panes.md similarity index 100% rename from docs/tools/search-panes.md rename to docs/tools/pane/search-panes.md diff --git a/docs/tools/select-pane.md b/docs/tools/pane/select-pane.md similarity index 100% rename from docs/tools/select-pane.md rename to docs/tools/pane/select-pane.md diff --git a/docs/tools/send-keys.md b/docs/tools/pane/send-keys.md similarity index 100% rename from docs/tools/send-keys.md rename to docs/tools/pane/send-keys.md diff --git a/docs/tools/set-pane-title.md b/docs/tools/pane/set-pane-title.md similarity index 100% rename from docs/tools/set-pane-title.md rename to docs/tools/pane/set-pane-title.md diff --git a/docs/tools/snapshot-pane.md b/docs/tools/pane/snapshot-pane.md similarity index 100% rename from docs/tools/snapshot-pane.md rename to docs/tools/pane/snapshot-pane.md diff --git a/docs/tools/swap-pane.md b/docs/tools/pane/swap-pane.md similarity index 100% rename from docs/tools/swap-pane.md rename to docs/tools/pane/swap-pane.md diff --git a/docs/tools/wait-for-content-change.md b/docs/tools/pane/wait-for-content-change.md similarity index 100% rename from docs/tools/wait-for-content-change.md rename to docs/tools/pane/wait-for-content-change.md diff --git a/docs/tools/wait-for-text.md b/docs/tools/pane/wait-for-text.md similarity index 100% rename from docs/tools/wait-for-text.md rename to docs/tools/pane/wait-for-text.md diff --git a/docs/tools/create-session.md b/docs/tools/server/create-session.md similarity index 100% rename from docs/tools/create-session.md rename to docs/tools/server/create-session.md diff --git a/docs/tools/get-server-info.md b/docs/tools/server/get-server-info.md similarity index 100% rename from docs/tools/get-server-info.md rename to docs/tools/server/get-server-info.md diff --git a/docs/tools/server/index.md b/docs/tools/server/index.md new file mode 100644 index 0000000..f14d294 --- /dev/null +++ b/docs/tools/server/index.md @@ -0,0 +1,69 @@ +# Server + +Tools for server-level operations: session discovery, server info, configuration, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_sessions +:link: list-sessions +:link-type: ref +List all active sessions. +::: + +:::{grid-item-card} get_server_info +:link: get-server-info +:link-type: ref +Get tmux server info. +::: + +:::{grid-item-card} create_session +:link: create-session +:link-type: ref +Start a new tmux session. +::: + +:::{grid-item-card} kill_server +:link: kill-server +:link-type: ref +Kill the entire tmux server. +::: + +:::{grid-item-card} show_option +:link: show-option +:link-type: ref +Query a tmux option value. +::: + +:::{grid-item-card} set_option +:link: set-option +:link-type: ref +Set a tmux option. +::: + +:::{grid-item-card} show_environment +:link: show-environment +:link-type: ref +Show tmux environment variables. +::: + +:::{grid-item-card} set_environment +:link: set-environment +:link-type: ref +Set a tmux environment variable. +::: + +:::: + +```{toctree} +:hidden: + +list-sessions +get-server-info +create-session +kill-server +show-option +set-option +show-environment +set-environment +``` diff --git a/docs/tools/kill-server.md b/docs/tools/server/kill-server.md similarity index 100% rename from docs/tools/kill-server.md rename to docs/tools/server/kill-server.md diff --git a/docs/tools/list-sessions.md b/docs/tools/server/list-sessions.md similarity index 100% rename from docs/tools/list-sessions.md rename to docs/tools/server/list-sessions.md diff --git a/docs/tools/set-environment.md b/docs/tools/server/set-environment.md similarity index 100% rename from docs/tools/set-environment.md rename to docs/tools/server/set-environment.md diff --git a/docs/tools/set-option.md b/docs/tools/server/set-option.md similarity index 100% rename from docs/tools/set-option.md rename to docs/tools/server/set-option.md diff --git a/docs/tools/show-environment.md b/docs/tools/server/show-environment.md similarity index 100% rename from docs/tools/show-environment.md rename to docs/tools/server/show-environment.md diff --git a/docs/tools/show-option.md b/docs/tools/server/show-option.md similarity index 100% rename from docs/tools/show-option.md rename to docs/tools/server/show-option.md diff --git a/docs/tools/create-window.md b/docs/tools/session/create-window.md similarity index 100% rename from docs/tools/create-window.md rename to docs/tools/session/create-window.md diff --git a/docs/tools/session/index.md b/docs/tools/session/index.md new file mode 100644 index 0000000..17053b3 --- /dev/null +++ b/docs/tools/session/index.md @@ -0,0 +1,48 @@ +# Session + +Tools for session-level operations: window management, session renaming, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_windows +:link: list-windows +:link-type: ref +List windows in a session. +::: + +:::{grid-item-card} create_window +:link: create-window +:link-type: ref +Add a window to a session. +::: + +:::{grid-item-card} rename_session +:link: rename-session +:link-type: ref +Rename a session. +::: + +:::{grid-item-card} select_window +:link: select-window +:link-type: ref +Focus a window by ID, index, or direction. +::: + +:::{grid-item-card} kill_session +:link: kill-session +:link-type: ref +Destroy a session and all its windows. +::: + +:::: + +```{toctree} +:hidden: + +list-windows +create-window +rename-session +select-window +kill-session +``` diff --git a/docs/tools/kill-session.md b/docs/tools/session/kill-session.md similarity index 100% rename from docs/tools/kill-session.md rename to docs/tools/session/kill-session.md diff --git a/docs/tools/list-windows.md b/docs/tools/session/list-windows.md similarity index 100% rename from docs/tools/list-windows.md rename to docs/tools/session/list-windows.md diff --git a/docs/tools/rename-session.md b/docs/tools/session/rename-session.md similarity index 100% rename from docs/tools/rename-session.md rename to docs/tools/session/rename-session.md diff --git a/docs/tools/select-window.md b/docs/tools/session/select-window.md similarity index 100% rename from docs/tools/select-window.md rename to docs/tools/session/select-window.md diff --git a/docs/tools/window/index.md b/docs/tools/window/index.md new file mode 100644 index 0000000..bcf091b --- /dev/null +++ b/docs/tools/window/index.md @@ -0,0 +1,62 @@ +# Window + +Tools for window-level operations: pane management, layout, resizing, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_panes +:link: list-panes +:link-type: ref +List panes in a window. +::: + +:::{grid-item-card} split_window +:link: split-window +:link-type: ref +Split a window into panes. +::: + +:::{grid-item-card} rename_window +:link: rename-window +:link-type: ref +Rename a window. +::: + +:::{grid-item-card} select_layout +:link: select-layout +:link-type: ref +Set window layout. +::: + +:::{grid-item-card} resize_window +:link: resize-window +:link-type: ref +Adjust window dimensions. +::: + +:::{grid-item-card} move_window +:link: move-window +:link-type: ref +Move window to another index or session. +::: + +:::{grid-item-card} kill_window +:link: kill-window +:link-type: ref +Destroy a window and all its panes. +::: + +:::: + +```{toctree} +:hidden: + +list-panes +split-window +rename-window +select-layout +resize-window +move-window +kill-window +``` diff --git a/docs/tools/kill-window.md b/docs/tools/window/kill-window.md similarity index 100% rename from docs/tools/kill-window.md rename to docs/tools/window/kill-window.md diff --git a/docs/tools/list-panes.md b/docs/tools/window/list-panes.md similarity index 100% rename from docs/tools/list-panes.md rename to docs/tools/window/list-panes.md diff --git a/docs/tools/move-window.md b/docs/tools/window/move-window.md similarity index 100% rename from docs/tools/move-window.md rename to docs/tools/window/move-window.md diff --git a/docs/tools/rename-window.md b/docs/tools/window/rename-window.md similarity index 100% rename from docs/tools/rename-window.md rename to docs/tools/window/rename-window.md diff --git a/docs/tools/resize-window.md b/docs/tools/window/resize-window.md similarity index 100% rename from docs/tools/resize-window.md rename to docs/tools/window/resize-window.md diff --git a/docs/tools/select-layout.md b/docs/tools/window/select-layout.md similarity index 100% rename from docs/tools/select-layout.md rename to docs/tools/window/select-layout.md diff --git a/docs/tools/split-window.md b/docs/tools/window/split-window.md similarity index 100% rename from docs/tools/split-window.md rename to docs/tools/window/split-window.md From c729553bae4c5ea7d2bbacf23bee65ae6b9ff42b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 18:37:33 -0500 Subject: [PATCH 6/6] docs(style): Strip badges from sidebar via SphinxContentsFilter Monkeypatch SphinxContentsFilter to raise SkipNode for all badge node types (_safety_badge_node, _resource_badge_node, _model_badge_node). This is the same pattern Sphinx uses for visit_image in titles. Badges stay inside nodes.title for proper heading rendering, but are stripped during toctree text extraction so the sidebar shows clean tool names without badge text or emoji leakage. Add CSS to size badges at 0.5em within h1/h2 headings for medium button appearance rather than full-scale heading text. --- docs/_ext/fastmcp_autodoc.py | 13 +++++++++++++ docs/_static/css/fastmcp_autodoc.css | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/docs/_ext/fastmcp_autodoc.py b/docs/_ext/fastmcp_autodoc.py index c6fddcb..7bdbc69 100644 --- a/docs/_ext/fastmcp_autodoc.py +++ b/docs/_ext/fastmcp_autodoc.py @@ -1997,6 +1997,19 @@ def setup(app: Sphinx) -> ExtensionMetadata: # CSS app.add_css_file("css/fastmcp_autodoc.css") + # Strip badge nodes from toctree/sidebar title extraction. + # SphinxContentsFilter walks title nodes to build toctree text. + # Without this, badges inside titles propagate to the sidebar. + # This is the same pattern Sphinx uses for visit_image (SkipNode). + from sphinx.transforms import SphinxContentsFilter + + def _skip_badge(self: t.Any, node: nodes.Node) -> None: + raise nodes.SkipNode + + SphinxContentsFilter.visit__safety_badge_node = _skip_badge # type: ignore[attr-defined] + SphinxContentsFilter.visit__resource_badge_node = _skip_badge # type: ignore[attr-defined] + SphinxContentsFilter.visit__model_badge_node = _skip_badge # type: ignore[attr-defined] + return { "version": "0.1.0", "parallel_read_safe": True, diff --git a/docs/_static/css/fastmcp_autodoc.css b/docs/_static/css/fastmcp_autodoc.css index 14461e2..8edd903 100644 --- a/docs/_static/css/fastmcp_autodoc.css +++ b/docs/_static/css/fastmcp_autodoc.css @@ -18,6 +18,18 @@ span.sd-badge[aria-label*="model"] { font-family: var(--sd-fontfamily-monospace, monospace); } +/* Badge in section headings — medium size, not full h1 scale */ +h1 .sd-badge, +h2 .sd-badge { + font-size: 0.5em; + vertical-align: middle; +} + +/* Sidebar: strip background from inline code (tool/resource/model names) */ +.sidebar-tree code.literal { + background: none; +} + /* Field/param table polish — tighter padding */ .fastmcp-autodoc-table td, .fastmcp-autodoc-table th {