Skip to content

Fix claude-teams pane anchoring with main-vertical layout - #2119

Merged
lawrencecchen merged 3 commits into
mainfrom
issue-2118-claude-teams-pane-anchoring
Mar 26, 2026
Merged

lawrencecchen merged 3 commits into
mainfrom
issue-2118-claude-teams-pane-anchoring

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Implement select-layout main-vertical in the tmux compat layer so teammate panes stack vertically in a right-side column instead of creating nested horizontal splits
  • Pass focus: false to surface.split from the split-window handler so focus stays on the leader pane
  • Fix tmuxCompatStoreURL to respect $HOME env var

Testing

All claude-teams CLI tests pass:

PASS: cmux claude-teams injects the auto-mode tmux env and shim
PASS: cmux claude-teams reuses an existing tmux shim
PASS: cmux claude-teams forwards --help to Claude
PASS: main-vertical layout stacks teammates vertically
PASS: cmux claude-teams skips cmux wrapper scripts on PATH
PASS: cmux claude-teams supports Claude's tmux teammate flow

New test (test_cli_claude_teams_main_vertical.py) verifies 3 teammates: first splits right of leader, second and third split down from the right column (verified via fake server split_calls recording).

Related


Summary by cubic

Teammate panes in Claude Teams now stack vertically in a right-side column when using the main-vertical layout, and focus stays on the leader pane. Also fixes the tmux compat store path to respect the $HOME env var.

  • New Features

    • Implemented select-layout main-vertical in the tmux compat layer with per-workspace state, redirecting subsequent leader right-splits to vertical splits in the right column.
  • Bug Fixes

    • Passed focus: false to surface.split from the split-window handler to keep focus on the leader pane.
    • Updated tmuxCompatStoreURL to correctly use $HOME for the compat store file path.

Written for commit d140f29. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Support for a "main-vertical" workspace layout with smarter split placement and leader-anchored teammate panes
    • Optional split focus parameter to keep the leader pane focused during teammate creation
  • Improvements

    • Improved split-window handling and layout selection to consistently seed and reuse right-column targets across operations
  • Tests

    • Added a regression test that verifies teammate split sequencing, targets, and focus behavior

Claude's agent teams sends `split-window -h` for each teammate then
`select-layout main-vertical` to stack them vertically. Three fixes:

1. Implement select-layout main-vertical: track layout state in the
   tmux compat store so subsequent horizontal splits of the leader
   pane get redirected to vertical splits of the right-side column.

2. Pass focus:false to surface.split from the split-window handler
   so internal bonsplit focus stays on the leader pane.

3. Fix tmuxCompatStoreURL to respect $HOME env var (was using
   NSString.expandingTildeInPath which ignores $HOME).

Closes #2118
@vercel

vercel Bot commented Mar 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 26, 2026 8:37am

@coderabbitai

coderabbitai Bot commented Mar 25, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Updated tmux-compat split handling to support "main-vertical" teammate layouts: splits now target a tracked right-column surface (or leader's canonical surface) with focus explicitly disabled, persistent per-workspace state is recorded, and CLI split requests accept an explicit focus flag.

Changes

Cohort / File(s) Summary
Tmux-compat split & state
CLI/cmux.swift
Redirects split-window targets when main-vertical is active, uses caller's canonical surface id when appropriate, sets focus:false on created surfaces, introduces MainVerticalState, mainVerticalLayouts, lastSplitSurface, and updates store path to ~/.cmuxterm/tmux-compat-store.json.
Split focus propagation
Sources/TerminalController.swift
Parses optional focus param for v2SurfaceSplit and forwards it to tabManager.newSplit(...) so clients can request non-focusing splits.
Regression test (claude teams)
tests/test_cli_claude_teams_main_vertical.py
New end-to-end test simulating Claude teammate flow: asserts 3 surface.split calls, verifies first is right of leader, subsequent splits target down of prior teammate surface, ensures focus=false and leader retains focus.

Sequence Diagram

sequenceDiagram
    participant Claude as Claude Client
    participant Compat as Tmux-Compat Handler
    participant Store as Compat Store
    participant Surface as Surface API

    Claude->>Compat: split-window -h (from leader)
    Compat->>Store: maybe init/record lastSplitSurface
    Compat->>Surface: surface.split(target=leaderSurface/right, focus=false)
    Surface-->>Compat: new teammate surface A

    Claude->>Compat: select-layout main-vertical
    Compat->>Store: enable MainVerticalState for workspace

    Claude->>Compat: split-window -h (leader again)
    Compat->>Store: lookup right-column target (lastSplitSurface / column)
    Compat->>Compat: redirect -> target=surface A, direction=down
    Compat->>Surface: surface.split(target=surface A/down, focus=false)
    Surface-->>Compat: new teammate surface B
    Compat->>Store: update lastSplitSurface / main-vertical tracking
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I dug a tunnel, neat and clever,

teammates stacked to the right, forever.
Focus stays on the leader, steady and bright,
splits march down in a tidy vertical sight.
Hooray — no wandering panes tonight! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main objective: fixing pane anchoring behavior in the main-vertical layout for claude-teams.
Description check ✅ Passed The description follows the template with Summary, Testing, and Related sections; comprehensively explains the three key changes and includes test results and issue reference.
Linked Issues check ✅ Passed The PR successfully addresses the three main objectives from issue #2118: implemented select-layout main-vertical handling [#2118], passed focus:false to surface.split [#2118], and fixed tmuxCompatStoreURL for $HOME [#2118].
Out of Scope Changes check ✅ Passed All changes are directly related to the linked issue #2118: tmux compat layer enhancements, focus parameter handling, store path fixes, and supporting test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2118-claude-teams-pane-anchoring

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d140f29181

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CLI/cmux.swift
Comment on lines +9838 to +9842
var mainVerticalLayouts: [String: MainVerticalState] = [:]
/// Tracks the last surface created by split-window per workspace.
/// Used to seed lastColumnSurfaceId when select-layout main-vertical
/// is called after the first split.
var lastSplitSurface: [String: String] = [:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve backward decoding of tmux compat store

The two new non-optional Codable properties make older tmux-compat-store.json files (written before this commit) fail to decode, and loadTmuxCompatStore() then silently falls back to an empty store. That drops previously persisted buffers/hooks on first read after upgrade, which can break tmux-compat behavior until the store is rebuilt. Please decode these new fields with backward-compatible defaults (e.g., optional fields or a custom init(from:)).

Useful? React with 👍 / 👎.

Comment thread CLI/cmux.swift
Comment on lines +9801 to +9802
if layoutName == "main-vertical" {
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset main-vertical redirect state for other layouts

This branch only updates state when layoutName == "main-vertical" and never clears prior mainVerticalLayouts state when another layout is selected. After one main-vertical call, a later select-layout to a different layout still leaves redirection active, so subsequent split-window -h calls from the leader can be incorrectly forced into down splits using stale column state.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the claude-teams pane layout so that teammate panes stack vertically in a right-side column (matching real tmux main-vertical semantics) instead of nesting successive horizontal splits. It introduces three coordinated changes: a select-layout main-vertical handler in the tmux compat layer that seeds per-workspace layout state, a redirect in the split-window handler that detects active main-vertical state and converts right-of-leader splits into downward splits on the bottom-most right-column pane, and a focus: false parameter threaded from the CLI split call through v2SurfaceSplit to tabManager.newSplit so keyboard focus stays on the leader.

  • CLI/cmux.swift: select-layout main-vertical now seeds MainVerticalState (leader surface + bottom-column surface) in TmuxCompatStore; subsequent split-window -h targeting the leader redirects to a down split of lastColumnSurfaceId and updates the pointer; tmuxCompatStoreURL() now prefers the $HOME env var over hard-coded tilde expansion, fixing the store path in non-standard environments.
  • Sources/TerminalController.swift: v2SurfaceSplit reads a focus boolean param (defaulting to true) and forwards it to tabManager.newSplit, enabling the focus-preservation behaviour.
  • tests/test_cli_claude_teams_main_vertical.py: New end-to-end regression test with a full fake server harness; verifies that three successive teammate splits produce right → down → down directions, target the correct surface at each step, and all carry focus=false.
  • Minor: target.paneId in the split redirect retains the leader's pane ID even after the surface is redirected to a teammate pane — harmless today but worth tidying.
  • Minor: mainVerticalLayouts and lastSplitSurface store entries are never removed when a workspace is closed, causing slow unbounded growth of the JSON file over time.

Confidence Score: 4/5

  • Safe to merge; the core layout logic is correct and all six CLI tests pass, with only minor housekeeping gaps remaining.
  • All three changes are narrow and well-scoped. The redirect algorithm, the seeding/update mechanism, and the focus-preservation path are each verified by the new regression test. The remaining issues are both P2: the paneId inconsistency in the redirected target tuple (no runtime impact), and the lack of store-entry cleanup on workspace close (slow growth, not a correctness bug). Neither blocks the primary user path.
  • No files require special attention; CLI/cmux.swift has the most logic but is well-exercised by the new test.

Important Files Changed

Filename Overview
CLI/cmux.swift Core logic for main-vertical layout: new select-layout handler seeds MainVerticalState, split-window redirects right splits to vertical when state is active, tmuxCompatStoreURL now correctly honours $HOME. The TOCTOU double-load design for the store is intentional and sound. Minor: target.paneId becomes inconsistent with the redirected surface after the redirect; store entries for closed workspaces accumulate indefinitely.
Sources/TerminalController.swift Minimal, correct change: reads the new focus boolean parameter (defaulting to true to preserve existing behaviour) and forwards it to tabManager.newSplit.
tests/test_cli_claude_teams_main_vertical.py New regression test with a full fake-server harness. Verifies direction, target surface, and focus=false for all three splits. The fake claude binary ignores its --version argument and always runs the full teammate flow — this is functional but could confuse future maintainers.

Sequence Diagram

sequenceDiagram
    participant Claude as Claude binary
    participant Shim as tmux shim (cmux)
    participant Store as TmuxCompatStore (JSON)
    participant Server as cmux surface.split

    Note over Claude,Server: First teammate
    Claude->>Shim: tmux split-window -h -t $TMUX_PANE
    Shim->>Store: loadTmuxCompatStore() — no mainVertical state yet
    Shim->>Server: surface.split(surface=leader, direction=right, focus=false)
    Server-->>Shim: {surface_id: T1}
    Shim->>Store: save lastSplitSurface[ws]=T1

    Claude->>Shim: tmux select-layout main-vertical
    Shim->>Store: loadTmuxCompatStore()
    Note right of Shim: seedColumn = lastSplitSurface[ws] = T1
    Shim->>Store: save mainVerticalLayouts[ws]={main=leader, lastCol=T1}

    Note over Claude,Server: Second teammate (redirect kicks in)
    Claude->>Shim: tmux split-window -h -t $TMUX_PANE
    Shim->>Store: loadTmuxCompatStore() — mainVertical active, lastCol=T1
    Note right of Shim: Redirect: target→T1, direction→down
    Shim->>Server: surface.split(surface=T1, direction=down, focus=false)
    Server-->>Shim: {surface_id: T2}
    Shim->>Store: save lastSplitSurface[ws]=T2, mainVerticalLayouts[ws].lastCol=T2

    Claude->>Shim: tmux select-layout main-vertical
    Shim->>Store: update mainVerticalLayouts[ws].lastCol=T2 (no change)

    Note over Claude,Server: Third teammate (same pattern, last col = T2)
    Claude->>Shim: tmux split-window -h -t $TMUX_PANE
    Shim->>Store: loadTmuxCompatStore() — lastCol=T2
    Note right of Shim: Redirect: target→T2, direction→down
    Shim->>Server: surface.split(surface=T2, direction=down, focus=false)
    Server-->>Shim: {surface_id: T3}
    Shim->>Store: save lastCol=T3
Loading

Reviews (1): Last reviewed commit: "Fix claude-teams pane anchoring: main-ve..." | Re-trigger Greptile

Comment thread CLI/cmux.swift Outdated
target.surfaceId == mvState.mainSurfaceId,
let lastColumn = mvState.lastColumnSurfaceId {
// Split the bottom pane of the right column downward.
target = (target.workspaceId, target.paneId, lastColumn)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Mismatched paneId in redirected target

After the redirect, target.paneId still holds the leader's pane ID even though lastColumn (now target.surfaceId) belongs to a completely different teammate pane. This doesn't break anything today — paneId is unused in the subsequent surface.split call and only workspaceId / surfaceId are forwarded — but the tuple is internally inconsistent and could cause confusion if a future code path reads target.paneId expecting it to be the owning pane of target.surfaceId.

Consider either documenting the invariant explicitly or resolving the pane ID for lastColumn:

Suggested change
target = (target.workspaceId, target.paneId, lastColumn)
target = (target.workspaceId, nil, lastColumn)

Using nil makes the inconsistency explicit without requiring an extra network round-trip, and matches the way tmuxResolveSurfaceTarget returns nil for paneId in the window-target path.

Comment on lines +331 to +338
proc = subprocess.run(
[cli_path, "claude-teams", "--version"],
capture_output=True,
text=True,
check=False,
env=env,
timeout=30,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 --version flag relies on fake claude ignoring its arguments

The test invokes claude-teams --version, which forwards --version to the fake claude binary. That binary is written to unconditionally run the full teammate flow regardless of the argument passed to it (it never inspects $@). So the test relies on an implicit contract: the fake binary silently ignores --version and proceeds. This is non-obvious to future maintainers and would silently stop exercising the teammate flow if a later iteration of the fake binary ever added early-exit argument handling.

Consider either:

  • Dropping the --version and just invoking claude-teams directly (if that is a valid invocation that runs the flow), or
  • Using a more intent-revealing name like cmux claude-teams run-teammate-flow if such an alias exists, or
  • Adding a brief inline comment explaining why --version is used here.

Comment thread CLI/cmux.swift
Comment on lines 9836 to 9843
var hooks: [String: String] = [:]
/// Tracks main-vertical layout state per workspace, keyed by workspace ID.
var mainVerticalLayouts: [String: MainVerticalState] = [:]
/// Tracks the last surface created by split-window per workspace.
/// Used to seed lastColumnSurfaceId when select-layout main-vertical
/// is called after the first split.
var lastSplitSurface: [String: String] = [:]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Store entries never purged on workspace close

mainVerticalLayouts and lastSplitSurface are keyed by workspace UUID and written on every split-window and select-layout main-vertical call, but there is no code path that removes entries when a workspace is closed (the kill-window / killw handler only calls workspace.close and returns). With UUIDs as keys the chance of a collision is negligible, but over a long-lived install the JSON store file will grow monotonically.

Consider pruning the stale entries inside the existing kill-window / killw handler after the workspace.close call, mirroring the pattern already used elsewhere in the file for buffer/hook cleanup.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
tests/test_cli_claude_teams_main_vertical.py (1)

294-320: Consider verifying RESULT_LOG contents or removing unused file creation.

The fake claude script writes pane IDs to RESULT_LOG (line 318), but the test never reads or verifies this file. While it may be useful for manual debugging, it could be:

  1. Removed if not needed
  2. Verified in assertions for additional confidence

This is a minor observation and doesn't affect test correctness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_cli_claude_teams_main_vertical.py` around lines 294 - 320, The
fake "claude" stub created via make_executable writes pane IDs to RESULT_LOG but
the test never consumes that file; either remove the printf >> "$RESULT_LOG"
line from the generated script to avoid creating an unused artifact, or add an
assertion in the test to open RESULT_LOG and validate its contents (e.g., check
it contains three non-empty pane IDs or specific expected values) after invoking
the stub; update references around make_executable and RESULT_LOG so the
produced script and the test remain consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 9798-9815: When handling the "select-layout" case, if layoutName
is not "main-vertical" you must clear any persisted main-vertical state for that
workspace so future splits don't reuse the old column; after resolving
workspaceId (tmuxResolveWorkspaceTarget) and loading the store
(loadTmuxCompatStore()), remove store.mainVerticalLayouts[workspaceId] (or set
it to nil) and save with try saveTmuxCompatStore(store); keep the existing
main-vertical branch unchanged but ensure non-main-vertical paths explicitly
delete the entry to avoid stale redirection on later split-window -h calls.
- Around line 9535-9562: The resize handling currently returns early when tmux
emits only -x/-y (no -L/-R/-U/-D), so after you fixed split routing you also
need to apply those resize-pane deltas to the correct surface in main-vertical
mode: in runClaudeTeamsTmuxCompat(...) detect resize-pane calls that specify
width/height (-x/-y) but no direction and, if
loadTmuxCompatStore().mainVerticalLayouts contains an entry for the
target.workspaceId with target.surfaceId == mvState.mainSurfaceId and a
lastColumnSurfaceId, forward the resize to mvState.lastColumnSurfaceId (use the
same numeric -x/-y values) instead of returning early; reference the existing
target/direction handling and mvState.lastColumnSurfaceId to locate where to add
this special-case resize routing.
- Around line 9568-9575: The code currently updates
mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId for every split;
change it so lastColumnSurfaceId is only updated for splits that actually extend
the right column: detect the split kind/axis (e.g. only when the split is a
horizontal/right-column extension, e.g. splitAxis == .horizontal or splitFlag ==
"-h") and additionally validate stored state to avoid poisoning with stale IDs
by ensuring the split source matches the current expected right-column surface
(compare the pre-update
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId or
updatedStore.lastSplitSurface[target.workspaceId] against the split source
surface id) before assigning lastColumnSurfaceId = surfaceId; leave
saveTmuxCompatStore(updatedStore) as-is but skip the lastColumnSurfaceId
mutation when these checks fail.

In `@Sources/TerminalController.swift`:
- Around line 4569-4570: The code currently passes the raw focus flag from
v2Bool into tabManager.newSplit(...), allowing non-focus-intent socket/CLI
commands (surface.split) to change app focus; instead, gate focus through
v2FocusAllowed by computing focusAllowed = v2FocusAllowed(params) and only
honoring the v2Bool(params, "focus") (or default true) when focusAllowed is
true, otherwise pass false into tabManager.newSplit/tabManager.newTerminalSplit;
update the call site (the block with v2Bool(...), tabManager.newSplit(tabId:
ws.id, ...)) to use this guarded focus value so only explicit focus-intent
commands can mutate in-app focus/selection.

---

Nitpick comments:
In `@tests/test_cli_claude_teams_main_vertical.py`:
- Around line 294-320: The fake "claude" stub created via make_executable writes
pane IDs to RESULT_LOG but the test never consumes that file; either remove the
printf >> "$RESULT_LOG" line from the generated script to avoid creating an
unused artifact, or add an assertion in the test to open RESULT_LOG and validate
its contents (e.g., check it contains three non-empty pane IDs or specific
expected values) after invoking the stub; update references around
make_executable and RESULT_LOG so the produced script and the test remain
consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 62c3caa9-93cf-46aa-9f13-420d677072e1

📥 Commits

Reviewing files that changed from the base of the PR and between 71828fe and d140f29.

📒 Files selected for processing (3)
  • CLI/cmux.swift
  • Sources/TerminalController.swift
  • tests/test_cli_claude_teams_main_vertical.py

Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift
Comment on lines +9798 to +9815
case "select-layout":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let layoutName = parsed.positional.first ?? ""
if layoutName == "main-vertical" {
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
if let callerSurface = tmuxCallerSurfaceHandle() {
var store = loadTmuxCompatStore()
// Seed lastColumnSurfaceId from the most recent split if
// this is the first time main-vertical is set and a split
// already happened (the normal flow: split then layout).
let existingColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId
let seedColumn = existingColumn ?? store.lastSplitSurface[workspaceId]
store.mainVerticalLayouts[workspaceId] = MainVerticalState(
mainSurfaceId: callerSurface,
lastColumnSurfaceId: seedColumn
)
try saveTmuxCompatStore(store)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clear persisted main-vertical state when another layout is selected.

This branch only mutates state for main-vertical. If the workspace later runs select-layout tiled/even-*, the old mainVerticalLayouts[workspaceId] entry survives and future split-window -h calls still get redirected into the remembered right column even though the layout changed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 9798 - 9815, When handling the "select-layout"
case, if layoutName is not "main-vertical" you must clear any persisted
main-vertical state for that workspace so future splits don't reuse the old
column; after resolving workspaceId (tmuxResolveWorkspaceTarget) and loading the
store (loadTmuxCompatStore()), remove store.mainVerticalLayouts[workspaceId] (or
set it to nil) and save with try saveTmuxCompatStore(store); keep the existing
main-vertical branch unchanged but ensure non-main-vertical paths explicitly
delete the entry to avoid stale redirection on later split-window -h calls.

Comment on lines +4569 to +4570
let focus = v2Bool(params, "focus") ?? true
if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Gate split focus through v2FocusAllowed.

Line 4569 forwards the raw focus flag into tabManager.newSplit(...), and that flag ultimately drives Workspace.newTerminalSplit(..., focus:), which changes the focused panel. Since surface.split is not a focus-intent socket command, this still lets callers steal in-app focus by omitting focus or sending true.

Suggested fix
-            let focus = v2Bool(params, "focus") ?? true
+            let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
             if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {

Based on learnings: Socket/CLI commands must not steal macOS app focus. Only explicit focus-intent commands may mutate in-app focus/selection.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let focus = v2Bool(params, "focus") ?? true
if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 4569 - 4570, The code
currently passes the raw focus flag from v2Bool into tabManager.newSplit(...),
allowing non-focus-intent socket/CLI commands (surface.split) to change app
focus; instead, gate focus through v2FocusAllowed by computing focusAllowed =
v2FocusAllowed(params) and only honoring the v2Bool(params, "focus") (or default
true) when focusAllowed is true, otherwise pass false into
tabManager.newSplit/tabManager.newTerminalSplit; update the call site (the block
with v2Bool(...), tabManager.newSplit(tabId: ws.id, ...)) to use this guarded
focus value so only explicit focus-intent commands can mutate in-app
focus/selection.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/TerminalController.swift">

<violation number="1" location="Sources/TerminalController.swift:4569">
P1: `surface.split` bypasses focus-mutation policy by using raw `focus` instead of `v2FocusAllowed(...)`, which can unexpectedly move focus during non-focus socket commands.</violation>
</file>

<file name="CLI/cmux.swift">

<violation number="1" location="CLI/cmux.swift:9572">
P2: `lastColumnSurfaceId` is updated for every split while main-vertical is enabled, which can corrupt the right-column anchor and misroute later teammate splits.</violation>

<violation number="2" location="CLI/cmux.swift:9801">
P2: Clear stored `mainVerticalLayouts` state when `select-layout` switches to a non-`main-vertical` layout. Leaving stale state active can incorrectly force later `split-window -h` calls into `down` splits.</violation>

<violation number="3" location="CLI/cmux.swift:9838">
P1: Add backward-compatible decoding defaults for the new tmux-compat store fields. Older store files that do not contain these keys should still decode instead of resetting persisted state.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

v2MaybeSelectWorkspace(tabManager, workspace: ws)

if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) {
let focus = v2Bool(params, "focus") ?? true

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: surface.split bypasses focus-mutation policy by using raw focus instead of v2FocusAllowed(...), which can unexpectedly move focus during non-focus socket commands.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TerminalController.swift, line 4569:

<comment>`surface.split` bypasses focus-mutation policy by using raw `focus` instead of `v2FocusAllowed(...)`, which can unexpectedly move focus during non-focus socket commands.</comment>

<file context>
@@ -4566,7 +4566,8 @@ class TerminalController {
             v2MaybeSelectWorkspace(tabManager, workspace: ws)
 
-            if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) {
+            let focus = v2Bool(params, "focus") ?? true
+            if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) {
                 let paneUUID = ws.paneId(forPanelId: newId)?.id
</file context>
Suggested change
let focus = v2Bool(params, "focus") ?? true
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
Fix with Cubic

Comment thread CLI/cmux.swift
var buffers: [String: String] = [:]
var hooks: [String: String] = [:]
/// Tracks main-vertical layout state per workspace, keyed by workspace ID.
var mainVerticalLayouts: [String: MainVerticalState] = [:]

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Add backward-compatible decoding defaults for the new tmux-compat store fields. Older store files that do not contain these keys should still decode instead of resetting persisted state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 9838:

<comment>Add backward-compatible decoding defaults for the new tmux-compat store fields. Older store files that do not contain these keys should still decode instead of resetting persisted state.</comment>

<file context>
@@ -9769,22 +9795,59 @@ struct CMUXCLI {
         var buffers: [String: String] = [:]
         var hooks: [String: String] = [:]
+        /// Tracks main-vertical layout state per workspace, keyed by workspace ID.
+        var mainVerticalLayouts: [String: MainVerticalState] = [:]
+        /// Tracks the last surface created by split-window per workspace.
+        /// Used to seed lastColumnSurfaceId when select-layout main-vertical
</file context>
Fix with Cubic

Comment thread CLI/cmux.swift
Comment thread CLI/cmux.swift
…t split

The split-window routing was broken in two ways:

1. After the first teammate split (right), the main-vertical state wasn't
   being created, so all subsequent splits also went right instead of
   stacking down in the right column.

2. The old code only redirected splits when main-vertical was already active
   AND the target was the leader surface. Claude's teams protocol targets
   arbitrary panes from list-panes, not necessarily the leader.

Now the first teammate split goes right (creating the column), auto-seeds
the main-vertical state, and all subsequent splits stack downward. The
caller's surface (CMUX_SURFACE_ID) is used as the anchor regardless of
which pane Claude targets.

Also adds caller surface preference in pane target resolution so the
caller's exact surface is used when the target pane matches, preventing
stale selected-surface references after tab switches.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af0290cb87

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CLI/cmux.swift
Comment on lines +9608 to +9612
} else if direction == "right", let callerSurface = tmuxCallerSurfaceHandle() {
// First right split created the column; seed main-vertical
// state so subsequent splits stack downward.
updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
mainSurfaceId: callerSurface,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Initialize main-vertical state only after layout command

This block creates mainVerticalLayouts on any first right split, even when select-layout main-vertical was never issued. Because later split-window calls check only whether mainVerticalLayouts[wsId] exists, the second split in the workspace is redirected to down unexpectedly, changing normal tmux split behavior and causing unintended vertical stacking in flows that never opted into main-vertical.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="CLI/cmux.swift">

<violation number="1" location="CLI/cmux.swift:9577">
P2: The new workspace fallback can pair `target.workspaceId` with `callerSurface`, creating an invalid cross-workspace `surface.split` request.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread CLI/cmux.swift
let store = loadTmuxCompatStore()
if let callerSurface = tmuxCallerSurfaceHandle(),
let callerWorkspace = tmuxCallerWorkspaceHandle() {
let wsId = (try? resolveWorkspaceId(callerWorkspace, client: client)) ?? target.workspaceId

@cubic-dev-ai cubic-dev-ai Bot Mar 26, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The new workspace fallback can pair target.workspaceId with callerSurface, creating an invalid cross-workspace surface.split request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 9577:

<comment>The new workspace fallback can pair `target.workspaceId` with `callerSurface`, creating an invalid cross-workspace `surface.split` request.</comment>

<file context>
@@ -9556,17 +9568,23 @@ struct CMUXCLI {
-                direction = "down"
+            if let callerSurface = tmuxCallerSurfaceHandle(),
+               let callerWorkspace = tmuxCallerWorkspaceHandle() {
+                let wsId = (try? resolveWorkspaceId(callerWorkspace, client: client)) ?? target.workspaceId
+                if let mvState = store.mainVerticalLayouts[wsId],
+                   let lastColumn = mvState.lastColumnSurfaceId {
</file context>
Fix with Cubic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
CLI/cmux.swift (2)

9839-9857: ⚠️ Potential issue | 🟠 Major

Clear persisted main-vertical state when another layout is selected.

This branch only seeds state for main-vertical. If the workspace later selects a different layout, the old right-column anchor survives and future splits still get redirected as if main-vertical were active.

Suggested fix
         case "select-layout":
             let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
+            let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
             let layoutName = parsed.positional.first ?? ""
             if layoutName == "main-vertical" {
-                let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
                 if let callerSurface = tmuxCallerSurfaceHandle() {
                     var store = loadTmuxCompatStore()
                     // Seed lastColumnSurfaceId from the most recent split if
                     // this is the first time main-vertical is set and a split
                     // already happened (the normal flow: split then layout).
@@
                     )
                     try saveTmuxCompatStore(store)
                 }
+            } else {
+                var store = loadTmuxCompatStore()
+                store.mainVerticalLayouts.removeValue(forKey: workspaceId)
+                try saveTmuxCompatStore(store)
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 9839 - 9857, When handling the "select-layout"
branch, ensure that when the chosen layout is not "main-vertical" you clear any
persisted main-vertical state for the resolved workspace so stale
lastColumnSurfaceId doesn't affect future splits; after calling
tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) and obtaining the
workspaceId (same place you currently seed MainVerticalState when layoutName ==
"main-vertical"), load the store via loadTmuxCompatStore(), remove or nil out
store.mainVerticalLayouts[workspaceId] (or delete the entry), and persist the
change with try saveTmuxCompatStore(store) so selecting any other layout clears
the old right-column anchor.

9606-9614: ⚠️ Potential issue | 🟠 Major

Only advance the right-column anchor when that column actually grew.

Once mainVerticalLayouts[target.workspaceId] exists, this updates lastColumnSurfaceId after every split. A split on some other pane will poison the stored anchor, and the next teammate gets redirected to the wrong surface.

Suggested fix
             do {
                 var updatedStore = loadTmuxCompatStore()
                 updatedStore.lastSplitSurface[target.workspaceId] = surfaceId
-                if updatedStore.mainVerticalLayouts[target.workspaceId] != nil {
-                    updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId
+                if let mvState = updatedStore.mainVerticalLayouts[target.workspaceId],
+                   direction == "down",
+                   target.surfaceId == mvState.lastColumnSurfaceId {
+                    updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId
                 } else if direction == "right", let callerSurface = tmuxCallerSurfaceHandle() {
                     // First right split created the column; seed main-vertical
                     // state so subsequent splits stack downward.
                     updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 9606 - 9614, The current code unconditionally
updates
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId on any
split once the workspace entry exists, poisoning the right-column anchor; change
the update so lastColumnSurfaceId is only advanced when the split actually grew
that right-most column — e.g., obtain the caller surface via
tmuxCallerSurfaceHandle() (or the split origin), verify the split direction is
"right" and that the caller surface is the current lastColumnSurfaceId (or that
the new surface belongs to the same right-most column) before assigning
lastColumnSurfaceId = surfaceId; keep the existing seeding logic that creates
MainVerticalState when the entry is missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 9603-9616: The load→mutate→save sequence for tmux-compat state
(loadTmuxCompatStore → mutate lastSplitSurface/mainVerticalLayouts →
saveTmuxCompatStore) must be protected with the same lock-file pattern used by
ClaudeHookSessionStore to prevent cross-process races; acquire the lock before
calling loadTmuxCompatStore, perform the mutations (including the
MainVerticalState seeding that uses tmuxCallerSurfaceHandle and updates
lastSplitSurface and
mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId), then
saveTmuxCompatStore and release the lock; apply the same locking change to the
other similar block around lines 9845–9855.

---

Duplicate comments:
In `@CLI/cmux.swift`:
- Around line 9839-9857: When handling the "select-layout" branch, ensure that
when the chosen layout is not "main-vertical" you clear any persisted
main-vertical state for the resolved workspace so stale lastColumnSurfaceId
doesn't affect future splits; after calling
tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) and obtaining the
workspaceId (same place you currently seed MainVerticalState when layoutName ==
"main-vertical"), load the store via loadTmuxCompatStore(), remove or nil out
store.mainVerticalLayouts[workspaceId] (or delete the entry), and persist the
change with try saveTmuxCompatStore(store) so selecting any other layout clears
the old right-column anchor.
- Around line 9606-9614: The current code unconditionally updates
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId on any
split once the workspace entry exists, poisoning the right-column anchor; change
the update so lastColumnSurfaceId is only advanced when the split actually grew
that right-most column — e.g., obtain the caller surface via
tmuxCallerSurfaceHandle() (or the split origin), verify the split direction is
"right" and that the caller surface is the current lastColumnSurfaceId (or that
the new surface belongs to the same right-most column) before assigning
lastColumnSurfaceId = surfaceId; keep the existing seeding logic that creates
MainVerticalState when the entry is missing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a6a9b8e0-cee1-4e45-a7b2-de4796614156

📥 Commits

Reviewing files that changed from the base of the PR and between d140f29 and af0290c.

📒 Files selected for processing (2)
  • CLI/cmux.swift
  • Sources/TerminalController.swift
✅ Files skipped from review due to trivial changes (1)
  • Sources/TerminalController.swift

Comment thread CLI/cmux.swift
Comment on lines +9603 to +9616
do {
var updatedStore = loadTmuxCompatStore()
updatedStore.lastSplitSurface[target.workspaceId] = surfaceId
if updatedStore.mainVerticalLayouts[target.workspaceId] != nil {
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId
} else if direction == "right", let callerSurface = tmuxCallerSurfaceHandle() {
// First right split created the column; seed main-vertical
// state so subsequent splits stack downward.
updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
mainSurfaceId: callerSurface,
lastColumnSurfaceId: surfaceId
)
}
try saveTmuxCompatStore(updatedStore)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Protect tmux-compat store updates with a lock.

These are now cross-process load → mutate → save sequences on shared JSON state. Two overlapping shim invocations can lose lastSplitSurface / lastColumnSurfaceId updates and make later split routing nondeterministic. The lock-file pattern already used by ClaudeHookSessionStore earlier in this file fits here.

Also applies to: 9845-9855

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 9603 - 9616, The load→mutate→save sequence for
tmux-compat state (loadTmuxCompatStore → mutate
lastSplitSurface/mainVerticalLayouts → saveTmuxCompatStore) must be protected
with the same lock-file pattern used by ClaudeHookSessionStore to prevent
cross-process races; acquire the lock before calling loadTmuxCompatStore,
perform the mutations (including the MainVerticalState seeding that uses
tmuxCallerSurfaceHandle and updates lastSplitSurface and
mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId), then
saveTmuxCompatStore and release the lock; apply the same locking change to the
other similar block around lines 9845–9855.

@lawrencecchen
lawrencecchen merged commit f507fd8 into main Mar 26, 2026
20 checks passed
@lawrencecchen
lawrencecchen deleted the issue-2118-claude-teams-pane-anchoring branch March 26, 2026 21:36
lawrencecchen added a commit that referenced this pull request Mar 26, 2026
- 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
lawrencecchen added a commit that referenced this pull request Mar 26, 2026
* 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 #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>
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…i#2119)

* Fix claude-teams pane anchoring: main-vertical layout + focus

Claude's agent teams sends `split-window -h` for each teammate then
`select-layout main-vertical` to stack them vertically. Three fixes:

1. Implement select-layout main-vertical: track layout state in the
   tmux compat store so subsequent horizontal splits of the leader
   pane get redirected to vertical splits of the right-side column.

2. Pass focus:false to surface.split from the split-window handler
   so internal bonsplit focus stays on the leader pane.

3. Fix tmuxCompatStoreURL to respect $HOME env var (was using
   NSString.expandingTildeInPath which ignores $HOME).

Closes manaflow-ai#2118

* Fix claude-teams split routing: auto-seed main-vertical on first right split

The split-window routing was broken in two ways:

1. After the first teammate split (right), the main-vertical state wasn't
   being created, so all subsequent splits also went right instead of
   stacking down in the right column.

2. The old code only redirected splits when main-vertical was already active
   AND the target was the leader surface. Claude's teams protocol targets
   arbitrary panes from list-panes, not necessarily the leader.

Now the first teammate split goes right (creating the column), auto-seeds
the main-vertical state, and all subsequent splits stack downward. The
caller's surface (CMUX_SURFACE_ID) is used as the anchor regardless of
which pane Claude targets.

Also adds caller surface preference in pane target resolution so the
caller's exact surface is used when the target pane matches, preventing
stale selected-surface references after tab switches.

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
)

* 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 manaflow-ai#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 manaflow-ai#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>

This branch was successfully deployed

1 active deployment
Preview — af0290cb Deployed Mar 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

claude-teams: pane splits ignore layout, steal focus, and ignore resize

1 participant