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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ bodek --sandbox # run tool calls inside odek's
bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed
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 -- --prompt-caching # pass extra flags through to `odek serve`
```

Expand Down Expand Up @@ -103,7 +104,8 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
| `^J` | Insert a newline in the input |
| `^L` | Clear the conversation |
| `Esc` | Cancel the running turn |
| `↑` / `↓` / `PgUp` / `PgDn` / wheel | Scroll the transcript |
| `↑` / `↓` / `PgUp` / `PgDn` | Scroll the transcript |
| `wheel` (with `--mouse`) | Scroll the transcript |
| `^C` | Quit |

### Commands (`/`)
Expand Down
96 changes: 64 additions & 32 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,60 @@ func main() {
}
}

func run() error {
var (
url = flag.String("url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080/?token=…)")
token = flag.String("token", "", "WS auth token for an attached odek serve (as printed at its startup)")
sandbox = flag.Bool("sandbox", false, "run tool calls inside odek's Docker sandbox")
bin = flag.String("odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)")
)
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: bodek [options] [-- <odek serve flags>]\n\n")
fmt.Fprintf(os.Stderr, "A terminal interface for the odek agent.\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " bodek # spawn odek serve and start chatting\n")
fmt.Fprintf(os.Stderr, " bodek --sandbox # spawn odek serve with Docker sandbox\n")
fmt.Fprintf(os.Stderr, " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n")
fmt.Fprintf(os.Stderr, " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n")
fmt.Fprintf(os.Stderr, " bodek -- --prompt-caching # pass extra flags to odek serve\n")
type config struct {
url string
token string
sandbox bool
bin string
mouse bool
extraArgs []string
}

func parseConfig(args []string, output io.Writer) (config, error) {
var cfg config
fs := flag.NewFlagSet("bodek", flag.ContinueOnError)
if output != nil {
fs.SetOutput(output)
}
fs.StringVar(&cfg.url, "url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080/?token=…)")
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.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")
_, _ = fmt.Fprintf(fs.Output(), "Options:\n")
fs.PrintDefaults()
_, _ = fmt.Fprintf(fs.Output(), "\nExamples:\n")
_, _ = fmt.Fprintf(fs.Output(), " bodek # spawn odek serve and start chatting\n")
_, _ = fmt.Fprintf(fs.Output(), " bodek --sandbox # spawn odek serve with Docker sandbox\n")
_, _ = 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 -- --prompt-caching # pass extra flags to odek serve\n")
}
flag.Parse()
if err := fs.Parse(args); err != nil {
fs.Usage()
return config{}, err
}
cfg.extraArgs = fs.Args()
return cfg, nil
}

extraArgs := flag.Args()
func buildProgramOptions(mouse bool) []tea.ProgramOption {
opts := []tea.ProgramOption{tea.WithAltScreen()}
if mouse {
opts = append(opts, tea.WithMouseCellMotion())
}
return opts
}

func run() error {
cfg, err := parseConfig(os.Args[1:], os.Stderr)
if err != nil {
return err
}

// 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 All @@ -62,7 +94,7 @@ func run() error {
// io.MultiWriter / *ringWriter below) without the redundant typed-var
// declaration that staticcheck's QF1011 flags.
serverErr := io.Writer(io.Discard)
if *url == "" {
if cfg.url == "" {
logTail = newRingWriter(50)
if f, path, closeLog := openServerLog(); path != "" {
defer closeLog()
Expand All @@ -75,11 +107,11 @@ func run() error {

// Spawn or attach to the odek serve backend.
srv, err := server.Connect(server.Options{
URL: *url,
Token: *token,
Bin: *bin,
Sandbox: *sandbox,
ExtraArgs: extraArgs,
URL: cfg.url,
Token: cfg.token,
Bin: cfg.bin,
Sandbox: cfg.sandbox,
ExtraArgs: cfg.extraArgs,
Stderr: serverErr,
})
if err != nil {
Expand Down Expand Up @@ -108,16 +140,16 @@ func run() error {
setupSignalHandler(srv, cl)

model := tui.New(cl, tui.Options{
Sandbox: *sandbox,
Sandbox: cfg.sandbox,
CWD: cwd,
LogPath: logPath,
})

// Mouse reporting enables wheel scrolling in the transcript. Click-drag text
// selection is delegated to the terminal's shift+drag fallback where the
// terminal supports it; otherwise users can rely on keyboard scrolling
// (↑/↓, PgUp/PgDn, ^U/^D).
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
// Mouse reporting enables wheel scrolling in the transcript, but it also
// 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)...)
if _, err := p.Run(); err != nil {
return fmt.Errorf("TUI exited: %w", err)
}
Expand Down
92 changes: 92 additions & 0 deletions cmd/bodek/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"bytes"
"errors"
"flag"
"io"
"testing"

tea "github.com/charmbracelet/bubbletea"
)

func TestParseConfigDefaults(t *testing.T) {
cfg, err := parseConfig(nil, io.Discard)
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
}
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 len(cfg.extraArgs) != 0 {
t.Errorf("expected no extra args, got %v", cfg.extraArgs)
}
}

func TestParseConfigMouseFlag(t *testing.T) {
cfg, err := parseConfig([]string{"--mouse"}, io.Discard)
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
}
if !cfg.mouse {
t.Error("expected --mouse to set mouse=true")
}
}

func TestParseConfigExtraArgs(t *testing.T) {
cfg, err := parseConfig([]string{"--mouse", "--", "--prompt-caching", "--verbose"}, io.Discard)
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
}
if !cfg.mouse {
t.Error("expected --mouse to be parsed before --")
}
want := []string{"--prompt-caching", "--verbose"}
if len(cfg.extraArgs) != len(want) {
t.Fatalf("expected extra args %v, got %v", want, cfg.extraArgs)
}
for i := range want {
if cfg.extraArgs[i] != want[i] {
t.Errorf("extra arg %d: want %q, got %q", i, want[i], cfg.extraArgs[i])
}
}
}

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

func TestParseConfigHelp(t *testing.T) {
var out bytes.Buffer
_, err := parseConfig([]string{"-h"}, &out)
if !errors.Is(err, flag.ErrHelp) {
t.Fatalf("expected flag.ErrHelp, got %v", err)
}
if out.Len() == 0 {
t.Error("expected usage output on -h")
}
}

func TestBuildProgramOptionsDefault(t *testing.T) {
opts := buildProgramOptions(false)
if len(opts) != 1 {
t.Fatalf("expected 1 default program option, got %d", len(opts))
}
// Sanity check: the option is callable like a real tea.ProgramOption.
var p tea.Program
_ = p
_ = opts[0]
}

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