mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 11:40:13 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
+34
-33
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user