cmux/tests_v2/test_tmux_compat_geometry.py
Lawrence Chen 84af32c56e
Add cmux omo command for oh-my-openagent integration (#2087)
* Add `cmux omo` command for OpenCode + oh-my-openagent integration

Same pattern as `cmux claude-teams`: creates a tmux shim so
oh-my-openagent's TmuxSessionManager spawns agents as native cmux
splits instead of tmux panes. Sets TMUX/TMUX_PANE env vars, prepends
shim to PATH, and execs into opencode.

Closes https://github.com/manaflow-ai/cmux/issues/2085

* Auto-install oh-my-opencode plugin when running cmux omo

Before launching opencode, cmux omo now:
- Checks if oh-my-opencode is registered in ~/.config/opencode/opencode.json
- If not, creates/updates the config with the plugin entry
- Checks if the npm package is installed in node_modules
- If not, runs bun add (or npm install) to install it
- Then proceeds with tmux shim setup and exec

* Use shadow config dir to avoid modifying user's opencode setup

Instead of writing directly to ~/.config/opencode/opencode.json,
cmux omo now creates a shadow config at ~/.cmuxterm/omo-config/ that
layers oh-my-opencode on top of the user's existing config. Symlinks
node_modules, package.json, bun.lock, and plugin config from the
original dir. Sets OPENCODE_CONFIG_DIR to the shadow directory.

Running plain `opencode` remains unaffected.

* Add Agent Integrations docs section with Claude Code Teams and oh-my-opencode pages

Adds sectioned sidebar navigation to the docs site. The new Agent
Integrations section contains separate pages for cmux claude-teams and
cmux omo, documenting usage, tmux shim mechanics, directory layout,
environment variables, and the shadow config approach. Both pages
include a nightly-only warning. Full English and Japanese translations,
nav item keys added to all 19 locales.

* Remove uppercase from sidebar section headers

* Add more spacing above and below sidebar section headers

* Enable tmux mode in oh-my-opencode config, improve docs

- cmux omo now writes tmux.enabled=true to the shadow oh-my-opencode.json
  config. Without this, oh-my-openagent's TmuxSessionManager won't spawn
  visual panes even though $TMUX is set (the config defaults to false).
- Nightly warnings now link to /nightly instead of generic text.
- Added "What you get" section to oh-my-opencode docs explaining the
  visual pane behavior (auto-layout, idle cleanup, queueing).
- Added tmux.enabled step to first-run and how-it-works sections.

* Add terminal-notifier shim to route oh-my-openagent notifications to cmux

oh-my-openagent sends macOS notifications via terminal-notifier
(args: -title <t> -message <m> [-activate <id>]). The shim in
~/.cmuxterm/omo-bin/terminal-notifier intercepts these calls and
routes them through cmux notify, so notifications appear in cmux's
sidebar panel instead of as raw macOS notifications.

* Add pane geometry to tmux-compat for oh-my-openagent grid planning

oh-my-openagent's TmuxSessionManager needs pane geometry (columns,
rows, position, window dimensions) to decide where to spawn agent
panes. Without this data, agents run headlessly.

Server side:
- pane.list v2 response now includes pixel_frame, cell_size, columns,
  rows per pane, plus container_frame at the top level
- Uses BonsplitController.layoutSnapshot() for pixel geometry and
  ghostty_surface_size() for terminal grid dimensions

CLI side:
- tmuxEnrichContextWithGeometry() computes character-cell positions
  from pixel frames and cell dimensions for tmux format variables
  (pane_width, pane_height, pane_left, pane_top, pane_active,
  window_width, window_height)
- list-panes now resolves pane targets (%uuid) via tmuxResolvePaneTarget
  instead of failing with "Workspace not found"
- display-message enriched with geometry for format strings like
  #{pane_width},#{window_width}
- tmux -V now returns "tmux 3.4" (needed by oh-my-openagent's
  tmux-path-resolver verification)

* Add socket tests for tmux-compat pane geometry

6 tests verifying the geometry enrichment works end-to-end:
- pane.list returns pixel_frame, columns, rows, cell_size, container_frame
- tmux -V returns version string
- list-panes -F renders geometry format variables as integers
- list-panes -t %<uuid> resolves pane targets
- display -p renders pane_width and window_width
- After split, two panes have different positions and halved widths

All 6 pass on macmini (cmux-macmini).

* Handle tmux -V in shim script directly (no socket needed)

oh-my-openagent's tmux-path-resolver runs tmux -V to verify the binary
works. The __tmux-compat handler requires a socket connection, which
may not be established at verification time. Handle -V in the bash
shim directly to avoid the socket dependency.

* Lower default tmux pane min widths for cmux omo

oh-my-openagent defaults: main_pane_min_width=120, agent_pane_min_width=40,
requiring 161+ columns. Most terminal windows are narrower, causing
decideSpawnActions to return canSpawn=false and defer agents forever.

cmux omo now sets: main_pane_min_width=60, agent_pane_min_width=30,
main_pane_size=50, requiring only 91 columns. Also moved tmux -V
handling into the bash shim to avoid needing a socket connection for
the version check.

* Resolve merge conflicts with main (main-vertical layout, focus param)

- Keep upstream main-vertical layout anchoring from #2119
- Keep upstream focus param (v2Bool) instead of no_focus
- Combine with our -d flag handling: -d sets focus=false
- Include customCommands nav item from main

* Implement select-layout equalize and resize-pane absolute width

When oh-my-openagent spawns agent panes, it calls select-layout
main-vertical after each split to redistribute panes evenly, then
resize-pane -x <columns> to set the main pane width. Both were
previously no-ops, causing cascading uneven splits.

Server side:
- Add workspace.equalize_splits v2 API that calls the existing
  TabManager.equalizeSplits (sets all dividers to 0.5)

CLI side:
- select-layout now calls workspace.equalize_splits before tracking
  main-vertical state
- resize-pane -x <columns> without directional flags now computes
  the pixel delta from current to desired width and resizes accordingly

* Fix equalize to use proportional divider positions

The previous equalize set all dividers to 0.5, which in a right-
recursive binary tree (from successive splits) gives 50/25/12.5/6.25%
instead of equal sizes.

New algorithm counts leaf panes on each side of each split and sets
the divider to N_left / (N_left + N_right). For 5 panes in a chain:
1/5, 1/4, 1/3, 1/2, giving each pane exactly 20%.

* Fix select-layout main-vertical to only equalize vertical splits

The proportional equalize was treating the top-level horizontal split
(main vs agent column) the same as vertical splits, setting the main
pane to 1/6 of the window with 5 agents.

For main-vertical layout, only equalize vertical splits (the agent
column), leaving the horizontal main/agent divider untouched. The
subsequent resize-pane -x handles the main pane width.

workspace.equalize_splits now accepts an optional orientation filter
("vertical" or "horizontal") to scope which splits get equalized.

* Re-equalize agent column after kill-pane

* Address PR review comments

- Fix cmux omo --help: remove omo from the help-bypass guard so
  --help shows usage text instead of trying to launch opencode
- Don't overwrite unreadable opencode.json: fail with an error
  instead of silently resetting to empty config
- Drain installer pipes concurrently before waitUntilExit to
  prevent deadlock from full pipe buffers during bun/npm install

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-26 16:07:59 -07:00

247 lines
10 KiB
Python

#!/usr/bin/env python3
"""Tests for tmux-compat pane geometry support (oh-my-openagent integration).
Verifies that:
1. pane.list v2 API returns geometry fields (pixel_frame, columns, rows, cell_size, container_frame)
2. tmux-compat list-panes renders geometry format variables correctly
3. tmux-compat display -p renders geometry format variables
4. tmux-compat list-panes resolves pane targets (%uuid)
5. tmux -V returns a version string
6. Multi-pane geometry reflects actual split layout
"""
import glob
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import List
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
candidates = glob.glob(os.path.expanduser(
"~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"
), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_tmux_compat(cli: str, args: List[str]) -> subprocess.CompletedProcess[str]:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env["CMUX_SOCKET_PATH"] = SOCKET_PATH
env["CMUX_OMO_CMUX_BIN"] = cli
cmd = [cli, "--socket", SOCKET_PATH, "__tmux-compat"] + args
return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
def test_pane_list_geometry_fields(c: cmux) -> None:
"""pane.list response includes geometry fields for each pane."""
print(" test_pane_list_geometry_fields ... ", end="", flush=True)
panes_raw = c.list_panes()
_must(len(panes_raw) >= 1, "Expected at least 1 pane")
payload = c._call("pane.list", {})
panes = payload.get("panes", [])
_must(len(panes) >= 1, f"Expected panes in payload, got {payload}")
pane = panes[0]
_must("pixel_frame" in pane, f"Missing pixel_frame in pane: {list(pane.keys())}")
_must("columns" in pane, f"Missing columns in pane: {list(pane.keys())}")
_must("rows" in pane, f"Missing rows in pane: {list(pane.keys())}")
_must("cell_width_px" in pane, f"Missing cell_width_px in pane: {list(pane.keys())}")
_must("cell_height_px" in pane, f"Missing cell_height_px in pane: {list(pane.keys())}")
frame = pane["pixel_frame"]
_must(frame["width"] > 0, f"pixel_frame.width should be > 0, got {frame['width']}")
_must(frame["height"] > 0, f"pixel_frame.height should be > 0, got {frame['height']}")
_must(pane["columns"] > 0, f"columns should be > 0, got {pane['columns']}")
_must(pane["rows"] > 0, f"rows should be > 0, got {pane['rows']}")
_must(pane["cell_width_px"] > 0, f"cell_width_px should be > 0, got {pane['cell_width_px']}")
_must(pane["cell_height_px"] > 0, f"cell_height_px should be > 0, got {pane['cell_height_px']}")
_must("container_frame" in payload, f"Missing container_frame in payload: {list(payload.keys())}")
cf = payload["container_frame"]
_must(cf["width"] > 0, f"container_frame.width should be > 0, got {cf['width']}")
_must(cf["height"] > 0, f"container_frame.height should be > 0, got {cf['height']}")
print("PASS")
def test_tmux_version(cli: str) -> None:
"""tmux -V returns a version string."""
print(" test_tmux_version ... ", end="", flush=True)
proc = _run_tmux_compat(cli, ["-V"])
_must(proc.returncode == 0, f"tmux -V failed with rc={proc.returncode}: {proc.stderr}")
output = proc.stdout.strip()
_must(output.startswith("tmux"), f"Expected 'tmux ...' output, got: {output!r}")
print("PASS")
def test_list_panes_geometry_format(cli: str) -> None:
"""list-panes with oh-my-openagent format string renders integer geometry."""
print(" test_list_panes_geometry_format ... ", end="", flush=True)
fmt = "#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}"
proc = _run_tmux_compat(cli, ["list-panes", "-F", fmt])
_must(proc.returncode == 0, f"list-panes failed: {proc.stderr}")
lines = [l for l in proc.stdout.strip().split("\n") if l.strip()]
_must(len(lines) >= 1, f"Expected at least 1 line, got {len(lines)}")
for line in lines:
# The line uses literal \t (backslash-t) from format rendering
parts = line.split("\\t") if "\\t" in line else line.split("\t")
_must(len(parts) >= 8, f"Expected >= 8 tab-separated fields, got {len(parts)}: {line!r}")
pane_id = parts[0]
_must(pane_id.startswith("%"), f"pane_id should start with %, got: {pane_id!r}")
# Validate integer fields (width, height, left, top, active, window_w, window_h)
for i, name in [(1, "pane_width"), (2, "pane_height"), (3, "pane_left"),
(4, "pane_top"), (5, "pane_active"), (6, "window_width"), (7, "window_height")]:
_must(parts[i].isdigit(), f"{name} should be integer, got: {parts[i]!r} in line: {line!r}")
_must(int(parts[1]) > 0, f"pane_width should be > 0, got {parts[1]}")
_must(int(parts[2]) > 0, f"pane_height should be > 0, got {parts[2]}")
_must(parts[5] in ("0", "1"), f"pane_active should be 0 or 1, got {parts[5]!r}")
_must(int(parts[6]) > 0, f"window_width should be > 0, got {parts[6]}")
_must(int(parts[7]) > 0, f"window_height should be > 0, got {parts[7]}")
print("PASS")
def test_list_panes_pane_target(cli: str, c: cmux) -> None:
"""list-panes -t %<pane-uuid> resolves pane target to workspace."""
print(" test_list_panes_pane_target ... ", end="", flush=True)
panes_raw = c.list_panes()
_must(len(panes_raw) >= 1, "No panes found")
pane_id = panes_raw[0][1]
proc = _run_tmux_compat(cli, ["list-panes", "-t", f"%{pane_id}", "-F", "#{pane_id}"])
_must(proc.returncode == 0, f"list-panes -t %{pane_id} failed: {proc.stderr}")
output = proc.stdout.strip()
_must(len(output) > 0, "Expected output from list-panes with pane target")
_must(output.startswith("%"), f"Expected pane_id starting with %, got: {output!r}")
print("PASS")
def test_display_geometry_format(cli: str) -> None:
"""display -p renders pane_width and window_width as integers."""
print(" test_display_geometry_format ... ", end="", flush=True)
proc = _run_tmux_compat(cli, ["display", "-p", "#{pane_width},#{window_width}"])
_must(proc.returncode == 0, f"display failed: {proc.stderr}")
output = proc.stdout.strip()
parts = output.split(",")
_must(len(parts) == 2, f"Expected 'N,M' output, got: {output!r}")
_must(parts[0].isdigit() and int(parts[0]) > 0, f"pane_width not a positive int: {parts[0]!r}")
_must(parts[1].isdigit() and int(parts[1]) > 0, f"window_width not a positive int: {parts[1]!r}")
print("PASS")
def test_multi_pane_geometry(cli: str, c: cmux) -> None:
"""After splitting, two panes have different pane_left values and halved widths."""
print(" test_multi_pane_geometry ... ", end="", flush=True)
ws = c.new_workspace()
c.select_workspace(ws)
time.sleep(0.3)
# Get single-pane geometry first
payload_before = c._call("pane.list", {"workspace_id": ws})
panes_before = payload_before.get("panes", [])
_must(len(panes_before) == 1, f"Expected 1 pane before split, got {len(panes_before)}")
single_cols = panes_before[0].get("columns", 0)
# Split horizontally
c.new_split("right")
time.sleep(0.3)
payload_after = c._call("pane.list", {"workspace_id": ws})
panes_after = payload_after.get("panes", [])
_must(len(panes_after) == 2, f"Expected 2 panes after split, got {len(panes_after)}")
p1, p2 = panes_after[0], panes_after[1]
_must("pixel_frame" in p1 and "pixel_frame" in p2, "Missing pixel_frame after split")
_must("columns" in p1 and "columns" in p2, "Missing columns after split")
# Pane left positions should differ (horizontal split)
left1 = p1["pixel_frame"]["x"]
left2 = p2["pixel_frame"]["x"]
_must(left1 != left2, f"Panes should have different x positions, got {left1} and {left2}")
# Each pane should be roughly half the original width
cols1 = p1["columns"]
cols2 = p2["columns"]
_must(cols1 > 0 and cols2 > 0, f"Columns should be > 0, got {cols1} and {cols2}")
_must(cols1 < single_cols, f"Split pane cols ({cols1}) should be less than original ({single_cols})")
# Verify tmux-compat format also shows two lines with different pane_left
fmt = "#{pane_id}\t#{pane_width}\t#{pane_left}"
proc = _run_tmux_compat(cli, ["list-panes", "-t", f"%{p1['id']}", "-F", fmt])
_must(proc.returncode == 0, f"list-panes after split failed: {proc.stderr}")
lines = [l for l in proc.stdout.strip().split("\n") if l.strip()]
_must(len(lines) == 2, f"Expected 2 lines after split, got {len(lines)}: {proc.stdout!r}")
# Clean up
c.close_workspace(ws)
print("PASS")
def main() -> int:
cli = _find_cli_binary()
print(f"Using CLI: {cli}")
print(f"Socket: {SOCKET_PATH}")
passed = 0
failed = 0
errors = []
with cmux(SOCKET_PATH) as c:
tests = [
("test_pane_list_geometry_fields", lambda: test_pane_list_geometry_fields(c)),
("test_tmux_version", lambda: test_tmux_version(cli)),
("test_list_panes_geometry_format", lambda: test_list_panes_geometry_format(cli)),
("test_list_panes_pane_target", lambda: test_list_panes_pane_target(cli, c)),
("test_display_geometry_format", lambda: test_display_geometry_format(cli)),
("test_multi_pane_geometry", lambda: test_multi_pane_geometry(cli, c)),
]
for name, test_fn in tests:
try:
test_fn()
passed += 1
except Exception as e:
failed += 1
errors.append((name, str(e)))
print(f"FAIL: {e}")
print(f"\n{'=' * 60}")
print(f"Results: {passed} passed, {failed} failed, {passed + failed} total")
if errors:
print("\nFailures:")
for name, err in errors:
print(f" {name}: {err}")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())