From ba7d6ef543583ebfefe74d298d46ea34e51cf113 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 23:53:41 +0000 Subject: [PATCH] refactor: remove Zellij multiplexer support Remove Zellij terminal multiplexer support and standardize on tmux as the only terminal multiplexer backend. Changes: - Remove internal/multiplexer/zellij.go and zellij_test.go - Remove Config.Default.Multiplexer configuration option - Remove --multiplexer CLI flag from root command - Hardcode tmux as the terminal multiplexer in initManager() - Remove validMultiplexers map and related validation functions - Remove HEADJACK_MULTIPLEXER environment variable binding - Remove Zellij installation from base container image - Update all CLI documentation to remove --multiplexer flag references - Update attach command help to reference tmux keybindings (Ctrl+B, d) - Update REFACTOR.md to reference tmux instead of Zellij --- REFACTOR.md | 16 +- docs/docs/reference/cli/attach.md | 10 +- docs/docs/reference/cli/auth.md | 8 - docs/docs/reference/cli/config.md | 7 - docs/docs/reference/cli/kill.md | 8 - docs/docs/reference/cli/logs.md | 6 - docs/docs/reference/cli/ps.md | 6 - docs/docs/reference/cli/recreate.md | 6 - docs/docs/reference/cli/rm.md | 6 - docs/docs/reference/cli/run.md | 6 - docs/docs/reference/cli/stop.md | 8 - docs/docs/reference/cli/version.md | 8 - docs/docs/reference/configuration.md | 5 - docs/docs/reference/images/overview.md | 2 +- images/base/Dockerfile | 22 -- internal/cmd/attach.go | 4 +- internal/cmd/root.go | 32 +-- internal/config/config.go | 40 +--- internal/multiplexer/multiplexer.go | 2 +- internal/multiplexer/zellij.go | 161 -------------- internal/multiplexer/zellij_test.go | 286 ------------------------- 21 files changed, 23 insertions(+), 626 deletions(-) delete mode 100644 internal/multiplexer/zellij.go delete mode 100644 internal/multiplexer/zellij_test.go diff --git a/REFACTOR.md b/REFACTOR.md index 5ea04ed..81d3103 100644 --- a/REFACTOR.md +++ b/REFACTOR.md @@ -4,7 +4,7 @@ This document provides context for implementing the session-based refactor. Dele ## Summary -We are introducing **sessions** as a new abstraction layer between users and instances. A session is a persistent, attachable/detachable process (shell, agent CLI, etc.) running within an instance, managed by Zellij. +We are introducing **sessions** as a new abstraction layer between users and instances. A session is a persistent, attachable/detachable process (shell, agent CLI, etc.) running within an instance, managed by tmux. ## Why @@ -21,7 +21,7 @@ The original design allowed multiple `resume` calls to the same instance but pro | Concept | Definition | |---------|------------| | Instance | Git worktree + container (unchanged) | -| Session | A Zellij-managed process within an instance (new) | +| Session | A tmux-managed process within an instance (new) | An instance can have zero or more sessions. Sessions persist across detach/attach cycles. @@ -82,7 +82,7 @@ This is implemented by teeing stdout/stderr when spawning the session process. ### Technology -Sessions are implemented using [Zellij](https://zellij.dev/). Zellij handles: +Sessions are implemented using [tmux](https://github.com/tmux/tmux). tmux handles: - Terminal multiplexing - Attach/detach mechanics - Process lifecycle within sessions @@ -119,7 +119,7 @@ After: "id": "sess-abc", "name": "happy-panda", "type": "claude", - "zellij_session": "hjk--sess-abc", + "tmux_session": "hjk--sess-abc", "created_at": "2025-12-30T10:00:00Z", "last_accessed": "2025-12-30T14:30:00Z" } @@ -135,16 +135,16 @@ After: - `internal/cmd/` — Replace `new.go`, `resume.go`, `list.go` with `run.go`, `attach.go`, `ps.go`, `logs.go`, `kill.go` - `internal/catalog/` — Add session tracking, `last_accessed` updates -- `internal/instance/` — Session CRUD operations, Zellij integration, log file management +- `internal/instance/` — Session CRUD operations, tmux integration, log file management ### May Need Changes -- `internal/container/` — Ensure Zellij is available in containers -- `docs/designs/base-image.md` — Add Zellij to base image +- `internal/container/` — Ensure tmux is available in containers +- `docs/designs/base-image.md` — Add tmux to base image ### New Code Needed -- Zellij interaction layer (create session, attach, list, kill) +- tmux interaction layer (create session, attach, list, kill) - Session name generator (word-based, Docker-style) - Session logging layer (tee output to log files, read logs for `hjk logs`) diff --git a/docs/docs/reference/cli/attach.md b/docs/docs/reference/cli/attach.md index 3af4749..9da7083 100644 --- a/docs/docs/reference/cli/attach.md +++ b/docs/docs/reference/cli/attach.md @@ -24,7 +24,7 @@ Attaches to an existing session using a most-recently-used (MRU) strategy: If no sessions exist for the resolved scope, the command displays an error suggesting `hjk run` to create one. -To detach from a session without terminating it, use the terminal multiplexer detach keybinding (for Zellij: `Ctrl+O, d`). This returns you to your host terminal while the session continues running. +To detach from a session without terminating it, use the tmux detach keybinding (`Ctrl+B, d`). This returns you to your host terminal while the session continues running. ## Arguments @@ -33,14 +33,6 @@ To detach from a session without terminating it, use the terminal multiplexer de | `branch` | Git branch name to filter by (optional) | | `session` | Session name within the instance (optional, requires branch) | -## Flags - -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/cli/auth.md b/docs/docs/reference/cli/auth.md index 4b0c761..a76e4b2 100644 --- a/docs/docs/reference/cli/auth.md +++ b/docs/docs/reference/cli/auth.md @@ -68,14 +68,6 @@ This command runs the Codex login flow which: The stored credentials use your ChatGPT Plus/Pro/Team/Enterprise subscription rather than API billing. -## Flags - -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/cli/config.md b/docs/docs/reference/cli/config.md index 34964c5..623b196 100644 --- a/docs/docs/reference/cli/config.md +++ b/docs/docs/reference/cli/config.md @@ -35,12 +35,6 @@ View and modify Headjack configuration: |------|------|---------|-------------| | `--edit` | bool | `false` | Open config file in `$EDITOR` | -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash @@ -65,7 +59,6 @@ Common configuration keys: |-----|------|-------------| | `default.agent` | string | Default agent when `--agent` is used without a value | | `default.base_image` | string | Default container base image | -| `default.multiplexer` | string | Default terminal multiplexer (`tmux`, `zellij`) | | `storage.worktrees` | string | Directory for git worktrees | | `storage.catalog` | string | Path to the instance catalog file | | `storage.logs` | string | Directory for session logs | diff --git a/docs/docs/reference/cli/kill.md b/docs/docs/reference/cli/kill.md index 6f6b322..191c05e 100644 --- a/docs/docs/reference/cli/kill.md +++ b/docs/docs/reference/cli/kill.md @@ -26,14 +26,6 @@ The argument must be in the format `/`, where branch is the ins |----------|-------------| | `branch/session` | Combined branch and session name separated by `/` (required) | -## Flags - -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/cli/logs.md b/docs/docs/reference/cli/logs.md index d4e0ae5..a7e8ffb 100644 --- a/docs/docs/reference/cli/logs.md +++ b/docs/docs/reference/cli/logs.md @@ -33,12 +33,6 @@ Reads from the session's log file, useful for checking on detached agents withou | `--lines` | `-n` | int | `100` | Number of lines to show | | `--full` | | bool | `false` | Show entire log from session start | -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/cli/ps.md b/docs/docs/reference/cli/ps.md index 101b166..557e905 100644 --- a/docs/docs/reference/cli/ps.md +++ b/docs/docs/reference/cli/ps.md @@ -34,12 +34,6 @@ Use `--all` to list instances across all repositories (only applies when listing |------|-------|------|---------|-------------| | `--all` | `-a` | bool | `false` | List instances across all repositories | -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Output ### Instance Listing diff --git a/docs/docs/reference/cli/recreate.md b/docs/docs/reference/cli/recreate.md index 5832e45..cace46a 100644 --- a/docs/docs/reference/cli/recreate.md +++ b/docs/docs/reference/cli/recreate.md @@ -35,12 +35,6 @@ Useful when the container environment is corrupted or needs a fresh state. The w |------|------|---------|-------------| | `--base` | string | | Use a different base image for the new container | -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/cli/rm.md b/docs/docs/reference/cli/rm.md index 04727ba..afeec80 100644 --- a/docs/docs/reference/cli/rm.md +++ b/docs/docs/reference/cli/rm.md @@ -37,12 +37,6 @@ Removes an instance entirely. This command: |------|-------|------|---------|-------------| | `--force` | `-f` | bool | `false` | Skip confirmation prompt | -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/cli/run.md b/docs/docs/reference/cli/run.md index ab7874f..06302bc 100644 --- a/docs/docs/reference/cli/run.md +++ b/docs/docs/reference/cli/run.md @@ -43,12 +43,6 @@ If an instance exists but is stopped, it is automatically restarted before creat | `--base` | | string | | Override the default base image | | `--detached` | `-d` | bool | `false` | Create session but do not attach (run in background) | -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/cli/stop.md b/docs/docs/reference/cli/stop.md index 8fce224..f5b8f92 100644 --- a/docs/docs/reference/cli/stop.md +++ b/docs/docs/reference/cli/stop.md @@ -26,14 +26,6 @@ This command is useful for freeing up system resources when an instance is not a |----------|-------------| | `branch` | Git branch name of the instance to stop (required) | -## Flags - -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/cli/version.md b/docs/docs/reference/cli/version.md index 94ee7ac..0ea2053 100644 --- a/docs/docs/reference/cli/version.md +++ b/docs/docs/reference/cli/version.md @@ -22,14 +22,6 @@ Displays the version, commit hash, and build date of the Headjack installation. This command takes no arguments. -## Flags - -### Inherited Flags - -| Flag | Type | Description | -|------|------|-------------| -| `--multiplexer` | string | Terminal multiplexer to use (`tmux`, `zellij`) | - ## Examples ```bash diff --git a/docs/docs/reference/configuration.md b/docs/docs/reference/configuration.md index ed787e3..8edc8c3 100644 --- a/docs/docs/reference/configuration.md +++ b/docs/docs/reference/configuration.md @@ -37,7 +37,6 @@ Default values applied when creating new instances. |-----|------|---------|-------------| | `default.agent` | string | `""` (empty) | Default agent to use. Valid values: `claude`, `gemini`, `codex`. Empty means no default. | | `default.base_image` | string | `ghcr.io/gilmanlab/headjack:base` | Container image to use for instances. Available variants: `:base` (minimal), `:systemd` (with init), `:dind` (with Docker). | -| `default.multiplexer` | string | `tmux` | Terminal multiplexer for session management. Valid values: `tmux`, `zellij`. | ### agents @@ -76,7 +75,6 @@ A complete configuration file with all options: default: agent: claude base_image: ghcr.io/gilmanlab/headjack:base - multiplexer: tmux agents: claude: @@ -118,7 +116,6 @@ hjk config storage.worktrees ```bash hjk config default.agent claude -hjk config default.multiplexer zellij hjk config runtime.name apple ``` @@ -140,7 +137,6 @@ The following environment variables override their corresponding configuration k |---------------------|-------------------| | `HEADJACK_DEFAULT_AGENT` | `default.agent` | | `HEADJACK_BASE_IMAGE` | `default.base_image` | -| `HEADJACK_MULTIPLEXER` | `default.multiplexer` | | `HEADJACK_WORKTREE_DIR` | `storage.worktrees` | ## Validation @@ -149,7 +145,6 @@ Headjack validates configuration values when loading and setting them: - `default.agent` must be one of: `claude`, `gemini`, `codex` (or empty) - `default.base_image` is required and cannot be empty -- `default.multiplexer` must be one of: `tmux`, `zellij` - `runtime.name` must be one of: `podman`, `apple` - All storage paths are required diff --git a/docs/docs/reference/images/overview.md b/docs/docs/reference/images/overview.md index ebf5176..6ec0ad4 100644 --- a/docs/docs/reference/images/overview.md +++ b/docs/docs/reference/images/overview.md @@ -46,7 +46,7 @@ base --> systemd --> dind | Agent CLIs (Claude, Gemini, Codex) | Yes | Yes | Yes | | Version managers (pyenv, nodenv, goenv, rustup) | Yes | Yes | Yes | | Development tools (git, gh, vim, ripgrep, etc.) | Yes | Yes | Yes | -| Terminal multiplexer (Zellij) | Yes | Yes | Yes | +| Terminal multiplexer (tmux) | Yes | Yes | Yes | | systemd init system | No | Yes | Yes | | Docker CE | No | No | Yes | | Docker Compose plugin | No | No | Yes | diff --git a/images/base/Dockerfile b/images/base/Dockerfile index db5ad70..58aa06a 100644 --- a/images/base/Dockerfile +++ b/images/base/Dockerfile @@ -21,7 +21,6 @@ ARG NODENV_VERSION=v1.6.2 ARG NODE_BUILD_VERSION=v5.4.22 ARG GOENV_VERSION=2.2.34 ARG RUSTUP_VERSION=1.28.2 -ARG ZELLIJ_VERSION=0.43.1 ARG CLAUDE_CODE_VERSION=2.0.76 ARG GEMINI_CLI_VERSION=0.22.5 ARG CODEX_CLI_VERSION=0.77.0 @@ -130,27 +129,6 @@ RUN : "${TARGETARCH:?TARGETARCH is required for yq install}" && \ install -m 0755 "/tmp/${yq_binary}" /usr/local/bin/yq && \ rm -f "/tmp/${yq_binary}" /tmp/yq_checksums -# ============================================================================= -# Zellij (terminal multiplexer) -# ============================================================================= - -RUN : "${TARGETARCH:?TARGETARCH is required for Zellij install}" && \ - case "${TARGETARCH}" in \ - amd64) zellij_arch="x86_64" ;; \ - arm64) zellij_arch="aarch64" ;; \ - *) echo "Unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ - esac && \ - zellij_archive="zellij-${zellij_arch}-unknown-linux-musl.tar.gz" && \ - zellij_checksums="zellij-${zellij_arch}-unknown-linux-musl.sha256sum" && \ - curl -fsSL "https://github.com/zellij-org/zellij/releases/download/v${ZELLIJ_VERSION}/${zellij_archive}" -o "/tmp/${zellij_archive}" && \ - curl -fsSL "https://github.com/zellij-org/zellij/releases/download/v${ZELLIJ_VERSION}/${zellij_checksums}" -o "/tmp/${zellij_checksums}" && \ - tar -xzf "/tmp/${zellij_archive}" -C /tmp && \ - expected_sha256="$(awk '{print $1}' "/tmp/${zellij_checksums}")" && \ - actual_sha256="$(sha256sum /tmp/zellij | awk '{print $1}')" && \ - [ "${expected_sha256}" = "${actual_sha256}" ] || { echo "Checksum mismatch"; exit 1; } && \ - install -m 0755 /tmp/zellij /usr/local/bin/zellij && \ - rm -f "/tmp/${zellij_archive}" "/tmp/${zellij_checksums}" /tmp/zellij - # ============================================================================= # Node.js (required for Agent CLIs) # ============================================================================= diff --git a/internal/cmd/attach.go b/internal/cmd/attach.go index f9746c3..e1b73ba 100644 --- a/internal/cmd/attach.go +++ b/internal/cmd/attach.go @@ -22,8 +22,8 @@ The command uses an MRU strategy: If no sessions exist for the resolved scope, the command errors with a message suggesting 'hjk run' to create one. -To detach from a session without terminating it, use the Zellij keybinding -(default: Ctrl+O, d). This returns you to your host terminal while the +To detach from a session without terminating it, use the tmux detach keybinding +(default: Ctrl+B, d). This returns you to your host terminal while the session continues running.`, Example: ` # Attach to whatever you were last working on hjk attach diff --git a/internal/cmd/root.go b/internal/cmd/root.go index dc85c47..a3eae8b 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -52,16 +52,7 @@ enabling safe parallel development across multiple branches.`, return err } - // Get multiplexer override from flag - muxOverride, err := cmd.Flags().GetString("multiplexer") - if err != nil { - return fmt.Errorf("get multiplexer flag: %w", err) - } - if muxOverride != "" && !config.IsValidMultiplexer(muxOverride) { - return fmt.Errorf("invalid multiplexer %q (valid: tmux, zellij)", muxOverride) - } - - if err := initManager(muxOverride); err != nil { + if err := initManager(); err != nil { return err } @@ -83,7 +74,6 @@ func Execute() error { func init() { cobra.OnInitialize(initConfig) - rootCmd.PersistentFlags().String("multiplexer", "", "terminal multiplexer to use (tmux, zellij)") } func initConfig() { @@ -140,8 +130,7 @@ func getRuntimeBinary() string { } // initManager initializes the instance manager with all dependencies. -// muxOverride can be used to override the configured multiplexer. -func initManager(muxOverride string) error { +func initManager() error { var worktreesDir string var catalogPath string var logsDir string @@ -180,21 +169,8 @@ func initManager(muxOverride string) error { opener := git.NewOpener(executor) - // Select multiplexer: CLI flag > config > default (tmux) - var mux multiplexer.Multiplexer - muxName := "tmux" // default - if appConfig != nil && appConfig.Default.Multiplexer != "" { - muxName = appConfig.Default.Multiplexer - } - if muxOverride != "" { - muxName = muxOverride - } - switch muxName { - case "zellij": - mux = multiplexer.NewZellij(executor) - default: - mux = multiplexer.NewTmux(executor) - } + // Use tmux as the terminal multiplexer + mux := multiplexer.NewTmux(executor) // Create registry client for fetching image metadata regClient := registry.NewClient(registry.ClientConfig{}) diff --git a/internal/config/config.go b/internal/config/config.go index 634a7b6..068bf78 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,11 +27,10 @@ const DefaultBaseImage = "ghcr.io/jmgilman/headjack:base" // Sentinel errors for configuration operations. var ( - ErrInvalidKey = errors.New("invalid configuration key") - ErrInvalidAgent = errors.New("invalid agent name") - ErrInvalidMultiplexer = errors.New("invalid multiplexer name") - ErrInvalidRuntime = errors.New("invalid runtime name") - ErrNoEditor = errors.New("$EDITOR environment variable not set") + ErrInvalidKey = errors.New("invalid configuration key") + ErrInvalidAgent = errors.New("invalid agent name") + ErrInvalidRuntime = errors.New("invalid runtime name") + ErrNoEditor = errors.New("$EDITOR environment variable not set") ) // validAgents contains the allowed agent names (unexported). @@ -41,12 +40,6 @@ var validAgents = map[string]bool{ "codex": true, } -// validMultiplexers contains the allowed multiplexer names (unexported). -var validMultiplexers = map[string]bool{ - "tmux": true, - "zellij": true, -} - // validRuntimes contains the allowed runtime names (unexported). var validRuntimes = map[string]bool{ "podman": true, @@ -69,9 +62,8 @@ type Config struct { // DefaultConfig holds default values for new instances. type DefaultConfig struct { - Agent string `mapstructure:"agent" validate:"omitempty,oneof=claude gemini codex"` - BaseImage string `mapstructure:"base_image" validate:"required"` - Multiplexer string `mapstructure:"multiplexer" validate:"omitempty,oneof=tmux zellij"` + Agent string `mapstructure:"agent" validate:"omitempty,oneof=claude gemini codex"` + BaseImage string `mapstructure:"base_image" validate:"required"` } // AgentConfig holds agent-specific configuration. @@ -132,8 +124,6 @@ func NewLoader() (*Loader, error) { //nolint:errcheck // BindEnv only fails with zero arguments v.BindEnv("default.base_image", "HEADJACK_BASE_IMAGE") //nolint:errcheck // BindEnv only fails with zero arguments - v.BindEnv("default.multiplexer", "HEADJACK_MULTIPLEXER") - //nolint:errcheck // BindEnv only fails with zero arguments v.BindEnv("storage.worktrees", "HEADJACK_WORKTREE_DIR") l := &Loader{ @@ -152,7 +142,6 @@ func NewLoader() (*Loader, error) { func (l *Loader) setDefaults() { l.v.SetDefault("default.agent", "") l.v.SetDefault("default.base_image", DefaultBaseImage) - l.v.SetDefault("default.multiplexer", "tmux") l.v.SetDefault("storage.worktrees", "~/.local/share/headjack/git") l.v.SetDefault("storage.catalog", "~/.local/share/headjack/catalog.json") l.v.SetDefault("storage.logs", "~/.local/share/headjack/logs") @@ -227,13 +216,6 @@ func (l *Loader) Set(key, value string) error { } } - // Validate multiplexer name if setting default.multiplexer - if key == "default.multiplexer" && value != "" { - if !validMultiplexers[value] { - return fmt.Errorf("%w: %s (valid: tmux, zellij)", ErrInvalidMultiplexer, value) - } - } - // Validate runtime name if setting runtime.name if key == "runtime.name" && value != "" { if !validRuntimes[value] { @@ -332,16 +314,6 @@ func ValidAgentNames() []string { return []string{"claude", "gemini", "codex"} } -// IsValidMultiplexer is a package-level helper for checking multiplexer validity. -func IsValidMultiplexer(name string) bool { - return validMultiplexers[name] -} - -// ValidMultiplexerNames returns the list of valid multiplexer names. -func ValidMultiplexerNames() []string { - return []string{"tmux", "zellij"} -} - // IsValidRuntime is a package-level helper for checking runtime validity. func IsValidRuntime(name string) bool { return validRuntimes[name] diff --git a/internal/multiplexer/multiplexer.go b/internal/multiplexer/multiplexer.go index 2dd77b7..4763f15 100644 --- a/internal/multiplexer/multiplexer.go +++ b/internal/multiplexer/multiplexer.go @@ -1,6 +1,6 @@ // Package multiplexer provides an abstraction over terminal multiplexer operations. // It defines a generic interface that can be implemented by different backends -// (Zellij, tmux, etc.) to manage persistent, attachable terminal sessions. +// (e.g., tmux) to manage persistent, attachable terminal sessions. package multiplexer import ( diff --git a/internal/multiplexer/zellij.go b/internal/multiplexer/zellij.go deleted file mode 100644 index 4fd1bd7..0000000 --- a/internal/multiplexer/zellij.go +++ /dev/null @@ -1,161 +0,0 @@ -package multiplexer - -import ( - "bytes" - "context" - "fmt" - "io" - "os" - "os/signal" - "strings" - "syscall" - - "golang.org/x/term" - - "github.com/jmgilman/headjack/internal/exec" -) - -// zellij implements Multiplexer using the Zellij terminal multiplexer. -type zellij struct { - exec exec.Executor -} - -// NewZellij creates a Multiplexer using Zellij CLI. -func NewZellij(e exec.Executor) Multiplexer { - return &zellij{exec: e} -} - -func (z *zellij) CreateSession(_ context.Context, _ *CreateSessionOpts) (*Session, error) { - // Zellij does not support creating sessions in detached mode. - // Unlike tmux (which supports `tmux new-session -d`), Zellij always - // attempts to attach to the session it creates, requiring a TTY. - // There is no reliable way to create a background/detached session. - return nil, ErrDetachedModeNotSupported -} - -func (z *zellij) AttachSession(ctx context.Context, sessionName string) error { - // zellij attach - // Note: We don't use --create here since CreateSession handles creation - args := []string{"attach", sessionName} - - stdinFd := int(os.Stdin.Fd()) - - // Capture stderr while also streaming to os.Stderr for user visibility - // This allows us to detect error messages like "session not found" - var stderrBuf bytes.Buffer - stderrWriter := io.MultiWriter(os.Stderr, &stderrBuf) - - // Check if stdin is a terminal - if !term.IsTerminal(stdinFd) { - // Fall back to non-interactive mode - _, err := z.exec.Run(ctx, &exec.RunOptions{ - Name: "zellij", - Args: args, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: stderrWriter, - }) - if err != nil { - stderr := stderrBuf.String() - if strings.Contains(stderr, "not found") || strings.Contains(stderr, "No session") { - return ErrSessionNotFound - } - return fmt.Errorf("%w: %v", ErrAttachFailed, err) - } - return nil - } - - // Put terminal in raw mode for proper TTY handling - oldState, err := term.MakeRaw(stdinFd) - if err != nil { - return fmt.Errorf("set terminal raw mode: %w", err) - } - defer func() { _ = term.Restore(stdinFd, oldState) }() - - // Handle window resize signals - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGWINCH) - defer signal.Stop(sigCh) - - // Run zellij with stdio attached - _, err = z.exec.Run(ctx, &exec.RunOptions{ - Name: "zellij", - Args: args, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: stderrWriter, - }) - if err != nil { - stderr := stderrBuf.String() - if strings.Contains(stderr, "not found") || strings.Contains(stderr, "No session") { - return ErrSessionNotFound - } - return fmt.Errorf("%w: %v", ErrAttachFailed, err) - } - - return nil -} - -func (z *zellij) ListSessions(ctx context.Context) ([]Session, error) { - // zellij list-sessions - result, err := z.exec.Run(ctx, &exec.RunOptions{ - Name: "zellij", - Args: []string{"list-sessions"}, - }) - if err != nil { - // If zellij exits with error but has no sessions, that's ok - stderr := string(result.Stderr) - if strings.Contains(stderr, "No active") || result.ExitCode == 0 { - return []Session{}, nil - } - return nil, fmt.Errorf("list sessions: %w", err) - } - - // Parse output - each line is a session name - // Format: "session-name [Created ...] (current)" or just "session-name" - output := strings.TrimSpace(string(result.Stdout)) - if output == "" { - return []Session{}, nil - } - - lines := strings.Split(output, "\n") - sessions := make([]Session, 0, len(lines)) - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - // Extract session name (first word before any brackets or parentheses) - name := line - if idx := strings.IndexAny(line, " \t[("); idx > 0 { - name = line[:idx] - } - - sessions = append(sessions, Session{ - ID: name, - Name: name, - // CreatedAt is not reliably available from list output - }) - } - - return sessions, nil -} - -func (z *zellij) KillSession(ctx context.Context, sessionName string) error { - // zellij kill-session - result, err := z.exec.Run(ctx, &exec.RunOptions{ - Name: "zellij", - Args: []string{"kill-session", sessionName}, - }) - if err != nil { - stderr := string(result.Stderr) - if strings.Contains(stderr, "not found") || strings.Contains(stderr, "No session") { - return ErrSessionNotFound - } - return fmt.Errorf("kill session: %w", err) - } - - return nil -} diff --git a/internal/multiplexer/zellij_test.go b/internal/multiplexer/zellij_test.go deleted file mode 100644 index 7d9b80c..0000000 --- a/internal/multiplexer/zellij_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package multiplexer - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/jmgilman/headjack/internal/exec" - "github.com/jmgilman/headjack/internal/exec/mocks" -) - -func TestNewZellij(t *testing.T) { - mockExec := &mocks.ExecutorMock{} - z := NewZellij(mockExec) - - require.NotNil(t, z) -} - -func TestZellij_CreateSession(t *testing.T) { - ctx := context.Background() - - t.Run("returns ErrDetachedModeNotSupported", func(t *testing.T) { - // Zellij does not support detached session creation - mockExec := &mocks.ExecutorMock{} - - z := NewZellij(mockExec) - _, err := z.CreateSession(ctx, &CreateSessionOpts{ - Name: "test-session", - }) - - require.ErrorIs(t, err, ErrDetachedModeNotSupported) - }) -} - -func TestZellij_ListSessions(t *testing.T) { - ctx := context.Background() - - t.Run("returns empty list when no sessions", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - assert.Equal(t, "zellij", opts.Name) - assert.Equal(t, []string{"list-sessions"}, opts.Args) - - return &exec.Result{ - Stdout: []byte(""), - ExitCode: 0, - }, nil - }, - } - - z := NewZellij(mockExec) - sessions, err := z.ListSessions(ctx) - - require.NoError(t, err) - assert.Empty(t, sessions) - }) - - t.Run("parses single session", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - return &exec.Result{ - Stdout: []byte("my-session\n"), - ExitCode: 0, - }, nil - }, - } - - z := NewZellij(mockExec) - sessions, err := z.ListSessions(ctx) - - require.NoError(t, err) - require.Len(t, sessions, 1) - assert.Equal(t, "my-session", sessions[0].ID) - assert.Equal(t, "my-session", sessions[0].Name) - }) - - t.Run("parses multiple sessions", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - return &exec.Result{ - Stdout: []byte("session-1\nsession-2\nsession-3\n"), - ExitCode: 0, - }, nil - }, - } - - z := NewZellij(mockExec) - sessions, err := z.ListSessions(ctx) - - require.NoError(t, err) - require.Len(t, sessions, 3) - assert.Equal(t, "session-1", sessions[0].Name) - assert.Equal(t, "session-2", sessions[1].Name) - assert.Equal(t, "session-3", sessions[2].Name) - }) - - t.Run("parses session with metadata", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - // Zellij output format: "session-name [Created 2h ago] (current)" - return &exec.Result{ - Stdout: []byte("my-session [Created 2h ago] (current)\nother-session [Created 1d ago]\n"), - ExitCode: 0, - }, nil - }, - } - - z := NewZellij(mockExec) - sessions, err := z.ListSessions(ctx) - - require.NoError(t, err) - require.Len(t, sessions, 2) - assert.Equal(t, "my-session", sessions[0].Name) - assert.Equal(t, "other-session", sessions[1].Name) - }) - - t.Run("handles no active sessions error gracefully", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - return &exec.Result{ - Stderr: []byte("No active zellij sessions found"), - ExitCode: 0, // zellij may return 0 with this message - }, nil - }, - } - - z := NewZellij(mockExec) - sessions, err := z.ListSessions(ctx) - - require.NoError(t, err) - assert.Empty(t, sessions) - }) - - t.Run("returns error on command failure", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - return &exec.Result{ - Stderr: []byte("zellij: command not found"), - ExitCode: 127, - }, errors.New("exit code 127") - }, - } - - z := NewZellij(mockExec) - _, err := z.ListSessions(ctx) - - require.Error(t, err) - assert.Contains(t, err.Error(), "list sessions") - }) -} - -func TestZellij_KillSession(t *testing.T) { - ctx := context.Background() - - t.Run("kills session successfully", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - assert.Equal(t, "zellij", opts.Name) - assert.Equal(t, []string{"kill-session", "my-session"}, opts.Args) - - return &exec.Result{ - ExitCode: 0, - }, nil - }, - } - - z := NewZellij(mockExec) - err := z.KillSession(ctx, "my-session") - - require.NoError(t, err) - }) - - t.Run("returns ErrSessionNotFound when session missing", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - return &exec.Result{ - Stderr: []byte("Session 'missing' not found"), - ExitCode: 1, - }, errors.New("exit code 1") - }, - } - - z := NewZellij(mockExec) - err := z.KillSession(ctx, "missing") - - assert.ErrorIs(t, err, ErrSessionNotFound) - }) - - t.Run("returns ErrSessionNotFound for no session error", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - return &exec.Result{ - Stderr: []byte("No session with that name"), - ExitCode: 1, - }, errors.New("exit code 1") - }, - } - - z := NewZellij(mockExec) - err := z.KillSession(ctx, "missing") - - assert.ErrorIs(t, err, ErrSessionNotFound) - }) - - t.Run("returns generic error for other failures", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - return &exec.Result{ - Stderr: []byte("unexpected error"), - ExitCode: 1, - }, errors.New("exit code 1") - }, - } - - z := NewZellij(mockExec) - err := z.KillSession(ctx, "my-session") - - require.Error(t, err) - require.NotErrorIs(t, err, ErrSessionNotFound) - assert.Contains(t, err.Error(), "kill session") - }) -} - -func TestZellij_AttachSession(t *testing.T) { - ctx := context.Background() - - t.Run("attaches to session with correct args", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - assert.Equal(t, "zellij", opts.Name) - assert.Equal(t, []string{"attach", "my-session"}, opts.Args) - - return &exec.Result{ - ExitCode: 0, - }, nil - }, - } - - z := NewZellij(mockExec) - // Note: This test won't fully exercise TTY handling since we're not in a terminal - err := z.AttachSession(ctx, "my-session") - - require.NoError(t, err) - }) - - t.Run("returns ErrSessionNotFound when session missing", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - // Write to the stderr writer (io.MultiWriter) that AttachSession provides - if opts.Stderr != nil { - _, _ = opts.Stderr.Write([]byte("Session not found")) - } - return &exec.Result{ - ExitCode: 1, - }, errors.New("exit code 1") - }, - } - - z := NewZellij(mockExec) - err := z.AttachSession(ctx, "missing") - - assert.ErrorIs(t, err, ErrSessionNotFound) - }) - - t.Run("returns ErrAttachFailed on command error", func(t *testing.T) { - mockExec := &mocks.ExecutorMock{ - RunFunc: func(ctx context.Context, opts *exec.RunOptions) (*exec.Result, error) { - // Write to the stderr writer (io.MultiWriter) that AttachSession provides - if opts.Stderr != nil { - _, _ = opts.Stderr.Write([]byte("attach failed")) - } - return &exec.Result{ - ExitCode: 1, - }, errors.New("exit code 1") - }, - } - - z := NewZellij(mockExec) - err := z.AttachSession(ctx, "my-session") - - assert.ErrorIs(t, err, ErrAttachFailed) - }) -}