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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`/`)
Expand Down
10 changes: 5 additions & 5 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/banner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"regexp"
"strings"
"time"
"unicode"

"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)), " ")
}
Expand Down
106 changes: 106 additions & 0 deletions internal/tui/scroll_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
37 changes: 37 additions & 0 deletions internal/tui/thinking_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}