From a4218bf7960256a57aa37768ce0d7d7a2ef3d6cb Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 23 Jul 2026 09:27:16 -0400 Subject: [PATCH 1/2] fix: honor "save + start now" in the editor wizard; hub ctrl+e feedback The embedded add wizard offered "save + start now" but the editor silently downgraded it to a plain draft: nothing started, even after saving. The choice now jumps straight into the save pipeline (diff -> write) with the draft marked to start, and a successful save starts the session detached via a fresh manager over the post-save config. The status line reports saved + started, a start failure, or a skipped start when tmux is unavailable. Hub: ctrl+e on a session that is not in the config was a silent no-op, which reads as a dead key when the highlighted session is tmux-only. It now reports " is not in the config", matching S. --- CHANGELOG.md | 11 ++++ internal/cli/edit.go | 21 ++++++-- internal/cli/editor_state.go | 11 ++-- internal/cli/editor_ui.go | 40 ++++++++++++-- internal/cli/editor_ui_test.go | 95 ++++++++++++++++++++++++++++++++++ internal/cli/hub_ui.go | 4 ++ internal/cli/hub_ui_test.go | 5 +- 7 files changed, 175 insertions(+), 12 deletions(-) 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/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) { From fb3c5116187610d8f5930361fa78d88be0bffde7 Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 23 Jul 2026 09:27:26 -0400 Subject: [PATCH 2/2] docs: same width for all README screenshots; refresh the description The list screenshot rendered at 700px next to the 850px hub and editor shots. All three are 850 now. Reworked the intro to cover what mox is today (config-defined sessions, ad-hoc sessions, the hub) and cleaned the punctuation throughout. --- README.md | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) 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