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
77 changes: 66 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,15 @@ This will download and install the latest version of Sesh. Make sure that your G

To install sesh, run **one** of the following commands, depending on your setup:

* Conda/(micro)mamba users
- Conda/(micro)mamba users

```sh
# Replace with mamba/micromamba if required
conda -c conda-forge install sesh
```

* Pixi users
- Pixi users

```sh
pixi global install sesh
```
Expand Down Expand Up @@ -193,19 +195,18 @@ Here are limitations to keep in mind:
## Ulauncher Extension

For Linux users using [Ulauncher](https://ulauncher.io/) there are two extensions to use sesh outside the terminal:

- [Sesh Session Manager](https://ext.ulauncher.io/-/github-jacostag-sesh-ulauncher)
- [SESHion Manager](https://ext.ulauncher.io/-/github-mrinfinidy-seshion-manager)

Here are limitations to keep in mind for Sesh Session Manager:

- tmux has to be running before you can use the extension


## Walker launcher usage (Linux)

Create an action directly on $XDG_CONFIG_HOME/config.toml


```
[[plugins]]
name = "sesh"
Expand All @@ -221,9 +222,11 @@ switcher_only = true
### For the dmenu mode you can use:

#### Fish shell:

set ssession $(sesh l -t -T -d -H | walker -d -f -k -p "Sesh sessions"); sesh cn --switch $ssession

#### Bash/Zsh:

ssession=$(sesh l -t -T -d -H | walker -d -f -k -p "Sesh sessions"); sesh cn --switch $ssession

##### For dmenu launchers replace walker -dfk with dmenu or rofi)
Expand Down Expand Up @@ -283,6 +286,55 @@ bind-key "T" display-popup -E -w 80% -h 70% -d '#{pane_current_path}' -T 'Sesh'

Use `Ctrl-s` to cycle through the sources, and `Ctrl-d` to kill the highlighted session.

### Window management

`sesh window` (alias `w`) lets you list, switch to, and create tmux windows within a session — similar to how `sesh list` and `sesh connect` work for sessions.

#### List windows in the current session

```sh
sesh window
```

#### Switch to an existing window by name

```sh
sesh window editor
```

If a window named `editor` exists in the current session, sesh will switch to it.

#### Create a new window at a directory

```sh
sesh window ~/projects/my-app
```

If no window with that name exists, sesh will create a new window named after the directory (`my-app`) with its working directory set to the given path.

#### Target a specific session

Use `--session` / `-s` to manage windows in a session other than the one you're currently attached to:

```sh
sesh window --session work
sesh window ~/projects/my-app --session work
```

#### fzf integration

You can combine `sesh window` with fzf to interactively switch windows:

```sh
sesh window $(sesh window | fzf)
```

Or as a tmux keybind:

```sh
bind-key "W" run-shell "sesh window \"$(sesh window | fzf-tmux -p 60%,50% --prompt '🪟 ')\""
```

## gum + tmux

If you prefer to use [charmbracelet's gum](https://github.com/charmbracelet/gum) then you can use the following command to connect to a session:
Expand Down Expand Up @@ -451,7 +503,7 @@ Control how many directory components are used for session names. Default is 1 (
dir_length = 2 # Uses last 2 directories: "projects/sesh" instead of just "sesh"
```

> [!NOTE]
> [!NOTE]
> Works great with [tmux-floax](https://github.com/omerxx/tmux-floax)

### Sorting
Expand All @@ -476,6 +528,7 @@ sort_order = [
"config", # resulting order: config, tmux, tmuxinator, zoxide
]
```

### Cache

Sesh can cache session lists to speed up repeated calls. Caching is opt-in and disabled by default. When enabled, sesh stores results at `$XDG_CACHE_HOME/sesh/sessions.gob` (default `~/.cache/sesh/sessions.gob`) and uses a stale-while-revalidate strategy with a 5-second TTL:
Expand Down Expand Up @@ -530,10 +583,12 @@ preview_command = "bat --color=always ~/c/dotfiles/.config/tmux/tmux.conf"
```

### Path substitution

If you want to use the path of the selected session in your startup or preview command, you can use the `{}` placeholder.
This will be replaced with the session's path when the command is run.

An example of this in use is the following, where the `tmuxinator` default_project uses the path as key/value pair using [ERB syntax](https://github.com/tmuxinator/tmuxinator?tab=readme-ov-file#erb):

```toml
[default_session]
startup_command = "tmuxinator start default_project path={}"
Expand Down Expand Up @@ -583,12 +638,12 @@ When you run `sesh connect ~/projects/myapp`, sesh matches the path against your

Available fields:

| Field | Description |
|-------|-------------|
| `pattern` | Glob pattern to match directories (e.g. `~/projects/*`) |
| `startup_command` | Command to run on session creation (supports `{}` for path) |
| `preview_command` | Command to run when previewing the session |
| `disable_startup_command` | Set to `true` to suppress the startup command |
| Field | Description |
| ------------------------- | ----------------------------------------------------------- |
| `pattern` | Glob pattern to match directories (e.g. `~/projects/*`) |
| `startup_command` | Command to run on session creation (supports `{}` for path) |
| `preview_command` | Command to run when previewing the session |
| `disable_startup_command` | Set to `true` to suppress the startup command |

**Note:** Patterns use Go's `filepath.Match` syntax which supports `*` (any sequence), `?` (single character), and `[...]` (character classes). Recursive matching with `**` is not supported -- `~/projects/*` matches `~/projects/foo` but not `~/projects/foo/bar`. Explicit `[[session]]` configs always take priority over wildcard matches. If multiple wildcards match, the first one in config order wins.

Expand Down
8 changes: 8 additions & 0 deletions model/tmux_window.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package model

type TmuxWindow struct {
Name string
Path string
Index int
Active bool
}
1 change: 1 addition & 0 deletions seshcli/root_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func NewRootCommand(version string) *cobra.Command {
NewRootSessionCommand(base),
NewPreviewCommand(base),
NewPickerCommand(base),
NewWindowCommand(base),
)

return rootCmd
Expand Down
101 changes: 101 additions & 0 deletions seshcli/window.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package seshcli

import (
"encoding/json"
"fmt"
"path/filepath"
"strings"

"github.com/spf13/cobra"
)

func NewWindowCommand(base *BaseDeps) *cobra.Command {
cmd := &cobra.Command{
Use: "window",
Aliases: []string{"w"},
Short: "List or switch/create windows in a tmux session",
RunE: func(cmd *cobra.Command, args []string) error {
targetSession, _ := cmd.Flags().GetString("session")
jsonOutput, _ := cmd.Flags().GetBool("json")

Comment on lines +18 to +20
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

The --json/-j flag is parsed but intentionally ignored (_ = jsonOutput), so users can enable it with no observable effect. Either implement JSON output for list mode or return a clear error when --json is provided to avoid a silent no-op.

Copilot uses AI. Check for mistakes.
if targetSession == "" {
if !base.Tmux.IsAttached() {
return fmt.Errorf("not inside a tmux session, use --session to specify one")
}
} else {
sessions, err := base.Tmux.ListSessions()
if err != nil {
return err
}
found := false
for _, s := range sessions {
if s.Name == targetSession {
found = true
break
}
}
if !found {
return fmt.Errorf("session '%s' not found", targetSession)
}
}

if len(args) == 0 {
windows, err := base.Tmux.ListWindows(targetSession)
if err != nil {
return err
}
if jsonOutput {
out, err := json.Marshal(windows)
if err != nil {
return err
}
fmt.Println(string(out))
return nil
}
for _, w := range windows {
fmt.Println(w.Name)
}
return nil
}

name := strings.Join(args, " ")

windows, err := base.Tmux.ListWindows(targetSession)
if err != nil {
return err
}
for _, w := range windows {
if w.Name == name {
target := name
if targetSession != "" {
target = fmt.Sprintf("%s:%s", targetSession, name)
}
if _, err := base.Tmux.SelectWindow(target); err != nil {
return fmt.Errorf("failed to select window '%s': %w", name, err)
}
return nil
}
}

expanded, err := base.Home.ExpandHome(name)
if err != nil {
return err
}
isDir, absPath := base.Dir.Dir(expanded)
if !isDir {
return fmt.Errorf("'%s' is not an existing window or valid directory", name)
}

windowName := filepath.Base(absPath)
if _, err := base.Tmux.NewWindowInSession(windowName, absPath, targetSession); err != nil {
return fmt.Errorf("failed to create window: %w", err)
}
return nil
},
}

cmd.Flags().StringP("session", "s", "", "target session (default: current attached session)")
cmd.Flags().BoolP("json", "j", false, "output as json (list mode only)")

return cmd
}
50 changes: 50 additions & 0 deletions tmux/list_tmux_win.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package tmux

import (
"strings"

"github.com/joshmedeski/sesh/v2/convert"
"github.com/joshmedeski/sesh/v2/model"
)

func listWindowsFormat() string {
variables := []string{
"#{window_index}",
"#{window_name}",
"#{pane_current_path}",
"#{window_active}",
}
return strings.Join(variables, separator)
}

func (t *RealTmux) ListWindows(targetSession string) ([]*model.TmuxWindow, error) {
var args []string
args = append(args, "list-windows")
if targetSession != "" {
args = append(args, "-t", targetSession)
}
args = append(args, "-F", listWindowsFormat())

output, err := t.shell.ListCmd("tmux", args...)
if err != nil {
return nil, err
}
return parseTmuxWindowsOutput(output)
}

func parseTmuxWindowsOutput(rawList []string) ([]*model.TmuxWindow, error) {
windows := make([]*model.TmuxWindow, 0, len(rawList))
for _, line := range rawList {
fields := strings.Split(line, separator)
if len(fields) != 4 {
continue
}
windows = append(windows, &model.TmuxWindow{
Index: convert.StringToInt(fields[0]),
Name: fields[1],
Path: fields[2],
Active: convert.StringToBool(fields[3]),
})
}
return windows, nil
}
54 changes: 54 additions & 0 deletions tmux/list_tmux_win_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package tmux

import (
"testing"

"github.com/joshmedeski/sesh/v2/shell"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

func TestListWindows(t *testing.T) {
t.Run("returns parsed windows", func(t *testing.T) {
mockShell := &shell.MockShell{}
tmux := &RealTmux{shell: mockShell}
mockShell.EXPECT().ListCmd("tmux", "list-windows", "-F", mock.Anything).Return(
[]string{"0::editor::/Users/josh/c/sesh::0", "1::server::/Users/josh/c/sesh::1"},
nil,
)
windows, err := tmux.ListWindows("")
assert.Nil(t, err)
assert.Len(t, windows, 2)
assert.Equal(t, "editor", windows[0].Name)
assert.Equal(t, "/Users/josh/c/sesh", windows[0].Path)
assert.Equal(t, 0, windows[0].Index)
assert.False(t, windows[0].Active)
assert.Equal(t, "server", windows[1].Name)
assert.True(t, windows[1].Active)
})

t.Run("target session flag is passed when non-empty", func(t *testing.T) {
mockShell := &shell.MockShell{}
tmux := &RealTmux{shell: mockShell}
mockShell.EXPECT().ListCmd("tmux", "list-windows", "-t", "work", "-F", mock.Anything).Return(
[]string{"0::main::/home/user::0"},
nil,
)
windows, err := tmux.ListWindows("work")
assert.Nil(t, err)
assert.Len(t, windows, 1)
})

t.Run("parseTmuxWindowsOutput", func(t *testing.T) {
raw := []string{"0::editor::/Users/josh/c/sesh::0", "1::server::/Users/josh/c/sesh::1"}
windows, err := parseTmuxWindowsOutput(raw)
assert.Nil(t, err)
assert.Len(t, windows, 2)
assert.Equal(t, "editor", windows[0].Name)
assert.Equal(t, 0, windows[0].Index)
assert.False(t, windows[0].Active)
assert.Equal(t, "server", windows[1].Name)
assert.Equal(t, 1, windows[1].Index)
assert.True(t, windows[1].Active)
})
}
9 changes: 9 additions & 0 deletions tmux/new_win.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package tmux

func (t *RealTmux) NewWindowInSession(name string, startDir string, targetSession string) (string, error) {
args := []string{"new-window", "-n", name, "-c", startDir}
if targetSession != "" {
args = append(args, "-t", targetSession)
}
return t.shell.Cmd("tmux", args...)
}
5 changes: 5 additions & 0 deletions tmux/select_win.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package tmux

func (t *RealTmux) SelectWindow(targetWindow string) (string, error) {
return t.shell.Cmd("tmux", "select-window", "-t", targetWindow)
}
Loading
Loading