diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index a96fd4399..b817639c3 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -56,6 +56,7 @@ type Model interface { LoadFromSession(sess *session.Session) tea.Cmd ScrollToBottom() tea.Cmd + AdjustBottomSlack(delta int) } // renderedItem represents a cached rendered message with position information @@ -1229,6 +1230,13 @@ func (m *model) ScrollToBottom() tea.Cmd { } } +func (m *model) AdjustBottomSlack(delta int) { + if delta == 0 { + return + } + m.bottomSlack = max(0, m.bottomSlack+delta) +} + // contentWidth returns the width available for content. // Always reserves 2 chars for scrollbar (space + bar) to prevent layout shifts. func (m *model) contentWidth() int { diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 81eeaedc8..da4f7f0f6 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -421,14 +421,47 @@ func (p *chatPage) setWorking(working bool) tea.Cmd { // setPendingResponse sets the pending response state. // When true, a spinner is shown below the messages while waiting for the first chunk. +// Resizes messages to leave room for the spinner; bottomSlack smooths the transition. func (p *chatPage) setPendingResponse(pending bool) tea.Cmd { - p.pendingResponse = pending if pending { - // Reset spinner to get a fresh random phrase + p.pendingResponse = pending + + // Resize messages to account for spinner space (3 lines: 2 newlines + 1 spinner) + mainWidth, messagesHeight := p.messagesSize() + resizeCmd := p.messages.SetSize(mainWidth, messagesHeight) + p.pendingSpinner = p.pendingSpinner.Reset() - return p.pendingSpinner.Init() + return tea.Batch(resizeCmd, p.pendingSpinner.Init()) } - return nil + + p.pendingResponse = pending + + // Resize messages to reclaim spinner space and add slack to prevent a visual jump. + mainWidth, messagesHeight := p.messagesSize() + resizeCmd := p.messages.SetSize(mainWidth, messagesHeight) + p.messages.AdjustBottomSlack(pendingSpinnerHeight) + + return resizeCmd +} + +// pendingSpinnerHeight is the space taken by the pending spinner (2 newlines + 1 line) +const pendingSpinnerHeight = 3 + +func (p *chatPage) messagesSize() (int, int) { + innerWidth := p.width - 2 + var mainWidth int + if p.width >= minWindowWidth { + mainWidth = max(1, innerWidth-sidebarWidth) + } else { + mainWidth = max(innerWidth, 1) + } + + messagesHeight := p.chatHeight + if p.pendingResponse { + messagesHeight -= pendingSpinnerHeight + } + + return mainWidth, max(1, messagesHeight) } // View renders the chat page @@ -453,13 +486,12 @@ func (p *chatPage) View() string { } } - if p.width >= minWindowWidth { - // Ensure we don't exceed available space - chatWidth := max(1, innerWidth-sidebarWidth) + mainWidth, _ := p.messagesSize() + if p.width >= minWindowWidth { chatView := styles.ChatStyle. Height(p.chatHeight). - Width(chatWidth). + Width(mainWidth). Render(messagesView) sidebarView := lipgloss.NewStyle(). @@ -478,7 +510,7 @@ func (p *chatPage) View() string { chatView := styles.ChatStyle. Height(p.chatHeight). - Width(innerWidth). + Width(mainWidth). Render(messagesView) sidebarView := lipgloss.NewStyle(). @@ -551,7 +583,6 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd { ) } else { const horizontalSidebarHeight = 3 - mainWidth = max(innerWidth, 1) p.chatHeight = max(1, height-actualEditorHeight-horizontalSidebarHeight-2) // -1 for resize handle, -1 for empty line before status bar p.sidebar.SetMode(sidebar.ModeHorizontal) cmds = append(cmds, @@ -562,8 +593,9 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd { } // Set component sizes + mainWidth, messagesHeight := p.messagesSize() cmds = append(cmds, - p.messages.SetSize(mainWidth, p.chatHeight), + p.messages.SetSize(mainWidth, messagesHeight), ) return tea.Batch(cmds...)