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
63 changes: 49 additions & 14 deletions chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,62 @@ func bbsBanner(width, phase int, style string) string {
return hdrBox.Width(width - 2).Render(title)
}

// trayMaxShown caps how many queued lines the tray lists before collapsing the
// remainder into a "… N more" summary — so a long queue can't fill the screen.
const trayMaxShown = 5

// trayWant is how many rows the pending tray would use for a queue of length n
// with no height pressure: a header, up to trayMaxShown message lines, and a
// "… N more" line when some are hidden. 0 for an empty queue.
func trayWant(n int) int {
if n <= 0 {
return 0
}
w := 1 + minInt(n, trayMaxShown)
if n > trayMaxShown {
w++
}
return w
}

// trayRows is trayWant clamped to a row budget, so the tray never claims more
// vertical space than the layout reserved for it. resizeViewport reserves
// exactly this many rows; pendingTray draws exactly this many. 0 disables it.
func trayRows(n, budget int) int {
if budget <= 0 {
return 0
}
return minInt(trayWant(n), budget)
}

// pendingTray renders the queued-while-busy messages as a dim strip above the
// input. Returns "" when the queue is empty. Truncates each line to width and
// caps the visible count so the tray doesn't take over the viewport.
func pendingTray(queue []string, width int) string {
if len(queue) == 0 {
// input, in at most `budget` rows (see trayRows) so it can't overrun the
// transcript on a short window. Returns "" when empty or starved of space.
// Under pressure it drops message lines — keeping the "N queued" header and a
// "… N more" tail — rather than spilling past its reserved height.
func pendingTray(queue []string, width, budget int) string {
n := len(queue)
rows := trayRows(n, budget)
if rows == 0 {
return ""
}
const maxShown = 5
rows := []string{cDim.Render(fmt.Sprintf("▶▶▶ %d queued", len(queue)))}
shown := queue
if len(shown) > maxShown {
shown = shown[:maxShown]
out := make([]string, 0, rows)
out = append(out, cDim.Render(fmt.Sprintf("▶▶▶ %d queued", n)))
body := rows - 1 // rows left for message + "… more" lines
shown := minInt(n, trayMaxShown)
more := n > shown
if rows < trayWant(n) { // budget-constrained: reserve a row for "… more"
shown = maxInt(0, body-1)
more = body >= 1
}
for _, q := range shown {
for _, q := range queue[:shown] {
line := strings.ReplaceAll(q, "\n", " ")
rows = append(rows, cDim.Render(" ▸ "+trunc(line, width-4)))
out = append(out, cDim.Render(" ▸ "+trunc(line, width-4)))
}
if len(queue) > maxShown {
rows = append(rows, cDim.Render(fmt.Sprintf(" … %d more", len(queue)-maxShown)))
if more {
out = append(out, cDim.Render(fmt.Sprintf(" … %d more", n-shown)))
}
return strings.Join(rows, "\n")
return strings.Join(out, "\n")
}

// bbsScrollbar renders a vertical scrollbar `height` rows tall. Mirrors Crush's
Expand Down
69 changes: 69 additions & 0 deletions chrome_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"fmt"
"strings"
"testing"
)

func lineCount(s string) int {
if s == "" {
return 0
}
return strings.Count(s, "\n") + 1
}

// The pending tray must never render more rows than its budget, and must always
// keep the "N queued" header so the count stays visible even when truncated.
func TestPendingTrayHonorsBudget(t *testing.T) {
for _, n := range []int{1, 2, 5, 6, 20} {
queue := make([]string, n)
for i := range queue {
queue[i] = fmt.Sprintf("queued message %d with some text", i)
}
for budget := 0; budget <= trayWant(n)+2; budget++ {
tray := pendingTray(queue, 80, budget)
got := lineCount(tray)
if got > budget {
t.Errorf("n=%d budget=%d: tray drew %d rows > budget", n, budget, got)
}
if got != trayRows(n, budget) {
t.Errorf("n=%d budget=%d: tray drew %d rows, trayRows says %d", n, budget, got, trayRows(n, budget))
}
if got > 0 && !strings.Contains(tray, fmt.Sprintf("%d queued", n)) {
t.Errorf("n=%d budget=%d: tray dropped the queued count:\n%s", n, budget, tray)
}
}
}
}

// The composed frame must fit the terminal height for any queue depth, at every
// window size that can still hold the chrome — the queued strip never laps over
// the chat (the reported bug: viewport clamped to 1 while the tray overran).
func TestFrameFitsWithQueue(t *testing.T) {
for _, h := range []int{14, 16, 20, 30, 40} {
for _, n := range []int{0, 1, 3, 6, 12} {
m := newModel(&Engine{}, "ask", nil, "bar", "")
m.w, m.h = 80, h
m.setPromptWidth(m.w - 4)
m.resizeViewport()
m.makeRenderer()
for i := 0; i < 80; i++ {
m.add(entClaude, "streamed assistant output line")
}
m.busy = true
for i := 0; i < n; i++ {
m.queue = append(m.queue, "queued message text here")
}
m.resizeViewport()
m.refreshBody()
got := lineCount(m.renderBackground())
if got > h {
t.Errorf("h=%d queue=%d: frame %d lines > terminal %d", h, n, got, h)
}
if m.vp.Height < 1 {
t.Errorf("h=%d queue=%d: viewport starved to %d rows", h, n, m.vp.Height)
}
}
}
}
11 changes: 3 additions & 8 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,9 @@ func slashCommands() []slashCmd {
},
{
name: "mouse",
desc: "toggle mouse capture — off lets you select/copy text",
desc: "toggle mouse capture — off (or shift+scroll) lets you select/copy text",
exec: func(m *model, _ string) (model, tea.Cmd) {
m.mouse = !m.mouse
if m.mouse {
m.add(entInfo, "→ mouse: ON — wheel scrolls the transcript")
return *m, tea.EnableMouseCellMotion
}
m.add(entInfo, "→ mouse: OFF — drag to select/copy · wheel or ↑/↓ scrolls · ctrl+↑/↓ history")
return *m, tea.DisableMouse
return *m, m.setMouseCapture(!m.mouse)
},
},
{
Expand Down Expand Up @@ -327,6 +321,7 @@ func helpText() string {
b.WriteString(" ? open this help modal\n")
b.WriteString(" ↑ / ↓ history · cursor between lines (multi-line) · scroll (mouse off)\n")
b.WriteString(" ctrl+↑ / ↓ prompt history (always)\n")
b.WriteString(" shift+scroll drop into select mode (terminals that forward it; /mouse returns)\n")
b.WriteString(" esc / ctrl+c quit\n")
b.WriteString("commands:\n")
for _, c := range cmds {
Expand Down
41 changes: 41 additions & 0 deletions input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,47 @@ func TestPromptGrowsOnSoftWrap(t *testing.T) {
}
}

// The reported bug: a newline keystroke moved the cursor to a new row while
// the widget was still its OLD (shorter) height, scrolling its internal
// viewport — and SetHeight doesn't reset that scroll, so the grown prompt
// showed only the last row. Drive the REAL Update loop: type, insert a line
// break (ctrl+j), type more — every row must stay on screen.
func TestNewlineKeepsFirstRowVisible(t *testing.T) {
cur := inputModel("")
cur.vp = newTranscriptViewport(40, 6)
cur.lastActivity = time.Now()
feed := func(msg tea.KeyMsg) {
next, _ := cur.Update(msg)
cur = next.(model)
// Render between keystrokes like the real program does — the textarea's
// internal viewport only ingests content during View, and its scrolling
// (the bug's trigger) acts on that state on the NEXT keystroke.
_ = cur.input.View()
}
for _, r := range "ALPHA line" {
feed(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
feed(tea.KeyMsg{Type: tea.KeyCtrlJ}) // the reported trigger
for _, r := range "second" {
feed(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
if rows := cur.promptRows(); rows != 2 {
t.Fatalf("prompt should be 2 rows, got %d", rows)
}
view := stripANSI(cur.input.View())
if !strings.Contains(view, "ALPHA") {
t.Fatalf("first row scrolled out of view after the newline:\n%s", view)
}
if !strings.Contains(view, "second") {
t.Fatalf("second row missing:\n%s", view)
}
// The cursor survived the re-anchor at the end of the draft.
feed(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Z'}})
if v := cur.input.Value(); !strings.HasSuffix(v, "secondZ") {
t.Fatalf("cursor not preserved at end after reanchor, value=%q", v)
}
}

// Fidelity: size the widget to our computed row count and confirm nothing is
// clipped. SetValue leaves the cursor at the END, so the textarea scrolls its
// tail into view — if promptVisualRows ever undercounts the real wrapped rows,
Expand Down
22 changes: 22 additions & 0 deletions keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,15 @@ func (m model) handleKey(msg tea.KeyMsg) (model, tea.Cmd, bool) {
m.follow = m.vp.AtBottom()
return m, nil, true
}
// Editing a queued message: an up-arrow on an empty prompt pulls the last
// queued message back out for editing (removing it from the queue) rather
// than recalling from history. Re-sending then replaces it, instead of the
// original going out as-is while the edit lands as a second queued item.
if !down && len(m.queue) > 0 && strings.TrimSpace(m.input.Value()) == "" {
m.input.SetValue(m.dequeueLast())
m.input.CursorEnd()
return m, nil, true
}
delta := -1
if down {
delta = 1
Expand Down Expand Up @@ -388,3 +397,16 @@ func (m *model) sendTurn(text string) tea.Cmd {
m.busy = true
return m.armSpinnerIfNeeded()
}

// dequeueLast removes and returns the most recently queued message, growing the
// transcript back into the space its tray row freed. Backs the up-arrow "edit
// the queued message" path: the message is pulled out for editing so re-sending
// replaces it, instead of the original going out as-is while the edit lands as
// a second queued item.
func (m *model) dequeueLast() string {
last := m.queue[len(m.queue)-1]
m.queue = m.queue[:len(m.queue)-1]
m.resizeViewport()
m.rebuild()
return last
}
7 changes: 5 additions & 2 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ type model struct {
input textarea.Model
// promptW is the total width last given to the input (setPromptWidth), kept
// so syncPromptHeight can derive the inner wrap width for soft-wrap sizing.
promptW int
sp spinner.Model
// lastPromptRows is the visual row count after the previous sync, used to
// detect re-entry from over-the-cap so the scroll can be re-anchored.
promptW int
lastPromptRows int
sp spinner.Model
// follow pins the transcript to the latest line while Claude streams;
// cleared when the user scrolls up to read back (see scroll.go).
follow bool
Expand Down
37 changes: 37 additions & 0 deletions queue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package main

import (
"testing"

tea "github.com/charmbracelet/bubbletea"
)

// Editing a queued message must replace it, not duplicate it. Before the fix,
// up-arrow recalled the queued text from history while leaving it on the queue,
// so the original went out as-is and the edit landed as a second queued item.
func TestUpArrowEditsQueuedMessage(t *testing.T) {
m := inputModel("")
m.mouse = true // plain up does history/queue recall (not transcript scroll)
m.hist = &history{}
m.busy = true
m.queue = []string{"original message"}

// Up-arrow pulls the queued message out for editing and empties the queue.
m, _, handled := m.handleKey(tea.KeyMsg{Type: tea.KeyUp})
if !handled {
t.Fatal("up-arrow with a queued message should be handled")
}
if got := m.input.Value(); got != "original message" {
t.Fatalf("input = %q, want the queued message pulled in for editing", got)
}
if len(m.queue) != 0 {
t.Fatalf("queue = %v, want empty (message moved into the prompt)", m.queue)
}

// Editing and re-sending replaces it — the queue holds only the edited text.
m.input.SetValue("edited message")
m, _, _ = m.handleEnter()
if len(m.queue) != 1 || m.queue[0] != "edited message" {
t.Fatalf("queue = %v, want exactly [edited message] (no duplicate original)", m.queue)
}
}
54 changes: 42 additions & 12 deletions render.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,20 +118,23 @@ func (m *model) renderEntry(e entry) string {
return ""
}

// trayBudget is how many rows the pending tray may occupy: the transcript's
// share of the window (total minus fixed chrome and the prompt) less a one-row
// floor kept for the viewport. resizeViewport reserves this and renderBackground
// draws within it — the single source that keeps the tray from overrunning the
// chat when the window is short or the queue is deep.
func (m *model) trayBudget() int {
return maxInt(0, m.h-5-m.promptRows()-1)
}

// resizeViewport sets m.vp.Width/Height based on the current window size,
// the sidebar flag, and the pending-tray height. Call after anything that
// changes those (window resize, sidebar toggle, queue mutation).
func (m *model) resizeViewport() {
vpH := m.h - 5 - m.promptRows() // banner(3) + divider(1) + status(1) + prompt
// Account for the pending tray when visible.
if n := len(m.queue); n > 0 {
const maxShown = 5
extra := 1 + minInt(n, maxShown)
if n > maxShown {
extra++
}
vpH -= extra
}
avail := m.h - 5 - m.promptRows() // banner(3) + divider(1) + status(1) + prompt
// Reserve the tray's rows out of the shared space; both sides read the same
// budget so viewport + tray always sum to `avail` — never an overrun.
vpH := avail - trayRows(len(m.queue), m.trayBudget())
if vpH < 1 {
vpH = 1
}
Expand Down Expand Up @@ -171,18 +174,45 @@ func (m *model) promptRows() int {
// window stays fully visible instead of scrolling out of sight — capped at
// maxPromptRows, resizing the transcript viewport when the row count changes.
// Called once per Update after the input has handled the message.
//
// The textarea scrolls its internal viewport while it is still the OLD height
// (its Update runs before this), and SetHeight doesn't re-anchor — so after
// growing we must reset that scroll or only the last row(s) stay visible.
// reanchorPrompt does that whenever the content fits the (new) height and a
// scroll could have happened: the height just changed, or we've come back
// under the cap.
func (m *model) syncPromptHeight() {
if m.pending != nil {
return // the approval bar replaces the prompt
}
want := promptVisualRows(m.input.Value(), m.promptInnerWidth())
rows := promptVisualRows(m.input.Value(), m.promptInnerWidth())
want := rows
if want > maxPromptRows {
want = maxPromptRows
}
if want != m.input.Height() {
changed := want != m.input.Height()
if changed {
m.input.SetHeight(want)
m.resizeViewport()
}
if rows <= maxPromptRows && (changed || m.lastPromptRows > maxPromptRows) {
m.reanchorPrompt()
}
m.lastPromptRows = rows
}

// reanchorPrompt scrolls the textarea's internal viewport back to the top —
// the only public route is rebuilding the value (SetValue → Reset → GotoTop) —
// and then restores the cursor: walk up to its hard line, then set the column.
func (m *model) reanchorPrompt() {
row := m.input.Line()
li := m.input.LineInfo()
col := li.StartColumn + li.CharOffset
m.input.SetValue(m.input.Value()) // resets scroll; cursor lands at the end
for i := 0; i < 1000 && m.input.Line() > row; i++ {
m.input.CursorUp()
}
m.input.SetCursor(col)
}

// promptInnerWidth is the width the textarea wraps text at: the total width we
Expand Down
Loading
Loading