Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### 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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
69 changes: 69 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 --
Expand Down
10 changes: 10 additions & 0 deletions docs/tools/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions docs/tools/pane/display-message.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
51 changes: 51 additions & 0 deletions docs/tools/pane/find-pane-by-position.md
Original file line number Diff line number Diff line change
@@ -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
```
15 changes: 15 additions & 0 deletions docs/tools/pane/get-pane-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
```
5 changes: 5 additions & 0 deletions docs/tools/pane/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
:::
Expand Down Expand Up @@ -99,6 +103,7 @@ capture-pane
search-panes
snapshot-pane
get-pane-info
find-pane-by-position
display-message
send-keys
paste-text
Expand Down
16 changes: 16 additions & 0 deletions docs/tools/pane/snapshot-pane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
```
2 changes: 1 addition & 1 deletion docs/topics/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
1 change: 1 addition & 0 deletions docs/topics/prompting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
38 changes: 37 additions & 1 deletion src/libtmux_mcp/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand All @@ -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),
Expand Down
Loading