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