From dd97c53e2c71a6c3daef6d8135086bb2032dfb01 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:18:03 -0300 Subject: [PATCH 1/4] fix: honor -t target session in tmux new-window tmuxNewWindow parsed -t but never used it, so the target session a caller specified was silently ignored and the new workspace always landed in the currently active window instead of the target's window. Resolve -t via tmuxResolveWorkspaceTarget and route workspace.create into that workspace's macOS window (mirroring how surface.split already resolves -t for split-window), matching tmux's new-window semantics where the target's session determines placement. Also collapse the copy-pasted body shared by tmuxNewSession and tmuxNewWindow into tmuxCreateWorkspace/tmuxPrintWorkspaceRef helpers. Refs #84. --- .../cmd/programad-remote/tmux_compat.go | 91 +++++++------- .../cmd/programad-remote/tmux_compat_test.go | 112 ++++++++++++++++++ 2 files changed, 163 insertions(+), 40 deletions(-) diff --git a/daemon/remote/cmd/programad-remote/tmux_compat.go b/daemon/remote/cmd/programad-remote/tmux_compat.go index 5c4e1111744..915b330c3a5 100644 --- a/daemon/remote/cmd/programad-remote/tmux_compat.go +++ b/daemon/remote/cmd/programad-remote/tmux_compat.go @@ -1112,24 +1112,29 @@ func dispatchTmuxCommand(rc *rpcContext, command string, args []string) error { // --- Command implementations --- -func tmuxNewSession(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-s"}, []string{"-A", "-d", "-P"}) - if p.hasFlag("-A") { - return fmt.Errorf("new-session -A is not supported") - } +// tmuxCreateWorkspace implements the body shared by `new-session` and +// `new-window`: create a workspace (optionally routed into the macOS window +// that owns targetWsId, mirroring tmux's "new window goes into the target's +// session"), rename it, and pipe in a shell command if one was given. +func tmuxCreateWorkspace(rc *rpcContext, p *tmuxParsed, title string, targetWsId string) (string, error) { params := map[string]any{"focus": false} if cwd := p.value("-c"); cwd != "" { params["cwd"] = cwd } + if targetWsId != "" { + // Route the new workspace into the same top-level window as the + // resolved target instead of always landing in the active window. + params["workspace_id"] = targetWsId + } created, err := rc.call("workspace.create", params) if err != nil { - return err + return "", err } wsId, _ := created["workspace_id"].(string) if wsId == "" { - return fmt.Errorf("workspace.create did not return workspace_id") + return "", fmt.Errorf("workspace.create did not return workspace_id") } - if title := firstNonEmpty(p.value("-n"), p.value("-s")); strings.TrimSpace(title) != "" { + if strings.TrimSpace(title) != "" { rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) } if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { @@ -1138,48 +1143,54 @@ func tmuxNewSession(rc *rpcContext, args []string) error { rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) } } - if p.hasFlag("-P") { - ctx, err := tmuxFormatContext(rc, wsId, "", "") - if err != nil { - fmt.Printf("@%s\n", wsId) - return nil - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) - } - return nil + return wsId, nil } -func tmuxNewWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-t"}, []string{"-d", "-P"}) - params := map[string]any{"focus": false} - if cwd := p.value("-c"); cwd != "" { - params["cwd"] = cwd +// tmuxPrintWorkspaceRef prints the `-P`/`-F` formatted reference for a +// newly created workspace, shared by `new-session` and `new-window`. +func tmuxPrintWorkspaceRef(rc *rpcContext, p *tmuxParsed, wsId string) { + if !p.hasFlag("-P") { + return } - created, err := rc.call("workspace.create", params) + ctx, err := tmuxFormatContext(rc, wsId, "", "") if err != nil { - return err - } - wsId, _ := created["workspace_id"].(string) - if wsId == "" { - return fmt.Errorf("workspace.create did not return workspace_id") + fmt.Printf("@%s\n", wsId) + return } - if title := p.value("-n"); strings.TrimSpace(title) != "" { - rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) +} + +func tmuxNewSession(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-s"}, []string{"-A", "-d", "-P"}) + if p.hasFlag("-A") { + return fmt.Errorf("new-session -A is not supported") } - if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { - surfaceId, err := tmuxGetFirstSurface(rc, wsId) - if err == nil { - rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) - } + title := firstNonEmpty(p.value("-n"), p.value("-s")) + wsId, err := tmuxCreateWorkspace(rc, p, title, "") + if err != nil { + return err } - if p.hasFlag("-P") { - ctx, err := tmuxFormatContext(rc, wsId, "", "") + tmuxPrintWorkspaceRef(rc, p, wsId) + return nil +} + +func tmuxNewWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-t"}, []string{"-d", "-P"}) + + targetWsId := "" + if raw := strings.TrimSpace(p.value("-t")); raw != "" { + resolved, err := tmuxResolveWorkspaceTarget(rc, raw) if err != nil { - fmt.Printf("@%s\n", wsId) - return nil + return err } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) + targetWsId = resolved + } + + wsId, err := tmuxCreateWorkspace(rc, p, p.value("-n"), targetWsId) + if err != nil { + return err } + tmuxPrintWorkspaceRef(rc, p, wsId) return nil } diff --git a/daemon/remote/cmd/programad-remote/tmux_compat_test.go b/daemon/remote/cmd/programad-remote/tmux_compat_test.go index 72ed21df6b1..f494c71c032 100644 --- a/daemon/remote/cmd/programad-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/programad-remote/tmux_compat_test.go @@ -1,9 +1,13 @@ package main import ( + "bufio" + "encoding/json" + "net" "os" "path/filepath" "strings" + "sync" "testing" ) @@ -434,6 +438,114 @@ func TestTmuxWaitForSignalRoundTrip(t *testing.T) { } } +// startMockNewWindowSocket returns a mock relay socket that resolves +// workspace.list to a single "target" workspace and records the params +// passed to every workspace.create call (guarded by a mutex, since the +// listener services requests on their own goroutines). +func startMockNewWindowSocket(t *testing.T) (sockPath string, createCalls *[]map[string]any, mu *sync.Mutex) { + t.Helper() + sockPath = makeShortUnixSocketPath(t) + calls := []map[string]any{} + var lock sync.Mutex + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + 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": "22222222-2222-4222-8222-222222222222", + "ref": "workspace:9", + "index": 9, + "title": "target-session", + }}, + } + case "workspace.create": + lock.Lock() + calls = append(calls, params) + lock.Unlock() + resp["result"] = map[string]any{"workspace_id": "33333333-3333-4333-8333-333333333333"} + case "workspace.rename": + 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, &calls, &lock +} + +func TestTmuxNewWindowHonorsTargetSession(t *testing.T) { + sockPath, calls, mu := startMockNewWindowSocket(t) + rc := &rpcContext{socketPath: sockPath} + + if err := dispatchTmuxCommand(rc, "new-window", []string{"-t", "target-session"}); err != nil { + t.Fatalf("new-window: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(*calls) != 1 { + t.Fatalf("workspace.create calls = %d, want 1", len(*calls)) + } + got, _ := (*calls)[0]["workspace_id"].(string) + if got != "22222222-2222-4222-8222-222222222222" { + t.Errorf("workspace.create routed workspace_id = %q, want the resolved -t target", got) + } +} + +func TestTmuxNewWindowWithoutTargetDoesNotRoute(t *testing.T) { + sockPath, calls, mu := startMockNewWindowSocket(t) + rc := &rpcContext{socketPath: sockPath} + + if err := dispatchTmuxCommand(rc, "new-window", nil); err != nil { + t.Fatalf("new-window: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(*calls) != 1 { + t.Fatalf("workspace.create calls = %d, want 1", len(*calls)) + } + if _, ok := (*calls)[0]["workspace_id"]; ok { + t.Errorf("workspace.create params should not carry workspace_id routing when -t is absent, got %v", (*calls)[0]) + } +} + func TestTmuxShowBuffer(t *testing.T) { tmpDir := t.TempDir() origHome := os.Getenv("HOME") From 51a2cbda9e5f6d351f1085581daae3001512ca15 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:22:45 -0300 Subject: [PATCH 2/4] refactor: split tmux_compat.go along its section comments tmux_compat.go had grown to 1,801 lines covering nine unrelated concerns in one file. File reorganization only, no behavior change: every type and function moved verbatim (confirmed by diffing the extracted func/type signatures against the pre-split file), all staying in package main so there is zero import churn. - tmux_compat.go: entry point (runTmuxCompat, rpcContext.call) + dispatchTmuxCommand - tmux_args.go: tmux argument parsing (tmuxParsed, parseTmuxArgs, splitTmuxCmd) - tmux_format.go: format string rendering + format context building - tmux_target.go: session/window/pane target resolution - tmux_store.go: TmuxCompatStore local JSON state - tmux_keys.go: special key translation - tmux_waitfor.go: wait-for signal path helper - tmux_commands.go: per-command implementations (new-session, split-window, ...) - tmux_helpers.go: small shared helpers (firstNonEmpty, parseInt, parseFloat) Refs #102. --- .../remote/cmd/programad-remote/tmux_args.go | 122 ++ .../cmd/programad-remote/tmux_commands.go | 659 +++++++ .../cmd/programad-remote/tmux_compat.go | 1714 +---------------- .../cmd/programad-remote/tmux_format.go | 217 +++ .../cmd/programad-remote/tmux_helpers.go | 57 + .../remote/cmd/programad-remote/tmux_keys.go | 71 + .../remote/cmd/programad-remote/tmux_store.go | 113 ++ .../cmd/programad-remote/tmux_target.go | 494 +++++ .../cmd/programad-remote/tmux_waitfor.go | 21 + 9 files changed, 1761 insertions(+), 1707 deletions(-) create mode 100644 daemon/remote/cmd/programad-remote/tmux_args.go create mode 100644 daemon/remote/cmd/programad-remote/tmux_commands.go create mode 100644 daemon/remote/cmd/programad-remote/tmux_format.go create mode 100644 daemon/remote/cmd/programad-remote/tmux_helpers.go create mode 100644 daemon/remote/cmd/programad-remote/tmux_keys.go create mode 100644 daemon/remote/cmd/programad-remote/tmux_store.go create mode 100644 daemon/remote/cmd/programad-remote/tmux_target.go create mode 100644 daemon/remote/cmd/programad-remote/tmux_waitfor.go diff --git a/daemon/remote/cmd/programad-remote/tmux_args.go b/daemon/remote/cmd/programad-remote/tmux_args.go new file mode 100644 index 00000000000..c2185b532ec --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_args.go @@ -0,0 +1,122 @@ +package main + +import ( + "fmt" + "strings" +) + +// --- Tmux argument parsing --- + +type tmuxParsed struct { + flags map[string]bool // boolean flags like -d, -P + options map[string][]string // value flags like -t + positional []string +} + +func (p *tmuxParsed) hasFlag(f string) bool { + return p.flags[f] +} + +func (p *tmuxParsed) value(f string) string { + vals := p.options[f] + if len(vals) == 0 { + return "" + } + return vals[len(vals)-1] +} + +func splitTmuxCmd(args []string) (string, []string, error) { + globalValueFlags := map[string]bool{"-L": true, "-S": true, "-f": true} + globalBoolFlags := map[string]bool{"-V": true, "-v": true} + + i := 0 + for i < len(args) { + arg := args[i] + if !strings.HasPrefix(arg, "-") || arg == "-" { + return strings.ToLower(arg), args[i+1:], nil + } + if arg == "--" { + break + } + if globalBoolFlags[arg] { + return arg, nil, nil + } + if globalValueFlags[arg] { + // Skip the value + i++ + } + i++ + } + return "", nil, fmt.Errorf("tmux shim requires a command") +} + +func parseTmuxArgs(args []string, valueFlags, boolFlags []string) *tmuxParsed { + vSet := make(map[string]bool, len(valueFlags)) + for _, f := range valueFlags { + vSet[f] = true + } + bSet := make(map[string]bool, len(boolFlags)) + for _, f := range boolFlags { + bSet[f] = true + } + + p := &tmuxParsed{ + flags: make(map[string]bool), + options: make(map[string][]string), + } + pastTerminator := false + + for i := 0; i < len(args); i++ { + arg := args[i] + if pastTerminator { + p.positional = append(p.positional, arg) + continue + } + if arg == "--" { + pastTerminator = true + continue + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + p.positional = append(p.positional, arg) + continue + } + if strings.HasPrefix(arg, "--") { + p.positional = append(p.positional, arg) + continue + } + + // Cluster parsing: -dPh etc. + cluster := []rune(arg[1:]) + cursor := 0 + recognized := false + for cursor < len(cluster) { + flag := "-" + string(cluster[cursor]) + if bSet[flag] { + p.flags[flag] = true + cursor++ + recognized = true + continue + } + if vSet[flag] { + remainder := string(cluster[cursor+1:]) + var value string + if remainder != "" { + value = remainder + } else if i+1 < len(args) { + i++ + value = args[i] + } + p.options[flag] = append(p.options[flag], value) + recognized = true + cursor = len(cluster) + continue + } + recognized = false + break + } + if !recognized { + p.positional = append(p.positional, arg) + } + } + return p +} diff --git a/daemon/remote/cmd/programad-remote/tmux_commands.go b/daemon/remote/cmd/programad-remote/tmux_commands.go new file mode 100644 index 00000000000..4a6d568dd93 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_commands.go @@ -0,0 +1,659 @@ +package main + +import ( + "fmt" + "math" + "os" + "strings" + "time" +) + +// --- Command implementations --- + +// tmuxCreateWorkspace implements the body shared by `new-session` and +// `new-window`: create a workspace (optionally routed into the macOS window +// that owns targetWsId, mirroring tmux's "new window goes into the target's +// session"), rename it, and pipe in a shell command if one was given. +func tmuxCreateWorkspace(rc *rpcContext, p *tmuxParsed, title string, targetWsId string) (string, error) { + params := map[string]any{"focus": false} + if cwd := p.value("-c"); cwd != "" { + params["cwd"] = cwd + } + if targetWsId != "" { + // Route the new workspace into the same top-level window as the + // resolved target instead of always landing in the active window. + params["workspace_id"] = targetWsId + } + created, err := rc.call("workspace.create", params) + if err != nil { + return "", err + } + wsId, _ := created["workspace_id"].(string) + if wsId == "" { + return "", fmt.Errorf("workspace.create did not return workspace_id") + } + if strings.TrimSpace(title) != "" { + rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) + } + if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { + surfaceId, err := tmuxGetFirstSurface(rc, wsId) + if err == nil { + rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) + } + } + return wsId, nil +} + +// tmuxPrintWorkspaceRef prints the `-P`/`-F` formatted reference for a +// newly created workspace, shared by `new-session` and `new-window`. +func tmuxPrintWorkspaceRef(rc *rpcContext, p *tmuxParsed, wsId string) { + if !p.hasFlag("-P") { + return + } + ctx, err := tmuxFormatContext(rc, wsId, "", "") + if err != nil { + fmt.Printf("@%s\n", wsId) + return + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) +} + +func tmuxNewSession(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-s"}, []string{"-A", "-d", "-P"}) + if p.hasFlag("-A") { + return fmt.Errorf("new-session -A is not supported") + } + title := firstNonEmpty(p.value("-n"), p.value("-s")) + wsId, err := tmuxCreateWorkspace(rc, p, title, "") + if err != nil { + return err + } + tmuxPrintWorkspaceRef(rc, p, wsId) + return nil +} + +func tmuxNewWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-t"}, []string{"-d", "-P"}) + + targetWsId := "" + if raw := strings.TrimSpace(p.value("-t")); raw != "" { + resolved, err := tmuxResolveWorkspaceTarget(rc, raw) + if err != nil { + return err + } + targetWsId = resolved + } + + wsId, err := tmuxCreateWorkspace(rc, p, p.value("-n"), targetWsId) + if err != nil { + return err + } + tmuxPrintWorkspaceRef(rc, p, wsId) + return nil +} + +func tmuxSplitWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-l", "-t"}, []string{"-P", "-b", "-d", "-h", "-v"}) + + targetWs, _, targetSurface, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + + direction := "down" + if p.hasFlag("-h") { + direction = "right" + if p.hasFlag("-b") { + direction = "left" + } + } else if p.hasFlag("-b") { + direction = "up" + } + + // Anchor splits to the leader surface for agent teams. + callerWorkspace := tmuxCallerWorkspaceHandle() + anchoredCallerSurface := "" + if callerWorkspace != "" { + if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil { + if anchored := tmuxAnchoredSplitTarget(rc, wsId); anchored != nil { + targetWs = wsId + targetSurface = anchored.targetSurfaceId + direction = anchored.direction + anchoredCallerSurface = anchored.callerSurfaceId + } + } + } + + focusNewPane := !p.hasFlag("-d") + created, err := rc.call("surface.split", map[string]any{ + "workspace_id": targetWs, + "surface_id": targetSurface, + "direction": direction, + "focus": focusNewPane, + }) + if err != nil { + return err + } + surfaceId, _ := created["surface_id"].(string) + if surfaceId == "" { + return fmt.Errorf("surface.split did not return surface_id") + } + newPaneId, _ := created["pane_id"].(string) + + // Track for main-vertical layout + store := loadTmuxCompatStore() + store.LastSplitSurface[targetWs] = surfaceId + if _, ok := store.MainVerticalLayouts[targetWs]; ok { + mvs := store.MainVerticalLayouts[targetWs] + mvs.LastColumnSurfaceId = surfaceId + store.MainVerticalLayouts[targetWs] = mvs + } else if direction == "right" && anchoredCallerSurface != "" { + store.MainVerticalLayouts[targetWs] = mainVerticalState{ + MainSurfaceId: anchoredCallerSurface, + LastColumnSurfaceId: surfaceId, + } + } + saveTmuxCompatStore(store) + + // Equalize vertical splits + rc.call("workspace.equalize_splits", map[string]any{ + "workspace_id": targetWs, + "orientation": "vertical", + }) + + if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { + rc.call("surface.send_text", map[string]any{ + "workspace_id": targetWs, + "surface_id": surfaceId, + "text": text, + }) + } + + if p.hasFlag("-P") { + ctx, err := tmuxFormatContext(rc, targetWs, newPaneId, surfaceId) + if err != nil { + fmt.Println(surfaceId) + return nil + } + fallback := surfaceId + if pid, ok := ctx["pane_id"]; ok { + fallback = pid + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxSelectWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.select", map[string]any{"workspace_id": wsId}) + return err +} + +func tmuxSelectPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-P", "-T", "-t"}, nil) + // -P (style) and -T (title) are no-ops + if p.value("-P") != "" || p.value("-T") != "" { + return nil + } + wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("pane.focus", map[string]any{"workspace_id": wsId, "pane_id": paneId}) + return err +} + +func tmuxKillWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId}) + if err != nil { + return err + } + _ = tmuxPruneCompatWorkspaceState(wsId) + return nil +} + +func tmuxKillPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("surface.close", map[string]any{"workspace_id": wsId, "surface_id": surfId}) + 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 +} + +func tmuxSendKeys(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, []string{"-l"}) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + text := tmuxSendKeysText(p.positional, p.hasFlag("-l")) + if text != "" { + _, err = rc.call("surface.send_text", map[string]any{ + "workspace_id": wsId, + "surface_id": surfId, + "text": text, + }) + } + return err +} + +func tmuxCapturePane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-E", "-S", "-t"}, []string{"-J", "-N", "-p"}) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + params := map[string]any{ + "workspace_id": wsId, + "surface_id": surfId, + "scrollback": true, + } + if start := p.value("-S"); start != "" { + if lines := parseInt(start); lines < 0 { + params["lines"] = int(math.Abs(float64(lines))) + } + } + payload, err := rc.call("surface.read_text", params) + if err != nil { + return err + } + text, _ := payload["text"].(string) + if p.hasFlag("-p") { + fmt.Print(text) + } else { + store := loadTmuxCompatStore() + store.Buffers["default"] = text + saveTmuxCompatStore(store) + } + return nil +} + +func tmuxDisplayMessage(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, []string{"-p"}) + wsId, paneId, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + ctx, err := tmuxFormatContext(rc, wsId, paneId, surfId) + if err != nil { + ctx = map[string]string{} + } + + // Enrich with geometry + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err == nil { + panes, _ := panePayload["panes"].([]any) + containerFrame, _ := panePayload["container_frame"].(map[string]any) + var matchingPane map[string]any + if paneId != "" { + for _, p := range panes { + pn, _ := p.(map[string]any) + if pid, _ := pn["id"].(string); pid == paneId { + matchingPane = pn + break + } + } + } + if matchingPane == nil { + for _, p := range panes { + pn, _ := p.(map[string]any) + if focused, _ := pn["focused"].(bool); focused { + matchingPane = pn + break + } + } + } + if matchingPane == nil && len(panes) > 0 { + matchingPane, _ = panes[0].(map[string]any) + } + if matchingPane != nil { + tmuxEnrichContextWithGeometry(ctx, matchingPane, containerFrame) + } + } + + format := p.value("-F") + if len(p.positional) > 0 { + format = strings.Join(p.positional, " ") + } + rendered := tmuxRenderFormat(format, ctx, "") + if p.hasFlag("-p") || rendered != "" { + fmt.Println(rendered) + } + return nil +} + +func tmuxListWindows(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) + items, err := tmuxWorkspaceItems(rc) + if err != nil { + return err + } + for _, item := range items { + wsId, _ := item["id"].(string) + if wsId == "" { + continue + } + ctx, err := tmuxFormatContext(rc, wsId, "", "") + if err != nil { + continue + } + fallback := "" + if idx, ok := ctx["window_index"]; ok { + fallback = idx + } else { + fallback = "?" + } + if name, ok := ctx["window_name"]; ok { + fallback += " " + name + } else { + fallback += " " + wsId + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxListPanes(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) + + target := p.value("-t") + var wsId string + var err error + + if target != "" && tmuxPaneSelector(target) != "" { + wsId, _, err = tmuxResolvePaneTarget(rc, target) + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, target) + } + if err != nil { + return err + } + + payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + return err + } + panes, _ := payload["panes"].([]any) + containerFrame, _ := payload["container_frame"].(map[string]any) + + for _, p2 := range panes { + pane, _ := p2.(map[string]any) + if pane == nil { + continue + } + paneId, _ := pane["id"].(string) + if paneId == "" { + continue + } + ctx, err := tmuxFormatContext(rc, wsId, paneId, "") + if err != nil { + continue + } + tmuxEnrichContextWithGeometry(ctx, pane, containerFrame) + fallback := "%" + paneId + if pid, ok := ctx["pane_id"]; ok { + fallback = pid + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxRenameWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + title := strings.TrimSpace(strings.Join(p.positional, " ")) + if title == "" { + return fmt.Errorf("rename-window requires a title") + } + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) + return err +} + +func tmuxResizePane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t", "-x", "-y"}, []string{"-D", "-L", "-R", "-U"}) + wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) + if err != nil { + return err + } + + hasDirectional := p.hasFlag("-L") || p.hasFlag("-R") || p.hasFlag("-U") || p.hasFlag("-D") + + if !hasDirectional { + if absWidthStr := p.value("-x"); absWidthStr != "" { + absWidth := parseInt(strings.ReplaceAll(absWidthStr, "%", "")) + // Get current width to compute delta + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + return err + } + panes, _ := panePayload["panes"].([]any) + for _, pp := range panes { + pane, _ := pp.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == paneId { + cellW := intFromAnyGo(pane["cell_width_px"]) + currentCols := intFromAnyGo(pane["columns"]) + if cellW > 0 && currentCols >= 0 { + delta := absWidth - currentCols + if delta != 0 { + dir := "right" + if delta < 0 { + dir = "left" + delta = -delta + } + rc.call("pane.resize", map[string]any{ + "workspace_id": wsId, + "pane_id": paneId, + "direction": dir, + "amount": delta * cellW, + }) + } + } + break + } + } + return nil + } + } + + if hasDirectional { + dir := "right" + if p.hasFlag("-L") { + dir = "left" + } else if p.hasFlag("-U") { + dir = "up" + } else if p.hasFlag("-D") { + dir = "down" + } + rawAmount := firstNonEmpty(p.value("-x"), p.value("-y"), "5") + rawAmount = strings.ReplaceAll(rawAmount, "%", "") + amount := parseInt(rawAmount) + if amount <= 0 { + amount = 5 + } + _, err := rc.call("pane.resize", map[string]any{ + "workspace_id": wsId, + "pane_id": paneId, + "direction": dir, + "amount": amount, + }) + return err + } + return nil +} + +func tmuxWaitFor(_ *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"--timeout"}, []string{"-S"}) + name := "" + for _, pos := range p.positional { + if !strings.HasPrefix(pos, "-") { + name = pos + break + } + } + if name == "" { + return fmt.Errorf("wait-for requires a name") + } + + signalPath := tmuxWaitForSignalPath(name) + + if p.hasFlag("-S") { + // Signal mode: create the file + os.WriteFile(signalPath, []byte{}, 0644) + fmt.Println("OK") + return nil + } + + // Wait mode: poll for the file + timeoutStr := p.value("--timeout") + timeout := 30.0 + if timeoutStr != "" { + if t := parseFloat(timeoutStr); t > 0 { + timeout = t + } + } + + deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) + for time.Now().Before(deadline) { + if _, err := os.Stat(signalPath); err == nil { + os.Remove(signalPath) + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("wait-for timeout: %s", name) +} + +func tmuxLastPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("pane.last", map[string]any{"workspace_id": wsId}) + return err +} + +func tmuxHasSession(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + _, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + return err +} + +func tmuxSelectLayout(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + layoutName := "" + if len(p.positional) > 0 { + layoutName = p.positional[0] + } + + // Resolve workspace from target (may be a pane reference) + var wsId string + var err error + if target := p.value("-t"); target != "" { + if tmuxPaneSelector(target) != "" { + wsId, _, err = tmuxResolvePaneTarget(rc, target) + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, target) + } + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, "") + } + if err != nil { + return err + } + + if layoutName == "main-vertical" || layoutName == "main-horizontal" { + orientation := "vertical" + if layoutName == "main-horizontal" { + orientation = "horizontal" + } + rc.call("workspace.equalize_splits", map[string]any{ + "workspace_id": wsId, + "orientation": orientation, + }) + } else { + rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId}) + } + + if layoutName == "main-vertical" { + if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { + store := loadTmuxCompatStore() + existingColumn := "" + if existing, ok := store.MainVerticalLayouts[wsId]; ok { + existingColumn = existing.LastColumnSurfaceId + } + seedColumn := existingColumn + if seedColumn == "" { + seedColumn = store.LastSplitSurface[wsId] + } + store.MainVerticalLayouts[wsId] = mainVerticalState{ + MainSurfaceId: callerSurface, + LastColumnSurfaceId: seedColumn, + } + saveTmuxCompatStore(store) + } + } else if layoutName != "" { + _ = tmuxPruneCompatWorkspaceState(wsId) + } + + return nil +} + +func tmuxShowBuffer(args []string) error { + p := parseTmuxArgs(args, []string{"-b"}, nil) + name := p.value("-b") + if name == "" { + name = "default" + } + store := loadTmuxCompatStore() + if buf, ok := store.Buffers[name]; ok { + fmt.Print(buf) + } + return nil +} + +func tmuxSaveBuffer(args []string) error { + p := parseTmuxArgs(args, []string{"-b"}, nil) + name := p.value("-b") + if name == "" { + name = "default" + } + store := loadTmuxCompatStore() + buf, ok := store.Buffers[name] + if !ok { + return fmt.Errorf("buffer not found: %s", name) + } + if len(p.positional) > 0 { + outputPath := strings.TrimSpace(p.positional[len(p.positional)-1]) + if outputPath != "" { + return os.WriteFile(outputPath, []byte(buf), 0644) + } + } + fmt.Print(buf) + return nil +} diff --git a/daemon/remote/cmd/programad-remote/tmux_compat.go b/daemon/remote/cmd/programad-remote/tmux_compat.go index 915b330c3a5..5612913f043 100644 --- a/daemon/remote/cmd/programad-remote/tmux_compat.go +++ b/daemon/remote/cmd/programad-remote/tmux_compat.go @@ -3,12 +3,7 @@ package main import ( "encoding/json" "fmt" - "math" "os" - "path/filepath" - "regexp" - "strings" - "time" ) // runTmuxCompat handles `programa __tmux-compat `, translating tmux @@ -48,1008 +43,14 @@ func (rc *rpcContext) call(method string, params map[string]any) (map[string]any return result, nil } -// --- Tmux argument parsing --- - -type tmuxParsed struct { - flags map[string]bool // boolean flags like -d, -P - options map[string][]string // value flags like -t - positional []string -} - -func (p *tmuxParsed) hasFlag(f string) bool { - return p.flags[f] -} - -func (p *tmuxParsed) value(f string) string { - vals := p.options[f] - if len(vals) == 0 { - return "" - } - return vals[len(vals)-1] -} - -func splitTmuxCmd(args []string) (string, []string, error) { - globalValueFlags := map[string]bool{"-L": true, "-S": true, "-f": true} - globalBoolFlags := map[string]bool{"-V": true, "-v": true} - - i := 0 - for i < len(args) { - arg := args[i] - if !strings.HasPrefix(arg, "-") || arg == "-" { - return strings.ToLower(arg), args[i+1:], nil - } - if arg == "--" { - break - } - if globalBoolFlags[arg] { - return arg, nil, nil - } - if globalValueFlags[arg] { - // Skip the value - i++ - } - i++ - } - return "", nil, fmt.Errorf("tmux shim requires a command") -} - -func parseTmuxArgs(args []string, valueFlags, boolFlags []string) *tmuxParsed { - vSet := make(map[string]bool, len(valueFlags)) - for _, f := range valueFlags { - vSet[f] = true - } - bSet := make(map[string]bool, len(boolFlags)) - for _, f := range boolFlags { - bSet[f] = true - } - - p := &tmuxParsed{ - flags: make(map[string]bool), - options: make(map[string][]string), - } - pastTerminator := false - - for i := 0; i < len(args); i++ { - arg := args[i] - if pastTerminator { - p.positional = append(p.positional, arg) - continue - } - if arg == "--" { - pastTerminator = true - continue - } - if !strings.HasPrefix(arg, "-") || arg == "-" { - p.positional = append(p.positional, arg) - continue - } - if strings.HasPrefix(arg, "--") { - p.positional = append(p.positional, arg) - continue - } - - // Cluster parsing: -dPh etc. - cluster := []rune(arg[1:]) - cursor := 0 - recognized := false - for cursor < len(cluster) { - flag := "-" + string(cluster[cursor]) - if bSet[flag] { - p.flags[flag] = true - cursor++ - recognized = true - continue - } - if vSet[flag] { - remainder := string(cluster[cursor+1:]) - var value string - if remainder != "" { - value = remainder - } else if i+1 < len(args) { - i++ - value = args[i] - } - p.options[flag] = append(p.options[flag], value) - recognized = true - cursor = len(cluster) - continue - } - recognized = false - break - } - if !recognized { - p.positional = append(p.positional, arg) - } - } - return p -} - -// --- Format string rendering --- - -var tmuxFormatVarRe = regexp.MustCompile(`#\{[^}]+\}`) - -func tmuxRenderFormat(format string, context map[string]string, fallback string) string { - if format == "" { - return fallback - } - rendered := format - for key, value := range context { - rendered = strings.ReplaceAll(rendered, "#{"+key+"}", value) - } - // Remove any remaining unresolved #{...} variables - rendered = tmuxFormatVarRe.ReplaceAllString(rendered, "") - rendered = strings.TrimSpace(rendered) - if rendered == "" { - return fallback - } - return rendered -} - -// --- Format context building --- - -func tmuxFormatContext(rc *rpcContext, workspaceId string, paneId string, surfaceId string) (map[string]string, error) { - canonicalWsId, err := tmuxResolveWorkspaceId(rc, workspaceId) - if err != nil { - return nil, err - } - - ctx := map[string]string{ - "session_name": "programa", - "session_id": "$0", - "window_id": "@" + canonicalWsId, - "window_uuid": canonicalWsId, - "window_active": "1", - "window_flags": "*", - "pane_active": "1", - } - - // Get workspace list for index/title - workspaces, err := tmuxWorkspaceItems(rc) - if err == nil { - for _, ws := range workspaces { - wsId, _ := ws["id"].(string) - wsRef, _ := ws["ref"].(string) - if wsId == canonicalWsId || wsRef == workspaceId { - if idx := intFromAnyGo(ws["index"]); idx >= 0 { - ctx["window_index"] = fmt.Sprintf("%d", idx) - } - if title, _ := ws["title"].(string); strings.TrimSpace(title) != "" { - ctx["window_name"] = strings.TrimSpace(title) - } - if paneCount := intFromAnyGo(ws["pane_count"]); paneCount >= 0 { - ctx["window_panes"] = fmt.Sprintf("%d", paneCount) - } - break - } - } - } - - // Get current surface info - currentPayload, err := rc.call("surface.current", map[string]any{"workspace_id": canonicalWsId}) - if err != nil { - return ctx, nil - } - - resolvedPaneId := paneId - if resolvedPaneId == "" { - if pid, ok := currentPayload["pane_id"].(string); ok { - resolvedPaneId = pid - } else if pref, ok := currentPayload["pane_ref"].(string); ok { - resolvedPaneId = pref - } - } - - resolvedSurfaceId := surfaceId - if resolvedSurfaceId == "" && resolvedPaneId != "" { - if sid, err := tmuxSelectedSurfaceId(rc, canonicalWsId, resolvedPaneId); err == nil { - resolvedSurfaceId = sid - } - } - if resolvedSurfaceId == "" { - if sid, ok := currentPayload["surface_id"].(string); ok { - resolvedSurfaceId = sid - } - } - - if resolvedPaneId != "" { - ctx["pane_id"] = "%" + resolvedPaneId - ctx["pane_uuid"] = resolvedPaneId - - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": canonicalWsId}) - if err == nil { - panes, _ := panePayload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - if pid, _ := pane["id"].(string); pid == resolvedPaneId { - if idx := intFromAnyGo(pane["index"]); idx >= 0 { - ctx["pane_index"] = fmt.Sprintf("%d", idx) - } - break - } - } - } - } - - if resolvedSurfaceId != "" { - ctx["surface_id"] = resolvedSurfaceId - surfacePayload, err := rc.call("surface.list", map[string]any{"workspace_id": canonicalWsId}) - if err == nil { - surfaces, _ := surfacePayload["surfaces"].([]any) - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - if sid, _ := surface["id"].(string); sid == resolvedSurfaceId { - if title, _ := surface["title"].(string); strings.TrimSpace(title) != "" { - ctx["pane_title"] = strings.TrimSpace(title) - if _, ok := ctx["window_name"]; !ok { - ctx["window_name"] = strings.TrimSpace(title) - } - } - break - } - } - } - } - - return ctx, nil -} - -func tmuxEnrichContextWithGeometry(ctx map[string]string, pane map[string]any, containerFrame map[string]any) { - isFocused, _ := pane["focused"].(bool) - if isFocused { - ctx["pane_active"] = "1" - } else { - ctx["pane_active"] = "0" - } - - columns := intFromAnyGo(pane["columns"]) - rows := intFromAnyGo(pane["rows"]) - if columns < 0 || rows < 0 { - return - } - ctx["pane_width"] = fmt.Sprintf("%d", columns) - ctx["pane_height"] = fmt.Sprintf("%d", rows) - - cellW := intFromAnyGo(pane["cell_width_px"]) - cellH := intFromAnyGo(pane["cell_height_px"]) - if cellW <= 0 || cellH <= 0 { - return - } - - if frame, ok := pane["pixel_frame"].(map[string]any); ok { - px := floatFromAny(frame["x"]) - py := floatFromAny(frame["y"]) - ctx["pane_left"] = fmt.Sprintf("%d", int(px)/cellW) - ctx["pane_top"] = fmt.Sprintf("%d", int(py)/cellH) - } - - if containerFrame != nil { - cw := floatFromAny(containerFrame["width"]) - ch := floatFromAny(containerFrame["height"]) - ww := int(cw) / cellW - wh := int(ch) / cellH - if ww < 1 { - ww = 1 - } - if wh < 1 { - wh = 1 - } - ctx["window_width"] = fmt.Sprintf("%d", ww) - ctx["window_height"] = fmt.Sprintf("%d", wh) - } -} - -func floatFromAny(v any) float64 { - switch t := v.(type) { - case float64: - return t - case int: - return float64(t) - case json.Number: - f, _ := t.Float64() - return f - } - return 0 -} - -func intFromAnyGo(v any) int { - switch t := v.(type) { - case float64: - return int(t) - case int: - return t - case json.Number: - i, err := t.Int64() - if err != nil { - return -1 - } - return int(i) - } - return -1 -} - -// --- Target resolution --- - -func tmuxCallerWorkspaceHandle() string { - return strings.TrimSpace(os.Getenv("PROGRAMA_WORKSPACE_ID")) -} - -func tmuxCallerSurfaceHandle() string { - return strings.TrimSpace(os.Getenv("PROGRAMA_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", "PROGRAMA_PANE_ID"} { - v := strings.TrimSpace(os.Getenv(key)) - if v != "" { - return strings.TrimPrefix(v, "%") - } - } - return "" -} - -func tmuxWorkspaceItems(rc *rpcContext) ([]map[string]any, error) { - payload, err := rc.call("workspace.list", nil) - if err != nil { - return nil, err - } - items, _ := payload["workspaces"].([]any) - var result []map[string]any - for _, item := range items { - if m, ok := item.(map[string]any); ok { - result = append(result, m) - } - } - return result, nil -} - -func isUUIDish(s string) bool { - // Simple UUID check: 8-4-4-4-12 hex - if len(s) != 36 { - return false - } - for i, c := range s { - if i == 8 || i == 13 || i == 18 || i == 23 { - if c != '-' { - return false - } - } else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - return false - } - } - return true -} - -func tmuxResolveWorkspaceId(rc *rpcContext, raw string) (string, error) { - if raw == "" || raw == "current" { - if caller := tmuxCallerWorkspaceHandle(); caller != "" { - if isUUIDish(caller) { - return caller, nil - } - // Resolve ref - return tmuxResolveWorkspaceId(rc, caller) - } - payload, err := rc.call("workspace.current", nil) - if err != nil { - return "", fmt.Errorf("no workspace selected: %w", err) - } - if wsId, ok := payload["workspace_id"].(string); ok { - return wsId, nil - } - return "", fmt.Errorf("no workspace selected") - } - - if isUUIDish(raw) { - return raw, nil - } - - // Try to resolve as ref or index - items, err := tmuxWorkspaceItems(rc) - if err != nil { - return "", err - } - for _, item := range items { - if ref, _ := item["ref"].(string); ref == raw { - if id, _ := item["id"].(string); id != "" { - return id, nil - } - } - } - - // Try name match - needle := strings.TrimSpace(raw) - for _, item := range items { - title, _ := item["title"].(string) - if strings.TrimSpace(title) == needle { - if id, _ := item["id"].(string); id != "" { - return id, nil - } - } - } - - return "", fmt.Errorf("workspace not found: %s", raw) -} - -func tmuxResolveWorkspaceTarget(rc *rpcContext, raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - if caller := tmuxCallerWorkspaceHandle(); caller != "" { - return tmuxResolveWorkspaceId(rc, caller) - } - return tmuxResolveWorkspaceId(rc, "") - } - - if raw == "!" || raw == "^" || raw == "-" { - payload, err := rc.call("workspace.last", nil) - if err != nil { - return "", fmt.Errorf("previous workspace not found: %w", err) - } - if wsId, ok := payload["workspace_id"].(string); ok { - return wsId, nil - } - return "", fmt.Errorf("previous workspace not found") - } - - // Strip session:window.pane format - token := raw - if dot := strings.LastIndex(token, "."); dot >= 0 { - token = token[:dot] - } - if colon := strings.LastIndex(token, ":"); colon >= 0 { - suffix := token[colon+1:] - if suffix != "" { - token = suffix - } else { - token = token[:colon] - } - } - token = strings.TrimPrefix(token, "@") - - return tmuxResolveWorkspaceId(rc, token) -} - -func tmuxPaneSelector(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(raw, "%") { - return raw[1:] - } - if strings.HasPrefix(raw, "pane:") { - return raw - } - if dot := strings.LastIndex(raw, "."); dot >= 0 { - return raw[dot+1:] - } - return "" -} - -func tmuxWindowSelector(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(raw, "%") || strings.HasPrefix(raw, "pane:") { - return "" - } - if dot := strings.LastIndex(raw, "."); dot >= 0 { - return raw[:dot] - } - return raw -} - -func tmuxCanonicalPaneId(rc *rpcContext, handle string, workspaceId string) (string, error) { - if isUUIDish(handle) { - return handle, nil - } - payload, err := rc.call("pane.list", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - panes, _ := payload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - if ref, _ := pane["ref"].(string); ref == handle { - if id, _ := pane["id"].(string); id != "" { - return id, nil - } - } - if id, _ := pane["id"].(string); id == handle { - return id, nil - } - } - 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 { - return "", err - } - if pid, ok := payload["pane_id"].(string); ok { - return pid, nil - } - if pref, ok := payload["pane_ref"].(string); ok { - return tmuxCanonicalPaneId(rc, pref, workspaceId) - } - return "", fmt.Errorf("pane not found") -} - -func tmuxWorkspaceIdForPaneHandle(rc *rpcContext, handle string) (string, error) { - if !isUUIDish(handle) { - return "", fmt.Errorf("not a UUID") - } - workspaces, err := tmuxWorkspaceItems(rc) - if err != nil { - return "", err - } - for _, ws := range workspaces { - wsId, _ := ws["id"].(string) - if wsId == "" { - continue - } - payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - continue - } - panes, _ := payload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - if pid, _ := pane["id"].(string); pid == handle { - return wsId, nil - } - if pref, _ := pane["ref"].(string); pref == handle { - return wsId, nil - } - } - } - return "", fmt.Errorf("pane not found in any workspace") -} - -func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, err error) { - raw = strings.TrimSpace(raw) - paneSelector := tmuxPaneSelector(raw) - windowSelector := tmuxWindowSelector(raw) - - if windowSelector != "" { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, windowSelector) - if err != nil { - return "", "", err - } - } else if paneSelector != "" { - workspaceId, err = tmuxWorkspaceIdForPaneHandle(rc, paneSelector) - if err != nil { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") - if err != nil { - return "", "", err - } - } - } else { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") - if err != nil { - return "", "", err - } - } - - if paneSelector != "" { - paneId, err = tmuxCanonicalPaneId(rc, paneSelector, workspaceId) - if err != nil { - return "", "", err - } - } else if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { - if callerPane := tmuxCallerPaneHandle(); callerPane != "" { - if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil { - paneId = pid - } - } - } - - if paneId == "" { - paneId, err = tmuxFocusedPaneId(rc, workspaceId) - if err != nil { - return "", "", err - } - } - return workspaceId, paneId, nil -} - -func tmuxSelectedSurfaceId(rc *rpcContext, workspaceId string, paneId string) (string, error) { - payload, err := rc.call("pane.surfaces", map[string]any{"workspace_id": workspaceId, "pane_id": paneId}) - if err != nil { - return "", err - } - surfaces, _ := payload["surfaces"].([]any) - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - if sel, _ := surface["selected"].(bool); sel { - if id, _ := surface["id"].(string); id != "" { - return id, nil - } - } - } - // Fall back to first surface - if len(surfaces) > 0 { - if surface, ok := surfaces[0].(map[string]any); ok { - if id, _ := surface["id"].(string); id != "" { - return id, nil - } - } - } - return "", fmt.Errorf("pane has no surface") -} - -func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, surfaceId string, err error) { - raw = strings.TrimSpace(raw) - - if tmuxPaneSelector(raw) != "" { - workspaceId, paneId, err = tmuxResolvePaneTarget(rc, raw) - if err != nil { - return "", "", "", err - } - // When target pane matches caller's pane, prefer caller's surface - callerPane := tmuxCallerPaneHandle() - callerSurface := tmuxCallerSurfaceHandle() - if callerPane != "" && callerSurface != "" { - canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId) - if paneId == callerPane || paneId == canonicalCallerPane { - surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) - if err == nil { - return - } - } - } - surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId) - return - } - - winSel := tmuxWindowSelector(raw) - workspaceId, err = tmuxResolveWorkspaceTarget(rc, winSel) - if err != nil { - return "", "", "", err - } - - // When no explicit target and caller workspace matches, use caller's surface - if winSel == "" { - if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { - if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { - surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) - if err == nil { - return - } - } - } - } - - // Fall back to focused surface - payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) - if err == nil { - if sid, ok := payload["surface_id"].(string); ok { - surfaceId = sid - return - } - } - - // Last resort: first surface in the workspace - surfPayload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) - if err == nil { - surfs, _ := surfPayload["surfaces"].([]any) - for _, s := range surfs { - surf, _ := s.(map[string]any) - if surf == nil { - continue - } - if focused, _ := surf["focused"].(bool); focused { - if id, _ := surf["id"].(string); id != "" { - surfaceId = id - return workspaceId, "", surfaceId, nil - } - } - } - if len(surfs) > 0 { - if surf, ok := surfs[0].(map[string]any); ok { - if id, _ := surf["id"].(string); id != "" { - surfaceId = id - return workspaceId, "", surfaceId, nil - } - } - } - } - - 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 { - MainSurfaceId string `json:"mainSurfaceId"` - LastColumnSurfaceId string `json:"lastColumnSurfaceId,omitempty"` -} - -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"` -} - -func tmuxCompatStoreURL() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".programaterm", "tmux-compat-store.json") -} - -func loadTmuxCompatStore() tmuxCompatStore { - data, err := os.ReadFile(tmuxCompatStoreURL()) - if err != nil { - return tmuxCompatStore{ - Buffers: make(map[string]string), - Hooks: make(map[string]string), - MainVerticalLayouts: make(map[string]mainVerticalState), - LastSplitSurface: make(map[string]string), - } - } - var store tmuxCompatStore - if err := json.Unmarshal(data, &store); err != nil { - return tmuxCompatStore{ - Buffers: make(map[string]string), - Hooks: make(map[string]string), - MainVerticalLayouts: make(map[string]mainVerticalState), - LastSplitSurface: make(map[string]string), - } - } - if store.Buffers == nil { - store.Buffers = make(map[string]string) - } - if store.Hooks == nil { - store.Hooks = make(map[string]string) - } - if store.MainVerticalLayouts == nil { - store.MainVerticalLayouts = make(map[string]mainVerticalState) - } - if store.LastSplitSurface == nil { - store.LastSplitSurface = make(map[string]string) - } - return store -} - -func saveTmuxCompatStore(store tmuxCompatStore) error { - path := tmuxCompatStoreURL() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - data, err := json.Marshal(store) - if err != nil { - return err - } - 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 { - switch strings.ToLower(token) { - case "enter", "c-m", "kpenter": - return "\r" - case "tab", "c-i": - return "\t" - case "space": - return " " - case "bspace", "backspace": - return "\x7f" - case "escape", "esc", "c-[": - return "\x1b" - case "c-c": - return "\x03" - case "c-d": - return "\x04" - case "c-z": - return "\x1a" - case "c-l": - return "\x0c" - default: - return "" - } -} - -func tmuxSendKeysText(tokens []string, literal bool) string { - if literal { - return strings.Join(tokens, " ") - } - var result strings.Builder - pendingSpace := false - for _, token := range tokens { - if special := tmuxSpecialKeyText(token); special != "" { - result.WriteString(special) - pendingSpace = false - continue - } - if pendingSpace { - result.WriteByte(' ') - } - result.WriteString(token) - pendingSpace = true - } - return result.String() -} - -func tmuxShellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" -} - -func tmuxShellCommandText(positional []string, cwd string) string { - cwd = strings.TrimSpace(cwd) - cmd := strings.TrimSpace(strings.Join(positional, " ")) - if cwd == "" && cmd == "" { - return "" - } - var pieces []string - if cwd != "" { - pieces = append(pieces, "cd -- "+tmuxShellQuote(cwd)) - } - if cmd != "" { - pieces = append(pieces, cmd) - } - return strings.Join(pieces, " && ") + "\r" -} - -// --- Wait-for (filesystem-based signaling) --- - -func tmuxWaitForSignalPath(name string) string { - var sanitized strings.Builder - for _, c := range name { - if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || - c == '.' || c == '_' || c == '-' { - sanitized.WriteRune(c) - } else { - sanitized.WriteByte('_') - } - } - return fmt.Sprintf("/tmp/programa-wait-for-%s.sig", sanitized.String()) -} - // --- Main dispatch --- +// +// The rest of the tmux-compat shim is split by section across this +// package: tmux_args.go (argument parsing), tmux_format.go (format +// string rendering/context), tmux_target.go (session/window/pane target +// resolution), tmux_store.go (local JSON state), tmux_keys.go (special +// key translation), tmux_waitfor.go (wait-for signaling path), tmux_commands.go +// (per-command implementations), and tmux_helpers.go (small shared helpers). func dispatchTmuxCommand(rc *rpcContext, command string, args []string) error { switch command { @@ -1109,704 +110,3 @@ func dispatchTmuxCommand(rc *rpcContext, command string, args []string) error { return fmt.Errorf("unsupported tmux command: %s", command) } } - -// --- Command implementations --- - -// tmuxCreateWorkspace implements the body shared by `new-session` and -// `new-window`: create a workspace (optionally routed into the macOS window -// that owns targetWsId, mirroring tmux's "new window goes into the target's -// session"), rename it, and pipe in a shell command if one was given. -func tmuxCreateWorkspace(rc *rpcContext, p *tmuxParsed, title string, targetWsId string) (string, error) { - params := map[string]any{"focus": false} - if cwd := p.value("-c"); cwd != "" { - params["cwd"] = cwd - } - if targetWsId != "" { - // Route the new workspace into the same top-level window as the - // resolved target instead of always landing in the active window. - params["workspace_id"] = targetWsId - } - created, err := rc.call("workspace.create", params) - if err != nil { - return "", err - } - wsId, _ := created["workspace_id"].(string) - if wsId == "" { - return "", fmt.Errorf("workspace.create did not return workspace_id") - } - if strings.TrimSpace(title) != "" { - rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) - } - if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { - surfaceId, err := tmuxGetFirstSurface(rc, wsId) - if err == nil { - rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) - } - } - return wsId, nil -} - -// tmuxPrintWorkspaceRef prints the `-P`/`-F` formatted reference for a -// newly created workspace, shared by `new-session` and `new-window`. -func tmuxPrintWorkspaceRef(rc *rpcContext, p *tmuxParsed, wsId string) { - if !p.hasFlag("-P") { - return - } - ctx, err := tmuxFormatContext(rc, wsId, "", "") - if err != nil { - fmt.Printf("@%s\n", wsId) - return - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) -} - -func tmuxNewSession(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-s"}, []string{"-A", "-d", "-P"}) - if p.hasFlag("-A") { - return fmt.Errorf("new-session -A is not supported") - } - title := firstNonEmpty(p.value("-n"), p.value("-s")) - wsId, err := tmuxCreateWorkspace(rc, p, title, "") - if err != nil { - return err - } - tmuxPrintWorkspaceRef(rc, p, wsId) - return nil -} - -func tmuxNewWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-t"}, []string{"-d", "-P"}) - - targetWsId := "" - if raw := strings.TrimSpace(p.value("-t")); raw != "" { - resolved, err := tmuxResolveWorkspaceTarget(rc, raw) - if err != nil { - return err - } - targetWsId = resolved - } - - wsId, err := tmuxCreateWorkspace(rc, p, p.value("-n"), targetWsId) - if err != nil { - return err - } - tmuxPrintWorkspaceRef(rc, p, wsId) - return nil -} - -func tmuxSplitWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-l", "-t"}, []string{"-P", "-b", "-d", "-h", "-v"}) - - targetWs, _, targetSurface, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - - direction := "down" - if p.hasFlag("-h") { - direction = "right" - if p.hasFlag("-b") { - direction = "left" - } - } else if p.hasFlag("-b") { - direction = "up" - } - - // Anchor splits to the leader surface for agent teams. - callerWorkspace := tmuxCallerWorkspaceHandle() - anchoredCallerSurface := "" - if callerWorkspace != "" { - if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil { - if anchored := tmuxAnchoredSplitTarget(rc, wsId); anchored != nil { - targetWs = wsId - targetSurface = anchored.targetSurfaceId - direction = anchored.direction - anchoredCallerSurface = anchored.callerSurfaceId - } - } - } - - focusNewPane := !p.hasFlag("-d") - created, err := rc.call("surface.split", map[string]any{ - "workspace_id": targetWs, - "surface_id": targetSurface, - "direction": direction, - "focus": focusNewPane, - }) - if err != nil { - return err - } - surfaceId, _ := created["surface_id"].(string) - if surfaceId == "" { - return fmt.Errorf("surface.split did not return surface_id") - } - newPaneId, _ := created["pane_id"].(string) - - // Track for main-vertical layout - store := loadTmuxCompatStore() - store.LastSplitSurface[targetWs] = surfaceId - if _, ok := store.MainVerticalLayouts[targetWs]; ok { - mvs := store.MainVerticalLayouts[targetWs] - mvs.LastColumnSurfaceId = surfaceId - store.MainVerticalLayouts[targetWs] = mvs - } else if direction == "right" && anchoredCallerSurface != "" { - store.MainVerticalLayouts[targetWs] = mainVerticalState{ - MainSurfaceId: anchoredCallerSurface, - LastColumnSurfaceId: surfaceId, - } - } - saveTmuxCompatStore(store) - - // Equalize vertical splits - rc.call("workspace.equalize_splits", map[string]any{ - "workspace_id": targetWs, - "orientation": "vertical", - }) - - if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { - rc.call("surface.send_text", map[string]any{ - "workspace_id": targetWs, - "surface_id": surfaceId, - "text": text, - }) - } - - if p.hasFlag("-P") { - ctx, err := tmuxFormatContext(rc, targetWs, newPaneId, surfaceId) - if err != nil { - fmt.Println(surfaceId) - return nil - } - fallback := surfaceId - if pid, ok := ctx["pane_id"]; ok { - fallback = pid - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - return nil -} - -func tmuxSelectWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("workspace.select", map[string]any{"workspace_id": wsId}) - return err -} - -func tmuxSelectPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-P", "-T", "-t"}, nil) - // -P (style) and -T (title) are no-ops - if p.value("-P") != "" || p.value("-T") != "" { - return nil - } - wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("pane.focus", map[string]any{"workspace_id": wsId, "pane_id": paneId}) - return err -} - -func tmuxKillWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId}) - if err != nil { - return err - } - _ = tmuxPruneCompatWorkspaceState(wsId) - return nil -} - -func tmuxKillPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("surface.close", map[string]any{"workspace_id": wsId, "surface_id": surfId}) - 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 -} - -func tmuxSendKeys(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, []string{"-l"}) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - text := tmuxSendKeysText(p.positional, p.hasFlag("-l")) - if text != "" { - _, err = rc.call("surface.send_text", map[string]any{ - "workspace_id": wsId, - "surface_id": surfId, - "text": text, - }) - } - return err -} - -func tmuxCapturePane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-E", "-S", "-t"}, []string{"-J", "-N", "-p"}) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - params := map[string]any{ - "workspace_id": wsId, - "surface_id": surfId, - "scrollback": true, - } - if start := p.value("-S"); start != "" { - if lines := parseInt(start); lines < 0 { - params["lines"] = int(math.Abs(float64(lines))) - } - } - payload, err := rc.call("surface.read_text", params) - if err != nil { - return err - } - text, _ := payload["text"].(string) - if p.hasFlag("-p") { - fmt.Print(text) - } else { - store := loadTmuxCompatStore() - store.Buffers["default"] = text - saveTmuxCompatStore(store) - } - return nil -} - -func tmuxDisplayMessage(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, []string{"-p"}) - wsId, paneId, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - ctx, err := tmuxFormatContext(rc, wsId, paneId, surfId) - if err != nil { - ctx = map[string]string{} - } - - // Enrich with geometry - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err == nil { - panes, _ := panePayload["panes"].([]any) - containerFrame, _ := panePayload["container_frame"].(map[string]any) - var matchingPane map[string]any - if paneId != "" { - for _, p := range panes { - pn, _ := p.(map[string]any) - if pid, _ := pn["id"].(string); pid == paneId { - matchingPane = pn - break - } - } - } - if matchingPane == nil { - for _, p := range panes { - pn, _ := p.(map[string]any) - if focused, _ := pn["focused"].(bool); focused { - matchingPane = pn - break - } - } - } - if matchingPane == nil && len(panes) > 0 { - matchingPane, _ = panes[0].(map[string]any) - } - if matchingPane != nil { - tmuxEnrichContextWithGeometry(ctx, matchingPane, containerFrame) - } - } - - format := p.value("-F") - if len(p.positional) > 0 { - format = strings.Join(p.positional, " ") - } - rendered := tmuxRenderFormat(format, ctx, "") - if p.hasFlag("-p") || rendered != "" { - fmt.Println(rendered) - } - return nil -} - -func tmuxListWindows(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) - items, err := tmuxWorkspaceItems(rc) - if err != nil { - return err - } - for _, item := range items { - wsId, _ := item["id"].(string) - if wsId == "" { - continue - } - ctx, err := tmuxFormatContext(rc, wsId, "", "") - if err != nil { - continue - } - fallback := "" - if idx, ok := ctx["window_index"]; ok { - fallback = idx - } else { - fallback = "?" - } - if name, ok := ctx["window_name"]; ok { - fallback += " " + name - } else { - fallback += " " + wsId - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - return nil -} - -func tmuxListPanes(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) - - target := p.value("-t") - var wsId string - var err error - - if target != "" && tmuxPaneSelector(target) != "" { - wsId, _, err = tmuxResolvePaneTarget(rc, target) - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, target) - } - if err != nil { - return err - } - - payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - return err - } - panes, _ := payload["panes"].([]any) - containerFrame, _ := payload["container_frame"].(map[string]any) - - for _, p2 := range panes { - pane, _ := p2.(map[string]any) - if pane == nil { - continue - } - paneId, _ := pane["id"].(string) - if paneId == "" { - continue - } - ctx, err := tmuxFormatContext(rc, wsId, paneId, "") - if err != nil { - continue - } - tmuxEnrichContextWithGeometry(ctx, pane, containerFrame) - fallback := "%" + paneId - if pid, ok := ctx["pane_id"]; ok { - fallback = pid - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - return nil -} - -func tmuxRenameWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - title := strings.TrimSpace(strings.Join(p.positional, " ")) - if title == "" { - return fmt.Errorf("rename-window requires a title") - } - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) - return err -} - -func tmuxResizePane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t", "-x", "-y"}, []string{"-D", "-L", "-R", "-U"}) - wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) - if err != nil { - return err - } - - hasDirectional := p.hasFlag("-L") || p.hasFlag("-R") || p.hasFlag("-U") || p.hasFlag("-D") - - if !hasDirectional { - if absWidthStr := p.value("-x"); absWidthStr != "" { - absWidth := parseInt(strings.ReplaceAll(absWidthStr, "%", "")) - // Get current width to compute delta - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - return err - } - panes, _ := panePayload["panes"].([]any) - for _, pp := range panes { - pane, _ := pp.(map[string]any) - if pane == nil { - continue - } - if pid, _ := pane["id"].(string); pid == paneId { - cellW := intFromAnyGo(pane["cell_width_px"]) - currentCols := intFromAnyGo(pane["columns"]) - if cellW > 0 && currentCols >= 0 { - delta := absWidth - currentCols - if delta != 0 { - dir := "right" - if delta < 0 { - dir = "left" - delta = -delta - } - rc.call("pane.resize", map[string]any{ - "workspace_id": wsId, - "pane_id": paneId, - "direction": dir, - "amount": delta * cellW, - }) - } - } - break - } - } - return nil - } - } - - if hasDirectional { - dir := "right" - if p.hasFlag("-L") { - dir = "left" - } else if p.hasFlag("-U") { - dir = "up" - } else if p.hasFlag("-D") { - dir = "down" - } - rawAmount := firstNonEmpty(p.value("-x"), p.value("-y"), "5") - rawAmount = strings.ReplaceAll(rawAmount, "%", "") - amount := parseInt(rawAmount) - if amount <= 0 { - amount = 5 - } - _, err := rc.call("pane.resize", map[string]any{ - "workspace_id": wsId, - "pane_id": paneId, - "direction": dir, - "amount": amount, - }) - return err - } - return nil -} - -func tmuxWaitFor(_ *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"--timeout"}, []string{"-S"}) - name := "" - for _, pos := range p.positional { - if !strings.HasPrefix(pos, "-") { - name = pos - break - } - } - if name == "" { - return fmt.Errorf("wait-for requires a name") - } - - signalPath := tmuxWaitForSignalPath(name) - - if p.hasFlag("-S") { - // Signal mode: create the file - os.WriteFile(signalPath, []byte{}, 0644) - fmt.Println("OK") - return nil - } - - // Wait mode: poll for the file - timeoutStr := p.value("--timeout") - timeout := 30.0 - if timeoutStr != "" { - if t := parseFloat(timeoutStr); t > 0 { - timeout = t - } - } - - deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) - for time.Now().Before(deadline) { - if _, err := os.Stat(signalPath); err == nil { - os.Remove(signalPath) - return nil - } - time.Sleep(50 * time.Millisecond) - } - return fmt.Errorf("wait-for timeout: %s", name) -} - -func tmuxLastPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("pane.last", map[string]any{"workspace_id": wsId}) - return err -} - -func tmuxHasSession(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - _, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - return err -} - -func tmuxSelectLayout(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - layoutName := "" - if len(p.positional) > 0 { - layoutName = p.positional[0] - } - - // Resolve workspace from target (may be a pane reference) - var wsId string - var err error - if target := p.value("-t"); target != "" { - if tmuxPaneSelector(target) != "" { - wsId, _, err = tmuxResolvePaneTarget(rc, target) - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, target) - } - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, "") - } - if err != nil { - return err - } - - if layoutName == "main-vertical" || layoutName == "main-horizontal" { - orientation := "vertical" - if layoutName == "main-horizontal" { - orientation = "horizontal" - } - rc.call("workspace.equalize_splits", map[string]any{ - "workspace_id": wsId, - "orientation": orientation, - }) - } else { - rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId}) - } - - if layoutName == "main-vertical" { - if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { - store := loadTmuxCompatStore() - existingColumn := "" - if existing, ok := store.MainVerticalLayouts[wsId]; ok { - existingColumn = existing.LastColumnSurfaceId - } - seedColumn := existingColumn - if seedColumn == "" { - seedColumn = store.LastSplitSurface[wsId] - } - store.MainVerticalLayouts[wsId] = mainVerticalState{ - MainSurfaceId: callerSurface, - LastColumnSurfaceId: seedColumn, - } - saveTmuxCompatStore(store) - } - } else if layoutName != "" { - _ = tmuxPruneCompatWorkspaceState(wsId) - } - - return nil -} - -func tmuxShowBuffer(args []string) error { - p := parseTmuxArgs(args, []string{"-b"}, nil) - name := p.value("-b") - if name == "" { - name = "default" - } - store := loadTmuxCompatStore() - if buf, ok := store.Buffers[name]; ok { - fmt.Print(buf) - } - return nil -} - -func tmuxSaveBuffer(args []string) error { - p := parseTmuxArgs(args, []string{"-b"}, nil) - name := p.value("-b") - if name == "" { - name = "default" - } - store := loadTmuxCompatStore() - buf, ok := store.Buffers[name] - if !ok { - return fmt.Errorf("buffer not found: %s", name) - } - if len(p.positional) > 0 { - outputPath := strings.TrimSpace(p.positional[len(p.positional)-1]) - if outputPath != "" { - return os.WriteFile(outputPath, []byte(buf), 0644) - } - } - fmt.Print(buf) - return nil -} - -// --- Helpers --- - -func tmuxGetFirstSurface(rc *rpcContext, 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) - if len(surfaces) == 0 { - return "", fmt.Errorf("workspace has no surfaces") - } - // Prefer focused surface - for _, s := range surfaces { - surf, _ := s.(map[string]any) - if focused, _ := surf["focused"].(bool); focused { - if id, _ := surf["id"].(string); id != "" { - return id, nil - } - } - } - if surf, ok := surfaces[0].(map[string]any); ok { - if id, _ := surf["id"].(string); id != "" { - return id, nil - } - } - return "", fmt.Errorf("workspace has no surfaces") -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if v != "" { - return v - } - } - return "" -} - -func parseInt(s string) int { - s = strings.TrimSpace(s) - var n int - fmt.Sscanf(s, "%d", &n) - return n -} - -func parseFloat(s string) float64 { - s = strings.TrimSpace(s) - var f float64 - fmt.Sscanf(s, "%f", &f) - return f -} diff --git a/daemon/remote/cmd/programad-remote/tmux_format.go b/daemon/remote/cmd/programad-remote/tmux_format.go new file mode 100644 index 00000000000..482050fe248 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_format.go @@ -0,0 +1,217 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// --- Format string rendering --- + +var tmuxFormatVarRe = regexp.MustCompile(`#\{[^}]+\}`) + +func tmuxRenderFormat(format string, context map[string]string, fallback string) string { + if format == "" { + return fallback + } + rendered := format + for key, value := range context { + rendered = strings.ReplaceAll(rendered, "#{"+key+"}", value) + } + // Remove any remaining unresolved #{...} variables + rendered = tmuxFormatVarRe.ReplaceAllString(rendered, "") + rendered = strings.TrimSpace(rendered) + if rendered == "" { + return fallback + } + return rendered +} + +// --- Format context building --- + +func tmuxFormatContext(rc *rpcContext, workspaceId string, paneId string, surfaceId string) (map[string]string, error) { + canonicalWsId, err := tmuxResolveWorkspaceId(rc, workspaceId) + if err != nil { + return nil, err + } + + ctx := map[string]string{ + "session_name": "programa", + "session_id": "$0", + "window_id": "@" + canonicalWsId, + "window_uuid": canonicalWsId, + "window_active": "1", + "window_flags": "*", + "pane_active": "1", + } + + // Get workspace list for index/title + workspaces, err := tmuxWorkspaceItems(rc) + if err == nil { + for _, ws := range workspaces { + wsId, _ := ws["id"].(string) + wsRef, _ := ws["ref"].(string) + if wsId == canonicalWsId || wsRef == workspaceId { + if idx := intFromAnyGo(ws["index"]); idx >= 0 { + ctx["window_index"] = fmt.Sprintf("%d", idx) + } + if title, _ := ws["title"].(string); strings.TrimSpace(title) != "" { + ctx["window_name"] = strings.TrimSpace(title) + } + if paneCount := intFromAnyGo(ws["pane_count"]); paneCount >= 0 { + ctx["window_panes"] = fmt.Sprintf("%d", paneCount) + } + break + } + } + } + + // Get current surface info + currentPayload, err := rc.call("surface.current", map[string]any{"workspace_id": canonicalWsId}) + if err != nil { + return ctx, nil + } + + resolvedPaneId := paneId + if resolvedPaneId == "" { + if pid, ok := currentPayload["pane_id"].(string); ok { + resolvedPaneId = pid + } else if pref, ok := currentPayload["pane_ref"].(string); ok { + resolvedPaneId = pref + } + } + + resolvedSurfaceId := surfaceId + if resolvedSurfaceId == "" && resolvedPaneId != "" { + if sid, err := tmuxSelectedSurfaceId(rc, canonicalWsId, resolvedPaneId); err == nil { + resolvedSurfaceId = sid + } + } + if resolvedSurfaceId == "" { + if sid, ok := currentPayload["surface_id"].(string); ok { + resolvedSurfaceId = sid + } + } + + if resolvedPaneId != "" { + ctx["pane_id"] = "%" + resolvedPaneId + ctx["pane_uuid"] = resolvedPaneId + + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": canonicalWsId}) + if err == nil { + panes, _ := panePayload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == resolvedPaneId { + if idx := intFromAnyGo(pane["index"]); idx >= 0 { + ctx["pane_index"] = fmt.Sprintf("%d", idx) + } + break + } + } + } + } + + if resolvedSurfaceId != "" { + ctx["surface_id"] = resolvedSurfaceId + surfacePayload, err := rc.call("surface.list", map[string]any{"workspace_id": canonicalWsId}) + if err == nil { + surfaces, _ := surfacePayload["surfaces"].([]any) + for _, s := range surfaces { + surface, _ := s.(map[string]any) + if surface == nil { + continue + } + if sid, _ := surface["id"].(string); sid == resolvedSurfaceId { + if title, _ := surface["title"].(string); strings.TrimSpace(title) != "" { + ctx["pane_title"] = strings.TrimSpace(title) + if _, ok := ctx["window_name"]; !ok { + ctx["window_name"] = strings.TrimSpace(title) + } + } + break + } + } + } + } + + return ctx, nil +} + +func tmuxEnrichContextWithGeometry(ctx map[string]string, pane map[string]any, containerFrame map[string]any) { + isFocused, _ := pane["focused"].(bool) + if isFocused { + ctx["pane_active"] = "1" + } else { + ctx["pane_active"] = "0" + } + + columns := intFromAnyGo(pane["columns"]) + rows := intFromAnyGo(pane["rows"]) + if columns < 0 || rows < 0 { + return + } + ctx["pane_width"] = fmt.Sprintf("%d", columns) + ctx["pane_height"] = fmt.Sprintf("%d", rows) + + cellW := intFromAnyGo(pane["cell_width_px"]) + cellH := intFromAnyGo(pane["cell_height_px"]) + if cellW <= 0 || cellH <= 0 { + return + } + + if frame, ok := pane["pixel_frame"].(map[string]any); ok { + px := floatFromAny(frame["x"]) + py := floatFromAny(frame["y"]) + ctx["pane_left"] = fmt.Sprintf("%d", int(px)/cellW) + ctx["pane_top"] = fmt.Sprintf("%d", int(py)/cellH) + } + + if containerFrame != nil { + cw := floatFromAny(containerFrame["width"]) + ch := floatFromAny(containerFrame["height"]) + ww := int(cw) / cellW + wh := int(ch) / cellH + if ww < 1 { + ww = 1 + } + if wh < 1 { + wh = 1 + } + ctx["window_width"] = fmt.Sprintf("%d", ww) + ctx["window_height"] = fmt.Sprintf("%d", wh) + } +} + +func floatFromAny(v any) float64 { + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case json.Number: + f, _ := t.Float64() + return f + } + return 0 +} + +func intFromAnyGo(v any) int { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case json.Number: + i, err := t.Int64() + if err != nil { + return -1 + } + return int(i) + } + return -1 +} diff --git a/daemon/remote/cmd/programad-remote/tmux_helpers.go b/daemon/remote/cmd/programad-remote/tmux_helpers.go new file mode 100644 index 00000000000..f1b8052adb1 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_helpers.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "strings" +) + +// --- Helpers --- + +func tmuxGetFirstSurface(rc *rpcContext, 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) + if len(surfaces) == 0 { + return "", fmt.Errorf("workspace has no surfaces") + } + // Prefer focused surface + for _, s := range surfaces { + surf, _ := s.(map[string]any) + if focused, _ := surf["focused"].(bool); focused { + if id, _ := surf["id"].(string); id != "" { + return id, nil + } + } + } + if surf, ok := surfaces[0].(map[string]any); ok { + if id, _ := surf["id"].(string); id != "" { + return id, nil + } + } + return "", fmt.Errorf("workspace has no surfaces") +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func parseInt(s string) int { + s = strings.TrimSpace(s) + var n int + fmt.Sscanf(s, "%d", &n) + return n +} + +func parseFloat(s string) float64 { + s = strings.TrimSpace(s) + var f float64 + fmt.Sscanf(s, "%f", &f) + return f +} diff --git a/daemon/remote/cmd/programad-remote/tmux_keys.go b/daemon/remote/cmd/programad-remote/tmux_keys.go new file mode 100644 index 00000000000..6aa64ffd4af --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_keys.go @@ -0,0 +1,71 @@ +package main + +import "strings" + +// --- Special key translation --- + +func tmuxSpecialKeyText(token string) string { + switch strings.ToLower(token) { + case "enter", "c-m", "kpenter": + return "\r" + case "tab", "c-i": + return "\t" + case "space": + return " " + case "bspace", "backspace": + return "\x7f" + case "escape", "esc", "c-[": + return "\x1b" + case "c-c": + return "\x03" + case "c-d": + return "\x04" + case "c-z": + return "\x1a" + case "c-l": + return "\x0c" + default: + return "" + } +} + +func tmuxSendKeysText(tokens []string, literal bool) string { + if literal { + return strings.Join(tokens, " ") + } + var result strings.Builder + pendingSpace := false + for _, token := range tokens { + if special := tmuxSpecialKeyText(token); special != "" { + result.WriteString(special) + pendingSpace = false + continue + } + if pendingSpace { + result.WriteByte(' ') + } + result.WriteString(token) + pendingSpace = true + } + return result.String() +} + +func tmuxShellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func tmuxShellCommandText(positional []string, cwd string) string { + cwd = strings.TrimSpace(cwd) + cmd := strings.TrimSpace(strings.Join(positional, " ")) + if cwd == "" && cmd == "" { + return "" + } + var pieces []string + if cwd != "" { + pieces = append(pieces, "cd -- "+tmuxShellQuote(cwd)) + } + if cmd != "" { + pieces = append(pieces, cmd) + } + return strings.Join(pieces, " && ") + "\r" +} diff --git a/daemon/remote/cmd/programad-remote/tmux_store.go b/daemon/remote/cmd/programad-remote/tmux_store.go new file mode 100644 index 00000000000..e5353ad27af --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_store.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// --- TmuxCompatStore (local JSON state) --- + +type mainVerticalState struct { + MainSurfaceId string `json:"mainSurfaceId"` + LastColumnSurfaceId string `json:"lastColumnSurfaceId,omitempty"` +} + +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"` +} + +func tmuxCompatStoreURL() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".programaterm", "tmux-compat-store.json") +} + +func loadTmuxCompatStore() tmuxCompatStore { + data, err := os.ReadFile(tmuxCompatStoreURL()) + if err != nil { + return tmuxCompatStore{ + Buffers: make(map[string]string), + Hooks: make(map[string]string), + MainVerticalLayouts: make(map[string]mainVerticalState), + LastSplitSurface: make(map[string]string), + } + } + var store tmuxCompatStore + if err := json.Unmarshal(data, &store); err != nil { + return tmuxCompatStore{ + Buffers: make(map[string]string), + Hooks: make(map[string]string), + MainVerticalLayouts: make(map[string]mainVerticalState), + LastSplitSurface: make(map[string]string), + } + } + if store.Buffers == nil { + store.Buffers = make(map[string]string) + } + if store.Hooks == nil { + store.Hooks = make(map[string]string) + } + if store.MainVerticalLayouts == nil { + store.MainVerticalLayouts = make(map[string]mainVerticalState) + } + if store.LastSplitSurface == nil { + store.LastSplitSurface = make(map[string]string) + } + return store +} + +func saveTmuxCompatStore(store tmuxCompatStore) error { + path := tmuxCompatStoreURL() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.Marshal(store) + if err != nil { + return err + } + 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 +} diff --git a/daemon/remote/cmd/programad-remote/tmux_target.go b/daemon/remote/cmd/programad-remote/tmux_target.go new file mode 100644 index 00000000000..3d09f97af2e --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_target.go @@ -0,0 +1,494 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// --- Target resolution --- + +func tmuxCallerWorkspaceHandle() string { + return strings.TrimSpace(os.Getenv("PROGRAMA_WORKSPACE_ID")) +} + +func tmuxCallerSurfaceHandle() string { + return strings.TrimSpace(os.Getenv("PROGRAMA_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", "PROGRAMA_PANE_ID"} { + v := strings.TrimSpace(os.Getenv(key)) + if v != "" { + return strings.TrimPrefix(v, "%") + } + } + return "" +} + +func tmuxWorkspaceItems(rc *rpcContext) ([]map[string]any, error) { + payload, err := rc.call("workspace.list", nil) + if err != nil { + return nil, err + } + items, _ := payload["workspaces"].([]any) + var result []map[string]any + for _, item := range items { + if m, ok := item.(map[string]any); ok { + result = append(result, m) + } + } + return result, nil +} + +func isUUIDish(s string) bool { + // Simple UUID check: 8-4-4-4-12 hex + if len(s) != 36 { + return false + } + for i, c := range s { + if i == 8 || i == 13 || i == 18 || i == 23 { + if c != '-' { + return false + } + } else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +func tmuxResolveWorkspaceId(rc *rpcContext, raw string) (string, error) { + if raw == "" || raw == "current" { + if caller := tmuxCallerWorkspaceHandle(); caller != "" { + if isUUIDish(caller) { + return caller, nil + } + // Resolve ref + return tmuxResolveWorkspaceId(rc, caller) + } + payload, err := rc.call("workspace.current", nil) + if err != nil { + return "", fmt.Errorf("no workspace selected: %w", err) + } + if wsId, ok := payload["workspace_id"].(string); ok { + return wsId, nil + } + return "", fmt.Errorf("no workspace selected") + } + + if isUUIDish(raw) { + return raw, nil + } + + // Try to resolve as ref or index + items, err := tmuxWorkspaceItems(rc) + if err != nil { + return "", err + } + for _, item := range items { + if ref, _ := item["ref"].(string); ref == raw { + if id, _ := item["id"].(string); id != "" { + return id, nil + } + } + } + + // Try name match + needle := strings.TrimSpace(raw) + for _, item := range items { + title, _ := item["title"].(string) + if strings.TrimSpace(title) == needle { + if id, _ := item["id"].(string); id != "" { + return id, nil + } + } + } + + return "", fmt.Errorf("workspace not found: %s", raw) +} + +func tmuxResolveWorkspaceTarget(rc *rpcContext, raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + if caller := tmuxCallerWorkspaceHandle(); caller != "" { + return tmuxResolveWorkspaceId(rc, caller) + } + return tmuxResolveWorkspaceId(rc, "") + } + + if raw == "!" || raw == "^" || raw == "-" { + payload, err := rc.call("workspace.last", nil) + if err != nil { + return "", fmt.Errorf("previous workspace not found: %w", err) + } + if wsId, ok := payload["workspace_id"].(string); ok { + return wsId, nil + } + return "", fmt.Errorf("previous workspace not found") + } + + // Strip session:window.pane format + token := raw + if dot := strings.LastIndex(token, "."); dot >= 0 { + token = token[:dot] + } + if colon := strings.LastIndex(token, ":"); colon >= 0 { + suffix := token[colon+1:] + if suffix != "" { + token = suffix + } else { + token = token[:colon] + } + } + token = strings.TrimPrefix(token, "@") + + return tmuxResolveWorkspaceId(rc, token) +} + +func tmuxPaneSelector(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "%") { + return raw[1:] + } + if strings.HasPrefix(raw, "pane:") { + return raw + } + if dot := strings.LastIndex(raw, "."); dot >= 0 { + return raw[dot+1:] + } + return "" +} + +func tmuxWindowSelector(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "%") || strings.HasPrefix(raw, "pane:") { + return "" + } + if dot := strings.LastIndex(raw, "."); dot >= 0 { + return raw[:dot] + } + return raw +} + +func tmuxCanonicalPaneId(rc *rpcContext, handle string, workspaceId string) (string, error) { + if isUUIDish(handle) { + return handle, nil + } + payload, err := rc.call("pane.list", map[string]any{"workspace_id": workspaceId}) + if err != nil { + return "", err + } + panes, _ := payload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if ref, _ := pane["ref"].(string); ref == handle { + if id, _ := pane["id"].(string); id != "" { + return id, nil + } + } + if id, _ := pane["id"].(string); id == handle { + return id, nil + } + } + 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 { + return "", err + } + if pid, ok := payload["pane_id"].(string); ok { + return pid, nil + } + if pref, ok := payload["pane_ref"].(string); ok { + return tmuxCanonicalPaneId(rc, pref, workspaceId) + } + return "", fmt.Errorf("pane not found") +} + +func tmuxWorkspaceIdForPaneHandle(rc *rpcContext, handle string) (string, error) { + if !isUUIDish(handle) { + return "", fmt.Errorf("not a UUID") + } + workspaces, err := tmuxWorkspaceItems(rc) + if err != nil { + return "", err + } + for _, ws := range workspaces { + wsId, _ := ws["id"].(string) + if wsId == "" { + continue + } + payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + continue + } + panes, _ := payload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == handle { + return wsId, nil + } + if pref, _ := pane["ref"].(string); pref == handle { + return wsId, nil + } + } + } + return "", fmt.Errorf("pane not found in any workspace") +} + +func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, err error) { + raw = strings.TrimSpace(raw) + paneSelector := tmuxPaneSelector(raw) + windowSelector := tmuxWindowSelector(raw) + + if windowSelector != "" { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, windowSelector) + if err != nil { + return "", "", err + } + } else if paneSelector != "" { + workspaceId, err = tmuxWorkspaceIdForPaneHandle(rc, paneSelector) + if err != nil { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") + if err != nil { + return "", "", err + } + } + } else { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") + if err != nil { + return "", "", err + } + } + + if paneSelector != "" { + paneId, err = tmuxCanonicalPaneId(rc, paneSelector, workspaceId) + if err != nil { + return "", "", err + } + } else if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { + if callerPane := tmuxCallerPaneHandle(); callerPane != "" { + if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil { + paneId = pid + } + } + } + + if paneId == "" { + paneId, err = tmuxFocusedPaneId(rc, workspaceId) + if err != nil { + return "", "", err + } + } + return workspaceId, paneId, nil +} + +func tmuxSelectedSurfaceId(rc *rpcContext, workspaceId string, paneId string) (string, error) { + payload, err := rc.call("pane.surfaces", map[string]any{"workspace_id": workspaceId, "pane_id": paneId}) + if err != nil { + return "", err + } + surfaces, _ := payload["surfaces"].([]any) + for _, s := range surfaces { + surface, _ := s.(map[string]any) + if surface == nil { + continue + } + if sel, _ := surface["selected"].(bool); sel { + if id, _ := surface["id"].(string); id != "" { + return id, nil + } + } + } + // Fall back to first surface + if len(surfaces) > 0 { + if surface, ok := surfaces[0].(map[string]any); ok { + if id, _ := surface["id"].(string); id != "" { + return id, nil + } + } + } + return "", fmt.Errorf("pane has no surface") +} + +func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, surfaceId string, err error) { + raw = strings.TrimSpace(raw) + + if tmuxPaneSelector(raw) != "" { + workspaceId, paneId, err = tmuxResolvePaneTarget(rc, raw) + if err != nil { + return "", "", "", err + } + // When target pane matches caller's pane, prefer caller's surface + callerPane := tmuxCallerPaneHandle() + callerSurface := tmuxCallerSurfaceHandle() + if callerPane != "" && callerSurface != "" { + canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId) + if paneId == callerPane || paneId == canonicalCallerPane { + surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) + if err == nil { + return + } + } + } + surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId) + return + } + + winSel := tmuxWindowSelector(raw) + workspaceId, err = tmuxResolveWorkspaceTarget(rc, winSel) + if err != nil { + return "", "", "", err + } + + // When no explicit target and caller workspace matches, use caller's surface + if winSel == "" { + if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { + if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { + surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) + if err == nil { + return + } + } + } + } + + // Fall back to focused surface + payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) + if err == nil { + if sid, ok := payload["surface_id"].(string); ok { + surfaceId = sid + return + } + } + + // Last resort: first surface in the workspace + surfPayload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) + if err == nil { + surfs, _ := surfPayload["surfaces"].([]any) + for _, s := range surfs { + surf, _ := s.(map[string]any) + if surf == nil { + continue + } + if focused, _ := surf["focused"].(bool); focused { + if id, _ := surf["id"].(string); id != "" { + surfaceId = id + return workspaceId, "", surfaceId, nil + } + } + } + if len(surfs) > 0 { + if surf, ok := surfs[0].(map[string]any); ok { + if id, _ := surf["id"].(string); id != "" { + surfaceId = id + return workspaceId, "", surfaceId, nil + } + } + } + } + + 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 +} diff --git a/daemon/remote/cmd/programad-remote/tmux_waitfor.go b/daemon/remote/cmd/programad-remote/tmux_waitfor.go new file mode 100644 index 00000000000..d098160c78d --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_waitfor.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "strings" +) + +// --- Wait-for (filesystem-based signaling) --- + +func tmuxWaitForSignalPath(name string) string { + var sanitized strings.Builder + for _, c := range name { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-' { + sanitized.WriteRune(c) + } else { + sanitized.WriteByte('_') + } + } + return fmt.Sprintf("/tmp/programa-wait-for-%s.sig", sanitized.String()) +} From c0438f45e53429cef208e5fd6e31ce8c70d32589 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:25:01 -0300 Subject: [PATCH 3/4] refactor: split main.go into proxy vs session RPC handlers main.go had grown to 1,105 lines mixing the stdio RPC server lifecycle with two unrelated groups of method handlers. File reorganization only, no behavior change: every type and function moved verbatim (confirmed by diffing extracted func/type signatures against the pre-split file), all staying in package main. - main.go: entry point, stdio RPC server lifecycle, frame I/O, handleRequest dispatch table, closeAll (shared teardown across both stream and session state), and the low-level param helpers shared by both handler groups - main_proxy.go: proxy.* handlers (open/close/write/stream.subscribe) plus their stream bookkeeping (getStream/dropStream/streamPump) - main_sessions.go: session.* handlers (open/close/attach/resize/ detach/status) plus their snapshot/resize bookkeeping Refs #102. --- daemon/remote/cmd/programad-remote/main.go | 681 +----------------- .../remote/cmd/programad-remote/main_proxy.go | 338 +++++++++ .../cmd/programad-remote/main_sessions.go | 357 +++++++++ 3 files changed, 702 insertions(+), 674 deletions(-) create mode 100644 daemon/remote/cmd/programad-remote/main_proxy.go create mode 100644 daemon/remote/cmd/programad-remote/main_sessions.go diff --git a/daemon/remote/cmd/programad-remote/main.go b/daemon/remote/cmd/programad-remote/main.go index 6bc4d7dac8b..182464f5078 100644 --- a/daemon/remote/cmd/programad-remote/main.go +++ b/daemon/remote/cmd/programad-remote/main.go @@ -3,7 +3,6 @@ package main import ( "bufio" "bytes" - "encoding/base64" "encoding/json" "errors" "flag" @@ -13,8 +12,6 @@ import ( "net" "os" "path/filepath" - "sort" - "strconv" "strings" "sync" "time" @@ -82,6 +79,10 @@ type sessionState struct { const maxRPCFrameBytes = 4 * 1024 * 1024 +// The RPC method handlers implementing this dispatch table live in +// main_proxy.go (proxy.* — raw TCP stream tunneling) and +// main_sessions.go (session.* — terminal attachment/resize bookkeeping). + func main() { if shouldRunCLIForInvocation(os.Args[0], os.Args[1:]) { os.Exit(runCLI(os.Args[1:])) @@ -360,621 +361,9 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { } } -func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { - host, ok := getStringParam(req.Params, "host") - if !ok || host == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.open requires host", - }, - } - } - port, ok := getIntParam(req.Params, "port") - if !ok || port <= 0 || port > 65535 { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.open requires port in range 1-65535", - }, - } - } - - timeoutMs := 10000 - if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { - timeoutMs = parsed - } - - conn, err := net.DialTimeout( - "tcp", - net.JoinHostPort(host, strconv.Itoa(port)), - time.Duration(timeoutMs)*time.Millisecond, - ) - if err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "open_failed", - Message: err.Error(), - }, - } - } - setTCPNoDelay(conn) - - s.mu.Lock() - streamID := fmt.Sprintf("s-%d", s.nextStreamID) - s.nextStreamID++ - s.streams[streamID] = &streamState{conn: conn} - s.mu.Unlock() - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "stream_id": streamID, - }, - } -} - -func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.close requires stream_id", - }, - } - } - - s.mu.Lock() - state, exists := s.streams[streamID] - if exists { - delete(s.streams, streamID) - } - s.mu.Unlock() - - if !exists { - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "closed": true, - }, - } - } - - _ = state.conn.Close() - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "closed": true, - }, - } -} - -func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.write requires stream_id", - }, - } - } - dataBase64, ok := getStringParam(req.Params, "data_base64") - if !ok { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.write requires data_base64", - }, - } - } - payload, err := base64.StdEncoding.DecodeString(dataBase64) - if err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "data_base64 must be valid base64", - }, - } - } - - state, found := s.getStream(streamID) - if !found { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "stream not found", - }, - } - } - conn := state.conn - - timeoutMs := 8000 - if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { - timeoutMs = parsed - } - if timeoutMs > 0 { - if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: err.Error(), - }, - } - } - defer conn.SetWriteDeadline(time.Time{}) - } - - total := 0 - for total < len(payload) { - written, writeErr := conn.Write(payload[total:]) - if written == 0 && writeErr == nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: "write made no progress", - }, - } - } - total += written - if writeErr != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: writeErr.Error(), - }, - } - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "written": total, - }, - } -} - -func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.stream.subscribe requires stream_id", - }, - } - } - - s.mu.Lock() - state, found := s.streams[streamID] - if !found { - s.mu.Unlock() - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "stream not found", - }, - } - } - alreadySubscribed := state.readerStarted - if !alreadySubscribed { - state.readerStarted = true - } - conn := state.conn - s.mu.Unlock() - - if !alreadySubscribed { - go s.streamPump(streamID, conn) - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "subscribed": true, - "already_subscribed": alreadySubscribed, - }, - } -} - -func (s *rpcServer) handleSessionOpen(req rpcRequest) rpcResponse { - sessionID, _ := getStringParam(req.Params, "session_id") - - s.mu.Lock() - defer s.mu.Unlock() - - if sessionID == "" { - sessionID = fmt.Sprintf("sess-%d", s.nextSessionID) - s.nextSessionID++ - } - - session, exists := s.sessions[sessionID] - if !exists { - session = &sessionState{ - attachments: map[string]sessionAttachment{}, - } - s.sessions[sessionID] = session - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionClose(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.close requires session_id", - }, - } - } - - s.mu.Lock() - _, exists := s.sessions[sessionID] - if exists { - delete(s.sessions, sessionID) - } - s.mu.Unlock() - - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "session_id": sessionID, - "closed": true, - }, - } -} - -func (s *rpcServer) handleSessionAttach(req rpcRequest) rpcResponse { - sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.attach") - if badResp != nil { - return *badResp - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - session.attachments[attachmentID] = sessionAttachment{ - Cols: cols, - Rows: rows, - UpdatedAt: time.Now().UTC(), - } - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionResize(req rpcRequest) rpcResponse { - sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.resize") - if badResp != nil { - return *badResp - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - if _, exists := session.attachments[attachmentID]; !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "attachment not found", - }, - } - } - - session.attachments[attachmentID] = sessionAttachment{ - Cols: cols, - Rows: rows, - UpdatedAt: time.Now().UTC(), - } - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionDetach(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.detach requires session_id", - }, - } - } - attachmentID, ok := getStringParam(req.Params, "attachment_id") - if !ok || attachmentID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.detach requires attachment_id", - }, - } - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - if _, exists := session.attachments[attachmentID]; !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "attachment not found", - }, - } - } - - delete(session.attachments, attachmentID) - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionStatus(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.status requires session_id", - }, - } - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func parseSessionAttachmentParams(req rpcRequest, method string) (sessionID string, attachmentID string, cols int, rows int, badResp *rpcResponse) { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires session_id", - }, - } - return "", "", 0, 0, &resp - } - attachmentID, ok = getStringParam(req.Params, "attachment_id") - if !ok || attachmentID == "" { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires attachment_id", - }, - } - return "", "", 0, 0, &resp - } - - cols, ok = getIntParam(req.Params, "cols") - if !ok || cols <= 0 { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires cols > 0", - }, - } - return "", "", 0, 0, &resp - } - rows, ok = getIntParam(req.Params, "rows") - if !ok || rows <= 0 { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires rows > 0", - }, - } - return "", "", 0, 0, &resp - } - - return sessionID, attachmentID, cols, rows, nil -} - -func recomputeSessionSize(session *sessionState) { - if len(session.attachments) == 0 { - session.effectiveCols = session.lastKnownCols - session.effectiveRows = session.lastKnownRows - return - } - - minCols := 0 - minRows := 0 - for _, attachment := range session.attachments { - if minCols == 0 || attachment.Cols < minCols { - minCols = attachment.Cols - } - if minRows == 0 || attachment.Rows < minRows { - minRows = attachment.Rows - } - } - - session.effectiveCols = minCols - session.effectiveRows = minRows - session.lastKnownCols = minCols - session.lastKnownRows = minRows -} - -func sessionSnapshot(sessionID string, session *sessionState) map[string]any { - attachmentIDs := make([]string, 0, len(session.attachments)) - for attachmentID := range session.attachments { - attachmentIDs = append(attachmentIDs, attachmentID) - } - sort.Strings(attachmentIDs) - - attachments := make([]map[string]any, 0, len(attachmentIDs)) - for _, attachmentID := range attachmentIDs { - attachment := session.attachments[attachmentID] - attachments = append(attachments, map[string]any{ - "attachment_id": attachmentID, - "cols": attachment.Cols, - "rows": attachment.Rows, - "updated_at": attachment.UpdatedAt.Format(time.RFC3339Nano), - }) - } - - return map[string]any{ - "session_id": sessionID, - "attachments": attachments, - "effective_cols": session.effectiveCols, - "effective_rows": session.effectiveRows, - "last_known_cols": session.lastKnownCols, - "last_known_rows": session.lastKnownRows, - } -} - -func (s *rpcServer) getStream(streamID string) (*streamState, bool) { - s.mu.Lock() - defer s.mu.Unlock() - state, ok := s.streams[streamID] - return state, ok -} - -func (s *rpcServer) dropStream(streamID string) { - s.mu.Lock() - state, ok := s.streams[streamID] - if ok { - delete(s.streams, streamID) - } - s.mu.Unlock() - if ok { - _ = state.conn.Close() - } -} - +// closeAll tears down every open proxy stream and clears session state. +// It touches both s.streams and s.sessions, so it lives here rather than +// in either handler file. func (s *rpcServer) closeAll() { s.mu.Lock() streams := make([]net.Conn, 0, len(s.streams)) @@ -991,62 +380,6 @@ func (s *rpcServer) closeAll() { } } -func (s *rpcServer) streamPump(streamID string, conn net.Conn) { - defer func() { - if recovered := recover(); recovered != nil { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: fmt.Sprintf("stream panic: %v", recovered), - }) - s.dropStream(streamID) - } - }() - - buffer := make([]byte, 32768) - for { - n, readErr := conn.Read(buffer) - data := append([]byte(nil), buffer[:max(0, n)]...) - if len(data) > 0 { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.data", - StreamID: streamID, - DataBase64: base64.StdEncoding.EncodeToString(data), - }) - } - - if readErr == nil { - if n == 0 { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: "read made no progress", - }) - s.dropStream(streamID) - return - } - continue - } - - if readErr == io.EOF { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.eof", - StreamID: streamID, - DataBase64: "", - }) - } else if !errors.Is(readErr, net.ErrClosed) { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: readErr.Error(), - }) - } - - s.dropStream(streamID) - return - } -} - func getStringParam(params map[string]any, key string) (string, bool) { if params == nil { return "", false diff --git a/daemon/remote/cmd/programad-remote/main_proxy.go b/daemon/remote/cmd/programad-remote/main_proxy.go new file mode 100644 index 00000000000..f9e72c831c4 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/main_proxy.go @@ -0,0 +1,338 @@ +package main + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "strconv" + "time" +) + +// --- proxy.* RPC handlers: raw TCP stream tunneling over the daemon's +// stdio RPC connection (proxy.open/close/write/stream.subscribe). --- + +func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { + host, ok := getStringParam(req.Params, "host") + if !ok || host == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires host", + }, + } + } + port, ok := getIntParam(req.Params, "port") + if !ok || port <= 0 || port > 65535 { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires port in range 1-65535", + }, + } + } + + timeoutMs := 10000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { + timeoutMs = parsed + } + + conn, err := net.DialTimeout( + "tcp", + net.JoinHostPort(host, strconv.Itoa(port)), + time.Duration(timeoutMs)*time.Millisecond, + ) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "open_failed", + Message: err.Error(), + }, + } + } + setTCPNoDelay(conn) + + s.mu.Lock() + streamID := fmt.Sprintf("s-%d", s.nextStreamID) + s.nextStreamID++ + s.streams[streamID] = &streamState{conn: conn} + s.mu.Unlock() + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "stream_id": streamID, + }, + } +} + +func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.close requires stream_id", + }, + } + } + + s.mu.Lock() + state, exists := s.streams[streamID] + if exists { + delete(s.streams, streamID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "closed": true, + }, + } + } + + _ = state.conn.Close() + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "closed": true, + }, + } +} + +func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires stream_id", + }, + } + } + dataBase64, ok := getStringParam(req.Params, "data_base64") + if !ok { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires data_base64", + }, + } + } + payload, err := base64.StdEncoding.DecodeString(dataBase64) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "data_base64 must be valid base64", + }, + } + } + + state, found := s.getStream(streamID) + if !found { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + conn := state.conn + + timeoutMs := 8000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { + timeoutMs = parsed + } + if timeoutMs > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: err.Error(), + }, + } + } + defer conn.SetWriteDeadline(time.Time{}) + } + + total := 0 + for total < len(payload) { + written, writeErr := conn.Write(payload[total:]) + if written == 0 && writeErr == nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: "write made no progress", + }, + } + } + total += written + if writeErr != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: writeErr.Error(), + }, + } + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "written": total, + }, + } +} + +func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.stream.subscribe requires stream_id", + }, + } + } + + s.mu.Lock() + state, found := s.streams[streamID] + if !found { + s.mu.Unlock() + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + alreadySubscribed := state.readerStarted + if !alreadySubscribed { + state.readerStarted = true + } + conn := state.conn + s.mu.Unlock() + + if !alreadySubscribed { + go s.streamPump(streamID, conn) + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "subscribed": true, + "already_subscribed": alreadySubscribed, + }, + } +} + +func (s *rpcServer) getStream(streamID string) (*streamState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + state, ok := s.streams[streamID] + return state, ok +} + +func (s *rpcServer) dropStream(streamID string) { + s.mu.Lock() + state, ok := s.streams[streamID] + if ok { + delete(s.streams, streamID) + } + s.mu.Unlock() + if ok { + _ = state.conn.Close() + } +} + +func (s *rpcServer) streamPump(streamID string, conn net.Conn) { + defer func() { + if recovered := recover(); recovered != nil { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: fmt.Sprintf("stream panic: %v", recovered), + }) + s.dropStream(streamID) + } + }() + + buffer := make([]byte, 32768) + for { + n, readErr := conn.Read(buffer) + data := append([]byte(nil), buffer[:max(0, n)]...) + if len(data) > 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.data", + StreamID: streamID, + DataBase64: base64.StdEncoding.EncodeToString(data), + }) + } + + if readErr == nil { + if n == 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: "read made no progress", + }) + s.dropStream(streamID) + return + } + continue + } + + if readErr == io.EOF { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.eof", + StreamID: streamID, + DataBase64: "", + }) + } else if !errors.Is(readErr, net.ErrClosed) { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: readErr.Error(), + }) + } + + s.dropStream(streamID) + return + } +} diff --git a/daemon/remote/cmd/programad-remote/main_sessions.go b/daemon/remote/cmd/programad-remote/main_sessions.go new file mode 100644 index 00000000000..a3cadd65598 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/main_sessions.go @@ -0,0 +1,357 @@ +package main + +import ( + "fmt" + "sort" + "time" +) + +// --- session.* RPC handlers: terminal session attachment/resize +// bookkeeping (session.open/close/attach/resize/detach/status). --- + +func (s *rpcServer) handleSessionOpen(req rpcRequest) rpcResponse { + sessionID, _ := getStringParam(req.Params, "session_id") + + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID == "" { + sessionID = fmt.Sprintf("sess-%d", s.nextSessionID) + s.nextSessionID++ + } + + session, exists := s.sessions[sessionID] + if !exists { + session = &sessionState{ + attachments: map[string]sessionAttachment{}, + } + s.sessions[sessionID] = session + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionClose(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.close requires session_id", + }, + } + } + + s.mu.Lock() + _, exists := s.sessions[sessionID] + if exists { + delete(s.sessions, sessionID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "session_id": sessionID, + "closed": true, + }, + } +} + +func (s *rpcServer) handleSessionAttach(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.attach") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionResize(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.resize") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionDetach(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires session_id", + }, + } + } + attachmentID, ok := getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires attachment_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + delete(session.attachments, attachmentID) + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionStatus(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.status requires session_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func parseSessionAttachmentParams(req rpcRequest, method string) (sessionID string, attachmentID string, cols int, rows int, badResp *rpcResponse) { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires session_id", + }, + } + return "", "", 0, 0, &resp + } + attachmentID, ok = getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires attachment_id", + }, + } + return "", "", 0, 0, &resp + } + + cols, ok = getIntParam(req.Params, "cols") + if !ok || cols <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires cols > 0", + }, + } + return "", "", 0, 0, &resp + } + rows, ok = getIntParam(req.Params, "rows") + if !ok || rows <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires rows > 0", + }, + } + return "", "", 0, 0, &resp + } + + return sessionID, attachmentID, cols, rows, nil +} + +func recomputeSessionSize(session *sessionState) { + if len(session.attachments) == 0 { + session.effectiveCols = session.lastKnownCols + session.effectiveRows = session.lastKnownRows + return + } + + minCols := 0 + minRows := 0 + for _, attachment := range session.attachments { + if minCols == 0 || attachment.Cols < minCols { + minCols = attachment.Cols + } + if minRows == 0 || attachment.Rows < minRows { + minRows = attachment.Rows + } + } + + session.effectiveCols = minCols + session.effectiveRows = minRows + session.lastKnownCols = minCols + session.lastKnownRows = minRows +} + +func sessionSnapshot(sessionID string, session *sessionState) map[string]any { + attachmentIDs := make([]string, 0, len(session.attachments)) + for attachmentID := range session.attachments { + attachmentIDs = append(attachmentIDs, attachmentID) + } + sort.Strings(attachmentIDs) + + attachments := make([]map[string]any, 0, len(attachmentIDs)) + for _, attachmentID := range attachmentIDs { + attachment := session.attachments[attachmentID] + attachments = append(attachments, map[string]any{ + "attachment_id": attachmentID, + "cols": attachment.Cols, + "rows": attachment.Rows, + "updated_at": attachment.UpdatedAt.Format(time.RFC3339Nano), + }) + } + + return map[string]any{ + "session_id": sessionID, + "attachments": attachments, + "effective_cols": session.effectiveCols, + "effective_rows": session.effectiveRows, + "last_known_cols": session.lastKnownCols, + "last_known_rows": session.lastKnownRows, + } +} From 4bb1ae49b4853d708dfc51af2fa38684432b975b Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:25:58 -0300 Subject: [PATCH 4/4] chore: rename daemon/remote go module to darkroomengineering/programa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module path was still the pre-rebrand github.com/manaflow-ai/cmux/daemon/remote leftover from before this repo became darkroomengineering/programa. Rename to github.com/darkroomengineering/programa/daemon/remote. No internal import paths needed updating — the module is a single package (cmd/programad-remote) with no intra-module imports, and a repo-wide grep confirmed no other file references the old manaflow-ai/cmux/daemon/remote import path. Refs #102. --- daemon/remote/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/remote/go.mod b/daemon/remote/go.mod index f4b93baa23c..92389552a9d 100644 --- a/daemon/remote/go.mod +++ b/daemon/remote/go.mod @@ -1,3 +1,3 @@ -module github.com/manaflow-ai/cmux/daemon/remote +module github.com/darkroomengineering/programa/daemon/remote go 1.22