Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
697f4ba
Add `cmux omo` command for OpenCode + oh-my-openagent integration
lawrencecchen Mar 25, 2026
60aca2a
Auto-install oh-my-opencode plugin when running cmux omo
lawrencecchen Mar 25, 2026
1b43d33
Use shadow config dir to avoid modifying user's opencode setup
lawrencecchen Mar 25, 2026
763d7b1
Add Agent Integrations docs section with Claude Code Teams and oh-my-…
lawrencecchen Mar 25, 2026
1025196
Remove uppercase from sidebar section headers
lawrencecchen Mar 25, 2026
ca5497e
Add more spacing above and below sidebar section headers
lawrencecchen Mar 25, 2026
b2a0bc7
Enable tmux mode in oh-my-opencode config, improve docs
lawrencecchen Mar 25, 2026
6b42f9f
Add terminal-notifier shim to route oh-my-openagent notifications to …
lawrencecchen Mar 25, 2026
45a0c37
Add pane geometry to tmux-compat for oh-my-openagent grid planning
lawrencecchen Mar 25, 2026
29135b4
Add socket tests for tmux-compat pane geometry
lawrencecchen Mar 25, 2026
44da195
Handle tmux -V in shim script directly (no socket needed)
lawrencecchen Mar 25, 2026
7d3b53f
Lower default tmux pane min widths for cmux omo
lawrencecchen Mar 25, 2026
2aa7f14
Merge origin/main into issue-2085-cmux-omo
lawrencecchen Mar 26, 2026
4622d66
Merge remote-tracking branch 'origin/main' into issue-2085-cmux-omo
lawrencecchen Mar 26, 2026
1804a31
Resolve merge conflicts with main (main-vertical layout, focus param)
lawrencecchen Mar 26, 2026
e5799e5
Implement select-layout equalize and resize-pane absolute width
lawrencecchen Mar 26, 2026
91deb44
Fix equalize to use proportional divider positions
lawrencecchen Mar 26, 2026
31c3c4d
Fix select-layout main-vertical to only equalize vertical splits
lawrencecchen Mar 26, 2026
714fe00
Re-equalize agent column after kill-pane
lawrencecchen Mar 26, 2026
5487d5e
Address PR review comments
lawrencecchen Mar 26, 2026
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
544 changes: 514 additions & 30 deletions CLI/cmux.swift

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,23 @@
}
}
},
"cli.omo.usage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Usage: cmux omo [opencode-args...]\n\nLaunch OpenCode with oh-my-openagent in a cmux-aware environment.\n\noh-my-openagent orchestrates multiple AI models as specialized agents in\nparallel. This command sets up a tmux shim so agent panes become native\ncmux splits with sidebar metadata and notifications.\n\nThis command:\n - sets a tmux-like environment so oh-my-openagent uses cmux splits\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to opencode\n\nThe tmux shim translates tmux window/pane commands into cmux workspace\nand split operations in the current cmux session.\n\nExamples:\n cmux omo\n cmux omo --continue\n cmux omo --model claude-sonnet-4-6"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "使い方: cmux omo [opencode-args...]\n\ncmux 対応の環境で OpenCode と oh-my-openagent を起動します。\n\noh-my-openagent は複数の AI モデルを専門エージェントとして並列に\nオーケストレーションします。このコマンドは tmux shim を設定し、\nエージェントのペインをネイティブの cmux split に変換します。\n\nこのコマンドは次を行います:\n - oh-my-openagent が cmux の split を使うよう tmux 風の環境を設定\n - 専用の tmux shim を PATH の先頭に追加\n - 残りの引数をそのまま opencode に渡す\n\ntmux shim は、tmux の window/pane コマンドを、現在の cmux セッション内の\nworkspace と split 操作に変換します。\n\n例:\n cmux omo\n cmux omo --continue\n cmux omo --model claude-sonnet-4-6"
}
}
}
},
"applescript.error.disabled": {
"extractionState": "manual",
"localizations": {
Expand Down
60 changes: 58 additions & 2 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2071,6 +2071,8 @@ class TerminalController {
return v2Result(id: id, self.v2WorkspacePrevious(params: params))
case "workspace.last":
return v2Result(id: id, self.v2WorkspaceLast(params: params))
case "workspace.equalize_splits":
return v2Result(id: id, self.v2WorkspaceEqualizeSplits(params: params))
case "workspace.remote.configure":
return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params))
case "workspace.remote.reconnect":
Expand Down Expand Up @@ -2443,6 +2445,7 @@ class TerminalController {
"workspace.next",
"workspace.previous",
"workspace.last",
"workspace.equalize_splits",
"workspace.remote.configure",
"workspace.remote.reconnect",
"workspace.remote.disconnect",
Expand Down Expand Up @@ -3672,6 +3675,24 @@ class TerminalController {
return result
}

private func v2WorkspaceEqualizeSplits(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}

var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
let success = tabManager.equalizeSplits(tabId: ws.id)
if success {
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id)])
} else {
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "note": "no splits to equalize"])
}
}
return result
}

private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult {
let requestedWorkspaceId = v2UUID(params, "workspace_id")
if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil {
Expand Down Expand Up @@ -5720,12 +5741,19 @@ class TerminalController {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }

let focusedPaneId = ws.bonsplitController.focusedPaneId
let snapshot = ws.bonsplitController.layoutSnapshot()
let geometryByPaneId = Dictionary(
snapshot.panes.map { ($0.paneId, $0.frame) },
uniquingKeysWith: { first, _ in first }
)

let panes: [[String: Any]] = ws.bonsplitController.allPaneIds.enumerated().map { index, paneId in
let tabs = ws.bonsplitController.tabs(inPane: paneId)
let surfaceUUIDs: [UUID] = tabs.compactMap { ws.panelIdFromSurfaceId($0.id) }
let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId)
let selectedSurfaceUUID = selectedTab.flatMap { ws.panelIdFromSurfaceId($0.id) }
return [

var dict: [String: Any] = [
"id": paneId.id.uuidString,
"ref": v2Ref(kind: .pane, uuid: paneId.id),
"index": index,
Expand All @@ -5736,16 +5764,44 @@ class TerminalController {
"selected_surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceUUID),
"surface_count": surfaceUUIDs.count
]

if let frame = geometryByPaneId[paneId.id.uuidString] {
dict["pixel_frame"] = [
"x": frame.x, "y": frame.y,
"width": frame.width, "height": frame.height
]
}

// Get terminal grid size from the selected surface
if let panelUUID = selectedSurfaceUUID,
let panel = ws.panels[panelUUID] as? TerminalPanel,
panel.surface.hasLiveSurface,
let ghosttySurface = panel.surface.surface {
let size = ghostty_surface_size(ghosttySurface)
if size.columns > 0 && size.rows > 0 {
dict["columns"] = Int(size.columns)
dict["rows"] = Int(size.rows)
dict["cell_width_px"] = Int(size.cell_width_px)
dict["cell_height_px"] = Int(size.cell_height_px)
}
}

return dict
}

let windowId = v2ResolveWindowId(tabManager: tabManager)
payload = [
var payloadDict: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"panes": panes,
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
]
payloadDict["container_frame"] = [
"width": snapshot.containerFrame.width,
"height": snapshot.containerFrame.height
]
payload = payloadDict
}

guard let payload else {
Expand Down
5 changes: 5 additions & 0 deletions scripts/reload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ if [[ -z "$TAG" ]]; then
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
)
fi
# Forward CMUX_SKIP_ZIG_BUILD to xcodebuild run script phases (e.g. macOS
# Tahoe where zig 0.15.2 can't link the ghostty CLI helper).
if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then
XCODEBUILD_ARGS+=(CMUX_SKIP_ZIG_BUILD=1)
fi
XCODEBUILD_ARGS+=(build)

XCODE_LOG="/tmp/cmux-xcodebuild-${TAG_SLUG}.log"
Expand Down
247 changes: 247 additions & 0 deletions tests_v2/test_tmux_compat_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
#!/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()
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Close the temporary workspace in a finally block; current cleanup is skipped when an assertion fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests_v2/test_tmux_compat_geometry.py, line 164:

<comment>Close the temporary workspace in a `finally` block; current cleanup is skipped when an assertion fails.</comment>

<file context>
@@ -0,0 +1,247 @@
+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)
</file context>
Fix with Cubic

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})")
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Validate both split panes against the original width; currently only cols1 is checked.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests_v2/test_tmux_compat_geometry.py, line 195:

<comment>Validate both split panes against the original width; currently only `cols1` is checked.</comment>

<file context>
@@ -0,0 +1,247 @@
+    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
</file context>
Fix with Cubic


# 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())
Loading
Loading