Skip main navigation
/user/kayd @ devops :~$ cat mastering-tmux-go-session-manager.md

Master tmux: From Terminal Multiplexer to a Go Session Manager Master tmux: From Terminal Multiplexer to a Go Session Manager

QR Code linking to: Master tmux: From Terminal Multiplexer to a Go Session Manager
Karandeep Singh
Karandeep Singh
• 15 minutes

Summary

Learn tmux one concept at a time — sessions, windows, panes, configuration — then build a Go CLI tool that reads a YAML file and launches your entire dev environment automatically.

I’ve used tmux every day for the past seven years. Not because I have some elaborate 200-line config or a fancy status bar with weather and stock tickers. I started using it because I was tired of losing SSH sessions, opening twelve terminal tabs, and forgetting which tab had my logs.

tmux solved all of that. Sessions that survive disconnects, windows that organize your work, panes that let you see everything at once. Once you get comfortable with the Ctrl+B combos, the terminal just feels different.

This article walks through tmux one concept at a time. By the end, you’ll build a Go CLI tool that reads a YAML config and launches your entire dev environment — editor, server, logs, shell — in one command.

Terminal workstation set up for mastering tmux and building a Go session manager

Prerequisites

  • A Linux or macOS terminal (native, WSL, or SSH to a server)
  • tmux installed (sudo apt install tmux or brew install tmux)
  • Go 1.21+ installed (for Steps 6-7)

Step 1: Your First Session (Detach and Reattach)

What: Create a tmux session, run something, disconnect, and come back to it.

Why: Without tmux, closing your terminal kills everything running in it. SSH drops, accidental Ctrl+D, laptop lid close — all of it kills your processes. tmux sessions live on the server. You just reattach.

Create a named session:

tmux new -s deploy

You’re now inside a tmux session called deploy. Run something that takes a while:

ping google.com

Now detach — press Ctrl+B, then d. That’s the tmux prefix (Ctrl+B) followed by the command key (d for detach). You’re back in your original shell. The ping is still running.

Verify it:

tmux list-sessions

Expected output:

deploy: 1 windows (created Fri Feb 21 14:00:00 2026)

Reattach:

tmux attach -t deploy

The ping is still going. Every line that printed while you were away is there in the scrollback. That’s the core value of tmux: your work survives disconnects.

Kill the session when you’re done:

tmux kill-session -t deploy

One concept: sessions persist independently of your terminal.

Step 2: Windows (Multiple Tasks, One Session)

What: Create tabs inside a tmux session so you can run multiple things without opening new terminals.

Why: A typical dev workflow has at least four things running: an editor, a dev server, a log tail, and a shell for ad-hoc commands. Without tmux, that’s four terminal tabs. With tmux, it’s four windows in one session that you can switch between instantly.

Create a session and add windows:

tmux new -s project

You’re in window 0. Rename it:

  • Press Ctrl+B , — a prompt appears at the bottom
  • Type editor and press Enter

Create a new window:

  • Press Ctrl+B c — you’re in a new, empty window

Rename it to server:

  • Press Ctrl+B ,, type server, press Enter

Create two more:

  • Ctrl+B c, rename to logs
  • Ctrl+B c, rename to shell

Now switch between them:

ShortcutAction
Ctrl+B nNext window
Ctrl+B pPrevious window
Ctrl+B 0Jump to window 0
Ctrl+B 1Jump to window 1
Ctrl+B wList all windows (interactive picker)

The status bar at the bottom shows all your windows with an asterisk on the active one:

[project] 0:editor  1:server  2:logs* 3:shell

One concept: windows are named tabs inside a session.

Step 3: Panes (Side-by-Side)

What: Split a window into multiple panes so you can see more than one thing at once.

Why: Sometimes switching windows isn’t enough. You want your server output and your log tail visible at the same time. Panes let you split a single window vertically or horizontally.

From any window, split horizontally (top and bottom):

# Press Ctrl+B "

Split vertically (left and right):

# Press Ctrl+B %

Navigate between panes:

ShortcutAction
Ctrl+B ↑Move to pane above
Ctrl+B ↓Move to pane below
Ctrl+B ←Move to pane left
Ctrl+B →Move to pane right
Ctrl+B zZoom current pane (toggle fullscreen)
Ctrl+B xKill current pane

The zoom toggle (Ctrl+B z) is one of the most useful shortcuts. It makes the current pane fill the entire window, and pressing it again restores the layout. Perfect for when you need to read a full stack trace in a small pane.

A common layout for a dev session is a large editor pane on top with two smaller panes on the bottom — one for the server and one for logs:

┌──────────────────────────┐
│                          │
│        editor            │
│                          │
├─────────────┬────────────┤
│   server    │    logs    │
└─────────────┴────────────┘

One concept: panes split a window into visible sections.

Step 4: The .tmux.conf You Actually Need

What: Configure tmux with sensible defaults.

Why: The default tmux config is fine for getting started, but a few tweaks make a big difference. I’ve seen people share 200-line configs with custom status bars and plugin managers. You don’t need that. Ten lines covers it.

Create ~/.tmux.conf:

# Start window numbering at 1 (0 is far from the other keys)
set -g base-index 1
setw -g pane-base-index 1

# Mouse support — scroll, click panes, resize
set -g mouse on

# Increase scrollback buffer
set -g history-limit 10000

# Vi-style copy mode
setw -g mode-keys vi

# Reload config without restarting
bind r source-file ~/.tmux.conf \; display "Config reloaded"

# Better split shortcuts (optional — | and - are more intuitive)
bind | split-window -h
bind - split-window -v

# Don't rename windows automatically
set -g allow-rename off

Reload it without restarting tmux:

tmux source-file ~/.tmux.conf

Or if you added the bind above, just press Ctrl+B r.

# ==========================
# Sensible tmux defaults
# ==========================

# Start window and pane numbering at 1
set -g base-index 1
setw -g pane-base-index 1

# Mouse support (click panes, scroll, resize)
set -g mouse on

# Scrollback buffer (default is 2000)
set -g history-limit 10000

# Vi-style keys in copy mode
setw -g mode-keys vi

# Reload config with Ctrl+B r
bind r source-file ~/.tmux.conf \; display "Config reloaded"

# Intuitive split shortcuts
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"

# New windows open in current directory
bind c new-window -c "#{pane_current_path}"

# Don't rename windows automatically
set -g allow-rename off

# Reduce escape delay (helps with vim)
set -sg escape-time 10

# 256 color support
set -g default-terminal "screen-256color"

# Status bar — clean and minimal
set -g status-style 'bg=#333333 fg=#ffffff'
set -g status-left '[#S] '
set -g status-right '%H:%M'

One concept: a short config with mouse support and vi keys is all you need.

Step 5: Scripting tmux from Bash

What: Automate your dev environment setup with a bash script.

Why: Every morning you open tmux, create a session, make four windows, rename them, cd into your project, start the server, tail the logs. That’s five minutes of repetitive setup. A bash script does it in one command.

Create dev-env.sh:

#!/bin/bash
SESSION="myproject"

# Kill existing session if it exists
tmux kill-session -t $SESSION 2>/dev/null

# Create session with first window named "editor"
tmux new-session -d -s $SESSION -n editor

# Send a command to the editor window
tmux send-keys -t $SESSION:editor "cd ~/projects/myapp && vim ." Enter

# Create and set up the server window
tmux new-window -t $SESSION -n server
tmux send-keys -t $SESSION:server "cd ~/projects/myapp && go run main.go" Enter

# Create and set up the logs window
tmux new-window -t $SESSION -n logs
tmux send-keys -t $SESSION:logs "tail -f /var/log/syslog" Enter

# Create a shell window
tmux new-window -t $SESSION -n shell
tmux send-keys -t $SESSION:shell "cd ~/projects/myapp" Enter

# Select the first window
tmux select-window -t $SESSION:editor

# Attach to the session
tmux attach -t $SESSION

Run it:

chmod +x dev-env.sh
./dev-env.sh

You get a four-window dev environment in under a second. The editor opens in vim, the server starts, logs are tailing, and a shell is ready.

This works. But bash scripts get messy fast once you add conditionals (does the session already exist?), error handling (what if go run fails?), and multiple projects. You end up with a different script for each project, duplicating the same session creation logic everywhere.

That’s where Go comes in. We can build a single tool that reads project configs and creates the right environment every time.

One concept: tmux is fully scriptable from the command line.

Step 6: Go — Execute tmux Commands

What: Use Go’s exec.Command to drive tmux programmatically.

Why: Go gives us proper error handling, structured data, and a single compiled binary that works anywhere tmux is installed. We’re going to build a tool called tmux-dev that replaces all those bash scripts.

Set up the project:

mkdir tmux-dev && cd tmux-dev
go mod init tmux-dev

Create main.go with a helper function that runs any tmux command:

package main

import (
	"fmt"
	"os"
	"os/exec"
	"strings"
)

// tmuxCmd runs a tmux command and returns the output
func tmuxCmd(args ...string) (string, error) {
	cmd := exec.Command("tmux", args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("tmux %s: %s (%w)", strings.Join(args, " "), string(out), err)
	}
	return strings.TrimSpace(string(out)), nil
}

func main() {
	// Create a test session
	_, err := tmuxCmd("new-session", "-d", "-s", "test-from-go", "-n", "main")
	if err != nil {
		fmt.Println("Error creating session:", err)
		os.Exit(1)
	}
	fmt.Println("Created session: test-from-go")

	// List sessions
	out, err := tmuxCmd("list-sessions")
	if err != nil {
		fmt.Println("Error listing sessions:", err)
		os.Exit(1)
	}
	fmt.Println("Active sessions:")
	fmt.Println(out)

	// Clean up
	tmuxCmd("kill-session", "-t", "test-from-go")
	fmt.Println("Cleaned up test session")
}

Run it:

go run main.go

Expected output:

Created session: test-from-go
Active sessions:
test-from-go: 1 windows (created Fri Feb 21 14:00:00 2026)
Cleaned up test session

Go can create, list, and kill tmux sessions. The tmuxCmd helper wraps every call with error handling — if tmux isn’t installed or a command fails, you get a clear error message instead of a silent failure.

Let’s add a function to check if a session already exists:

// sessionExists checks if a tmux session with the given name is running
func sessionExists(name string) bool {
	_, err := tmuxCmd("has-session", "-t", name)
	return err == nil
}

This uses tmux has-session, which exits 0 if the session exists and 1 if it doesn’t. We turn that exit code into a boolean.

One concept: Go can drive tmux through exec.Command.

Step 7: Go — Config-Driven Project Launcher

What: Read a YAML config file that defines windows and commands for a project, then create the entire tmux environment automatically.

Why: Instead of writing a bash script per project, you write a YAML file. The Go tool reads it and does the rest. Add a new project? Add a YAML file. Change the layout? Edit the config. No code changes.

First, add the YAML dependency:

go get gopkg.in/yaml.v3

Create a config file projects/myapp.yaml:

session: myapp
dir: ~/projects/myapp
windows:
  - name: editor
    command: vim .
  - name: server
    command: go run main.go
  - name: logs
    command: tail -f /tmp/myapp.log
  - name: shell

Now update main.go to parse this config and build the session:

package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"gopkg.in/yaml.v3"
)

type Window struct {
	Name    string `yaml:"name"`
	Command string `yaml:"command"`
}

type Project struct {
	Session string   `yaml:"session"`
	Dir     string   `yaml:"dir"`
	Windows []Window `yaml:"windows"`
}

// tmuxCmd runs a tmux command and returns the output
func tmuxCmd(args ...string) (string, error) {
	cmd := exec.Command("tmux", args...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("tmux %s: %s (%w)", strings.Join(args, " "), string(out), err)
	}
	return strings.TrimSpace(string(out)), nil
}

// sessionExists checks if a tmux session is already running
func sessionExists(name string) bool {
	_, err := tmuxCmd("has-session", "-t", name)
	return err == nil
}

// expandHome replaces ~ with the actual home directory
func expandHome(path string) string {
	if strings.HasPrefix(path, "~/") {
		home, err := os.UserHomeDir()
		if err != nil {
			return path
		}
		return filepath.Join(home, path[2:])
	}
	return path
}

// startProject creates a tmux session from a Project config
func startProject(p Project) error {
	if sessionExists(p.Session) {
		fmt.Printf("Session '%s' already exists. Attaching...\n", p.Session)
		return attachSession(p.Session)
	}

	dir := expandHome(p.Dir)

	// Create the session with the first window
	if len(p.Windows) == 0 {
		return fmt.Errorf("no windows defined for session %s", p.Session)
	}

	first := p.Windows[0]
	_, err := tmuxCmd("new-session", "-d", "-s", p.Session, "-n", first.Name, "-c", dir)
	if err != nil {
		return fmt.Errorf("create session: %w", err)
	}

	// Send command to first window if specified
	if first.Command != "" {
		tmuxCmd("send-keys", "-t", p.Session+":"+first.Name, first.Command, "Enter")
	}

	// Create remaining windows
	for _, w := range p.Windows[1:] {
		_, err := tmuxCmd("new-window", "-t", p.Session, "-n", w.Name, "-c", dir)
		if err != nil {
			return fmt.Errorf("create window %s: %w", w.Name, err)
		}
		if w.Command != "" {
			tmuxCmd("send-keys", "-t", p.Session+":"+w.Name, w.Command, "Enter")
		}
	}

	// Select the first window
	tmuxCmd("select-window", "-t", p.Session+":"+p.Windows[0].Name)

	fmt.Printf("Session '%s' created with %d windows\n", p.Session, len(p.Windows))
	return attachSession(p.Session)
}

// attachSession attaches to a tmux session
func attachSession(name string) error {
	cmd := exec.Command("tmux", "attach", "-t", name)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

func main() {
	if len(os.Args) < 2 {
		fmt.Println("usage: tmux-dev <config.yaml>")
		fmt.Println("       tmux-dev list")
		os.Exit(1)
	}

	if os.Args[1] == "list" {
		out, err := tmuxCmd("list-sessions")
		if err != nil {
			fmt.Println("No active sessions")
			return
		}
		fmt.Println(out)
		return
	}

	configFile := os.Args[1]
	data, err := os.ReadFile(configFile)
	if err != nil {
		fmt.Printf("Error reading config: %v\n", err)
		os.Exit(1)
	}

	var project Project
	if err := yaml.Unmarshal(data, &project); err != nil {
		fmt.Printf("Error parsing config: %v\n", err)
		os.Exit(1)
	}

	if err := startProject(project); err != nil {
		fmt.Printf("Error: %v\n", err)
		os.Exit(1)
	}
}

Build and run:

go build -o tmux-dev .
./tmux-dev projects/myapp.yaml

Expected output:

Session 'myapp' created with 4 windows

You’re now inside a tmux session with four named windows. The editor has vim open, the server is running, logs are tailing, and you have a clean shell. All from a 10-line YAML file.

Add more projects by creating more config files:

# projects/blog.yaml
session: blog
dir: ~/projects/blog
windows:
  - name: editor
    command: vim content/
  - name: hugo
    command: hugo server -D
  - name: shell
# projects/api.yaml
session: api
dir: ~/projects/api
windows:
  - name: code
    command: vim .
  - name: server
    command: go run cmd/server/main.go
  - name: tests
    command: go test ./... -v
  - name: db
    command: psql -U postgres mydb
  - name: shell

List active sessions:

./tmux-dev list

Expected output:

myapp: 4 windows (created Fri Feb 21 14:00:00 2026)
blog: 3 windows (created Fri Feb 21 14:05:00 2026)
api: 5 windows (created Fri Feb 21 14:10:00 2026)

The entire tool is about 110 lines of Go. No plugins, no framework, just exec.Command and YAML parsing. It does exactly one thing: turn a config file into a tmux session.

One concept: declarative config replaces repetitive scripting.

What We Built

Starting from zero tmux knowledge, we worked through each concept:

  1. Sessions — persistent workspaces that survive disconnects
  2. Windows — named tabs inside a session for organizing tasks
  3. Panes — side-by-side views within a single window
  4. Configuration — a minimal .tmux.conf with mouse support and vi keys
  5. Bash scripting — automating environment setup with send-keys and new-window
  6. Go + tmux — driving tmux from Go with exec.Command
  7. Config-driven launcher — a YAML file per project, one command to launch everything

The Go tool replaces a pile of bash scripts with a single binary and a config directory.

Cheat Sheet

Session management:

tmux new -s name          # Create named session
tmux attach -t name       # Attach to session
tmux kill-session -t name # Kill session
tmux list-sessions        # List all sessions
tmux switch -t name       # Switch between sessions

Window management:

Ctrl+B c                  # New window
Ctrl+B ,                  # Rename window
Ctrl+B n                  # Next window
Ctrl+B p                  # Previous window
Ctrl+B 0-9                # Jump to window by number
Ctrl+B w                  # Interactive window list
Ctrl+B &                  # Kill window

Pane management:

Ctrl+B "                  # Split horizontal
Ctrl+B %                  # Split vertical
Ctrl+B arrow              # Navigate panes
Ctrl+B z                  # Toggle zoom
Ctrl+B x                  # Kill pane
Ctrl+B space              # Cycle pane layouts

Scripting patterns:

tmux new-session -d -s name -n window     # Create detached
tmux new-window -t session -n name        # Add window
tmux send-keys -t session:window "cmd" Enter  # Run command
tmux select-window -t session:window      # Focus window

Go patterns:

// Run any tmux command
cmd := exec.Command("tmux", "new-session", "-d", "-s", "myapp")
out, err := cmd.CombinedOutput()

// Check if session exists
cmd := exec.Command("tmux", "has-session", "-t", "myapp")
exists := cmd.Run() == nil

// Attach (needs stdin/stdout wired up)
cmd := exec.Command("tmux", "attach", "-t", "myapp")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()

Key rules to remember:

  • Ctrl+B is the prefix — every tmux shortcut starts with it
  • Ctrl+B d detaches. The session keeps running
  • Sessions contain windows. Windows contain panes
  • tmux send-keys lets you script any sequence of keystrokes
  • Mouse mode (:set mouse on) enables scroll, click, and resize
  • In Go, use CombinedOutput() to capture both stdout and stderr from tmux
  • Wire up Stdin/Stdout/Stderr when attaching to sessions from Go

Keep Reading

References and Further Reading

Question

What's your must-have tmux keybinding or config line? The one setting that changed how you work in the terminal?

Similar Articles

More from devops