`:
@@ -35,16 +37,16 @@ Edit any session without touching YAML — `mox edit `:
## 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 ` 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 ` 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
@@ -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
diff --git a/internal/cli/edit.go b/internal/cli/edit.go
index 7e524de..adea889 100644
--- a/internal/cli/edit.go
+++ b/internal/cli/edit.go
@@ -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
}
diff --git a/internal/cli/editor_state.go b/internal/cli/editor_state.go
index 2299ae6..c8a7508 100644
--- a/internal/cli/editor_state.go
+++ b/internal/cli/editor_state.go
@@ -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.
diff --git a/internal/cli/editor_ui.go b/internal/cli/editor_ui.go
index d9e1295..c826c42 100644
--- a/internal/cli/editor_ui.go
+++ b/internal/cli/editor_ui.go
@@ -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,
@@ -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
@@ -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 {
@@ -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
@@ -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) {
diff --git a/internal/cli/editor_ui_test.go b/internal/cli/editor_ui_test.go
index d8d4613..84ae246 100644
--- a/internal/cli/editor_ui_test.go
+++ b/internal/cli/editor_ui_test.go
@@ -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 ---
@@ -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()
diff --git a/internal/cli/hub_ui.go b/internal/cli/hub_ui.go
index 61d80a7..56fa3ca 100644
--- a/internal/cli/hub_ui.go
+++ b/internal/cli/hub_ui.go
@@ -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:
diff --git a/internal/cli/hub_ui_test.go b/internal/cli/hub_ui_test.go
index 164a75b..6ab34d8 100644
--- a/internal/cli/hub_ui_test.go
+++ b/internal/cli/hub_ui_test.go
@@ -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) {