diff --git a/AGENTS.md b/AGENTS.md index b619743..04a3eee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -387,7 +387,7 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T` restore typed cards, argument previews, icons, summaries, or result schemas. Preserve `delegate_tasks`, all `bg_*` views, and supported individual `shell`, `patch`, `read_file`, `search_files`, and `http_request` rendering. -- `TestTerminalWorkflowLayouts` checks real views across four themes at 40, 80, +- `TestTerminalWorkflowLayouts` checks real views across five themes at 40, 80, and 120 columns. Set `BODEK_RENDER_PREVIEW_DIR` to write optional ANSI fixtures for visual review without an engine/provider; generated captures are not source. diff --git a/README.md b/README.md index f53b2c6..c3b2100 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ own front-end settings are separate; see [Configuration](#configuration). ### Interface - **EMBER Terminal** — the WebUI's design language (electric amber on - blue-charcoal) as terminal tokens; four themes (`ember-dark` · - `ember-light` · `high-contrast` · `classic`), switchable live with `/theme` + blue-charcoal) as terminal tokens; five themes (`ember-dark` · + `ember-light` · `high-contrast` · `obsidian` · `classic`), switchable live with `/theme` and persisted to `~/.bodek/config.json`. Secondary labels and focused rows stay readable across dark, light, and high-contrast surfaces. The terminal controls the font; bodek does not replace it. @@ -519,7 +519,7 @@ full command and press `⏎`. | `/export` | Save the session transcript next to you — `/export [md|json]` (markdown by default, never overwrites) | | `/retry` | Re-send the last prompt (queues it if a turn is running) | | `/queue` | Manage the prompt queue — priority, delete, send now (the full manager over the `^Q` strip) | -| `/theme [name]` | Open a searchable theme selector with the current theme marked; arrows + Enter apply and save, Esc keeps the current theme. `/theme name` still switches directly (`ember-dark` · `ember-light` · `high-contrast` · `classic`) | +| `/theme [name]` | Open a searchable theme selector with the current theme marked; arrows + Enter apply and save, Esc keeps the current theme. `/theme name` still switches directly (`ember-dark` · `ember-light` · `high-contrast` · `obsidian` · `classic`) | | `/verbosity [quiet\|normal\|detailed]` | One-dial noise policy (persisted): quiet hides engine traces, detailed switches on the `^E` expand-all view; bare `/verbosity` cycles | | `/stats` | Session metrics sheet (speed, TTFT, cost, cache, context gauge) | | `/server` | Cockpit — server, link, budget & session in one card (or click the header) | diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index aa45341..26bef0d 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -130,7 +130,7 @@ func parseConfig(args []string, output io.Writer) (config, error) { if os.Getenv("BODEK_THEME") != "" { themeDefault = "" // the env override wins over the persisted file } - fs.StringVar(&cfg.theme, "theme", themeDefault, "color theme: ember-dark, ember-light, high-contrast, classic (default: BODEK_THEME, then the settings file)") + fs.StringVar(&cfg.theme, "theme", themeDefault, "color theme: ember-dark, ember-light, high-contrast, obsidian, classic (default: BODEK_THEME, then the settings file)") fs.BoolVar(&cfg.bel, "bel", st.Bool(st.Bell, true), "ring the terminal bell when a turn completes or an approval is waiting (--bel=false mutes)") fs.BoolVar(&cfg.notify, "notify", st.Bool(st.Notify, false), "raise desktop notifications (OSC 9) on turn completion and approvals") fs.BoolVar(&cfg.plain, "plain", st.Bool(st.Plain, false), "linear mode: no alt-screen, transcript printed to scrollback (screen readers, pipes, logs)") diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2e628ec..aa82c42 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -25,7 +25,7 @@ silently ignored. | Key | Type | Default | Flag | What it does | |-----|------|---------|------|--------------| -| `theme` | string | `ember-dark` | `--theme` | Color palette: `ember-dark` · `ember-light` · `high-contrast` · `classic`. `/theme ` switches live **and persists it here**. | +| `theme` | string | `ember-dark` | `--theme` | Color palette: `ember-dark` · `ember-light` · `high-contrast` · `obsidian` (true black) · `classic`. `/theme ` switches live **and persists it here**. | | `bel` | bool | `true` | `--bel` | Ring the terminal bell when a turn completes or an approval is waiting (`--bel=false` mutes; the title still updates). | | `notify` | bool | `false` | `--notify` | Raise desktop notifications (OSC 9) on turn completion and pending approvals. | | `plain` | bool | `false` | `--plain` | Linear mode: no alt-screen; agent events print to the terminal's native scrollback (screen readers, pipes, logs). | diff --git a/internal/tui/canvas_test.go b/internal/tui/canvas_test.go index 3dc75b8..cd91331 100644 --- a/internal/tui/canvas_test.go +++ b/internal/tui/canvas_test.go @@ -47,6 +47,28 @@ func TestLightCanvasCoversTerminalAndPreservesCardSurfaces(t *testing.T) { } } +// TestObsidianCanvasIsTrueBlack pins obsidian's own #000 canvas: a gray or +// blue-tinted host terminal still renders pure black, and the graphite +// answer card keeps its surface on top of it. +func TestObsidianCanvasIsTrueBlack(t *testing.T) { + old := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(old) + m := newTestModel() + m.th = themeFrom(obsidian) + m.width, m.height = 20, 3 + out := m.paintCanvas("header\x1b[0m body\n" + m.th.answerCard.Render("answer")) + if !strings.HasPrefix(out, "\x1b[38;2;243;243;245m\x1b[48;2;0;0;0m") { + t.Fatalf("obsidian frame does not start on the black canvas: %q", out) + } + if !strings.Contains(out, "\x1b[48;2;14;14;17m") { + t.Fatal("answer card lost its graphite surface") + } + if !strings.HasSuffix(out, "\x1b[0m") { + t.Fatal("canvas leaks into terminal after frame") + } +} + func TestLightThemeSwitchUpdatesComposerAndCanvas(t *testing.T) { old := themeOverride defer func() { themeOverride = old }() diff --git a/internal/tui/commands.go b/internal/tui/commands.go index c4e8ab5..e81f6a9 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -261,8 +261,8 @@ func (m *Model) openCmdAC(query string) { } // themeOptions lists the palettes /theme accepts (aliases included in -// canonicalTheme: light, contrast, dark, default). -const themeOptions = "ember-dark · ember-light · high-contrast · classic" +// canonicalTheme: light, contrast, black, dark, default). +const themeOptions = "ember-dark · ember-light · high-contrast · obsidian · classic" // runTheme handles /theme: no argument reports the active palette and the // options; a name switches at runtime and persists via OnThemeChange. diff --git a/internal/tui/copyclean_test.go b/internal/tui/copyclean_test.go index 966ef64..e2ae661 100644 --- a/internal/tui/copyclean_test.go +++ b/internal/tui/copyclean_test.go @@ -128,6 +128,7 @@ func TestAnswerCard(t *testing.T) { "ember-dark": "[48;2;11;13;18m", // #0B0D12 (EMBER bg-1) "ember-light": "[48;2;239;236;229m", // #EFECE5 "classic": "[48;2;22;22;30m", // #16161E + "obsidian": "[48;2;14;14;17m", // #0E0E11 "high-contrast": "", } { var pal palette @@ -138,6 +139,8 @@ func TestAnswerCard(t *testing.T) { pal = emberLight case "classic": pal = classic + case "obsidian": + pal = obsidian default: pal = emberHighContrast } @@ -245,6 +248,7 @@ func TestAnswerTextIsThemeBright(t *testing.T) { "ember-light": "38;2;34;36;44", // #22252C (glamour quantizes g−1) "classic": "38;2;229;231;235", // #E5E7EB "high-contrast": "38;2;255;255;255", // #FFFFFF + "obsidian": "38;2;243;243;245", // #F4F4F5 (quantizes r/g−1) } { t.Run(name, func(t *testing.T) { t.Setenv("BODEK_THEME", name) diff --git a/internal/tui/model.go b/internal/tui/model.go index 8485e3e..fca0528 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -165,7 +165,7 @@ type Options struct { ReduceMotion bool // Theme names the startup palette (ember-dark, ember-light, - // high-contrast, classic). Empty defers to BODEK_THEME, then the + // high-contrast, obsidian, classic). Empty defers to BODEK_THEME, then the // settings file — the same order /theme persists into. Theme string diff --git a/internal/tui/palette.go b/internal/tui/palette.go index fd87fa7..8cc2540 100644 --- a/internal/tui/palette.go +++ b/internal/tui/palette.go @@ -104,6 +104,7 @@ func themePaletteEntries() []palEntry { {title: "ember-dark", kind: "theme", detail: "dark terminal", run: func(m *Model) tea.Cmd { return m.switchTheme("ember-dark") }}, {title: "ember-light", kind: "theme", detail: "light parchment", run: func(m *Model) tea.Cmd { return m.switchTheme("ember-light") }}, {title: "high-contrast", kind: "theme", detail: "high contrast", run: func(m *Model) tea.Cmd { return m.switchTheme("high-contrast") }}, + {title: "obsidian", kind: "theme", detail: "true black", run: func(m *Model) tea.Cmd { return m.switchTheme("obsidian") }}, {title: "classic", kind: "theme", detail: "classic violet", run: func(m *Model) tea.Cmd { return m.switchTheme("classic") }}, } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index be1b6ab..0923697 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -109,6 +109,28 @@ var ( grad: [2][3]int{{0xFF, 0xE0, 0x8A}, {0xFF, 0xB2, 0x24}}, } + // obsidian is a true-black theme for OLED panels and dark rooms: it + // paints its own #000 canvas (any terminal background becomes pure + // black), graphite cards, neutral zinc greys, and one champagne accent. + obsidian = palette{ + canvas: "#000000", + accent: "#E2C08D", + accentHi: "#F3DDB3", + accentLo: "#C9A06A", + steel: "#9BA3B4", + green: "#4ADE80", + // Lemon, not gold: warnings must not read as the champagne accent. + yellow: "#FDE047", + red: "#F87171", + text: "#F4F4F5", + muted: "#A1A1AA", + faint: "#71717A", + bodyText: "#B4B4BD", + hairline: "#232328", + surface: "#0E0E11", + grad: [2][3]int{{0xF3, 0xDD, 0xB3}, {0xC9, 0xA0, 0x6A}}, + } + // classic is the pre-EMBER Charm-inspired palette, kept for muscle memory // (BODEK_THEME=classic). classic = palette{ @@ -154,6 +176,8 @@ func themeName() string { return "ember-light" case "high-contrast", "contrast": return "high-contrast" + case "obsidian", "black": + return "obsidian" default: return "ember-dark" } @@ -172,6 +196,8 @@ func canonicalTheme(name string) (canonical string, ok bool) { return "ember-light", true case "high-contrast", "contrast": return "high-contrast", true + case "obsidian", "black": + return "obsidian", true default: return "", false } @@ -185,6 +211,8 @@ func paletteByName(name string) palette { return emberLight case "high-contrast": return emberHighContrast + case "obsidian": + return obsidian default: return emberDark } diff --git a/internal/tui/terminal_preview_test.go b/internal/tui/terminal_preview_test.go index 32c9c87..c90a6f3 100644 --- a/internal/tui/terminal_preview_test.go +++ b/internal/tui/terminal_preview_test.go @@ -19,7 +19,7 @@ func TestTerminalWorkflowLayouts(t *testing.T) { oldProfile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.TrueColor) defer lipgloss.SetColorProfile(oldProfile) - for _, themeName := range []string{"ember-dark", "ember-light", "high-contrast", "classic"} { + for _, themeName := range []string{"ember-dark", "ember-light", "high-contrast", "obsidian", "classic"} { for _, width := range []int{40, 80, 120} { for _, height := range []int{12, 16, 20, 32} { for _, active := range []bool{false, true} { diff --git a/internal/tui/theme_readability_test.go b/internal/tui/theme_readability_test.go index e6c88bf..fad0268 100644 --- a/internal/tui/theme_readability_test.go +++ b/internal/tui/theme_readability_test.go @@ -28,6 +28,8 @@ func TestEmberThemeReadability(t *testing.T) { {"ember-dark", emberDark, string(emberDark.surface)}, {"ember-light", emberLight, string(emberLight.surface)}, {"high-contrast", emberHighContrast, "#000000"}, + {"obsidian", obsidian, string(obsidian.surface)}, + {"obsidian canvas", obsidian, string(obsidian.canvas)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -61,6 +63,7 @@ func TestThemeSemanticStyles(t *testing.T) { "ember-dark": emberDark, "ember-light": emberLight, "high-contrast": emberHighContrast, + "obsidian": obsidian, } { th := themeFrom(p) focused := lipgloss.NewStyle().Foreground(p.accentHi).Bold(true).Render("focused") diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go index be8e092..db68053 100644 --- a/internal/tui/theme_test.go +++ b/internal/tui/theme_test.go @@ -14,6 +14,7 @@ func TestThemeVariants(t *testing.T) { "ember-light": func() theme { return themeFrom(emberLight) }, "high-contrast": func() theme { return themeFrom(emberHighContrast) }, "classic": func() theme { return themeFrom(classic) }, + "obsidian": func() theme { return themeFrom(obsidian) }, } { th := p() if got := plain(th.logo.Render("⬡ bodek")); !strings.Contains(got, "bodek") { @@ -34,6 +35,8 @@ func TestThemeSelection(t *testing.T) { "ember-light": "ember-light", "high-contrast": "high-contrast", "CONTRAST": "high-contrast", + "obsidian": "obsidian", + "black": "obsidian", } for env, want := range cases { t.Setenv("BODEK_THEME", env) diff --git a/internal/tui/top3_test.go b/internal/tui/top3_test.go index d12dd91..ca096ce 100644 --- a/internal/tui/top3_test.go +++ b/internal/tui/top3_test.go @@ -87,6 +87,8 @@ func TestCanonicalTheme(t *testing.T) { {"high-contrast", "high-contrast", true}, {"contrast", "high-contrast", true}, {"classic", "classic", true}, + {"obsidian", "obsidian", true}, + {"Black", "obsidian", true}, {" Classic ", "classic", true}, {"neon-night", "", false}, {"", "", false}, diff --git a/internal/tui/visual_polish_test.go b/internal/tui/visual_polish_test.go index 08e6e9a..fe8834e 100644 --- a/internal/tui/visual_polish_test.go +++ b/internal/tui/visual_polish_test.go @@ -119,7 +119,7 @@ func TestWarningHueDistinctFromAccent(t *testing.T) { } return math.Mod(h*60+360, 360) } - for _, name := range []string{"ember-dark", "ember-light", "high-contrast", "classic"} { + for _, name := range []string{"ember-dark", "ember-light", "high-contrast", "obsidian", "classic"} { p := paletteByName(name) d := math.Abs(hue(p.yellow) - hue(p.accent)) if d > 180 {