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]

### 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Edit any session without touching YAML using `mox edit <session>`:
- **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 <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
Expand Down
6 changes: 5 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 30 additions & 2 deletions internal/cli/hub_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -31,6 +32,7 @@ const (
hubQuit hubAction = iota
hubAttach
hubEdit
hubImport
)

type hubMode int
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions internal/cli/hub_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
19 changes: 19 additions & 0 deletions internal/cli/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 36 additions & 3 deletions internal/cli/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading