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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL
bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token
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 --plain # linear mode: transcript to scrollback (a11y, pipes)
bodek -- --prompt-caching # pass extra flags through to `odek serve`
bodek version # print the bodek version
bodek upgrade # download and install the latest release
Expand Down Expand Up @@ -320,6 +323,24 @@ one `Esc`.
quiet status lines. Nothing lingers: info traces fade after 3s, and
errors, warnings, and disconnect notes autoclose after 10s (connection
state stays visible in the header badge).
- **Attention when backgrounded** — turn completion and pending approvals set
the terminal window title (`✓ done — <model>` / `⚠ approval needed —
<model>`) and ring the bell (`--bel=false` mutes); `--notify` adds OSC 9
desktop notifications. Fires only on terminal states — never per token.
- **Linear mode (`--plain`)** — skips the alt-screen entirely: agent events
print as append-only text in the terminal's native scrollback above a
minimal input chrome (`▸` tool calls, `[think]`, `[error]`, `⚠ approval`,
`❯` your prompts, `✓ done · N tools · Xs · N tok`). Severity never rides
color alone, which makes this the accessible surface for screen readers —
and the natural one for pipes: `bodek --plain < task > run.log`. Streamed
fragments stay suppressed; the reply lands whole when the turn ends.
`NO_COLOR` degrades the entire EMBER palette to plain text in every mode.
- **Diff-aware tool steps** — expanding a step (`^E`) renders its output by
shape: unified diffs tint (`+` green, `-` red, hunk headers steel, file
markers dim) with a `+N −M` chip on the step head, fenced ` ```diff `
blocks tint inside prose without mis-styling the surrounding text, file
reads get line numbers, JSON pretty-prints, and test runs summarize
pass/fail on the step line.
- **Version display** — the header shows bodek's own version next to the logo
and the spawned odek's version next to the model name.
- **Update hint** — at startup, a quiet note appears when a newer bodek release
Expand Down
35 changes: 32 additions & 3 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"syscall"

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

"github.com/BackendStack21/bodek/internal/client"
"github.com/BackendStack21/bodek/internal/server"
Expand All @@ -32,6 +34,9 @@ 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)
extraArgs []string
}

Expand All @@ -46,6 +51,9 @@ func parseConfig(args []string, output io.Writer) (config, error) {
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)")
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 @@ -60,6 +68,8 @@ func parseConfig(args []string, output io.Writer) (config, error) {
_, _ = fmt.Fprintf(fs.Output(), " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n")
_, _ = 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 --plain # linear mode: transcript to scrollback (pipes, a11y)\n")
_, _ = fmt.Fprintf(fs.Output(), " bodek -- --prompt-caching # pass extra flags to odek serve\n")
}
if err := fs.Parse(args); err != nil {
Expand All @@ -70,14 +80,29 @@ func parseConfig(args []string, output io.Writer) (config, error) {
return cfg, nil
}

func buildProgramOptions(mouse bool) []tea.ProgramOption {
opts := []tea.ProgramOption{tea.WithAltScreen()}
func buildProgramOptions(mouse, plain bool) []tea.ProgramOption {
var opts []tea.ProgramOption
if !plain {
// The alt-screen transcript is the default surface. Linear mode
// (--plain) stays on the main buffer so printed lines persist in
// the terminal's native scrollback.
opts = append(opts, tea.WithAltScreen())
}
if mouse {
opts = append(opts, tea.WithMouseCellMotion())
}
return opts
}

// applyNoColor honors https://no-color.org deterministically: when the
// variable is set, the whole palette (EMBER gradients included) degrades
// to plain text regardless of what the terminal advertises.
func applyNoColor() {
if os.Getenv("NO_COLOR") != "" {
lipgloss.SetColorProfile(termenv.Ascii)
}
}

func run() error {
// Bare subcommands (`bodek version`, `bodek upgrade`) bypass the TUI
// entirely, so they run before flag parsing.
Expand All @@ -89,6 +114,7 @@ func run() error {
if err != nil {
return err
}
applyNoColor()

// A spawned `odek serve` logs to stderr. Routing that to our own terminal
// would corrupt the Bubble Tea alt-screen (stray writes desync the diff
Expand Down Expand Up @@ -154,6 +180,9 @@ func run() error {
LogPath: logPath,
OdekVersion: srv.Version,
Version: currentVersion(),
Bell: cfg.bel,
Notify: cfg.notify,
Plain: cfg.plain,
Reconnect: func() (*client.Client, error) {
return client.Dial(srv.WSURL, srv.Origin, srv.BaseURL, srv.Token)
},
Expand All @@ -163,7 +192,7 @@ func run() error {
// captures the terminal mouse and blocks native click-drag text selection
// and copy. Keep it off by default so users can copy freely; enable it only
// when explicitly requested with --mouse.
p := tea.NewProgram(model, buildProgramOptions(cfg.mouse)...)
p := tea.NewProgram(model, buildProgramOptions(cfg.mouse, cfg.plain)...)
if _, err := p.Run(); err != nil {
return fmt.Errorf("TUI exited: %w", err)
}
Expand Down
44 changes: 40 additions & 4 deletions cmd/bodek/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"testing"

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

func TestParseConfigDefaults(t *testing.T) {
Expand All @@ -18,8 +20,11 @@ func TestParseConfigDefaults(t *testing.T) {
if cfg.url != "" || cfg.token != "" || cfg.bin != "" {
t.Errorf("unexpected non-empty defaults: %+v", cfg)
}
if cfg.sandbox || cfg.mouse {
t.Errorf("expected sandbox and mouse to be false by default, got sandbox=%v mouse=%v", cfg.sandbox, cfg.mouse)
if cfg.sandbox || cfg.mouse || cfg.notify {
t.Errorf("expected sandbox, mouse, notify false by default, got sandbox=%v mouse=%v notify=%v", cfg.sandbox, cfg.mouse, cfg.notify)
}
if !cfg.bel {
t.Error("expected bel to default to true (attention bell on)")
}
if len(cfg.extraArgs) != 0 {
t.Errorf("expected no extra args, got %v", cfg.extraArgs)
Expand All @@ -36,6 +41,19 @@ func TestParseConfigMouseFlag(t *testing.T) {
}
}

func TestParseConfigAttentionFlags(t *testing.T) {
cfg, err := parseConfig([]string{"--bel=false", "--notify"}, io.Discard)
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
}
if cfg.bel {
t.Error("expected --bel=false to mute the attention bell")
}
if !cfg.notify {
t.Error("expected --notify to enable desktop notifications")
}
}

func TestParseConfigExtraArgs(t *testing.T) {
cfg, err := parseConfig([]string{"--mouse", "--", "--prompt-caching", "--verbose"}, io.Discard)
if err != nil {
Expand Down Expand Up @@ -74,7 +92,7 @@ func TestParseConfigHelp(t *testing.T) {
}

func TestBuildProgramOptionsDefault(t *testing.T) {
opts := buildProgramOptions(false)
opts := buildProgramOptions(false, false)
if len(opts) != 1 {
t.Fatalf("expected 1 default program option, got %d", len(opts))
}
Expand All @@ -85,8 +103,26 @@ func TestBuildProgramOptionsDefault(t *testing.T) {
}

func TestBuildProgramOptionsWithMouse(t *testing.T) {
opts := buildProgramOptions(true)
opts := buildProgramOptions(true, false)
if len(opts) != 2 {
t.Fatalf("expected 2 program options with mouse, got %d", len(opts))
}
}

func TestBuildProgramOptionsPlain(t *testing.T) {
if opts := buildProgramOptions(false, true); len(opts) != 0 {
t.Fatalf("plain mode must skip the alt-screen, got %d options", len(opts))
}
if opts := buildProgramOptions(true, true); len(opts) != 1 {
t.Fatalf("plain+mouse = %d options, want mouse only", len(opts))
}
}

func TestApplyNoColor(t *testing.T) {
defer lipgloss.SetColorProfile(termenv.TrueColor)
t.Setenv("NO_COLOR", "1")
applyNoColor()
if lipgloss.ColorProfile() != termenv.Ascii {
t.Error("NO_COLOR did not degrade the color profile to Ascii")
}
}
85 changes: 85 additions & 0 deletions internal/tui/attention.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package tui

import (
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)

// attentionKind selects the terminal state the attention layer announces.
type attentionKind int

const (
attentionDone attentionKind = iota // a turn finished (done event)
attentionApproval // an approval is waiting (approval_request)
)

// attention is the plan of terminal-attention effects for one state change.
// The window title always updates — it is silent and keeps tmux/terminal
// window lists truthful. The bell (--bel=false mutes) and the desktop
// notification (--notify enables, OSC 9) are user-gated. Fires only on
// terminal states, never on streamed tokens.
type attention struct {
title string // OSC 0 window title ("" = leave the title alone)
bell bool // ring the terminal bell
notify string // OSC 9 desktop notification text ("" = none)
}

func (a attention) empty() bool {
return a.title == "" && !a.bell && a.notify == ""
}

// sequence renders the plan as one raw terminal write: title, notification,
// bell — in that order. Every embedded string is pre-sanitized by the
// planner, so control bytes from the wire cannot inject escapes here.
func (a attention) sequence() string {
var b strings.Builder
if a.title != "" {
b.WriteString(ansi.SetWindowTitle(a.title))
}
if a.notify != "" {
b.WriteString("\x1b]9;" + a.notify + "\x07")
}
if a.bell {
b.WriteString("\a")
}
return b.String()
}

// attentionFor decides the attention plan for a terminal state. Wire-borne
// pieces (the model name) go through collapse() — sanitize + whitespace
// flatten — because both consumers are raw escape-sequence payloads.
func (m *Model) attentionFor(kind attentionKind) attention {
model := collapse(m.model)
var prefix, note string
switch kind {
case attentionApproval:
prefix, note = "⚠ approval needed", "bodek: approval needed"
case attentionDone:
prefix, note = "✓ done", "bodek: turn complete"
default:
return attention{}
}
a := attention{bell: m.bell}
if model != "" {
a.title = prefix + " — " + model
a.notify = note + " — " + model
} else {
a.title = prefix
a.notify = note
}
if !m.notify {
a.notify = ""
}
return a
}

// attentionCmd materializes the plan as a single raw write via tea.Exec —
// the same escape hatch as the OSC 52 clipboard write. Nil when all-muted.
func (m *Model) attentionCmd(a attention) tea.Cmd {
if a.empty() {
return nil
}
return tea.Exec(&rawSeq{seq: a.sequence()}, nil)
}
Loading