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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<p align="center">
<img src="assets/screenshot-hub.png" alt="mox session hub" width="850">
Expand Down
Binary file modified assets/screenshot-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-hub.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshot-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed assets/screenshot-picker.png
Binary file not shown.
30 changes: 18 additions & 12 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session> 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)
Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/color.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions internal/cli/screenshot_gen_test.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 6 additions & 1 deletion internal/cli/tuicolor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading