From 168493bf849172e6d2114883fd232957c305c93e Mon Sep 17 00:00:00 2001 From: tdwd Date: Wed, 29 Jul 2026 15:16:07 +0200 Subject: [PATCH] Steer the running turn instead of queuing messages Typed-while-busy input was held in a local queue and only handed to claude one-per-turn after the result event, so it could never influence work already in flight. But claude's stream-json subprocess accepts a user envelope mid-turn and injects it at the next step boundary. Send immediately when busy so a message steers the running turn, landing in the transcript inline. Retires the now-dead local queue: pending tray, flushQueue, up-arrow edit-queued / dequeueLast, the queue field, and the esc-clear-queue stage (Esc now goes straight to interrupt, which is the undo for a mis-sent steer). --- README.md | 4 +-- chrome.go | 58 ------------------------------------------ chrome_test.go | 69 +++++++++++++++----------------------------------- keys.go | 60 ++++++++++--------------------------------- model.go | 1 - queue_test.go | 37 --------------------------- render.go | 20 +++------------ stream.go | 1 - update.go | 22 ---------------- view.go | 3 --- 10 files changed, 40 insertions(+), 235 deletions(-) delete mode 100644 queue_test.go diff --git a/README.md b/README.md index f81fe60..1577579 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 | diff --git a/chrome.go b/chrome.go index 75a8be3..8ee7ab3 100644 --- a/chrome.go +++ b/chrome.go @@ -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 diff --git a/chrome_test.go b/chrome_test.go index 896e6e6..a8b8289 100644 --- a/chrome_test.go +++ b/chrome_test.go @@ -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) } } } diff --git a/keys.go b/keys.go index 38b535d..01fd5b9 100644 --- a/keys.go +++ b/keys.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "os" "strconv" "strings" @@ -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 @@ -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()) @@ -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 -} diff --git a/model.go b/model.go index 9f5311b..ccf8f01 100644 --- a/model.go +++ b/model.go @@ -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 diff --git a/queue_test.go b/queue_test.go deleted file mode 100644 index 0a205b3..0000000 --- a/queue_test.go +++ /dev/null @@ -1,37 +0,0 @@ -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 0ea4573..fbd3cc3 100644 --- a/render.go +++ b/render.go @@ -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 } diff --git a/stream.go b/stream.go index bdd6651..688850c 100644 --- a/stream.go +++ b/stream.go @@ -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() } } diff --git a/update.go b/update.go index 90036fa..1d71ae2 100644 --- a/update.go +++ b/update.go @@ -1,7 +1,6 @@ package main import ( - "os" "time" "github.com/charmbracelet/bubbles/spinner" @@ -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 -} diff --git a/view.go b/view.go index ed5b567..bca81ea 100644 --- a/view.go +++ b/view.go @@ -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),