From 049497147612cc6c23c580c485e39fc08cd93190 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 20 Jun 2026 09:44:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(tui):=20scroll=20conversation=20with=20?= =?UTF-8?q?=E2=86=91/=E2=86=93/wheel=20and=20cap=20thinking=20excerpt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable mouse cell motion so the wheel scrolls the transcript. - Route ↑/↓ (and Ctrl+P/Ctrl+N) to the viewport when the textarea cursor is at the first/last input line, preserving multi-line cursor movement. - Update welcome banner and README keybinding hints. - Cap the live thinking excerpt to the latest 240 runes so verbose reasoning streams do not push the transcript off-screen. - Add tests for scroll navigation, mouse wheel, and thinking cap. --- README.md | 2 +- cmd/bodek/main.go | 10 ++-- internal/tui/banner.go | 2 +- internal/tui/model.go | 42 ++++++++++++++ internal/tui/scroll_test.go | 106 ++++++++++++++++++++++++++++++++++ internal/tui/thinking_test.go | 37 ++++++++++++ 6 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 internal/tui/scroll_test.go create mode 100644 internal/tui/thinking_test.go diff --git a/README.md b/README.md index ec63de8..7a18677 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ 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` / wheel | Scroll the transcript | | `^C` | Quit | ### Commands (`/`) diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 90d984e..c6258e3 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -110,11 +110,11 @@ func run() error { LogPath: logPath, }) - // No mouse capture: with mouse reporting on, the terminal cannot do native - // click-drag text selection. Leaving it off lets users select & copy text - // (copy-on-select where the terminal supports it). Scrolling stays on the - // keyboard (PgUp/PgDn, ^U/^D). - p := tea.NewProgram(model, tea.WithAltScreen()) + // 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()) if _, err := p.Run(); err != nil { return fmt.Errorf("TUI exited: %w", err) } diff --git a/internal/tui/banner.go b/internal/tui/banner.go index 1c5d018..342166a 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -48,7 +48,7 @@ func welcome(th theme, width int, cwd string) string { {"/stats", "session metrics & live context-window gauge"}, {"@ to attach", "attach files, e.g. @main.go"}, {"⏎ send", "· ^J newline · ^T toggle thinking"}, - {"^L clear", "· PgUp/PgDn scroll · ^C quit"}, + {"^L clear", "· ↑/↓ scroll · PgUp/PgDn page · ^C quit"}, {"approvals", "answer with [a]pprove [d]eny [t]rust"}, } const keyW = 11 diff --git a/internal/tui/model.go b/internal/tui/model.go index 3dd00b5..74d38c8 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" "time" + "unicode" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" @@ -365,6 +366,21 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refresh() } return m, nil + case "up", "ctrl+p": + // Scroll the transcript when the cursor is already at the top line of + // the input; otherwise let the textarea move the cursor up. + if m.ta.Line() == 0 { + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + } + case "down", "ctrl+n": + // Likewise, scroll down when the cursor is on the bottom input line. + if m.ta.Line() == m.ta.LineCount()-1 { + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + } case "pgup", "pgdown", "ctrl+u", "ctrl+d": var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) @@ -393,6 +409,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "thinking": m.thinking.WriteString(sanitize(ev.Content)) + capThinking(&m.thinking, maxThinkingLen) m.status = "thinking" case "token": @@ -921,6 +938,31 @@ func (m *Model) attachSubLog(i int, line string) bool { return false } +// maxThinkingLen caps the live "thinking…" excerpt so a verbose reasoning +// stream does not push the transcript off-screen. +const maxThinkingLen = 240 + +// capThinking trims the builder to at most n runes, starting at the next +// whitespace so the visible excerpt does not begin mid-word. +func capThinking(b *strings.Builder, n int) { + if b.Len() <= n { + return + } + s := []rune(b.String()) + if len(s) <= n { + return + } + s = s[len(s)-n:] + for i, r := range s { + if unicode.IsSpace(r) { + s = s[i+1:] + break + } + } + b.Reset() + b.WriteString(string(s)) +} + func collapse(s string) string { return strings.Join(strings.Fields(sanitize(s)), " ") } diff --git a/internal/tui/scroll_test.go b/internal/tui/scroll_test.go new file mode 100644 index 0000000..9268d34 --- /dev/null +++ b/internal/tui/scroll_test.go @@ -0,0 +1,106 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// TestUpDownScrollTranscript verifies that ↑/↓ scroll the conversation when +// the textarea cursor is at the corresponding edge of the input. +func TestUpDownScrollTranscript(t *testing.T) { + m := newTestModel() + + // Build a tall, markdown-heavy transcript so the viewport can scroll. + md := "# Heading\n\nThis is **bold** and *italic*.\n\n```go\nfunc main() {}\n```\n\n- one\n- two\n- three\n\n" + strings.Repeat("More text. ", 30) + for i := 0; i < 4; i++ { + m.msgs = append(m.msgs, message{role: roleUser, content: "prompt"}) + m.msgs = append(m.msgs, message{role: roleAsst, content: md, rendered: m.render(md)}) + } + m.refresh() + + if m.vp.TotalLineCount() <= m.vp.Height { + t.Fatal("test transcript should be taller than the viewport") + } + if !m.vp.AtBottom() { + t.Fatal("transcript should start pinned to the bottom") + } + bottom := m.vp.YOffset + + // With an empty single-line input, pressing Up should scroll the viewport. + m.Update(key("up")) + if m.vp.YOffset >= bottom { + t.Errorf("up did not scroll transcript up: yoffset=%d, was=%d", m.vp.YOffset, bottom) + } + + // And Down should return to the bottom. + m.Update(key("down")) + if !m.vp.AtBottom() { + t.Errorf("down did not return transcript to bottom: yoffset=%d", m.vp.YOffset) + } +} + +// TestUpDownEditMultiLine verifies that ↑/↓ still edit a multi-line input +// when the cursor is not at the corresponding edge. +func TestUpDownEditMultiLine(t *testing.T) { + m := newTestModel() + + // Two-line input with the cursor on the first line. + m.ta.Focus() + m.ta.SetValue("line one\nline two") + m.ta.CursorStart() + m.ta.CursorUp() + if m.ta.Line() != 0 { + t.Fatalf("expected cursor on line 0, got %d", m.ta.Line()) + } + + // Up from the first line should scroll, but with no transcript there is + // nowhere to scroll; the viewport stays at top and the key is consumed. + before := m.vp.YOffset + m.Update(key("up")) + if m.vp.YOffset != before { + t.Errorf("up scrolled an empty transcript unexpectedly") + } + + // Down from the first line of a two-line input should move the cursor down, + // not scroll the transcript. + m.Update(key("down")) + if m.ta.Line() != 1 { + t.Errorf("down should move cursor to line 1, got line %d", m.ta.Line()) + } + + // Another down from the last line should scroll (empty transcript: no-op). + m.Update(key("down")) + if m.ta.Line() != 1 { + t.Errorf("down from last line changed cursor unexpectedly to %d", m.ta.Line()) + } +} + +// TestMouseWheelScrollsTranscript verifies that wheel events scroll the +// transcript once mouse reporting is enabled by the program. +func TestMouseWheelScrollsTranscript(t *testing.T) { + m := newTestModel() + + md := "# Section\n\n" + strings.Repeat("Paragraph. ", 40) + for i := 0; i < 5; i++ { + m.msgs = append(m.msgs, message{role: roleUser, content: "q"}) + m.msgs = append(m.msgs, message{role: roleAsst, content: md, rendered: m.render(md)}) + } + m.refresh() + + if !m.vp.AtBottom() { + t.Fatal("transcript should start at the bottom") + } + bottom := m.vp.YOffset + + _, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelUp}) + if m.vp.YOffset >= bottom { + t.Errorf("mouse wheel up did not scroll transcript up: yoffset=%d, was=%d", m.vp.YOffset, bottom) + } + + _, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown}) + if !m.vp.AtBottom() { + t.Errorf("mouse wheel down did not return to bottom: yoffset=%d", m.vp.YOffset) + } +} diff --git a/internal/tui/thinking_test.go b/internal/tui/thinking_test.go new file mode 100644 index 0000000..1c1694c --- /dev/null +++ b/internal/tui/thinking_test.go @@ -0,0 +1,37 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestThinkingCap verifies that the live reasoning excerpt is capped so a long +// thinking stream cannot grow without bound. +func TestThinkingCap(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + + // Stream a thinking chunk well over the cap. + chunk := strings.Repeat("word ", 200) + m.handleEvent(client.Event{Type: "thinking", Content: chunk}) + + if m.thinking.Len() > maxThinkingLen*2 { + t.Errorf("thinking excerpt grew too large: %d", m.thinking.Len()) + } + + // The visible excerpt should end with the tail of the latest input. + out := m.thinking.String() + if !strings.HasSuffix(out, "word ") { + t.Errorf("thinking excerpt lost the tail: %q", out) + } + + // A subsequent event should keep replacing/capping from the end. + m.handleEvent(client.Event{Type: "thinking", Content: "final thought"}) + if !strings.Contains(m.thinking.String(), "final thought") { + t.Errorf("latest thinking not retained: %q", m.thinking.String()) + } +}