Compare commits

..

4 Commits

Author SHA1 Message Date
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
4 changed files with 291 additions and 53 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
+34 -33
View File
@@ -1799,39 +1799,40 @@ 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.
+101 -13
View File
@@ -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.
@@ -505,6 +509,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
@@ -726,6 +733,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
@@ -788,6 +796,9 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// Init implements tea.Model. Initialises child components. Startup info is
// printed to stdout before the program starts via PrintStartupInfo().
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()
@@ -865,6 +876,92 @@ func (m *AppModel) PrintStartupInfo() {
}
}
// AddStartupMessageToScrollList adds the startup info as the first message in the ScrollList.
// This ensures the startup info is visible in alt screen mode.
func (m *AppModel) AddStartupMessageToScrollList() {
if m.uiVis().HideStartupMessage {
return
}
// Build the same content as PrintStartupInfo but add to ScrollList
ty := createTypography(GetTheme())
var pairs [][2]string
if m.providerName != "" && m.modelName != "" {
pairs = append(pairs, [2]string{"Model", fmt.Sprintf("%s (%s)", m.providerName, m.modelName)})
}
if m.loadingMessage != "" {
pairs = append(pairs, [2]string{"Status", m.loadingMessage})
}
// Context — loaded AGENTS.md files.
if len(m.contextPaths) > 0 {
contextStr := tildeHome(m.contextPaths[0])
if len(m.contextPaths) > 1 {
contextStr += fmt.Sprintf(" +%d more", len(m.contextPaths)-1)
}
pairs = append(pairs, [2]string{"Context", contextStr})
}
// Skills — listed by name.
if len(m.skillItems) > 0 {
names := make([]string, len(m.skillItems))
for i, si := range m.skillItems {
names[i] = si.Name
}
pairs = append(pairs, [2]string{"Skills", strings.Join(names, ", ")})
}
// Extension tool count (only shown when > 0).
if m.extensionToolCount > 0 {
pairs = append(pairs, [2]string{"Extensions", fmt.Sprintf("%d tools", m.extensionToolCount)})
}
// MCP tool count (only shown when > 0).
if m.mcpToolCount > 0 {
pairs = append(pairs, [2]string{"MCP", fmt.Sprintf("%d tools", m.mcpToolCount)})
}
if len(pairs) > 0 {
rendered := ty.KVGroup(pairs)
rendered = styleMarginBottom1.Render(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
if len(m.messages) > 0 {
theme := GetTheme()
separator := strings.Repeat("─", 80)
separatorStyled := lipgloss.NewStyle().
Foreground(theme.Border).
Render(separator)
// Add blank line, separator, blank line
blankMsg := NewStyledMessageItem(generateMessageID(), "system", "", "")
separatorMsg := NewStyledMessageItem(generateMessageID(), "system", separatorStyled, separatorStyled)
blankMsg2 := NewStyledMessageItem(generateMessageID(), "system", "", "")
m.messages = append(m.messages, blankMsg, separatorMsg, blankMsg2)
}
// Refresh ScrollList once with all startup messages
if len(m.messages) > 0 {
m.refreshContent()
}
}
// tildeHome replaces the user's home directory prefix with ~ for display.
func tildeHome(path string) string {
home, err := os.UserHomeDir()
@@ -1548,14 +1645,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
@@ -2086,13 +2178,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.
@@ -2886,7 +2973,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()
}
+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
}
}
}
}