diff --git a/.env.example b/.env.example index 59e2f37f..567c2ed8 100644 --- a/.env.example +++ b/.env.example @@ -40,8 +40,8 @@ # Allowed: true or false. # DRIGA_NO_SERVICE_HINT= -# ui.theme: string; default=auto; optional. Palette for the whole terminal UI. -# Allowed: auto, mocha or latte. +# ui.theme: string; default=system; optional. Palette for the whole terminal UI: light, dark, or system to follow the terminal. +# Allowed: system, light or dark. # DRIGA_UI_THEME= # adapters.claude.enabled: bool; default=true; optional. Enable Claude session discovery. diff --git a/docs/machine-health.md b/docs/machine-health.md index 2d844c18..c560c3fd 100644 --- a/docs/machine-health.md +++ b/docs/machine-health.md @@ -16,7 +16,7 @@ for retained history and uncapped evidence lists. MCP additionally retains its standard envelope `limit`/`truncated` contract. These are observed processes, not an exhaustive accounting of kernel memory or unreadable tasks. -The TUI Machine tab (`5`, or `driga top --tab machine`) expands an app with +The TUI Machines tab (`2`, or `driga top --tab machines`) expands an app with Right and collapses it with Left. Enter shows process details, including memory measures and attribution proofs. The web `/machines` page uses keyboard-accessible native disclosure controls. Both preserve the existing compact table/pane styles. @@ -276,7 +276,7 @@ On macOS, Driga.app sends native UserNotifications with Driga's name and icon. A local single-item notification click runs `driga inbox jump ` through its delegate while the collector is running. Unbundled builds have no macOS alerts. The stored destination is `machine`. On macOS, the click opens Terminal and runs -the installed executable's `top --tab machine`; on Linux, a manual jump uses +the installed executable's `top --tab machines`; on Linux, a manual jump uses `x-terminal-emulator -e`. This avoids requiring a running web server. As with existing Linux alerts, notify-send itself has no click action. A click does not acknowledge the item. No notification is sent by tests; their runners/notifiers diff --git a/docs/usage.md b/docs/usage.md index 6b39fe3b..02bbb5af 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -28,7 +28,7 @@ with activity within the last 24 hours, alongside recently ended sessions. Collection views merge paired machines by default; `machines show` defaults to local. `--machine all|local|` selects a scope; `--project .` resolves against local repositories only. View flags are scoped to commands that consume them; see the [command reference](#command-reference). -`--theme=auto|mocha|latte` belongs to `top`; `NO_COLOR` is also honored. View JSON has schema 2, +`--theme=system|light|dark` belongs to `top`; `NO_COLOR` is also honored. View JSON has schema 2, an observation time, collection mode, source freshness, coverage gaps, and data. `doctor --json` returns diagnostic checks rather than a view envelope. Use `driga --help` for command-specific flags. @@ -120,7 +120,7 @@ background collector posts notifications on macOS, so run the service Clicking a local item's notification runs `driga inbox jump ` without acknowledging it. Jumps select a proven tmux pane, focus a proven Terminal/iTerm window with `inbox.jump_terminal = true`, or print a resume command. Machine -warnings open Terminal on `driga top --tab machine`. Batch summaries and remote +warnings open Terminal on `driga top --tab machines`. Batch summaries and remote alerts have no click action. The collector keeps the native notification delegate and main run loop alive while it runs. Re-sending an item replaces its existing notification. Linux uses notify-send; Windows uses native WinRT toasts. @@ -155,7 +155,7 @@ Defaults and their purpose: | Setting | Default | Purpose | | --- | --- | --- | -| `ui.theme` | `auto` | Whole TUI palette: `auto`, `mocha`, `latte`; override with `--theme`. | +| `ui.theme` | `system` | Whole TUI palette: `system`, `light`, `dark`; override with `--theme`. | | `roots` | none | Discover from sessions; add scanning roots only when wanted. | | `root_depth` | `3` | Bound discovery below configured roots. | | `exclude` | `**/node_modules`, `**/.tmp/**` | Avoid dependency and temporary directories. | @@ -839,18 +839,24 @@ process ancestry, reparenting, activity, and running app ownership also matter. ### Themes -Set `[ui] theme = 'auto'` (the default), `'mocha'`, or `'latte'` in TOML. -For example: +Themes are **light**, **dark** and **system** (the default), set with +`[ui] theme` in TOML: ```toml [ui] -theme = 'latte' +theme = 'light' ``` -`driga top --theme mocha` overrides the config for the whole TUI. Auto follows -terminal background detection; `--no-color` keeps the layout without ANSI styling. -The web console's Mocha, Latte, and System choices use the same Catppuccin tokens, -including assistant Markdown. Web System follows the browser's color preference. +`driga top --theme dark` overrides the config for the whole TUI. System follows +the terminal's background in the TUI and the browser's colour preference on the +web; `--no-color` keeps the layout without ANSI styling. + +Light and dark are the [Catppuccin](https://catppuccin.com/palette/) Latte and +Mocha palettes, taken from the official `github.com/catppuccin/go` package and +shared by the TUI, the web console and assistant Markdown. Dark uses the +official colours unchanged. In light, text in Latte's yellow, green, blue, +peach, teal and sky is darkened, keeping the hue, just enough to reach 4.5:1 +contrast on the background; fills and bars keep the official colours. ### Answer Claude permission prompts @@ -912,7 +918,7 @@ contains related sessions; selecting a session never selects the whole family. | `cleanup list` | Local candidates; `--kind=worktree\|scratch\|history`, `--sizes`, `--why`, `--json`. History is report-only. | | `cleanup remove ` | Verify and remove one candidate; `--dry-run`, `--sizes`, `--include-ignored`, `--yes/-y`, `--json`. | | `cleanup remove --all --kind worktree` or `--kind scratch` | Remove ready candidates of the selected kind; report completed/skipped/failed counts. `--include-ignored` requires one explicit ID. | -| `top` | Interactive terminal dashboard; `--tab=home\|agents\|projects\|inbox\|orphans\|machine\|cleanup\|flow\|chat`, `--theme=auto\|mocha\|latte`, `--machine`, `--project`, `--since`, `--snapshot`, `--no-color`. | +| `top` | Interactive terminal dashboard; `--tab=flow\|projects\|machines`, `--theme=system\|light\|dark`, `--machine`, `--project`, `--since`, `--snapshot`, `--no-color`. | | `web` | Serve the interactive browser dashboard on loopback; `--listen`, `--open`, `--machine`, `--project`, `--since`, `--snapshot`. | | `ask ...` | One-shot Chat question; `--machine`, `--project`, `--json`. | | `mcp` | Serve read-only MCP tools over stdio; `--snapshot` forces one-shot collection for read tools without history. | diff --git a/go.mod b/go.mod index f555f762..8723d58b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/alecthomas/kong v1.16.1 github.com/aymanbagabas/go-udiff v0.4.1 github.com/bmatcuk/doublestar/v4 v4.10.3 + github.com/catppuccin/go v0.3.0 github.com/charmbracelet/x/ansi v0.11.9 github.com/charmbracelet/x/term v0.2.2 github.com/ebitengine/purego v0.11.1 diff --git a/go.sum b/go.sum index 7fe2db39..8ca6bea1 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bmatcuk/doublestar/v4 v4.10.3 h1:1UIv1+fqH3++ViVkpnaPTB+Pi1UHycnPFAq14zgK+gI= github.com/bmatcuk/doublestar/v4 v4.10.3/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw= diff --git a/internal/attention/jump.go b/internal/attention/jump.go index 7a3232d1..5e0747d9 100644 --- a/internal/attention/jump.go +++ b/internal/attention/jump.go @@ -150,10 +150,10 @@ func JumpCommands(j *model.InboxJump, opts JumpOptions) ([][]string, error) { } if goos == "darwin" { const script = "on run argv\ntell application \"Terminal\"\nactivate\ndo script (item 1 of argv)\nend tell\nend run" - return [][]string{{"osascript", "-e", script, Quote(bin) + " top --tab machine"}}, nil + return [][]string{{"osascript", "-e", script, Quote(bin) + " top --tab machines"}}, nil } if goos == "linux" { - return [][]string{{"x-terminal-emulator", "-e", bin, "top", "--tab", "machine"}}, nil + return [][]string{{"x-terminal-emulator", "-e", bin, "top", "--tab", "machines"}}, nil } return nil, fmt.Errorf("machine jump unsupported on %s", goos) case "tmux": diff --git a/internal/attention/machine_health_test.go b/internal/attention/machine_health_test.go index 4fab018f..cf5ba453 100644 --- a/internal/attention/machine_health_test.go +++ b/internal/attention/machine_health_test.go @@ -34,11 +34,11 @@ func TestMachineAlertRepeatAndBatch(t *testing.T) { func TestMachineJumpPlansSafeTerminal(t *testing.T) { bin := "/opt/my app's/driga" cmds, err := JumpCommands(&model.InboxJump{Kind: "machine"}, JumpOptions{OS: "darwin", DrigaPath: bin}) - if err != nil || len(cmds) != 1 || cmds[0][0] != "osascript" || cmds[0][3] != Quote(bin)+" top --tab machine" { + if err != nil || len(cmds) != 1 || cmds[0][0] != "osascript" || cmds[0][3] != Quote(bin)+" top --tab machines" { t.Fatalf("%q %v", cmds, err) } cmds, err = JumpCommands(&model.InboxJump{Kind: "machine"}, JumpOptions{OS: "linux", DrigaPath: bin}) - if err != nil || !reflect.DeepEqual(cmds, [][]string{{"x-terminal-emulator", "-e", bin, "top", "--tab", "machine"}}) { + if err != nil || !reflect.DeepEqual(cmds, [][]string{{"x-terminal-emulator", "-e", bin, "top", "--tab", "machines"}}) { t.Fatalf("%q %v", cmds, err) } } diff --git a/internal/cli/command_help_test.go b/internal/cli/command_help_test.go index 78750233..1e447ab6 100644 --- a/internal/cli/command_help_test.go +++ b/internal/cli/command_help_test.go @@ -19,7 +19,7 @@ func TestHelpOnlyAdvertisesSupportedJSON(t *testing.T) { if code != 0 || strings.Contains(out.String(), "--json") != tc.json { t.Fatalf("%v: %d %s %s", tc.args, code, &out, &diagnostic) } - if tc.args[0] == "top" && !strings.Contains(strings.Join(strings.Fields(out.String()), " "), "Initial tab: home, agents, projects, inbox, orphans, machine, cleanup, flow, or chat.") { + if tc.args[0] == "top" && !strings.Contains(strings.Join(strings.Fields(out.String()), " "), "Initial tab: flow, projects or machines.") { t.Fatal(out.String()) } } diff --git a/internal/cli/command_tree_test.go b/internal/cli/command_tree_test.go index ee749746..046635af 100644 --- a/internal/cli/command_tree_test.go +++ b/internal/cli/command_tree_test.go @@ -31,11 +31,16 @@ func TestCommandTree(t *testing.T) { } }) } - for _, tab := range []string{"home", "agents", "projects", "inbox", "orphans", "machine", "cleanup", "flow", "chat"} { + for _, tab := range []string{"flow", "projects", "machines"} { if _, _, err := parseCommand(t, "top", "--tab", tab); err != nil { t.Fatal(tab, err) } } + for _, tab := range []string{"home", "agents", "inbox", "machine", "chat"} { + if _, _, err := parseCommand(t, "top", "--tab", tab); err == nil { + t.Fatal("removed tab accepted:", tab) + } + } } func TestRemovedSpellingsAndUnsupportedFlags(t *testing.T) { paths := []string{"kill item", "agent 12", "where app", "summarize app", "jump item", "serve", "machine show", "machine reset-id", "fleet add host", "fleet remove peer", "fleet revoke viewer", "fleet peers", "fleet viewers", "fleet peers peer", "fleet viewers viewer", "fleet enroll --allow id", "fleet enroll --ack", "fleet peers add host --id id", "hooks install --agent claude", "hooks status --yes", "hooks status --dry-run", "hooks status --traces", "service start -f", "agents kill 12 --approved", "agents jump 12 --machine remote", "machines show --machine local", "projects summarize app --project other", "cleanup item", "cleanup remove item --why", "cleanup list --yes", "version --machine local", "doctor --snapshot", "mcp --json", "web --json", "agents --theme mocha", "agents show 12 --since 1h", "flow --watch 2s --theme mocha", "projects show app --project other", "projects show app --since 1h", "inbox ack item --watch 2s"} diff --git a/internal/cli/jump_test.go b/internal/cli/jump_test.go index c0e5276f..6b004db4 100644 --- a/internal/cli/jump_test.go +++ b/internal/cli/jump_test.go @@ -174,7 +174,7 @@ func TestMachineNotificationClickUsesInstalledExecutable(t *testing.T) { readEnv(t) t.Setenv("PATH", t.TempDir()) st := readDB(t, func(tx *store.Tx) error { - return tx.UpsertInboxItem(model.InboxItem{ID: "low_disk:1", Kind: "low_disk", TS: time.Now(), Jump: &model.InboxJump{Kind: "machine", Command: "driga top --tab machine"}}) + return tx.UpsertInboxItem(model.InboxItem{ID: "low_disk:1", Kind: "low_disk", TS: time.Now(), Jump: &model.InboxJump{Kind: "machine", Command: "driga top --tab machines"}}) }) bin, err := os.Executable() if err != nil { @@ -183,7 +183,7 @@ func TestMachineNotificationClickUsesInstalledExecutable(t *testing.T) { var calls int err = (JumpCmd{ID: "low_disk:1"}).run(&Globals{Stdout: &bytes.Buffer{}}, jumpRunner(func(_ context.Context, name string, args ...string) ([]byte, error) { calls++ - if name != "osascript" || len(args) != 3 || args[2] != attention.Quote(bin)+" top --tab machine" { + if name != "osascript" || len(args) != 3 || args[2] != attention.Quote(bin)+" top --tab machines" { t.Fatalf("%s %q", name, args) } return nil, nil diff --git a/internal/cli/options.go b/internal/cli/options.go index 52c264e8..8a899235 100644 --- a/internal/cli/options.go +++ b/internal/cli/options.go @@ -140,7 +140,7 @@ type TopOptions struct { Since string `help:"Only show items since a duration ago or yesterday."` Snapshot bool `help:"Force one-shot snapshot collection without history."` NoColor bool `name:"no-color" help:"Disable colors."` - Theme *string `enum:"auto,mocha,latte" help:"TUI theme (overrides ui.theme)."` + Theme *string `enum:"system,light,dark" help:"TUI theme: system, light or dark (overrides ui.theme)."` } func (o *TopOptions) AfterApply(g *Globals) error { diff --git a/internal/cli/top.go b/internal/cli/top.go index 71f568cd..31d9183c 100644 --- a/internal/cli/top.go +++ b/internal/cli/top.go @@ -18,7 +18,7 @@ import ( // TopCmd opens the interactive viewer using daemon data or live snapshots. type TopCmd struct { TopOptions - Tab string `enum:"home,agents,projects,inbox,orphans,machine,cleanup,flow,chat" default:"home" help:"Initial tab: home, agents, projects, inbox, orphans, machine, cleanup, flow, or chat."` + Tab string `enum:"flow,projects,machines" default:"flow" help:"Initial tab: flow, projects or machines."` } // Run keeps the viewer and its optional collector within the same lifetime. diff --git a/internal/collector/cleanup.go b/internal/collector/cleanup.go index b26c43e1..dde035ec 100644 --- a/internal/collector/cleanup.go +++ b/internal/collector/cleanup.go @@ -48,7 +48,7 @@ func lowDiskInbox(tx *store.Tx, cfg config.MachineConfig, s model.MachineSample, for _, w := range worktrees[:min(3, len(worktrees))] { names = append(names, w.Path) } - summary := fmt.Sprintf("%s free, below %s. Inspect known cleanup candidates with driga cleanup list --sizes; driga top --tab machine shows the largest apps.", attention.DecimalBytesFloor(s.DiskFree), attention.DecimalBytes(threshold)) + summary := fmt.Sprintf("%s free, below %s. Inspect known cleanup candidates with driga cleanup list --sizes; driga top --tab machines shows the largest apps.", attention.DecimalBytesFloor(s.DiskFree), attention.DecimalBytes(threshold)) if len(names) > 0 { summary += " Oldest stored worktrees to inspect: " + strings.Join(names, ", ") } diff --git a/internal/collector/machine_health.go b/internal/collector/machine_health.go index 4e0aa39b..8a38a729 100644 --- a/internal/collector/machine_health.go +++ b/internal/collector/machine_health.go @@ -13,7 +13,7 @@ import ( ) func machineJump() *model.InboxJump { - return &model.InboxJump{Kind: "machine", Command: "driga top --tab machine"} + return &model.InboxJump{Kind: "machine", Command: "driga top --tab machines"} } // Only successful, distinct OS readings count. Unknown readings and source diff --git a/internal/config/settings.go b/internal/config/settings.go index ae384bca..c8eb5df1 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -39,7 +39,7 @@ var settings = []Setting{ {"capture_text", "CaptureText", "string", "titles", "Local text capture policy.", "titles or snippets"}, {"open_cmd", "OpenCmd", "string", "", "Editor command; {cwd} is substituted.", "empty = auto-detect"}, {"no_service_hint", "NoServiceHint", "bool", false, "Suppress optional service hints.", "true or false"}, - {"ui.theme", "UI.Theme", "string", "auto", "Palette for the whole terminal UI.", "auto, mocha or latte"}, + {"ui.theme", "UI.Theme", "string", "system", "Palette for the whole terminal UI: light, dark, or system to follow the terminal.", "system, light or dark"}, {"adapters.claude.enabled", "", "bool", true, "Enable Claude session discovery.", "true or false"}, {"adapters.codex.enabled", "", "bool", true, "Enable Codex session discovery.", "true or false"}, {"adapters.gemini.enabled", "", "bool", true, "Enable Gemini session discovery.", "true or false"}, @@ -174,7 +174,7 @@ func (s Setting) assign(c *Config, raw any) error { } switch s.Key { case "ui.theme": - if text != "auto" && text != "mocha" && text != "latte" { + if text != "system" && text != "light" && text != "dark" { return fmt.Errorf("allowed values: %s", s.Range) } case "motion": diff --git a/internal/config/theme_test.go b/internal/config/theme_test.go index dbfa25e9..572317c0 100644 --- a/internal/config/theme_test.go +++ b/internal/config/theme_test.go @@ -7,30 +7,30 @@ import ( ) func TestUITheme(t *testing.T) { - if Default().UI.Theme != "auto" { + if Default().UI.Theme != "system" { t.Fatal("wrong default") } path := filepath.Join(t.TempDir(), "config.toml") - for _, value := range []string{"'auto'", "'mocha'", "'latte'", "'dark'", "''", "true", "1"} { + for _, value := range []string{"'system'", "'light'", "'dark'", "'auto'", "'mocha'", "''", "true", "1"} { if err := os.WriteFile(path, []byte("[ui]\ntheme = "+value), 0600); err != nil { t.Fatal(err) } c, err := Load(path) - valid := value == "'auto'" || value == "'mocha'" || value == "'latte'" + valid := value == "'system'" || value == "'light'" || value == "'dark'" if (err == nil) != valid { t.Fatalf("%s => %+v %v", value, c, err) } } - if err := os.WriteFile(path, []byte("[ui]\ntheme = 'latte'"), 0600); err != nil { + if err := os.WriteFile(path, []byte("[ui]\ntheme = 'light'"), 0600); err != nil { t.Fatal(err) } - t.Setenv("DRIGA_UI_THEME", "mocha") + t.Setenv("DRIGA_UI_THEME", "dark") c, err := Load(path) - if err != nil || c.UI.Theme != "mocha" { + if err != nil || c.UI.Theme != "dark" { t.Fatal(c, err) } - c, err = LoadWithOverrides(path, map[string]any{"ui.theme": "auto"}) - if err != nil || c.UI.Theme != "auto" { + c, err = LoadWithOverrides(path, map[string]any{"ui.theme": "system"}) + if err != nil || c.UI.Theme != "system" { t.Fatal(c, err) } if _, err = LoadWithOverrides(path, map[string]any{"ui.theme": "bad"}); err == nil { diff --git a/internal/tui/app.go b/internal/tui/app.go index 673f8bb0..6190bf7a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -202,14 +202,11 @@ func New(src Source, opts Options) tea.Model { input.Placeholder = "filter" input.SetStyles(textinput.Styles{}) m := &app{chatInput: newChatInput(), chatSpinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)), chatEphemeral: opts.ChatEphemeral, tab: flowTab, machineScope: opts.Machine, src: src, opts: opts, width: 80, height: 24, expanded: map[string]bool{}, filter: input, table: table.New(table.WithFocused(true)), viewport: viewport.New()} - for i, name := range []string{"agents", "projects", "inbox", "orphans", "machine", "cleanup", "home", "flow", "chat"} { - if opts.InitialTab == name { - m.tab = i - break - } - } - if m.tab == 6 { - m.tab = flowTab + switch opts.InitialTab { + case "projects": + m.tab = 1 + case "machines": + m.tab = 4 } m.rebuild() return m diff --git a/internal/tui/board_test.go b/internal/tui/board_test.go index 09d1e620..e8546b7e 100644 --- a/internal/tui/board_test.go +++ b/internal/tui/board_test.go @@ -155,12 +155,12 @@ func TestBoardGoldenFrames(t *testing.T) { } func TestBoardThemesAndMotionKeepGeometry(t *testing.T) { - defer func() { _ = view.SetTheme("auto") }() + defer func() { _ = view.SetTheme("system") }() for _, width := range []int{80, 100, 140, 200} { m := boardApp(t, "busy") m.width = width plain := m.flowView() - for _, theme := range []string{"latte", "mocha"} { + for _, theme := range []string{"light", "dark"} { if err := view.SetTheme(theme); err != nil { t.Fatal(err) } diff --git a/internal/tui/chat_markdown_test.go b/internal/tui/chat_markdown_test.go index 326b3a87..d186bfcf 100644 --- a/internal/tui/chat_markdown_test.go +++ b/internal/tui/chat_markdown_test.go @@ -78,7 +78,7 @@ func TestChatMarkdownElements(t *testing.T) { t.Fatal(err) } input = bytes.ReplaceAll(input, []byte("\r\n"), []byte("\n")) - defer view.SetTheme("auto") + defer view.SetTheme("system") for _, width := range []int{120, 60, 24} { t.Run(fmt.Sprint(width), func(t *testing.T) { plain := chatMarkdown(string(input), width, true) @@ -97,7 +97,7 @@ func TestChatMarkdownElements(t *testing.T) { t.Fatalf("Markdown layout differs from %s:\n%s", path, plain) } var mocha string - for _, theme := range []string{"mocha", "latte"} { + for _, theme := range []string{"dark", "light"} { _ = view.SetTheme(theme) colored := chatMarkdown(string(input), width, false) if ansi.Strip(colored) != plain { @@ -108,7 +108,7 @@ func TestChatMarkdownElements(t *testing.T) { t.Fatalf("overflow: %q", line) } } - if theme == "mocha" { + if theme == "dark" { mocha = colored } else if colored == mocha { t.Fatal("theme colors did not switch") diff --git a/internal/tui/machine_golden_test.go b/internal/tui/machine_golden_test.go index b91c3b7c..5d5129e5 100644 --- a/internal/tui/machine_golden_test.go +++ b/internal/tui/machine_golden_test.go @@ -49,7 +49,7 @@ func TestMachineGoldenFrames(t *testing.T) { src.snap.Machine.MCPServers = derive.MCPServers(ps, nil, at) derive.ApplyMCPTrends(&src.snap.Machine.MCPServers, trends.Trends()) src.snap.Summary.Sample = src.snap.Machine.Sample - m := setup(t, src, Options{InitialTab: "machine"}).(*app) + m := setup(t, src, Options{InitialTab: "machines"}).(*app) if m.tab != 4 { t.Fatal("initial tab ignored") } @@ -109,7 +109,7 @@ func TestMachineGoldenFrames(t *testing.T) { func TestMachineWarningJumpStaysInCurrentTUI(t *testing.T) { m := setup(t, fixture(), Options{Exec: func([]string) error { t.Fatal("launched terminal"); return nil }}).(*app) m.tab = 2 - item := model.InboxItem{ID: "low_disk:1", Kind: "low_disk", Jump: &model.InboxJump{Kind: "machine", Command: "driga top --tab machine"}} + item := model.InboxItem{ID: "low_disk:1", Kind: "low_disk", Jump: &model.InboxJump{Kind: "machine", Command: "driga top --tab machines"}} if cmd := m.jumpInbox(tabRow{inbox: &item}); cmd != nil { t.Fatal("external command for internal jump") } @@ -121,7 +121,7 @@ func TestMachineWarningJumpStaysInCurrentTUI(t *testing.T) { func TestMachineViewOlderPeerFields(t *testing.T) { src := fixture() src.snap.Machine = view.MachineView{Machines: []view.MachineView{{MachineID: "peer", TopMemory: []view.MachineApp{{ID: "legacy", Name: "Legacy", Memory: 2 << 30}}}}} - m := setup(t, src, Options{InitialTab: "machine"}).(*app) + m := setup(t, src, Options{InitialTab: "machines"}).(*app) m.rebuild() var text strings.Builder otherKey := "" diff --git a/internal/tui/mcp_servers_test.go b/internal/tui/mcp_servers_test.go index 4c1d78c7..a4962624 100644 --- a/internal/tui/mcp_servers_test.go +++ b/internal/tui/mcp_servers_test.go @@ -19,7 +19,7 @@ func TestMCPRollupRowAndDetails(t *testing.T) { ps := []model.Proc{{PID: 1, Cmdline: "claude", Argv: []string{"claude"}}, {PID: 2, PPID: 1, Cmdline: "node exec firecrawl-mcp", Argv: []string{"node", "exec", "firecrawl-mcp"}, RSS: 10 << 20}, {PID: 3, PPID: 2, Cmdline: "node /cache/node_modules/.bin/firecrawl-mcp", Argv: []string{"node", "/cache/node_modules/.bin/firecrawl-mcp"}, RSS: 20 << 20}} src.snap.Machine = view.MachineView{MCPServers: derive.MCPServers(ps, nil, src.snap.ObservedAt)} for _, width := range []int{80, 140} { - m := setup(t, src, Options{InitialTab: "machine"}).(*app) + m := setup(t, src, Options{InitialTab: "machines"}).(*app) m.width, m.height = width, 50 m.rebuild() if !strings.Contains(m.View().Content, "firecrawl-mcp 1 servers · 30.0M · 1 hosts · 0 duplicates") { @@ -50,7 +50,7 @@ func TestMachineMCPServersReadOnlyDetails(t *testing.T) { servers := derive.MCPServers([]model.Proc{{PID: 10, Cmdline: "codex app-server", Argv: []string{"codex", "app-server"}, StartTime: at.Add(-31 * time.Hour)}, {PID: 11, PPID: 10, Cmdline: "node /opt/codegraph/dist/index.js", Argv: []string{"node", "/opt/codegraph/dist/index.js"}, RSS: 100, StartTime: at.Add(-time.Hour)}, {PID: 12, PPID: 10, Cmdline: "node /opt/codegraph/dist/index.js", Argv: []string{"node", "/opt/codegraph/dist/index.js"}, RSS: 100, StartTime: at}}, sigs, at) src.snap.Machine = view.MachineView{MCPServers: servers} for _, width := range []int{80, 140} { - m := setup(t, src, Options{InitialTab: "machine", Exec: func([]string) error { t.Fatal("executed command"); return nil }, Kill: func(context.Context, model.Orphan, []int32, bool) (kill.Result, error) { + m := setup(t, src, Options{InitialTab: "machines", Exec: func([]string) error { t.Fatal("executed command"); return nil }, Kill: func(context.Context, model.Orphan, []int32, bool) (kill.Result, error) { t.Fatal("attempted to signal pile-up") return kill.Result{}, nil }}).(*app) diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go index 25ca5367..aeb41799 100644 --- a/internal/tui/theme_test.go +++ b/internal/tui/theme_test.go @@ -25,13 +25,13 @@ func TestTerminalBackgroundUpdatesTheme(t *testing.T) { func TestExplicitThemeIgnoresBackground(t *testing.T) { original := view.HasDarkBackground() - t.Cleanup(func() { _ = view.SetTheme("auto"); view.SetDarkBackground(original) }) + t.Cleanup(func() { _ = view.SetTheme("system"); view.SetDarkBackground(original) }) var colors [2][4]uint32 - for i, theme := range []string{"mocha", "latte"} { + for i, theme := range []string{"dark", "light"} { m := setup(t, fixture(), Options{Theme: theme}).(*app) for _, background := range []color.Color{color.White, color.Black} { _, _ = m.Update(tea.BackgroundColorMsg{Color: background}) - if view.HasDarkBackground() != (theme == "mocha") { + if view.HasDarkBackground() != (theme == "dark") { t.Fatal("explicit theme lost") } } diff --git a/internal/view/theme.go b/internal/view/theme.go index 8ea2505d..c7cdf33f 100644 --- a/internal/view/theme.go +++ b/internal/view/theme.go @@ -3,83 +3,152 @@ package view import ( "fmt" "image/color" + "math" "strings" "sync/atomic" "charm.land/lipgloss/v2" + catppuccin "github.com/catppuccin/go" ) // ThemeToken is one semantic color in the browser or terminal palette. type ThemeToken struct{ Name, Light, Dark string } +// Palette roles come from the official Catppuccin palettes (MIT): Latte for +// light, Mocha for dark. Where an official Latte color falls below 4.5:1 for +// text on Latte's base, text roles use readable(), the same hue darkened just +// enough. Tinted backgrounds are tint() mixes of the state color into base. +var ( + lb, mb = catppuccin.Latte.Base(), catppuccin.Mocha.Base() + pair = func(l, d catppuccin.Color) [2]string { return [2]string{hex(l), hex(d)} } + text = func(l, d catppuccin.Color) [2]string { return [2]string{readable(l, lb), hex(d)} } +) + +func tok(name string, v [2]string) ThemeToken { return ThemeToken{name, v[0], v[1]} } + // Markdown roles share names and colors across terminal and browser output. var markdownTokens = []ThemeToken{ - {"md-heading", "#7629D6", "#CBA6F7"}, - {"md-code", "#4C4F69", "#CDD6F4"}, - {"md-code-bg", "#DCE0E8", "#11111B"}, - {"md-link", "#1A5AD8", "#89B4FA"}, - {"md-quote", "#5C5F77", "#A6ADC8"}, - {"md-rule", "#5C5F77", "#A6ADC8"}, - {"md-table-border", "#5C5F77", "#A6ADC8"}, - {"md-strike", "#5C5F77", "#A6ADC8"}, - {"md-task", "#256817", "#A6E3A1"}, + tok("md-heading", text(catppuccin.Latte.Mauve(), catppuccin.Mocha.Mauve())), + tok("md-code", pair(catppuccin.Latte.Text(), catppuccin.Mocha.Text())), + tok("md-code-bg", pair(catppuccin.Latte.Crust(), catppuccin.Mocha.Crust())), + tok("md-link", text(catppuccin.Latte.Blue(), catppuccin.Mocha.Blue())), + tok("md-quote", pair(catppuccin.Latte.Subtext1(), catppuccin.Mocha.Subtext0())), + tok("md-rule", pair(catppuccin.Latte.Subtext1(), catppuccin.Mocha.Subtext0())), + tok("md-table-border", pair(catppuccin.Latte.Subtext1(), catppuccin.Mocha.Subtext0())), + tok("md-strike", pair(catppuccin.Latte.Subtext1(), catppuccin.Mocha.Subtext0())), + tok("md-task", text(catppuccin.Latte.Green(), catppuccin.Mocha.Green())), } -// Catppuccin palettes (MIT): Latte for light, Mocha for dark. Mocha uses the -// official colors. Latte's yellow, green, blue, peach and overlay grays fall -// below 4.5:1 for text on its base, so those text roles use darker shades of -// the same hue; bars and fills reuse them. +// Semantic state roles, shared by the terminal and the browser. +var ( + ink = pair(catppuccin.Latte.Text(), catppuccin.Mocha.Text()) + muted = pair(catppuccin.Latte.Subtext1(), catppuccin.Mocha.Subtext0()) + working = text(catppuccin.Latte.Green(), catppuccin.Mocha.Green()) + waiting = text(catppuccin.Latte.Yellow(), catppuccin.Mocha.Yellow()) + stalled = text(catppuccin.Latte.Red(), catppuccin.Mocha.Red()) + idle = text(catppuccin.Latte.Blue(), catppuccin.Mocha.Blue()) + warn = text(catppuccin.Latte.Peach(), catppuccin.Mocha.Peach()) + accent = text(catppuccin.Latte.Mauve(), catppuccin.Mocha.Mauve()) + observed = text(catppuccin.Latte.Teal(), catppuccin.Mocha.Teal()) + tool = text(catppuccin.Latte.Sky(), catppuccin.Mocha.Sky()) + onBase = pair(catppuccin.Latte.Base(), catppuccin.Mocha.Base()) +) + var themeTokens = append([]ThemeToken{ - {"sunk", "#DCE0E8", "#11111B"}, - {"ink", "#4C4F69", "#CDD6F4"}, - {"muted", "#5C5F77", "#A6ADC8"}, - {"faint", "#5C5F77", "#A6ADC8"}, - {"line", "#CCD0DA", "#313244"}, - {"strong", "#4C4F69", "#CDD6F4"}, - {"on-strong", "#EFF1F5", "#1E1E2E"}, - {"working", "#256817", "#A6E3A1"}, - {"waiting", "#7A4C07", "#F9E2AF"}, - {"stalled", "#B50C30", "#F38BA8"}, - {"idle", "#1A5AD8", "#89B4FA"}, - {"finished", "#5C5F77", "#A6ADC8"}, - {"crit", "#B50C30", "#F38BA8"}, - {"warn", "#963F06", "#FAB387"}, - {"ok", "#1A5AD8", "#89B4FA"}, - {"accent", "#7629D6", "#CBA6F7"}, - {"observation", "#116762", "#94E2D5"}, - {"tool", "#04778A", "#89DCEB"}, - {"ended", "#5C5F77", "#A6ADC8"}, + tok("sunk", pair(catppuccin.Latte.Crust(), catppuccin.Mocha.Crust())), + tok("ink", ink), tok("muted", muted), tok("faint", muted), + tok("line", pair(catppuccin.Latte.Surface0(), catppuccin.Mocha.Surface0())), + tok("strong", ink), tok("on-strong", onBase), + tok("working", working), tok("waiting", waiting), tok("stalled", stalled), + tok("idle", idle), tok("finished", muted), tok("crit", stalled), + tok("warn", warn), tok("ok", idle), tok("accent", accent), + tok("observation", observed), tok("tool", tool), tok("ended", muted), }, markdownTokens...) -// consoleTokens maps the console's roles onto the same Catppuccin palettes. +// consoleTokens maps the console's roles onto the same palettes. The Flow +// canvas sits one step below Mocha's crust so the project cards stand out. var consoleTokens = append([]ThemeToken{ - {"flow-canvas", "#EFF1F5", "#0E0E16"}, {"flow-panel", "#E6E9EF", "#10101A"}, {"flow-line", "#BCC0CC", "#262636"}, {"flow-muted", "#5C5F77", "#7F849C"}, - {"bg", "#EFF1F5", "#1E1E2E"}, {"pane", "#E6E9EF", "#181825"}, - {"raise", "#CCD0DA", "#313244"}, {"line", "#BCC0CC", "#45475A"}, - {"fg", "#4C4F69", "#CDD6F4"}, {"mid", "#5C5F77", "#BAC2DE"}, - {"dim", "#5C5F77", "#A6ADC8"}, {"accent", "#7629D6", "#CBA6F7"}, - {"on-accent", "#EFF1F5", "#1E1E2E"}, {"working", "#256817", "#A6E3A1"}, - {"waiting", "#7A4C07", "#F9E2AF"}, {"stalled", "#B50C30", "#F38BA8"}, - {"idle", "#1A5AD8", "#89B4FA"}, {"finished", "#5C5F77", "#A6ADC8"}, - {"crit", "#B50C30", "#F38BA8"}, {"warn", "#963F06", "#FAB387"}, - {"observation", "#116762", "#94E2D5"}, - {"waiting-bar", "#DF8E1D", "#F9E2AF"}, {"waiting-bg", "#F3E7D3", "#3A3530"}, - {"tool", "#04778A", "#89DCEB"}, {"rail", "#A9AEBC", "#585B70"}, - {"faint", "#5C5F77", "#A6ADC8"}, - {"working-bg", "#D3E4D5", "#364143"}, {"stalled-bg", "#EACDD7", "#443244"}, - {"idle-bg", "#CEDBF5", "#313953"}, {"finished-bg", "#DADCE3", "#333446"}, + tok("flow-canvas", [2]string{hex(catppuccin.Latte.Base()), mix(catppuccin.Mocha.Crust().RGB, [3]uint8{}, 0.2)}), + tok("flow-panel", [2]string{hex(catppuccin.Latte.Mantle()), mix(catppuccin.Mocha.Crust().RGB, [3]uint8{}, 0.08)}), + tok("flow-line", [2]string{hex(catppuccin.Latte.Surface1()), mix(mb.RGB, catppuccin.Mocha.Surface0().RGB, 0.45)}), + tok("flow-muted", pair(catppuccin.Latte.Subtext1(), catppuccin.Mocha.Overlay1())), + tok("bg", onBase), tok("pane", pair(catppuccin.Latte.Mantle(), catppuccin.Mocha.Mantle())), + tok("raise", pair(catppuccin.Latte.Surface0(), catppuccin.Mocha.Surface0())), + tok("line", pair(catppuccin.Latte.Surface1(), catppuccin.Mocha.Surface1())), + tok("fg", ink), tok("mid", pair(catppuccin.Latte.Subtext1(), catppuccin.Mocha.Subtext1())), + tok("dim", muted), tok("accent", accent), tok("on-accent", onBase), + tok("working", working), tok("waiting", waiting), tok("stalled", stalled), + tok("idle", idle), tok("finished", muted), tok("crit", stalled), tok("warn", warn), + tok("observation", observed), tok("tool", tool), tok("faint", muted), + tok("waiting-bar", pair(catppuccin.Latte.Yellow(), catppuccin.Mocha.Yellow())), + tok("rail", pair(catppuccin.Latte.Surface2(), catppuccin.Mocha.Surface2())), + tok("waiting-bg", tint(catppuccin.Latte.Yellow(), catppuccin.Mocha.Yellow())), + tok("working-bg", tint(catppuccin.Latte.Green(), catppuccin.Mocha.Green())), + tok("stalled-bg", tint(catppuccin.Latte.Red(), catppuccin.Mocha.Red())), + tok("idle-bg", tint(catppuccin.Latte.Blue(), catppuccin.Mocha.Blue())), + tok("finished-bg", tint(catppuccin.Latte.Subtext1(), catppuccin.Mocha.Subtext0())), }, markdownTokens...) +func hex(c catppuccin.Color) string { return strings.ToUpper(c.Hex) } + +// tint mixes a state color into each flavor's base for a soft background. +func tint(l, d catppuccin.Color) [2]string { + return [2]string{mix(lb.RGB, l.RGB, 0.15), mix(mb.RGB, d.RGB, 0.15)} +} + +func mix(a, b [3]uint8, t float64) string { + var out [3]uint8 + for i := range out { + out[i] = uint8(math.Round(float64(a[i]) + (float64(b[i])-float64(a[i]))*t)) + } + return fmt.Sprintf("#%02X%02X%02X", out[0], out[1], out[2]) +} + +// readable darkens c in small steps, keeping its hue, until text in it +// reaches 4.5:1 (WCAG AA) on bg. Colors that already pass are unchanged. +func readable(c, bg catppuccin.Color) string { + rgb := c.RGB + for k := 1.0; k > 0; k -= 0.01 { + var out [3]uint8 + for i := range out { + out[i] = uint8(math.Round(float64(c.RGB[i]) * k)) + } + rgb = out + if Contrast(rgb, bg.RGB) >= 4.5 { + break + } + } + return fmt.Sprintf("#%02X%02X%02X", rgb[0], rgb[1], rgb[2]) +} + +// Contrast is the WCAG contrast ratio of two sRGB colors. +func Contrast(a, b [3]uint8) float64 { + lum := func(c [3]uint8) float64 { + var l [3]float64 + for i, v := range c { + x := float64(v) / 255 + if x <= 0.04045 { + l[i] = x / 12.92 + } else { + l[i] = math.Pow((x+0.055)/1.055, 2.4) + } + } + return 0.2126*l[0] + 0.7152*l[1] + 0.0722*l[2] + } + x, y := lum(a), lum(b) + return (max(x, y) + 0.05) / (min(x, y) + 0.05) +} + // ThemeCSS supports named preferences and the system default without script. func ThemeCSS() string { - var latte, mocha strings.Builder + var light, dark strings.Builder for _, t := range consoleTokens { - fmt.Fprintf(&latte, "--%s:%s;", t.Name, t.Light) - fmt.Fprintf(&mocha, "--%s:%s;", t.Name, t.Dark) + fmt.Fprintf(&light, "--%s:%s;", t.Name, t.Light) + fmt.Fprintf(&dark, "--%s:%s;", t.Name, t.Dark) } - return `:root,[data-theme="mocha"]{` + mocha.String() + "color-scheme:dark;}\n" + - `[data-theme="latte"]{` + latte.String() + "color-scheme:light;}\n" + - `@media (prefers-color-scheme: light){:root:not([data-theme="mocha"]):not([data-theme="latte"]){` + latte.String() + "color-scheme:light;}}\n" + return `:root,[data-theme="dark"]{` + dark.String() + "color-scheme:dark;}\n" + + `[data-theme="light"]{` + light.String() + "color-scheme:light;}\n" + + `@media (prefers-color-scheme: light){:root:not([data-theme="dark"]):not([data-theme="light"]){` + light.String() + "color-scheme:light;}}\n" } // Background detection belongs to the interactive event loop. Import-time @@ -87,17 +156,17 @@ func ThemeCSS() string { var lightBackground atomic.Bool var themePreference atomic.Int32 -// SetTheme selects a palette for the entire TUI. Auto follows background events. +// SetTheme selects a palette for the entire TUI. System follows the terminal background. func SetTheme(theme string) error { switch theme { - case "", "auto": + case "", "system": themePreference.Store(0) - case "mocha": + case "dark": themePreference.Store(1) - case "latte": + case "light": themePreference.Store(2) default: - return fmt.Errorf("theme must be auto, mocha or latte") + return fmt.Errorf("theme must be system, light or dark") } return nil } diff --git a/internal/view/theme_test.go b/internal/view/theme_test.go index ae4736f9..13144e6a 100644 --- a/internal/view/theme_test.go +++ b/internal/view/theme_test.go @@ -1,6 +1,8 @@ package view import ( + "fmt" + catppuccin "github.com/catppuccin/go" "math" "os" "path/filepath" @@ -44,24 +46,53 @@ func TestThemeTerminalParity(t *testing.T) { } } -func TestConsoleThemeTokenParity(t *testing.T) { +func TestConsoleThemeSelectors(t *testing.T) { css := ThemeCSS() - // Catppuccin Mocha (official) and Latte (text roles darkened for 4.5:1). - for _, set := range []string{ - `--bg:#1E1E2E;--pane:#181825;--raise:#313244;--line:#45475A;--fg:#CDD6F4;--mid:#BAC2DE;--dim:#A6ADC8;--accent:#CBA6F7;--on-accent:#1E1E2E;--working:#A6E3A1;--waiting:#F9E2AF;--stalled:#F38BA8;--idle:#89B4FA;--finished:#A6ADC8;--crit:#F38BA8;--warn:#FAB387;`, - `--bg:#EFF1F5;--pane:#E6E9EF;--raise:#CCD0DA;--line:#BCC0CC;--fg:#4C4F69;--mid:#5C5F77;--dim:#5C5F77;--accent:#7629D6;--on-accent:#EFF1F5;--working:#256817;--waiting:#7A4C07;--stalled:#B50C30;--idle:#1A5AD8;--finished:#5C5F77;--crit:#B50C30;--warn:#963F06;`, - } { - if !strings.Contains(css, set) { - t.Errorf("missing exact console token set %s", set) - } - } - for _, selector := range []string{`[data-theme="mocha"]`, `[data-theme="latte"]`, `prefers-color-scheme: light`} { + for _, selector := range []string{`[data-theme="dark"]`, `[data-theme="light"]`, `prefers-color-scheme: light`} { if !strings.Contains(css, selector) { t.Errorf("missing %s", selector) } } } +// Dark uses the official Mocha colors for every state; light text roles keep +// the official Latte hue and are only as much darker as 4.5:1 requires. +func TestThemeFollowsCatppuccinAndStaysReadable(t *testing.T) { + rgb := func(h string) [3]uint8 { + var c [3]uint8 + fmt.Sscanf(h, "#%02X%02X%02X", &c[0], &c[1], &c[2]) + return c + } + official := map[string][2]catppuccin.Color{ + "working": {catppuccin.Latte.Green(), catppuccin.Mocha.Green()}, + "waiting": {catppuccin.Latte.Yellow(), catppuccin.Mocha.Yellow()}, + "stalled": {catppuccin.Latte.Red(), catppuccin.Mocha.Red()}, + "idle": {catppuccin.Latte.Blue(), catppuccin.Mocha.Blue()}, + "warn": {catppuccin.Latte.Peach(), catppuccin.Mocha.Peach()}, + "accent": {catppuccin.Latte.Mauve(), catppuccin.Mocha.Mauve()}, + "tool": {catppuccin.Latte.Sky(), catppuccin.Mocha.Sky()}, + } + for _, tok := range consoleTokens { + want, ok := official[tok.Name] + if !ok { + continue + } + if tok.Dark != strings.ToUpper(want[1].Hex) { + t.Errorf("%s dark %s, official Mocha %s", tok.Name, tok.Dark, want[1].Hex) + } + if c := Contrast(rgb(tok.Light), catppuccin.Latte.Base().RGB); c < 4.5 { + t.Errorf("%s light %s contrast %.2f", tok.Name, tok.Light, c) + } + if c := Contrast(rgb(tok.Dark), catppuccin.Mocha.Base().RGB); c < 4.5 { + t.Errorf("%s dark %s contrast %.2f", tok.Name, tok.Dark, c) + } + // Passing official colors are used unchanged. + if Contrast(want[0].RGB, catppuccin.Latte.Base().RGB) >= 4.5 && tok.Light != strings.ToUpper(want[0].Hex) { + t.Errorf("%s light %s should be official %s", tok.Name, tok.Light, want[0].Hex) + } + } +} + // An unknown token must render plain text, never a nil color: rendering a // zero AdaptiveColor panics inside Lip Gloss and kills the TUI. func TestUnknownTokenRendersPlain(t *testing.T) { @@ -83,26 +114,6 @@ func TestTUITokensExist(t *testing.T) { } } -func TestFlowMockTokens(t *testing.T) { - for _, want := range []ThemeToken{ - {"waiting-bar", "#DF8E1D", "#F9E2AF"}, {"waiting-bg", "#F3E7D3", "#3A3530"}, - {"tool", "#04778A", "#89DCEB"}, {"rail", "#A9AEBC", "#585B70"}, - } { - found := false - for _, got := range consoleTokens { - if got.Name == want.Name { - found = true - if got != want { - t.Errorf("%s: %+v", want.Name, got) - } - } - } - if !found { - t.Error("missing console token", want.Name) - } - } -} - // Every palette variable used by web assets must have a console value. func TestWebAssetsUseConsoleTokens(t *testing.T) { console := map[string]bool{} diff --git a/internal/web/console_test.go b/internal/web/console_test.go index eb642020..1040a35e 100644 --- a/internal/web/console_test.go +++ b/internal/web/console_test.go @@ -41,7 +41,7 @@ func TestConsoleStructureAndCSP(t *testing.T) { } } body := request(s, "GET", "/", s.token).Body.String() - for _, want := range []string{`tail -f events`, `aria-live="polite"`, `data-tail`, `data-window="2"`, `value="mocha"`, `value="latte"`} { + for _, want := range []string{`tail -f events`, `aria-live="polite"`, `data-tail`, `data-window="2"`, `value="light"`, `value="dark"`} { if !strings.Contains(body, want) { t.Errorf("missing %s", want) } diff --git a/internal/web/design_test.go b/internal/web/design_test.go index 0ad90fb8..a3038794 100644 --- a/internal/web/design_test.go +++ b/internal/web/design_test.go @@ -11,7 +11,7 @@ import ( func TestThemeControlAndStylesheet(t *testing.T) { s, _, _, _ := fixture(t) body := request(s, "GET", "/", s.token).Body.String() - for _, want := range []string{`/static/theme.css`, `id="theme"`, `value="system"`, `value="mocha"`, `value="latte"`, `system`, `mocha`, `latte`} { + for _, want := range []string{`/static/theme.css`, `id="theme"`, `value="system"`, `value="light"`, `value="dark"`, `system`, `light`, `dark`} { if !strings.Contains(body, want) { t.Errorf("missing %s", want) } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 045c9b8c..fd460751 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -36,8 +36,8 @@ if (tokens.length) query.id = tokens[0]; return url("/flow"); } if (verb === "theme") { - if (tokens.length === 1 && ["mocha", "latte", "system"].includes(tokens[0])) return {theme: tokens[0]}; - return error("Use theme mocha|latte|system"); + if (tokens.length === 1 && ["light", "dark", "system"].includes(tokens[0])) return {theme: tokens[0]}; + return error("Use theme light|dark|system"); } if (["agents", "projects", "inbox", "orphans", "machines", "cleanup"].includes(verb) && tokens[0] === "list") tokens.shift(); if (verb === "agents" && tokens[0] === "show") { @@ -135,7 +135,7 @@ let preference = "system", history = [], historyIndex = 0, draft = ""; try { const saved = localStorage.getItem("driga-theme"); - if (["mocha", "latte", "system"].includes(saved)) preference = saved; + if (["light", "dark", "system"].includes(saved)) preference = saved; const stored = JSON.parse(localStorage.getItem("driga-command-history") || "[]"); if (Array.isArray(stored)) history = stored.filter(s => typeof s === "string" && s.length <= 500).slice(-100); } catch (_) { /* Storage is optional. */ } @@ -262,7 +262,7 @@ const input = document.getElementById("cmdline"), menu = document.getElementById("command-menu"), error = document.getElementById("command-error"), help = document.getElementById("help"); let suggestions = [], choice = -1; - const commands = ["flow", "flow ", "agents", ...states.map(s => "agents --state " + s), "agents --all", "agents --project ", "projects show ", "projects", "inbox", "orphans", "machines", "cleanup", "cleanup --why", "activity", "theme mocha", "theme latte", "theme system", "agents show ", "agents jump ", "agents kill ", "inbox ack ", "inbox jump ", "orphans kill ", "cleanup remove ", "hooks install"]; + const commands = ["flow", "flow ", "agents", ...states.map(s => "agents --state " + s), "agents --all", "agents --project ", "projects show ", "projects", "inbox", "orphans", "machines", "cleanup", "cleanup --why", "activity", "theme light", "theme dark", "theme system", "agents show ", "agents jump ", "agents kill ", "inbox ack ", "inbox jump ", "orphans kill ", "cleanup remove ", "hooks install"]; const mutating = command => parseCommand(command).error === readOnly; function closeMenu() { menu.hidden = true; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); choice = -1; } function complete() { diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 649fb8ea..46c53003 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -10,7 +10,7 @@ {{define "content"}}
driga{{if .Hostname}}{{.Hostname}}{{else}}{{.Scope}}{{end}}● live {{.Mode}}{{if eq .Mode "snapshot"}}Collecting a snapshot (no collector running)…{{end}} -{{range .Machine.Machines}}{{if not .Sample.TS.IsZero}}mem{{printf "%.0f" (percent .Sample.MemUsed .Sample.MemTotal)}}%{{if .Sample.SwapTotal}}swap{{printf "%.0f" (percent .Sample.SwapUsed .Sample.SwapTotal)}}%{{end}}load{{if lt .Sample.Load1 0.0}}unknown{{else}}{{printf "%.1f" .Sample.Load1}}{{end}}/{{.Sample.Cores}}disk{{bytes .Sample.DiskFree}}{{end}}{{end}}{{if or .LastEvents .PendingSources .StaleSources}}intake{{if .StaleSources}} · attention{{else if .PendingSources}} · waiting{{else}} · receiving{{end}}{{end}}{{if or (eq .Page "/") (eq .Page "/flow")}}{{if .IsBoard}}{{index .Board.Pulse "waiting"}} waiting{{else}}{{waitingHeader .Inbox.Rows .Agents.Rows .ObservedAt}}{{end}}{{end}}{{clock .ObservedAt}}
+{{range .Machine.Machines}}{{if not .Sample.TS.IsZero}}mem{{printf "%.0f" (percent .Sample.MemUsed .Sample.MemTotal)}}%{{if .Sample.SwapTotal}}swap{{printf "%.0f" (percent .Sample.SwapUsed .Sample.SwapTotal)}}%{{end}}load{{if lt .Sample.Load1 0.0}}unknown{{else}}{{printf "%.1f" .Sample.Load1}}{{end}}/{{.Sample.Cores}}disk{{bytes .Sample.DiskFree}}{{end}}{{end}}{{if or .LastEvents .PendingSources .StaleSources}}intake{{if .StaleSources}} · attention{{else if .PendingSources}} · waiting{{else}} · receiving{{end}}{{end}}{{if or (eq .Page "/") (eq .Page "/flow")}}{{if .IsBoard}}{{index .Board.Pulse "waiting"}} waiting{{else}}{{waitingHeader .Inbox.Rows .Agents.Rows .ObservedAt}}{{end}}{{end}}{{clock .ObservedAt}}
{{if gt (len .Machines.Rows) 1}}
{{if .All}}{{end}}
{{end}} {{if or (eq .Page "/") (eq .Page "/agents") (eq .Page "/projects")}}

{{if .All}}Showing all sessions · Show default sessions{{else}}Showing default sessions · Show all sessions (including ended and hosted idle up to 24h){{end}}

{{end}}
diff --git a/internal/web/testdata/chat-browser.cjs b/internal/web/testdata/chat-browser.cjs index e302c46f..c92b5cf3 100644 --- a/internal/web/testdata/chat-browser.cjs +++ b/internal/web/testdata/chat-browser.cjs @@ -9,7 +9,7 @@ const html=fs.readFileSync(path.join(root,'internal/web/templates/chat.html'),'u let css=fs.readFileSync(path.join(root,'internal/web/static/app.css'),'utf8')+'\n'+fs.readFileSync(path.join(root,'internal/web/static/chat.css'),'utf8'); css=css.replace(/url\("\/static\/([^"\)]+)"\)/g,(_,file)=>`url("data:font/woff2;base64,${fs.readFileSync(path.join(root,'internal/web/static',file)).toString('base64')}")`); const tokens=[...fs.readFileSync(path.join(root,'internal/view/theme.go'),'utf8').matchAll(/\{"([^" ]+)", "(#[A-Fa-f0-9]+)", "(#[A-Fa-f0-9]+)"\}/g)]; -const palette=theme=>':root{'+tokens.map(([,name,light,dark])=>`--${name}:${theme==='latte'?light:dark};`).join('')+'}'; +const palette=theme=>':root{'+tokens.map(([,name,light,dark])=>`--${name}:${theme==='light'?light:dark};`).join('')+'}'; const chatJS=fs.readFileSync(path.join(root,'internal/web/static/chat.js'),'utf8'); (async()=>{ let browser; @@ -17,7 +17,7 @@ const chatJS=fs.readFileSync(path.join(root,'internal/web/static/chat.js'),'utf8 browser=await chromium.launch({headless:true,executablePath:process.env.DRIGA_CHROMIUM_PATH||undefined,env:{...process.env,HOME:scratch,XDG_CONFIG_HOME:scratch,TMPDIR:scratch},args:['--disable-background-networking','--disable-component-update','--disable-sync','--no-first-run']}); const page=await browser.newPage();await page.route('**/*',route=>route.abort()); const errors=[];page.on('pageerror',error=>errors.push(error.message)); - for(const theme of ['latte','mocha'])for(const [width,height]of [[375,600],[375,667],[768,700],[1280,900]]){ + for(const theme of ['light','dark'])for(const [width,height]of [[375,600],[375,667],[768,700],[1280,900]]){ await page.setViewportSize({width,height}); await page.setContent(`
${html}
`); await page.evaluate(()=>{ diff --git a/internal/web/testdata/console.js b/internal/web/testdata/console.js index 1f1a5046..d589a40f 100644 --- a/internal/web/testdata/console.js +++ b/internal/web/testdata/console.js @@ -17,12 +17,12 @@ for (const state of ["working", "waiting", "stalled", "idle", "finished", "ended", "dead", "unknown"]) { assert(parse("agents --state " + state).url === "/agents?machine=all&state=" + encodeURIComponent(state), "state " + state); } - for (const theme of ["mocha", "latte", "system"]) assert(parse("theme " + theme).theme === theme, "theme " + theme); + for (const theme of ["light", "dark", "system"]) assert(parse("theme " + theme).theme === theme, "theme " + theme); assert(parse("agents", "peer&x").url === "/agents?machine=peer%26x", "scope is encoded"); for (const command of ["inbox ack --all", "agents jump 12", "agents kill 12", "inbox jump item", "projects summarize app", "orphans kill 42", "cleanup remove", "hooks install"]) { const result=parse(command); assert(result.error === "read-only in browser · use the CLI" && !result.url && !result.theme, command + " must not run"); } - for (const command of ["", "unknown", "agents --state nope", "agents --state", "agents --all --all", "agents --project", "projects show", "theme dark", "inbox trailing", "cleanup --why apply", 'projects show "unterminated', "agents; kill 42", "activity --window 6h", "x".repeat(501)]) { + for (const command of ["", "unknown", "agents --state nope", "agents --state", "agents --all --all", "agents --project", "projects show", "theme mocha", "inbox trailing", "cleanup --why apply", 'projects show "unterminated', "agents; kill 42", "activity --window 6h", "x".repeat(501)]) { const result=parse(command); assert(result.error && !result.url && !result.theme, "invalid " + command); } })();