Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions internal/platform/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
100 changes: 100 additions & 0 deletions internal/platform/tmux_test.go
Original file line number Diff line number Diff line change
@@ -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")

Check failure on line 35 in internal/platform/tmux_test.go

View workflow job for this annotation

GitHub Actions / test

undefined: assertContains
assertNotContains(t, args, "-v")

Check failure on line 36 in internal/platform/tmux_test.go

View workflow job for this annotation

GitHub Actions / test

undefined: assertNotContains
case "-v":
assertContains(t, args, "-v")

Check failure on line 38 in internal/platform/tmux_test.go

View workflow job for this annotation

GitHub Actions / test

undefined: assertContains
assertNotContains(t, args, "-h")

Check failure on line 39 in internal/platform/tmux_test.go

View workflow job for this annotation

GitHub Actions / test

undefined: assertNotContains
default:
assertNotContains(t, args, "-h", "-v")

Check failure on line 41 in internal/platform/tmux_test.go

View workflow job for this annotation

GitHub Actions / test

undefined: assertNotContains
}

// Working directory and resume command must always be present.
assertContains(t, args, "-c", "/work/dir")

Check failure on line 45 in internal/platform/tmux_test.go

View workflow job for this annotation

GitHub Actions / test

undefined: assertContains (typecheck)
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
}
Loading