Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
398 changes: 398 additions & 0 deletions packages/tui/internal/components/chat_view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,398 @@
package components

import (
"strings"

tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/rogue/tui/internal/styles"
"github.com/rogue/tui/internal/theme"
)

// ChatView is a reusable chat component with message history and input
type ChatView struct {
// Layout
width int
height int

// Components
messageHistory *MessageHistoryView
input *TextArea

// State
errorMessage string

// Config
inputLabel string
inputPlaceholder string
showHeader bool
headerText string
baseID int // Base ID for sub-components
}

// NewChatView creates a new chat view component
func NewChatView(id int, width, height int, t theme.Theme) *ChatView {
// Create message history view
messageHistory := NewMessageHistoryView(id, width, height-24, t)

// Create input
input := NewTextArea(id+100, width-6, 4, t)
input.Placeholder = "Type your message..."
input.ShowLineNumbers = false // Disable line numbers for chat input
input.Focus()

return &ChatView{
width: width,
height: height,
messageHistory: messageHistory,
input: &input,
errorMessage: "",
inputLabel: "Your Response:",
inputPlaceholder: "Type your message...",
showHeader: false,
headerText: "",
baseID: id,
}
}

// SetSize updates the component dimensions
func (c *ChatView) SetSize(width, height int) {
c.width = width
c.height = height
if c.messageHistory != nil {
c.messageHistory.SetSize(width, height-24)
}
if c.input != nil {
c.input.Width = width - 4
}
}

// SetPrefixes customizes the message prefixes
func (c *ChatView) SetPrefixes(userPrefix, assistantPrefix string) {
if c.messageHistory != nil {
c.messageHistory.SetPrefixes(userPrefix, assistantPrefix)
}
}

// SetInputLabel customizes the input label
func (c *ChatView) SetInputLabel(label string) {
c.inputLabel = label
}

// SetInputPlaceholder customizes the input placeholder
func (c *ChatView) SetInputPlaceholder(placeholder string) {
c.inputPlaceholder = placeholder
if c.input != nil {
c.input.Placeholder = placeholder
}
}

// SetHeader sets a custom header text for the chat view
func (c *ChatView) SetHeader(text string) {
c.showHeader = text != ""
c.headerText = text
}

// HideHeader hides the header
func (c *ChatView) HideHeader() {
c.showHeader = false
c.headerText = ""
}

// AddMessage adds a message to the chat history
func (c *ChatView) AddMessage(role, content string) {
if c.messageHistory != nil {
c.messageHistory.AddMessage(role, content)
}
}

// ClearMessages removes all messages
func (c *ChatView) ClearMessages() {
if c.messageHistory != nil {
c.messageHistory.ClearMessages()
}
}

// GetMessages returns all messages
func (c *ChatView) GetMessages() []Message {
if c.messageHistory != nil {
return c.messageHistory.GetMessages()
}
return []Message{}
}

// GetError returns the current error message
func (c *ChatView) GetError() string {
return c.errorMessage
}

// SetLoading sets the loading state
func (c *ChatView) SetLoading(loading bool) {
if c.messageHistory != nil {
c.messageHistory.SetSpinner(loading)
}
}

// StartSpinner starts the spinner animation
func (c *ChatView) StartSpinner() tea.Cmd {
if c.messageHistory != nil {
return c.messageHistory.StartSpinner()
}
return nil
}

// IsLoading returns the loading state
func (c *ChatView) IsLoading() bool {
if c.messageHistory != nil {
return c.messageHistory.showSpinner
}
return false
}

// SetError sets an error message
func (c *ChatView) SetError(err string) {
c.errorMessage = err
}

// ClearError clears the error message
func (c *ChatView) ClearError() {
c.errorMessage = ""
}

// GetInputValue returns the current input value
func (c *ChatView) GetInputValue() string {
if c.input != nil {
return c.input.GetValue()
}
return ""
}

// ClearInput clears the input field
func (c *ChatView) ClearInput() {
if c.input != nil {
c.input.SetValue("")
}
}

// IsViewportFocused returns true if viewport is focused
func (c *ChatView) IsViewportFocused() bool {
if c.messageHistory != nil {
return c.messageHistory.IsFocused()
}
return false
}

// IsViewportFocused returns true if viewport is focused
func (c *ChatView) IsInputFocused() bool {
if c.input != nil {
return c.input.IsFocused()
}
return false
}
Comment on lines +185 to +191
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix copy-paste error in doc comment.

Line 185's comment incorrectly says "IsViewportFocused" when the method is IsInputFocused.

-// IsViewportFocused returns true if viewport is focused
+// IsInputFocused returns true if input is focused
 func (c *ChatView) IsInputFocused() bool {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// IsViewportFocused returns true if viewport is focused
func (c *ChatView) IsInputFocused() bool {
if c.input != nil {
return c.input.IsFocused()
}
return false
}
// IsInputFocused returns true if input is focused
func (c *ChatView) IsInputFocused() bool {
if c.input != nil {
return c.input.IsFocused()
}
return false
}
🤖 Prompt for AI Agents
In packages/tui/internal/components/chat_view.go around lines 185 to 191, the
doc comment above the IsInputFocused method mistakenly reads
"IsViewportFocused"; update the comment to correctly describe the method (e.g.,
"IsInputFocused returns true if input is focused") so it matches the method name
and behavior, keeping Go doc comment style.


// FocusViewport focuses the viewport for scrolling
func (c *ChatView) FocusViewport() {
if c.messageHistory != nil {
c.messageHistory.Focus()
}
if c.input != nil {
c.input.Blur()
}
}

// FocusInput focuses the input field
func (c *ChatView) FocusInput() {
if c.messageHistory != nil {
c.messageHistory.Blur()
}
if c.input != nil {
c.input.Focus()
}
}

// Update handles keyboard input for the chat view
func (c *ChatView) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyMsg:
return c.handleKeyPress(msg)
case tea.PasteMsg:
// Forward paste to input if not loading and not scrolling
if c.input != nil && !c.IsLoading() && c.IsInputFocused() {
updatedTextArea, cmd := c.input.Update(msg)
*c.input = *updatedTextArea
return cmd
}
return nil
case SpinnerTickMsg:
if c.messageHistory != nil {
return c.messageHistory.Update(msg)
}
}
return nil
}

// handleKeyPress processes keyboard input
func (c *ChatView) handleKeyPress(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "tab", "shift+tab":
// Toggle focus between viewport and input
if !c.IsLoading() {
if c.IsViewportFocused() {
c.FocusInput()
} else {
c.FocusViewport()
}
}
return nil

case "up":
// When viewport is focused: scroll up
// When input is focused: move focus to viewport
if !c.IsLoading() {
if c.IsViewportFocused() {
// Scroll viewport up
if c.messageHistory != nil {
c.messageHistory.ScrollUp(1)
}
} else {
// Move focus to viewport
c.FocusViewport()
}
}
return nil
Comment on lines +248 to +262
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Up arrow also blocks TextArea navigation.

Same issue as "down": when the input is focused, "up" moves focus to the viewport instead of forwarding to the TextArea, preventing users from moving the cursor up in multi-line input.

Apply this diff to forward the key when input is focused:

 case "up":
-	// When viewport is focused: scroll up
-	// When input is focused: move focus to viewport
 	if !c.IsLoading() {
 		if c.IsViewportFocused() {
-			// Scroll viewport up
+			// Viewport focused: scroll up
 			if c.messageHistory != nil {
 				c.messageHistory.ScrollUp(1)
 			}
-		} else {
-			// Move focus to viewport
+		} else if c.IsInputFocused() {
+			// Input focused: forward up arrow to TextArea for cursor navigation
+			if c.input != nil {
+				updatedTextArea, cmd := c.input.Update(msg)
+				*c.input = *updatedTextArea
+				return cmd
+			}
+		} else {
+			// Neither focused: move focus to viewport
 			c.FocusViewport()
 		}
 	}
 	return nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "up":
// When viewport is focused: scroll up
// When input is focused: move focus to viewport
if !c.IsLoading() {
if c.IsViewportFocused() {
// Scroll viewport up
if c.messageHistory != nil {
c.messageHistory.ScrollUp(1)
}
} else {
// Move focus to viewport
c.FocusViewport()
}
}
return nil
case "up":
if !c.IsLoading() {
if c.IsViewportFocused() {
// Viewport focused: scroll up
if c.messageHistory != nil {
c.messageHistory.ScrollUp(1)
}
} else if c.IsInputFocused() {
// Input focused: forward up arrow to TextArea for cursor navigation
if c.input != nil {
updatedTextArea, cmd := c.input.Update(msg)
*c.input = *updatedTextArea
return cmd
}
} else {
// Neither focused: move focus to viewport
c.FocusViewport()
}
}
return nil
🤖 Prompt for AI Agents
In packages/tui/internal/components/chat_view.go around lines 248-262, the "up"
key handling currently moves focus to the viewport when the input is focused,
blocking TextArea navigation; instead, detect when the input/TextArea is focused
and forward the key event to the TextArea component (call its existing key
handler or movement method) so the caret/selection moves up in multi-line input,
otherwise keep the existing behavior of scrolling the viewport or focusing it;
mirror the same forwarding approach used to fix the "down" key case.


case "down":
// When viewport is focused: scroll down or move to input if at bottom
if !c.IsLoading() {
if c.IsViewportFocused() {
// Check if viewport is at bottom
if c.messageHistory != nil && !c.messageHistory.AtBottom() {
// Not at bottom - scroll down
c.messageHistory.ScrollDown(1)
} else {
// At bottom or messageHistory is nil - shift focus to input
c.FocusInput()
}
}
}
return nil
Comment on lines +264 to +278
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Let the TextArea handle ↓ when it has focus.

Catching "down" here always returns without forwarding the event, so a focused TextArea never sees the key. That prevents cursor navigation inside multi-line input (you can add lines via Shift+Enter but can’t move the caret down to edit them), which is a serious UX regression. Only intercept the key when the viewport is focused; otherwise delegate to the TextArea (c.input.Update(msg)) so multiline editing keeps working.

🤖 Prompt for AI Agents
In packages/tui/internal/components/chat_view.go around lines 256-270, the
handler for the "down" key unconditionally returns without forwarding the event
which prevents a focused TextArea from receiving the key; change the logic so
you only intercept "down" when the viewport is focused: if the viewport is
focused handle scroll or shift focus to input as currently implemented,
otherwise forward the message to the TextArea by calling c.input.Update(msg)
(and return its result) so multiline caret navigation works.


case "shift+enter":
// Insert newline in the input
if c.input != nil && !c.IsLoading() && c.IsInputFocused() {
c.input.InsertNewline()
return nil
}
return nil

default:
// Forward to TextArea for text input (only when input is focused)
if c.input != nil && !c.IsLoading() && c.IsInputFocused() {
updatedTextArea, cmd := c.input.Update(msg)
*c.input = *updatedTextArea
return cmd
}
return nil
}
}

// ViewWithoutHelp renders the chat view without the help/error section at the bottom
// Useful when you want to add custom UI elements below the chat
func (c *ChatView) ViewWithoutHelp(t theme.Theme) string {
return c.renderChatContent(t, false)
}

// View renders the chat view
func (c *ChatView) View(t theme.Theme) string {
return c.renderChatContent(t, true)
}

// renderChatContent renders the chat view with optional help section
func (c *ChatView) renderChatContent(t theme.Theme, includeHelp bool) string {
// Header (optional)
var header string
if c.showHeader {
header = lipgloss.NewStyle().
Background(t.Background()).
Foreground(t.Primary()).
Bold(true).
Render("\n" + c.headerText)
}

// Render message history using MessageHistoryView
var borderedHistory string
if c.messageHistory != nil {
borderedHistory = c.messageHistory.View(t)
}

// Input section
inputLabelStyled := lipgloss.NewStyle().
Background(t.Background()).
Foreground(t.Accent()).
Padding(1, 0).
Render(c.inputLabel)

// Input area with focus-dependent border
var inputArea string
if c.input != nil {
borderColor := t.TextMuted()
if c.IsInputFocused() {
borderColor = t.Primary()
}

inputArea = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(borderColor).
Render(c.input.View())
}

// Build the view
contentParts := make([]string, 0, 5)
if c.showHeader && header != "" {
contentParts = append(contentParts, header)
}
contentParts = append(contentParts, borderedHistory, inputLabelStyled, inputArea)

// Only add help/error section if requested
if includeHelp {
// Help text - context-aware based on focus
var helpText string
if c.IsViewportFocused() {
helpText = "↑↓ scroll Tab switch focus"
} else {
helpText = "↑ to scroll history Tab switch focus Enter send Shift+Enter new line"
}
helpStyle := lipgloss.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Padding(1, 0).
Render(helpText)

help := lipgloss.Place(
c.width-4,
3,
lipgloss.Right,
lipgloss.Top,
helpStyle,
styles.WhitespaceStyle(t.Background()),
)

// Error display
errorLine := ""
if c.errorMessage != "" {
errorLine = lipgloss.NewStyle().
Background(t.Background()).
Foreground(t.Error()).
Padding(1, 0).
Render("⚠ " + c.errorMessage)
}

if errorLine != "" {
contentParts = append(contentParts, errorLine)
} else {
contentParts = append(contentParts, help)
}
}

return strings.Join(contentParts, "\n")
}
Loading
Loading