From a345b561c8f03288e57df41f6d0f167342cceedd Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:44:07 -0700 Subject: [PATCH] Launch sessions in a tmux split when running inside tmux Pane launch mode previously only worked in Windows Terminal. When dispatch runs inside a tmux session (TMUX is set), pane mode now routes through 'tmux split-window' so macOS and Linux users get a split in their current tmux window instead of a new terminal. - Maps pane direction to tmux flags: right/left to -h, down/up to -v. - Sets the working directory with -c and runs the resume command. - Falls back to the existing behavior when not inside tmux. Closes #219 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 16 +++++- internal/platform/shell.go | 56 ++++++++++++++++++ internal/platform/tmux_test.go | 100 +++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 internal/platform/tmux_test.go diff --git a/README.md b/README.md index 7752edc..5e768e8 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess - **Copy session ID** (`c`) — copy the selected session's ID to the system clipboard. Also available by clicking the ID row in the preview pane - **Copy resume command** (`Y`) — copy the selected session's full resume command to the system clipboard - **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer on Windows, Finder on macOS, the default file manager on Linux) -- **Four launch modes** (`Enter` / `t` / `w` / `e`) — in-place, new tab, new window, split pane (Windows Terminal) with per-session overrides +- **Four launch modes** (`Enter` / `t` / `w` / `e`) — in-place, new tab, new window, split pane (Windows Terminal, or tmux when running inside a tmux session) with per-session overrides - **Multi-session open** (`Space` / `L` / `a` / `d`) — select multiple sessions with Space, launch all at once with L, select/deselect all with a/d. Shift+↑/↓ for range selection, Ctrl+click and Shift+click for mouse selection. With a selection active, `h` (hide) and `*` (favorite) apply to every selected session at once - **Attention indicators** — colored dots showing real-time session status: working (blue, executing tools), thinking (cyan, generating response), compacting (magenta, context compaction), waiting (purple), active (green), stale (yellow), interrupted (orange ⚡), idle (gray). Jump to next waiting session with `n`, resume interrupted sessions with `R`, filter by status with `!` - **Host type icons** — sessions display an icon indicating their origin: CLI (desktop), Cloud (cloud), or Actions (gear) @@ -172,7 +172,7 @@ Add `--json` (`dispatch doctor --json`) to print the same checks as a single JSO | `Enter` | Launch selected session (or toggle folder) | | `w` | Launch in new window | | `t` | Launch in new tab | -| `e` | Launch in split pane (Windows Terminal) | +| `e` | Launch in split pane (Windows Terminal, or tmux inside a tmux session) | #### Multi-Select @@ -324,6 +324,18 @@ When `launch_mode` is `"pane"`, the `pane_direction` value maps to Windows Termi > **Note:** `-H` and `-V` control split *orientation* only (the direction the divider runs). Windows Terminal decides actual pane placement based on available space. +#### tmux Support (macOS and Linux) + +When you run dispatch inside a tmux session (the `TMUX` environment variable is set), pane mode splits the current tmux window with `tmux split-window` instead of opening a new terminal emulator. The `pane_direction` value maps to tmux flags: + +| Direction | tmux Flag | Meaning | +|-----------|-----------|---------| +| `right` / `left` | `-h` | Vertical divider — new pane to the right | +| `down` / `up` | `-v` | Horizontal divider — new pane below | +| `auto` | *(none)* | tmux uses its default (a pane below) | + +The split starts in the session's working directory (`-c`) and runs the resume command in your shell. Outside tmux, pane mode behaves as before. + ### Example config.json ```json diff --git a/internal/platform/shell.go b/internal/platform/shell.go index 50226db..b0ba973 100644 --- a/internal/platform/shell.go +++ b/internal/platform/shell.go @@ -460,6 +460,14 @@ var platformLaunchSessionFn = platformLaunchSession // platformLaunchSession is the default implementation of platformLaunchSessionFn. func platformLaunchSession(shell ShellInfo, resumeCmd string, terminal string, cwd string, launchStyle string, paneDirection string) error { + // Inside tmux, pane mode splits the current tmux window instead of + // spawning a new terminal emulator. This makes pane mode work on macOS + // and Linux for users who live in tmux, matching the Windows Terminal + // split-pane behavior. + if launchStyle == LaunchStylePane && insideTmux() { + return launchTmuxPane(shell, resumeCmd, cwd, paneDirection) + } + switch runtime.GOOS { case "windows": return launchWindowsSession(shell, resumeCmd, terminal, cwd, launchStyle, paneDirection) @@ -582,6 +590,54 @@ func appendWTPaneDirFlags(args []string, dir string) []string { } } +// insideTmux reports whether the current process is running inside a tmux +// session, detected via the TMUX environment variable that tmux sets for +// every process in a pane. +func insideTmux() bool { + return os.Getenv("TMUX") != "" +} + +// buildTmuxSplitArgs builds the argument list for `tmux split-window` that +// opens the resume command in a new split of the current tmux window. +// +// direction selects the divider orientation: +// +// "right"/"left" → -h vertical divider, new pane to the right +// "down"/"up" → -v horizontal divider, new pane below +// "auto"/"" → (no flag) tmux uses its default (a pane below) +// +// The working directory is set with -c so the split starts where the session +// lives, and the resume command runs through the user's shell. +func buildTmuxSplitArgs(shell ShellInfo, resumeCmd, cwd, direction string) []string { + args := []string{"split-window"} + switch direction { + case "right", "left": + args = append(args, "-h") + case "down", "up": + args = append(args, "-v") + } + if cwd != "" { + args = append(args, "-c", cwd) + } + args = append(args, shell.Path, "-c", resumeCmd) + return args +} + +// launchTmuxPane opens the resume command in a split of the current tmux +// window. It is only used when running inside tmux (see insideTmux). +func launchTmuxPane(shell ShellInfo, resumeCmd, cwd, paneDirection string) error { + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + return fmt.Errorf("TMUX is set but the tmux binary was not found on PATH: %w", err) + } + args := buildTmuxSplitArgs(shell, resumeCmd, cwd, paneDirection) + cmd := exec.CommandContext(context.Background(), tmuxPath, args...) + if cwd != "" { + cmd.Dir = cwd + } + return startAndWaitBriefly(cmd) +} + func launchWindowsSession(shell ShellInfo, resumeCmd string, terminal string, cwd string, launchStyle string, paneDirection string) error { // Use Windows Terminal when configured (or defaulted by LaunchSession). if terminal == termWindowsTerminal { diff --git a/internal/platform/tmux_test.go b/internal/platform/tmux_test.go new file mode 100644 index 0000000..ee3ea53 --- /dev/null +++ b/internal/platform/tmux_test.go @@ -0,0 +1,100 @@ +package platform + +import ( + "testing" +) + +func TestBuildTmuxSplitArgs_Direction(t *testing.T) { + shell := ShellInfo{Path: "/usr/bin/bash"} + resume := "ghcs --resume s1" + + tests := []struct { + name string + dir string + wantDir string // "-h", "-v", or "" for no direction flag + }{ + {"right maps to -h", "right", "-h"}, + {"left maps to -h", "left", "-h"}, + {"down maps to -v", "down", "-v"}, + {"up maps to -v", "up", "-v"}, + {"auto has no flag", "auto", ""}, + {"empty has no flag", "", ""}, + {"unknown has no flag", "sideways", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := buildTmuxSplitArgs(shell, resume, "/work/dir", tc.dir) + + if len(args) == 0 || args[0] != "split-window" { + t.Fatalf("args must start with split-window, got %v", args) + } + + switch tc.wantDir { + case "-h": + assertContains(t, args, "-h") + assertNotContains(t, args, "-v") + case "-v": + assertContains(t, args, "-v") + assertNotContains(t, args, "-h") + default: + assertNotContains(t, args, "-h", "-v") + } + + // Working directory and resume command must always be present. + assertContains(t, args, "-c", "/work/dir") + if !containsSeq(args, shell.Path, "-c", resume) { + t.Errorf("dir %q: missing shell resume command in %v", tc.dir, args) + } + }) + } +} + +func TestBuildTmuxSplitArgs_NoCwd(t *testing.T) { + shell := ShellInfo{Path: "/bin/zsh"} + args := buildTmuxSplitArgs(shell, "cmd", "", "right") + + // With no cwd there is exactly one -c (the shell flag), never a + // working-directory -c pair. + if !containsSeq(args, shell.Path, "-c", "cmd") { + t.Errorf("missing shell resume command in %v", args) + } + if containsSeq(args, "-c", "") { + t.Errorf("unexpected empty working directory flag in %v", args) + } +} + +func TestInsideTmux(t *testing.T) { + t.Run("set", func(t *testing.T) { + t.Setenv("TMUX", "/tmp/tmux-1000/default,1234,0") + if !insideTmux() { + t.Error("insideTmux() = false with TMUX set, want true") + } + }) + t.Run("empty", func(t *testing.T) { + t.Setenv("TMUX", "") + if insideTmux() { + t.Error("insideTmux() = true with empty TMUX, want false") + } + }) +} + +// containsSeq reports whether seq appears as a contiguous subsequence of s. +func containsSeq(s []string, seq ...string) bool { + if len(seq) == 0 { + return true + } + for i := 0; i+len(seq) <= len(s); i++ { + match := true + for j := range seq { + if s[i+j] != seq[j] { + match = false + break + } + } + if match { + return true + } + } + return false +}