refactor(ui): remove pre-alt-screen dead code and boilerplate

- Remove scrollbackBuf, appendScrollback(), drainScrollback() and all
  call sites — the entire terminal scrollback pipeline was dead code
  since the alt screen migration
- Remove StreamComponent.render(), renderCache, renderDirty,
  scrollbackFlushedLines, viewContent(), and ConsumeOverflow() body —
  rendering is now handled by StreamingMessageItem in the ScrollList
- Remove SetHeight and ConsumeOverflow from streamComponentIface since
  height is managed by ScrollList and overflow is a no-op
- Remove redundant AltScreen/MouseMode/ReportFocus/KeyboardEnhancements
  boilerplate from 6 child View() methods — parent already sets these
- Convert two orphan appendScrollback calls (extension default text,
  shell command output) to proper ScrollList message items
- Update ~30 stale comments referencing tea.Println and scrollback buffer
This commit is contained in:
Ed Zynda
2026-04-01 01:13:19 +03:00
parent 4c566836b2
commit b70cce4f34
9 changed files with 83 additions and 508 deletions
+13 -145
View File
@@ -701,167 +701,35 @@ func TestStreamComponent_StaleFlushTick_Discarded(t *testing.T) {
// TestStreamComponent_ConsumeOverflow_NoHeight verifies that when height is
// unconstrained (0), ConsumeOverflow always returns "".
func TestStreamComponent_ConsumeOverflow_NoHeight(t *testing.T) {
func TestStreamComponent_ConsumeOverflow_NoOp(t *testing.T) {
c := newTestStream()
// Commit some content directly.
c.streamContent.WriteString("line1\nline2\nline3")
c.phase = streamPhaseActive
c.renderDirty = true
// ConsumeOverflow is a no-op in alt screen mode — always returns "".
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty with height=0, got %q", got)
t.Fatalf("expected empty from no-op ConsumeOverflow, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_NoOverflow verifies that when content fits
// within the allocated height, ConsumeOverflow returns "".
func TestStreamComponent_ConsumeOverflow_NoOverflow(t *testing.T) {
c := newTestStream()
c.streamContent.WriteString("line1\nline2")
c.phase = streamPhaseActive
c.renderDirty = true
c.height = 20 // plenty of room
// Also returns "" with a height set.
c.height = 2
if got := c.ConsumeOverflow(); got != "" {
t.Fatalf("expected empty when content fits, got %q", got)
t.Fatalf("expected empty from no-op ConsumeOverflow with height, got %q", got)
}
}
// TestStreamComponent_ConsumeOverflow_EmitsTopLines verifies that when the
// rendered content has more lines than the allocated height, ConsumeOverflow
// returns the top overflow lines and advances the internal pointer.
func TestStreamComponent_ConsumeOverflow_EmitsTopLines(t *testing.T) {
// TestStreamComponent_GetRenderedContent_ReturnsAll verifies that
// GetRenderedContent returns all accumulated content.
func TestStreamComponent_GetRenderedContent_ReturnsAll(t *testing.T) {
c := newTestStream()
c.height = 2
// Build raw content that when "rendered" (plain text for this test)
// is 5 lines — we bypass the markdown renderer by writing directly to
// streamContent and using a nil renderer.
c.renderer = nil
c.phase = streamPhaseActive
c.streamContent.WriteString("a\nb\nc\nd\ne")
c.phase = streamPhaseActive
c.renderDirty = true
// First call: should return lines a, b, c (5 lines - 2 visible = 3 overflow).
overflow1 := c.ConsumeOverflow()
if overflow1 == "" {
t.Fatal("expected overflow, got empty")
}
overflowLines := strings.Split(overflow1, "\n")
if len(overflowLines) != 3 {
t.Fatalf("expected 3 overflow lines, got %d: %q", len(overflowLines), overflow1)
}
if overflowLines[0] != "a" || overflowLines[1] != "b" || overflowLines[2] != "c" {
t.Fatalf("unexpected overflow lines: %v", overflowLines)
}
// Second call without new content should return "" (pointer already advanced).
overflow2 := c.ConsumeOverflow()
if overflow2 != "" {
t.Fatalf("expected empty on second call, got %q", overflow2)
}
}
// TestStreamComponent_ConsumeOverflow_IncrementalFlush verifies that as new
// content arrives, ConsumeOverflow incrementally returns only newly overflowed
// lines on each call.
func TestStreamComponent_ConsumeOverflow_IncrementalFlush(t *testing.T) {
c := newTestStream()
c.height = 2
c.renderer = nil
c.phase = streamPhaseActive
// Start with 3 lines — 1 overflows.
c.streamContent.WriteString("a\nb\nc")
c.renderDirty = true
overflow1 := c.ConsumeOverflow()
if overflow1 != "a" {
t.Fatalf("expected 'a', got %q", overflow1)
}
// Add 2 more lines — 2 additional overflows.
c.streamContent.WriteString("\nd\ne")
c.renderDirty = true
overflow2 := c.ConsumeOverflow()
want := "b\nc"
if overflow2 != want {
t.Fatalf("expected %q, got %q", want, overflow2)
}
}
// TestStreamComponent_ConsumeOverflow_ResetClearsPointer verifies that Reset()
// resets the scrollback pointer so the next response starts fresh.
func TestStreamComponent_ConsumeOverflow_ResetClearsPointer(t *testing.T) {
c := newTestStream()
c.height = 1
c.renderer = nil
c.phase = streamPhaseActive
c.streamContent.WriteString("a\nb")
c.renderDirty = true
overflow := c.ConsumeOverflow()
if overflow != "a" {
t.Fatalf("expected 'a', got %q", overflow)
}
c.Reset()
if c.scrollbackFlushedLines != 0 {
t.Fatalf("expected scrollbackFlushedLines=0 after Reset, got %d", c.scrollbackFlushedLines)
}
}
// TestStreamComponent_GetRenderedContent_SkipsFlushedLines verifies that
// GetRenderedContent skips lines already emitted via ConsumeOverflow so the
// caller doesn't re-print content already in the terminal scrollback.
func TestStreamComponent_GetRenderedContent_SkipsFlushedLines(t *testing.T) {
c := newTestStream()
c.height = 2
c.renderer = nil
c.phase = streamPhaseActive
// 5 lines → 3 overflow, 2 visible.
c.streamContent.WriteString("a\nb\nc\nd\ne")
c.renderDirty = true
// Consume the overflow: lines a, b, c.
overflow := c.ConsumeOverflow()
if overflow != "a\nb\nc" {
t.Fatalf("expected 'a\\nb\\nc', got %q", overflow)
}
if c.scrollbackFlushedLines != 3 {
t.Fatalf("expected flushedLines=3, got %d", c.scrollbackFlushedLines)
}
// GetRenderedContent should only return the non-flushed portion: d, e.
got := c.GetRenderedContent()
if got != "d\ne" {
t.Fatalf("expected 'd\\ne', got %q", got)
}
}
// TestStreamComponent_GetRenderedContent_AllFlushed verifies that when all
// lines have been pushed via ConsumeOverflow, GetRenderedContent returns "".
func TestStreamComponent_GetRenderedContent_AllFlushed(t *testing.T) {
c := newTestStream()
c.height = 1
c.renderer = nil
c.phase = streamPhaseActive
// 2 lines → height=1, so 1 overflow.
c.streamContent.WriteString("a\nb")
c.renderDirty = true
// Consume overflow (line a), leaving 1 visible line (b).
_ = c.ConsumeOverflow()
// Now bump height so everything overflows — simulate a resize that made
// the viewable area 0, forcing all content to be "flushed".
c.scrollbackFlushedLines = 2 // pretend both lines were flushed
got := c.GetRenderedContent()
if got != "" {
t.Fatalf("expected empty when all lines flushed, got %q", got)
if got != "a\nb\nc\nd\ne" {
t.Fatalf("expected full content, got %q", got)
}
}
+2 -9
View File
@@ -411,7 +411,7 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
// Resolve via canonical command lookup so aliases are handled uniformly.
// Only /quit is handled locally — all other slash commands (including
// /clear and /clear-queue) are forwarded to the parent model via
// submitMsg so the parent can update its own state (scrollback, queue
// submitMsg so the parent can update its own state (ScrollList, queue
// counts, etc.) in one place.
if sc := GetCommandByName(trimmed); sc != nil {
switch sc.Name {
@@ -531,14 +531,7 @@ func (s *InputComponent) View() tea.View {
view.WriteString(helpStyle.Render(hint))
}
v := tea.NewView(containerStyle.Render(view.String()))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
return tea.NewView(containerStyle.Render(view.String()))
}
// renderPopup renders the autocomplete popup for slash command suggestions.
+43 -150
View File
@@ -395,11 +395,11 @@ type AppModelOptions struct {
// layout. It holds a reference to the app layer (AppController) for triggering
// agent work and queue operations.
//
// Layout (stacked, no alt screen):
// Layout (alt screen):
//
// ┌─ [custom header] (optional, from extension) ──────┐
// ├─ stream region (variable height) ─────────────────┤
// │
// ├─ scroll region (variable height, ScrollList) ─────┤
// │ (completed messages + live streaming text)
// ├─ separator line (with optional queue count) ───────┤
// │ [above widgets] │
// │ queued How do I fix the build? │
@@ -413,8 +413,8 @@ type AppModelOptions struct {
// The status bar is always present (1 line) to avoid layout shifts that
// occurred when usage info appeared/disappeared conditionally.
//
// Completed responses are emitted above the BT-managed region via tea.Println()
// before the model resets for the next interaction.
// All messages (completed and streaming) are rendered via the ScrollList
// viewport. The alt screen owns the full terminal.
type AppModel struct {
// state is the current state machine state.
state appState
@@ -429,7 +429,7 @@ type AppModel struct {
// stream is the child streaming display component (spinner + streaming text).
stream streamComponentIface
// renderer renders completed messages for tea.Println output.
// renderer renders completed messages for ScrollList display.
renderer Renderer
// modelName is the LLM model name shown in rendered messages.
@@ -437,7 +437,7 @@ type AppModel struct {
// queuedMessages stores the text of prompts that were queued (not yet
// submitted to the agent). They are rendered with a "queued" badge above
// the input and move to scrollback when the agent picks them up.
// the input and move to the ScrollList when the agent picks them up.
queuedMessages []string
// steeringMessages stores the text of prompts that were sent as steer
@@ -446,8 +446,6 @@ type AppModel struct {
steeringMessages []string
// scrollList manages the in-memory message history with viewport scrolling.
// Replaces the terminal scrollback (tea.Println) pattern with in-memory
// scrollback for alt screen mode.
scrollList *ScrollList
// messages holds all completed messages in the conversation history.
@@ -455,21 +453,11 @@ type AppModel struct {
messages []MessageItem
// pendingUserPrints holds user messages that have been consumed from the
// queue but not yet printed to scrollback. They are deferred until
// queue but not yet added to the ScrollList. They are deferred until
// SpinnerEvent{Show: true} so the previous assistant response can be
// flushed first, preserving chronological order.
// NOTE: With ScrollList, we add these directly to messages instead of printing.
pendingUserPrints []string
// scrollbackBuf is DEPRECATED in alt screen mode but kept for compatibility.
// In alt screen mode, messages go directly to the scrollList.
// All print helpers append here instead of returning tea.Println directly.
// The buffer is drained into a single atomic tea.Println at the end of
// each Update call via drainScrollback(). If the stream component has
// unflushed content, it is automatically prepended so that new messages
// always appear below the previous assistant response.
scrollbackBuf []string
// canceling tracks whether the user has pressed ESC once during stateWorking.
// A second ESC within 2 seconds will cancel the current step.
canceling bool
@@ -650,15 +638,9 @@ type streamComponentIface interface {
tea.Model
// Reset clears accumulated state between agent steps.
Reset()
// SetHeight constrains the render output to at most h lines (0 = unconstrained).
SetHeight(h int)
// GetRenderedContent returns the rendered assistant message from accumulated
// streaming text, or empty string if nothing has been accumulated.
GetRenderedContent() string
// ConsumeOverflow returns lines from the top of the rendered content that
// have overflowed the allocated height and haven't been pushed to the
// terminal scrollback yet. Returns "" when no new overflow exists.
ConsumeOverflow() string
// SpinnerView returns the rendered spinner line (animation + optional label).
// Returns "" when the spinner is not active. The parent renders this in the
// status bar so the spinner never changes the view height.
@@ -821,7 +803,7 @@ func (m *AppModel) uiVis() UIVisibility {
// AddStartupMessageToScrollList adds the logo and startup info as the first
// messages in the ScrollList. This is the only place startup information is
// rendered — nothing is printed to stdout/terminal scrollback.
// rendered — nothing is printed to stdout.
func (m *AppModel) AddStartupMessageToScrollList() {
if m.uiVis().HideStartupMessage {
return
@@ -1012,7 +994,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case ModelSelectorCancelledMsg:
@@ -1034,7 +1015,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.printSystemMessage("Session switching not available.")
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case SessionSelectorCancelledMsg:
@@ -1045,7 +1025,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case SessionDeletedMsg:
// Session was deleted from picker — just show a message.
m.printSystemMessage(fmt.Sprintf("Deleted session: %s", msg.Name))
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
// ── Window resize ────────────────────────────────────────────────────────
@@ -1367,7 +1346,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.handleSlashCommand(sc, strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
@@ -1388,7 +1366,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Regular prompt — forward to the app layer.
// Preprocess @file references: expand them into XML-wrapped file
// content before sending to the agent. The display text (shown in
// scrollback) uses the original user text so the UI stays clean.
// ScrollList) uses the original user text so the UI stays clean.
processedText := msg.Text
if m.cwd != "" {
processedText = ProcessFileAttachments(msg.Text, m.cwd)
@@ -1403,7 +1381,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
})
}
// Build display text for scrollback (include image count if any).
// Build display text for ScrollList (include image count if any).
displayText := msg.Text
if len(msg.Images) > 0 {
displayText = fmt.Sprintf("%s\n[%d image(s) attached]", msg.Text, len(msg.Images))
@@ -1422,15 +1400,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if qLen > 0 {
// Queued: anchor the message text above the input with a
// "queued" badge. It will be printed to scrollback when
// "queued" badge. It will be added to the ScrollList when
// the agent picks it up (via SpinnerEvent).
m.queuedMessages = append(m.queuedMessages, displayText)
m.layoutDirty = true
} else {
// Started immediately. Flush any leftover stream content
// from the previous step first, then print the user
// message — combined via the scrollback buffer so
// scrollback stays in chronological order.
// message — combined via the ScrollList so
// messages stay in chronological order.
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
m.flushStreamAndPendingUserMessages()
}
@@ -1468,9 +1446,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.SpinnerEvent:
// SpinnerEvent{Show: true} means a new agent step has started (either
// freshly or from the queue after a previous step completed). Flush
// any leftover stream content from the previous step to scrollback
// any leftover stream content from the previous step to the ScrollList
// before starting the new one, followed by any pending user messages
// from the queue. Everything goes through the scrollback buffer to
// from the queue. Everything goes through the ScrollList to
// guarantee chronological ordering.
if msg.Show {
m.flushStreamAndPendingUserMessages()
@@ -1506,7 +1484,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.appendStreamingChunk("assistant", msg.Content)
case app.ToolCallStartedEvent:
// Flush any accumulated streaming text to scrollback first (streaming
// Flush any accumulated streaming text to the ScrollList first (streaming
// always completes before tool calls fire). The tool call itself is
// NOT printed here — a unified block (header + result) will be
// rendered when the ToolResultEvent arrives.
@@ -1625,7 +1603,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.QueueUpdatedEvent:
// drainQueue popped item(s) from the queue. Move consumed
// messages to pendingUserPrints — they will be printed to
// scrollback in the next SpinnerEvent{Show: true} after the
// the ScrollList in the next SpinnerEvent{Show: true} after the
// previous assistant response is flushed.
for len(m.queuedMessages) > msg.Length {
text := m.queuedMessages[0]
@@ -1644,7 +1622,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// true} will follow within this turn, so we cannot rely on
// flushStreamAndPendingUserMessages() being called. Flush any live
// stream content first (assistant text up to the steer point), then
// render the steering user messages immediately to scrollback.
// render the steering user messages immediately to the ScrollList.
//
// 2. Post-turn (text-only response, drained after StepComplete): a
// SpinnerEvent{Show: true} for the next turn is already in flight.
@@ -1658,7 +1636,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.steeringMessages = m.steeringMessages[:0]
m.layoutDirty = true
cmds = append(cmds, m.drainScrollback())
} else {
// Case 2: post-turn — defer so SpinnerEvent orders correctly.
m.pendingUserPrints = append(m.pendingUserPrints, m.steeringMessages...)
@@ -1667,11 +1644,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case app.StepCompleteEvent:
// Keep stream content visible in the view — don't flush to scrollback
// Keep stream content visible in the view — don't flush to the ScrollList
// yet. Flushing + resetting in the same frame would shrink the view
// height, and bubbletea's inline renderer leaves blank lines at the
// bottom for the orphaned rows. The content will be flushed to
// scrollback when the next step starts (SpinnerEvent{Show: true}).
// the ScrollList when the next step starts (SpinnerEvent{Show: true}).
// Just stop the spinner and return to input state.
if m.stream != nil {
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
@@ -1694,7 +1671,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.StepErrorEvent:
// Keep partial stream content visible (same reasoning as
// StepCompleteEvent). Print the error to scrollback — it appears
// StepCompleteEvent). Print the error to the ScrollList — it appears
// above the view, and the partial response stays visible below.
if m.stream != nil {
updated, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
@@ -1874,7 +1851,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.printSystemMessage(fmt.Sprintf("Session shared!\n\n Viewer: %s\n Gist: %s", msg.viewerURL, msg.gistURL))
}
return m, m.drainScrollback()
return m, nil
case app.ExtensionPrintEvent:
// Extension output — route through styled renderers when a level is set.
@@ -1888,7 +1865,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "block":
m.printExtensionBlock(msg)
default:
m.appendScrollback(msg.Text)
// Plain text from extension — add as system message.
m.printSystemMessage(msg.Text)
}
default:
@@ -1905,29 +1883,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Flush any stream overflow lines that have grown past the allocated
// height into the terminal's real scrollback buffer. This ensures the
// diagram's invariant: streaming text starts at the top of the viewable
// terminal and overflows upward into the scrollback buffer rather than
// silently discarding the older lines.
//
// IMPORTANT: overflow is emitted directly via tea.Println rather than
// via appendScrollback. Using appendScrollback would cause drainScrollback
// to see a non-empty scrollbackBuf and trigger its auto-flush, which calls
// GetRenderedContent() + Reset() while the stream is still active —
// causing duplication and premature resets.
//
// NOTE: In alt screen mode, overflow is handled differently - we don't use
// tea.Println() since that writes to terminal scrollback, not alt screen.
// The StreamingMessageItem dynamically renders the current stream content.
// Overflow is not emitted - the full stream content is always rendered
// via StreamingMessageItem in the ScrollList viewport.
if m.stream != nil {
// Consume and discard overflow in alt screen mode
_ = m.stream.ConsumeOverflow()
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
@@ -2117,8 +2072,6 @@ func overlayContent(base, overlay string, width, height int) string {
return strings.Join(result, "\n")
}
// renderStream returns the stream region content.
// refreshContent updates the ScrollList with current messages.
// Called whenever messages change (new message, streaming update, etc.)
// ScrollList lazily renders only visible items on View() call.
@@ -2139,11 +2092,6 @@ func (m *AppModel) renderScrollback() string {
return m.scrollList.View()
}
// renderStreamingBashOutput renders accumulated streaming bash output (stdout + stderr)
// below the LLM streaming text. Returns empty string if no bash output is present.
// Lines are truncated to the terminal width and capped to maxBashLines to prevent
// long-running commands from blowing up the TUI layout.
// renderStatusBar renders a persistent single-line status bar below the input.
// Left side: spinner (when active). Middle: extension status entries (sorted by
// priority). Right side: provider · model + usage stats.
@@ -2425,10 +2373,10 @@ func (m *AppModel) renderQueuedMessages() string {
}
// --------------------------------------------------------------------------
// Print helpers — emit content to scrollback via tea.Println
// Print helpers — add content to ScrollList
// --------------------------------------------------------------------------
// printUserMessage renders a user message into the scrollback buffer.
// printUserMessage renders a user message into the ScrollList.
func (m *AppModel) printUserMessage(text string) {
// Check if this exact message was just added (prevents duplicates)
if len(m.messages) > 0 {
@@ -2448,12 +2396,9 @@ func (m *AppModel) printUserMessage(text string) {
// Refresh ScrollList content and scroll to bottom
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
// printAssistantMessage renders an assistant message into the scrollback buffer.
// printAssistantMessage renders an assistant message into the ScrollList.
func (m *AppModel) printAssistantMessage(text string) {
if strings.TrimSpace(text) != "" {
// Render styled content using MessageRenderer
@@ -2465,13 +2410,10 @@ func (m *AppModel) printAssistantMessage(text string) {
// Refresh ScrollList content and scroll to bottom
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
}
// printToolResult renders a tool result message into the scrollback buffer.
// printToolResult renders a tool result message into the ScrollList.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
// Render styled tool message using MessageRenderer
styledMsg := m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError)
@@ -2482,12 +2424,9 @@ func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
// Refresh ScrollList content
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
// printErrorResponse renders an error message into the scrollback buffer.
// printErrorResponse renders an error message into the ScrollList.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
if evt.Err != nil {
// Render styled error message using MessageRenderer
@@ -2499,9 +2438,6 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
// Refresh ScrollList content
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
}
@@ -2510,8 +2446,7 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
// --------------------------------------------------------------------------
// handleSlashCommand executes a recognized slash command and returns a tea.Cmd.
// args contains any text after the command name (may be empty). Returns tea.Quit
// for /quit, nil for commands with no output, or a tea.Println cmd for display.
// args contains any text after the command name (may be empty).
func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
switch sc.Name {
case "/quit":
@@ -2575,7 +2510,7 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
return nil
}
// printSystemMessage renders a system-level message into the scrollback buffer.
// printSystemMessage renders a system-level message into the ScrollList.
func (m *AppModel) printSystemMessage(text string) {
// Render styled system message using MessageRenderer
styledMsg := m.renderer.RenderSystemMessage(text, time.Now())
@@ -2586,13 +2521,10 @@ func (m *AppModel) printSystemMessage(text string) {
// Refresh ScrollList content
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
// printExtensionBlock renders a custom styled block from an extension with
// caller-chosen border color and optional subtitle into the scrollback buffer.
// caller-chosen border color and optional subtitle into the ScrollList.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
theme := GetTheme()
@@ -2623,9 +2555,6 @@ func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
// Refresh ScrollList content
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(rendered)
}
// handleExtensionCommand checks if the submitted text matches an extension-
@@ -2846,12 +2775,11 @@ func (m *AppModel) handleCompactCommand(customInstructions string) tea.Cmd {
}
// printCompactResult renders the compaction summary in a styled block with
// a distinct border color and a stats subtitle into the scrollback buffer.
// a distinct border color and a stats subtitle into the ScrollList.
// flushStreamContent moves rendered content from the stream component into the
// scrollback buffer and resets the stream. Called before tool calls (streaming
// completes before tools fire). The actual tea.Println is deferred to
// drainScrollback() at the end of the Update cycle.
// ScrollList and resets the stream. Called before tool calls (streaming
// completes before tools fire).
func (m *AppModel) flushStreamContent() {
if m.stream == nil {
return
@@ -2873,9 +2801,9 @@ func (m *AppModel) flushStreamContent() {
}
// flushStreamAndPendingUserMessages moves the previous assistant response and
// any pending queued user messages into the scrollback buffer. Called from
// any pending queued user messages into the ScrollList. Called from
// SpinnerEvent{Show: true} where all previous stream chunks are guaranteed to
// have been processed. The actual tea.Println is deferred to drainScrollback().
// have been processed.
func (m *AppModel) flushStreamAndPendingUserMessages() {
// 1. Flush previous stream content (assistant response).
if m.stream != nil {
@@ -2888,9 +2816,6 @@ func (m *AppModel) flushStreamAndPendingUserMessages() {
// Add to in-memory scrollList with styled content
msg := NewStyledMessageItem(generateMessageID(), "assistant", content, styledMsg.Content)
m.messages = append(m.messages, msg)
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
}
@@ -2902,9 +2827,6 @@ func (m *AppModel) flushStreamAndPendingUserMessages() {
// Add to in-memory scrollList with styled content
msg := NewStyledMessageItem(generateMessageID(), "user", text, styledMsg.Content)
m.messages = append(m.messages, msg)
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
m.pendingUserPrints = nil
@@ -2947,33 +2869,6 @@ func (m *AppModel) appendStreamingChunk(role, content string) {
m.refreshContent()
}
// appendScrollback adds rendered content to the scrollback buffer. The content
// will be emitted via tea.Println when drainScrollback is called at the end of
// the current Update cycle.
func (m *AppModel) appendScrollback(content string) {
if content != "" {
m.scrollbackBuf = append(m.scrollbackBuf, content)
}
}
// drainScrollback flushes the scrollback buffer into a single tea.Println. If
// the stream component has unflushed content, it is automatically prepended so
// that new messages always appear below the previous assistant response. When
// stream content is flushed a ClearScreen follows to clean up orphaned terminal
// rows left after the view height shrinks. Returns nil if there is nothing to
// print.
//
// drainScrollback is a no-op in alt screen mode. Scrollback is managed
// in-memory by ScrollList and never printed via tea.Println().
// The scrollbackBuf is still populated for compatibility but cleared here
// to prevent memory leaks.
func (m *AppModel) drainScrollback() tea.Cmd {
// In alt screen mode, all scrollback is managed in-memory by ScrollList.
// Never use tea.Println() as it writes to terminal scrollback, not alt screen.
m.scrollbackBuf = m.scrollbackBuf[:0] // Clear buffer to prevent memory leak
return nil
}
// distributeHeight recalculates child component heights after a window resize,
// queue change, widget update, or state transition, and propagates the computed
// stream height to the StreamComponent.
@@ -3051,11 +2946,6 @@ func (m *AppModel) distributeHeight() {
// The stream component still exists but is embedded as the last item in scrollList.
m.scrollList.SetHeight(streamHeight)
m.scrollList.SetWidth(m.width)
// Keep stream height in sync for rendering (even though it's embedded in scrollList)
if m.stream != nil {
m.stream.SetHeight(streamHeight)
}
}
// clamp constrains v to the range [lo, hi].
@@ -4022,7 +3912,7 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
}
// handleShellCommandResult processes the result of a shell command execution.
// It prints the output to scrollback and optionally injects it into the
// It prints the output to the ScrollList and optionally injects it into the
// conversation context (for ! commands) so the LLM can see it.
func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
theme := GetTheme()
@@ -4093,7 +3983,10 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
WithMarginBottom(1),
)
m.appendScrollback(rendered)
// Add shell command output to ScrollList.
msg2 := NewStyledMessageItem(generateMessageID(), "system", rendered, rendered)
m.messages = append(m.messages, msg2)
m.refreshContent()
// For ! (included in context): inject the command output into the
// conversation as a user message so the LLM can reference it on the
+1 -8
View File
@@ -281,14 +281,7 @@ func (ms *ModelSelectorComponent) View() tea.View {
footerStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
b.WriteString(footerStyle.Render(strings.Join(footerParts, " ")))
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
return tea.NewView(b.String())
}
// IsActive returns whether the selector is still accepting input.
+7 -10
View File
@@ -87,7 +87,6 @@ func (s *stubAppController) Steer(prompt string) int {
// stubStreamComponent satisfies streamComponentIface without rendering anything.
type stubStreamComponent struct {
resetCalled int
height int
lastMsg tea.Msg
renderedContent string // returned by GetRenderedContent
}
@@ -99,9 +98,7 @@ func (s *stubStreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (s *stubStreamComponent) View() tea.View { return tea.NewView("") }
func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" }
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
func (s *stubStreamComponent) ConsumeOverflow() string { return "" }
func (s *stubStreamComponent) SpinnerView() string { return "" }
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
func (s *stubStreamComponent) HasReasoning() bool { return false }
@@ -419,7 +416,7 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
if m.queuedMessages[0] != "queued prompt" {
t.Fatalf("expected queued message text 'queued prompt', got %q", m.queuedMessages[0])
}
// Should NOT produce a tea.Println cmd (message is anchored, not in scrollback).
// Should NOT flush (message is anchored in ScrollList).
if cmd != nil {
t.Fatal("expected nil cmd for queued submit (message should not print to scrollback)")
}
@@ -509,19 +506,19 @@ func TestWindowResize_propagatesToStream(t *testing.T) {
// sets the stream height after a resize.
func TestWindowResize_distributeHeight(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m, _, _ := newTestAppModel(ctrl)
// With height=30, stream height = 30 - 1 (separator) - 9 (input) - 1 (statusBar) = 19
// With height=30, scroll height = 30 - 1 (separator) - 9 (input) - 1 (statusBar) = 19
m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 30})
_ = m
if stream.height != 19 {
t.Fatalf("expected stream height=19, got %d", stream.height)
if m.scrollList.height != 19 {
t.Fatalf("expected scroll list height=19, got %d", m.scrollList.height)
}
}
// --------------------------------------------------------------------------
// tea.Println on step complete
// Step complete behavior
// --------------------------------------------------------------------------
// TestStepComplete_preservesStreamContent verifies that StepCompleteEvent
@@ -563,7 +560,7 @@ func TestSubmitMsg_printsUserMessage(t *testing.T) {
m = sendMsg(m, submitMsg{Text: "user query"})
// In alt screen mode, user messages are added to the in-memory ScrollList
// rather than printed via tea.Println. Verify the message was added.
// rather than printed separately. Verify the message was added.
found := false
for _, msg := range m.messages {
if tm, ok := msg.(*TextMessageItem); ok && tm.role == "user" && tm.content == "user query" {
+1 -8
View File
@@ -325,14 +325,7 @@ func (ss *SessionSelectorComponent) View() tea.View {
}
}
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
return tea.NewView(b.String())
}
// IsActive returns whether the selector is still accepting input.
+13 -158
View File
@@ -131,13 +131,13 @@ const (
// alongside streaming text until the step completes and Reset() is called.
//
// Tool calls, tool results, user messages, and other non-streaming content
// are printed immediately by the parent AppModel via tea.Println(). The
// StreamComponent only handles the live streaming text and spinner display.
// are added to the ScrollList by the parent AppModel. The StreamComponent
// only handles the live streaming text and spinner display.
//
// Lifecycle is managed entirely by the parent AppModel:
// - Parent calls Reset() between agent steps to clear state.
// - Parent emits completed responses above the BT region via tea.Println()
// then calls Reset(); StreamComponent never calls tea.Quit.
// - Content is displayed via StreamingMessageItem in the ScrollList.
// - StreamComponent never calls tea.Quit.
//
// Events handled:
// - app.SpinnerEvent{Show:true} → start spinner tick loop
@@ -196,23 +196,6 @@ type StreamComponent struct {
// ticks from a previous step can be discarded.
flushGeneration uint64
// renderCache holds the last rendered output string. Reused by View()
// between flush ticks to avoid redundant markdown re-parsing.
renderCache string
// renderDirty is true when committed content has changed since the
// last render. Set on flush tick; cleared after render() rebuilds
// the cache.
renderDirty bool
// scrollbackFlushedLines is the number of lines from the top of the
// rendered content that have already been emitted to the terminal
// scrollback buffer. On each flush, lines that overflow the allocated
// height and haven't been pushed yet are emitted via tea.Println so
// they appear in the terminal's real scrollback (scrollable with the
// terminal's own scroll mechanism).
scrollbackFlushedLines int
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
thinkingVisible bool
@@ -272,9 +255,6 @@ func (s *StreamComponent) SetHeight(h int) {
}
if s.height != h {
s.height = h
// Invalidate cache — height clamp affects output.
s.renderCache = ""
s.renderDirty = true
}
}
@@ -293,59 +273,23 @@ func (s *StreamComponent) Reset() {
s.pendingReasoning.Reset()
s.flushPending = false
s.flushGeneration++
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
s.reasoningStartTime = time.Time{}
s.reasoningDuration = 0
s.scrollbackFlushedLines = 0
}
// ConsumeOverflow returns any lines from the rendered stream content that have
// overflowed the allocated height and have not yet been pushed to the terminal
// scrollback buffer. It advances the internal flushed-line pointer so
// subsequent calls only return newly overflowed lines.
//
// Returns "" when there is no overflow or height is unconstrained (0).
// The caller should emit the returned string via tea.Println so the content
// appears in the terminal's real scrollback (not just discarded).
// ConsumeOverflow is a no-op in alt screen mode. Overflow is handled by the
// ScrollList viewport. Retained to satisfy streamComponentIface.
func (s *StreamComponent) ConsumeOverflow() string {
if s.height <= 0 {
return ""
}
content := s.render()
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
totalLines := len(lines)
// Number of lines that overflow the viewable height.
overflowLines := totalLines - s.height
if overflowLines <= 0 {
return ""
}
// How many overflow lines are new (not yet flushed to scrollback).
newOverflow := overflowLines - s.scrollbackFlushedLines
if newOverflow <= 0 {
return ""
}
// The new overflow is lines [s.scrollbackFlushedLines .. overflowLines).
start := s.scrollbackFlushedLines
end := overflowLines
s.scrollbackFlushedLines = overflowLines
return strings.Join(lines[start:end], "\n")
return ""
}
// GetRenderedContent returns the rendered assistant message from the accumulated
// streaming text. Returns empty string if no text has been accumulated. Used by
// the parent AppModel to flush content via tea.Println() before resetting.
// the parent AppModel to flush stream content before resetting.
//
// This commits any pending chunks first so the output includes all received
// content, not just what has been flushed by the tick.
//
// Lines already pushed to the terminal scrollback buffer via ConsumeOverflow
// are skipped so that callers do not re-emit content that is already visible
// in the terminal's real scrollback.
func (s *StreamComponent) GetRenderedContent() string {
// Commit any pending chunks so the final output is complete.
s.commitPending()
@@ -366,35 +310,21 @@ func (s *StreamComponent) GetRenderedContent() string {
if len(sections) == 0 {
return ""
}
fullContent := strings.Join(sections, "\n")
// Skip lines already emitted to the terminal scrollback via ConsumeOverflow
// so the caller doesn't re-print content that is already there.
if s.scrollbackFlushedLines > 0 {
lines := strings.Split(fullContent, "\n")
if s.scrollbackFlushedLines >= len(lines) {
return "" // everything already in scrollback
}
return strings.Join(lines[s.scrollbackFlushedLines:], "\n")
}
return fullContent
return strings.Join(sections, "\n")
}
// commitPending moves any pending chunks to the committed content builders.
// Called before reading content for scrollback output or on flush tick.
// Called before reading content for output or on flush tick.
func (s *StreamComponent) commitPending() {
if s.pendingStream.Len() > 0 {
// Strip ... tags that some models wrap reasoning in
cleanedText := thinkTagRegex.ReplaceAllString(s.pendingStream.String(), "")
s.streamContent.WriteString(cleanedText)
s.pendingStream.Reset()
s.renderDirty = true
}
if s.pendingReasoning.Len() > 0 {
s.reasoningContent.WriteString(s.pendingReasoning.String())
s.pendingReasoning.Reset()
s.renderDirty = true
}
}
@@ -417,9 +347,6 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if s.renderer != nil {
s.renderer.SetWidth(s.width)
}
// Invalidate render cache — width change affects wrapping/styling.
s.renderCache = ""
s.renderDirty = true
case streamSpinnerTickMsg:
// Only continue the tick loop if this tick belongs to the current
@@ -559,79 +486,10 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, nil
}
// View implements tea.Model. Renders the current stream region content.
// View implements tea.Model. Returns an empty view since rendering is handled
// by StreamingMessageItem in the ScrollList. Retained to satisfy tea.Model.
func (s *StreamComponent) View() tea.View {
fullContent := s.render()
visibleContent := s.viewContent(fullContent)
v := tea.NewView(visibleContent)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// --------------------------------------------------------------------------
// Internal rendering
// --------------------------------------------------------------------------
// render builds the full content string for the stream region. Uses a render
// cache to avoid redundant markdown re-parsing between flush ticks. The cache
// is invalidated when committed content changes (flush tick), terminal width
// changes, or height/thinking visibility changes.
func (s *StreamComponent) render() string {
if s.phase == streamPhaseIdle {
return ""
}
// Return cached render if committed content hasn't changed.
if !s.renderDirty {
return s.renderCache
}
var sections []string
// Render reasoning/thinking block above the main text if present.
if reasoning := s.reasoningContent.String(); reasoning != "" {
sections = append(sections, s.renderReasoningBlock(reasoning))
}
// Render streaming text only. The spinner is rendered in the status bar
// by the parent so it never changes the stream region height.
text := s.streamContent.String()
if text != "" {
sections = append(sections, s.renderStreamingText(text))
}
if len(sections) == 0 {
s.renderCache = ""
s.renderDirty = false
return ""
}
content := strings.Join(sections, "\n")
// Cache FULL content without height clamping.
// Height clamping is applied in View() for display only.
s.renderCache = content
s.renderDirty = false
return content
}
// viewContent returns the visible portion of content based on height constraint.
// This is called by View() to get the slice that fits in the terminal.
func (s *StreamComponent) viewContent(fullContent string) string {
if s.height > 0 && fullContent != "" {
lines := strings.Split(fullContent, "\n")
if len(lines) > s.height {
// Keep only the last h lines so the most recent output is visible.
lines = lines[len(lines)-s.height:]
return strings.Join(lines, "\n")
}
}
return fullContent
return tea.NewView("")
}
// renderReasoningBlock renders the reasoning/thinking content using blockquote.
@@ -692,9 +550,6 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
func (s *StreamComponent) SetThinkingVisible(visible bool) {
if s.thinkingVisible != visible {
s.thinkingVisible = visible
// Invalidate cache — thinking visibility affects rendered output.
s.renderCache = ""
s.renderDirty = true
}
}
+2 -12
View File
@@ -83,17 +83,8 @@ func (t *ToolApprovalInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (t *ToolApprovalInput) View() tea.View {
v := tea.NewView("")
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
if t.done {
v.Content = "we are done"
return v
return tea.NewView("we are done")
}
containerStyle := lipgloss.NewStyle()
@@ -145,6 +136,5 @@ func (t *ToolApprovalInput) View() tea.View {
}
view.WriteString(yesText + "/" + noText + "\n")
v.Content = containerStyle.Render(inputBoxStyle.Render(view.String()))
return v
return tea.NewView(containerStyle.Render(inputBoxStyle.Render(view.String())))
}
+1 -8
View File
@@ -265,14 +265,7 @@ func (ts *TreeSelectorComponent) View() tea.View {
footer := fmt.Sprintf("(%d/%d) [%s]", ts.cursor+1, len(ts.flatNodes), ts.filter)
b.WriteString(footerStyle.Render(footer))
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
return tea.NewView(b.String())
}
// IsActive returns whether the tree selector is still accepting input.