From 4401a7f4e40ce7bdea72147e3a0b7dfcfa754cd8 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 26 Jul 2026 21:16:35 +0200 Subject: [PATCH 1/3] fix(tui): allow native text selection/copy by disabling mouse capture by default Mouse reporting (tea.WithMouseCellMotion) captures terminal mouse events and prevents standard click-drag selection and copy. Keep mouse support off by default and add a --mouse flag for users who prefer wheel scrolling. - add --mouse CLI flag - only enable WithMouseCellMotion when --mouse is set - update README usage examples and key bindings --- README.md | 4 +++- cmd/bodek/main.go | 15 ++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 846cd1b..1ce55ac 100644 --- a/README.md +++ b/README.md @@ -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` ``` @@ -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 (`/`) diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 6e919fc..712d411 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -32,6 +32,7 @@ func run() error { 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)") + mouse = flag.Bool("mouse", false, "enable mouse wheel scrolling (disables native text selection/copy)") ) flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: bodek [options] [-- ]\n\n") @@ -113,11 +114,15 @@ func run() error { 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. + programOpts := []tea.ProgramOption{tea.WithAltScreen()} + if *mouse { + programOpts = append(programOpts, tea.WithMouseCellMotion()) + } + p := tea.NewProgram(model, programOpts...) if _, err := p.Run(); err != nil { return fmt.Errorf("TUI exited: %w", err) } From b6d9a56c9a05e3ad9c5ff76a0b3b319f7d477e20 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 26 Jul 2026 21:19:16 +0200 Subject: [PATCH 2/3] test(tui): add tests for --mouse flag and program options - Extract parseConfig and buildProgramOptions so they are unit-testable. - Cover default parsing, --mouse, extra args after --, unknown flags, and help. - Verify program option counts for default and mouse-enabled modes. --- cmd/bodek/main.go | 93 +++++++++++++++++++++++++++--------------- cmd/bodek/main_test.go | 92 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 cmd/bodek/main_test.go diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 712d411..874724f 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -26,29 +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)") - mouse = flag.Bool("mouse", false, "enable mouse wheel scrolling (disables native text selection/copy)") - ) - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: bodek [options] [-- ]\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] [-- ]\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") + } + if err := fs.Parse(args); err != nil { + fs.Usage() + return config{}, err + } + cfg.extraArgs = fs.Args() + return cfg, nil +} + +func buildProgramOptions(mouse bool) []tea.ProgramOption { + opts := []tea.ProgramOption{tea.WithAltScreen()} + if mouse { + opts = append(opts, tea.WithMouseCellMotion()) } - flag.Parse() + return opts +} - extraArgs := flag.Args() +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 @@ -63,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() @@ -76,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 { @@ -109,7 +140,7 @@ func run() error { setupSignalHandler(srv, cl) model := tui.New(cl, tui.Options{ - Sandbox: *sandbox, + Sandbox: cfg.sandbox, CWD: cwd, LogPath: logPath, }) @@ -118,11 +149,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. - programOpts := []tea.ProgramOption{tea.WithAltScreen()} - if *mouse { - programOpts = append(programOpts, tea.WithMouseCellMotion()) - } - p := tea.NewProgram(model, programOpts...) + p := tea.NewProgram(model, buildProgramOptions(cfg.mouse)...) if _, err := p.Run(); err != nil { return fmt.Errorf("TUI exited: %w", err) } diff --git a/cmd/bodek/main_test.go b/cmd/bodek/main_test.go new file mode 100644 index 0000000..bbaed43 --- /dev/null +++ b/cmd/bodek/main_test.go @@ -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)) + } +} From f56dd80c5bf3767cf8db522296b451284ea7abe9 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 26 Jul 2026 21:29:29 +0200 Subject: [PATCH 3/3] style(tui): satisfy errcheck in usage printer --- cmd/bodek/main.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 874724f..5fcf1f8 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -47,17 +47,17 @@ func parseConfig(args []string, output io.Writer) (config, error) { 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] [-- ]\n\n") - fmt.Fprintf(fs.Output(), "A terminal interface for the odek agent.\n\n") - fmt.Fprintf(fs.Output(), "Options:\n") + _, _ = fmt.Fprintf(fs.Output(), "Usage: bodek [options] [-- ]\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") + _, _ = 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") } if err := fs.Parse(args); err != nil { fs.Usage()