diff --git a/CHANGELOG.md b/CHANGELOG.md index 2163a83..f3c5e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 200fdd8..acf5ec2 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,13 @@ License

-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:

mox session hub @@ -24,10 +26,10 @@ sessions, start/kill/edit without leaving it: Or see everything at a glance with `mox list`:

- mox list output + mox list output

-Edit any session without touching YAML — `mox edit `: +Edit any session without touching YAML using `mox edit `:

mox session editor @@ -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) {