Skip to content

Commit fc1d684

Browse files
committed
mcp(feat[split_window_many]): Fan panes out and keep the layout
why: Splitting halves a pane, so six split_window calls in a small window stop at "no space for new pane". libtmux's Window.split_many re-applies the layout after each split. what: - Add split_window_many (count 1 to 64, layout, start_directory, shell, environment) returning the new panes in order - Document it, list it in the index, README and tool expectations - Tests: six panes in an 80x24 window, bad count and layout; the first fails when the call is replaced by plain splits
1 parent dcf9302 commit fc1d684

9 files changed

Lines changed: 192 additions & 2 deletions

File tree

‎CHANGES‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ marker to be read. A process still running at the timeout comes back with
1717
`timed_out=true`. The pane stays on screen, dead, so `capture_pane` can read its
1818
output.
1919

20+
**`split_window_many` adds several panes and keeps the layout**
21+
22+
`split_window_many` adds `count` panes (1 to 64) to a window and re-applies a
23+
layout after each split, so a fan-out of workers no longer stops at "no space
24+
for new pane" in a small window.
25+
2026
**`create_session` can reuse a session and set its scrollback**
2127

2228
`create_session(session_name=..., if_exists="reuse")` returns the session of

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ commands, read output, orchestrate panes.
3434
| **Server** | `list_servers`, `list_sessions`, `create_session`, `kill_server`, `get_server_info` |
3535
| **Batch** | `call_read_tools_batch` |
3636
| **Session** | `list_windows`, `get_session_info`, `create_window`, `rename_session`, `select_window`, `kill_session` |
37-
| **Window** | `list_panes`, `get_window_info`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` |
37+
| **Window** | `list_panes`, `get_window_info`, `split_window`, `split_window_many`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` |
3838
| **Pane** | `run_command`, `send_keys`, `send_keys_batch`, `paste_text`, `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `find_pane_by_position`, `get_pane_info`, `wait_for_text`, `wait_for_channel`, `wait_for_pane_exit`, `signal_channel`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `respawn_pane`, `kill_pane` |
3939
| **Options** | `show_option`, `set_option` |
4040
| **Environment** | `show_environment`, `set_environment` |

‎docs/tools/index.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ leave socket selection inside each nested tool's arguments. See
3636
- New session → {tool}`create-session`
3737
- New window → {tool}`create-window`
3838
- New pane → {tool}`split-window`
39+
- Several panes at once (one per worker) → {tool}`split-window-many`
3940

4041
**Navigating?**
4142
- Switch pane → {tool}`select-pane` (by ID or direction)
@@ -352,6 +353,12 @@ Add a window to a session.
352353
Split a window into panes.
353354
:::
354355

356+
:::{grid-item-card} split_window_many
357+
:link: split-window-many
358+
:link-type: ref
359+
Add several panes at once, keeping a layout applied.
360+
:::
361+
355362
:::{grid-item-card} send_keys
356363
:link: send-keys
357364
:link-type: ref

‎docs/tools/window/index.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Read metadata for one window.
1717
Split a window into a new pane.
1818
:::
1919

20+
:::{grid-item-card} {tooliconl}`split-window-many`
21+
Add several panes at once, keeping a layout applied.
22+
:::
23+
2024
:::{grid-item-card} {tooliconl}`rename-window`
2125
Rename an existing window.
2226
:::
@@ -46,6 +50,7 @@ Terminate a window. Not reversible.
4650
list-panes
4751
get-window-info
4852
split-window
53+
split-window-many
4954
rename-window
5055
select-layout
5156
resize-window
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Split window many
2+
3+
Adds several panes to a window in one call and keeps a layout applied after
4+
every split. Splitting halves a pane, so a window runs out of room after a few
5+
{tooliconl}`split-window` calls with "no space for new pane"; applying a layout
6+
once at the end does not help, because the failure comes first.
7+
8+
```python
9+
panes = split_window_many(window_id="@1", count=6, layout="tiled")
10+
```
11+
12+
```{fastmcp-tool} window_tools.split_window_many
13+
```
14+
15+
**Use when** you want one pane per worker, per host or per log.
16+
17+
**Avoid when** you need a specific split direction or size for one pane. Use
18+
{tooliconl}`split-window`.
19+
20+
**Side effects:** Creates `count` panes and changes the window's layout. The
21+
window keeps the layout afterwards. When the window is too small for another
22+
pane even after the layout is applied, the call fails and the panes created so
23+
far remain.
24+
25+
`environment`, `start_directory` and `suppress_persistent_history` apply to
26+
every new pane exactly as for {tooliconl}`split-window`; see {ref}`trust` for
27+
what environment values expose.
28+
29+
```{fastmcp-tool-input} window_tools.split_window_many
30+
```

‎src/libtmux_mcp/tools/window_tools.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,100 @@ def split_window(
256256
return _serialize_pane(new_pane)
257257

258258

259+
#: Most panes one ``split_window_many`` call adds. A window that cannot hold
260+
#: them fails at tmux's own limit; this stops a typo from asking for thousands.
261+
_SPLIT_MANY_MAX_COUNT = 64
262+
263+
264+
@handle_tool_errors
265+
def split_window_many(
266+
count: int,
267+
session_name: str | None = None,
268+
session_id: str | None = None,
269+
window_id: str | None = None,
270+
window_index: str | None = None,
271+
layout: str = "tiled",
272+
start_directory: str | None = None,
273+
shell: str | None = None,
274+
socket_name: str | None = None,
275+
*,
276+
environment: dict[str, str] | str | None = None,
277+
suppress_persistent_history: bool = False,
278+
) -> list[PaneInfo]:
279+
"""Add ``count`` panes to a window, keeping a layout applied throughout.
280+
281+
Use to fan work out across several panes at once, for example one
282+
worker per pane. Splitting halves a pane, so a window runs out of room
283+
after a few ``split_window`` calls with "no space for new pane"; this
284+
re-applies ``layout`` after every split so the window keeps room.
285+
Returns the new panes in creation order.
286+
287+
Parameters
288+
----------
289+
count : int
290+
Number of panes to add, 1 to 64.
291+
session_name : str, optional
292+
Session name.
293+
session_id : str, optional
294+
Session ID (e.g. '$1').
295+
window_id : str, optional
296+
Window ID (e.g. '@1').
297+
window_index : str, optional
298+
Window index within the session.
299+
layout : str
300+
Layout kept applied: ``tiled`` (default), ``even-horizontal``,
301+
``even-vertical``, ``main-horizontal`` or ``main-vertical``.
302+
start_directory : str, optional
303+
Existing directory to start every new pane in. ``~`` expands; a
304+
relative path resolves against the MCP server process's directory.
305+
shell : str, optional
306+
Shell command to run in every new pane.
307+
socket_name : str, optional
308+
tmux socket name.
309+
environment : dict or str, optional
310+
Per-process environment for every new pane, as a mapping or JSON
311+
object string. Values may be visible to host process inspection in
312+
the tmux client argv during launch and in the child environment
313+
afterward; MCP audit redaction does not hide either surface. Pass
314+
credential references, not literal credentials.
315+
suppress_persistent_history : bool
316+
Whether to suppress persistent history for the spawned shells.
317+
Defaults to False. This per-call option does not inherit
318+
LIBTMUX_SUPPRESS_HISTORY.
319+
320+
Returns
321+
-------
322+
list[PaneInfo]
323+
The new panes, in creation order.
324+
"""
325+
if not 1 <= count <= _SPLIT_MANY_MAX_COUNT:
326+
msg = f"count must be between 1 and {_SPLIT_MANY_MAX_COUNT} (received {count})"
327+
raise ExpectedToolError(msg)
328+
spawn_environment = _prepare_spawn_environment(
329+
environment,
330+
suppress_persistent_history=suppress_persistent_history,
331+
)
332+
server = _get_server(socket_name=socket_name)
333+
window = _resolve_window(
334+
server,
335+
window_id=window_id,
336+
window_index=window_index,
337+
session_name=session_name,
338+
session_id=session_id,
339+
)
340+
try:
341+
panes = window.split_many(
342+
count,
343+
layout=layout,
344+
start_directory=_prepare_start_directory(start_directory),
345+
shell=shell,
346+
environment=spawn_environment,
347+
)
348+
except ValueError as e:
349+
raise ExpectedToolError(str(e)) from e
350+
return [_serialize_pane(pane) for pane in panes]
351+
352+
259353
@handle_tool_errors
260354
def rename_window(
261355
new_name: str,
@@ -514,6 +608,11 @@ def register(mcp: FastMCP) -> None:
514608
annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
515609
tags={TOOLSET_EXECUTE},
516610
)(split_window)
611+
mcp.tool(
612+
title="Split tmux Window Many",
613+
annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
614+
tags={TOOLSET_EXECUTE},
615+
)(split_window_many)
517616
mcp.tool(
518617
title="Rename tmux Window",
519618
annotations=ANNOTATIONS_AMBIENT_UNKNOWN,

‎tests/test_server.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -806,10 +806,11 @@ def test_probe_hint_visible_only_on_an_inspect_only_surface(
806806
"rename_session",
807807
"kill_session",
808808
"select_window",
809-
# 8 window-level
809+
# 9 window-level
810810
"list_panes",
811811
"get_window_info",
812812
"split_window",
813+
"split_window_many",
813814
"rename_window",
814815
"kill_window",
815816
"select_layout",

‎tests/test_tool_annotations.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"set_environment",
8585
"set_option",
8686
"split_window",
87+
"split_window_many",
8788
}
8889
),
8990
TOOLSET_TEARDOWN: frozenset(

‎tests/test_window_tools.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
resize_window,
1919
select_layout,
2020
split_window,
21+
split_window_many,
2122
)
2223

2324
if t.TYPE_CHECKING:
@@ -381,3 +382,43 @@ def test_kill_window(mcp_server: Server, mcp_session: Session) -> None:
381382
socket_name=mcp_server.socket_name,
382383
)
383384
assert "killed" in result.lower()
385+
386+
387+
def test_split_window_many_adds_panes_in_a_small_window(
388+
mcp_server: Server, mcp_session: Session
389+
) -> None:
390+
"""Six splits fit in a window that rejects them without a layout in between."""
391+
window = mcp_session.active_window
392+
window.resize(height=24, width=80)
393+
394+
result = split_window_many(
395+
count=6,
396+
window_id=window.window_id,
397+
socket_name=mcp_server.socket_name,
398+
)
399+
400+
assert len(result) == 6
401+
assert len({pane.pane_id for pane in result}) == 6
402+
window.refresh()
403+
assert len(window.panes) == 7
404+
assert window.window_layout is not None
405+
406+
407+
def test_split_window_many_refuses_bad_count_and_layout(
408+
mcp_server: Server, mcp_session: Session
409+
) -> None:
410+
"""A count out of range and an unknown layout are agent-correctable errors."""
411+
from fastmcp.exceptions import ToolError
412+
413+
window_id = mcp_session.active_window.window_id
414+
with pytest.raises(ToolError, match="count must be between"):
415+
split_window_many(
416+
count=0, window_id=window_id, socket_name=mcp_server.socket_name
417+
)
418+
with pytest.raises(ToolError):
419+
split_window_many(
420+
count=2,
421+
window_id=window_id,
422+
layout="not-a-layout",
423+
socket_name=mcp_server.socket_name,
424+
)

0 commit comments

Comments
 (0)