Skip to content

Commit 7e306f2

Browse files
authored
Merge pull request #4 from BackendStack21/fix/scroll-thinking-cap
fix(tui): scroll conversation with ↑/↓/wheel and cap thinking excerpt
2 parents 7f4d439 + 0494971 commit 7e306f2

6 files changed

Lines changed: 192 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json`
9393
| `^J` | Insert a newline in the input |
9494
| `^L` | Clear the conversation |
9595
| `Esc` | Cancel the running turn |
96-
| `PgUp` / `PgDn` / wheel | Scroll the transcript |
96+
| `` / `` / `PgUp` / `PgDn` / wheel | Scroll the transcript |
9797
| `^C` | Quit |
9898

9999
### Commands (`/`)

cmd/bodek/main.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,11 @@ func run() error {
110110
LogPath: logPath,
111111
})
112112

113-
// No mouse capture: with mouse reporting on, the terminal cannot do native
114-
// click-drag text selection. Leaving it off lets users select & copy text
115-
// (copy-on-select where the terminal supports it). Scrolling stays on the
116-
// keyboard (PgUp/PgDn, ^U/^D).
117-
p := tea.NewProgram(model, tea.WithAltScreen())
113+
// Mouse reporting enables wheel scrolling in the transcript. Click-drag text
114+
// selection is delegated to the terminal's shift+drag fallback where the
115+
// terminal supports it; otherwise users can rely on keyboard scrolling
116+
// (↑/↓, PgUp/PgDn, ^U/^D).
117+
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
118118
if _, err := p.Run(); err != nil {
119119
return fmt.Errorf("TUI exited: %w", err)
120120
}

internal/tui/banner.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func welcome(th theme, width int, cwd string) string {
4848
{"/stats", "session metrics & live context-window gauge"},
4949
{"@ to attach", "attach files, e.g. @main.go"},
5050
{"⏎ send", "· ^J newline · ^T toggle thinking"},
51-
{"^L clear", "· PgUp/PgDn scroll · ^C quit"},
51+
{"^L clear", ↑/↓ scroll · PgUp/PgDn page · ^C quit"},
5252
{"approvals", "answer with [a]pprove [d]eny [t]rust"},
5353
}
5454
const keyW = 11

internal/tui/model.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"regexp"
77
"strings"
88
"time"
9+
"unicode"
910

1011
"github.com/charmbracelet/bubbles/spinner"
1112
"github.com/charmbracelet/bubbles/textarea"
@@ -365,6 +366,21 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
365366
m.refresh()
366367
}
367368
return m, nil
369+
case "up", "ctrl+p":
370+
// Scroll the transcript when the cursor is already at the top line of
371+
// the input; otherwise let the textarea move the cursor up.
372+
if m.ta.Line() == 0 {
373+
var cmd tea.Cmd
374+
m.vp, cmd = m.vp.Update(msg)
375+
return m, cmd
376+
}
377+
case "down", "ctrl+n":
378+
// Likewise, scroll down when the cursor is on the bottom input line.
379+
if m.ta.Line() == m.ta.LineCount()-1 {
380+
var cmd tea.Cmd
381+
m.vp, cmd = m.vp.Update(msg)
382+
return m, cmd
383+
}
368384
case "pgup", "pgdown", "ctrl+u", "ctrl+d":
369385
var cmd tea.Cmd
370386
m.vp, cmd = m.vp.Update(msg)
@@ -393,6 +409,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
393409

394410
case "thinking":
395411
m.thinking.WriteString(sanitize(ev.Content))
412+
capThinking(&m.thinking, maxThinkingLen)
396413
m.status = "thinking"
397414

398415
case "token":
@@ -921,6 +938,31 @@ func (m *Model) attachSubLog(i int, line string) bool {
921938
return false
922939
}
923940

941+
// maxThinkingLen caps the live "thinking…" excerpt so a verbose reasoning
942+
// stream does not push the transcript off-screen.
943+
const maxThinkingLen = 240
944+
945+
// capThinking trims the builder to at most n runes, starting at the next
946+
// whitespace so the visible excerpt does not begin mid-word.
947+
func capThinking(b *strings.Builder, n int) {
948+
if b.Len() <= n {
949+
return
950+
}
951+
s := []rune(b.String())
952+
if len(s) <= n {
953+
return
954+
}
955+
s = s[len(s)-n:]
956+
for i, r := range s {
957+
if unicode.IsSpace(r) {
958+
s = s[i+1:]
959+
break
960+
}
961+
}
962+
b.Reset()
963+
b.WriteString(string(s))
964+
}
965+
924966
func collapse(s string) string {
925967
return strings.Join(strings.Fields(sanitize(s)), " ")
926968
}

internal/tui/scroll_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
tea "github.com/charmbracelet/bubbletea"
8+
)
9+
10+
// TestUpDownScrollTranscript verifies that ↑/↓ scroll the conversation when
11+
// the textarea cursor is at the corresponding edge of the input.
12+
func TestUpDownScrollTranscript(t *testing.T) {
13+
m := newTestModel()
14+
15+
// Build a tall, markdown-heavy transcript so the viewport can scroll.
16+
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)
17+
for i := 0; i < 4; i++ {
18+
m.msgs = append(m.msgs, message{role: roleUser, content: "prompt"})
19+
m.msgs = append(m.msgs, message{role: roleAsst, content: md, rendered: m.render(md)})
20+
}
21+
m.refresh()
22+
23+
if m.vp.TotalLineCount() <= m.vp.Height {
24+
t.Fatal("test transcript should be taller than the viewport")
25+
}
26+
if !m.vp.AtBottom() {
27+
t.Fatal("transcript should start pinned to the bottom")
28+
}
29+
bottom := m.vp.YOffset
30+
31+
// With an empty single-line input, pressing Up should scroll the viewport.
32+
m.Update(key("up"))
33+
if m.vp.YOffset >= bottom {
34+
t.Errorf("up did not scroll transcript up: yoffset=%d, was=%d", m.vp.YOffset, bottom)
35+
}
36+
37+
// And Down should return to the bottom.
38+
m.Update(key("down"))
39+
if !m.vp.AtBottom() {
40+
t.Errorf("down did not return transcript to bottom: yoffset=%d", m.vp.YOffset)
41+
}
42+
}
43+
44+
// TestUpDownEditMultiLine verifies that ↑/↓ still edit a multi-line input
45+
// when the cursor is not at the corresponding edge.
46+
func TestUpDownEditMultiLine(t *testing.T) {
47+
m := newTestModel()
48+
49+
// Two-line input with the cursor on the first line.
50+
m.ta.Focus()
51+
m.ta.SetValue("line one\nline two")
52+
m.ta.CursorStart()
53+
m.ta.CursorUp()
54+
if m.ta.Line() != 0 {
55+
t.Fatalf("expected cursor on line 0, got %d", m.ta.Line())
56+
}
57+
58+
// Up from the first line should scroll, but with no transcript there is
59+
// nowhere to scroll; the viewport stays at top and the key is consumed.
60+
before := m.vp.YOffset
61+
m.Update(key("up"))
62+
if m.vp.YOffset != before {
63+
t.Errorf("up scrolled an empty transcript unexpectedly")
64+
}
65+
66+
// Down from the first line of a two-line input should move the cursor down,
67+
// not scroll the transcript.
68+
m.Update(key("down"))
69+
if m.ta.Line() != 1 {
70+
t.Errorf("down should move cursor to line 1, got line %d", m.ta.Line())
71+
}
72+
73+
// Another down from the last line should scroll (empty transcript: no-op).
74+
m.Update(key("down"))
75+
if m.ta.Line() != 1 {
76+
t.Errorf("down from last line changed cursor unexpectedly to %d", m.ta.Line())
77+
}
78+
}
79+
80+
// TestMouseWheelScrollsTranscript verifies that wheel events scroll the
81+
// transcript once mouse reporting is enabled by the program.
82+
func TestMouseWheelScrollsTranscript(t *testing.T) {
83+
m := newTestModel()
84+
85+
md := "# Section\n\n" + strings.Repeat("Paragraph. ", 40)
86+
for i := 0; i < 5; i++ {
87+
m.msgs = append(m.msgs, message{role: roleUser, content: "q"})
88+
m.msgs = append(m.msgs, message{role: roleAsst, content: md, rendered: m.render(md)})
89+
}
90+
m.refresh()
91+
92+
if !m.vp.AtBottom() {
93+
t.Fatal("transcript should start at the bottom")
94+
}
95+
bottom := m.vp.YOffset
96+
97+
_, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelUp})
98+
if m.vp.YOffset >= bottom {
99+
t.Errorf("mouse wheel up did not scroll transcript up: yoffset=%d, was=%d", m.vp.YOffset, bottom)
100+
}
101+
102+
_, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown})
103+
if !m.vp.AtBottom() {
104+
t.Errorf("mouse wheel down did not return to bottom: yoffset=%d", m.vp.YOffset)
105+
}
106+
}

internal/tui/thinking_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/BackendStack21/bodek/internal/client"
8+
)
9+
10+
// TestThinkingCap verifies that the live reasoning excerpt is capped so a long
11+
// thinking stream cannot grow without bound.
12+
func TestThinkingCap(t *testing.T) {
13+
m := newTestModel()
14+
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true})
15+
m.curIdx = 0
16+
m.busy = true
17+
18+
// Stream a thinking chunk well over the cap.
19+
chunk := strings.Repeat("word ", 200)
20+
m.handleEvent(client.Event{Type: "thinking", Content: chunk})
21+
22+
if m.thinking.Len() > maxThinkingLen*2 {
23+
t.Errorf("thinking excerpt grew too large: %d", m.thinking.Len())
24+
}
25+
26+
// The visible excerpt should end with the tail of the latest input.
27+
out := m.thinking.String()
28+
if !strings.HasSuffix(out, "word ") {
29+
t.Errorf("thinking excerpt lost the tail: %q", out)
30+
}
31+
32+
// A subsequent event should keep replacing/capping from the end.
33+
m.handleEvent(client.Event{Type: "thinking", Content: "final thought"})
34+
if !strings.Contains(m.thinking.String(), "final thought") {
35+
t.Errorf("latest thinking not retained: %q", m.thinking.String())
36+
}
37+
}

0 commit comments

Comments
 (0)