Skip to content
Open
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
2 changes: 2 additions & 0 deletions cmd/ota/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func run(args []string, stdout, stderr *os.File) int {
pollSec = fs.Int("poll-interval", 0, "override Logs tail poll interval in seconds")
showVersion = fs.Bool("version", false, "print version and exit")
checkMode = fs.Bool("check", false, "probe Okta API once and print a plain-text diagnostic (no TUI)")
themeName = fs.String("theme", "", "theme theme name (dark / light / high-contrast, or a custom theme under ~/.config/ota/themes)")
)
if err := fs.Parse(args); err != nil {
// flag.ErrHelp is not an error — user asked for help.
Expand Down Expand Up @@ -103,6 +104,7 @@ func run(args []string, stdout, stderr *os.File) int {
TokenEnv: *tokenEnv,
Debug: *debugMode,
PollSec: *pollSec,
Theme: *themeName,
})

var rootModel tea.Model = wireModel
Expand Down
4 changes: 4 additions & 0 deletions cmd/ota/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type WireInput struct {
TokenEnv string
Debug bool
PollSec int
Theme string
}

// Wire is the single explicit dependency-assembly point. It loads config,
Expand Down Expand Up @@ -134,6 +135,9 @@ func Wire(ctx context.Context, in WireInput) (app.Model, config.Config, error) {
// override via cfg.OktaStatusEndpoint when self-hosted Okta
// orgs run a different statuspage (rare).
OktaStatusEndpoint: oktastatus.DefaultEndpoint,
// MW-10 — forward the CLI `--theme` flag. Empty string falls
// back to the default dark palette.
Theme: in.Theme,
})
return model, cfg, nil
}
Expand Down
48 changes: 40 additions & 8 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package app
import (
"log/slog"
"net/url"
"os"
"strings"
"time"

Expand Down Expand Up @@ -387,6 +386,12 @@ type Deps struct {
// outbound HTTP. main.go sets it to the public default.
OktaStatusEndpoint string

// Theme is the initial theme theme name (MW-10). Empty resolves
// to "dark". Loaded via shared.LoadTheme — falls back to the
// built-in dark palette (with a warning to the logger) if the
// name is unknown.
Theme string

// Optional initial state for tests / direct embedding.
InitialScreen Screen
}
Expand Down Expand Up @@ -522,6 +527,14 @@ type Model struct {
// New constructs the App Shell. The initial screen is materialized eagerly
// so Init() can return its first Cmd directly.
func New(deps Deps) Model {
// MW-10 — resolve the initial theme. Unknown names fall through
// to whatever init() left in place (dark), with a warning so
// operators see why their `--theme=nope` didn't take effect.
if theme := strings.TrimSpace(deps.Theme); theme != "" {
if _, err := shared.SetActiveTheme(theme); err != nil && deps.Logger != nil {
deps.Logger.Warn("theme load failed; falling back to dark", "theme", theme, "err", err.Error())
}
}
m := Model{
deps: deps,
active: deps.InitialScreen,
Expand Down Expand Up @@ -2309,14 +2322,12 @@ func tenantFromOrgURL(orgURL string) string {
return parsed.Host
}

// activeTokens picks the token set. NO_COLOR forces Monochrome.
// Otherwise an OTA_THEME env var override (when set to "dark" /
// "light" / "high-contrast" / "monochrome") wins; absent that,
// COLORFGBG-based detection picks Light on light terminals and
// falls back to Dark. Called per View() so a runtime toggle takes
// effect immediately. Issue #U12 v0.2.5.
// activeTokens picks the token set. MW-10 — thin passthrough to the
// shared active-theme state so `:theme <name>` / --theme <name> updates
// take effect on the next render. NO_COLOR still short-circuits
// inside shared.ActiveTokens.
func activeTokens() shared.Tokens {
return shared.PickTheme(shared.ResolveTheme(os.Getenv("OTA_THEME")))
return shared.ActiveTokens()
}

// Active reports the active resource screen (useful for tests / wiring).
Expand Down Expand Up @@ -2858,6 +2869,16 @@ func (m Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, toastCmdInfo("select a user before running :xray")
}
return m, openXRayCmd(user)
case paletteCmdTheme:
// MW-10 — swap the active theme. Empty arg → usage toast.
name := strings.TrimSpace(arg)
if name == "" {
return m, toastCmdInfo("usage: :theme <name> — try " + strings.Join(shared.BuiltinSkinNames(), " / "))
}
if _, err := shared.SetActiveTheme(name); err != nil {
return m, toastCmdError(ErrorMsg{Err: err})
}
return m, toastCmdInfo("theme: " + name)
}
return m, nil
case tea.KeyBackspace:
Expand Down Expand Up @@ -2934,6 +2955,9 @@ func paletteCommandPool() []string {
// MW-1 — `:xray` opens the user-scoped dependency tree.
"xray",
"apilog",
// MW-10 — `:theme` hot-swap. Autocomplete stops at the verb;
// the operator supplies the name after a space.
"theme",
"help", "quit",
}
}
Expand Down Expand Up @@ -3234,6 +3258,9 @@ const (
// Arg may carry "local" / "utc" to force a specific mode instead
// of toggling.
paletteCmdTimezone
// paletteCmdTheme hot-swaps the active theme (MW-10). The
// theme name lives in the `arg` return slot.
paletteCmdTheme
)

// UnmaskFieldMsg / MaskAllMsg are re-exported from the shared msgs
Expand Down Expand Up @@ -3287,6 +3314,11 @@ func resolvePaletteCommand(raw string) (kind paletteCmdKind, screen Screen, arg
return paletteCmdTimezone, 0, "local", true
case "tz utc", "timezone utc":
return paletteCmdTimezone, 0, "utc", true
case "theme":
// MW-10 — `:theme <name>` hot-swaps the active palette.
// Missing arg is a no-op-with-toast (handled by the caller
// via ok=true + empty arg → toast).
return paletteCmdTheme, 0, rest, true
}
// Direct policy-type routes (issue #165). The verb arg field
// carries the canonical PolicyType so the App Shell can build a
Expand Down
87 changes: 87 additions & 0 deletions internal/app/theme_palette_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package app_test

// MW-10 — palette + Deps.Theme smoke tests. Pins the contract that
// - `app.New(Deps{Theme: "light"})` swaps the shared active theme
// - `:theme light` typed through the palette overlay hot-swaps and
// leaves the app on its current screen (no navigation side-effect)
// - `:theme nope` errors — the previous active theme stays put

import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/tedilabs/ota/internal/app"
"github.com/tedilabs/ota/internal/tui/shared"
)

func Test_Deps_Skin_SetsActiveSkin(t *testing.T) {
// Restore the default afterwards so this test can't leak into
// other packages sharing the same shared.SetActiveTheme state.
t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") })

_ = app.New(app.Deps{
InitialScreen: app.ScreenUsers,
Theme: "light",
})
assert.Equal(t, "light", shared.ActiveSkinName(),
"app.New(Deps{Theme:\"light\"}) must call SetActiveTheme")
}

func Test_Palette_SkinCommand_HotSwaps(t *testing.T) {
t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") })

_, err := shared.SetActiveTheme("dark")
require.NoError(t, err)

m := app.New(app.Deps{InitialScreen: app.ScreenUsers})
got := drivePalette(t, m, "theme light")

assert.Equal(t, "users", app.ActiveScreenName(got),
":theme must not change the active screen")
assert.Equal(t, "light", shared.ActiveSkinName(),
":theme light must swap the active theme")
}

func Test_Palette_SkinCommand_UnknownName(t *testing.T) {
t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") })

_, err := shared.SetActiveTheme("dark")
require.NoError(t, err)

m := app.New(app.Deps{InitialScreen: app.ScreenUsers})
_ = drivePalette(t, m, "theme nope-does-not-exist")

assert.Equal(t, "dark", shared.ActiveSkinName(),
":theme nope must leave the previous active theme untouched")
}

// drivePalette opens the palette, types cmd, hits Enter, and drains
// the resulting Cmd chain. Returns the resulting Model.
func drivePalette(t *testing.T, m app.Model, cmd string) app.Model {
t.Helper()

updated, c := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")})
model := updated.(app.Model)
if c != nil {
if msg := c(); msg != nil {
updated, _ = model.Update(msg)
model = updated.(app.Model)
}
}
for _, r := range cmd {
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
model = updated.(app.Model)
}
updated, c = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(app.Model)
if c != nil {
if msg := c(); msg != nil {
updated, _ = model.Update(msg)
model = updated.(app.Model)
}
}
return model
}
4 changes: 4 additions & 0 deletions internal/testfx/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import (
// Call once from an init() at the top of each *_golden_test.go.
func PinTestEnvironment() {
_ = os.Setenv("NO_COLOR", "1")
// shared.MonochromeEnabled() caches NO_COLOR at package init; the
// shared package initialises before this init() runs, so refresh
// the cache to observe the value we just set.
shared.RefreshMonochromeEnabledForTest()
lipgloss.SetColorProfile(termenv.Ascii)
shared.SetTZ(shared.TZUTC)
}
5 changes: 1 addition & 4 deletions internal/tui/apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -721,10 +721,7 @@ func (m ListModel) observedColumnWidths() []int {
}

func activeTokens() shared.Tokens {
if shared.MonochromeEnabled() {
return shared.Monochrome()
}
return shared.Dark()
return shared.ActiveTokens()
}

// --- Detail ------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/authenticators/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ func fetchAuthsCmd(port domain.AuthenticatorsPort) tea.Cmd {

// activeTokens picks the active theme. Mirrors the App Shell helper.
func activeTokens() shared.Tokens {
return shared.PickTheme(shared.ResolveTheme(""))
return shared.ActiveTokens()
}

var _ tea.Model = ListModel{}
9 changes: 4 additions & 5 deletions internal/tui/groups/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -1219,12 +1219,11 @@ func strconvI(n int) string {
return string(buf[i:])
}

// activeTokens picks the right token set per NO_COLOR.
// activeTokens picks the right token set. MW-10 — routed through
// shared.ActiveTokens so `:theme` / --theme hot-swaps land on the next
// render. NO_COLOR still short-circuits inside ActiveTokens.
func activeTokens() shared.Tokens {
if shared.MonochromeEnabled() {
return shared.Monochrome()
}
return shared.Dark()
return shared.ActiveTokens()
}

// now returns the current time, preferring the injected clock.
Expand Down
5 changes: 1 addition & 4 deletions internal/tui/logs/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1292,10 +1292,7 @@ func visibleLenLog(s string) int {

// activeTokens picks the right token set per NO_COLOR.
func activeTokens() shared.Tokens {
if shared.MonochromeEnabled() {
return shared.Monochrome()
}
return shared.Dark()
return shared.ActiveTokens()
}

// now returns the injected clock or wall time.
Expand Down
3 changes: 2 additions & 1 deletion internal/tui/overlay/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ var paletteHints = []string{
":network-zones", ":authorization-servers", ":api-tokens", ":administrators",
":profile", ":search", ":filter", ":unmask", ":mask", ":raw",
":refresh", ":about", ":ratelimit", ":errors", ":healthcheck",
":apilog", ":tz", ":debug", ":help", ":quit",
":apilog", ":tz", ":debug", ":theme", ":help", ":quit",
}

// paletteHintsStripped is paletteHints with the leading ":" removed —
Expand Down Expand Up @@ -475,6 +475,7 @@ func paletteHelpEntries() []helpEntry {
{":tz", "toggle timezone (Local ↔ UTC)"},
{":unmask <fld>", "reveal a masked field"},
{":mask", "re-mask PII fields"},
{":theme <name>", "swap theme theme (dark / light / high-contrast)"},
{":help", "this overlay"},
{":quit", "quit ota"},
}
Expand Down
Loading