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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ subscription** because we never set an API key.
- **Info sidebar** — `ctrl+g` / `/sidebar` toggles an at-a-glance BBS info rail; `/sidebar left|right` (or `/settings`) sets the side it docks to (default right).
- **Bring your own tools** — point `-mcp` at a `.mcp.json` to wire extra MCP tools alongside the built-in approvals server.
- **Multi-line input** — Enter sends; insert a line break with `Alt+Enter`, `Ctrl+J`, or a trailing `\`. The prompt grows with your draft — line breaks *and* soft-wrap in narrow windows — up to 8 rows, then scrolls.
- **Prompt history & queueing** — `↑` / `↓` recalls past prompts (use `Ctrl+↑/↓` while composing a multi-line draft, where `↑/↓` move between lines); type while Claude is busy and messages queue, draining one per turn.
- **Prompt history & steering** — `↑` / `↓` recalls past prompts (use `Ctrl+↑/↓` while composing a multi-line draft, where `↑/↓` move between lines); type while Claude is busy and the message is injected into the running turn, so you can course-correct mid-flight instead of waiting for it to finish (`Esc` interrupts the turn to undo a mis-sent steer).

## Why this architecture (vs forking Crush/OpenCode)

Expand Down Expand Up @@ -140,7 +140,7 @@ Small files by responsibility (the project keeps each one scannable).

| file | role |
|------|------|
| `chrome.go` | banner, scrollbar, spinner frames, queued-message tray |
| `chrome.go` | banner, scrollbar, spinner frames |
| `status.go` | the DOS-style status bar + context gauge + git branch |
| `sidebar.go` | the BBS info rail |
| `theme.go` | palettes, styles, `applyTheme`, the theme list |
Expand Down
58 changes: 0 additions & 58 deletions chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,64 +24,6 @@ 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, 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 ""
}
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 queue[:shown] {
line := strings.ReplaceAll(q, "\n", " ")
out = append(out, cDim.Render(" ▸ "+trunc(line, width-4)))
}
if more {
out = append(out, cDim.Render(fmt.Sprintf(" … %d more", n-shown)))
}
return strings.Join(out, "\n")
}

// bbsScrollbar renders a vertical scrollbar `height` rows tall. Mirrors Crush's
// algorithm: thumb size scales with the visible portion; thumb position scales
// linearly with the scroll offset. When content fits, returns a blank track so
Expand Down
69 changes: 20 additions & 49 deletions chrome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,57 +64,28 @@ func lineCount(s string) int {
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)
// The composed frame must fit the terminal height at every window size that can
// still hold the chrome — the transcript viewport absorbs the slack and never
// pushes the total past the terminal.
func TestFrameFitsHeight(t *testing.T) {
for _, h := range []int{14, 16, 20, 30, 40} {
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")
}
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)
}
m.busy = true
m.resizeViewport()
m.refreshBody()
got := lineCount(m.renderBackground())
if got > h {
t.Errorf("h=%d: frame %d lines > terminal %d", h, got, h)
}
}
}

// 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)
}
if m.vp.Height < 1 {
t.Errorf("h=%d: viewport starved to %d rows", h, m.vp.Height)
}
}
}
60 changes: 14 additions & 46 deletions keys.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"fmt"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -169,15 +168,6 @@ 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 @@ -314,17 +304,10 @@ func (m *model) cancelQuestion() tea.Cmd {
return waitApproval(m.approvals)
}

// handleEsc cascades: clear queue → interrupt in-flight → quit. So a stray
// Esc with messages queued or work in flight never drops the whole session.
// handleEsc cascades: interrupt in-flight → quit. So a stray Esc with work in
// flight aborts the current turn (the way to undo a mis-sent steer) rather than
// dropping the whole session.
func (m model) handleEsc() (model, tea.Cmd, bool) {
if len(m.queue) > 0 {
dropped := len(m.queue)
m.queue = nil
m.resizeViewport()
m.rebuild()
m.add(entInfo, fmt.Sprintf("dropped %d queued message(s)", dropped))
return m, nil, true
}
if m.busy {
if err := m.engine.Interrupt(); err != nil {
m.add(entError, "interrupt failed: "+err.Error())
Expand Down Expand Up @@ -370,43 +353,28 @@ func (m model) handleEnter() (model, tea.Cmd, bool) {
return m, cmd, true
}

// sendTurn submits text as a user turn — queued while claude is busy, otherwise
// sent now — recording it in history and returning the working-spinner Cmd.
// Shared by Enter (typed prompts and forwarded slash commands) and the command
// palette, so all three reach claude the same way.
// sendTurn submits text as a user turn. When claude is already working the
// message is handed to the running subprocess right away, so claude injects it
// into the turn at its next step boundary (steering) — you can course-correct
// mid-flight instead of waiting for the turn to end. Idle, it opens a fresh
// turn. Either way it lands in the transcript now, is recorded in history, and
// arms the working spinner. Shared by Enter (typed prompts and forwarded slash
// commands) and the command palette, so all reach claude the same way.
func (m *model) sendTurn(text string) tea.Cmd {
m.hist.Append(text)
// Busy: queue the message instead of dropping it on the floor.
if m.busy {
m.queue = append(m.queue, text)
m.resizeViewport()
m.rebuild()
return nil
}
steering := m.busy
m.add(entUser, text)
if err := m.engine.Send(text); err != nil {
m.add(entError, "send error: "+err.Error())
return nil
}
// Capture the first prompt for the session so the resume picker has a
// human-recognisable label.
if m.session != "" {
// human-recognisable label — only when opening a turn, so a mid-turn steer
// doesn't overwrite it with a follow-up correction.
if !steering && m.session != "" {
cwd, _ := os.Getwd()
m.sessions.Touch(m.session, m.modelID, cwd, truncFirst(text), time.Now())
}
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
}
1 change: 0 additions & 1 deletion model.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ type model struct {
frameBody string
bodyKey bodyKey
contentVer int
queue []string // user messages typed while busy; drained one per turn end
toolUses map[string]string // tool_use_id -> tool name, so tool_result events can show what they're answering
busy bool
mode string
Expand Down
37 changes: 0 additions & 37 deletions queue_test.go

This file was deleted.

20 changes: 4 additions & 16 deletions render.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,23 +118,11 @@ 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).
// resizeViewport sets m.vp.Width/Height based on the current window size and
// the sidebar flag. Call after anything that changes those (window resize,
// sidebar toggle, prompt height change).
func (m *model) resizeViewport() {
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())
vpH := m.h - 5 - m.promptRows() // banner(3) + divider(1) + status(1) + prompt
if vpH < 1 {
vpH = 1
}
Expand Down
1 change: 0 additions & 1 deletion stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,5 @@ func (m *model) handleEvent(e Envelope) {
}
m.add(entInfo, fmt.Sprintf("— done · %.4f USD · %dms · %d turns —",
e.TotalCostUSD, e.DurationMS, e.NumTurns))
m.flushQueue()
}
}
22 changes: 0 additions & 22 deletions update.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"os"
"time"

"github.com/charmbracelet/bubbles/spinner"
Expand Down Expand Up @@ -211,24 +210,3 @@ func (m *model) armSpinnerIfNeeded() tea.Cmd {
return nil
}

// flushQueue pops the front of the queue (if any), sends it, and re-enters
// busy mode. Called when a turn ends; remaining items wait for the next
// result event.
func (m *model) flushQueue() {
if len(m.queue) == 0 {
return
}
next := m.queue[0]
m.queue = m.queue[1:]
m.resizeViewport()
m.add(entUser, next)
if err := m.engine.Send(next); err != nil {
m.add(entError, "send error: "+err.Error())
return
}
if m.session != "" {
cwd, _ := os.Getwd()
m.sessions.Touch(m.session, m.modelID, cwd, truncFirst(next), time.Now())
}
m.busy = true
}
3 changes: 0 additions & 3 deletions view.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,6 @@ func (m model) renderBackground() string {
sceneDivider(leet("session"), m.w),
body,
}
if tray := pendingTray(m.queue, m.w, m.trayBudget()); tray != "" {
parts = append(parts, tray)
}
parts = append(parts,
prompt,
bbsStatus(m.mode, m.modelID, m.session, gitBranch(), m.lastCost, m.ctxTokens, m.outTokens, m.ctxLimit, m.busy, m.sp.View(), m.w),
Expand Down
Loading