feat: implement full alt screen mode with in-memory scrollback

Add ScrollList component for viewport-based message history with lazy
rendering and offset-based scrolling. Implement MessageItem system for
user, assistant, tool, system, and error messages with pre-rendered
styled content from MessageRenderer.

Key changes:
- ScrollList: height-constrained viewport with itemGap support, padding
  to ensure fixed height for sticky bottom layout
- MessageItem implementations with preRendered content from MessageRenderer
- refreshContent() pattern for efficient ScrollList updates
- Mouse wheel scrolling (3 lines per tick) with auto-scroll behavior
- All message types (user, assistant, tool, system, error, extension)
  properly added to in-memory scrollback
- PgUp/PgDn/Alt+Home/Alt+End keybindings for navigation
- Removed tea.Println() calls for alt screen compatibility
- Sticky bottom layout: input, separator, status bar fixed at bottom

Files added:
- internal/ui/scrolllist.go (ScrollList component)
- internal/ui/message_items.go (MessageItem implementations)

Files modified:
- internal/ui/model.go (main integration)
- internal/ui/*.go (alt screen config for components)
This commit is contained in:
Ed Zynda
2026-03-31 16:12:30 +03:00
parent 80093e69ed
commit db4be4f9a2
10 changed files with 867 additions and 64 deletions
+8 -1
View File
@@ -533,7 +533,14 @@ func (s *InputComponent) View() tea.View {
view.WriteString(helpStyle.Render(hint))
}
return tea.NewView(containerStyle.Render(view.String()))
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
}
// renderPopup renders the autocomplete popup for slash command suggestions.
+164
View File
@@ -0,0 +1,164 @@
package ui
import (
"fmt"
"strings"
"time"
)
// --------------------------------------------------------------------------
// MessageItem implementations for ScrollList
// --------------------------------------------------------------------------
// TextMessageItem represents a completed text message (user or assistant)
// in the scrollback. It uses pre-rendered styled content from MessageRenderer.
type TextMessageItem struct {
id string
role string // "user" or "assistant"
content string // Raw content (for re-rendering if needed)
preRendered string // Pre-rendered styled content from MessageRenderer
timestamp time.Time
}
// NewTextMessageItem creates a new text message for the scrollback.
// The content should be pre-rendered using MessageRenderer for proper styling.
func NewTextMessageItem(id string, role string, content string) *TextMessageItem {
return &TextMessageItem{
id: id,
role: role,
content: content,
timestamp: time.Now(),
}
}
// NewStyledMessageItem creates a message item with pre-rendered styled content.
// This is the preferred way to create messages when you have styled content from MessageRenderer.
func NewStyledMessageItem(id string, role string, rawContent string, preRendered string) *TextMessageItem {
return &TextMessageItem{
id: id,
role: role,
content: rawContent,
preRendered: preRendered,
timestamp: time.Now(),
}
}
func (m *TextMessageItem) ID() string {
return m.id
}
func (m *TextMessageItem) Render(width int) string {
// If we have pre-rendered styled content, return it
if m.preRendered != "" {
return m.preRendered
}
// Fallback to simple formatting if no pre-rendered content
return m.renderContent(width)
}
func (m *TextMessageItem) Height() int {
rendered := m.Render(0) // Width doesn't matter since we use pre-rendered
if rendered == "" {
return 0
}
return strings.Count(rendered, "\n") + 1
}
func (m *TextMessageItem) renderContent(width int) string {
var parts []string
// Role indicator
if m.role == "user" {
parts = append(parts, "│ ▸ You")
} else {
parts = append(parts, "") // Assistant messages start without role
}
// Content with simple wrapping
contentWidth := width - 4
if contentWidth < 20 {
contentWidth = 20
}
lines := strings.Split(m.content, "\n")
for _, line := range lines {
if len(line) <= contentWidth {
parts = append(parts, "│ "+line)
} else {
// Basic wrap
for len(line) > contentWidth {
parts = append(parts, "│ "+line[:contentWidth])
line = line[contentWidth:]
}
if len(line) > 0 {
parts = append(parts, "│ "+line)
}
}
}
return strings.Join(parts, "\n")
}
// --------------------------------------------------------------------------
// SystemMessageItem - System messages (commands, info, errors)
// --------------------------------------------------------------------------
// SystemMessageItem represents a system message (commands, info, errors).
type SystemMessageItem struct {
id string
content string
timestamp time.Time
cachedRender string
cachedWidth int
}
// NewSystemMessageItem creates a new system message for the scrollback.
func NewSystemMessageItem(id, content string) *SystemMessageItem {
return &SystemMessageItem{
id: id,
content: content,
timestamp: time.Now(),
}
}
func (m *SystemMessageItem) ID() string {
return m.id
}
func (m *SystemMessageItem) Render(width int) string {
// Return cached render if width matches
if m.cachedWidth == width && m.cachedRender != "" {
return m.cachedRender
}
// Simple system message formatting
rendered := "│ " + strings.ReplaceAll(m.content, "\n", "\n│ ")
// Cache and return
m.cachedRender = rendered
m.cachedWidth = width
return rendered
}
func (m *SystemMessageItem) Height() int {
if m.cachedRender != "" {
return strings.Count(m.cachedRender, "\n") + 1
}
// Estimate
if m.cachedWidth > 0 {
return (len(m.content) / max(m.cachedWidth-10, 40)) + 3
}
return 3
}
// --------------------------------------------------------------------------
// Helper: generateMessageID
// --------------------------------------------------------------------------
var messageCounter = 0
func generateMessageID() string {
messageCounter++
return fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), messageCounter)
}
+278 -51
View File
@@ -441,13 +441,24 @@ type AppModel struct {
// badge above the input. Cleared when the steer is consumed.
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.
// The scrollList renders from this slice based on its viewport offset.
messages []MessageItem
// pendingUserPrints holds user messages that have been consumed from the
// queue but not yet printed to scrollback. 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 collects rendered content during a single Update() call.
// 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
@@ -588,6 +599,10 @@ type AppModel struct {
width int
height int
// quitting signals that the app is shutting down. When true, View()
// disables alt screen to restore the terminal properly.
quitting bool
// streamingBashOutput holds the current streaming bash output lines.
// Lines are accumulated as they arrive and displayed in the stream region.
streamingBashOutput []string
@@ -715,6 +730,11 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// Initialize streaming bash output buffer.
m.streamingBashMaxLines = 50 // cap to prevent memory issues
// Initialize ScrollList for in-memory message history (alt screen mode).
// Height will be set properly by distributeHeight().
m.scrollList = NewScrollList(width, height-10) // Placeholder height
m.messages = []MessageItem{}
// Wire up child components now that we have the concrete implementations.
m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to quit)", appCtrl)
@@ -994,6 +1014,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.layoutDirty = true
// Update renderer width for proper message styling
m.renderer.SetWidth(m.width)
// Propagate to children.
if m.input != nil {
updated, cmd := m.input.Update(msg)
@@ -1006,6 +1028,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
// ── Mouse wheel scrolling ────────────────────────────────────────────────
case tea.MouseWheelMsg:
// Scroll the scrollback viewport with mouse wheel
const scrollLines = 3
switch msg.Button {
case tea.MouseWheelUp:
m.scrollList.ScrollBy(-scrollLines)
m.scrollList.autoScroll = false
case tea.MouseWheelDown:
m.scrollList.ScrollBy(scrollLines)
if m.scrollList.AtBottom() {
m.scrollList.autoScroll = true
}
}
// ── Keyboard input ───────────────────────────────────────────────────────
case tea.KeyPressMsg:
switch msg.String() {
@@ -1022,6 +1059,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.overlayResponseCh = nil
m.overlay = nil
}
// Set quitting flag so View() disables alt screen for clean exit.
m.quitting = true
// Graceful quit: app.Close() is deferred in cmd/root.go.
return m, tea.Quit
}
@@ -1041,6 +1080,31 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Scrollback keybindings (PgUp/PgDn/Home/End) for navigating message history.
// Only active when not working (to avoid conflicts during streaming).
if m.state == stateInput {
switch msg.String() {
case "pgup":
m.scrollList.ScrollBy(-m.scrollList.height)
m.scrollList.autoScroll = false
return m, tea.Batch(cmds...)
case "pgdown":
m.scrollList.ScrollBy(m.scrollList.height)
if m.scrollList.AtBottom() {
m.scrollList.autoScroll = true
}
return m, tea.Batch(cmds...)
case "alt+home":
m.scrollList.GotoTop()
m.scrollList.autoScroll = false
return m, tea.Batch(cmds...)
case "alt+end":
m.scrollList.GotoBottom()
m.scrollList.autoScroll = true
return m, tea.Batch(cmds...)
}
}
// Thinking keybindings — only when the model supports reasoning.
if m.isReasoningModel {
switch msg.String() {
@@ -1398,12 +1462,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ResponseCompleteEvent:
// This event fires for both streaming and non-streaming paths.
// In streaming mode, the content was already delivered via StreamChunkEvents
// and is sitting in the stream component (possibly with reasoning). Don't
// print or reset — flushStreamContent() handles it on the next step.
// In streaming mode, flush the accumulated stream content to scrollback.
// In non-streaming mode (no stream content accumulated), print the text.
hasStreamContent := m.stream != nil && m.stream.GetRenderedContent() != ""
if !hasStreamContent && strings.TrimSpace(msg.Content) != "" {
if hasStreamContent {
// Flush stream content to scrollback immediately
m.flushStreamContent()
} else if strings.TrimSpace(msg.Content) != "" {
m.printAssistantMessage(msg.Content)
if m.stream != nil {
m.stream.Reset()
@@ -1689,10 +1754,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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 {
if overflow := m.stream.ConsumeOverflow(); overflow != "" {
cmds = append(cmds, tea.Println(overflow))
}
// Consume and discard overflow in alt screen mode
_ = m.stream.ConsumeOverflow()
}
cmds = append(cmds, m.drainScrollback())
@@ -1704,6 +1774,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// The status bar is always present (1 line) to avoid layout shifts.
// When the tree selector is active, it replaces the stream region.
func (m *AppModel) View() tea.View {
// When quitting, disable alt screen for clean terminal restoration.
// This prevents terminal corruption issues on exit.
if m.quitting {
v := tea.NewView("")
v.AltScreen = false
v.MouseMode = tea.MouseModeNone
return v
}
// Tree selector overlay replaces the normal layout.
if m.state == stateTreeSelector && m.treeSelector != nil {
return m.treeSelector.View()
@@ -1721,7 +1800,14 @@ func (m *AppModel) View() tea.View {
// Overlay dialog replaces the normal layout.
if m.state == stateOverlay && m.overlay != nil {
return tea.NewView(m.overlay.Render())
v := tea.NewView(m.overlay.Render())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// Recompute layout heights if any Update() changed state that affects
@@ -1735,7 +1821,8 @@ func (m *AppModel) View() tea.View {
vis := m.uiVis()
streamView := m.renderStream()
// Render scrollback content from ScrollList (replaces renderStream() in alt screen mode)
scrollbackView := m.renderScrollback()
// Propagate hint visibility to the input component before rendering.
if ic, ok := m.input.(*InputComponent); ok {
@@ -1760,11 +1847,26 @@ func (m *AppModel) View() tea.View {
parts = append(parts, headerView)
}
// Only include the stream region when it has content. When idle the
// stream renders "" which JoinVertical would pad to a full-width blank
// Only include the scrollback region when it has content. When idle the
// scrollback renders "" which JoinVertical would pad to a full-width blank
// line, inflating the view unnecessarily.
if streamView != "" {
parts = append(parts, streamView)
if scrollbackView != "" {
parts = append(parts, scrollbackView)
}
// Add bash output and canceling warning between scrollback and separator
// (these don't go inside scrollback viewport to avoid affecting scroll position)
theme := GetTheme()
bashView := m.renderStreamingBashOutput(theme)
if bashView != "" {
parts = append(parts, bashView)
}
if m.canceling {
warning := lipgloss.NewStyle().
Foreground(theme.Warning).
Bold(true).
Render(" ⚠ Press ESC again to cancel")
parts = append(parts, warning)
}
if !vis.HideSeparator {
@@ -1798,7 +1900,14 @@ func (m *AppModel) View() tea.View {
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
return tea.NewView(content)
v := tea.NewView(content)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// --------------------------------------------------------------------------
@@ -1840,6 +1949,31 @@ func (m *AppModel) renderStream() string {
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
// 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.
func (m *AppModel) refreshContent() {
if m.scrollList == nil {
return
}
// MessageItem implements ScrollItem interface, so we can use copy
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.
// This replaces renderStream() in alt screen mode.
func (m *AppModel) renderScrollback() string {
// Content is refreshed via refreshContent() when messages change
// ScrollList renders lazily on View() call
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
@@ -2214,25 +2348,78 @@ func (m *AppModel) renderQueuedMessages() string {
// printUserMessage renders a user message into the scrollback buffer.
func (m *AppModel) printUserMessage(text string) {
m.appendScrollback(m.renderer.RenderUserMessage(text, time.Now()).Content)
// Check if this exact message was just added (prevents duplicates)
if len(m.messages) > 0 {
if lastMsg, ok := m.messages[len(m.messages)-1].(*TextMessageItem); ok {
if lastMsg.role == "user" && lastMsg.content == text {
return // Skip duplicate
}
}
}
// Render styled content using MessageRenderer
styledMsg := m.renderer.RenderUserMessage(text, time.Now())
// Add to in-memory scrollList with styled content
msg := NewStyledMessageItem(generateMessageID(), "user", text, styledMsg.Content)
m.messages = append(m.messages, msg)
// 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.
func (m *AppModel) printAssistantMessage(text string) {
if strings.TrimSpace(text) != "" {
m.appendScrollback(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
// Render styled content using MessageRenderer
styledMsg := m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName)
// Add to in-memory scrollList with styled content
msg := NewStyledMessageItem(generateMessageID(), "assistant", text, styledMsg.Content)
m.messages = append(m.messages, msg)
// 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.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
m.appendScrollback(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
// Render styled tool message using MessageRenderer
styledMsg := m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError)
// Add to in-memory scrollList with styled content
msg := NewStyledMessageItem(generateMessageID(), "tool", styledMsg.Content, styledMsg.Content)
m.messages = append(m.messages, msg)
// 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.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
if evt.Err != nil {
m.appendScrollback(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
// Render styled error message using MessageRenderer
styledMsg := m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now())
// Add to in-memory scrollList with styled content
msg := NewStyledMessageItem(generateMessageID(), "error", styledMsg.Content, styledMsg.Content)
m.messages = append(m.messages, msg)
// Refresh ScrollList content
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
}
@@ -2246,6 +2433,7 @@ func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
switch sc.Name {
case "/quit":
m.quitting = true
return tea.Quit
case "/help":
m.printHelpMessage()
@@ -2305,7 +2493,18 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand, args string) tea.Cmd {
// printSystemMessage renders a system-level message into the scrollback buffer.
func (m *AppModel) printSystemMessage(text string) {
m.appendScrollback(m.renderer.RenderSystemMessage(text, time.Now()).Content)
// Render styled system message using MessageRenderer
styledMsg := m.renderer.RenderSystemMessage(text, time.Now())
// Add to in-memory scrollList with styled content
msg := NewStyledMessageItem(generateMessageID(), "system", styledMsg.Content, styledMsg.Content)
m.messages = append(m.messages, msg)
// 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
@@ -2333,6 +2532,15 @@ func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
WithBorderColor(borderClr),
WithMarginBottom(1),
)
// Add to in-memory scrollList with rendered content
msg := NewStyledMessageItem(generateMessageID(), "extension", rendered, rendered)
m.messages = append(m.messages, msg)
// Refresh ScrollList content
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(rendered)
}
@@ -2593,7 +2801,19 @@ func (m *AppModel) flushStreamContent() {
return
}
m.stream.Reset()
m.appendScrollback(content)
// Render styled content using MessageRenderer
styledMsg := m.renderer.RenderAssistantMessage(content, time.Now(), m.modelName)
// Add to in-memory scrollList as a completed assistant message with styled content
msg := NewStyledMessageItem(generateMessageID(), "assistant", content, styledMsg.Content)
m.messages = append(m.messages, msg)
// Refresh ScrollList content
m.refreshContent()
// Also append to legacy buffer for compatibility
m.appendScrollback(styledMsg.Content)
}
// flushStreamAndPendingUserMessages moves the previous assistant response and
@@ -2605,16 +2825,35 @@ func (m *AppModel) flushStreamAndPendingUserMessages() {
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
m.appendScrollback(content)
// Render styled content using MessageRenderer
styledMsg := m.renderer.RenderAssistantMessage(content, time.Now(), m.modelName)
// 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)
}
}
// 2. Render pending user messages from the queue.
for _, text := range m.pendingUserPrints {
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
m.appendScrollback(rendered)
// Render styled content using MessageRenderer
styledMsg := m.renderer.RenderUserMessage(text, time.Now())
// 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
// Refresh ScrollList content once after all messages are added
m.refreshContent()
}
// appendScrollback adds rendered content to the scrollback buffer. The content
@@ -2632,34 +2871,16 @@ func (m *AppModel) appendScrollback(content string) {
// 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 {
if len(m.scrollbackBuf) == 0 {
return nil
}
var parts []string
needsClear := false
// Auto-flush any stream content so it appears before new messages.
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
parts = append(parts, content)
needsClear = true
}
}
parts = append(parts, m.scrollbackBuf...)
m.scrollbackBuf = m.scrollbackBuf[:0]
printCmd := tea.Println(strings.Join(parts, "\n"))
if needsClear {
return tea.Sequence(
printCmd,
func() tea.Msg { return tea.ClearScreen() },
)
}
return printCmd
// 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,
@@ -2735,6 +2956,12 @@ func (m *AppModel) distributeHeight() {
streamHeight := max(m.height-separatorLines-widgetLines-headerFooterLines-queuedLines-inputLines-statusBarLines, 0)
// In alt screen mode, give the calculated height to ScrollList instead of stream.
// 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)
}
+8 -1
View File
@@ -281,7 +281,14 @@ func (ms *ModelSelectorComponent) View() tea.View {
footerStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
b.WriteString(footerStyle.Render(strings.Join(footerParts, " ")))
return tea.NewView(b.String())
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// IsActive returns whether the selector is still accepting input.
+16 -5
View File
@@ -118,22 +118,33 @@ func (m ProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// status information and help text. Displays error messages if present or
// a completion message when the download finishes.
func (m ProgressModel) View() tea.View {
var v tea.View
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
if m.err != nil {
return tea.NewView(fmt.Sprintf("Error: %s\n", m.err.Error()))
v.Content = fmt.Sprintf("Error: %s\n", m.err.Error())
return v
}
if m.complete {
return tea.NewView(fmt.Sprintf("\n%s%s\n\n%sComplete!\n",
v.Content = fmt.Sprintf("\n%s%s\n\n%sComplete!\n",
strings.Repeat(" ", padding),
m.progress.View(),
strings.Repeat(" ", padding)))
strings.Repeat(" ", padding))
return v
}
pad := strings.Repeat(" ", padding)
return tea.NewView(fmt.Sprintf("\n%s%s\n%s%s\n\n%s",
v.Content = fmt.Sprintf("\n%s%s\n%s%s\n\n%s",
pad, m.progress.View(),
pad, m.status,
pad+helpStyle("Press 'q' or Ctrl+C to cancel")))
pad+helpStyle("Press 'q' or Ctrl+C to cancel"))
return v
}
// ProgressReader wraps an io.Reader to intercept and parse Ollama pull operation
+355
View File
@@ -0,0 +1,355 @@
package ui
import (
"strings"
)
// MessageItem is the interface all scrollback messages must implement.
// This allows lazy rendering - messages are only rendered when visible.
type MessageItem interface {
// Render returns the styled content for this message at the given width.
// Implementations should cache the result to avoid re-rendering.
Render(width int) string
// Height returns the number of lines this message occupies when rendered.
Height() int
// ID returns a unique identifier for this message (for tracking).
ID() string
}
// ScrollList manages a viewport over a list of MessageItems.
// It handles offset-based scrolling and lazy rendering. Only visible
// items are rendered on each View() call.
type ScrollList struct {
items []MessageItem
offsetIdx int // Index of first visible item
offsetLine int // Lines to skip from first visible item
width int
height int // Viewport height in lines
autoScroll bool // Whether to auto-scroll to bottom on new content
itemGap int // Number of blank lines between items (0 = no gap)
}
// NewScrollList creates a new ScrollList with the given dimensions.
func NewScrollList(width, height int) *ScrollList {
return &ScrollList{
items: []MessageItem{},
offsetIdx: 0,
offsetLine: 0,
width: width,
height: height,
autoScroll: true, // Start with auto-scroll enabled
}
}
// SetItems replaces the items in the scroll list. If auto-scroll is enabled,
// the viewport will scroll to the bottom to show the latest content.
func (s *ScrollList) SetItems(items []MessageItem) {
s.items = items
if s.autoScroll {
s.GotoBottom()
}
}
// SetHeight updates the viewport height. Called when the terminal is resized.
func (s *ScrollList) SetHeight(height int) {
s.height = height
s.clampOffset()
}
// SetWidth updates the viewport width. Called when the terminal is resized.
// This may invalidate cached renders in MessageItems.
func (s *ScrollList) SetWidth(width int) {
s.width = width
s.clampOffset()
}
// SetItemGap sets the number of blank lines between items (0 = no gap).
func (s *ScrollList) SetItemGap(gap int) {
s.itemGap = gap
}
// ItemGap returns the current gap between items.
func (s *ScrollList) ItemGap() int {
return s.itemGap
}
// ScrollBy scrolls the viewport by the given number of lines.
// Positive = scroll down, negative = scroll up.
func (s *ScrollList) ScrollBy(lines int) {
if lines > 0 {
// Scroll down
for lines > 0 && s.offsetIdx < len(s.items) {
if s.offsetIdx >= len(s.items) {
break
}
currentItem := s.items[s.offsetIdx]
itemHeight := currentItem.Height()
remainingLines := itemHeight - s.offsetLine
if lines >= remainingLines {
// Move to next item
s.offsetIdx++
s.offsetLine = 0
lines -= remainingLines
// Consume gap lines between items
if s.itemGap > 0 && s.offsetIdx < len(s.items) {
if lines >= s.itemGap {
lines -= s.itemGap
} else {
lines = 0
}
}
} else {
// Stay on current item, skip more lines
s.offsetLine += lines
lines = 0
}
}
} else if lines < 0 {
// Scroll up
lines = -lines
for lines > 0 && (s.offsetIdx > 0 || s.offsetLine > 0) {
if s.offsetLine > 0 {
// Scroll within current item
if lines >= s.offsetLine {
lines -= s.offsetLine
s.offsetLine = 0
} else {
s.offsetLine -= lines
lines = 0
}
} else if s.offsetIdx > 0 {
// Consume gap lines between items
if s.itemGap > 0 {
if lines > s.itemGap {
lines -= s.itemGap
} else {
lines = 0
continue
}
}
// Move to previous item
s.offsetIdx--
if s.offsetIdx < len(s.items) {
currentItem := s.items[s.offsetIdx]
itemHeight := currentItem.Height()
if lines >= itemHeight {
lines -= itemHeight
s.offsetLine = 0
} else {
s.offsetLine = itemHeight - lines
lines = 0
}
}
}
}
}
s.clampOffset()
}
// GotoBottom scrolls to the end of the list.
func (s *ScrollList) GotoBottom() {
if len(s.items) == 0 {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Calculate total height including gaps
totalHeight := 0
for i, item := range s.items {
totalHeight += item.Height()
// Add gap after each item except the last
if s.itemGap > 0 && i < len(s.items)-1 {
totalHeight += s.itemGap
}
}
// If content fits in viewport, start at top
if totalHeight <= s.height {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Otherwise, position viewport at bottom
remaining := totalHeight - s.height
for idx := 0; idx < len(s.items); idx++ {
itemHeight := s.items[idx].Height()
if remaining < itemHeight {
s.offsetIdx = idx
s.offsetLine = remaining
return
}
remaining -= itemHeight
// Subtract gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
remaining -= s.itemGap
}
}
// Fallback: show last item
s.offsetIdx = max(0, len(s.items)-1)
s.offsetLine = 0
}
// GotoTop scrolls to the beginning of the list.
func (s *ScrollList) GotoTop() {
s.offsetIdx = 0
s.offsetLine = 0
}
// AtBottom returns true if the viewport is at the bottom of the list.
func (s *ScrollList) AtBottom() bool {
if len(s.items) == 0 {
return true
}
// Calculate visible height from current position including gaps
visibleHeight := 0
for idx := s.offsetIdx; idx < len(s.items); idx++ {
item := s.items[idx]
itemHeight := item.Height()
if idx == s.offsetIdx {
visibleHeight += itemHeight - s.offsetLine
} else {
visibleHeight += itemHeight
}
// Add gap after item (except last)
if s.itemGap > 0 && idx < len(s.items)-1 {
visibleHeight += s.itemGap
}
if visibleHeight >= s.height {
return false
}
}
return true
}
// AtTop returns true if the viewport is at the top of the list.
func (s *ScrollList) AtTop() bool {
return s.offsetIdx == 0 && s.offsetLine == 0
}
// View renders the visible portion of the scrollback.
// Only items that fit within the viewport height are rendered.
// ALWAYS returns exactly s.height lines (padded with empty lines if needed)
// to ensure the input/footer stay fixed at the bottom.
func (s *ScrollList) View() string {
if s.height <= 0 {
return ""
}
var lines []string
remainingHeight := s.height
// Render visible items
if len(s.items) > 0 {
for idx := s.offsetIdx; idx < len(s.items) && remainingHeight > 0; idx++ {
item := s.items[idx]
content := item.Render(s.width)
contentLines := strings.Split(content, "\n")
startLine := 0
if idx == s.offsetIdx {
startLine = s.offsetLine
}
for i := startLine; i < len(contentLines) && remainingHeight > 0; i++ {
lines = append(lines, contentLines[i])
remainingHeight--
}
// Add gap lines between items (but not after the last visible item)
if remainingHeight > 0 && idx < len(s.items)-1 && s.itemGap > 0 {
for g := 0; g < s.itemGap && remainingHeight > 0; g++ {
lines = append(lines, "")
remainingHeight--
}
}
}
}
// Pad with empty lines to ensure exactly s.height lines
// This keeps the input/footer fixed at the bottom of the screen
for remainingHeight > 0 {
lines = append(lines, "")
remainingHeight--
}
return strings.Join(lines, "\n")
}
// ScrollPercent returns the current scroll position as a percentage (0.0-1.0).
// 0.0 = at top, 1.0 = at bottom. Useful for scroll indicators.
func (s *ScrollList) ScrollPercent() float64 {
if len(s.items) == 0 {
return 0.0
}
totalHeight := 0
for _, item := range s.items {
totalHeight += item.Height()
}
if totalHeight <= s.height {
return 1.0 // All content fits, consider it "at bottom"
}
// Calculate how many lines are above the viewport
linesAbove := 0
for i := 0; i < s.offsetIdx && i < len(s.items); i++ {
linesAbove += s.items[i].Height()
}
linesAbove += s.offsetLine
scrollableHeight := totalHeight - s.height
if scrollableHeight <= 0 {
return 1.0
}
percent := float64(linesAbove) / float64(scrollableHeight)
if percent > 1.0 {
percent = 1.0
}
if percent < 0.0 {
percent = 0.0
}
return percent
}
// clampOffset ensures the offset values are within valid bounds after
// resizing or scrolling operations.
func (s *ScrollList) clampOffset() {
if len(s.items) == 0 {
s.offsetIdx = 0
s.offsetLine = 0
return
}
// Clamp offsetIdx
if s.offsetIdx >= len(s.items) {
s.offsetIdx = len(s.items) - 1
}
if s.offsetIdx < 0 {
s.offsetIdx = 0
}
// Clamp offsetLine
if s.offsetIdx < len(s.items) {
itemHeight := s.items[s.offsetIdx].Height()
if s.offsetLine >= itemHeight {
s.offsetLine = max(0, itemHeight-1)
}
}
if s.offsetLine < 0 {
s.offsetLine = 0
}
}
+8 -1
View File
@@ -325,7 +325,14 @@ func (ss *SessionSelectorComponent) View() tea.View {
}
}
return tea.NewView(b.String())
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// IsActive returns whether the selector is still accepting input.
+8 -1
View File
@@ -563,7 +563,14 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (s *StreamComponent) View() tea.View {
fullContent := s.render()
visibleContent := s.viewContent(fullContent)
return tea.NewView(visibleContent)
v := tea.NewView(visibleContent)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// --------------------------------------------------------------------------
+14 -3
View File
@@ -83,9 +83,19 @@ func (t *ToolApprovalInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (t *ToolApprovalInput) View() tea.View {
if t.done {
return tea.NewView("we are done")
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
}
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
@@ -135,5 +145,6 @@ func (t *ToolApprovalInput) View() tea.View {
}
view.WriteString(yesText + "/" + noText + "\n")
return tea.NewView(containerStyle.Render(inputBoxStyle.Render(view.String())))
v.Content = containerStyle.Render(inputBoxStyle.Render(view.String()))
return v
}
+8 -1
View File
@@ -265,7 +265,14 @@ 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))
return tea.NewView(b.String())
v := tea.NewView(b.String())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = true
v.KeyboardEnhancements = tea.KeyboardEnhancements{
ReportEventTypes: true,
}
return v
}
// IsActive returns whether the tree selector is still accepting input.