diff --git a/CHANGES b/CHANGES index 384c466..ff670d7 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,34 @@ _Notes on upcoming releases will be added here_ +### What's new + +**New tools** + +- {tooliconl}`find-pane-by-position` — resolve a layout-relative pane + ("the bottom-right pane", any corner) to a `PaneInfo` in one + read-only call. Composes the four `pane_at_*` predicates with a + `pane_left + pane_top` tie-break for ambiguous layouts (e.g. + single-pane windows that touch every edge). Replaces the + {tooliconl}`display-message`-and-parse workaround. (#34) + +**Pane geometry on response models** + +- {class}`~libtmux_mcp.models.PaneInfo`, + {class}`~libtmux_mcp.models.PaneContentMatch`, and + {class}`~libtmux_mcp.models.PaneSnapshot` now carry window-relative + `pane_left` / `pane_top` / `pane_right` / `pane_bottom` (typed + `int | None`), the four `pane_at_left` / `pane_at_right` / + `pane_at_top` / `pane_at_bottom` edge predicates (typed + `bool | None`), and `pane_tty`. Agents reason about layout off the + returned model instead of expanding `#{pane_at_*}` format strings + by hand. {tooliconl}`snapshot-pane` fetches the new variables in + the same `display-message` round-trip it already makes, so there + is no extra tmux call. The older `pane_width` / `pane_height` + fields stay `str | None` for now — switching them to `int | None` + is a breaking schema change deferred as a separate follow-up. + (#34) + ### Development - `scripts/mcp_swap.py` — `use-local` and `revert` accept @@ -36,6 +64,17 @@ _Notes on upcoming releases will be added here_ `localStorage` (CLS drops to 0), and the sidebar logo plus all bold and italic text no longer pop in late as fonts arrive. (#36) +- Discoverability sweep for {tooliconl}`find-pane-by-position`: + dedicated topic page, homepage `Inspect (readonly)` chain entry, + `### Inspect` grid card and "Targeting a pane by layout?" + decision-tree cluster on the tools index, {ref}`concepts` + Discovery enumeration, a "Spin up the dev server in the + bottom-right pane" row in the prompting-patterns table, a new + corner-targeting recipe in {ref}`recipes`, and refreshed example + responses on {tooliconl}`get-pane-info` and + {tooliconl}`snapshot-pane` showing the new geometry block. + {tooliconl}`display-message` "Avoid when" guidance now redirects + layout-relative use cases to the corner-finder. (#34) ## libtmux-mcp 0.1.0a4 (2026-05-02) diff --git a/docs/index.md b/docs/index.md index ae09e48..cbc932f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -71,7 +71,7 @@ Config blocks for Claude Desktop, Claude Code, Cursor, and others. Read tmux state without changing anything. -{toolref}`list-sessions` · {toolref}`capture-pane` · {toolref}`snapshot-pane` · {toolref}`get-pane-info` · {toolref}`search-panes` · {toolref}`wait-for-text` · {toolref}`wait-for-content-change` · {toolref}`display-message` +{toolref}`list-sessions` · {toolref}`capture-pane` · {toolref}`snapshot-pane` · {toolref}`get-pane-info` · {toolref}`find-pane-by-position` · {toolref}`search-panes` · {toolref}`wait-for-text` · {toolref}`wait-for-content-change` · {toolref}`display-message` ### Act (mutating) diff --git a/docs/recipes.md b/docs/recipes.md index ffbddce..9f1e2e8 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -85,6 +85,75 @@ what URL it chose. --- +## Target the bottom-right pane for an ad-hoc command + +**Situation.** The developer keeps a four-pane tiled layout for the +project: editor top-left, watcher top-right, logs bottom-left, scratch +bottom-right. They want the agent to spin up a one-off dev server in the +scratch pane without naming it explicitly. + +```{admonition} Prompt +:class: prompt + +Spin up the dev server in the bottom-right pane. +``` + +### Discover + +```{admonition} Agent reasoning +:class: agent-thought + +I do not need to list every pane and compute geometry. tmux already +tracks each pane's edge predicates -- there is a tool that resolves a +corner directly to a `PaneInfo`. +``` + +The agent calls {tooliconl}`find-pane-by-position` with +`corner: "bottom-right"`. The response is a +{class}`~libtmux_mcp.models.PaneInfo` carrying the pane's `pane_id` plus +the new geometry block: `pane_at_bottom: true`, `pane_at_right: true`, +`pane_left`, `pane_top`, etc. + +### Decide + +```{admonition} Agent reasoning +:class: agent-thought + +I have the `pane_id`. From here on I target by ID, never by corner -- +once the user resizes the layout, "bottom-right" might mean a different +pane, but the `pane_id` of the pane I just identified stays stable. +``` + +### Act + +The agent calls {tooliconl}`send-keys` in that pane: `pnpm start`. Then +{tooliconl}`wait-for-text` with `pattern: "Local:"` and a generous +`timeout` so Vite has room to start. + +```{tip} +"The bottom-right pane" is a *role* -- a layout-relative target the +human reasons about. The `pane_id` returned by +{toolref}`find-pane-by-position` is the *handle* the agent should use +for every subsequent call. Do not call the corner-finder again on each +follow-up; reuse the ID. +``` + +### The non-obvious part + +Before {toolref}`find-pane-by-position`, the only way to resolve a +corner was {toolref}`display-message` with `#{pane_at_bottom}` and +`#{pane_at_right}` per pane, then parsing the string output. The +structured `PaneInfo` response now carries `pane_left`, `pane_top`, +`pane_right`, `pane_bottom` and the four `pane_at_*` predicates as +typed fields, so agents reasoning about layout no longer need a +parsing detour through {toolref}`display-message`. + +For single-pane windows, every corner resolves to the same pane (it +touches every edge). For genuinely ambiguous layouts, the visually +innermost pane wins via a `pane_left + pane_top` tie-break. + +--- + ## Start a service and wait for it before running dependent work **Situation.** The developer is starting fresh in their `backend` session -- diff --git a/docs/tools/index.md b/docs/tools/index.md index 9cfbc68..1bd13ba 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -15,6 +15,10 @@ All tools accept an optional `socket_name` parameter for multi-server support. I - Only need metadata (PID, path, size)? → {tool}`get-pane-info` - Need an arbitrary tmux variable? → {tool}`display-message` +**Targeting a pane by layout?** +- "The bottom-right pane", "top-left", any corner → {tool}`find-pane-by-position` +- Already know the `pane_id` → use it directly + **Running a command?** - {tool}`send-keys` — then {tool}`wait-for-text` + {tool}`capture-pane` - Pasting multi-line text? → {tool}`paste-text` @@ -100,6 +104,12 @@ Read visible content of a pane. Get detailed pane metadata. ::: +:::{grid-item-card} find_pane_by_position +:link: find-pane-by-position +:link-type: ref +Resolve "the bottom-right pane" (or any corner) to a `PaneInfo`. +::: + :::{grid-item-card} search_panes :link: search-panes :link-type: ref diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md index 2b8f426..db3ce5b 100644 --- a/docs/tools/pane/display-message.md +++ b/docs/tools/pane/display-message.md @@ -10,8 +10,12 @@ it wraps), this tool does **not** display anything to the user; it expands the format string with `display-message -p` and returns the value. **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. +{tooliconl}`snapshot-pane` for cursor position and mode, +{tooliconl}`get-pane-info` for standard metadata (including the new +`pane_left` / `pane_top` / `pane_at_*` geometry block), or +{tooliconl}`find-pane-by-position` to resolve a window corner to a +`PaneInfo` without parsing `#{pane_at_bottom}` / `#{pane_at_right}` +yourself. **Side effects:** None. Readonly. diff --git a/docs/tools/pane/find-pane-by-position.md b/docs/tools/pane/find-pane-by-position.md new file mode 100644 index 0000000..974bb82 --- /dev/null +++ b/docs/tools/pane/find-pane-by-position.md @@ -0,0 +1,51 @@ +# Find pane by position + +```{fastmcp-tool} pane_tools.find_pane_by_position +``` + +**Use when** you need to act on a layout-relative pane — "the bottom-right +pane", "whichever pane is in the top-left" — without listing every pane and +computing geometry yourself. + +**Avoid when** you already know the `pane_id`. Use {tooliconl}`get-pane-info` +or {tooliconl}`select-pane` directly. + +**Side effects:** None. Read-only. + +**Example:** + +```json +{ + "tool": "find_pane_by_position", + "arguments": { + "corner": "bottom-right", + "window_id": "@0" + } +} +``` + +Response is a {class}`~libtmux_mcp.models.PaneInfo` for the pane occupying +that corner. The new geometry fields make the result self-describing: + +```json +{ + "pane_id": "%3", + "pane_left": 40, + "pane_top": 12, + "pane_right": 79, + "pane_bottom": 23, + "pane_at_left": false, + "pane_at_right": true, + "pane_at_top": false, + "pane_at_bottom": true, + "pane_tty": "/dev/pts/5" +} +``` + +**Tie-break.** When multiple panes satisfy both edge predicates (a +single-pane window touches every edge; some custom layouts can produce +ambiguous corners) the visually innermost pane wins — the one with the +largest `pane_left + pane_top`. + +```{fastmcp-tool-input} pane_tools.find_pane_by_position +``` diff --git a/docs/tools/pane/get-pane-info.md b/docs/tools/pane/get-pane-info.md index 7a4c3ae..74e2bc5 100644 --- a/docs/tools/pane/get-pane-info.md +++ b/docs/tools/pane/get-pane-info.md @@ -29,6 +29,15 @@ Response: "pane_index": "0", "pane_width": "80", "pane_height": "24", + "pane_left": 0, + "pane_top": 0, + "pane_right": 79, + "pane_bottom": 23, + "pane_at_left": true, + "pane_at_right": true, + "pane_at_top": true, + "pane_at_bottom": true, + "pane_tty": "/dev/pts/5", "pane_current_command": "zsh", "pane_current_path": "/home/user/myproject", "pane_pid": "12345", @@ -40,5 +49,11 @@ Response: } ``` +Coordinates are window-relative cell offsets. `pane_left` / `pane_top` are +0-based and `pane_right` / `pane_bottom` are inclusive. The four +`pane_at_*` predicates account for `pane-border-status`. To target a +layout-relative pane (e.g. "the bottom-right pane") use +{tooliconl}`find-pane-by-position` instead of computing edges yourself. + ```{fastmcp-tool-input} pane_tools.get_pane_info ``` diff --git a/docs/tools/pane/index.md b/docs/tools/pane/index.md index f77c1ab..bdb1333 100644 --- a/docs/tools/pane/index.md +++ b/docs/tools/pane/index.md @@ -21,6 +21,10 @@ Capture content plus cursor, mode, and scroll state in one call. Read pane metadata without content. ::: +:::{grid-item-card} {tooliconl}`find-pane-by-position` +Find the pane at a given corner of a window. +::: + :::{grid-item-card} {tooliconl}`display-message` Evaluate a tmux format string against a target. ::: @@ -99,6 +103,7 @@ capture-pane search-panes snapshot-pane get-pane-info +find-pane-by-position display-message send-keys paste-text diff --git a/docs/tools/pane/snapshot-pane.md b/docs/tools/pane/snapshot-pane.md index 074f4e4..3659e82 100644 --- a/docs/tools/pane/snapshot-pane.md +++ b/docs/tools/pane/snapshot-pane.md @@ -34,6 +34,15 @@ Response: "cursor_y": 4, "pane_width": 80, "pane_height": 24, + "pane_left": 0, + "pane_top": 0, + "pane_right": 79, + "pane_bottom": 23, + "pane_at_left": true, + "pane_at_right": true, + "pane_at_top": true, + "pane_at_bottom": true, + "pane_tty": "/dev/pts/5", "pane_in_mode": false, "pane_mode": null, "scroll_position": null, @@ -45,5 +54,12 @@ Response: } ``` +The geometry block (`pane_left` / `pane_top` / `pane_right` / +`pane_bottom` and the four `pane_at_*` predicates) is fetched in the +same `display-message` round-trip as the cursor and mode fields, so +there is no extra tmux call. To target a layout-relative pane (e.g. +"the bottom-right pane") use {tooliconl}`find-pane-by-position` +instead of computing edges from this snapshot. + ```{fastmcp-tool-input} pane_tools.snapshot_pane ``` diff --git a/docs/topics/concepts.md b/docs/topics/concepts.md index a38065d..74e8c27 100644 --- a/docs/topics/concepts.md +++ b/docs/topics/concepts.md @@ -45,7 +45,7 @@ For pane tools, you can combine parameters to narrow the search: `session_name` Tools fall into three categories: -- **Discovery** — Read-only operations: `list_sessions`, `list_windows`, `list_panes`, `capture_pane`, `get_pane_info`, `search_panes`, `wait_for_text`, `show_option`, `show_environment` +- **Discovery** — Read-only operations: `list_sessions`, `list_windows`, `list_panes`, `capture_pane`, `get_pane_info`, `find_pane_by_position`, `search_panes`, `wait_for_text`, `show_option`, `show_environment` - **Mutation** — Create, modify, or send input: `create_session`, `create_window`, `split_window`, `send_keys`, `rename_*`, `resize_*`, `set_pane_title`, `clear_pane`, `select_layout`, `set_option`, `set_environment` - **Destruction** — Remove tmux objects: `kill_server`, `kill_session`, `kill_window`, `kill_pane` diff --git a/docs/topics/prompting.md b/docs/topics/prompting.md index 74250ff..e31c81c 100644 --- a/docs/topics/prompting.md +++ b/docs/topics/prompting.md @@ -38,6 +38,7 @@ These natural-language prompts reliably trigger the right tool sequences: |--------|-------------------| | [Run `pytest` in my build pane and show results]{.prompt} | {toolref}`send-keys` → {toolref}`wait-for-text` → {toolref}`capture-pane` | | [Start the dev server and wait until it's ready]{.prompt} | {toolref}`send-keys` → {toolref}`wait-for-text` (for "listening on") | +| [Spin up the dev server in the bottom-right pane]{.prompt} | {toolref}`find-pane-by-position` (corner=bottom-right) → {toolref}`send-keys` → {toolref}`wait-for-text` | | [Check if any pane has errors]{.prompt} | {toolref}`search-panes` with pattern "error" | | [Set up a workspace with editor, server, and tests]{.prompt} | {toolref}`create-session` → {toolref}`split-window` (x2) → {toolref}`set-pane-title` (x3) | | [What's running in my tmux sessions?]{.prompt} | {toolref}`list-sessions` → {toolref}`list-panes` → {toolref}`capture-pane` | diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 6a597a0..20d66c7 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -830,6 +830,33 @@ def _serialize_window(window: Window) -> WindowInfo: ) +def _coerce_int(value: str | None) -> int | None: + """Parse a tmux format-string number into ``int`` or ``None``. + + tmux format variables come back as strings; an empty string means + "tmux returned nothing" (e.g. older tmux that doesn't know the var). + """ + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_bool(value: str | None) -> bool | None: + """Parse a tmux ``"1"``/``"0"`` flag into ``bool`` or ``None``. + + Mirrors libtmux's own ``Pane.at_top`` / ``at_bottom`` typing, which + folds ``"1"`` to True and everything else to False — except we keep + ``None`` distinct so callers can tell "tmux didn't tell us" from + "tmux said no". + """ + if value is None or value == "": + return None + return value == "1" + + def _serialize_pane(pane: Pane) -> PaneInfo: """Serialize a Pane to a Pydantic model. @@ -841,7 +868,7 @@ def _serialize_pane(pane: Pane) -> PaneInfo: Returns ------- PaneInfo - Pane data including id, dimensions, current command, title. + Pane data including id, dimensions, geometry, current command, title. """ from libtmux_mcp.models import PaneInfo @@ -851,6 +878,15 @@ def _serialize_pane(pane: Pane) -> PaneInfo: pane_index=getattr(pane, "pane_index", None), pane_width=getattr(pane, "pane_width", None), pane_height=getattr(pane, "pane_height", None), + pane_left=_coerce_int(getattr(pane, "pane_left", None)), + pane_top=_coerce_int(getattr(pane, "pane_top", None)), + pane_right=_coerce_int(getattr(pane, "pane_right", None)), + pane_bottom=_coerce_int(getattr(pane, "pane_bottom", None)), + pane_at_left=_coerce_bool(getattr(pane, "pane_at_left", None)), + pane_at_right=_coerce_bool(getattr(pane, "pane_at_right", None)), + pane_at_top=_coerce_bool(getattr(pane, "pane_at_top", None)), + pane_at_bottom=_coerce_bool(getattr(pane, "pane_at_bottom", None)), + pane_tty=getattr(pane, "pane_tty", None), pane_current_command=getattr(pane, "pane_current_command", None), pane_current_path=getattr(pane, "pane_current_path", None), pane_pid=getattr(pane, "pane_pid", None), diff --git a/src/libtmux_mcp/models.py b/src/libtmux_mcp/models.py index 2cf63e1..85e083f 100644 --- a/src/libtmux_mcp/models.py +++ b/src/libtmux_mcp/models.py @@ -53,6 +53,46 @@ class PaneInfo(BaseModel): pane_index: str | None = Field(default=None, description="Pane index") pane_width: str | None = Field(default=None, description="Width in columns") pane_height: str | None = Field(default=None, description="Height in rows") + pane_left: int | None = Field( + default=None, + description="Left edge column, 0-based and window-relative.", + ) + pane_top: int | None = Field( + default=None, + description="Top edge row, 0-based and window-relative.", + ) + pane_right: int | None = Field( + default=None, + description="Right edge column (inclusive), window-relative.", + ) + pane_bottom: int | None = Field( + default=None, + description="Bottom edge row (inclusive), window-relative.", + ) + pane_at_left: bool | None = Field( + default=None, + description="True when the pane touches the window's left edge.", + ) + pane_at_right: bool | None = Field( + default=None, + description="True when the pane touches the window's right edge.", + ) + pane_at_top: bool | None = Field( + default=None, + description=( + "True when the pane touches the window's top edge. tmux " + "accounts for ``pane-border-status`` here, so the top row " + "may be 1 instead of 0 when the status bar is at the top." + ), + ) + pane_at_bottom: bool | None = Field( + default=None, + description="True when the pane touches the window's bottom edge.", + ) + pane_tty: str | None = Field( + default=None, + description="TTY device path of the pane (e.g. '/dev/pts/5').", + ) pane_current_command: str | None = Field( default=None, description="Running command" ) @@ -84,6 +124,45 @@ class PaneContentMatch(BaseModel): """A pane whose captured content matched a search pattern.""" pane_id: str = Field(description="Pane ID (e.g. '%1')") + pane_left: int | None = Field( + default=None, + description="Left edge column, 0-based and window-relative.", + ) + pane_top: int | None = Field( + default=None, + description="Top edge row, 0-based and window-relative.", + ) + pane_right: int | None = Field( + default=None, + description="Right edge column (inclusive), window-relative.", + ) + pane_bottom: int | None = Field( + default=None, + description="Bottom edge row (inclusive), window-relative.", + ) + pane_at_left: bool | None = Field( + default=None, + description="True when the pane touches the window's left edge.", + ) + pane_at_right: bool | None = Field( + default=None, + description="True when the pane touches the window's right edge.", + ) + pane_at_top: bool | None = Field( + default=None, + description=( + "True when the pane touches the window's top edge. tmux " + "accounts for ``pane-border-status`` here." + ), + ) + pane_at_bottom: bool | None = Field( + default=None, + description="True when the pane touches the window's bottom edge.", + ) + pane_tty: str | None = Field( + default=None, + description="TTY device path of the pane (e.g. '/dev/pts/5').", + ) pane_current_command: str | None = Field( default=None, description="Running command" ) @@ -170,6 +249,45 @@ class PaneSnapshot(BaseModel): 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_left: int | None = Field( + default=None, + description="Left edge column, 0-based and window-relative.", + ) + pane_top: int | None = Field( + default=None, + description="Top edge row, 0-based and window-relative.", + ) + pane_right: int | None = Field( + default=None, + description="Right edge column (inclusive), window-relative.", + ) + pane_bottom: int | None = Field( + default=None, + description="Bottom edge row (inclusive), window-relative.", + ) + pane_at_left: bool | None = Field( + default=None, + description="True when the pane touches the window's left edge.", + ) + pane_at_right: bool | None = Field( + default=None, + description="True when the pane touches the window's right edge.", + ) + pane_at_top: bool | None = Field( + default=None, + description=( + "True when the pane touches the window's top edge. tmux " + "accounts for ``pane-border-status`` here." + ), + ) + pane_at_bottom: bool | None = Field( + default=None, + description="True when the pane touches the window's bottom edge.", + ) + pane_tty: str | None = Field( + default=None, + description="TTY device path of the pane (e.g. '/dev/pts/5').", + ) 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" diff --git a/src/libtmux_mcp/tools/pane_tools/__init__.py b/src/libtmux_mcp/tools/pane_tools/__init__.py index a3787ef..cb444e0 100644 --- a/src/libtmux_mcp/tools/pane_tools/__init__.py +++ b/src/libtmux_mcp/tools/pane_tools/__init__.py @@ -35,6 +35,7 @@ swap_pane, ) from libtmux_mcp.tools.pane_tools.lifecycle import ( + find_pane_by_position, get_pane_info, kill_pane, respawn_pane, @@ -57,6 +58,7 @@ "display_message", "enter_copy_mode", "exit_copy_mode", + "find_pane_by_position", "get_pane_info", "kill_pane", "paste_text", @@ -102,6 +104,11 @@ def register(mcp: FastMCP) -> None: mcp.tool(title="Get Pane Info", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( get_pane_info ) + mcp.tool( + title="Find Pane By Position", + annotations=ANNOTATIONS_RO, + tags={TAG_READONLY}, + )(find_pane_by_position) mcp.tool(title="Clear Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING})( clear_pane ) diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index 1edd0dc..ee82a16 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -2,6 +2,8 @@ from __future__ import annotations +import typing as t + from fastmcp.exceptions import ToolError from libtmux_mcp._utils import ( @@ -9,6 +11,7 @@ _get_caller_identity, _get_server, _resolve_pane, + _resolve_window, _serialize_pane, handle_tool_errors, ) @@ -16,6 +19,9 @@ PaneInfo, ) +#: The four window corners ``find_pane_by_position`` accepts. +PaneCorner = t.Literal["top-left", "top-right", "bottom-left", "bottom-right"] + @handle_tool_errors def kill_pane( @@ -259,3 +265,88 @@ def get_pane_info( window_id=window_id, ) return _serialize_pane(pane) + + +@handle_tool_errors +def find_pane_by_position( + corner: PaneCorner, + 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: + """Find the pane occupying a corner of a tmux window. + + Composes the four ``pane_at_*`` predicates so callers can target a + layout-relative position (e.g. "the bottom-right pane") in one + round-trip instead of listing every pane and computing the + geometry. Resolves the window the same way as the other + window-scoped tools. + + Parameters + ---------- + corner : str + One of ``'top-left'``, ``'top-right'``, ``'bottom-left'``, + ``'bottom-right'``. + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index. Requires session_name or session_id. + session_name : str, optional + Session name. + session_id : str, optional + Session ID. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + Serialized pane occupying the requested corner. + + Raises + ------ + ToolError + If no pane satisfies both edge predicates for that corner — in + practice only possible for layouts tmux itself produced via + custom layout strings; the built-in layouts always have a pane + at every corner. + """ + 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, + ) + + vertical, horizontal = corner.split("-") + matches = [ + p + for p in window.panes + if getattr(p, f"at_{vertical}", False) and getattr(p, f"at_{horizontal}", False) + ] + if not matches: + msg = ( + f"No pane found at corner {corner!r} in window " + f"{window.window_id}. This is unusual — built-in layouts " + "always have a pane at every corner." + ) + raise ToolError(msg) + + # When more than one pane qualifies (e.g. a single-pane window + # touches all four edges, or an unusual layout), prefer the pane + # whose top-left coordinate is furthest from window origin (0,0). + # That picks the visually innermost pane for the corner — i.e. + # for 'bottom-right', the pane with the largest pane_left + + # pane_top, which sits visually closest to the bottom-right. + def _innermost_score(p: t.Any) -> int: + try: + return int(p.pane_left or 0) + int(p.pane_top or 0) + except (TypeError, ValueError): + return 0 + + matches.sort(key=_innermost_score, reverse=True) + return _serialize_pane(matches[0]) diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index 373f2de..d8ce72e 100644 --- a/src/libtmux_mcp/tools/pane_tools/meta.py +++ b/src/libtmux_mcp/tools/pane_tools/meta.py @@ -3,6 +3,8 @@ from __future__ import annotations from libtmux_mcp._utils import ( + _coerce_bool, + _coerce_int, _compute_is_caller, _get_server, _resolve_pane, @@ -132,26 +134,34 @@ def snapshot_pane( # tabs are legal (if rare) in Linux paths and could realistically # appear in pane_current_path. _SEP = "␞" - fmt = _SEP.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}", - ] - ) + _FMT_VARS = [ + "#{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}", + "#{pane_left}", + "#{pane_top}", + "#{pane_right}", + "#{pane_bottom}", + "#{pane_at_left}", + "#{pane_at_right}", + "#{pane_at_top}", + "#{pane_at_bottom}", + "#{pane_tty}", + ] + fmt = _SEP.join(_FMT_VARS) result = pane.cmd("display-message", "-p", fmt) raw = result.stdout[0] if result.stdout else "" - # Pad defensively to guarantee 11 fields even if tmux drops an - # unknown format variable on older versions. - parts = (raw.split(_SEP) + [""] * 11)[:11] + # Pad defensively to guarantee one slot per format var even if tmux + # drops an unknown variable on older versions. + parts = (raw.split(_SEP) + [""] * len(_FMT_VARS))[: len(_FMT_VARS)] raw_lines = pane.capture_pane() kept_lines, truncated, dropped = _truncate_lines_tail(raw_lines, max_lines) @@ -175,6 +185,15 @@ def snapshot_pane( 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, + pane_left=_coerce_int(parts[11]), + pane_top=_coerce_int(parts[12]), + pane_right=_coerce_int(parts[13]), + pane_bottom=_coerce_int(parts[14]), + pane_at_left=_coerce_bool(parts[15]), + pane_at_right=_coerce_bool(parts[16]), + pane_at_top=_coerce_bool(parts[17]), + pane_at_bottom=_coerce_bool(parts[18]), + pane_tty=parts[19] if parts[19] else None, is_caller=_compute_is_caller(pane), content_truncated=truncated, content_truncated_lines=dropped, diff --git a/src/libtmux_mcp/tools/pane_tools/search.py b/src/libtmux_mcp/tools/pane_tools/search.py index bfaec79..5d57141 100644 --- a/src/libtmux_mcp/tools/pane_tools/search.py +++ b/src/libtmux_mcp/tools/pane_tools/search.py @@ -7,6 +7,8 @@ from fastmcp.exceptions import ToolError from libtmux_mcp._utils import ( + _coerce_bool, + _coerce_int, _compute_is_caller, _get_server, _resolve_session, @@ -243,6 +245,15 @@ def search_panes( all_matches.append( PaneContentMatch( pane_id=pane_id_str, + pane_left=_coerce_int(getattr(pane, "pane_left", None)), + pane_top=_coerce_int(getattr(pane, "pane_top", None)), + pane_right=_coerce_int(getattr(pane, "pane_right", None)), + pane_bottom=_coerce_int(getattr(pane, "pane_bottom", None)), + pane_at_left=_coerce_bool(getattr(pane, "pane_at_left", None)), + pane_at_right=_coerce_bool(getattr(pane, "pane_at_right", None)), + pane_at_top=_coerce_bool(getattr(pane, "pane_at_top", None)), + pane_at_bottom=_coerce_bool(getattr(pane, "pane_at_bottom", None)), + pane_tty=getattr(pane, "pane_tty", None), pane_current_command=getattr(pane, "pane_current_command", None), pane_current_path=getattr(pane, "pane_current_path", None), window_id=pane.window_id, diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 87deb55..18d395f 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -22,6 +22,7 @@ display_message, enter_copy_mode, exit_copy_mode, + find_pane_by_position, get_pane_info, kill_pane, paste_text, @@ -141,6 +142,92 @@ def test_get_pane_info(mcp_server: Server, mcp_pane: Pane) -> None: assert result.pane_height is not None +def test_get_pane_info_returns_geometry( + mcp_server: Server, mcp_session: Session +) -> None: + """PaneInfo carries window-relative geometry as int/bool, not raw strings.""" + from libtmux.constants import PaneDirection + + window = mcp_session.active_window + window.split(direction=PaneDirection.Right) + + panes = window.panes + assert len(panes) == 2 + + infos = [ + get_pane_info(pane_id=p.pane_id, socket_name=mcp_server.socket_name) + for p in panes + ] + + for info in infos: + assert isinstance(info.pane_left, int) + assert isinstance(info.pane_top, int) + assert isinstance(info.pane_right, int) + assert isinstance(info.pane_bottom, int) + assert isinstance(info.pane_at_left, bool) + assert isinstance(info.pane_at_right, bool) + assert isinstance(info.pane_at_top, bool) + assert isinstance(info.pane_at_bottom, bool) + assert info.pane_tty is not None and info.pane_tty.startswith("/dev/") + # Both panes span the full window vertically in a horizontal split. + assert info.pane_at_top is True + assert info.pane_at_bottom is True + + left, right = sorted(infos, key=lambda i: i.pane_left or 0) + assert left.pane_at_left is True and left.pane_at_right is False + assert right.pane_at_left is False and right.pane_at_right is True + assert (left.pane_left or 0) < (right.pane_left or 0) + + +def test_find_pane_by_position_each_corner( + mcp_server: Server, mcp_session: Session +) -> None: + """find_pane_by_position returns the right pane for each corner of a 2x2.""" + from libtmux.constants import PaneDirection + + window = mcp_session.active_window + # Three splits → four panes; ``tiled`` arranges them as a 2x2 grid. + window.split(direction=PaneDirection.Right) + window.split(direction=PaneDirection.Below) + window.split(direction=PaneDirection.Below) + window.select_layout("tiled") + assert len(window.panes) == 4 + + corners = ("top-left", "top-right", "bottom-left", "bottom-right") + found = { + corner: find_pane_by_position( + corner=corner, # type: ignore[arg-type] + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + for corner in corners + } + + pane_ids = {corner: info.pane_id for corner, info in found.items()} + assert len(set(pane_ids.values())) == 4, ( + f"Expected 4 distinct panes for 4 corners, got {pane_ids}" + ) + + assert found["top-left"].pane_at_top is True + assert found["top-left"].pane_at_left is True + assert found["bottom-right"].pane_at_bottom is True + assert found["bottom-right"].pane_at_right is True + + +def test_find_pane_by_position_single_pane_window_returns_only_pane( + mcp_server: Server, mcp_pane: Pane +) -> None: + """Single-pane window touches every edge — returns it for any corner.""" + window = mcp_pane.window + for corner in ("top-left", "top-right", "bottom-left", "bottom-right"): + info = find_pane_by_position( + corner=corner, + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + assert info.pane_id == mcp_pane.pane_id + + def test_set_pane_title(mcp_server: Server, mcp_pane: Pane) -> None: """set_pane_title sets the pane title.""" result = set_pane_title(