From 2550fa64465c609f40d3461c40d817cb24f51101 Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 23 Jul 2026 10:27:42 -0400 Subject: [PATCH] fix: TUIs follow mox's own color policy; origin is a glyph shape The hub, editor, and add wizard render through lipgloss, whose default termenv sniffing returns no-color for TERM values it doesn't recognize, silently stripping every style while mox list (own TTY + NO_COLOR policy) stays colored on the same terminal. The TUIs now pin lipgloss's profile from the same policy mox list uses. With colors reliable, the managed/unmanaged distinction moves to shape plus color, shared across hub and mox list: yellow diamond for a session running outside the config, green dot for a running configured session, dim circle for stopped. The hub's row-level "tmux" text tag is replaced by the glyph; the preview title keeps "tmux only" as the decoder. --- CHANGELOG.md | 17 +++++++++++++++++ docs/commands.md | 9 +++++---- go.mod | 2 +- internal/cli/add.go | 1 + internal/cli/edit.go | 1 + internal/cli/hub_ui.go | 5 ----- internal/cli/hub_ui_test.go | 12 ++++++------ internal/cli/list.go | 7 ++++--- internal/cli/list_test.go | 28 ++++++++++++++++++++++++++++ internal/cli/picker.go | 1 + internal/cli/picker_ui.go | 5 ++++- internal/cli/tuicolor.go | 32 ++++++++++++++++++++++++++++++++ internal/cli/tuicolor_test.go | 32 ++++++++++++++++++++++++++++++++ 13 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 internal/cli/tuicolor.go create mode 100644 internal/cli/tuicolor_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb2a4b..732ed81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The full-screen TUIs (hub, editor, add wizard) could render entirely + colorless on terminals whose TERM value lipgloss's environment sniffing + doesn't recognize, while `mox list` colored fine on the same terminal. + The TUIs now follow the same color policy as `mox list` (a terminal, + and NO_COLOR unset) instead of delegating the decision to TERM + sniffing. + +### Changed + +- Sessions running outside the config are marked by glyph shape, shared + between the hub and `mox list`: `◆` (yellow) for tmux-only, `●` (green) + for a running configured session, `○` for stopped. The hub's row-level + `tmux` text tag from 0.6.0 is replaced by the glyph; the preview title + keeps `tmux only`. + ## [0.6.0] — 2026-07-23 ### Added diff --git a/docs/commands.md b/docs/commands.md index 88169dd..7afb331 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -50,10 +50,11 @@ 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. 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 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. diff --git a/go.mod b/go.mod index a8e042f..1c8dfa1 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.6 + github.com/muesli/termenv v0.16.0 github.com/sahilm/fuzzy v0.1.3 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 @@ -30,7 +31,6 @@ require ( github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/internal/cli/add.go b/internal/cli/add.go index 2cbe89f..ce8f5a0 100644 --- a/internal/cli/add.go +++ b/internal/cli/add.go @@ -58,6 +58,7 @@ func runAdd(cmd *cobra.Command, args []string) error { prefill = args[0] } + applyTUIColorPolicy() final, err := tea.NewProgram(newAddModel(cfg, clusters, prefill)).Run() if err != nil { return err diff --git a/internal/cli/edit.go b/internal/cli/edit.go index adea889..7bee322 100644 --- a/internal/cli/edit.go +++ b/internal/cli/edit.go @@ -150,6 +150,7 @@ func runEditorTUI(cmd *cobra.Command, st *editorState, initial string) error { } } + applyTUIColorPolicy() model := newEditorModel(st, clusters, running, initial) model.startSession = start _, err := tea.NewProgram(model, tea.WithAltScreen()).Run() diff --git a/internal/cli/hub_ui.go b/internal/cli/hub_ui.go index 949624c..34c0609 100644 --- a/internal/cli/hub_ui.go +++ b/internal/cli/hub_ui.go @@ -677,11 +677,6 @@ 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 a37d53e..c1c3fef 100644 --- a/internal/cli/hub_ui_test.go +++ b/internal/cli/hub_ui_test.go @@ -362,8 +362,8 @@ func TestHubImportKey(t *testing.T) { } // TestHubUnmanagedMarker pins that unmanaged sessions carry a visible -// "tmux" origin marker in the list row and preview title, not just a -// different name color. +// origin marker — the ◆ glyph in the list row and "tmux only" in the +// preview title — not just a different name color. func TestHubUnmanagedMarker(t *testing.T) { m := testHubModel(t, nil) rows := m.listLines(36, 20) @@ -377,12 +377,12 @@ func TestHubUnmanagedMarker(t *testing.T) { 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) + if !strings.Contains(scratchRow, "◆") { + t.Fatalf("scratch row has no ◆ origin glyph: %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) + if strings.Contains(r, "webfarm") && strings.Contains(r, "◆") { + t.Fatalf("managed webfarm row carries the ◆ glyph: %q", r) } } // preview title marks the origin too diff --git a/internal/cli/list.go b/internal/cli/list.go index 1f0df04..08a298e 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -106,8 +106,9 @@ func splitByManaged(infos []session.SessionInfo) (managed, unmanaged []session.S } // nameCell prefixes the session name with a colored status glyph when color is -// enabled (● running, ○ stopped; yellow for unmanaged). Under NO_COLOR or a -// non-TTY the glyph is omitted entirely — the STATE column carries the meaning. +// enabled (● running, ◆ running tmux-only, ○ stopped) — the same vocabulary +// as the hub's status dots. Under NO_COLOR or a non-TTY the glyph is omitted +// entirely; the STATE and ORIGIN columns carry the meaning. func nameCell(out io.Writer, s session.SessionInfo) string { if !useColor(out) { return s.Name @@ -116,7 +117,7 @@ func nameCell(out io.Writer, s session.SessionInfo) string { if s.Running { glyph, code = "●", ansiGreen if !s.Managed { - code = ansiYellow + glyph, code = "◆", ansiYellow } } return colorize(out, code, glyph) + " " + s.Name diff --git a/internal/cli/list_test.go b/internal/cli/list_test.go index 1bee974..92d6f8d 100644 --- a/internal/cli/list_test.go +++ b/internal/cli/list_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "os" "strings" "testing" "time" @@ -92,6 +93,33 @@ func TestRenderList_Empty(t *testing.T) { } } +// TestNameCellGlyphs pins the colored status glyphs: ● for a running mox +// session, ◆ for a running tmux-only session, ○ stopped. /dev/null is a +// char device, so useColor treats it as a color-capable terminal. +func TestNameCellGlyphs(t *testing.T) { + tty, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tty.Close() }() + + cases := []struct { + info session.SessionInfo + glyph string + code string + }{ + {session.SessionInfo{Name: "m", Running: true, Managed: true}, "●", ansiGreen}, + {session.SessionInfo{Name: "u", Running: true, Managed: false}, "◆", ansiYellow}, + {session.SessionInfo{Name: "s", Managed: true}, "○", ansiDim}, + } + for _, c := range cases { + got := nameCell(tty, c.info) + if !strings.Contains(got, c.code+c.glyph) { + t.Errorf("nameCell(%+v) = %q, want glyph %q in color %q", c.info, got, c.glyph, c.code) + } + } +} + func TestRenderList_HostsTruncated(t *testing.T) { long := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf"} infos := []session.SessionInfo{ diff --git a/internal/cli/picker.go b/internal/cli/picker.go index 5b069ad..94bfedb 100644 --- a/internal/cli/picker.go +++ b/internal/cli/picker.go @@ -217,6 +217,7 @@ type hubNote struct { // 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, note hubNote) (string, hubAction, error) { + applyTUIColorPolicy() capture, windows := hubTmuxFuncs(tmux.NewClient()) model := newHubModel(ctx, mgr, order, capture, windows, candidates, sessions, time.Now()) diff --git a/internal/cli/picker_ui.go b/internal/cli/picker_ui.go index ebb8904..4643cf1 100644 --- a/internal/cli/picker_ui.go +++ b/internal/cli/picker_ui.go @@ -39,10 +39,13 @@ func hints(pairs ...string) string { return b.String() } +// statusDot is the per-session status glyph: shape carries the origin +// (● in the config, ◆ tmux only), color carries the state — so the +// distinction survives without color too. func statusDot(c session.SessionInfo) string { switch { case c.Running && !c.Managed: - return pkForeign.Render("●") + return pkForeign.Render("◆") case c.Running: return pkRunning.Render("●") default: diff --git a/internal/cli/tuicolor.go b/internal/cli/tuicolor.go new file mode 100644 index 0000000..a7e770d --- /dev/null +++ b/internal/cli/tuicolor.go @@ -0,0 +1,32 @@ +package cli + +// The full-screen TUIs render through lipgloss, which by default asks +// termenv to sniff the environment for color support. That sniffing +// returns no-color for any TERM it doesn't recognize, silently stripping +// every style while `mox list` (which colors on its own TTY + NO_COLOR +// policy) stays colored on the same terminal. mox makes the color +// decision once, with the same policy, for both. + +import ( + "io" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// tuiColorProfile maps mox's color policy onto a lipgloss/termenv profile. +// Every style in the TUIs uses the base 16-color palette, so ANSI256 is +// never more than the terminal already proved it can render for mox list. +func tuiColorProfile(w io.Writer) termenv.Profile { + if useColor(w) { + return termenv.ANSI256 + } + return termenv.Ascii +} + +// applyTUIColorPolicy pins lipgloss's global profile before a TUI starts. +// A terminal that can host the alt-screen UI can render SGR colors. +func applyTUIColorPolicy() { + lipgloss.SetColorProfile(tuiColorProfile(os.Stdout)) +} diff --git a/internal/cli/tuicolor_test.go b/internal/cli/tuicolor_test.go new file mode 100644 index 0000000..b719213 --- /dev/null +++ b/internal/cli/tuicolor_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "bytes" + "os" + "testing" + + "github.com/muesli/termenv" +) + +// TestTUIColorProfile pins that the TUIs follow mox's own color policy +// (useColor: TTY + NO_COLOR) instead of termenv's TERM sniffing, which +// strips every style on TERM values it doesn't recognize. +func TestTUIColorProfile(t *testing.T) { + if got := tuiColorProfile(&bytes.Buffer{}); got != termenv.Ascii { + t.Errorf("non-file writer: profile = %v, want Ascii", got) + } + + tty, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) // char device: useColor says yes + if err != nil { + t.Fatal(err) + } + defer func() { _ = tty.Close() }() + if got := tuiColorProfile(tty); got != termenv.ANSI256 { + t.Errorf("char device: profile = %v, want ANSI256", got) + } + + t.Setenv("NO_COLOR", "1") + if got := tuiColorProfile(tty); got != termenv.Ascii { + t.Errorf("NO_COLOR set: profile = %v, want Ascii", got) + } +}