mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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:
+13
-145
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user