diff --git a/README.md b/README.md index fb1a1ee..2fb087c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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) | @@ -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 | @@ -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, diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 845662f..872c100 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -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" ) @@ -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) @@ -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] [-- ]\n\n") _, _ = fmt.Fprintf(fs.Output(), "A terminal interface for the odek agent.\n\n") @@ -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") } @@ -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) }, diff --git a/cmd/bodek/main_test.go b/cmd/bodek/main_test.go index abbf8ac..8ac5646 100644 --- a/cmd/bodek/main_test.go +++ b/cmd/bodek/main_test.go @@ -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) @@ -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) @@ -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) @@ -74,6 +90,7 @@ 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") @@ -81,6 +98,7 @@ func TestParseConfigUnknownFlag(t *testing.T) { } func TestParseConfigHelp(t *testing.T) { + hermetic(t) var out bytes.Buffer _, err := parseConfig([]string{"-h"}, &out) if !errors.Is(err, flag.ErrHelp) { @@ -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") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..fd0aab8 --- /dev/null +++ b/internal/settings/settings.go @@ -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 +} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..3222c8f --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,171 @@ +package settings + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// homeDir points HOME at a fresh temp dir so tests never touch the real +// ~/.bodek, and returns the expected config path. +func homeDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("BODEK_CONFIG", "") // never honor an inherited override in tests + return filepath.Join(dir, ".bodek", "config.json") +} + +func TestLoadMissingFile(t *testing.T) { + path := homeDir(t) + got, err := Load() + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + if got.Theme != "" { + t.Errorf("Theme = %q, want empty", got.Theme) + } + if got.Mouse != nil || got.Bell != nil || got.Notify != nil || got.Plain != nil { + t.Errorf("boolean settings = %+v, want all unset", got) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Errorf("Load created %s, want read-only behavior", path) + } +} + +func TestSaveLoadRoundTrip(t *testing.T) { + homeDir(t) + in := Settings{ + Theme: "ember-light", + Mouse: ptr(true), + Bell: ptr(false), + Notify: ptr(true), + Plain: ptr(false), + } + if err := Save(in); err != nil { + t.Fatalf("Save() error = %v", err) + } + got, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.Theme != in.Theme { + t.Errorf("Theme = %q, want %q", got.Theme, in.Theme) + } + for name, pair := range map[string][2]*bool{ + "Mouse": {got.Mouse, in.Mouse}, + "Bell": {got.Bell, in.Bell}, + "Notify": {got.Notify, in.Notify}, + "Plain": {got.Plain, in.Plain}, + } { + if pair[0] == nil || pair[1] == nil || *pair[0] != *pair[1] { + t.Errorf("%s = %v, want %v", name, pair[0], pair[1]) + } + } +} + +func TestLoadInvalidJSON(t *testing.T) { + homeDir(t) + if err := os.MkdirAll(filepath.Dir(Path()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(Path(), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(); err == nil { + t.Fatal("Load() error = nil, want invalid-JSON error") + } +} + +func TestPathUnderHome(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("BODEK_CONFIG", "") + want := filepath.Join(dir, ".bodek", "config.json") + if got := Path(); got != want { + t.Errorf("Path() = %q, want %q", got, want) + } +} + +func TestSaveCreatesDir(t *testing.T) { + homeDir(t) + if err := Save(Settings{Theme: "classic"}); err != nil { + t.Fatalf("Save() error = %v", err) + } + if fi, err := os.Stat(Path()); err != nil || fi.IsDir() { + t.Fatalf("Save did not create %s: %v", Path(), err) + } +} + +func TestSaveNoHome(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("BODEK_CONFIG", "") + if err := Save(Settings{Theme: "classic"}); !errors.Is(err, os.ErrNotExist) { + t.Errorf("Save() with no home = %v, want os.ErrNotExist", err) + } +} + +func TestSaveUnwritableDir(t *testing.T) { + dir := t.TempDir() + blocker := filepath.Join(dir, "file") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", dir) + t.Setenv("BODEK_CONFIG", filepath.Join(blocker, "config.json")) // a file can't be a dir + if err := Save(Settings{}); err == nil { + t.Error("Save() under an unwritable path = nil error, want failure") + } +} + +func TestBool(t *testing.T) { + s := Settings{} + if s.Bool(nil, true) != true || s.Bool(nil, false) != false { + t.Error("nil pointer must resolve to the default") + } + on := true + if !(Settings{}).Bool(&on, false) { + t.Error("pointer must win over the default") + } + off := false + if (Settings{}).Bool(&off, true) { + t.Error("explicit false must survive") + } +} + +func TestLoadUnreadableFile(t *testing.T) { + // A directory errors on read with something other than NotExist — that + // error must surface, not silently read as defaults. + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("BODEK_CONFIG", filepath.Join(dir, "config.json")) + if err := os.MkdirAll(Path(), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Load(); err == nil || os.IsNotExist(err) { + t.Errorf("Load() over a directory = %v, want a read error", err) + } +} + +func TestBODEKConfigOverride(t *testing.T) { + dir := t.TempDir() + alt := filepath.Join(dir, "custom.json") + t.Setenv("HOME", dir) + t.Setenv("BODEK_CONFIG", alt) + if err := Save(Settings{Theme: "high-contrast"}); err != nil { + t.Fatalf("Save() error = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".bodek")); !os.IsNotExist(err) { + t.Errorf("Save touched ~/.bodek despite BODEK_CONFIG override") + } + got, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.Theme != "high-contrast" { + t.Errorf("Theme = %q, want high-contrast", got.Theme) + } +} + +func ptr(b bool) *bool { return &b } diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index 02b9922..e4e7d9d 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -47,12 +47,12 @@ func (m *Model) lastReply() string { return "" } -// copyLastReply puts the latest assistant reply on the system clipboard via -// OSC 52: the sequence is consumed by the terminal emulator itself, so no -// external tool runs and the payload never leaves the machine. Terminals -// without OSC 52 support silently ignore the sequence — the note says so. -func (m *Model) copyLastReply() tea.Cmd { - text := m.lastReply() +// copyText puts text on the system clipboard via OSC 52: the sequence is +// consumed by the terminal emulator itself, so no external tool runs and +// the payload never leaves the machine. Terminals without OSC 52 support +// silently ignore the sequence — the note says so. Shared by copy-last- +// reply (^Y) and copy-focused-turn (alt+y). +func (m *Model) copyText(text string) tea.Cmd { if text == "" { return m.transientNoteCmd("nothing to copy — no assistant reply yet") } @@ -62,3 +62,26 @@ func (m *Model) copyLastReply() tea.Cmd { note := m.transientNoteCmd(fmt.Sprintf("copied %d chars via OSC 52 — needs a supporting terminal", len(text))) return tea.Batch(tea.Exec(&rawSeq{seq: ansi.SetSystemClipboard(text)}, nil), note) } + +// focusedReply returns the reply of the turn the cursor last jumped to +// (alt+↑/alt+↓, find jumps), falling back to the latest reply when there +// is no focus or it went stale (messages trimmed, index now a user turn). +func (m *Model) focusedReply() string { + if m.focusIdx >= 0 && m.focusIdx < len(m.msgs) { + if msg := m.msgs[m.focusIdx]; msg.role == roleAsst && msg.content != "" { + return msg.content + } + } + return m.lastReply() +} + +// copyLastReply puts the latest assistant reply on the system clipboard. +func (m *Model) copyLastReply() tea.Cmd { + return m.copyText(m.lastReply()) +} + +// copyFocusedTurn puts the focused turn's reply on the clipboard — any +// earlier answer, not just the newest one. +func (m *Model) copyFocusedTurn() tea.Cmd { + return m.copyText(m.focusedReply()) +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 5565f1e..f6ed3e6 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -38,6 +38,10 @@ func slashCommands() []command { {"copy", "copy the last reply to the clipboard (OSC 52)", func(m *Model, _ string) tea.Cmd { return m.copyLastReply() }}, + {"theme", "switch the color theme — /theme [name]", runTheme}, + {"retry", "re-send the last prompt (alt+r)", func(m *Model, _ string) tea.Cmd { + return m.retryLast() + }}, {"stats", "session metrics & context gauge", func(m *Model, _ string) tea.Cmd { m.showStats() return nil @@ -190,6 +194,44 @@ func (m *Model) openCmdAC(query string) { m.refresh() } +// themeOptions lists the palettes /theme accepts (aliases included in +// canonicalTheme: light, contrast, dark, default). +const themeOptions = "ember-dark · ember-light · high-contrast · classic" + +// runTheme handles /theme: no argument reports the active palette and the +// options; a name switches at runtime and persists via OnThemeChange. +func runTheme(m *Model, args string) tea.Cmd { + if args == "" { + return m.transientNoteCmd("theme: " + themeName() + " — options: " + themeOptions) + } + return m.switchTheme(args) +} + +// switchTheme swaps the active palette mid-run: every style rebuilds from +// the new palette, the glamour renderer is recreated and finalized +// messages re-render through resize(), and the choice persists so the +// next launch starts there (flag > BODEK_THEME > settings file). +func (m *Model) switchTheme(name string) tea.Cmd { + canonical, ok := canonicalTheme(name) + if !ok { + return m.transientNoteCmd("unknown theme: " + name + " — options: " + themeOptions) + } + if canonical == themeName() { + return m.transientNoteCmd(canonical + " is already the active theme") + } + themeOverride = canonical + m.th = themeFrom(paletteByName(canonical)) + m.ta.FocusedStyle.CursorLine = m.th.taCursorLine + m.logoCache = "" // the banner gradient is palette-dependent + m.resize(m.width, m.height) + if m.opts.OnThemeChange != nil { + if err := m.opts.OnThemeChange(canonical); err != nil { + return m.transientNoteCmd("theme set to " + canonical + " — not saved: " + sanitize(err.Error())) + } + } + return m.transientNoteCmd("theme set to " + canonical) +} + // showHelp appends a help card listing commands and key bindings. Like /stats // it is pre-styled to the brand palette (raw), not glamour's stock dark style. func (m *Model) showHelp() { @@ -217,6 +259,8 @@ func (m *Model) showHelp() { {"@", "attach files"}, {"↑↓", "scroll the transcript"}, {"alt+↑↓", "jump to the previous/next turn"}, + {"alt+y", "copy the focused turn's reply (falls back to the latest)"}, + {"alt+r", "re-send the last prompt (/retry)"}, {"^F", "fold/unfold the latest turn card"}, {"tab", "open/close the latest reasoning block"}, {"Pg↑↓", "page the transcript"}, diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 32daec3..8ac8b8e 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -136,6 +136,27 @@ func TestE2EAllCommands(t *testing.T) { t.Fatal("/copy returned nil cmd with a reply on record") } }, + "/theme": func(t *testing.T, m *Model) { + if !notePresent(m, "theme set to classic") { + t.Fatalf("no switch note: %v", m.notices) + } + if themeOverride != "classic" { + t.Errorf("themeOverride = %q, want classic", themeOverride) + } + }, + "/retry": func(t *testing.T, m *Model) { + // The turn itself may already have settled via echoed events + // during pumpWS — the durable signal is the re-sent prompt. + found := false + for _, msg := range m.msgs { + if msg.role == roleUser && strings.Contains(msg.content, "echo me once") { + found = true + } + } + if !found { + t.Errorf("retry did not re-send the seeded prompt (%d msgs)", len(m.msgs)) + } + }, "/stats": func(t *testing.T, m *Model) { card := lastMsg(m) if card == nil || !card.raw || !strings.Contains(plain(card.content), "⬡ session") { @@ -307,6 +328,11 @@ func TestE2EAllCommands(t *testing.T) { m.attachments = append(m.attachments, client.Attachment{Name: "notes.txt", Content: "hello"}) case "/plan": m.sessionID, m.authToken = "s1", "a1" // fetch targets a session + case "/theme": + lines[name] = "/theme classic" + t.Cleanup(func() { themeOverride = "" }) // don't leak into later subtests + case "/retry": + m.lastPrompt = "echo me once" } line := lines[name] if line == "" { diff --git a/internal/tui/find.go b/internal/tui/find.go index 21a4af5..ccda2f0 100644 --- a/internal/tui/find.go +++ b/internal/tui/find.go @@ -127,6 +127,9 @@ func (m *Model) findGoto(dir int) { return } line := m.msgLine(m.find.matches[m.find.sel]) + if idx := m.find.matches[m.find.sel]; idx >= 0 && idx < len(m.msgs) && m.msgs[idx].role == roleAsst { + m.focusIdx = idx // the jumped-to reply becomes the alt+y copy target + } m.find.sel = ((m.find.sel+dir)%n + n) % n if line > 0 { line-- // land with one line of context above the block diff --git a/internal/tui/input.go b/internal/tui/input.go index 8571e51..f3f32c1 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -137,6 +137,7 @@ func (m *Model) submit() tea.Cmd { // sendPrompt appends the user/assistant pair to the transcript, records the // prompt in the history ring, and dispatches it to the server. func (m *Model) sendPrompt(text string) tea.Cmd { + m.lastPrompt = text m.recordHistory(text) shown := text if n := len(m.attachments); n > 0 { @@ -201,6 +202,19 @@ func (m *Model) sendQueued() tea.Cmd { return m.sendPrompt(text) } +// retryLast re-sends the most recent prompt: immediately when idle, queued +// when a turn is running — the same path a mid-turn typed prompt takes. +func (m *Model) retryLast() tea.Cmd { + if m.lastPrompt == "" { + return m.transientNoteCmd("nothing to retry yet — send a prompt first") + } + if m.busy { + m.queue = append(m.queue, m.lastPrompt) + return m.transientNoteCmd("retry queued — it sends when the turn ends") + } + return m.sendPrompt(m.lastPrompt) +} + // maxHistory bounds the in-memory prompt history ring. const maxHistory = 100 diff --git a/internal/tui/model.go b/internal/tui/model.go index 9362cf2..0620922 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -105,6 +105,16 @@ type Options struct { Bell bool Notify bool + // Theme names the startup palette (ember-dark, ember-light, + // high-contrast, classic). Empty defers to BODEK_THEME, then the + // settings file — the same order /theme persists into. + Theme string + + // OnThemeChange persists a runtime /theme switch. Nil (tests, embedded + // uses) skips persistence; an error surfaces as a note while the + // switch still applies for this run. + OnThemeChange func(name string) error + // Plain selects the linear rendering mode: no alt-screen, append-only // scrollback transcript, severity prefixes instead of color (--plain). Plain bool @@ -149,6 +159,8 @@ type Model struct { pal palState // ⌘K command palette — the navigation spine skillSuggest *client.Event // pending skill suggestion card (skill_event "suggested") queue []string // prompts typed mid-turn, sent when the turn ends + lastPrompt string // most recent prompt sent — /retry re-sends it + focusIdx int // transcript cursor: turn head alt+↑/↓ last jumped to (-1 none) history []string // submitted prompts, newest last (recalled with ↑) histNav bool // true while ^P/^N is walking the history @@ -271,6 +283,11 @@ type Model struct { // New builds the initial model. func New(cl *client.Client, opts Options) *Model { + // The startup palette: an explicit option wins, else BODEK_THEME, then + // the persisted settings default — all resolved by themeName(). + if canonical, ok := canonicalTheme(opts.Theme); ok { + themeOverride = canonical + } th := newTheme() ta := textarea.New() @@ -306,6 +323,7 @@ func New(cl *client.Client, opts Options) *Model { ta: ta, sp: sp, curIdx: -1, + focusIdx: -1, model: opts.Model, sandbox: opts.Sandbox, thinkOn: false, @@ -732,6 +750,13 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+y": // Copy the latest reply — a chord, so typing a y is never hijacked. return m, m.copyLastReply() + case "alt+y": + // Copy the focused turn — the one alt+↑/↓ last jumped to (falls back + // to the latest reply). A chord, so typing a y is never hijacked. + return m, m.copyFocusedTurn() + case "alt+r": + // Re-send the last prompt — a chord, so typing an r is never hijacked. + return m, m.retryLast() case "ctrl+g": // Jump to the latest output. A ctrl binding, so typing a capital G // (even as the first character of a prompt) is never hijacked. @@ -1134,6 +1159,7 @@ func (m *Model) jumpTurn(next bool) { } if target < 0 { m.vp.GotoTop() + m.focusTurnAt(m.turnLineIndex[0].line) return } } @@ -1141,9 +1167,18 @@ func (m *Model) jumpTurn(next bool) { target-- // land with one line of context above the head } m.vp.SetYOffset(target) + m.focusTurnAt(target + 1) // the head line we landed under m.refresh() } +// focusTurnAt records the turn head at the given viewport line as the copy +// target (alt+y). A no-op when no head sits on that line. +func (m *Model) focusTurnAt(line int) { + if idx, ok := m.turnAtLine(line); ok { + m.focusIdx = idx + } +} + // turnAtLine maps a viewport content line to a turn head (stepIdx -1). func (m *Model) turnAtLine(line int) (msgIdx int, ok bool) { for _, r := range m.turnLineIndex { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index e58e74b..29ad77e 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -131,9 +131,19 @@ var ( // reduced-functionality mode. var motionEnabled = os.Getenv("NO_MOTION") != "1" -// themeName resolves the configured palette. BODEK_THEME selects -// ember-dark (default) · ember-light · high-contrast · classic. +// themeOverride, when non-empty, replaces the BODEK_THEME env as the active +// palette: set at startup from --theme / the settings file and at runtime +// by /theme. Package-level on purpose — like motionEnabled, a bodek process +// renders exactly one transcript, and glamour's answer styling resolves the +// active palette through themeName(). +var themeOverride string + +// themeName resolves the configured palette: the runtime/startup override +// first, then BODEK_THEME, then the default. func themeName() string { + if themeOverride != "" { + return themeOverride + } switch strings.ToLower(strings.TrimSpace(os.Getenv("BODEK_THEME"))) { case "classic": return "classic" @@ -146,6 +156,24 @@ func themeName() string { } } +// canonicalTheme maps a user-supplied theme name (including the aliases +// BODEK_THEME accepts) to its canonical palette name; ok is false for +// unknown names, so /theme can reject them instead of silently defaulting. +func canonicalTheme(name string) (canonical string, ok bool) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "ember-dark", "dark", "default": + return "ember-dark", true + case "classic": + return "classic", true + case "ember-light", "light": + return "ember-light", true + case "high-contrast", "contrast": + return "high-contrast", true + default: + return "", false + } +} + func paletteByName(name string) palette { switch name { case "classic": diff --git a/internal/tui/top3_test.go b/internal/tui/top3_test.go new file mode 100644 index 0000000..78af76b --- /dev/null +++ b/internal/tui/top3_test.go @@ -0,0 +1,245 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// The top-3 improvements round: runtime /theme with persistence, copy any +// turn card (alt+y), and /retry. Each test drives the real command/key +// paths; persistence is observed through the OnThemeChange hook. + +func lastNote(m *Model) string { + if n := len(m.notices); n > 0 { + return m.notices[n-1] + } + return "" +} + +// --- /theme --- + +func TestThemeListNote(t *testing.T) { + m := wired(t) + m.ta.SetValue("/theme") + exec(m.submit()) + want := "theme: " + themeName() + if !strings.Contains(lastNote(m), want) || !strings.Contains(lastNote(m), "classic") { + t.Errorf("/theme note = %q, want current theme %q plus options", lastNote(m), want) + } +} + +func TestThemeSwitchPersists(t *testing.T) { + m := wired(t) + t.Cleanup(func() { themeOverride = "" }) + var saved string + m.opts.OnThemeChange = func(name string) error { saved = name; return nil } + + m.ta.SetValue("/theme classic") + exec(m.submit()) + + if themeOverride != "classic" { + t.Errorf("themeOverride = %q, want classic", themeOverride) + } + if saved != "classic" { + t.Errorf("OnThemeChange saved %q, want classic", saved) + } + if !strings.Contains(lastNote(m), "classic") { + t.Errorf("switch note = %q, want confirmation", lastNote(m)) + } +} + +func TestThemeSwitchUnknown(t *testing.T) { + m := wired(t) + m.opts.OnThemeChange = func(string) error { t.Fatal("OnThemeChange called for unknown theme"); return nil } + m.ta.SetValue("/theme neon-night") + exec(m.submit()) + if themeOverride == "neon-night" { + t.Error("unknown theme accepted") + } + if !strings.Contains(lastNote(m), "neon-night") { + t.Errorf("note = %q, want unknown-theme mention", lastNote(m)) + } +} + +func TestThemeSwitchAlreadyActive(t *testing.T) { + m := wired(t) + m.ta.SetValue("/theme " + themeName()) + exec(m.submit()) + if !strings.Contains(lastNote(m), "already") { + t.Errorf("note = %q, want already-active hint", lastNote(m)) + } +} + +func TestCanonicalTheme(t *testing.T) { + cases := []struct { + in string + want string + ok bool + }{ + {"ember-dark", "ember-dark", true}, + {"dark", "ember-dark", true}, + {"ember-light", "ember-light", true}, + {"light", "ember-light", true}, + {"high-contrast", "high-contrast", true}, + {"contrast", "high-contrast", true}, + {"classic", "classic", true}, + {" Classic ", "classic", true}, + {"neon-night", "", false}, + {"", "", false}, + } + for _, c := range cases { + got, ok := canonicalTheme(c.in) + if got != c.want || ok != c.ok { + t.Errorf("canonicalTheme(%q) = (%q,%v), want (%q,%v)", c.in, got, ok, c.want, c.ok) + } + } +} + +func TestThemeSwitchRebuildsGlam(t *testing.T) { + m := wired(t) + t.Cleanup(func() { themeOverride = "" }) + m.msgs = append(m.msgs, + message{role: roleUser, content: "hi"}, + message{role: roleAsst, content: "**bold** answer"}, + ) + m.msgs[1].rendered = m.render(m.msgs[1].content) + m.ta.SetValue("/theme ember-light") + exec(m.submit()) + // The finalized message must have been re-rendered under the new palette + // (resize() runs on switch; its render must differ from the old cache + // only if colors changed — here we assert it was recomputed at all). + if m.msgs[1].rendered == "" { + t.Error("finalized message not re-rendered after theme switch") + } +} + +// --- copy any turn card (alt+y) --- + +func TestCopyFocusedTurn(t *testing.T) { + m := wired(t) + m.msgs = append(m.msgs, + message{role: roleUser, content: "first prompt"}, + message{role: roleAsst, content: "first reply"}, + message{role: roleUser, content: "second prompt"}, + message{role: roleAsst, content: "second reply"}, + ) + m.refresh() // rebuilds the turn-head line index the jumps navigate + + // Jump to the previous turn twice: lands on the first assistant head. + m.Update(key("alt+up")) + m.Update(key("alt+up")) + if m.focusIdx < 0 { + t.Fatalf("focusIdx = %d, want a turn head after alt+up jumps", m.focusIdx) + } + if got := m.focusedReply(); got != "first reply" { + t.Errorf("focusedReply = %q, want first reply", got) + } + + // alt+y copies the focused turn through the OSC 52 path (exec command). + _, cmd := m.Update(key("alt+y")) + if cmd == nil { + t.Fatal("alt+y produced no command") + } +} + +func TestCopyFocusedTurnFallsBackToLastReply(t *testing.T) { + m := wired(t) + m.msgs = append(m.msgs, + message{role: roleUser, content: "p"}, + message{role: roleAsst, content: "the only reply"}, + ) + if got := m.focusedReply(); got != "the only reply" { + t.Errorf("focusedReply without focus = %q, want last reply", got) + } + // A stale index (out of range or non-assistant) must fall back, not panic. + m.focusIdx = 99 + if got := m.focusedReply(); got != "the only reply" { + t.Errorf("focusedReply with stale focus = %q, want last reply", got) + } + m.focusIdx = 0 // user message — not copyable + if got := m.focusedReply(); got != "the only reply" { + t.Errorf("focusedReply on user msg = %q, want last reply", got) + } +} + +func TestCopyFocusedTurnIsEmpty(t *testing.T) { + m := wired(t) + _, cmd := m.Update(key("alt+y")) + if cmd == nil { + t.Fatal("alt+y on an empty transcript produced no command") + } + if !strings.Contains(lastNote(m), "nothing to copy") { + t.Errorf("note = %q, want nothing-to-copy hint", lastNote(m)) + } +} + +// --- /retry --- + +func TestRetryResendsLastPrompt(t *testing.T) { + m := wired(t) + m.handleEvent(client.Event{Type: "session", SessionID: "s1"}) + m.ta.SetValue("build the thing") + exec(m.submit()) + if !m.busy { + t.Fatal("prompt did not start a turn") + } + // End the turn like the server would. + m.handleEvent(client.Event{Type: "done", Content: "ok"}) + m.busy = false + + m.ta.SetValue("/retry") + exec(m.submit()) + + if !m.busy { + t.Error("/retry did not start a new turn") + } + if n := len(m.msgs); n < 2 { + t.Fatalf("/retry produced %d messages, want at least the user echo", n) + } + if last := m.msgs[len(m.msgs)-2]; last.role != roleUser || !strings.Contains(last.content, "build the thing") { + t.Errorf("retry sent %q, want the previous prompt", last.content) + } +} + +func TestRetryWithoutHistory(t *testing.T) { + m := wired(t) + m.ta.SetValue("/retry") + exec(m.submit()) + if !strings.Contains(lastNote(m), "nothing to retry") { + t.Errorf("note = %q, want nothing-to-retry hint", lastNote(m)) + } +} + +func TestRetryWhileBusyQueues(t *testing.T) { + m := wired(t) + m.handleEvent(client.Event{Type: "session", SessionID: "s1"}) + m.ta.SetValue("first prompt") + exec(m.submit()) // starts a turn — busy + + m.ta.SetValue("/retry") + exec(m.submit()) + + if len(m.queue) != 1 || m.queue[0] != "first prompt" { + t.Errorf("queue = %v, want [first prompt]", m.queue) + } + if !strings.Contains(lastNote(m), "queued") { + t.Errorf("note = %q, want queued hint", lastNote(m)) + } +} + +func TestRetryChordAltR(t *testing.T) { + m := wired(t) + m.lastPrompt = "from the chord" + _, cmd := m.Update(key("alt+r")) + if cmd == nil { + t.Fatal("alt+r produced no command") + } + if !m.busy { + t.Error("alt+r did not start a turn") + } + if m.lastPrompt != "from the chord" { + t.Errorf("lastPrompt = %q", m.lastPrompt) + } +}