Skip to content

Commit 883482c

Browse files
authored
Merge pull request #10 from BackendStack21/fix/tui-copy
fix(tui): allow native text selection/copy by default; add --mouse flag
2 parents 4c54d13 + f56dd80 commit 883482c

3 files changed

Lines changed: 159 additions & 33 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ bodek --sandbox # run tool calls inside odek's
7676
bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed
7777
bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token
7878
bodek --odek-bin ./odek # use a specific odek binary
79+
bodek --mouse # enable mouse wheel scrolling (blocks text selection)
7980
bodek -- --prompt-caching # pass extra flags through to `odek serve`
8081
```
8182

@@ -103,7 +104,8 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
103104
| `^J` | Insert a newline in the input |
104105
| `^L` | Clear the conversation |
105106
| `Esc` | Cancel the running turn |
106-
| `` / `` / `PgUp` / `PgDn` / wheel | Scroll the transcript |
107+
| `` / `` / `PgUp` / `PgDn` | Scroll the transcript |
108+
| `wheel` (with `--mouse`) | Scroll the transcript |
107109
| `^C` | Quit |
108110

109111
### Commands (`/`)

cmd/bodek/main.go

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,60 @@ func main() {
2626
}
2727
}
2828

29-
func run() error {
30-
var (
31-
url = flag.String("url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080/?token=…)")
32-
token = flag.String("token", "", "WS auth token for an attached odek serve (as printed at its startup)")
33-
sandbox = flag.Bool("sandbox", false, "run tool calls inside odek's Docker sandbox")
34-
bin = flag.String("odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)")
35-
)
36-
flag.Usage = func() {
37-
fmt.Fprintf(os.Stderr, "Usage: bodek [options] [-- <odek serve flags>]\n\n")
38-
fmt.Fprintf(os.Stderr, "A terminal interface for the odek agent.\n\n")
39-
fmt.Fprintf(os.Stderr, "Options:\n")
40-
flag.PrintDefaults()
41-
fmt.Fprintf(os.Stderr, "\nExamples:\n")
42-
fmt.Fprintf(os.Stderr, " bodek # spawn odek serve and start chatting\n")
43-
fmt.Fprintf(os.Stderr, " bodek --sandbox # spawn odek serve with Docker sandbox\n")
44-
fmt.Fprintf(os.Stderr, " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n")
45-
fmt.Fprintf(os.Stderr, " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n")
46-
fmt.Fprintf(os.Stderr, " bodek -- --prompt-caching # pass extra flags to odek serve\n")
29+
type config struct {
30+
url string
31+
token string
32+
sandbox bool
33+
bin string
34+
mouse bool
35+
extraArgs []string
36+
}
37+
38+
func parseConfig(args []string, output io.Writer) (config, error) {
39+
var cfg config
40+
fs := flag.NewFlagSet("bodek", flag.ContinueOnError)
41+
if output != nil {
42+
fs.SetOutput(output)
43+
}
44+
fs.StringVar(&cfg.url, "url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080/?token=…)")
45+
fs.StringVar(&cfg.token, "token", "", "WS auth token for an attached odek serve (as printed at its startup)")
46+
fs.BoolVar(&cfg.sandbox, "sandbox", false, "run tool calls inside odek's Docker sandbox")
47+
fs.StringVar(&cfg.bin, "odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)")
48+
fs.BoolVar(&cfg.mouse, "mouse", false, "enable mouse wheel scrolling (disables native text selection/copy)")
49+
fs.Usage = func() {
50+
_, _ = fmt.Fprintf(fs.Output(), "Usage: bodek [options] [-- <odek serve flags>]\n\n")
51+
_, _ = fmt.Fprintf(fs.Output(), "A terminal interface for the odek agent.\n\n")
52+
_, _ = fmt.Fprintf(fs.Output(), "Options:\n")
53+
fs.PrintDefaults()
54+
_, _ = fmt.Fprintf(fs.Output(), "\nExamples:\n")
55+
_, _ = fmt.Fprintf(fs.Output(), " bodek # spawn odek serve and start chatting\n")
56+
_, _ = fmt.Fprintf(fs.Output(), " bodek --sandbox # spawn odek serve with Docker sandbox\n")
57+
_, _ = fmt.Fprintf(fs.Output(), " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n")
58+
_, _ = fmt.Fprintf(fs.Output(), " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n")
59+
_, _ = fmt.Fprintf(fs.Output(), " bodek --mouse # enable mouse wheel scrolling (blocks text selection)\n")
60+
_, _ = fmt.Fprintf(fs.Output(), " bodek -- --prompt-caching # pass extra flags to odek serve\n")
4761
}
48-
flag.Parse()
62+
if err := fs.Parse(args); err != nil {
63+
fs.Usage()
64+
return config{}, err
65+
}
66+
cfg.extraArgs = fs.Args()
67+
return cfg, nil
68+
}
4969

50-
extraArgs := flag.Args()
70+
func buildProgramOptions(mouse bool) []tea.ProgramOption {
71+
opts := []tea.ProgramOption{tea.WithAltScreen()}
72+
if mouse {
73+
opts = append(opts, tea.WithMouseCellMotion())
74+
}
75+
return opts
76+
}
77+
78+
func run() error {
79+
cfg, err := parseConfig(os.Args[1:], os.Stderr)
80+
if err != nil {
81+
return err
82+
}
5183

5284
// A spawned `odek serve` logs to stderr. Routing that to our own terminal
5385
// would corrupt the Bubble Tea alt-screen (stray writes desync the diff
@@ -62,7 +94,7 @@ func run() error {
6294
// io.MultiWriter / *ringWriter below) without the redundant typed-var
6395
// declaration that staticcheck's QF1011 flags.
6496
serverErr := io.Writer(io.Discard)
65-
if *url == "" {
97+
if cfg.url == "" {
6698
logTail = newRingWriter(50)
6799
if f, path, closeLog := openServerLog(); path != "" {
68100
defer closeLog()
@@ -75,11 +107,11 @@ func run() error {
75107

76108
// Spawn or attach to the odek serve backend.
77109
srv, err := server.Connect(server.Options{
78-
URL: *url,
79-
Token: *token,
80-
Bin: *bin,
81-
Sandbox: *sandbox,
82-
ExtraArgs: extraArgs,
110+
URL: cfg.url,
111+
Token: cfg.token,
112+
Bin: cfg.bin,
113+
Sandbox: cfg.sandbox,
114+
ExtraArgs: cfg.extraArgs,
83115
Stderr: serverErr,
84116
})
85117
if err != nil {
@@ -108,16 +140,16 @@ func run() error {
108140
setupSignalHandler(srv, cl)
109141

110142
model := tui.New(cl, tui.Options{
111-
Sandbox: *sandbox,
143+
Sandbox: cfg.sandbox,
112144
CWD: cwd,
113145
LogPath: logPath,
114146
})
115147

116-
// Mouse reporting enables wheel scrolling in the transcript. Click-drag text
117-
// selection is delegated to the terminal's shift+drag fallback where the
118-
// terminal supports it; otherwise users can rely on keyboard scrolling
119-
// (↑/↓, PgUp/PgDn, ^U/^D).
120-
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
148+
// Mouse reporting enables wheel scrolling in the transcript, but it also
149+
// captures the terminal mouse and blocks native click-drag text selection
150+
// and copy. Keep it off by default so users can copy freely; enable it only
151+
// when explicitly requested with --mouse.
152+
p := tea.NewProgram(model, buildProgramOptions(cfg.mouse)...)
121153
if _, err := p.Run(); err != nil {
122154
return fmt.Errorf("TUI exited: %w", err)
123155
}

cmd/bodek/main_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"flag"
7+
"io"
8+
"testing"
9+
10+
tea "github.com/charmbracelet/bubbletea"
11+
)
12+
13+
func TestParseConfigDefaults(t *testing.T) {
14+
cfg, err := parseConfig(nil, io.Discard)
15+
if err != nil {
16+
t.Fatalf("parseConfig returned error: %v", err)
17+
}
18+
if cfg.url != "" || cfg.token != "" || cfg.bin != "" {
19+
t.Errorf("unexpected non-empty defaults: %+v", cfg)
20+
}
21+
if cfg.sandbox || cfg.mouse {
22+
t.Errorf("expected sandbox and mouse to be false by default, got sandbox=%v mouse=%v", cfg.sandbox, cfg.mouse)
23+
}
24+
if len(cfg.extraArgs) != 0 {
25+
t.Errorf("expected no extra args, got %v", cfg.extraArgs)
26+
}
27+
}
28+
29+
func TestParseConfigMouseFlag(t *testing.T) {
30+
cfg, err := parseConfig([]string{"--mouse"}, io.Discard)
31+
if err != nil {
32+
t.Fatalf("parseConfig returned error: %v", err)
33+
}
34+
if !cfg.mouse {
35+
t.Error("expected --mouse to set mouse=true")
36+
}
37+
}
38+
39+
func TestParseConfigExtraArgs(t *testing.T) {
40+
cfg, err := parseConfig([]string{"--mouse", "--", "--prompt-caching", "--verbose"}, io.Discard)
41+
if err != nil {
42+
t.Fatalf("parseConfig returned error: %v", err)
43+
}
44+
if !cfg.mouse {
45+
t.Error("expected --mouse to be parsed before --")
46+
}
47+
want := []string{"--prompt-caching", "--verbose"}
48+
if len(cfg.extraArgs) != len(want) {
49+
t.Fatalf("expected extra args %v, got %v", want, cfg.extraArgs)
50+
}
51+
for i := range want {
52+
if cfg.extraArgs[i] != want[i] {
53+
t.Errorf("extra arg %d: want %q, got %q", i, want[i], cfg.extraArgs[i])
54+
}
55+
}
56+
}
57+
58+
func TestParseConfigUnknownFlag(t *testing.T) {
59+
_, err := parseConfig([]string{"--unknown"}, io.Discard)
60+
if err == nil {
61+
t.Fatal("expected error for unknown flag")
62+
}
63+
}
64+
65+
func TestParseConfigHelp(t *testing.T) {
66+
var out bytes.Buffer
67+
_, err := parseConfig([]string{"-h"}, &out)
68+
if !errors.Is(err, flag.ErrHelp) {
69+
t.Fatalf("expected flag.ErrHelp, got %v", err)
70+
}
71+
if out.Len() == 0 {
72+
t.Error("expected usage output on -h")
73+
}
74+
}
75+
76+
func TestBuildProgramOptionsDefault(t *testing.T) {
77+
opts := buildProgramOptions(false)
78+
if len(opts) != 1 {
79+
t.Fatalf("expected 1 default program option, got %d", len(opts))
80+
}
81+
// Sanity check: the option is callable like a real tea.ProgramOption.
82+
var p tea.Program
83+
_ = p
84+
_ = opts[0]
85+
}
86+
87+
func TestBuildProgramOptionsWithMouse(t *testing.T) {
88+
opts := buildProgramOptions(true)
89+
if len(opts) != 2 {
90+
t.Fatalf("expected 2 program options with mouse, got %d", len(opts))
91+
}
92+
}

0 commit comments

Comments
 (0)