Fix tmux-compat split-window surface resolution (#2351)

* Add tmux-compat split-window ref regression tests

* Fix tmux-compat split-window surface resolution

* Fix stale tmux caller surface fallback

* Add stale tmux-compat split-window regressions

* Fix stale tmux-compat split-window anchors

* Preserve tmux fallback and column anchor
This commit is contained in:
Austin Wang 2026-03-30 03:28:25 -07:00 committed by GitHub
parent 867c93e4fa
commit 2c5c4fcf8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 870 additions and 68 deletions

View file

@ -383,6 +383,18 @@ func tmuxCallerSurfaceHandle() string {
return strings.TrimSpace(os.Getenv("CMUX_SURFACE_ID"))
}
func tmuxResolvedCallerWorkspaceId(rc *rpcContext) string {
caller := tmuxCallerWorkspaceHandle()
if caller == "" {
return ""
}
wsId, err := tmuxResolveWorkspaceId(rc, caller)
if err != nil {
return ""
}
return wsId
}
func tmuxCallerPaneHandle() string {
for _, key := range []string{"TMUX_PANE", "CMUX_PANE_ID"} {
v := strings.TrimSpace(os.Getenv(key))
@ -570,6 +582,29 @@ func tmuxCanonicalPaneId(rc *rpcContext, handle string, workspaceId string) (str
return "", fmt.Errorf("pane not found: %s", handle)
}
func tmuxCanonicalSurfaceId(rc *rpcContext, handle string, workspaceId string) (string, error) {
payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId})
if err != nil {
return "", err
}
surfaces, _ := payload["surfaces"].([]any)
for _, s := range surfaces {
surface, _ := s.(map[string]any)
if surface == nil {
continue
}
if ref, _ := surface["ref"].(string); ref == handle {
if id, _ := surface["id"].(string); id != "" {
return id, nil
}
}
if id, _ := surface["id"].(string); id == handle {
return id, nil
}
}
return "", fmt.Errorf("surface not found: %s", handle)
}
func tmuxFocusedPaneId(rc *rpcContext, workspaceId string) (string, error) {
payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId})
if err != nil {
@ -648,7 +683,7 @@ func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, pane
if err != nil {
return "", "", err
}
} else if callerWs := tmuxCallerWorkspaceHandle(); callerWs == workspaceId {
} else if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId {
if callerPane := tmuxCallerPaneHandle(); callerPane != "" {
if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil {
paneId = pid
@ -707,8 +742,10 @@ func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, p
if callerPane != "" && callerSurface != "" {
canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId)
if paneId == callerPane || paneId == canonicalCallerPane {
surfaceId = callerSurface
return
surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId)
if err == nil {
return
}
}
}
surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId)
@ -723,10 +760,12 @@ func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, p
// When no explicit target and caller workspace matches, use caller's surface
if winSel == "" {
if callerWs := tmuxCallerWorkspaceHandle(); callerWs == workspaceId {
if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId {
if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" {
surfaceId = callerSurface
return
surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId)
if err == nil {
return
}
}
}
}
@ -769,6 +808,58 @@ func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, p
return "", "", "", fmt.Errorf("unable to resolve surface")
}
type tmuxSplitAnchor struct {
targetSurfaceId string
callerSurfaceId string
direction string
}
func tmuxAnchoredSplitTarget(rc *rpcContext, workspaceId string) *tmuxSplitAnchor {
store := loadTmuxCompatStore()
if mvState, ok := store.MainVerticalLayouts[workspaceId]; ok && mvState.LastColumnSurfaceId != "" {
lastColumnId, err := tmuxCanonicalSurfaceId(rc, mvState.LastColumnSurfaceId, workspaceId)
if err == nil {
return &tmuxSplitAnchor{
targetSurfaceId: lastColumnId,
callerSurfaceId: "",
direction: "down",
}
}
// Right-column anchors can outlive the pane they pointed at.
// Drop stale state and rebuild from the caller surface instead.
mvState.LastColumnSurfaceId = ""
store.MainVerticalLayouts[workspaceId] = mvState
delete(store.LastSplitSurface, workspaceId)
_ = saveTmuxCompatStore(store)
}
candidateAnchors := []string{tmuxCallerSurfaceHandle()}
if mvState, ok := store.MainVerticalLayouts[workspaceId]; ok && mvState.MainSurfaceId != "" {
candidateAnchors = append(candidateAnchors, mvState.MainSurfaceId)
}
for _, candidate := range candidateAnchors {
if candidate == "" {
continue
}
anchorSurfaceId, err := tmuxCanonicalSurfaceId(rc, candidate, workspaceId)
if err == nil {
return &tmuxSplitAnchor{
targetSurfaceId: anchorSurfaceId,
callerSurfaceId: anchorSurfaceId,
direction: "right",
}
}
}
if _, ok := store.MainVerticalLayouts[workspaceId]; ok {
delete(store.MainVerticalLayouts, workspaceId)
delete(store.LastSplitSurface, workspaceId)
_ = saveTmuxCompatStore(store)
}
return nil
}
// --- TmuxCompatStore (local JSON state) ---
type mainVerticalState struct {
@ -777,10 +868,10 @@ type mainVerticalState struct {
}
type tmuxCompatStore struct {
Buffers map[string]string `json:"buffers,omitempty"`
Hooks map[string]string `json:"hooks,omitempty"`
MainVerticalLayouts map[string]mainVerticalState `json:"mainVerticalLayouts,omitempty"`
LastSplitSurface map[string]string `json:"lastSplitSurface,omitempty"`
Buffers map[string]string `json:"buffers,omitempty"`
Hooks map[string]string `json:"hooks,omitempty"`
MainVerticalLayouts map[string]mainVerticalState `json:"mainVerticalLayouts,omitempty"`
LastSplitSurface map[string]string `json:"lastSplitSurface,omitempty"`
}
func tmuxCompatStoreURL() string {
@ -834,6 +925,47 @@ func saveTmuxCompatStore(store tmuxCompatStore) error {
return os.WriteFile(path, data, 0644)
}
func tmuxPruneCompatWorkspaceState(workspaceId string) error {
store := loadTmuxCompatStore()
changed := false
if _, ok := store.MainVerticalLayouts[workspaceId]; ok {
delete(store.MainVerticalLayouts, workspaceId)
changed = true
}
if _, ok := store.LastSplitSurface[workspaceId]; ok {
delete(store.LastSplitSurface, workspaceId)
changed = true
}
if changed {
return saveTmuxCompatStore(store)
}
return nil
}
func tmuxPruneCompatSurfaceState(workspaceId string, surfaceId string) error {
store := loadTmuxCompatStore()
changed := false
if lastSplit := store.LastSplitSurface[workspaceId]; lastSplit == surfaceId {
delete(store.LastSplitSurface, workspaceId)
changed = true
}
if layout, ok := store.MainVerticalLayouts[workspaceId]; ok {
if layout.MainSurfaceId == surfaceId {
delete(store.MainVerticalLayouts, workspaceId)
delete(store.LastSplitSurface, workspaceId)
changed = true
} else if layout.LastColumnSurfaceId == surfaceId {
layout.LastColumnSurfaceId = ""
store.MainVerticalLayouts[workspaceId] = layout
changed = true
}
}
if changed {
return saveTmuxCompatStore(store)
}
return nil
}
// --- Special key translation ---
func tmuxSpecialKeyText(token string) string {
@ -1069,20 +1201,16 @@ func tmuxSplitWindow(rc *rpcContext, args []string) error {
direction = "up"
}
// Anchor splits to the leader surface for agent teams
callerSurface := tmuxCallerSurfaceHandle()
// Anchor splits to the leader surface for agent teams.
callerWorkspace := tmuxCallerWorkspaceHandle()
if callerSurface != "" && callerWorkspace != "" {
anchoredCallerSurface := ""
if callerWorkspace != "" {
if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil {
store := loadTmuxCompatStore()
if mvState, ok := store.MainVerticalLayouts[wsId]; ok && mvState.LastColumnSurfaceId != "" {
if anchored := tmuxAnchoredSplitTarget(rc, wsId); anchored != nil {
targetWs = wsId
targetSurface = mvState.LastColumnSurfaceId
direction = "down"
} else {
targetWs = wsId
targetSurface = callerSurface
direction = "right"
targetSurface = anchored.targetSurfaceId
direction = anchored.direction
anchoredCallerSurface = anchored.callerSurfaceId
}
}
}
@ -1110,9 +1238,9 @@ func tmuxSplitWindow(rc *rpcContext, args []string) error {
mvs := store.MainVerticalLayouts[targetWs]
mvs.LastColumnSurfaceId = surfaceId
store.MainVerticalLayouts[targetWs] = mvs
} else if direction == "right" && callerSurface != "" {
} else if direction == "right" && anchoredCallerSurface != "" {
store.MainVerticalLayouts[targetWs] = mainVerticalState{
MainSurfaceId: callerSurface,
MainSurfaceId: anchoredCallerSurface,
LastColumnSurfaceId: surfaceId,
}
}
@ -1178,7 +1306,11 @@ func tmuxKillWindow(rc *rpcContext, args []string) error {
return err
}
_, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId})
return err
if err != nil {
return err
}
_ = tmuxPruneCompatWorkspaceState(wsId)
return nil
}
func tmuxKillPane(rc *rpcContext, args []string) error {
@ -1191,6 +1323,7 @@ func tmuxKillPane(rc *rpcContext, args []string) error {
if err != nil {
return err
}
_ = tmuxPruneCompatSurfaceState(wsId, surfId)
// Re-equalize after removal
rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId, "orientation": "vertical"})
return nil
@ -1576,19 +1709,7 @@ func tmuxSelectLayout(rc *rpcContext, args []string) error {
saveTmuxCompatStore(store)
}
} else if layoutName != "" {
store := loadTmuxCompatStore()
changed := false
if _, ok := store.MainVerticalLayouts[wsId]; ok {
delete(store.MainVerticalLayouts, wsId)
changed = true
}
if _, ok := store.LastSplitSurface[wsId]; ok {
delete(store.LastSplitSurface, wsId)
changed = true
}
if changed {
saveTmuxCompatStore(store)
}
_ = tmuxPruneCompatWorkspaceState(wsId)
}
return nil

View file

@ -0,0 +1,241 @@
package main
import (
"bufio"
"encoding/json"
"net"
"os"
"path/filepath"
"testing"
)
func startMockTmuxCompatSocket(t *testing.T) string {
t.Helper()
sockPath := makeShortUnixSocketPath(t)
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
splitCreated := false
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var req map[string]any
if err := json.Unmarshal(line, &req); err != nil {
_, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n"))
return
}
method, _ := req["method"].(string)
params, _ := req["params"].(map[string]any)
resp := map[string]any{
"id": req["id"],
"ok": true,
}
switch method {
case "workspace.list":
resp["result"] = map[string]any{
"workspaces": []map[string]any{{
"id": "11111111-1111-4111-8111-111111111111",
"ref": "workspace:1",
"index": 1,
"title": "demo",
}},
}
case "surface.list":
surfaces := []map[string]any{{
"id": "44444444-4444-4444-8444-444444444444",
"ref": "surface:1",
"focused": true,
"pane_id": "33333333-3333-4333-8333-333333333333",
"title": "leader",
}}
if splitCreated {
surfaces = append(surfaces, map[string]any{
"id": "77777777-7777-4777-8777-777777777777",
"ref": "surface:2",
"focused": false,
"pane_id": "66666666-6666-4666-8666-666666666666",
"title": "teammate",
})
}
resp["result"] = map[string]any{"surfaces": surfaces}
case "surface.current":
resp["result"] = map[string]any{
"workspace_id": "11111111-1111-4111-8111-111111111111",
"workspace_ref": "workspace:1",
"pane_id": "33333333-3333-4333-8333-333333333333",
"pane_ref": "pane:1",
"surface_id": "44444444-4444-4444-8444-444444444444",
"surface_ref": "surface:1",
}
case "pane.list":
panes := []map[string]any{{
"id": "33333333-3333-4333-8333-333333333333",
"ref": "pane:1",
"index": 1,
}}
if splitCreated {
panes = append(panes, map[string]any{
"id": "66666666-6666-4666-8666-666666666666",
"ref": "pane:2",
"index": 2,
})
}
resp["result"] = map[string]any{"panes": panes}
case "surface.split":
if got, _ := params["surface_id"].(string); got != "44444444-4444-4444-8444-444444444444" {
resp["ok"] = false
resp["error"] = map[string]any{
"code": "not_found",
"message": "Surface not found",
}
break
}
splitCreated = true
resp["result"] = map[string]any{
"surface_id": "77777777-7777-4777-8777-777777777777",
"pane_id": "66666666-6666-4666-8666-666666666666",
}
case "workspace.equalize_splits":
resp["result"] = map[string]any{"ok": true}
default:
resp["ok"] = false
resp["error"] = map[string]any{
"code": "unsupported",
"message": method,
}
}
payload, _ := json.Marshal(resp)
_, _ = conn.Write(append(payload, '\n'))
}(conn)
}
}()
return sockPath
}
func TestTmuxSplitWindowCanonicalizesCallerSurfaceRefs(t *testing.T) {
origHome := os.Getenv("HOME")
origWorkspace := os.Getenv("CMUX_WORKSPACE_ID")
origSurface := os.Getenv("CMUX_SURFACE_ID")
origPane := os.Getenv("TMUX_PANE")
os.Setenv("HOME", t.TempDir())
os.Setenv("CMUX_WORKSPACE_ID", "workspace:1")
os.Setenv("CMUX_SURFACE_ID", "surface:1")
os.Setenv("TMUX_PANE", "%pane:1")
defer func() {
os.Setenv("HOME", origHome)
if origWorkspace != "" {
os.Setenv("CMUX_WORKSPACE_ID", origWorkspace)
} else {
os.Unsetenv("CMUX_WORKSPACE_ID")
}
if origSurface != "" {
os.Setenv("CMUX_SURFACE_ID", origSurface)
} else {
os.Unsetenv("CMUX_SURFACE_ID")
}
if origPane != "" {
os.Setenv("TMUX_PANE", origPane)
} else {
os.Unsetenv("TMUX_PANE")
}
}()
sockPath := startMockTmuxCompatSocket(t)
rc := &rpcContext{socketPath: sockPath}
output := captureStdout(t, func() {
if err := dispatchTmuxCommand(rc, "split-window", []string{"-h", "-P", "-F", "#{pane_id}"}); err != nil {
t.Fatalf("split-window: %v", err)
}
})
if got := output; got != "%66666666-6666-4666-8666-666666666666\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestTmuxSplitWindowIgnoresStaleUUIDColumnSurface(t *testing.T) {
origHome := os.Getenv("HOME")
origWorkspace := os.Getenv("CMUX_WORKSPACE_ID")
origSurface := os.Getenv("CMUX_SURFACE_ID")
origPane := os.Getenv("TMUX_PANE")
home := t.TempDir()
os.Setenv("HOME", home)
os.Setenv("CMUX_WORKSPACE_ID", "workspace:1")
os.Setenv("CMUX_SURFACE_ID", "surface:1")
os.Setenv("TMUX_PANE", "%pane:1")
defer func() {
os.Setenv("HOME", origHome)
if origWorkspace != "" {
os.Setenv("CMUX_WORKSPACE_ID", origWorkspace)
} else {
os.Unsetenv("CMUX_WORKSPACE_ID")
}
if origSurface != "" {
os.Setenv("CMUX_SURFACE_ID", origSurface)
} else {
os.Unsetenv("CMUX_SURFACE_ID")
}
if origPane != "" {
os.Setenv("TMUX_PANE", origPane)
} else {
os.Unsetenv("TMUX_PANE")
}
}()
storePath := filepath.Join(home, ".cmuxterm", "tmux-compat-store.json")
if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil {
t.Fatalf("mkdir store dir: %v", err)
}
storeBytes, err := json.Marshal(tmuxCompatStore{
Buffers: make(map[string]string),
Hooks: make(map[string]string),
MainVerticalLayouts: map[string]mainVerticalState{
"11111111-1111-4111-8111-111111111111": {
MainSurfaceId: "44444444-4444-4444-8444-444444444444",
LastColumnSurfaceId: "77777777-7777-4777-8777-777777777777",
},
},
LastSplitSurface: map[string]string{
"11111111-1111-4111-8111-111111111111": "77777777-7777-4777-8777-777777777777",
},
})
if err != nil {
t.Fatalf("marshal store: %v", err)
}
if err := os.WriteFile(storePath, storeBytes, 0o644); err != nil {
t.Fatalf("write store: %v", err)
}
sockPath := startMockTmuxCompatSocket(t)
rc := &rpcContext{socketPath: sockPath}
output := captureStdout(t, func() {
if err := dispatchTmuxCommand(rc, "split-window", []string{"-h", "-P", "-F", "#{pane_id}"}); err != nil {
t.Fatalf("split-window: %v", err)
}
})
if got := output; got != "%66666666-6666-4666-8666-666666666666\n" {
t.Fatalf("stdout = %q", got)
}
}