Compare commits

..

6 Commits

Author SHA1 Message Date
Ed Zynda b70cce4f34 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
2026-04-01 01:13:19 +03:00
Ed Zynda 4c566836b2 refactor(ui): move startup banner into ScrollList, fix /resume rendering
- Render ASCII logo and startup info exclusively in the ScrollList
  instead of printing to stdout/terminal scrollback
- Remove PrintStartupInfo() and move kitBanner() to ui.KitBanner()
- Fix separator spacing: use single pre-rendered item with embedded
  blank lines to avoid left-border artifacts on spacing rows
- Rewrite renderSessionHistory() to populate ScrollList with proper
  MessageItems instead of legacy appendScrollback() calls
- Clear m.messages on /clear, /new, and /resume so the ScrollList
  resets correctly when switching sessions
- Add pendingGotoBottom flag to defer scroll-to-bottom until after
  distributeHeight() recalculates the correct viewport height
- Fix pre-existing test failures: initialize scrollList in test helper,
  update 5 tests from tea.Println assertions to ScrollList checks
2026-04-01 00:39:32 +03:00
Ed Zynda bb3261883a Add visual separator after startup info in ScrollList
Added a horizontal rule (────) with blank lines above and below to
visually separate the startup info from the conversation history.

The separator uses theme.Border color and spans 80 characters, providing
a clear visual break between startup messages and the chat content.

This makes it easier to distinguish where the conversation starts when
scrolling back through history.
2026-03-31 19:07:56 +03:00
Ed Zynda 512d0f16ce Show startup info in ScrollList (alt screen mode)
Added AddStartupMessageToScrollList() method that renders startup info
(model, context, skills, extensions, MCP tools) and extension startup
messages as system messages in the ScrollList.

This ensures startup info is visible and scrollable in alt screen mode,
rather than being printed before BubbleTea starts and becoming hidden
when alt screen takes over.

Changes:
- AppModelOptions: Added StartupExtensionMessages field
- AppModel: Store and render startup messages in Init()
- AddStartupMessageToScrollList(): Renders startup info + extension messages
- cmd/root.go: Pass startupExtensionMessages to NewAppModel

The startup info now appears at the top of conversation history and can
be scrolled back to at any time.
2026-03-31 19:03:21 +03:00
Ed Zynda 8159431ce4 Prevent scrolling past bottom of content in ScrollList
Enhanced clampOffset() to detect when the viewport has scrolled past the
bottom of the content (would show empty space) and automatically reposition
to show the last line of content at the bottom of the viewport.

This prevents the 'floating' effect where multiple PgDn or scroll down
operations would push content off the top while showing blank space below.

The clamping logic:
1. Calculates total content height
2. If content fits in viewport, forces position to top
3. Otherwise, checks if remaining content < viewport height
4. If so, repositions to show exactly the last line at viewport bottom

Also updated clampOffset to use rendered height calculation (handles
non-cached items like reasoning blocks) instead of cached Height().
2026-03-31 18:56:18 +03:00
Ed Zynda 9f9f265fb3 Fix autoscroll for streaming messages (iteratr pattern)
Root cause: GotoBottom() was calculating heights using Height() which returns
0 for non-cached items. Reasoning blocks never cache renders due to live
duration updates, causing incorrect scroll calculations during reasoning →
assistant transitions.

Fix: Calculate heights directly from rendered strings instead of relying on
cached Height() values. This ensures accurate scroll positioning for all
message types.

Changes:
- ScrollList.GotoBottom(): Render items and calculate height from string
- ScrollList.AtBottom(): Same pattern for bottom detection
- appendStreamingChunk(): Call GotoBottom() directly for existing messages
- refreshContent(): Remove redundant GotoBottom() (handled by SetItems)

Tested with 'explore this repo' prompt - autoscroll now works correctly
throughout reasoning and assistant streaming phases.
2026-03-31 18:53:18 +03:00
13 changed files with 482 additions and 654 deletions
+80
View File
@@ -0,0 +1,80 @@
# Autoscroll Fix - Final Summary
## Root Cause
The autoscroll was failing for streaming assistant messages due to a bug in how `GotoBottom()` calculated item heights.
### The Problem
1. **Reasoning blocks** (`StreamingMessageItem` with `role="reasoning"`) are **never cached** because they have live duration counters that update every render
2. The `Height()` method returns `0` when `cachedRender == ""`
3. `GotoBottom()` was calling:
```go
itemHeight := item.Height() // Returns 0 for reasoning
if itemHeight == 0 {
item.Render(s.width) // Renders but doesn't cache (reasoning)
itemHeight = item.Height() // Still returns 0!
}
```
4. This caused incorrect scroll position calculations, especially during reasoning → assistant transitions
## The Solution
Changed `GotoBottom()` and `AtBottom()` to calculate height **directly from the rendered string** instead of relying on the cached height:
```go
// OLD: item.Height() which checks cached render
itemHeight := item.Height()
if itemHeight == 0 {
item.Render(s.width)
itemHeight = item.Height() // Still might be 0!
}
// NEW: Calculate from rendered string directly
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
```
This works for **all** items regardless of whether they cache their render or not.
## Files Changed
### `internal/ui/scrolllist.go`
- **`GotoBottom()`**: Calculate height from rendered string (2 loops)
- **`AtBottom()`**: Calculate height from rendered string (1 loop)
### `internal/ui/model.go`
- **`appendStreamingChunk()`**: For existing messages, call `GotoBottom()` directly (iteratr pattern)
- **`refreshContent()`**: Simplified to only call `SetItems()` (removed redundant `GotoBottom()`)
- **Bash streaming handler**: Removed redundant `GotoBottom()` after `refreshContent()`
## Testing Results
✅ **Test prompt**: "explore this repo"
**Before fix**:
- Autoscroll stopped after reasoning block completed
- Viewport stuck showing end of reasoning ("Thought for 203ms")
- Assistant response streamed off-screen below
**After fix**:
- Autoscroll works throughout reasoning block
- Autoscroll continues during reasoning → assistant transition
- Viewport stays at bottom showing latest assistant content
- Final position shows end of response (build commands section)
## Behavior Verified
1. ✅ Streaming text auto-scrolls to bottom
2. ✅ Works across reasoning → assistant transition
3. ✅ Manual scroll up (PgUp) disables autoscroll
4. ✅ Scroll to bottom (Alt+End) re-enables autoscroll
5. ✅ Accurate positioning with no offset errors
## Performance Note
The fix calls `Render()` on all items during `GotoBottom()` calculations. This is acceptable because:
- `Render()` is already optimized with caching for non-reasoning items
- `GotoBottom()` is only called during content updates (not every frame)
- Reasoning blocks need to render anyway for live duration updates
- This matches iteratr's approach of ensuring items are rendered before height calculations
+37 -70
View File
@@ -10,7 +10,6 @@ import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/auth"
"github.com/mark3labs/kit/internal/config"
@@ -217,29 +216,10 @@ func configToUiTheme(cfg config.Theme) ui.Theme {
}
}
// kitBanner returns the KIT ASCII art title with KITT scanner lights,
// rendered with a KITT red gradient.
// kitBanner returns the KIT ASCII art title with KITT scanner lights.
// Delegates to ui.KitBanner() which owns the logo rendering.
func kitBanner() string {
kittDark := lipgloss.Color("#8B0000")
kittBright := lipgloss.Color("#FF2200")
lines := []string{
" ██╗ ██╗ ██╗ ████████╗",
" ██║ ██╔╝ ██║ ╚══██╔══╝",
" █████╔╝ ██║ ██║",
" ██╔═██╗ ██║ ██║",
" ██║ ██╗ ██║ ██║",
" ╚═╝ ╚═╝ ╚═╝ ╚═╝",
" ░░░░░░▒▒▒▒▒▓▓▓▓███████████████▓▓▓▓▒▒▒▒▒░░░░░░",
}
var result strings.Builder
for i, line := range lines {
if i > 0 {
result.WriteString("\n")
}
result.WriteString(ui.ApplyGradient(line, kittDark, kittBright))
}
return result.String()
return ui.KitBanner()
}
func init() {
@@ -1799,55 +1779,42 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
Cwd: cwd,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
PromptTemplates: promptTemplates,
ContextPaths: contextPaths,
SkillItems: skillItems,
GetWidgets: getWidgets,
GetHeader: getHeader,
GetFooter: getFooter,
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
GetUIVisibility: getUIVisibility,
GetStatusBarEntries: getStatusBarEntries,
EmitBeforeFork: emitBeforeFork,
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
GetGlobalShortcuts: getGlobalShortcuts,
GetExtensionCommands: getExtensionCommands,
SetModel: setModel,
EmitModelChange: emitModelChange,
ThinkingLevel: thinkingLevel,
IsReasoningModel: isReasoningModel,
SetThinkingLevel: setThinkingLevel,
SwitchSession: switchSession,
ShowSessionPicker: resumeFlag,
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
Cwd: cwd,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
PromptTemplates: promptTemplates,
ContextPaths: contextPaths,
SkillItems: skillItems,
StartupExtensionMessages: startupExtensionMessages,
GetWidgets: getWidgets,
GetHeader: getHeader,
GetFooter: getFooter,
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
GetUIVisibility: getUIVisibility,
GetStatusBarEntries: getStatusBarEntries,
EmitBeforeFork: emitBeforeFork,
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
GetGlobalShortcuts: getGlobalShortcuts,
GetExtensionCommands: getExtensionCommands,
SetModel: setModel,
EmitModelChange: emitModelChange,
ThinkingLevel: thinkingLevel,
IsReasoningModel: isReasoningModel,
SetThinkingLevel: setThinkingLevel,
SwitchSession: switchSession,
ShowSessionPicker: resumeFlag,
})
// Print KIT banner and startup info to stdout before Bubble Tea takes over the screen.
fmt.Println(kitBanner())
fmt.Println()
appModel.PrintStartupInfo()
// Print any extension messages that were captured during startup.
if len(startupExtensionMessages) > 0 {
fmt.Println()
for _, msg := range startupExtensionMessages {
fmt.Println(msg)
}
fmt.Println()
}
program := tea.NewProgram(appModel)
// Register the program with the app layer so agent events are sent to the TUI.
+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)
}
}
+25
View File
@@ -294,3 +294,28 @@ func ApplyGradient(text string, colorA, colorB color.Color) string {
return result.String()
}
// KitBanner returns the KIT ASCII art title with KITT scanner lights,
// rendered with a KITT red gradient.
func KitBanner() string {
kittDark := lipgloss.Color("#8B0000")
kittBright := lipgloss.Color("#FF2200")
lines := []string{
" ██╗ ██╗ ██╗ ████████╗",
" ██║ ██╔╝ ██║ ╚══██╔══╝",
" █████╔╝ ██║ ██║",
" ██╔═██╗ ██║ ██║",
" ██║ ██╗ ██║ ██║",
" ╚═╝ ╚═╝ ╚═╝ ╚═╝",
" ░░░░░░▒▒▒▒▒▓▓▓▓███████████████▓▓▓▓▒▒▒▒▒░░░░░░",
}
var result strings.Builder
for i, line := range lines {
if i > 0 {
result.WriteString("\n")
}
result.WriteString(ApplyGradient(line, kittDark, kittBright))
}
return result.String()
}
+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.
+138 -184
View File
@@ -325,7 +325,7 @@ type AppModelOptions struct {
// GetUIVisibility returns the current UI visibility overrides set by
// an extension, or nil if none have been set (show everything).
// Called during View() and PrintStartupInfo() to conditionally hide
// Called during View() to conditionally hide
// built-in chrome elements. May be nil if no extensions are loaded.
GetUIVisibility func() *UIVisibility
@@ -378,6 +378,10 @@ type AppModelOptions struct {
// on startup (used by --resume flag).
ShowSessionPicker bool
// StartupExtensionMessages are messages captured during extension
// initialization. They are displayed in the ScrollList at startup.
StartupExtensionMessages []string
// ThinkingLevel is the initial thinking level (e.g. "off", "medium").
ThinkingLevel string
// IsReasoningModel is true when the current model supports reasoning.
@@ -391,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? │
@@ -409,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
@@ -425,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.
@@ -433,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
@@ -442,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.
@@ -451,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
@@ -495,7 +487,7 @@ type AppModel struct {
// treeSelector is the tree navigation overlay, active in stateTreeSelector.
treeSelector *TreeSelectorComponent
// contextPaths and skillItems are used by PrintStartupInfo for the
// contextPaths and skillItems are used by AddStartupMessageToScrollList for the
// [Context] and [Skills] sections.
contextPaths []string
skillItems []SkillItem
@@ -505,6 +497,9 @@ type AppModel struct {
mcpToolCount int
extensionToolCount int
// startupExtensionMessages stores messages from extensions during initialization.
startupExtensionMessages []string
// getWidgets returns extension widgets for a given placement. May be nil.
getWidgets func(placement string) []WidgetData
@@ -622,6 +617,11 @@ type AppModel struct {
// (resize, queue changes, widget updates, visibility changes, etc.).
// View() calls distributeHeight() when this is true and then clears it.
layoutDirty bool
// pendingGotoBottom requests a GotoBottom() after the next layout
// recalculation. Set when loading a session so that scrolling to the
// bottom happens with the correct viewport height.
pendingGotoBottom bool
}
// --------------------------------------------------------------------------
@@ -638,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.
@@ -726,6 +720,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.skillItems = opts.SkillItems
m.mcpToolCount = opts.MCPToolCount
m.extensionToolCount = opts.ExtensionToolCount
m.startupExtensionMessages = opts.StartupExtensionMessages
// Initialize streaming bash output buffer.
m.streamingBashMaxLines = 50 // cap to prevent memory issues
@@ -785,9 +780,11 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// tea.Model interface
// --------------------------------------------------------------------------
// Init implements tea.Model. Initialises child components. Startup info is
// printed to stdout before the program starts via PrintStartupInfo().
// Init implements tea.Model. Initialises child components.
func (m *AppModel) Init() tea.Cmd {
// Add startup info to ScrollList so it's visible in alt screen mode
m.AddStartupMessageToScrollList()
// m.input is always set by NewAppModel; its Init starts the textarea cursor blink.
// m.stream.Init() always returns nil, so there is nothing to batch.
return m.input.Init()
@@ -804,22 +801,21 @@ func (m *AppModel) uiVis() UIVisibility {
return UIVisibility{}
}
// PrintStartupInfo prints the startup banner (model name, context, skills,
// tool counts) to stdout. Call this before program.Run() so the messages are
// visible above the Bubble Tea managed region.
//
// All startup information is rendered inside a single system message block.
func (m *AppModel) PrintStartupInfo() {
// 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.
func (m *AppModel) AddStartupMessageToScrollList() {
if m.uiVis().HideStartupMessage {
return
}
// Create typography instance for startup rendering
// Add the ASCII logo at the very top.
logo := KitBanner()
logoMsg := NewStyledMessageItem(generateMessageID(), "logo", logo, logo)
m.messages = append(m.messages, logoMsg)
// Build key-value pairs for startup info.
ty := createTypography(GetTheme())
fmt.Println()
// Build key-value pairs for startup info
var pairs [][2]string
if m.providerName != "" && m.modelName != "" {
@@ -861,8 +857,33 @@ func (m *AppModel) PrintStartupInfo() {
if len(pairs) > 0 {
rendered := ty.KVGroup(pairs)
rendered = styleMarginBottom1.Render(rendered)
fmt.Println(rendered)
// Add as a styled system message to ScrollList
msg := NewStyledMessageItem(generateMessageID(), "system", rendered, rendered)
m.messages = append(m.messages, msg)
}
// Add extension startup messages if any
if len(m.startupExtensionMessages) > 0 {
for _, extMsg := range m.startupExtensionMessages {
msg := NewStyledMessageItem(generateMessageID(), "system", extMsg, extMsg)
m.messages = append(m.messages, msg)
}
}
// Add a visual separator after startup info: blank line + HR + blank line.
// Uses a single pre-rendered item so there are no left borders on the spacing.
theme := GetTheme()
separator := strings.Repeat("─", 80)
separatorStyled := lipgloss.NewStyle().
Foreground(theme.Border).
Render(separator)
separatorBlock := "\n" + separatorStyled + "\n"
separatorMsg := NewStyledMessageItem(generateMessageID(), "separator", separatorBlock, separatorBlock)
m.messages = append(m.messages, separatorMsg)
// Refresh ScrollList once with all startup messages
m.refreshContent()
}
// tildeHome replaces the user's home directory prefix with ~ for display.
@@ -973,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:
@@ -995,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:
@@ -1006,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 ────────────────────────────────────────────────────────
@@ -1328,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...)
}
}
@@ -1349,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)
@@ -1364,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))
@@ -1383,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()
}
@@ -1429,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()
@@ -1467,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.
@@ -1548,14 +1565,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// Refresh ScrollList
// Refresh ScrollList (handles autoscroll internally)
m.refreshContent()
// Auto-scroll to bottom
if m.scrollList != nil && m.scrollList.autoScroll {
m.scrollList.GotoBottom()
}
case app.ToolCallContentEvent:
// In streaming mode this text was already delivered via StreamChunkEvents
// and will be flushed before the next tool call. Ignore to avoid
@@ -1591,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]
@@ -1610,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.
@@ -1624,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...)
@@ -1633,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})
@@ -1660,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})
@@ -1840,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.
@@ -1854,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:
@@ -1871,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...)
}
@@ -1947,6 +1936,13 @@ func (m *AppModel) View() tea.View {
m.layoutDirty = false
}
// After layout is recalculated with correct heights, scroll to bottom
// if requested (e.g. after loading a session).
if m.pendingGotoBottom {
m.scrollList.GotoBottom()
m.pendingGotoBottom = false
}
vis := m.uiVis()
// Render scrollback content from ScrollList (replaces renderStream() in alt screen mode)
@@ -2076,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.
@@ -2086,13 +2080,8 @@ func (m *AppModel) refreshContent() {
return
}
// MessageItem implements ScrollItem interface, so we can use copy
// SetItems handles autoscroll internally if enabled
m.scrollList.SetItems(m.messages)
// Only adjust scroll position if auto-scroll is enabled
if m.scrollList.autoScroll {
m.scrollList.GotoBottom()
}
}
// renderScrollback returns the scrollback content from ScrollList.
@@ -2103,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.
@@ -2389,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 {
@@ -2412,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
@@ -2429,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)
@@ -2446,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
@@ -2463,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)
}
}
@@ -2474,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":
@@ -2503,6 +2474,8 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
// Clear the ScrollList so the conversation starts fresh.
m.messages = []MessageItem{}
m.printSystemMessage("Conversation cleared. Starting fresh.")
case "/clear-queue":
if m.appCtrl != nil {
@@ -2537,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())
@@ -2548,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()
@@ -2585,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-
@@ -2808,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
@@ -2835,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 {
@@ -2850,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)
}
}
@@ -2864,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
@@ -2886,7 +2846,8 @@ func (m *AppModel) appendStreamingChunk(role, content string) {
// If last message is a StreamingMessageItem with matching role, append to it
if streamMsg, ok := lastMsg.(*StreamingMessageItem); ok && streamMsg.role == role {
streamMsg.AppendChunk(content)
// Auto-scroll to bottom if enabled
// Auto-scroll to bottom if enabled (iteratr pattern)
// Don't call SetItems() - the slice reference hasn't changed
if m.scrollList != nil && m.scrollList.autoScroll {
m.scrollList.GotoBottom()
}
@@ -2908,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.
@@ -3012,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].
@@ -3308,6 +3237,8 @@ func (m *AppModel) performNewSession() tea.Cmd {
if m.usageTracker != nil {
m.usageTracker.Reset()
}
// Clear the ScrollList so the new session starts fresh.
m.messages = []MessageItem{}
m.printSystemMessage("Conversation cleared. Starting fresh.")
return nil
}
@@ -3325,6 +3256,8 @@ func (m *AppModel) performNewSession() tea.Cmd {
if m.usageTracker != nil {
m.usageTracker.Reset()
}
// Clear the ScrollList so the new session starts fresh.
m.messages = []MessageItem{}
m.printSystemMessage("New session started. Previous conversation saved.")
return nil
}
@@ -3575,7 +3508,7 @@ func (m *AppModel) handleResumeCommand() tea.Cmd {
}
// renderSessionHistory walks the current session branch and renders all
// messages (user, assistant, tool calls/results) into the scrollback buffer.
// messages (user, assistant, tool calls/results) into the ScrollList.
// This gives the user visual context of the conversation when resuming or
// importing a session. Call this after switchSession succeeds.
func (m *AppModel) renderSessionHistory() {
@@ -3589,6 +3522,9 @@ func (m *AppModel) renderSessionHistory() {
return
}
// Clear existing messages so we start fresh with the resumed session.
m.messages = []MessageItem{}
// First pass: build a map of tool call ID → {name, args} from assistant
// messages so we can pair them with tool results.
type toolCallInfo struct {
@@ -3613,7 +3549,7 @@ func (m *AppModel) renderSessionHistory() {
}
}
// Second pass: render each message in order.
// Second pass: create MessageItems for each message in order.
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
@@ -3628,14 +3564,18 @@ func (m *AppModel) renderSessionHistory() {
case message.RoleUser:
text := msg.Content()
if text != "" {
m.appendScrollback(m.renderer.RenderUserMessage(text, msg.CreatedAt).Content)
styledMsg := m.renderer.RenderUserMessage(text, msg.CreatedAt)
item := NewStyledMessageItem(generateMessageID(), "user", text, styledMsg.Content)
m.messages = append(m.messages, item)
}
case message.RoleAssistant:
// First render any reasoning/thinking content
reasoning := msg.Reasoning()
if reasoning.Thinking != "" {
m.appendScrollback(m.renderer.RenderReasoningBlock(reasoning.Thinking, msg.CreatedAt).Content)
styledMsg := m.renderer.RenderReasoningBlock(reasoning.Thinking, msg.CreatedAt)
item := NewStyledMessageItem(generateMessageID(), "reasoning", reasoning.Thinking, styledMsg.Content)
m.messages = append(m.messages, item)
}
// Then render the text content
text := msg.Content()
@@ -3644,7 +3584,9 @@ func (m *AppModel) renderSessionHistory() {
if msg.Model != "" {
modelName = msg.Model
}
m.appendScrollback(m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName).Content)
styledMsg := m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName)
item := NewStyledMessageItem(generateMessageID(), "assistant", text, styledMsg.Content)
m.messages = append(m.messages, item)
}
// Tool calls from assistant messages are rendered when we
// encounter their corresponding tool results below.
@@ -3659,10 +3601,19 @@ func (m *AppModel) renderSessionHistory() {
}
toolArgs = info.Args
}
m.appendScrollback(m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError).Content)
styledMsg := m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError)
item := NewStyledMessageItem(generateMessageID(), "tool", styledMsg.Content, styledMsg.Content)
m.messages = append(m.messages, item)
}
}
}
// Update the ScrollList with the rebuilt message list.
// Defer GotoBottom until after the next distributeHeight() so the
// scroll position is calculated with the correct viewport height.
m.refreshContent()
m.layoutDirty = true
m.pendingGotoBottom = true
}
// handleSessionInfoCommand shows session statistics.
@@ -3961,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()
@@ -4032,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.
+93 -45
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 }
@@ -136,6 +133,8 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
width: 80,
height: 24,
streamingBashMaxLines: 50, // Initialize buffer cap like NewAppModel does
scrollList: NewScrollList(80, 20),
messages: []MessageItem{},
}
return m, stream, input
}
@@ -417,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)")
}
@@ -507,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
@@ -552,65 +551,87 @@ func TestStepComplete_noStreamContent_noCmd(t *testing.T) {
}
}
// TestSubmitMsg_printsUserMessage verifies that submitMsg produces a tea.Println
// cmd for the user message.
// TestSubmitMsg_printsUserMessage verifies that submitMsg adds the user message
// to the ScrollList messages and triggers a layout update.
func TestSubmitMsg_printsUserMessage(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
_, cmd := m.Update(submitMsg{Text: "user query"})
m = sendMsg(m, submitMsg{Text: "user query"})
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) for user message on submitMsg")
// In alt screen mode, user messages are added to the in-memory ScrollList
// 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" {
found = true
break
}
}
if !found {
t.Fatal("expected user message 'user query' in ScrollList messages")
}
}
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent flushes
// accumulated stream content but does NOT print a tool call block (the unified
// block is printed later on ToolResultEvent).
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent marks
// any active StreamingMessageItem as complete and resets the stream.
func TestToolCallStarted_flushesOnly(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
// With no stream content, flush returns nil → cmd should be nil.
_, cmd := m.Update(app.ToolCallStartedEvent{
// With no stream content, nothing should change.
initialCount := len(m.messages)
m = sendMsg(m, app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
if cmd != nil {
t.Fatal("expected nil cmd on ToolCallStartedEvent with no stream content")
if len(m.messages) != initialCount {
t.Fatal("expected no new messages on ToolCallStartedEvent with no stream content")
}
// With stream content, flush returns tea.Println → cmd should be non-nil.
// Simulate a StreamingMessageItem already in messages (as if appendStreamingChunk was called)
// plus the stream component having rendered content.
streamItem := NewStreamingMessageItem("stream-1", "assistant", "test-model")
streamItem.AppendChunk("partial text")
m.messages = append(m.messages, streamItem)
stream.renderedContent = "partial text"
_, cmd = m.Update(app.ToolCallStartedEvent{
_ = sendMsg(m, app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
if cmd == nil {
t.Fatal("expected non-nil cmd on ToolCallStartedEvent with stream content to flush")
// The StreamingMessageItem should have been marked complete.
if streamItem.streaming {
t.Fatal("expected StreamingMessageItem to be marked complete after ToolCallStartedEvent")
}
// Stream should have been reset.
if stream.resetCalled == 0 {
t.Fatal("expected stream.Reset() to be called")
}
}
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent produces
// a non-nil cmd and the stream receives a SpinnerEvent.
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent adds
// the tool result to the ScrollList and the stream receives a SpinnerEvent.
func TestToolResult_printsAndStartsSpinner(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(app.ToolResultEvent{
initialCount := len(m.messages)
m = sendMsg(m, app.ToolResultEvent{
ToolName: "bash",
ToolArgs: "{}",
Result: "output",
IsError: false,
})
if cmd == nil {
t.Fatal("expected non-nil cmd on ToolResultEvent")
// Tool result should have been added to ScrollList messages.
if len(m.messages) <= initialCount {
t.Fatal("expected tool result message added to ScrollList")
}
// Stream should have received a SpinnerEvent to start spinner for next LLM call.
if stream.lastMsg == nil {
@@ -622,7 +643,7 @@ func TestToolResult_printsAndStartsSpinner(t *testing.T) {
}
// TestToolOutputEvent_accumulatesBashOutput verifies that ToolOutputEvent
// accumulates stdout and stderr lines into the streaming bash output buffers.
// accumulates stdout and stderr lines into a StreamingBashOutputItem in the ScrollList.
func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
@@ -636,11 +657,22 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
IsStderr: false,
})
if len(m.streamingBashOutput) != 1 || m.streamingBashOutput[0] != "line one\n" {
t.Fatalf("expected streamingBashOutput=['line one\\n'], got %v", m.streamingBashOutput)
// Should have created a StreamingBashOutputItem in messages.
var bashItem *StreamingBashOutputItem
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
break
}
}
if len(m.streamingBashStderr) != 0 {
t.Fatalf("expected empty streamingBashStderr, got %v", m.streamingBashStderr)
if bashItem == nil {
t.Fatal("expected StreamingBashOutputItem in messages after ToolOutputEvent")
}
if len(bashItem.stdoutLines) != 1 || bashItem.stdoutLines[0] != "line one\n" {
t.Fatalf("expected stdout=['line one\\n'], got %v", bashItem.stdoutLines)
}
if len(bashItem.stderrLines) != 0 {
t.Fatalf("expected empty stderr, got %v", bashItem.stderrLines)
}
// Send another stdout chunk.
@@ -651,8 +683,15 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
IsStderr: false,
})
if len(m.streamingBashOutput) != 2 {
t.Fatalf("expected 2 stdout lines, got %d", len(m.streamingBashOutput))
// Re-find the bash item (same item, updated)
bashItem = nil
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
}
}
if bashItem == nil || len(bashItem.stdoutLines) != 2 {
t.Fatalf("expected 2 stdout lines, got %d", len(bashItem.stdoutLines))
}
// Send stderr chunk.
@@ -663,11 +702,17 @@ func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
IsStderr: true,
})
if len(m.streamingBashStderr) != 1 {
t.Fatalf("expected 1 stderr line, got %d", len(m.streamingBashStderr))
bashItem = nil
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
}
}
if m.streamingBashStderr[0] != "error: something failed\n" {
t.Fatalf("expected stderr 'error: something failed\\n', got %q", m.streamingBashStderr[0])
if bashItem == nil || len(bashItem.stderrLines) != 1 {
t.Fatalf("expected 1 stderr line, got %d", len(bashItem.stderrLines))
}
if bashItem.stderrLines[0] != "error: something failed\n" {
t.Fatalf("expected stderr 'error: something failed\\n', got %q", bashItem.stderrLines[0])
}
}
@@ -749,16 +794,19 @@ func TestToolCallStarted_nonBashTool_doesNotSetCommand(t *testing.T) {
}
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
// produces a non-nil cmd (the tea.Println call for the error message).
// adds an error message to the ScrollList.
func TestStepError_printCmd(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(app.StepErrorEvent{Err: errors.New("agent failed")})
initialCount := len(m.messages)
if cmd == nil {
t.Fatal("expected non-nil cmd (tea.Println) on StepErrorEvent with error")
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("agent failed")})
// Error should have been added to ScrollList messages.
if len(m.messages) <= initialCount {
t.Fatal("expected error message added to ScrollList on StepErrorEvent")
}
}
+76 -7
View File
@@ -361,9 +361,13 @@ func (s *ScrollList) GotoBottom() {
}
// Calculate total height including gaps
// Ensure items are rendered before checking height (iteratr pattern)
totalHeight := 0
for i, item := range s.items {
totalHeight += item.Height()
// Render to get actual content (handles non-cached items like reasoning blocks)
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
totalHeight += itemHeight
// Add gap after each item except the last
if s.itemGap > 0 && i < len(s.items)-1 {
totalHeight += s.itemGap
@@ -380,7 +384,9 @@ func (s *ScrollList) GotoBottom() {
// Otherwise, position viewport at bottom
remaining := totalHeight - s.height
for idx := 0; idx < len(s.items); idx++ {
itemHeight := s.items[idx].Height()
// Render to get actual content
rendered := s.items[idx].Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if remaining < itemHeight {
s.offsetIdx = idx
s.offsetLine = remaining
@@ -411,10 +417,13 @@ func (s *ScrollList) AtBottom() bool {
}
// Calculate visible height from current position including gaps
// Calculate height directly from rendered content (handles non-cached items)
visibleHeight := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
itemHeight := item.Height()
// Render to get actual content
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if idx == s.offsetIdx {
visibleHeight += itemHeight - s.offsetLine
@@ -600,7 +609,8 @@ func (s *ScrollList) ScrollPercent() float64 {
}
// clampOffset ensures the offset values are within valid bounds after
// resizing or scrolling operations.
// resizing or scrolling operations. Prevents scrolling past the bottom
// of content (showing empty space when there's content above).
func (s *ScrollList) clampOffset() {
if len(s.items) == 0 {
s.offsetIdx = 0
@@ -608,7 +618,7 @@ func (s *ScrollList) clampOffset() {
return
}
// Clamp offsetIdx
// First, clamp offsetIdx to valid item range
if s.offsetIdx >= len(s.items) {
s.offsetIdx = len(s.items) - 1
}
@@ -616,9 +626,11 @@ func (s *ScrollList) clampOffset() {
s.offsetIdx = 0
}
// Clamp offsetLine
// Clamp offsetLine within current item
if s.offsetIdx < len(s.items) {
itemHeight := s.items[s.offsetIdx].Height()
// Calculate height from rendered content (handles non-cached items)
rendered := s.items[s.offsetIdx].Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if s.offsetLine >= itemHeight {
s.offsetLine = max(0, itemHeight-1)
}
@@ -626,4 +638,61 @@ func (s *ScrollList) clampOffset() {
if s.offsetLine < 0 {
s.offsetLine = 0
}
// Prevent scrolling past the bottom (showing empty space at bottom when there's content above)
// Calculate total content height
totalHeight := 0
for i, item := range s.items {
rendered := item.Render(s.width)
totalHeight += strings.Count(rendered, "\n") + 1
if s.itemGap > 0 && i < len(s.items)-1 {
totalHeight += s.itemGap
}
}
// If content fits in viewport, force start at top
if totalHeight <= s.height {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Calculate how many lines are currently above the viewport
linesAbove := 0
for i := 0; i < s.offsetIdx; i++ {
rendered := s.items[i].Render(s.width)
linesAbove += strings.Count(rendered, "\n") + 1
if s.itemGap > 0 && i < len(s.items)-1 {
linesAbove += s.itemGap
}
}
linesAbove += s.offsetLine
// Calculate how many lines are visible from current position to end
linesFromCurrentToEnd := totalHeight - linesAbove
// If there's less content remaining than the viewport height,
// we've scrolled past the bottom - need to back up
if linesFromCurrentToEnd < s.height {
// Position viewport so the last line of content is at the bottom
targetLine := totalHeight - s.height
currentLine := 0
for idx := 0; idx < len(s.items); idx++ {
rendered := s.items[idx].Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
if currentLine+itemHeight > targetLine {
// This item contains the target line
s.offsetIdx = idx
s.offsetLine = targetLine - currentLine
return
}
currentLine += itemHeight
if s.itemGap > 0 && idx < len(s.items)-1 {
currentLine += s.itemGap
}
}
}
}
+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.