diff --git a/chrome.go b/chrome.go index 4782d2f..ff8dbe7 100644 --- a/chrome.go +++ b/chrome.go @@ -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 diff --git a/chrome_test.go b/chrome_test.go new file mode 100644 index 0000000..6fc37d8 --- /dev/null +++ b/chrome_test.go @@ -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) + } + } + } +} diff --git a/commands.go b/commands.go index 1287ca2..af05cc3 100644 --- a/commands.go +++ b/commands.go @@ -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) }, }, { @@ -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 { diff --git a/input_test.go b/input_test.go index a6f5658..cab9570 100644 --- a/input_test.go +++ b/input_test.go @@ -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, diff --git a/keys.go b/keys.go index dfa63ba..38b535d 100644 --- a/keys.go +++ b/keys.go @@ -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 @@ -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 +} diff --git a/model.go b/model.go index 4687dc5..01f844c 100644 --- a/model.go +++ b/model.go @@ -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 diff --git a/queue_test.go b/queue_test.go new file mode 100644 index 0000000..0a205b3 --- /dev/null +++ b/queue_test.go @@ -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) + } +} diff --git a/render.go b/render.go index be4a448..0ea4573 100644 --- a/render.go +++ b/render.go @@ -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 } @@ -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 diff --git a/scroll.go b/scroll.go index 311c9c6..a8dbf95 100644 --- a/scroll.go +++ b/scroll.go @@ -24,6 +24,20 @@ func newTranscriptViewport(w, h int) viewport.Model { return vp } +// setMouseCapture switches wheel capture on/off, notes it in the transcript, and +// returns the Bubble Tea command that reconfigures the terminal. Shared by the +// /mouse command and the shift+wheel gesture (see update.go) so the two entry +// points can't drift. +func (m *model) setMouseCapture(on bool) tea.Cmd { + m.mouse = on + if on { + m.add(entInfo, "→ mouse: ON — wheel scrolls the transcript") + return tea.EnableMouseCellMotion + } + m.add(entInfo, "→ mouse: OFF — drag to select/copy · wheel or ↑/↓ scrolls · ctrl+↑/↓ history") + return tea.DisableMouse +} + // syncScroll reconciles m.follow with what the user just did. Called at the tail // of Update, after the raw msg has been handed to the input and viewport. func (m *model) syncScroll(msg tea.Msg, prevInput string) { diff --git a/update.go b/update.go index a0d9c0e..fa7d3c9 100644 --- a/update.go +++ b/update.go @@ -33,6 +33,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.makeRenderer() m.rebuild() + case tea.MouseMsg: + // Shift+wheel is the terminal's native "let me select text" gesture. In the + // terminals that forward it to us (many grab it for their own scrollback and + // never do), take it as a shortcut into select mode — the same state /mouse + // off reaches. Only fires while capture is on; once off we stop receiving + // mouse events, so it's inherently one-way (run /mouse to come back). + if m.mouse && msg.Shift && tea.MouseEvent(msg).IsWheel() { + cmd := m.setMouseCapture(false) + m.refreshBody() + return m, tea.Batch(cmd, m.armHeaderIfNeeded()) + } + case tea.KeyMsg: nm, cmd, handled := m.handleKey(msg) if handled { diff --git a/view.go b/view.go index c5f466d..21bc5b9 100644 --- a/view.go +++ b/view.go @@ -53,7 +53,7 @@ func (m model) renderBackground() string { sceneDivider(leet("session"), m.w), body, } - if tray := pendingTray(m.queue, m.w); tray != "" { + if tray := pendingTray(m.queue, m.w, m.trayBudget()); tray != "" { parts = append(parts, tray) } parts = append(parts,