From 0fd4f95e30f7cd54263ba11afced3b949d2fb3b1 Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 23 Jul 2026 09:59:01 -0400 Subject: [PATCH] feat: hub import action and an explicit marker for unmanaged sessions i on a running session that isn't in the config hands off to the same capture as mox import, then returns to a fresh hub with the session configured and a status-line result (including the first geometry warning when capture degraded). i on a configured session reports that it's already in the config. Unmanaged sessions were only distinguishable by name color, which is easy to miss. The list row now carries a "tmux" tag (mox list's ORIGIN vocabulary) and the preview title says "tmux only". --- CHANGELOG.md | 11 ++++++++ README.md | 2 +- docs/commands.md | 6 ++++- internal/cli/hub_ui.go | 32 ++++++++++++++++++++-- internal/cli/hub_ui_test.go | 54 +++++++++++++++++++++++++++++++++++++ internal/cli/import.go | 19 +++++++++++++ internal/cli/picker.go | 39 ++++++++++++++++++++++++--- 7 files changed, 156 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f98e3..e7adbf3 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] +### Added + +- Hub: `i` imports the highlighted unmanaged session into the config + (the same capture as `mox import`), then returns to the hub with the + session now configured. Configured sessions get a status-line notice + instead. +- Hub: sessions running outside the config are now explicitly marked — + a `tmux` tag on the list row and `tmux only` in the preview title — + instead of relying on name color alone to tell them apart from + configured sessions. + ## [0.5.2] — 2026-07-23 ### Fixed diff --git a/README.md b/README.md index acf5ec2..f964f58 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Edit any session without touching YAML using `mox edit `: - **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 +- **Session hub**: bare `mox` opens a full-screen hub with a filterable session list, live buffer previews of running sessions, and start/kill/edit/import 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 diff --git a/docs/commands.md b/docs/commands.md index eff3da1..88169dd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -46,10 +46,14 @@ window summary line. For a **stopped** session it's the config summary. | `S` | start the highlighted configured session detached | | `K` | kill the highlighted running session (confirm, runs `on_stop`) | | `ctrl+e` | open the session in the config editor; quitting returns here | +| `i` | import the highlighted unmanaged session into the config | | `q` / `esc` | quit (esc clears an active filter first) | Start and kill run in the background with a status line while they work; -the list refreshes in place when they finish. Terminals that can't host +the list refreshes in place when they finish. Sessions running outside +the config carry a `tmux` marker in the list (and `tmux only` in the +preview title): they can be attached, killed, or imported, but not +started or edited. Terminals that can't host the UI get a numbered prompt instead; piped and scripted invocations print help, so scripts never hang. diff --git a/internal/cli/hub_ui.go b/internal/cli/hub_ui.go index 56fa3ca..949624c 100644 --- a/internal/cli/hub_ui.go +++ b/internal/cli/hub_ui.go @@ -4,7 +4,8 @@ package cli // left, a preview of the highlighted session on the right — a live buffer // capture for running sessions, the config summary for stopped ones. Enter // attaches; S starts a configured session detached; K kills after a -// one-line confirm. ctrl+e hands off to the config editor. +// one-line confirm. ctrl+e hands off to the config editor; i hands off to +// the import flow for a running session that isn't in the config. import ( "context" @@ -31,6 +32,7 @@ const ( hubQuit hubAction = iota hubAttach hubEdit + hubImport ) type hubMode int @@ -472,6 +474,24 @@ func (m hubModel) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.mode = hubConfirmKill return m, nil + case "i": + c, ok := m.selected() + if !ok || m.pending != "" { + return m, nil + } + if _, managed := m.sessions[c.Name]; managed { + m.status = c.Name + " is already in the config" + m.statusErr = false + return m, nil + } + if !c.Running { + m.status = c.Name + " is not running" + m.statusErr = false + return m, nil + } + m.choice = c.Name + m.action = hubImport + return m, tea.Quit } return m, nil } @@ -620,6 +640,9 @@ func (m hubModel) previewTitle() string { return "mox" } if c.Running { + if !c.Managed { + return fmt.Sprintf("%s · tmux only · running · %s", c.Name, pluralize(c.Windows, "window")) + } return fmt.Sprintf("%s · running · %s", c.Name, pluralize(c.Windows, "window")) } return c.Name + " · stopped" @@ -632,7 +655,7 @@ func (m hubModel) footer() string { case hubConfirmKill: return hints("y", "kill", "esc", "cancel") } - return hints("↵", "attach", "S", "start", "K", "kill", "^e", "edit", "q", "quit") + return hints("↵", "attach", "S", "start", "K", "kill", "^e", "edit", "i", "import", "q", "quit") } func (m hubModel) listLines(w, h int) []string { @@ -654,6 +677,11 @@ func (m hubModel) listLines(w, h int) []string { meta := "" if c.Running { meta = fmt.Sprintf("%dw %s", c.Windows, relativeShort(m.now, c.LastActivity)) + if !c.Managed { + // mox list's ORIGIN vocabulary; color alone doesn't say why + // a session can't be edited or started. + meta += " tmux" + } } name := truncate(c.Name, w-6-len(meta)) pad := w - 4 - lipgloss.Width(name) - len(meta) diff --git a/internal/cli/hub_ui_test.go b/internal/cli/hub_ui_test.go index 6ab34d8..a37d53e 100644 --- a/internal/cli/hub_ui_test.go +++ b/internal/cli/hub_ui_test.go @@ -341,6 +341,60 @@ func TestHubAttachAndEdit(t *testing.T) { } } +// TestHubImportKey pins the import action: i on an unmanaged running +// session hands off to the import flow; i on a configured session says so. +func TestHubImportKey(t *testing.T) { + m := testHubModel(t, nil) + m2, _ := hubRunes(t, m, "j") + m2, _ = hubRunes(t, m2, "j") // scratch: running, unmanaged + m3, cmd := hubRunes(t, m2, "i") + if !isQuit(cmd) || m3.action != hubImport || m3.choice != "scratch" { + t.Fatalf("i on unmanaged: action=%v choice=%q", m3.action, m3.choice) + } + // i on a configured session is refused with feedback + m4, cmd := hubRunes(t, m, "i") // webfarm + if cmd != nil || m4.action != hubQuit { + t.Fatal("i acted on a configured session") + } + if !strings.Contains(m4.status, "already in the config") || m4.statusErr { + t.Fatalf("i on managed session: status = %q", m4.status) + } +} + +// TestHubUnmanagedMarker pins that unmanaged sessions carry a visible +// "tmux" origin marker in the list row and preview title, not just a +// different name color. +func TestHubUnmanagedMarker(t *testing.T) { + m := testHubModel(t, nil) + rows := m.listLines(36, 20) + joined := strings.Join(rows, "\n") + scratchRow := "" + for _, r := range rows { + if strings.Contains(r, "scratch") { + scratchRow = r + } + } + if scratchRow == "" { + t.Fatalf("scratch row missing:\n%s", joined) + } + if !strings.Contains(scratchRow, "tmux") { + t.Fatalf("scratch row has no tmux marker: %q", scratchRow) + } + for _, r := range rows { + if strings.Contains(r, "webfarm") && strings.Contains(r, "tmux") { + t.Fatalf("managed webfarm row carries the tmux marker: %q", r) + } + } + // preview title marks the origin too + m2, _ := hubRunes(t, m, "jj") // scratch + if title := m2.previewTitle(); !strings.Contains(title, "tmux") { + t.Fatalf("preview title = %q, want tmux marker", title) + } + if title := m.previewTitle(); strings.Contains(title, "tmux") { + t.Fatalf("managed preview title = %q, should not carry tmux marker", title) + } +} + func TestHubFilterAndBatchedKeys(t *testing.T) { m := testHubModel(t, nil) if !strings.Contains(m.View(), "/ filter") { diff --git a/internal/cli/import.go b/internal/cli/import.go index 3dfa217..64f5406 100644 --- a/internal/cli/import.go +++ b/internal/cli/import.go @@ -111,6 +111,25 @@ func runImport(cmd *cobra.Command, args []string, o *importOpts) error { return nil } +// importRunningSession captures the running tmux session into the config at +// path under its own name, never overwriting an existing entry. It returns +// the inspect warnings. This is the hub's import action; the command flow +// with flags (--as, --print, --force) lives in runImport. +func importRunningSession(path, name string) ([]string, error) { + client, err := tmux.NewClient() + if err != nil { + return nil, err + } + imported, warnings, err := inspectSession(client, name) + if err != nil { + return nil, fmt.Errorf("inspect %q: %w", name, err) + } + if err := imported.Validate(name); err != nil { + return warnings, fmt.Errorf("imported session is invalid: %w", err) + } + return warnings, appendSessionToConfig(path, name, imported, false) +} + // capturedPane is the per-pane state import recovers from tmux and the process // table: the pane's id (for matching against the window layout), its working // directory, and the argv of its foreground process (nil when none could be diff --git a/internal/cli/picker.go b/internal/cli/picker.go index 96a114e..5b069ad 100644 --- a/internal/cli/picker.go +++ b/internal/cli/picker.go @@ -29,6 +29,10 @@ func runPicker(cmd *cobra.Command) error { logger := loggerFromContext(cmd.Context()) out := cmd.OutOrStdout() + // note carries feedback from one hub visit into the next (import result); + // consumed on the iteration after it is set. + var note hubNote + for { cfg, _ := tryLoadConfig(opts.configPath) if cfg == nil { @@ -60,7 +64,8 @@ func runPicker(cmd *cobra.Command) error { order := func(infos []session.SessionInfo) []session.SessionInfo { return orderPickerCandidates(infos, recent) } - name, action, err := runHub(cmd.Context(), mgr, order, candidates, cfg.Sessions) + name, action, err := runHub(cmd.Context(), mgr, order, candidates, cfg.Sessions, note) + note = hubNote{} if err != nil { return err } @@ -80,6 +85,21 @@ func runPicker(cmd *cobra.Command) error { return err } continue // fresh hub over the (possibly changed) config + case hubImport: + path, local := config.EffectivePath(opts.configPath) + if local { + fmt.Fprintf(os.Stderr, "mox: importing into ./%s\n", config.LocalConfigName) + } + warnings, err := importRunningSession(path, name) + switch { + case err != nil: + note = hubNote{text: "import " + name + ": " + err.Error(), isErr: true} + case len(warnings) > 0: + note = hubNote{text: "imported " + name + " ✓ · " + warnings[0]} + default: + note = hubNote{text: "imported " + name + " ✓"} + } + continue // fresh hub over the grown config default: return nil // quit; also covers S/K-only visits } @@ -186,13 +206,26 @@ func isTerminal(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } +// hubNote is one line of feedback carried into a fresh hub visit — the +// result of an action (import) that ran between two hub iterations. +type hubNote struct { + text string + isErr bool +} + // runHub runs the full-screen session hub and reports the chosen exit. // Capture failures inside the hub degrade per session; a missing tmux // binary degrades every preview the same way. -func runHub(ctx context.Context, mgr *session.Manager, order hubOrder, candidates []session.SessionInfo, sessions map[string]*config.Session) (string, hubAction, error) { +func runHub(ctx context.Context, mgr *session.Manager, order hubOrder, candidates []session.SessionInfo, sessions map[string]*config.Session, note hubNote) (string, hubAction, error) { capture, windows := hubTmuxFuncs(tmux.NewClient()) - final, err := tea.NewProgram(newHubModel(ctx, mgr, order, capture, windows, candidates, sessions, time.Now()), tea.WithAltScreen()).Run() + model := newHubModel(ctx, mgr, order, capture, windows, candidates, sessions, time.Now()) + if note.text != "" { + model.status = note.text + model.statusErr = note.isErr + model.statusOK = !note.isErr + } + final, err := tea.NewProgram(model, tea.WithAltScreen()).Run() if err != nil { return "", hubQuit, err }