mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-16 04:26:04 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b70cce4f34 | |||
| 4c566836b2 | |||
| bb3261883a | |||
| 512d0f16ce | |||
| 8159431ce4 | |||
| 9f9f265fb3 |
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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