diff --git a/internal/ui/input.go b/internal/ui/input.go index cf9fce2c..6917a6d3 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -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. diff --git a/internal/ui/message_items.go b/internal/ui/message_items.go new file mode 100644 index 00000000..14640976 --- /dev/null +++ b/internal/ui/message_items.go @@ -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) +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 0afbaefa..0b6b81eb 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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) } diff --git a/internal/ui/model_selector.go b/internal/ui/model_selector.go index 9b336c60..0c796902 100644 --- a/internal/ui/model_selector.go +++ b/internal/ui/model_selector.go @@ -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. diff --git a/internal/ui/progress/ollama.go b/internal/ui/progress/ollama.go index 31e85c59..900a260c 100644 --- a/internal/ui/progress/ollama.go +++ b/internal/ui/progress/ollama.go @@ -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 diff --git a/internal/ui/scrolllist.go b/internal/ui/scrolllist.go new file mode 100644 index 00000000..9d76d1ab --- /dev/null +++ b/internal/ui/scrolllist.go @@ -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 + } +} diff --git a/internal/ui/session_selector.go b/internal/ui/session_selector.go index d946ea73..87a794bb 100644 --- a/internal/ui/session_selector.go +++ b/internal/ui/session_selector.go @@ -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. diff --git a/internal/ui/stream.go b/internal/ui/stream.go index f44f8e01..79ce88b4 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -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 } // -------------------------------------------------------------------------- diff --git a/internal/ui/tool_approval_input.go b/internal/ui/tool_approval_input.go index d308020d..b4cfefc2 100644 --- a/internal/ui/tool_approval_input.go +++ b/internal/ui/tool_approval_input.go @@ -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 } diff --git a/internal/ui/tree_selector.go b/internal/ui/tree_selector.go index a72ba9c5..10dcecf6 100644 --- a/internal/ui/tree_selector.go +++ b/internal/ui/tree_selector.go @@ -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.