diff --git a/README.md b/README.md index 1bcfe13..13a6960 100644 --- a/README.md +++ b/README.md @@ -178,13 +178,16 @@ untrusted-content boundary), so attachments go through the same security model as any other external input — bodek doesn't special-case them. (Saved sessions are resumed via `/sessions` or `^R`, not `@`.) -When the agent requests approval for a dangerous operation, answer inline: +When the agent requests approval for a dangerous operation, pick an outcome +from the panel and confirm — typing never answers by accident: | Key | Action | |-----|--------| -| `a` | Approve once | -| `d` | Deny | -| `t` | Trust this risk class for the session (when offered) | +| `↑` / `↓` (or `←` / `→`) | Move the highlight (Approve / Deny / Trust class when offered) | +| `⏎` | Confirm the highlighted option | +| `Esc` | Deny (abort) | +| `Tab` | Expand/collapse the full command & description text | +| `PgUp` / `PgDn` / `^U` / `^D` | Scroll the transcript while the panel is open | --- diff --git a/internal/tui/approval.go b/internal/tui/approval.go index d56cc30..034010b 100644 --- a/internal/tui/approval.go +++ b/internal/tui/approval.go @@ -4,18 +4,54 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// handleApprovalKey answers the pending approval (or quits); any other key -// is swallowed while the panel waits for a decision. +// approvalOption is one selectable outcome in the approval panel; action is +// the protocol reply sent back to the server. +type approvalOption struct { + label string + action string +} + +// approvalOptions lists the panel's outcomes in display order; trust is only +// offered when the server allows it. +func (m *Model) approvalOptions() []approvalOption { + opts := []approvalOption{ + {"approve", "approve"}, + {"deny", "deny"}, + } + if m.approval.AllowTrust { + opts = append(opts, approvalOption{"trust class", "trust"}) + } + return opts +} + +// handleApprovalKey drives the pending approval: arrows move the highlight, +// enter confirms it, esc denies, tab expands the full command/description, +// and the transcript scroll keys keep working. Bare letters never decide — a +// prompt typed mid-approval must not leak into a decision. func (m *Model) handleApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { - case "a", "y": - return m, m.answer("approve") - case "d", "n": - return m, m.answer("deny") - case "t": - if m.approval.AllowTrust { - return m, m.answer("trust") + case "up", "left": + if m.apprSel > 0 { + m.apprSel-- } + case "down", "right": + if m.apprSel < len(m.approvalOptions())-1 { + m.apprSel++ + } + case "enter": + return m, m.answer(m.approvalOptions()[m.apprSel].action) + case "esc": + return m, m.answer("deny") + case "tab": + m.apprExpanded = !m.apprExpanded + m.relayout() // the panel grows/shrinks with the full text + case "pgup", "pgdown", "ctrl+u", "ctrl+d": + var cmd tea.Cmd + m.vp, cmd = m.vp.Update(msg) + return m, cmd + case "ctrl+g": + m.vp.GotoBottom() + return m, nil case "ctrl+c": m.quitting = true return m, tea.Quit @@ -26,6 +62,8 @@ func (m *Model) handleApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) answer(action string) tea.Cmd { id := m.approval.ID m.approval = nil + m.apprSel = 0 + m.apprExpanded = false m.status = "thinking" m.relayout() m.refresh() diff --git a/internal/tui/approval_test.go b/internal/tui/approval_test.go new file mode 100644 index 0000000..8f90711 --- /dev/null +++ b/internal/tui/approval_test.go @@ -0,0 +1,187 @@ +package tui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + ws "golang.org/x/net/websocket" + + "github.com/BackendStack21/bodek/internal/client" +) + +// approvalRecorder builds a Model against a stand-in that records every +// approval_response action it receives, so tests can assert the exact +// protocol reply a key sequence produced. +func approvalRecorder(t *testing.T) (*Model, chan string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + + actions := make(chan string, 4) + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + for { + var d []byte + if err := ws.Message.Receive(c, &d); err != nil { + return + } + var msg struct { + Type string `json:"type"` + Action string `json:"action"` + } + if json.Unmarshal(d, &msg) == nil && msg.Type == "approval_response" { + actions <- msg.Action + } + } + })) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + cl, err := client.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", srv.URL, srv.URL, "") + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { cl.Close() }) + + m := New(cl, Options{Model: "m"}) + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + return m, actions +} + +// awaitAction reads the next recorded approval_response action. +func awaitAction(t *testing.T, actions chan string) string { + t.Helper() + select { + case a := <-actions: + return a + case <-time.After(2 * time.Second): + t.Fatal("no approval_response received") + return "" + } +} + +// TestApprovalEnterConfirmsHighlight verifies that only enter on the +// highlighted option answers the approval, with the same protocol replies as +// before (approve / deny / trust). +func TestApprovalEnterConfirmsHighlight(t *testing.T) { + m, actions := approvalRecorder(t) + cases := []struct { + name string + allowTrust bool + keys []string + want string + }{ + {"approve is the default highlight", false, []string{"enter"}, "approve"}, + {"deny one down", false, []string{"down", "enter"}, "deny"}, + {"trust at the bottom when offered", true, []string{"down", "down", "enter"}, "trust"}, + {"left/right also navigate", true, []string{"right", "right", "left", "enter"}, "deny"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m.handleEvent(client.Event{Type: "approval_request", ID: "apr", AllowTrust: tc.allowTrust}) + var cmd tea.Cmd + for _, k := range tc.keys { + _, cmd = m.Update(key(k)) + } + exec(cmd) + if m.approval != nil { + t.Fatal("approval still pending after enter") + } + if got := awaitAction(t, actions); got != tc.want { + t.Errorf("action = %q, want %q", got, tc.want) + } + }) + } +} + +// TestApprovalEscDenies verifies esc is the abort path: it denies even when +// the highlight sits on another option. +func TestApprovalEscDenies(t *testing.T) { + m, actions := approvalRecorder(t) + m.handleEvent(client.Event{Type: "approval_request", ID: "apr", AllowTrust: true}) + m.Update(key("down")) // highlight elsewhere — esc must not confirm it + _, cmd := m.Update(key("esc")) + exec(cmd) + if m.approval != nil { + t.Fatal("approval still pending after esc") + } + if got := awaitAction(t, actions); got != "deny" { + t.Errorf("action = %q, want deny", got) + } +} + +// TestApprovalExpandToggle verifies the panel starts collapsed to one +// truncated line and tab reveals the full command/description text without +// changing the total screen height. +func TestApprovalExpandToggle(t *testing.T) { + m := newTestModel() + height := func() int { return strings.Count(m.View(), "\n") + 1 } + cmd := "git push origin " + strings.Repeat("some/really/long/path/", 8) + "end-marker" + m.handleEvent(client.Event{Type: "approval_request", ID: "apr", + Name: "shell", Command: cmd, Description: "push it"}) + + collapsed := plain(m.approvalPanel()) + if !strings.Contains(collapsed, "…") { + t.Error("collapsed panel should truncate the command") + } + if strings.Contains(collapsed, "end-marker") { + t.Error("collapsed panel leaked the full command tail") + } + base := height() + + m.Update(key("tab")) + if out := plain(m.approvalPanel()); !strings.Contains(out, "end-marker") { + t.Errorf("expanded panel should show the full command:\n%s", out) + } + if got := height(); got != base { + t.Errorf("view height changed when panel expanded: %d → %d rows", base, got) + } + + m.Update(key("tab")) + if plain(m.approvalPanel()) != collapsed { + t.Error("second tab should restore the collapsed panel") + } +} + +// TestApprovalScrollWhilePending verifies the transcript scroll keys keep +// working while the approval panel waits for a decision. +func TestApprovalScrollWhilePending(t *testing.T) { + m := newTestModel() + md := strings.Repeat("transcript line\n", 60) + // Pre-rendered verbatim (finalized messages use msg.rendered as-is). + m.msgs = append(m.msgs, message{role: roleAsst, content: md, rendered: md}) + m.refresh() + if m.vp.TotalLineCount() <= m.vp.Height { + t.Fatal("test transcript should be taller than the viewport") + } + m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Command: "rm x"}) + if m.approval == nil { + t.Fatal("approval not set") + } + + bottom := m.vp.YOffset + m.Update(key("pgup")) + if m.vp.YOffset >= bottom { + t.Errorf("pgup did not scroll while approval pending: yoffset=%d, was=%d", m.vp.YOffset, bottom) + } + m.Update(key("ctrl+g")) + if !m.vp.AtBottom() { + t.Errorf("ctrl+g did not jump to the latest while approval pending: yoffset=%d", m.vp.YOffset) + } + m.Update(key("pgup")) + up := m.vp.YOffset + m.Update(key("ctrl+d")) + if m.vp.YOffset <= up { + t.Errorf("ctrl+d did not scroll down while approval pending: yoffset=%d, was=%d", m.vp.YOffset, up) + } + m.Update(key("pgdown")) + if !m.vp.AtBottom() { + t.Errorf("pgdown did not return to the bottom: yoffset=%d", m.vp.YOffset) + } + if m.approval == nil { + t.Error("scrolling must not answer the approval") + } +} diff --git a/internal/tui/banner.go b/internal/tui/banner.go index 028676b..e0a1f8f 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -27,8 +27,15 @@ var ( // bindings — left-aligned with a gentle margin. func welcome(th theme, width int, cwd string) string { var b strings.Builder - for _, line := range bannerArt { - b.WriteString(gradient(line, gradFrom, gradTo)) + // The block art needs its own width plus the left padding below; narrower + // terminals get a one-line wordmark instead of wrapped garbage. + if artW := lipgloss.Width(bannerArt[0]); width >= artW+2 { + for _, line := range bannerArt { + b.WriteString(gradient(line, gradFrom, gradTo)) + b.WriteByte('\n') + } + } else { + b.WriteString(th.logo.Render(gradient("⬡ bodek", gradFrom, gradTo))) b.WriteByte('\n') } b.WriteByte('\n') @@ -49,7 +56,7 @@ func welcome(th theme, width int, cwd string) string { {"@ to attach", "attach files, e.g. @main.go"}, {"⏎ send", "^J newline · ^T toggle thinking"}, {"^L clear", "↑/↓ scroll · PgUp/PgDn page · ^C quit"}, - {"approvals", "a approve · d deny · t trust"}, + {"approvals", "↑↓ select · ⏎ confirm · esc deny"}, {"tool steps", "^E toggle tool details · --mouse to click-expand"}, } const keyW = 11 diff --git a/internal/tui/banner_test.go b/internal/tui/banner_test.go new file mode 100644 index 0000000..6366edc --- /dev/null +++ b/internal/tui/banner_test.go @@ -0,0 +1,37 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +// TestWelcomeNarrowFallback verifies the welcome splash drops the block art +// for a one-line wordmark when the terminal is too narrow for it, and that +// the compact render stays within the terminal width. +func TestWelcomeNarrowFallback(t *testing.T) { + th := newTheme() + artW := lipgloss.Width(bannerArt[0]) + + wide := plain(welcome(th, artW+2, "/tmp")) + if !strings.Contains(wide, "██████") { + t.Error("banner at art width should show the block art") + } + + narrow := plain(welcome(th, artW, "/tmp")) // one column short of art+padding + if strings.Contains(narrow, "██████") { + t.Error("narrow banner should drop the block art") + } + if !strings.Contains(narrow, "bodek") || !strings.Contains(narrow, "terminal interface") { + t.Errorf("compact banner should keep wordmark and tagline:\n%s", narrow) + } + // The box wraps its content at `width` and then adds its 2-column left + // padding (pre-existing at every width) — the fallback's job is that no + // line exceeds that. + for i, ln := range strings.Split(narrow, "\n") { + if w := lipgloss.Width(ln); w > artW+2 { + t.Errorf("line %d wraps past the rendered width (%d > %d): %q", i, w, artW+2, ln) + } + } +} diff --git a/internal/tui/coverage_test.go b/internal/tui/coverage_test.go index c9ce8ce..7a5212f 100644 --- a/internal/tui/coverage_test.go +++ b/internal/tui/coverage_test.go @@ -244,19 +244,38 @@ func TestSubmitGuards(t *testing.T) { } m.disconn = true m.ta.SetValue("hi") - cmd := m.submit() - if cmd == nil { - t.Error("submit while disconnected should arm the notice expiry") + if cmd := m.submit(); cmd != nil { + t.Error("submit while disconnected should be nil (the warning is sticky, no expiry to arm)") } if m.ta.Value() != "hi" { t.Error("submit while disconnected must keep the draft") } - exec(cmd) // fires the notice-expiry tick safely if len(m.notices) == 0 { t.Error("submit while disconnected should explain why nothing was sent") } } +// TestWrapText covers the approval panel's hard-wrap helper: degenerate +// widths, empty and blank lines, exact fits, and unbreakable words. +func TestWrapText(t *testing.T) { + cases := []struct { + in string + n int + want []string + }{ + {"", 5, []string{""}}, // empty input still claims its row + {"hello", 5, []string{"hello"}}, // exact width + {"hello", 0, []string{"h", "e", "l", "l", "o"}}, // width floors at 1 + {"abcdefghij", 4, []string{"abcd", "efgh", "ij"}}, // unbreakable word chunks + {"a\n\nb", 10, []string{"a", "", "b"}}, // blank lines survive + } + for _, tc := range cases { + if got := wrapText(tc.in, tc.n); strings.Join(got, "\n") != strings.Join(tc.want, "\n") { + t.Errorf("wrapText(%q, %d) = %q, want %q", tc.in, tc.n, got, tc.want) + } + } +} + func TestTinyHelpers(t *testing.T) { if orDash("") != "—" || orDash("x") != "x" { t.Error("orDash") diff --git a/internal/tui/events.go b/internal/tui/events.go index 177e5a8..3bf1a4e 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -42,14 +42,16 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "thinking": // Append to the open reasoning block (the last timeline item when it is - // a thinking item), or start a new one after a tool call. + // a thinking item), or start a new one after a tool call. The full text + // is stored; only the rendered excerpt is capped (see maxThinkingLen), + // so expandAll can unfold the complete block once the turn finalizes. if i := m.cur(); i >= 0 { msg := &m.msgs[i] if n := len(msg.items); n > 0 && msg.items[n-1].thinking { - msg.items[n-1].text = capThinkingText(msg.items[n-1].text+sanitize(ev.Content), maxThinkingLen) + msg.items[n-1].text += sanitize(ev.Content) } else { msg.items = append(msg.items, turnItem{thinking: true, - text: capThinkingText(sanitize(ev.Content), maxThinkingLen)}) + text: sanitize(ev.Content)}) } } m.status = "thinking" @@ -171,6 +173,8 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { case "approval_request": e := ev m.approval = &e + m.apprSel = 0 + m.apprExpanded = false m.status = "approval required" m.relayout() // the panel is taller than the textarea — shrink the viewport @@ -197,6 +201,18 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.disconn = true m.busy = false m.renderPending = false + // A turn in flight when the socket drops will never finish: close it + // out with an interrupted marker instead of leaving it streaming + // forever. Idempotent — with no open turn this is a no-op, so a + // later resume is untouched. + if i := m.cur(); i >= 0 { + if m.msgs[i].content == "" { + m.msgs[i].content = "**Interrupted:** connection lost" + } else { + m.msgs[i].content += "\n\n**Interrupted:** connection lost" + } + } + m.finalize() m.relayout() // the busy status line is gone with the socket if cmd := m.scheduleReconnect(0); cmd != nil { m.status = "reconnecting…" @@ -501,12 +517,14 @@ 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. +// maxThinkingLen caps the rendered "thinking…" excerpt so a verbose reasoning +// block does not push the transcript off-screen. The full text stays stored on +// the turn item; expandAll renders it whole (for finalized turns). const maxThinkingLen = 240 -// capThinkingText trims s to at most n runes, starting at the next whitespace -// so the visible excerpt does not begin mid-word. +// capThinkingText trims s to its first n runes, backing off to the last +// whitespace so the visible excerpt does not stop mid-word. Showing the head +// orients the reader at the thought's beginning, not its end. func capThinkingText(s string, n int) string { if len(s) <= n { return s @@ -515,10 +533,10 @@ func capThinkingText(s string, n int) string { if len(r) <= n { return s } - r = r[len(r)-n:] - for i, c := range r { - if unicode.IsSpace(c) { - r = r[i+1:] + r = r[:n] + for i := len(r) - 1; i >= 0; i-- { + if unicode.IsSpace(r[i]) { + r = r[:i] break } } diff --git a/internal/tui/gaps_test.go b/internal/tui/gaps_test.go index 9b05d75..366dbd3 100644 --- a/internal/tui/gaps_test.go +++ b/internal/tui/gaps_test.go @@ -7,18 +7,27 @@ import ( "github.com/BackendStack21/bodek/internal/client" ) -func TestApprovalUnmatchedAndNoTrust(t *testing.T) { +func TestApprovalLettersNeverDecide(t *testing.T) { m := wired(t) - // AllowTrust=false: pressing "t" must NOT resolve the approval. + // Bare letters — including the old a/d/t/y/n shortcuts — must never + // resolve the approval; only arrows + enter or esc do. m.approval = &client.Event{Type: "approval_request", AllowTrust: false} - m.Update(key("t")) - if m.approval == nil { - t.Error("'t' without AllowTrust should not clear approval") + for _, k := range []string{"a", "d", "t", "y", "n", "z"} { + m.Update(key(k)) + if m.approval == nil { + t.Fatalf("letter %q resolved the approval", k) + } + } + // AllowTrust=false: the highlight clamps at "deny" — trust is unreachable. + m.Update(key("down")) + m.Update(key("down")) + if m.apprSel != 1 { + t.Errorf("apprSel = %d, want clamped at 1 (deny)", m.apprSel) } - // An unrelated key falls through to a no-op. - m.Update(key("z")) - if m.approval == nil { - t.Error("unrelated key should leave approval pending") + _, cmd := m.Update(key("enter")) + exec(cmd) + if m.approval != nil { + t.Error("enter on deny should clear the approval") } } diff --git a/internal/tui/input.go b/internal/tui/input.go index 960ea74..7a45ca8 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -100,10 +100,16 @@ func (m *Model) submit() tea.Cmd { } if m.disconn { // Keep the draft — swallowing it silently reads as a lost message. - prev := m.noticeSeq - m.addTransientNote("disconnected — press r to retry, your draft is kept") + // Sticky (not transient): the warning must outlive a glance away, so + // it stays until newer notices push it out. Deduped, since every + // enter re-posts it. Note r only retries with an empty input, which + // a preserved draft is not — the hint spells that out. + const warn = "disconnected — your draft is kept · clear the input, then r to retry" + if n := len(m.notices); n == 0 || m.notices[n-1] != warn { + m.addNote(warn) + } m.refresh() - return m.noticeTimer(prev) + return nil } if m.busy { // Queue mid-turn prompts instead of dropping them; the queue drains diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index e6f1db4..cca4635 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -139,8 +139,18 @@ func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyUp} case "down": return tea.KeyMsg{Type: tea.KeyDown} + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} case "pgup": return tea.KeyMsg{Type: tea.KeyPgUp} + case "pgdown": + return tea.KeyMsg{Type: tea.KeyPgDown} + case "ctrl+d": + return tea.KeyMsg{Type: tea.KeyCtrlD} + case "ctrl+g": + return tea.KeyMsg{Type: tea.KeyCtrlG} case "ctrl+c": return tea.KeyMsg{Type: tea.KeyCtrlC} case "ctrl+r": @@ -270,13 +280,16 @@ func TestApprovalFlow(t *testing.T) { if !strings.Contains(plain(out), "approval required") { t.Error("approval panel missing") } - // Trust, then a fresh approval and deny, then approve. - for _, action := range []string{"t", "d", "a"} { + // Trust (highlight → enter), then a fresh approval and deny, then approve. + for _, keys := range [][]string{{"down", "down", "enter"}, {"down", "enter"}, {"enter"}} { m.handleEvent(client.Event{Type: "approval_request", ID: "id", AllowTrust: true}) - _, cmd := m.Update(key(action)) + var cmd tea.Cmd + for _, k := range keys { + _, cmd = m.Update(key(k)) + } exec(cmd) if m.approval != nil { - t.Errorf("approval not cleared after %q", action) + t.Errorf("approval not cleared after %v", keys) } } // ctrl+c during approval quits. diff --git a/internal/tui/model.go b/internal/tui/model.go index c9a6888..62ba995 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -119,9 +119,11 @@ type Model struct { lastTool string lastArg string - approval *client.Event // pending approval, nil when none - ac autocomplete // @-reference completion state - queue []string // prompts typed mid-turn, sent when the turn ends + approval *client.Event // pending approval, nil when none + apprSel int // highlighted option in the approval panel + apprExpanded bool // tab: show the full command/description text + ac autocomplete // @-reference completion state + queue []string // prompts typed mid-turn, sent when the turn ends history []string // submitted prompts, newest last (recalled with ↑) histNav bool // true while ^P/^N is walking the history @@ -248,6 +250,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.busy = false m.status = "error" m.addNote("error: " + msg.err.Error()) + // Close out the turn sendPrompt opened — otherwise the transcript + // keeps a phantom streaming assistant message with no reply. Same + // inline styling as a server-side error event. + if i := m.cur(); i >= 0 && m.msgs[i].content == "" { + m.msgs[i].content = "**Error:** " + msg.err.Error() + } + m.finalize() m.relayout() // the busy status line releases its row m.refresh() return m, m.sendQueued() @@ -290,6 +299,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.addNote("cancel failed: " + msg.err.Error()) m.refresh() + } else { + // The API accepted the abort; the turn's done event settles the + // status shortly after. Acknowledge the keypress in the meantime. + m.addTransientNote("cancelled") + m.refresh() } return m, nil @@ -338,7 +352,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Approval mode captures the keyboard until answered. + // Approval mode captures the keyboard until answered; only the transcript + // scroll keys pass through to the viewport. if m.approval != nil { return m.handleApprovalKey(msg) } @@ -384,6 +399,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmd, m.syncAC()) case "ctrl+t": m.thinkOn = !m.thinkOn + state := "off" + if m.thinkOn { + state = "on" + } + m.addTransientNote("thinking " + state) + m.refresh() return m, nil case "ctrl+l": if !m.busy { @@ -394,6 +415,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Global details toggle: every step (including in-flight ones) shows // its full output/logs; the per-step mouse toggle still layers on top. m.expandAll = !m.expandAll + state := "off" + if m.expandAll { + state = "on" + } + m.addTransientNote("tool details " + state) m.convCount = -1 // re-render the cached transcript prefix too m.refresh() return m, nil @@ -571,11 +597,7 @@ func (m *Model) relayout() { // shrinks by exactly the right amount and the footer never moves. func (m *Model) inputAreaHeight() int { if m.approval != nil { - rows := 3 // head + command + keys - if m.approval.Description != "" { - rows++ - } - return rows + 2 // panel border + return lineCount(m.approvalBody()) + 2 // panel border } h := inputHeight if m.statusLineVisible() { @@ -677,21 +699,17 @@ func (m *Model) toggleStep(msgIdx, stepIdx int) { m.convCount = -1 } -// stepAtLine maps a viewport content line to the nearest step header at or -// above it. Used for mouse click-to-expand. +// stepAtLine maps a viewport content line to a step for mouse hit-testing, +// matching only the step's own header line (the chevron row) — a click on +// detail lines or prose below a step must not toggle it. func (m *Model) stepAtLine(line int) (msgIdx, stepIdx int, ok bool) { - if len(m.stepLineIndex) == 0 { - return - } - var ref *stepRef for i := range m.stepLineIndex { if m.stepLineIndex[i].line > line { break } - ref = &m.stepLineIndex[i] - } - if ref == nil { - return + if m.stepLineIndex[i].line == line { + return m.stepLineIndex[i].msgIdx, m.stepLineIndex[i].stepIdx, true + } } - return ref.msgIdx, ref.stepIdx, true + return } diff --git a/internal/tui/model_smoke_test.go b/internal/tui/model_smoke_test.go index 62c6012..3740998 100644 --- a/internal/tui/model_smoke_test.go +++ b/internal/tui/model_smoke_test.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/BackendStack21/bodek/internal/client" ) @@ -145,8 +146,9 @@ func TestApprovalPanelLayoutStable(t *testing.T) { height := func() int { return strings.Count(m.View(), "\n") + 1 } base := height() - // A description pushes the panel to 6 rendered rows; relayout must shrink - // the viewport to match so the footer stays put. + // The panel (selectable options + a description) is taller than the + // textarea it replaces; relayout must shrink the viewport to match so the + // footer stays put. m.handleEvent(client.Event{Type: "approval_request", Risk: "shell_exec", Name: "shell", Command: "rm x", Description: "delete files", AllowTrust: true}) if got := height(); got != base { @@ -162,3 +164,46 @@ func TestApprovalPanelLayoutStable(t *testing.T) { m.handleEvent(client.Event{Type: "approval_request", Name: "shell", Command: "rm x"}) _ = m.View() } + +// TestHeaderNeverExceedsHeight verifies a long model name truncates instead +// of wrapping the header past headerHeight rows, at any width — relayout and +// the mouse offset math assume the header occupies exactly that many rows. +func TestHeaderNeverExceedsHeight(t *testing.T) { + m := newTestModel() + m.model = strings.Repeat("very-long-model-id-", 10) + for _, w := range []int{10, 24, 40, 72, 100} { + m.resize(w, 24) + h := m.header() + if got := strings.Count(h, "\n") + 1; got != headerHeight { + t.Errorf("width %d: header = %d lines, want %d", w, got, headerHeight) + } + if bar, _, _ := strings.Cut(h, "\n"); lipgloss.Width(bar) > w { + t.Errorf("width %d: header bar is %d columns wide", w, lipgloss.Width(bar)) + } + } + // Last loop width (100): the model name must carry an ellipsis. + bar, _, _ := strings.Cut(m.header(), "\n") + if !strings.Contains(plain(bar), "…") { + t.Errorf("long model name should truncate with an ellipsis: %q", plain(bar)) + } +} + +// TestRenderStepTinyWidths verifies step rendering degrades without panics at +// absurd widths, and that truncated detail lines actually fit the viewport. +func TestRenderStepTinyWidths(t *testing.T) { + m := newTestModel() + s := step{name: "shell", arg: strings.Repeat("x", 100), done: true, + expanded: true, result: strings.Repeat("y", 200)} + for _, w := range []int{4, 8, 12, 24} { + m.resize(w, 20) + out, _, _ := m.renderStep(s, false, 0, 0, 0) // must not panic + if w < 8 { + continue // below the tree connector itself, only the panic matters + } + for _, ln := range strings.Split(plain(out), "\n")[1:] { // detail lines + if n := lipgloss.Width(ln); n > w { + t.Errorf("width %d: detail line overflows (%d cols): %q", w, n, ln) + } + } + } +} diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 4a7c863..604a53a 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -179,6 +179,8 @@ func (m *Model) deleteSelected() tea.Cmd { // for editing instead of firing them into a cancelled session. func (m *Model) cancelRun() tea.Cmd { if !m.busy || m.sessionID == "" { + m.addTransientNote("nothing to cancel") + m.refresh() return nil } if len(m.queue) > 0 { @@ -189,6 +191,8 @@ func (m *Model) cancelRun() tea.Cmd { m.ta.SetValue(draft) m.ta.CursorEnd() m.queue = nil + // The textarea content just changed out from under the user — say why. + m.addTransientNote("queued prompts returned to the input") } m.status = "cancelling" m.refresh() @@ -318,8 +322,9 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) { cur = &message{role: roleAsst} } if rc := sanitize(mm.ReasoningContent); strings.TrimSpace(rc) != "" { - cur.items = append(cur.items, turnItem{thinking: true, - text: capThinkingText(rc, maxThinkingLen)}) + // Full text, like live turns — the rendered excerpt is capped + // at render time, expandAll unfolds the whole block. + cur.items = append(cur.items, turnItem{thinking: true, text: rc}) } for _, tc := range mm.ToolCalls { name := tc.Function.Name diff --git a/internal/tui/promptflow_test.go b/internal/tui/promptflow_test.go index 48149b5..ed630b1 100644 --- a/internal/tui/promptflow_test.go +++ b/internal/tui/promptflow_test.go @@ -304,6 +304,9 @@ func TestUpScrollsEvenWithHistory(t *testing.T) { // and cancelRun's draft-prepend branch. func TestHistoryEdgeCases(t *testing.T) { m := newTestModel() + if m.historyPrev() { + t.Error("historyPrev with empty history should return false") + } for i := 0; i < maxHistory+10; i++ { m.recordHistory("prompt") m.recordHistory("unique") @@ -352,3 +355,110 @@ func TestCancelRestoresQueue(t *testing.T) { t.Error("no turn should start after cancel restored the queue") } } + +// TestSendFailureFinalizesTurn verifies a failed send closes out the phantom +// assistant turn sendPrompt opened, with the error inline in the transcript. +func TestSendFailureFinalizesTurn(t *testing.T) { + m := newTestModel() + busyTurn(m) + + m.Update(errMsg{err: errors.New("write broke")}) + if m.busy { + t.Error("errMsg should clear busy") + } + if m.curIdx != -1 { + t.Error("errMsg should finalize the in-flight turn") + } + msg := m.msgs[1] + if msg.streaming { + t.Error("assistant message should no longer be streaming") + } + if !strings.Contains(msg.content, "**Error:**") || !strings.Contains(msg.content, "write broke") { + t.Errorf("inline error missing from the turn: %q", msg.content) + } + if out := plain(m.conversation()); !strings.Contains(out, "Error:") { + t.Errorf("inline error not rendered in the transcript:\n%s", out) + } +} + +// TestDisconnectedFooterHidesRetryWithDraft verifies the r retry hint only +// shows when it actually works — with an empty input. +func TestDisconnectedFooterHidesRetryWithDraft(t *testing.T) { + m := newTestModel() + m.opts.Reconnect = func() (*client.Client, error) { return nil, errors.New("down") } + m.disconn = true + + m.ta.SetValue("draft") + if foot := plain(m.footer()); strings.Contains(foot, "retry") { + t.Errorf("footer offers r retry with a draft present: %q", foot) + } + m.ta.SetValue("") + if foot := plain(m.footer()); !strings.Contains(foot, "retry") { + t.Errorf("footer missing r retry with an empty input: %q", foot) + } +} + +// TestDisconnectedSubmitWarningSticky verifies the submit-while-disconnected +// warning is sticky (not a 3s transient), keeps the draft, and does not +// stack a duplicate on every enter. +func TestDisconnectedSubmitWarningSticky(t *testing.T) { + m := newTestModel() + m.disconn = true + m.ta.SetValue("hello") + + m.submit() + if len(m.notices) == 0 { + t.Fatal("no warning posted") + } + last := len(m.notices) - 1 + if !strings.Contains(m.notices[last], "draft is kept") { + t.Errorf("warning text = %q", m.notices[last]) + } + if !m.noticeExp[last].IsZero() { + t.Error("disconnect warning should be sticky (no expiry)") + } + if m.ta.Value() != "hello" { + t.Errorf("draft should be kept, got %q", m.ta.Value()) + } + + m.submit() // draft still there — must not stack a duplicate + if len(m.notices) != last+1 { + t.Errorf("duplicate warning posted: %v", m.notices) + } +} + +// TestCancelFeedback verifies the cancel path acknowledges itself: a note +// when there is nothing to cancel, one when queued prompts return to the +// input, and one when the abort lands. +func TestCancelFeedback(t *testing.T) { + m := newTestModel() + hasNote := func(sub string) bool { + for _, n := range m.notices { + if strings.Contains(n, sub) { + return true + } + } + return false + } + + // Idle: nothing to cancel. + m.cancelRun() + if !hasNote("nothing to cancel") { + t.Errorf("idle cancel posted no note: %v", m.notices) + } + + // Busy with a queue: the draft restore is announced. + busyTurn(m) + m.sessionID = "s1" + m.queue = []string{"held"} + m.cancelRun() + if !hasNote("returned to the input") { + t.Errorf("queue restore posted no note: %v", m.notices) + } + + // A successful abort acknowledges itself (the failure path already notes). + m.Update(cancelDoneMsg{}) + if !hasNote("cancelled") { + t.Errorf("successful cancel posted no note: %v", m.notices) + } +} diff --git a/internal/tui/reconnect_test.go b/internal/tui/reconnect_test.go index a5688c7..574ae18 100644 --- a/internal/tui/reconnect_test.go +++ b/internal/tui/reconnect_test.go @@ -140,3 +140,54 @@ func TestReconnectBackoff(t *testing.T) { t.Errorf("backoff(20) = %v, want the 8s cap", got) } } + +// The scheduled redial cmd runs the hook after its backoff tick and yields a +// reconnectMsg with the outcome. +func TestScheduleReconnectTick(t *testing.T) { + m := newTestModel() + called := false + m.opts.Reconnect = func() (*client.Client, error) { called = true; return nil, errors.New("down") } + + msg := exec(m.scheduleReconnect(0)) // blocks for the 500ms attempt-0 backoff + rm, ok := msg.(reconnectMsg) + if !ok { + t.Fatalf("tick yielded %T, want reconnectMsg", msg) + } + if !called || rm.err == nil { + t.Errorf("hook did not run: called=%v, err=%v", called, rm.err) + } +} + +// A disconnect mid-turn closes the turn out with an interrupted marker +// instead of leaving it streaming forever; a repeat disconnect is a no-op. +func TestDisconnectFinalizesTurn(t *testing.T) { + m := newTestModel() + busyTurn(m) + m.msgs[1].content = "partial answer" + + m.handleEvent(client.Event{Type: client.EventDisconnected}) + msg := m.msgs[1] + if msg.streaming { + t.Error("disconnect should finalize the in-flight turn") + } + if m.curIdx != -1 { + t.Error("disconnect should close the open turn index") + } + if !strings.Contains(msg.content, "partial answer") || !strings.Contains(msg.content, "**Interrupted:**") { + t.Errorf("interrupted marker missing: %q", msg.content) + } + + // Idempotent: a second disconnect must not corrupt the finalized turn. + m.handleEvent(client.Event{Type: client.EventDisconnected}) + if strings.Count(m.msgs[1].content, "Interrupted") != 1 { + t.Errorf("repeat disconnect corrupted the turn: %q", m.msgs[1].content) + } + + // A turn that never streamed anything: the marker is the whole content. + m2 := newTestModel() + busyTurn(m2) + m2.handleEvent(client.Event{Type: client.EventDisconnected}) + if got := m2.msgs[1].content; got != "**Interrupted:** connection lost" { + t.Errorf("empty turn marker = %q", got) + } +} diff --git a/internal/tui/steps_test.go b/internal/tui/steps_test.go index 4f34767..0020440 100644 --- a/internal/tui/steps_test.go +++ b/internal/tui/steps_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/BackendStack21/bodek/internal/client" ) @@ -261,6 +263,18 @@ func TestToggleStep(t *testing.T) { } } +// TestToggleStepGuards verifies out-of-range indices are safe no-ops. +func TestToggleStepGuards(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, steps: []step{{name: "read", done: true}}}) + for _, idx := range [][2]int{{-1, 0}, {1, 0}, {5, 0}, {0, -1}, {0, 1}} { + m.toggleStep(idx[0], idx[1]) // must not panic + } + if m.msgs[0].steps[0].expanded { + t.Error("out-of-range toggleStep should not touch any step") + } +} + // TestKeyCtrlETogglesToolDetails verifies Ctrl+E flips the global details // toggle: every step across messages — cached prefix and streaming tail alike — // reveals its detail lines, and a second press collapses them again. @@ -311,6 +325,54 @@ func TestKeyCtrlETogglesToolDetails(t *testing.T) { } } +// TestCtrlEIndicator verifies the chrome flags the global details toggle: a +// transient note acknowledges the keypress and the footer carries a +// persistent indicator while expandAll holds every step open. +func TestCtrlEIndicator(t *testing.T) { + m := newTestModel() + m.Update(key("ctrl+e")) + if !m.expandAll { + t.Fatal("^E did not enable expandAll") + } + found := false + for _, n := range m.notices { + if strings.Contains(n, "tool details on") { + found = true + } + } + if !found { + t.Errorf("^E posted no acknowledgement note: %v", m.notices) + } + if !strings.Contains(plain(m.footer()), "details") { + t.Error("footer shows no expandAll indicator while enabled") + } + + m.Update(key("ctrl+e")) + if strings.Contains(plain(m.footer()), "details") { + t.Error("footer indicator should clear when expandAll is disabled") + } + + // Busy + expandAll: the indicator rides alongside the cancel hint. + m.busy = true + m.Update(key("ctrl+e")) + if foot := plain(m.footer()); !strings.Contains(foot, "cancel") || !strings.Contains(foot, "details") { + t.Errorf("busy footer should carry both hints: %q", foot) + } +} + +// TestExpandedOutputCap verifies huge tool output is capped with a +// truncation footer instead of flooding the transcript. +func TestExpandedOutputCap(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, steps: []step{ + {name: "shell", done: true, result: strings.Repeat("line\n", 250)}, + }}) + m.toggleStep(0, 0) + if out := plain(m.conversation()); !strings.Contains(out, "… output truncated") { + t.Errorf("expanded output should be capped:\n%s", out) + } +} + func TestKeyETypesLetter(t *testing.T) { m := newTestModel() m.ta.Focus() @@ -347,10 +409,70 @@ func TestStepLineIndex(t *testing.T) { if !ok || msgIdx != 1 || stepIdx != 0 { t.Errorf("stepAtLine second header: got %d,%d,%v", msgIdx, stepIdx, ok) } - // A line between the two headers still maps to the first step. + // A line between the two headers maps to nothing — only exact header + // lines hit. mid := (m.stepLineIndex[0].line + m.stepLineIndex[1].line) / 2 - msgIdx, stepIdx, ok = m.stepAtLine(mid) - if !ok || msgIdx != 0 || stepIdx != 0 { - t.Errorf("stepAtLine between headers: got %d,%d,%v", msgIdx, stepIdx, ok) + if _, _, ok := m.stepAtLine(mid); ok { + t.Errorf("stepAtLine between headers should not match (line %d)", mid) + } +} + +// TestStepClickHitTesting verifies only a click on a step's own header line +// toggles it — clicks on prose below the step no longer reach it. +func TestStepClickHitTesting(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, + content: "line a\nline b\nline c\nline d\nline e", + steps: []step{{name: "read", done: true, result: "ok"}}, + }) + _ = m.conversation() + if len(m.stepLineIndex) != 1 { + t.Fatalf("expected 1 step ref, got %+v", m.stepLineIndex) + } + click := func(line int) { + // Viewport content begins below the header (2 rows); see Update. + m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, Y: 2 + line}) + } + + // Prose below the header: no toggle. + click(m.stepLineIndex[0].line + 3) + if m.msgs[0].steps[0].expanded { + t.Error("click below the step header should not toggle it") + } + + // The header line itself toggles. + click(m.stepLineIndex[0].line) + if !m.msgs[0].steps[0].expanded { + t.Error("click on the step header did not toggle it") + } +} + +// TestExpandedOutputPreservesWhitespace verifies the expanded detail view +// keeps tool output verbatim — indentation and internal spacing intact — so +// diffs, JSON, and code stay aligned when expanded. +func TestExpandedOutputPreservesWhitespace(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, steps: []step{ + {name: "shell", arg: "git diff", done: true, + result: "line one\n indented := code\n\tvar x = 1 + 2"}, + }}) + m.toggleStep(0, 0) + out := plain(m.conversation()) + // Leading spaces and internal spacing survive verbatim; the tab renders + // expanded (the chrome reflows tabs), which keeps alignment intact. + for _, want := range []string{" indented := code", "var x = 1 + 2"} { + if !strings.Contains(out, want) { + t.Errorf("expanded output lost whitespace %q in:\n%s", want, out) + } + } +} + +// TestRunningStepChevron verifies an in-flight step advertises the same +// expand affordance as a finished one — toggleStep works on it too. +func TestRunningStepChevron(t *testing.T) { + m := newTestModel() + out, _, _ := m.renderStep(step{name: "shell", arg: "go build"}, true, 0, 0, 0) + if !strings.Contains(plain(out), "▶") { + t.Errorf("running step should show a collapsed chevron:\n%s", plain(out)) } } diff --git a/internal/tui/thinking_test.go b/internal/tui/thinking_test.go index b16866c..c0429c2 100644 --- a/internal/tui/thinking_test.go +++ b/internal/tui/thinking_test.go @@ -7,41 +7,111 @@ import ( "github.com/BackendStack21/bodek/internal/client" ) -// TestThinkingCap verifies that each reasoning block is capped so a long -// thinking stream cannot grow without bound. +// TestThinkingCap verifies that a long reasoning stream is stored in full but +// rendered as an excerpt capped at maxThinkingLen from the HEAD of the block, +// so it cannot push the transcript off-screen and orients the reader at the +// thought's beginning. 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) + // Stream a thinking chunk well over the excerpt cap. + chunk := "head-marker" + strings.Repeat(" filler", 100) + " tail-marker" m.handleEvent(client.Event{Type: "thinking", Content: chunk}) if len(m.msgs[0].items) != 1 || !m.msgs[0].items[0].thinking { t.Fatalf("expected one thinking item, got %+v", m.msgs[0].items) } - block := m.msgs[0].items[0].text - if len(block) > maxThinkingLen*2 { - t.Errorf("thinking block grew too large: %d", len(block)) + if block := m.msgs[0].items[0].text; block != chunk { + t.Errorf("thinking block should be stored in full, got %d of %d bytes", len(block), len(chunk)) } - // The visible excerpt should end with the tail of the latest input. - if !strings.HasSuffix(block, "word ") { - t.Errorf("thinking block lost the tail: %q", block) + // The rendered excerpt is capped and shows the head, not the tail. + rendered, _ := m.renderMessage(m.msgs[0], 0, 0) + out := plain(rendered) + if !strings.Contains(out, "head-marker") { + t.Errorf("excerpt lost the head:\n%s", out) + } + if strings.Contains(out, "tail-marker") { + t.Errorf("excerpt should be capped before the tail:\n%s", out) } - // A subsequent event extends the same block, capping from the end again. - m.handleEvent(client.Event{Type: "thinking", Content: "final thought"}) + // A subsequent event extends the same block. + m.handleEvent(client.Event{Type: "thinking", Content: " final thought"}) if len(m.msgs[0].items) != 1 { t.Fatalf("thinking delta opened a new block: %+v", m.msgs[0].items) } - if !strings.Contains(m.msgs[0].items[0].text, "final thought") { + if !strings.HasSuffix(m.msgs[0].items[0].text, "final thought") { t.Errorf("latest thinking not retained: %q", m.msgs[0].items[0].text) } } +// TestExpandAllFullThinking verifies ^E unfolds the complete thinking text +// past the excerpt cap once the turn is finalized, while a live stream keeps +// the bounded excerpt. +func TestExpandAllFullThinking(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + + thought := "head-marker" + strings.Repeat(" filler", 100) + " tail-marker" + m.handleEvent(client.Event{Type: "thinking", Content: thought}) + + // Streaming + expandAll: still the capped excerpt, never the unbounded stream. + m.expandAll = true + rendered, _ := m.renderMessage(m.msgs[0], 0, 0) + if out := plain(rendered); strings.Contains(out, "tail-marker") { + t.Errorf("streaming render should keep the capped excerpt under expandAll:\n%s", out) + } + + // Finalized + expandAll: the full text renders, wrapped in thinkStyle. + m.handleEvent(client.Event{Type: "done", Latency: 0.5, ContextTokens: 10, OutputTokens: 1}) + rendered, _ = m.renderMessage(m.msgs[0], 0, 0) + if out := plain(rendered); !strings.Contains(out, "head-marker") || !strings.Contains(out, "tail-marker") { + t.Errorf("expandAll should render the full thinking text:\n%s", out) + } + + // Collapsed again: back to the capped head excerpt. + m.expandAll = false + rendered, _ = m.renderMessage(m.msgs[0], 0, 0) + if out := plain(rendered); !strings.Contains(out, "head-marker") || strings.Contains(out, "tail-marker") { + t.Errorf("collapsed render should show the capped head excerpt:\n%s", out) + } +} + +// TestCtrlTThinkingFeedback verifies ^T acknowledges the toggle with a +// transient note and a persistent header indicator. +func TestCtrlTThinkingFeedback(t *testing.T) { + m := newTestModel() + m.Update(key("ctrl+t")) + if !m.thinkOn { + t.Fatal("^T did not enable thinking") + } + found := false + for _, n := range m.notices { + if strings.Contains(n, "thinking on") { + found = true + } + } + if !found { + t.Errorf("^T posted no acknowledgement note: %v", m.notices) + } + if !strings.Contains(plain(m.header()), "✳ think") { + t.Error("header shows no thinking indicator while enabled") + } + + m.Update(key("ctrl+t")) + if m.thinkOn { + t.Fatal("second ^T did not disable thinking") + } + if strings.Contains(plain(m.header()), "✳ think") { + t.Error("header thinking indicator should clear when disabled") + } +} + // TestThinkingInterleavesWithTools verifies that reasoning blocks and tool // steps render in chronological order — thinking before and after a tool call // appears around it, not pinned above it — both while streaming and after the @@ -83,6 +153,25 @@ func TestThinkingInterleavesWithTools(t *testing.T) { } } +// TestRenderMessageTimelineFallbacks covers the timeline edge branches: a +// hand-built message (no items) falls back to fixed thinking→steps order, +// blank thinking items are skipped, and out-of-range step references are +// ignored without dropping the valid ones. +func TestRenderMessageTimelineFallbacks(t *testing.T) { + m := newTestModel() + msg := message{role: roleAsst, thinking: "old thought", steps: []step{{name: "read", done: true}}} + out, _ := m.renderMessage(msg, 0, 0) + if !strings.Contains(plain(out), "old thought") { + t.Errorf("fallback thinking not rendered:\n%s", plain(out)) + } + + msg.items = []turnItem{{thinking: true, text: " "}, {stepIdx: 7}, {stepIdx: -1}, {stepIdx: 0}} + out, _ = m.renderMessage(msg, 0, 0) + if !strings.Contains(plain(out), "read") { + t.Errorf("valid step should still render:\n%s", plain(out)) + } +} + // TestThinkingCapturedOnFinalize verifies that reasoning is stored in the // assistant message and still renders above the final response after the turn // ends. diff --git a/internal/tui/view.go b/internal/tui/view.go index ffdcf71..5569b74 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -50,20 +50,26 @@ func (m *Model) header() string { if modelName == "" { modelName = "default" } - // Sandbox status, prominently colored: green ● when isolated, amber ▲ - // when the agent has host access. - sandbox := m.sandboxBadge() - model := th.headerKey.Render(modelName) - left := logo + " " + model + // The left cluster, split around the model name: its truncation budget is + // computed against everything else once the segments are known. + head := logo + " " + tail := "" + // A subtle, persistent marker while extended thinking is enabled — the + // same ✳ glyph the per-turn stat line uses to flag a thought turn. + if m.thinkOn { + tail += th.headerMeta.Render(" · ✳ think") + } if m.odekVersion != "" { - left += th.headerMeta.Render(" · odek ") + th.headerKey.Render(m.odekVersion) + tail += th.headerMeta.Render(" · odek ") + th.headerKey.Render(m.odekVersion) } - left += th.headerMeta.Render(" · ") + sandbox + // Sandbox status, prominently colored: green ● when isolated, amber ▲ + // when the agent has host access. + tail += th.headerMeta.Render(" · ") + m.sandboxBadge() // Session spend rides the left cluster; hidden until odek reports both // token prices (never show a guessed $0). if inPrice, outPrice := m.limits.ResolvePrices(m.model); inPrice > 0 && outPrice > 0 { - left += th.headerMeta.Render(" · ") + th.headerKey.Render(formatUSD(costUSD(m.sessCtxTok, m.sessOutTok, inPrice, outPrice))) + tail += th.headerMeta.Render(" · ") + th.headerKey.Render(formatUSD(costUSD(m.sessCtxTok, m.sessOutTok, inPrice, outPrice))) } status := m.statusBadge() @@ -81,9 +87,17 @@ func (m *Model) header() string { return right } + // The model name carries the slack: truncate it (with ellipsis) to what + // the bar can hold against the most-shed right cluster, so a long model + // ID can never push the header past headerHeight lines. + budget := m.width - lipgloss.Width(head) - lipgloss.Width(tail) - lipgloss.Width(buildRight("")) - 1 // gap + if budget < 4 { + budget = 4 // keep a few chars; the clamp below covers absurd widths + } + left := head + th.headerKey.Render(truncate(modelName, budget)) + tail + // Shed gauge detail under width pressure: full gauge → compact glyph+percent - // → no gauge at all. The final gap clamp only prevents a negative pad; the - // remaining left/tokens/status overflow (if any) is pre-existing. + // → no gauge at all. The final gap clamp only prevents a negative pad. right := buildRight(m.ctxGauge(false)) gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { @@ -98,6 +112,12 @@ func (m *Model) header() string { gap = 1 } bar := left + strings.Repeat(" ", gap) + right + // Absolute guarantee: relayout and the mouse offset math assume the + // header occupies exactly headerHeight rows, so clamp any residual + // overflow ANSI-safely to one line (a no-op whenever the bar fits). + if m.width > 0 && lipgloss.Width(bar) > m.width { + bar = lipgloss.NewStyle().MaxWidth(m.width).Render(bar) + } return bar + "\n" + m.rule() } @@ -394,7 +414,14 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st if t == "" { continue } - excerpt := th.thinkStyle.Width(max(m.vp.Width-4, 8)).Render("… " + collapse(t)) + // Default: a capped one-line excerpt of the thought's head. + // expandAll unfolds the full text — but only once the turn is + // finalized; a live stream keeps the bounded excerpt. + body := collapse(capThinkingText(t, maxThinkingLen)) + if m.expandAll && !msg.streaming { + body = t + } + excerpt := th.thinkStyle.Width(max(m.vp.Width-4, 8)).Render("… " + body) if b.Len() > 0 { b.WriteString("\n") } @@ -542,14 +569,10 @@ func max(a, b int) int { // count. func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine int) (string, stepRef, int) { th := m.th - budget := m.vp.Width - 10 - if budget < 14 { - budget = 14 - } - detailBudget := m.vp.Width - 8 - if detailBudget < 16 { - detailBudget = 16 - } + // Floors stay at a few chars so truncation genuinely shrinks with the + // viewport instead of overflowing tiny widths (truncate handles the rest). + budget := max(m.vp.Width-10, 4) + detailBudget := max(m.vp.Width-8, 4) // A step shows its details when toggled individually or via the global // Ctrl+E toggle. expanded := s.expanded || m.expandAll @@ -565,15 +588,11 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in default: icon = th.stepRun.Render("▸") } - var chevron string - if s.done { - if expanded { - chevron = th.stepTree.Render("▼") - } else { - chevron = th.stepTree.Render("▶") - } - } else { - chevron = th.stepTree.Render(" ") + // Chevron: the expand affordance — shown on running steps too, since they + // toggle just like finished ones. + chevron := th.stepTree.Render("▶") + if expanded { + chevron = th.stepTree.Render("▼") } head := chevron + " " + icon + " " + th.toolIcon.Render(toolGlyph(s.name)) + " " + th.stepName.Render(s.name) if s.subagent { @@ -591,8 +610,11 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in if expanded { details := append([]string{}, s.logs...) for _, ln := range strings.Split(s.result, "\n") { - if c := collapse(ln); c != "" { - details = append(details, c) + // Already sanitized at ingest — keep the line verbatim so + // indentation and internal spacing (diffs, JSON, code) survive + // expansion; blank lines stay stripped for compactness. + if strings.TrimSpace(ln) != "" { + details = append(details, ln) } } if len(details) > 200 { @@ -706,6 +728,15 @@ func (m *Model) acPopup() string { } func (m *Model) approvalPanel() string { + return m.th.apprBox.Width(m.width - 2).Render(m.approvalBody()) +} + +// approvalBody builds the panel's inner content: head, the command (one +// collapsed line, or the full wrapped text once expanded via tab), the +// optional description, the selectable options, and the key hints. +// inputAreaHeight counts these lines, so every line must be pre-wrapped to +// fit the box — lipgloss would otherwise reflow them and break layout math. +func (m *Model) approvalBody() string { th := m.th a := m.approval head := th.apprHead.Render(fmt.Sprintf("⚠ approval required · risk: %s", orDash(a.Risk))) @@ -720,21 +751,38 @@ func (m *Model) approvalPanel() string { if a.Name != "" { target = a.Name + ": " + target } - cmd := th.apprBody.Render(truncate(collapse(target), m.width-8)) - desc := "" - if a.Description != "" { - desc = th.noticeStyle.Render(truncate(collapse(a.Description), m.width-8)) + "\n" + budget := m.width - 8 + lines := []string{head} + if m.apprExpanded { + for _, ln := range wrapText(sanitize(target), budget) { + lines = append(lines, th.apprBody.Render(ln)) + } + if a.Description != "" { + for _, ln := range wrapText(sanitize(a.Description), budget) { + lines = append(lines, th.noticeStyle.Render(ln)) + } + } + } else { + lines = append(lines, th.apprBody.Render(truncate(collapse(target), budget))) + if a.Description != "" { + lines = append(lines, th.noticeStyle.Render(truncate(collapse(a.Description), budget))) + } } - keys := th.apprKey.Render("a") + th.apprBody.Render(" approve ") + - th.apprKey.Render("d") + th.apprBody.Render(" deny") - if a.AllowTrust { - keys += th.apprBody.Render(" ") + th.apprKey.Render("t") + th.apprBody.Render(" trust class") + for i, o := range m.approvalOptions() { + prefix, label := " ", th.apprBody.Render(o.label) + if i == m.apprSel { + prefix, label = th.apprKey.Render("› "), th.apprKey.Render(o.label) + } + lines = append(lines, prefix+label) } - body := head + "\n" + cmd + "\n" + desc + keys - return th.apprBox.Width(m.width - 2).Render(body) + keys := th.apprKey.Render("↑↓") + th.apprBody.Render(" select ") + + th.apprKey.Render("⏎") + th.apprBody.Render(" confirm ") + + th.apprKey.Render("tab") + th.apprBody.Render(" expand ") + + th.apprKey.Render("esc") + th.apprBody.Render(" deny") + return strings.Join(append(lines, keys), "\n") } // ── footer ───────────────────────────────────────────────────────────────── @@ -746,7 +794,9 @@ func (m *Model) footer() string { } if m.disconn { hints := []string{th.footer.Render("connection closed")} - if m.opts.Reconnect != nil { + // r only fires with an empty input (see handleKey) — with a draft + // preserved it would just type into it, so don't offer it then. + if m.opts.Reconnect != nil && m.ta.Value() == "" { hints = append(hints, th.footerKey.Render("r")+th.footer.Render(" retry")) } hints = append(hints, th.footer.Render("^C to quit")) @@ -777,6 +827,16 @@ func (m *Model) footer() string { left += th.footerSep.Render(" · ") + th.scroll.Render(fmt.Sprintf("▸ %d queued", n)) } } + // Persistent expandAll indicator — while the global toggle holds every + // step open, per-step toggles look dead unless the chrome says why. + if m.expandAll { + ind := th.footerKey.Render("▼") + th.footer.Render(" details") + if left == "" { + left = " " + ind + } else { + left += th.footerSep.Render(" · ") + ind + } + } var segs []string if m.lastLatency > 0 { @@ -818,6 +878,28 @@ func (m *Model) panelFooter(hints ...string) string { // ── small helpers ────────────────────────────────────────────────────────── +// wrapText hard-wraps s to n columns by runes, keeping existing line breaks. +// It always returns at least one line, so an empty input still claims its row. +func wrapText(s string, n int) []string { + if n < 1 { + n = 1 + } + var out []string + for _, ln := range strings.Split(s, "\n") { + r := []rune(ln) + if len(r) == 0 { + out = append(out, "") + continue + } + for len(r) > n { + out = append(out, string(r[:n])) + r = r[n:] + } + out = append(out, string(r)) + } + return out +} + func orDash(s string) string { if s == "" { return "—"