diff --git a/CHANGELOG.md b/CHANGELOG.md index 171005e..d47a911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `CLICOLOR_FORCE=1` forces color output even when piped (`mox list | + less -R`), following the de facto standard. `NO_COLOR` still wins. + ## [0.6.1] — 2026-07-23 ### Fixed diff --git a/Makefile b/Makefile index 5732f06..b6bc4a6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-coverage integration install install-completion clean run fmt lint vuln release-snapshot help +.PHONY: build test test-coverage integration install install-completion clean run fmt lint vuln release-snapshot screenshots help BINARY_NAME=mox BUILD_DIR=./build @@ -96,4 +96,14 @@ release-snapshot: ## Build a local snapshot release with goreleaser @echo "Building snapshot release..." goreleaser release --snapshot --clean +screenshots: ## Regenerate README screenshots (requires charmbracelet/freeze) + @echo "Rendering screenshot frames..." + @mkdir -p build/frames + MOX_SCREENSHOT_DIR=$(CURDIR)/build/frames go test ./internal/cli -run TestGenerateScreenshots -v + @for n in hub list editor; do \ + freeze build/frames/$$n.ans -l ansi --window --padding 25 --margin 15 \ + --border.radius 10 --font.family "DejaVu Sans Mono" \ + -o assets/screenshot-$$n.png; \ + done + .DEFAULT_GOAL := build diff --git a/README.md b/README.md index f964f58..e2412db 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ start, attach, edit, or kill it by name. Ad-hoc sessions work straight from the command line, no config required. Bare `mox` opens the session hub. Live previews of running sessions, -with start, kill, and edit in place: +with start, kill, edit, and import in place:

mox session hub diff --git a/assets/screenshot-editor.png b/assets/screenshot-editor.png index 3dc5865..3e7dba5 100644 Binary files a/assets/screenshot-editor.png and b/assets/screenshot-editor.png differ diff --git a/assets/screenshot-hub.png b/assets/screenshot-hub.png index f8c2f83..b60294e 100644 Binary files a/assets/screenshot-hub.png and b/assets/screenshot-hub.png differ diff --git a/assets/screenshot-list.png b/assets/screenshot-list.png index 16d7108..6185007 100644 Binary files a/assets/screenshot-list.png and b/assets/screenshot-list.png differ diff --git a/assets/screenshot-picker.png b/assets/screenshot-picker.png deleted file mode 100644 index b07f08c..0000000 Binary files a/assets/screenshot-picker.png and /dev/null differ diff --git a/docs/commands.md b/docs/commands.md index 7afb331..a2bf343 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,7 +2,8 @@ ``` Session lifecycle: - mox interactive picker over running/configured/recent sessions + mox the session hub: browse, preview, and act on every + running/configured/recent session mox -a attach to a configured session (builds it if not running) also attaches to any running tmux session by name mox new [hosts...] ad-hoc session or window (alias: cssh) @@ -50,17 +51,19 @@ window summary line. For a **stopped** session it's the config summary. | `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. The status glyphs are shared -with `mox list`: shape carries the origin, color the state — `●` green for -a running configured session, `◆` yellow for a session running outside the -config (`tmux only` in the preview title; it can be attached, killed, or -imported, but not started or edited), `○` for stopped. Terminals that can't host -the UI get a numbered prompt instead; piped and scripted invocations print -help, so scripts never hang. +the list refreshes in place when they finish. Import and edit hand off to +their own flows and return to a fresh hub over the updated config. + +The status glyphs are shared with `mox list`: shape carries the origin, +color the state. `●` green is a running configured session; `◆` yellow is +a session running outside the config (`tmux only` in the preview title; it +can be attached, killed, or imported, but not started or edited); `○` is +stopped. Terminals that can't host the UI get a numbered prompt instead; +piped and scripted invocations print help, so scripts never hang. ## The session editor -`mox edit` (or `Ctrl-E` in the picker) opens a full-screen editor: +`mox edit` (or `ctrl+e` in the hub) opens a full-screen editor: configured sessions on the left, the selected session's fields on the right, with the focused field's documentation always visible. Name a session (`mox edit webfarm`) to open with it selected. A config the editor @@ -74,7 +77,7 @@ validation on exit — fixing a broken config by hand still works. | `/` | filter the session list | | `enter` | edit the focused field (text input, toggle, or list editor) | | `space` | cycle toggle/enum fields (`sync`, `arrange`, `hold`) | -| `a` | add a session (runs the `mox add` wizard; result lands as a draft) | +| `a` | add a session (runs the `mox add` wizard; *save* lands as a draft, *save + start now* opens the diff preview and starts the session detached once the save lands) | | `r` / `y` / `D` | rename / duplicate / delete (all buffered until save) | | `o` | open the config in `$EDITOR` (window/pane structure) | | `s` | save: validate → diff preview → write | @@ -121,14 +124,17 @@ Three routes, by decreasing interactivity: 1. **`mox add`** — a short wizard: name, hosts (with live `@cluster` expansion), ssh user, sync, arrangement, directory, commands, then a - YAML preview with *save* or *save + start*. Simple-mode sessions only. + YAML preview with *save to config* or *save + start now*. Simple-mode + sessions only. 2. **`mox new ... --save`** — you already expressed the session in flags; `--save` persists that definition to the config (requires `-n`) and creates the session as usual. Refuses to overwrite an existing entry. 3. **`mox import [session]`** — capture a *running* session: window/pane structure with real split directions and sizes, working directories, and SSH connections recovered from the process table. Run it with no - argument from inside tmux to capture the session you're in. + argument from inside tmux to capture the session you're in, or press + `i` in the hub on a highlighted `◆` tmux-only session for the same + capture without leaving the hub. The build-by-doing loop for custom layouts: `mox new`, split and arrange panes by hand until the window looks right, then `mox import` from inside diff --git a/docs/recipes.md b/docs/recipes.md index c0e3bec..abf7980 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -61,6 +61,9 @@ mox import work -p # preview the YAML on stdout without saving mox import work -F # overwrite an existing config entry ``` +The same capture is one key away in the hub: run bare `mox`, highlight a +`◆` tmux-only session, and press `i`. + SSH connections are recovered from the OS process table: a window whose panes are all plain `ssh host` connections imports as a simple `hosts:` list, and other ssh panes keep their connection as a `commands:` entry. diff --git a/internal/cli/color.go b/internal/cli/color.go index 623a396..03051da 100644 --- a/internal/cli/color.go +++ b/internal/cli/color.go @@ -28,6 +28,11 @@ func useColor(w io.Writer) bool { if os.Getenv("NO_COLOR") != "" { return false } + // The de facto CLICOLOR_FORCE standard: force color even when piped + // (`mox list | less -R`, screenshot generation). NO_COLOR still wins. + if os.Getenv("CLICOLOR_FORCE") == "1" { + return true + } f, ok := w.(*os.File) if !ok { return false diff --git a/internal/cli/screenshot_gen_test.go b/internal/cli/screenshot_gen_test.go new file mode 100644 index 0000000..069d2e8 --- /dev/null +++ b/internal/cli/screenshot_gen_test.go @@ -0,0 +1,131 @@ +package cli + +// Screenshot frame generator, not a test: `make screenshots` runs this with +// MOX_SCREENSHOT_DIR set, writing the README screenshots' terminal frames as +// raw ANSI to that directory. Everything is staged demo data — never real +// session names. The frames are rendered to PNG by charm's freeze (see the +// Makefile target). Skipped entirely during normal test runs. + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/bthall/mox/internal/config" + "github.com/bthall/mox/internal/history" + "github.com/bthall/mox/internal/session" +) + +const screenshotConfigYAML = `sessions: + webfarm: + hosts: [web1, web2, web3] + sync: true + arrange: tiled + pre: + - export TERM=xterm-256color + dev: + root: /home/demo/dev + dbcluster: + hosts: [db1, db2] + ssh_user: admin +` + +func TestGenerateScreenshots(t *testing.T) { + dir := os.Getenv("MOX_SCREENSHOT_DIR") + if dir == "" { + t.Skip("set MOX_SCREENSHOT_DIR to generate screenshot frames") + } + + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(termenv.Ascii) }) + t.Setenv("CLICOLOR_FORCE", "1") // colors mox list's raw-ANSI path into files + + now := time.Now() + write := func(name, frame string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(frame), 0o600); err != nil { //nolint:gosec // dev-only generator; dir is the developer's own env var + t.Fatal(err) + } + } + + write("hub.ans", hubFrame(t, now)) + write("list.ans", listFrame(t, now)) + write("editor.ans", editorFrame(t)) +} + +// hubFrame stages the session hub: a running unmanaged session (◆), the +// highlighted running webfarm with a live preview, and two stopped sessions. +func hubFrame(t *testing.T, now time.Time) string { + t.Helper() + candidates := []session.SessionInfo{ + {Name: "main", Running: true, Managed: false, Windows: 1, LastActivity: now}, + {Name: "webfarm", Running: true, Managed: true, Windows: 1, LastActivity: now, Hosts: []string{"web1", "web2", "web3"}}, + {Name: "dev", Managed: true}, + {Name: "dbcluster", Managed: true, Hosts: []string{"db1", "db2"}}, + } + sessions := demoConfigSessions(t) + m := newHubModel(context.Background(), nil, nil, nil, nil, candidates, sessions, now) + m.width, m.height = 110, 30 + m.sel = 1 // webfarm + m.previewName = "webfarm" + m.previewWin = "1:sh*" + m.previewBody = []string{ + "\x1b[32m✔ deploy ok\x1b[0m", + "\x1b[31m✘ web3 unreachable\x1b[0m", + "\x1b[33m⚠ retrying\x1b[0m", + "ok: [\x1b[36mweb1\x1b[0m] task 0", + "ok: [\x1b[36mweb2\x1b[0m] task 1", + "ok: [\x1b[36mweb3\x1b[0m] task 2", + "ok: [\x1b[36mweb1\x1b[0m] task 3", + "ok: [\x1b[36mweb2\x1b[0m] task 4", + } + return m.View() +} + +// listFrame stages mox list: configured stopped/running sessions plus one +// unmanaged (◆) tmux-only session, with the recents footer and summary. +func listFrame(t *testing.T, now time.Time) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "list-*.ans") + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + infos := []session.SessionInfo{ + {Name: "db-primary", Managed: true, Hosts: []string{"db1", "db2"}}, + {Name: "monitoring", Managed: true, Hosts: []string{"mon1"}}, + {Name: "web-cluster", Running: true, Managed: true, Windows: 1, LastActivity: now, Hosts: []string{"web1", "web2", "web3"}}, + {Name: "scratch", Running: true, Managed: false, Windows: 1, LastActivity: now}, + } + recent := []history.Entry{ + {Name: "scratch", Action: history.ActionCreated, Time: now}, + {Name: "web-cluster", Action: history.ActionCreated, Time: now}, + } + renderList(f, infos, recent, now) + data, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// editorFrame stages the session editor on webfarm's field form. +func editorFrame(t *testing.T) string { + t.Helper() + st := testEditorState(t, screenshotConfigYAML) + m := newEditorModel(st, nil, nil, "webfarm") + m.width, m.height = 110, 30 + return m.View() +} + +// demoConfigSessions parses the demo config for the hub's managed lookup. +func demoConfigSessions(t *testing.T) map[string]*config.Session { + t.Helper() + st := testEditorState(t, screenshotConfigYAML) + return st.cfg.Sessions +} diff --git a/internal/cli/tuicolor_test.go b/internal/cli/tuicolor_test.go index b719213..00edcd9 100644 --- a/internal/cli/tuicolor_test.go +++ b/internal/cli/tuicolor_test.go @@ -25,7 +25,12 @@ func TestTUIColorProfile(t *testing.T) { t.Errorf("char device: profile = %v, want ANSI256", got) } - t.Setenv("NO_COLOR", "1") + t.Setenv("CLICOLOR_FORCE", "1") + if got := tuiColorProfile(&bytes.Buffer{}); got != termenv.ANSI256 { + t.Errorf("CLICOLOR_FORCE set: profile = %v, want ANSI256 even for a non-file writer", got) + } + + t.Setenv("NO_COLOR", "1") // NO_COLOR beats CLICOLOR_FORCE if got := tuiColorProfile(tty); got != termenv.Ascii { t.Errorf("NO_COLOR set: profile = %v, want Ascii", got) }