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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` switches live **and persists it here**. |
| `theme` | string | `ember-dark` | `--theme` | Color palette: `ember-dark` · `ember-light` · `high-contrast` · `obsidian` (true black) · `classic`. `/theme <name>` 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). |
Expand Down
22 changes: 22 additions & 0 deletions internal/tui/canvas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }()
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions internal/tui/copyclean_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -138,6 +139,8 @@ func TestAnswerCard(t *testing.T) {
pal = emberLight
case "classic":
pal = classic
case "obsidian":
pal = obsidian
default:
pal = emberHighContrast
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions internal/tui/palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") }},
}
}
Expand Down
28 changes: 28 additions & 0 deletions internal/tui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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"
}
Expand All @@ -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
}
Expand All @@ -185,6 +211,8 @@ func paletteByName(name string) palette {
return emberLight
case "high-contrast":
return emberHighContrast
case "obsidian":
return obsidian
default:
return emberDark
}
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/terminal_preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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} {
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/theme_readability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/top3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/visual_polish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading