Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- The editor's embedded add wizard honors "save + start now": the choice
now jumps straight into the save preview, and confirming the save also
starts the session detached (like the hub's `S`), with the outcome
reported in the status line. Previously the choice silently behaved
like "save to config" and the session never started.
- Hub: `ctrl+e` on a session that is not in the config now says so in the
status line (matching `S`) instead of doing nothing, which read as a
dead key when the highlighted session was tmux-only.

## [0.5.1] — 2026-07-22

### Fixed
Expand Down
42 changes: 22 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
<a href="LICENSE"><img src="https://img.shields.io/github/license/bthall/mox" alt="License"></a>
</p>

A CLI tool for creating and managing tmux sessions declaratively from YAML
configuration files — with cssh-style multi-host broadcast built in.
mox builds and manages tmux sessions from declarative YAML, with
cssh-style multi-host broadcast built in. Define a session once, then
start, attach, edit, or kill it by name. Ad-hoc sessions work straight
from the command line, no config required.

Run `mox` and you're in the session hub — live previews of running
sessions, start/kill/edit without leaving it:
Bare `mox` opens the session hub. Live previews of running sessions,
with start, kill, and edit in place:

<p align="center">
<img src="assets/screenshot-hub.png" alt="mox session hub" width="850">
Expand All @@ -24,27 +26,27 @@ sessions, start/kill/edit without leaving it:
Or see everything at a glance with `mox list`:

<p align="center">
<img src="assets/screenshot-list.png" alt="mox list output" width="700">
<img src="assets/screenshot-list.png" alt="mox list output" width="850">
</p>

Edit any session without touching YAML `mox edit <session>`:
Edit any session without touching YAML using `mox edit <session>`:

<p align="center">
<img src="assets/screenshot-editor.png" alt="mox session editor" width="850">
</p>

## Features

- **Declarative YAML config** one window per host, full custom layouts, or anything between; project-local `.mox.yml` overrides; editor autocomplete via a published JSON Schema
- **Cssh-style broadcast** `sync: true` for synchronized typing; tiled layouts; `sudo -i` once for every pane
- **Ad-hoc sessions** `mox new @cluster` or `mox new host1 host2` without touching config; `-x` excludes hosts; `--save` keeps the definition
- **Session hub**bare `mox` opens a full-screen hub: filterable session list, live buffer previews of running sessions, and start/kill/edit actions in place
- **Config without YAML**`mox edit <session>` opens a full-screen editor: buffered drafts, a validated diff preview before anything is written, comment-preserving saves; `mox add` walks a short wizard; `mox import` captures a running session structure, pane geometry, *and its SSH connections*
- **Broadcast safety** an ended connection holds its pane instead of dropping to a local shell; optional retry
- **Lifecycle hooks** `on_start`/`on_stop` run locally around a session; `pre` seeds every pane
- **Recents** `mox recent` remembers what you used; `mox last` is `cd -` for sessions
- **Dry-run** `--print` shows the exact tmux commands without running them
- **Honest defaults** single binary, no daemon, strict config validation; the only state is a small recents history
- **Declarative YAML config**: one window per host, full custom layouts, or anything between; project-local `.mox.yml` overrides; editor autocomplete via a published JSON Schema
- **Cssh-style broadcast**: `sync: true` for synchronized typing; tiled layouts; `sudo -i` once for every pane
- **Ad-hoc sessions**: `mox new @cluster` or `mox new host1 host2` without touching config; `-x` excludes hosts; `--save` keeps the definition
- **Session hub**: bare `mox` opens a full-screen hub with a filterable session list, live buffer previews of running sessions, and start/kill/edit actions in place
- **Config without YAML**: `mox edit <session>` opens a full-screen editor with buffered drafts, a validated diff preview before anything is written, and comment-preserving saves; `mox add` walks a short wizard; `mox import` captures a running session (structure, pane geometry, *and its SSH connections*)
- **Broadcast safety**: an ended connection holds its pane instead of dropping to a local shell; optional retry
- **Lifecycle hooks**: `on_start`/`on_stop` run locally around a session; `pre` seeds every pane
- **Recents**: `mox recent` remembers what you used; `mox last` is `cd -` for sessions
- **Dry-run**: `--print` shows the exact tmux commands without running them
- **Honest defaults**: single binary, no daemon, strict config validation; the only state is a small recents history

## Install

Expand All @@ -70,16 +72,16 @@ mox add # interactively add a session to it
mox edit # full-screen session editor
mox edit example # same, with a session pre-selected
mox -a example # build + attach to the "example" session
mox # or pick a session interactively
mox # or open the session hub
mox new @webfarm # ad-hoc broadcast session on a cluster
mox kill example # destroy a running session
```

## Documentation

- **[Configuration](docs/configuration.md)**the YAML schema: sessions, windows, layouts, connect templates, hooks, holding/retry, validation rules
- **[Commands](docs/commands.md)**every command and flag, the picker, cluster expansion, dry-run, shell completion
- **[Recipes](docs/recipes.md)** copy-paste workflows, from quick local sessions to clusterssh migration
- **[Configuration](docs/configuration.md)**: the YAML schema (sessions, windows, layouts, connect templates, hooks, holding/retry, validation rules)
- **[Commands](docs/commands.md)**: every command and flag, the session hub, cluster expansion, dry-run, shell completion
- **[Recipes](docs/recipes.md)**: copy-paste workflows, from quick local sessions to clusterssh migration

## Contributing

Expand Down
21 changes: 18 additions & 3 deletions internal/cli/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,32 @@ func editAndValidate(path, editor string, out io.Writer) error {
// state (callers validate the initial session before getting here).
func runEditorTUI(cmd *cobra.Command, st *editorState, initial string) error {
clusters, _ := loadClusterssh() // missing file is fine
logger := loggerFromContext(cmd.Context())
ctx := cmd.Context()

// Running-state dots are best-effort: no tmux, no dots.
// Running-state dots and the post-save start hook are best-effort: no
// tmux, no dots, and "save + start now" degrades to save-only.
running := map[string]session.SessionInfo{}
if mgr, err := session.NewManager(st.cfg, loggerFromContext(cmd.Context())); err == nil {
var start func(cfg *config.Config, name string) error
if mgr, err := session.NewManager(st.cfg, logger); err == nil {
if infos, err := mgr.List(); err == nil {
for _, info := range infos {
running[info.Name] = info
}
}
// Saves replace st.cfg, so the start hook builds a manager over the
// config it is handed rather than reusing the launch-time one.
start = func(cfg *config.Config, name string) error {
m, err := session.NewManager(cfg, logger)
if err != nil {
return err
}
return m.Create(ctx, name, false)
}
}

_, err := tea.NewProgram(newEditorModel(st, clusters, running, initial), tea.WithAltScreen()).Run()
model := newEditorModel(st, clusters, running, initial)
model.startSession = start
_, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
return err
}
11 changes: 6 additions & 5 deletions internal/cli/editor_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,12 @@ func loadEditorState(path string) (*editorState, error) {
// Exactly one draft is active in the editor at a time; guards force a
// save/discard decision before it can be abandoned.
type sessionDraft struct {
orig string // config name this draft edits; "" for brand-new sessions
name string // current name (differs from orig after a rename)
sess *config.Session // working copy; ignored when deleted
deleted bool
added bool // brand-new (wizard/duplicate): no orig entry to replace
orig string // config name this draft edits; "" for brand-new sessions
name string // current name (differs from orig after a rename)
sess *config.Session // working copy; ignored when deleted
deleted bool
added bool // brand-new (wizard/duplicate): no orig entry to replace
startAfter bool // wizard's "save + start now": start detached after a successful save
}

// newDraft starts a clean draft for an existing configured session.
Expand Down
40 changes: 37 additions & 3 deletions internal/cli/editor_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,17 @@ type editorModel struct {
statusErr bool
statusOK bool // success feedback renders green

// startSession starts a just-saved session detached, from the post-save
// config. nil when tmux is unavailable (and in most tests).
startSession func(cfg *config.Config, name string) error

width, height int
}

func newEditorModel(st *editorState, clusters map[string][]string, running map[string]session.SessionInfo, initial string) editorModel {
if running == nil {
running = map[string]session.SessionInfo{}
}
m := editorModel{
st: st,
clusters: clusters,
Expand Down Expand Up @@ -842,8 +849,8 @@ func (m editorModel) continuePending() (tea.Model, tea.Cmd) {
// updateWizard forwards input to the embedded 'mox add' wizard and, when it
// finishes, converts its result into the active draft. The wizard's file
// write never runs here — the editor's save pipeline is the only writer.
// Its "save + start now" choice behaves like "save to config" in embedded
// mode (the draft still goes through s → diff → write).
// "save + start now" jumps straight into that pipeline (diff → write) with
// the draft marked to start detached once the save lands.
func (m editorModel) updateWizard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.Type == tea.KeyCtrlC {
// Hard-quit like every other sub-mode — the wizard's own ctrl+c
Expand All @@ -865,7 +872,7 @@ func (m editorModel) updateWizard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if res.action == addActionCancel {
return m, nil
}
d := &sessionDraft{name: res.name, sess: res.sess}
d := &sessionDraft{name: res.name, sess: res.sess, startAfter: res.action == addActionSaveStart}
if _, exists := m.st.cfg.Sessions[res.name]; exists {
d.orig = res.name // wizard-confirmed overwrite of an existing session
} else {
Expand All @@ -883,6 +890,9 @@ func (m editorModel) updateWizard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.fields = sessionFields(d.sess)
m.fieldSel = 0
m.pane = paneForm
if d.startAfter {
return m.startSave()
}
m.status = "new session drafted — press s to save it"
m.statusErr = false
return m, nil
Expand Down Expand Up @@ -933,9 +943,33 @@ func (m editorModel) finishSave() (editorModel, bool) {
}
m.statusErr = false
m.statusOK = true
if d.startAfter && !d.deleted {
m.startSaved(d.name)
}
return m, true
}

// startSaved honors a draft's "save + start now" intent: start the session
// detached from the freshly saved config. Synchronous — session builds are
// tmux calls, quick enough to run on the UI thread, and this way the start
// also happens when a guard-save quits the editor right after.
func (m *editorModel) startSaved(name string) {
if m.startSession == nil {
m.status = "saved " + name + " ✓ — start skipped: tmux unavailable"
m.statusErr = true
m.statusOK = false
return
}
if err := m.startSession(m.st.cfg, name); err != nil {
m.status = "saved " + name + " ✓ — start failed: " + err.Error()
m.statusErr = true
m.statusOK = false
return
}
m.status = "saved + started " + name + " ✓ (detached)"
m.running[name] = session.SessionInfo{Name: name, Running: true, Managed: true}
}

// jumpToErrorField moves the form cursor to the field a validation error
// names, when one matches (best-effort substring match on field keys).
func (m *editorModel) jumpToErrorField(err error) {
Expand Down
95 changes: 95 additions & 0 deletions internal/cli/editor_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"time"

tea "github.com/charmbracelet/bubbletea"

"github.com/bthall/mox/internal/config"
)

// --- key helpers shared by all editor UI tests ---
Expand Down Expand Up @@ -904,6 +906,99 @@ func TestEditorWizardAdd(t *testing.T) {
}
}

// TestEditorWizardSaveStart pins that the wizard's "save + start now"
// choice drives the draft through the save pipeline and then actually
// starts the session (detached) once the save lands.
func TestEditorWizardSaveStart(t *testing.T) {
m := testEditorModel(t)
var started []string
m.startSession = func(cfg *config.Config, name string) error {
if _, ok := cfg.GetSession(name); !ok {
t.Errorf("start called with a config missing %q", name)
}
started = append(started, name)
return nil
}
m = edRunes(t, m, "a")
m = edRunes(t, m, "brandnew")
m = edType(t, m, tea.KeyEnter) // name
m = edType(t, m, tea.KeyEnter) // hosts: empty → root
m = edType(t, m, tea.KeyEnter) // root
m = edType(t, m, tea.KeyEnter) // commands → confirm
m = edType(t, m, tea.KeyDown) // select "save + start now"
m = edType(t, m, tea.KeyEnter)

if m.mode != modeDiff {
t.Fatalf("save + start did not open the save preview: mode=%v", m.mode)
}
if m.draft == nil || !m.draft.startAfter {
t.Fatalf("draft = %+v, want startAfter set", m.draft)
}
if len(started) != 0 {
t.Fatal("session started before the save was confirmed")
}

m = edType(t, m, tea.KeyEnter) // confirm the diff → write + start
if len(started) != 1 || started[0] != "brandnew" {
t.Fatalf("started = %v, want [brandnew]", started)
}
if !strings.Contains(m.status, "started brandnew") || m.statusErr || !m.statusOK {
t.Fatalf("status = %q (err=%v ok=%v)", m.status, m.statusErr, m.statusOK)
}
data, _ := os.ReadFile(m.st.path)
if !strings.Contains(string(data), "brandnew") {
t.Fatalf("save + start did not write the session:\n%s", data)
}
if info := m.running["brandnew"]; !info.Running {
t.Fatal("running map not updated after start")
}
}

// TestEditorWizardSaveStartFailure pins that a failed start reports the
// error without hiding that the save itself succeeded.
func TestEditorWizardSaveStartFailure(t *testing.T) {
m := testEditorModel(t)
m.startSession = func(cfg *config.Config, name string) error {
return fmt.Errorf("session %q already exists", name)
}
m = edRunes(t, m, "a")
m = edRunes(t, m, "brandnew")
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyDown)
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyEnter) // confirm the diff

if !strings.Contains(m.status, "saved brandnew") || !strings.Contains(m.status, "already exists") || !m.statusErr {
t.Fatalf("status = %q (err=%v)", m.status, m.statusErr)
}
data, _ := os.ReadFile(m.st.path)
if !strings.Contains(string(data), "brandnew") {
t.Fatal("failed start should not roll back the save")
}
}

// TestEditorWizardSaveStartNoTmux pins the degraded path: no start hook
// (tmux unavailable) still saves, and says the start was skipped.
func TestEditorWizardSaveStartNoTmux(t *testing.T) {
m := testEditorModel(t)
m = edRunes(t, m, "a")
m = edRunes(t, m, "brandnew")
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyDown)
m = edType(t, m, tea.KeyEnter)
m = edType(t, m, tea.KeyEnter) // confirm the diff

if !strings.Contains(m.status, "saved brandnew") || !strings.Contains(m.status, "start skipped") {
t.Fatalf("status = %q", m.status)
}
}

func TestEditorWizardCancel(t *testing.T) {
m := testEditorModel(t)
before := m.selectedName()
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/hub_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ func (m hubModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.action = hubEdit
return m, tea.Quit
}
// Same feedback as S: a silent no-op reads as a dead key.
m.status = c.Name + " is not in the config"
m.statusErr = false
m.statusOK = false
}
return m, nil
case tea.KeyEsc:
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/hub_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,16 @@ func TestHubAttachAndEdit(t *testing.T) {
if !isQuit(cmd) || m3.action != hubEdit || m3.choice != "webfarm" {
t.Fatalf("ctrl+e: action=%v choice=%q", m3.action, m3.choice)
}
// ctrl+e on an unmanaged session is a no-op
// ctrl+e on an unmanaged session stays in the hub and says why.
m4, _ := hubRunes(t, m, "j")
m4, _ = hubRunes(t, m4, "j") // scratch
m5, cmd := hubKey(t, m4, tea.KeyMsg{Type: tea.KeyCtrlE})
if cmd != nil || m5.action != hubQuit {
t.Fatal("ctrl+e acted on an unmanaged session")
}
if !strings.Contains(m5.status, "not in the config") || m5.statusErr {
t.Fatalf("ctrl+e on unmanaged session: status = %q (statusErr=%v)", m5.status, m5.statusErr)
}
}

func TestHubFilterAndBatchedKeys(t *testing.T) {
Expand Down
Loading