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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cli/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cli/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 0 additions & 5 deletions internal/cli/hub_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions internal/cli/hub_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions internal/cli/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions internal/cli/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bytes"
"os"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions internal/cli/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/picker_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions internal/cli/tuicolor.go
Original file line number Diff line number Diff line change
@@ -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))
}
32 changes: 32 additions & 0 deletions internal/cli/tuicolor_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading