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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ bodek --odek-bin ./odek # use a specific odek binary
bodek --mouse # enable mouse wheel scrolling (blocks text selection)
bodek --bel=false # mute the attention bell (title still updates)
bodek --notify # desktop notifications (OSC 9) on turn/approval events
bodek --theme ember-light # start with a theme (/theme switches live)
bodek --plain # linear mode: transcript to scrollback (a11y, pipes)
bodek -- --prompt-caching # pass extra flags through to `odek serve`
bodek version # print the bodek version
Expand All @@ -123,6 +124,12 @@ Configuration (model, base URL, API key, MCP servers, memory, skills) is read
by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` →
`ODEK_*` env vars — so bodek inherits whatever you've already set up.

bodek keeps its **own front-end settings** in `~/.bodek/config.json` (override
with `BODEK_CONFIG`): `theme`, `mouse`, `bel`, `notify`, `plain`. Switching
the theme with `/theme` persists it there automatically; the other values can
be written by hand and seed the matching flag defaults. Resolution order:
**flag → `BODEK_THEME` env (theme) → settings file → built-in default**.

### Key bindings

| Key | Action |
Expand All @@ -132,6 +139,8 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
| `/` | Open the command palette (see below) |
| `@` | Attach a file (see below) |
| `alt+↑` / `alt+↓` | Jump to the previous / next turn |
| `alt+y` | Copy the **focused** turn's reply — the one you last jumped to (falls back to the latest reply) |
| `alt+r` | Re-send the last prompt (`/retry`) |
| `alt+f` | Search the transcript (`⏎` next match · `N` previous) |
| `^F` | Fold/unfold the most recent turn card (click any turn head with `--mouse`) |
| `tab` | Open/close the latest reasoning block (live turns auto-expand) |
Expand Down Expand Up @@ -174,6 +183,8 @@ command and press `⏎`.
| `/help` | Show available commands and key bindings |
| `/clear` | Clear the conversation (two-step confirm; idle only) |
| `/copy` | Copy the last reply to the clipboard (OSC 52) |
| `/retry` | Re-send the last prompt (queues it if a turn is running) |
| `/theme [name]` | Switch the color theme at runtime and persist it (`ember-dark` · `ember-light` · `high-contrast` · `classic`) |
| `/stats` | Session metrics card (cost, cache, context gauge) |
| `/server` | Cockpit — server, link, budget & session in one card (or click the header) |
| `/sessions` | Browse, search, pin, rename, export & resume sessions |
Expand Down Expand Up @@ -260,7 +271,8 @@ one `Esc`.

- **EMBER Terminal** — the WebUI's design language (electric amber on
blue-charcoal) as terminal tokens; `BODEK_THEME=ember-light|high-contrast|classic`
and `NO_MOTION=1` for a fully static UI.
or `/theme` to switch live (persisted to `~/.bodek/config.json`), and
`NO_MOTION=1` for a fully static UI.
- **The palette (`^K`)** — every surface one fuzzy search away, every row
teaching its chord.
- **Turn cards** — telemetry rides the turn head, `^F` folds noisy turns,
Expand Down
40 changes: 33 additions & 7 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/BackendStack21/bodek/internal/client"
"github.com/BackendStack21/bodek/internal/server"
"github.com/BackendStack21/bodek/internal/settings"
"github.com/BackendStack21/bodek/internal/tui"
)

Expand All @@ -34,14 +35,28 @@ type config struct {
sandbox bool
bin string
mouse bool
bel bool // terminal bell on turn completion / approval waiting
notify bool // desktop notifications (OSC 9) on the same events
plain bool // linear rendering mode (no alt-screen)
bel bool // terminal bell on turn completion / approval waiting
notify bool // desktop notifications (OSC 9) on the same events
plain bool // linear rendering mode (no alt-screen)
theme string // startup palette override (empty = BODEK_THEME / settings)
extraArgs []string

persist settings.Settings // the loaded file, re-saved when /theme switches
}

func parseConfig(args []string, output io.Writer) (config, error) {
var cfg config
// Persisted preferences seed the flag defaults, so a choice made once
// (via /theme or by hand) survives relaunches; explicit flags still
// win. A broken file warns but never blocks startup.
st, err := settings.Load()
if err != nil {
if output != nil {
_, _ = fmt.Fprintf(output, "bodek: ignoring settings file: %v\n", err)
}
st = settings.Settings{}
}
cfg.persist = st
fs := flag.NewFlagSet("bodek", flag.ContinueOnError)
if output != nil {
fs.SetOutput(output)
Expand All @@ -50,10 +65,15 @@ func parseConfig(args []string, output io.Writer) (config, error) {
fs.StringVar(&cfg.token, "token", "", "WS auth token for an attached odek serve (as printed at its startup)")
fs.BoolVar(&cfg.sandbox, "sandbox", false, "run tool calls inside odek's Docker sandbox")
fs.StringVar(&cfg.bin, "odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)")
fs.BoolVar(&cfg.mouse, "mouse", false, "enable mouse wheel scrolling (disables native text selection/copy)")
fs.BoolVar(&cfg.bel, "bel", true, "ring the terminal bell when a turn completes or an approval is waiting (--bel=false mutes)")
fs.BoolVar(&cfg.notify, "notify", false, "raise desktop notifications (OSC 9) on turn completion and approvals")
fs.BoolVar(&cfg.plain, "plain", false, "linear mode: no alt-screen, transcript printed to scrollback (screen readers, pipes, logs)")
themeDefault := st.Theme
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.BoolVar(&cfg.mouse, "mouse", st.Bool(st.Mouse, false), "enable mouse wheel scrolling (disables native text selection/copy)")
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)")
fs.Usage = func() {
_, _ = fmt.Fprintf(fs.Output(), "Usage: bodek [options] [-- <odek serve flags>]\n\n")
_, _ = fmt.Fprintf(fs.Output(), "A terminal interface for the odek agent.\n\n")
Expand All @@ -69,6 +89,7 @@ func parseConfig(args []string, output io.Writer) (config, error) {
_, _ = fmt.Fprintf(fs.Output(), " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n")
_, _ = fmt.Fprintf(fs.Output(), " bodek --mouse # enable mouse wheel scrolling (blocks text selection)\n")
_, _ = fmt.Fprintf(fs.Output(), " bodek --notify # desktop notifications on turn/approval events\n")
_, _ = fmt.Fprintf(fs.Output(), " bodek --theme ember-light # start with a specific theme (/theme switches at runtime)\n")
_, _ = fmt.Fprintf(fs.Output(), " bodek --plain # linear mode: transcript to scrollback (pipes, a11y)\n")
_, _ = fmt.Fprintf(fs.Output(), " bodek -- --prompt-caching # pass extra flags to odek serve\n")
}
Expand Down Expand Up @@ -183,6 +204,11 @@ func run() error {
Bell: cfg.bel,
Notify: cfg.notify,
Plain: cfg.plain,
Theme: cfg.theme,
OnThemeChange: func(name string) error {
cfg.persist.Theme = name
return settings.Save(cfg.persist)
},
Reconnect: func() (*client.Client, error) {
return client.Dial(srv.WSURL, srv.Origin, srv.BaseURL, srv.Token)
},
Expand Down
105 changes: 105 additions & 0 deletions cmd/bodek/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,28 @@ import (
"errors"
"flag"
"io"
"os"
"path/filepath"
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"

"github.com/BackendStack21/bodek/internal/settings"
)

// hermetic points BODEK_CONFIG at a temp path (and clears BODEK_THEME) so
// parseConfig never reads the developer's real ~/.bodek/config.json.
func hermetic(t *testing.T) {
t.Helper()
t.Setenv("BODEK_CONFIG", filepath.Join(t.TempDir(), "config.json"))
t.Setenv("BODEK_THEME", "")
}

func TestParseConfigDefaults(t *testing.T) {
hermetic(t)
cfg, err := parseConfig(nil, io.Discard)
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
Expand Down Expand Up @@ -42,6 +56,7 @@ func TestParseConfigMouseFlag(t *testing.T) {
}

func TestParseConfigAttentionFlags(t *testing.T) {
hermetic(t)
cfg, err := parseConfig([]string{"--bel=false", "--notify"}, io.Discard)
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
Expand All @@ -55,6 +70,7 @@ func TestParseConfigAttentionFlags(t *testing.T) {
}

func TestParseConfigExtraArgs(t *testing.T) {
hermetic(t)
cfg, err := parseConfig([]string{"--mouse", "--", "--prompt-caching", "--verbose"}, io.Discard)
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
Expand All @@ -74,13 +90,15 @@ func TestParseConfigExtraArgs(t *testing.T) {
}

func TestParseConfigUnknownFlag(t *testing.T) {
hermetic(t)
_, err := parseConfig([]string{"--unknown"}, io.Discard)
if err == nil {
t.Fatal("expected error for unknown flag")
}
}

func TestParseConfigHelp(t *testing.T) {
hermetic(t)
var out bytes.Buffer
_, err := parseConfig([]string{"-h"}, &out)
if !errors.Is(err, flag.ErrHelp) {
Expand Down Expand Up @@ -126,3 +144,90 @@ func TestApplyNoColor(t *testing.T) {
t.Error("NO_COLOR did not degrade the color profile to Ascii")
}
}

func TestThemeSeededFromSettings(t *testing.T) {
hermetic(t)
if err := settings.Save(settings.Settings{Theme: "classic"}); err != nil {
t.Fatalf("Save: %v", err)
}
cfg, err := parseConfig(nil, io.Discard)
if err != nil {
t.Fatalf("parseConfig: %v", err)
}
if cfg.theme != "classic" {
t.Errorf("theme = %q, want classic seeded from the settings file", cfg.theme)
}
}

func TestThemeFlagOverridesSettings(t *testing.T) {
hermetic(t)
if err := settings.Save(settings.Settings{Theme: "classic"}); err != nil {
t.Fatalf("Save: %v", err)
}
cfg, err := parseConfig([]string{"--theme", "ember-light"}, io.Discard)
if err != nil {
t.Fatalf("parseConfig: %v", err)
}
if cfg.theme != "ember-light" {
t.Errorf("theme = %q, want the explicit flag to win", cfg.theme)
}
}

func TestThemeEnvOverridesSettings(t *testing.T) {
hermetic(t)
t.Setenv("BODEK_THEME", "high-contrast")
if err := settings.Save(settings.Settings{Theme: "classic"}); err != nil {
t.Fatalf("Save: %v", err)
}
cfg, err := parseConfig(nil, io.Discard)
if err != nil {
t.Fatalf("parseConfig: %v", err)
}
if cfg.theme != "" {
t.Errorf("theme = %q, want empty (env wins, resolved inside the tui)", cfg.theme)
}
}

func TestSettingsBooleansSeedDefaults(t *testing.T) {
hermetic(t)
on, off := true, false
if err := settings.Save(settings.Settings{Mouse: &on, Plain: &off, Bell: &off}); err != nil {
t.Fatalf("Save: %v", err)
}
cfg, err := parseConfig(nil, io.Discard)
if err != nil {
t.Fatalf("parseConfig: %v", err)
}
if !cfg.mouse {
t.Error("mouse = false, want true from the settings file")
}
if cfg.plain {
t.Error("plain = true, want false from the settings file")
}
if cfg.bel {
t.Error("bel = true, want false from the settings file")
}
if cfg.notify {
t.Error("notify = true, want the built-in default")
}
}

func TestParseConfigBrokenSettingsWarns(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv("BODEK_CONFIG", cfgPath)
t.Setenv("BODEK_THEME", "")
if err := os.WriteFile(cfgPath, []byte("{bogus"), 0o600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
cfg, err := parseConfig(nil, &out)
if err != nil {
t.Fatalf("a broken settings file must not block startup: %v", err)
}
if !strings.Contains(out.String(), "ignoring settings file") {
t.Errorf("output = %q, want a warning", out.String())
}
if cfg.bel != true { // built-in default after the file is dropped
t.Error("bel should fall back to the built-in default")
}
}
83 changes: 83 additions & 0 deletions internal/settings/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Package settings persists bodek's own front-end preferences so they
// survive relaunches: theme, mouse, bell, notify, plain. odek's server-side
// configuration is unaffected — this file belongs to the terminal UI alone.
//
// Resolution order everywhere: explicit flag > BODEK_THEME env (theme) >
// this file > built-in default. Save rewrites the whole file; fields are
// pointers so "unset" survives a round trip and only what the user chose
// is ever written.
package settings

import (
"encoding/json"
"os"
"path/filepath"
)

// Settings is the persisted front-end preference set. Booleans are pointers:
// nil means "never chosen" and is skipped on save / treated as default on
// load; a value is an explicit user choice that flags may still override.
type Settings struct {
Theme string `json:"theme,omitempty"`
Mouse *bool `json:"mouse,omitempty"`
Bell *bool `json:"bel,omitempty"`
Notify *bool `json:"notify,omitempty"`
Plain *bool `json:"plain,omitempty"`
}

// Path returns the settings file location: $BODEK_CONFIG if set, else
// ~/.bodek/config.json (mirroring odek's ~/.odek/config.json convention).
func Path() string {
if p := os.Getenv("BODEK_CONFIG"); p != "" {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return "" // no home dir: persistence is unavailable, Load stays empty
}
return filepath.Join(home, ".bodek", "config.json")
}

// Load reads the settings file. A missing file is not an error — it simply
// yields the zero Settings (everything defaulted). A malformed file IS an
// error so the caller can surface it instead of silently dropping choices.
func Load() (Settings, error) {
var s Settings
data, err := os.ReadFile(Path())
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return s, err
}
if err := json.Unmarshal(data, &s); err != nil {
return s, err
}
return s, nil
}

// Save writes the settings file, creating ~/.bodek when needed. The write
// is a full replace (no merge) — callers load-modify-save to keep unset
// fields intact.
func Save(s Settings) error {
path := Path()
if path == "" {
return os.ErrNotExist
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0o600)
}

// Bool resolves an optional persisted boolean against its built-in default.
func (s Settings) Bool(v *bool, def bool) bool {
if v != nil {
return *v
}
return def
}
Loading