diff --git a/go.mod b/go.mod index 2d6c50606..e7044f9ee 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea + github.com/charmbracelet/x/ansi v0.10.2 github.com/coder/acp-go-sdk v0.4.9 github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 github.com/fatih/color v1.18.0 @@ -50,7 +51,6 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef // indirect - github.com/charmbracelet/x/ansi v0.10.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 76e73da26..1018d642b 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -3,17 +3,22 @@ package messages import ( "fmt" "strings" + "time" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" "github.com/docker/cagent/pkg/app" "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/tools" "github.com/docker/cagent/pkg/tui/components/markdown" "github.com/docker/cagent/pkg/tui/components/message" + "github.com/docker/cagent/pkg/tui/components/notification" "github.com/docker/cagent/pkg/tui/components/tool" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" @@ -29,6 +34,11 @@ type EvalSavedMsg struct { File string } +// AutoScrollTickMsg triggers auto-scroll during selection +type AutoScrollTickMsg struct { + Direction int // -1 for up, 1 for down +} + // Model represents a chat message list component type Model interface { layout.Model @@ -54,10 +64,39 @@ type Model interface { // renderedItem represents a cached rendered message with position information type renderedItem struct { - id int // Message ID or index as int view string // Cached rendered content height int // Height in lines - end int // Ending line position in complete content +} + +// selectionState encapsulates all state related to text selection +type selectionState struct { + active bool + startLine int + startCol int + endLine int + endCol int + mouseButtonDown bool +} + +// start initializes a new selection at the given position +func (s *selectionState) start(line, col int) { + s.active = true + s.mouseButtonDown = true + s.startLine = line + s.startCol = col + s.endLine = line + s.endCol = col +} + +// update updates the end position of the selection +func (s *selectionState) update(line, col int) { + s.endLine = line + s.endCol = col +} + +// end finalizes the selection and stops mouse tracking +func (s *selectionState) end() { + s.mouseButtonDown = false } // model implements Model @@ -73,6 +112,8 @@ type model struct { rendered string // Complete rendered content string renderedItems map[int]renderedItem // Cache of rendered items with positions totalHeight int // Total height of all content in lines + + selection selectionState } // New creates a new message list component @@ -119,6 +160,36 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + case tea.MouseClickMsg: + if msg.Button == tea.MouseLeft { + line, col := m.mouseToLineCol(msg.X, msg.Y) + m.selection.start(line, col) + } + return m, nil + + case tea.MouseMotionMsg: + if m.selection.mouseButtonDown && m.selection.active { + line, col := m.mouseToLineCol(msg.X, msg.Y) + m.selection.update(line, col) + + cmd := m.autoScroll() + return m, cmd + } + return m, nil + + case tea.MouseReleaseMsg: + if msg.Button == tea.MouseLeft && m.selection.mouseButtonDown { + if m.selection.active { + line, col := m.mouseToLineCol(msg.X, msg.Y) + m.selection.update(line, col) + m.selection.end() + cmd := m.copySelectionToClipboard() + return m, cmd + } + m.selection.end() + } + return m, nil + case tea.MouseWheelMsg: const mouseScrollAmount = 3 buttonStr := msg.Button.String() @@ -145,8 +216,18 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case AutoScrollTickMsg: + if m.selection.mouseButtonDown && m.selection.active { + cmd := m.autoScroll() + return m, cmd + } + return m, nil + case tea.KeyPressMsg: switch msg.String() { + case "esc": + m.clearSelection() + return m, nil case "up", "k": m.scrollUp() return m, nil @@ -211,7 +292,13 @@ func (m *model) View() string { return "" } - return strings.Join(lines[startLine:endLine], "\n") + visibleLines := lines[startLine:endLine] + + if m.selection.active { + visibleLines = m.applySelectionHighlight(visibleLines, startLine) + } + + return strings.Join(visibleLines, "\n") } // SetSize sets the dimensions of the component @@ -219,11 +306,6 @@ func (m *model) SetSize(width, height int) tea.Cmd { m.width = width m.height = height - // Ensure minimum width - if width < 10 { - width = 10 - } - // Update all views with new size for _, view := range m.views { view.SetSize(width, 0) @@ -341,7 +423,6 @@ func (m *model) renderItem(index int, view layout.Model) renderedItem { } item := renderedItem{ - id: index, view: rendered, height: height, } @@ -362,31 +443,21 @@ func (m *model) ensureAllItemsRendered() { return } - // Render all items and calculate their positions + // Render all items and build the full content var allLines []string - currentPosition := 0 for i, view := range m.views { item := m.renderItem(i, view) - // Update position information - if item.height > 0 { - item.end = currentPosition + item.height - 1 - } else { - item.end = currentPosition - } - // Add content to complete rendered string if item.view != "" { lines := strings.Split(item.view, "\n") allLines = append(allLines, lines...) - currentPosition += len(lines) } // Add separator between messages (but not after last message) if i < len(m.views)-1 && item.view != "" { allLines = append(allLines, "") - currentPosition += 1 } } @@ -470,7 +541,10 @@ func (m *model) AddAssistantMessage() tea.Cmd { } func (m *model) addMessage(msg *types.Message) tea.Cmd { + m.clearSelection() + wasAtBottom := m.isAtBottom() + m.messages = append(m.messages, *msg) view := m.createMessageView(msg) @@ -752,3 +826,232 @@ func toolResultLabel(msg types.Message) string { } return fmt.Sprintf("Tool Result (%s)", name) } + +// mouseToLineCol converts mouse position to line/column in rendered content +func (m *model) mouseToLineCol(x, y int) (line, col int) { + // Adjust for header (2 lines: text + bottom padding) + adjustedY := max(0, y-2) + line = m.scrollOffset + adjustedY + + // Adjust for left padding (1 column from AppStyle) + adjustedX := max(0, x-1) + col = adjustedX + + return line, col +} + +func (m *model) extractSelectedText() string { + if !m.selection.active { + return "" + } + + lines := strings.Split(m.rendered, "\n") + + // Normalize selection direction + startLine, startCol := m.selection.startLine, m.selection.startCol + endLine, endCol := m.selection.endLine, m.selection.endCol + + if startLine > endLine || (startLine == endLine && startCol > endCol) { + startLine, endLine = endLine, startLine + startCol, endCol = endCol, startCol + } + + if startLine < 0 || startLine >= len(lines) { + return "" + } + if endLine >= len(lines) { + endLine = len(lines) - 1 + } + + // Single line selection + if startLine == endLine { + if startLine < len(lines) { + line := ansi.Strip(lines[startLine]) + // Convert display width to rune indices + startIdx := displayWidthToRuneIndex(line, startCol) + endIdx := displayWidthToRuneIndex(line, endCol) + runes := []rune(line) + if startIdx < len(runes) && startIdx < endIdx { + if endIdx > len(runes) { + endIdx = len(runes) + } + return string(runes[startIdx:endIdx]) + } + } + return "" + } + + // Multi-line selection + var result strings.Builder + for i := startLine; i <= endLine && i < len(lines); i++ { + line := ansi.Strip(lines[i]) + runes := []rune(line) + + switch i { + case startLine: + // First line: from startCol to end + startIdx := displayWidthToRuneIndex(line, startCol) + if startIdx < len(runes) { + result.WriteString(string(runes[startIdx:])) + } + case endLine: + // Last line: from start to endCol + endIdx := min(displayWidthToRuneIndex(line, endCol), len(runes)) + result.WriteString(string(runes[:endIdx])) + default: + // Middle lines: entire line + result.WriteString(line) + } + + // Add newline except for last line + if i < endLine { + result.WriteString("\n") + } + } + + return result.String() +} + +func (m *model) copySelectionToClipboard() tea.Cmd { + if !m.selection.active { + return nil + } + + selectedText := m.extractSelectedText() + if selectedText == "" { + return nil + } + + if err := clipboard.WriteAll(selectedText); err != nil { + return core.CmdHandler(notification.ShowMsg{Text: "Failed to copy: " + err.Error()}) + } + + return core.CmdHandler(notification.ShowMsg{Text: "Text copied to clipboard"}) +} + +func (m *model) clearSelection() { + m.selection = selectionState{} +} + +func (m *model) applySelectionHighlight(lines []string, viewportStartLine int) []string { + // Normalize selection bounds + startLine, startCol := m.selection.startLine, m.selection.startCol + endLine, endCol := m.selection.endLine, m.selection.endCol + + if startLine > endLine || (startLine == endLine && startCol > endCol) { + startLine, endLine = endLine, startLine + startCol, endCol = endCol, startCol + } + + highlighted := make([]string, len(lines)) + + for i, line := range lines { + absoluteLine := viewportStartLine + i + + if absoluteLine < startLine || absoluteLine > endLine { + highlighted[i] = line + continue + } + + switch { + case startLine == endLine && absoluteLine == startLine: + // Single line selection + highlighted[i] = m.highlightLine(line, startCol, endCol) + case absoluteLine == startLine: + // Start of multi-line selection + plainLine := ansi.Strip(line) + lineWidth := runewidth.StringWidth(plainLine) + highlighted[i] = m.highlightLine(line, startCol, lineWidth) + case absoluteLine == endLine: + // End of multi-line selection + highlighted[i] = m.highlightLine(line, 0, endCol) + default: + // Middle of multi-line selection + plainLine := ansi.Strip(line) + lineWidth := runewidth.StringWidth(plainLine) + highlighted[i] = m.highlightLine(line, 0, lineWidth) + } + } + + return highlighted +} + +func (m *model) highlightLine(line string, startCol, endCol int) string { + plainLine := ansi.Strip(line) + + startRuneIdx := displayWidthToRuneIndex(plainLine, startCol) + endRuneIdx := displayWidthToRuneIndex(plainLine, endCol) + + if startRuneIdx >= len([]rune(plainLine)) { + return line + } + if startRuneIdx >= endRuneIdx { + return line + } + + // TODO(rumpl): move style to styles.go + selectionStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("237")). + Foreground(lipgloss.Color("255")) + + runes := []rune(plainLine) + before := string(runes[:startRuneIdx]) + selected := selectionStyle.Render(string(runes[startRuneIdx:endRuneIdx])) + after := "" + if endRuneIdx < len(runes) { + after = string(runes[endRuneIdx:]) + } + + return before + selected + after +} + +func displayWidthToRuneIndex(s string, targetWidth int) int { + if targetWidth <= 0 { + return 0 + } + + runes := []rune(s) + currentWidth := 0 + + for i, r := range runes { + if currentWidth >= targetWidth { + return i + } + currentWidth += runewidth.RuneWidth(r) + } + + return len(runes) +} + +func (m *model) autoScroll() tea.Cmd { + const scrollThreshold = 2 + direction := 0 + + if m.selection.endCol < scrollThreshold && m.scrollOffset > 0 { + // Scroll up + direction = -1 + m.scrollUp() + if m.selection.endLine > 0 { + m.selection.endLine-- + } + } else if m.selection.endCol >= m.height-scrollThreshold { + // Scroll down + maxScrollOffset := max(0, m.totalHeight-m.height) + if m.scrollOffset < maxScrollOffset { + direction = 1 + m.scrollDown() + lines := strings.Split(m.rendered, "\n") + if m.selection.endLine < len(lines)-1 { + m.selection.endLine++ + } + } + } + + if direction == 0 { + return nil + } + + return tea.Tick(20*time.Millisecond, func(time.Time) tea.Msg { + return AutoScrollTickMsg{Direction: direction} + }) +} diff --git a/pkg/tui/components/notification/notification.go b/pkg/tui/components/notification/notification.go new file mode 100644 index 000000000..4e03156d9 --- /dev/null +++ b/pkg/tui/components/notification/notification.go @@ -0,0 +1,112 @@ +package notification + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "github.com/docker/cagent/pkg/tui/styles" +) + +const ( + defaultDuration = 3 * time.Second + notificationPadding = 2 + maxWidth = 50 +) + +type ShowMsg struct { + Text string +} + +type HideMsg struct{} + +type State int + +const ( + StateHidden State = iota + StateVisible +) + +// Notification represents a notification component that displays +// a message in the bottom right corner of the screen +type Notification struct { + width, height int + text string + state State +} + +func New() Notification { + return Notification{ + state: StateHidden, + } +} + +func (n *Notification) SetSize(width, height int) { + n.width = width + n.height = height +} + +func (n *Notification) Update(msg tea.Msg) (Notification, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + n.width = msg.Width + n.height = msg.Height + return *n, nil + + case ShowMsg: + n.text = msg.Text + n.state = StateVisible + return *n, tea.Tick(defaultDuration, func(t time.Time) tea.Msg { + return HideMsg{} + }) + + case HideMsg: + n.state = StateHidden + n.text = "" + return *n, nil + } + + return *n, nil +} + +func (n *Notification) View() string { + if n.state == StateHidden || n.text == "" { + return "" + } + + notificationStyle := styles.BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(styles.SuccessStyle.GetForeground()). + Padding(0, 1). + MaxWidth(maxWidth) + + return notificationStyle.Render(n.text) +} + +func (n *Notification) GetLayer() *lipgloss.Layer { + if n.state == StateHidden || n.text == "" { + return nil + } + + view := n.View() + row, col := n.position() + + return lipgloss.NewLayer(view).X(col).Y(row) +} + +func (n *Notification) position() (row, col int) { + notificationView := n.View() + viewHeight := lipgloss.Height(notificationView) + viewWidth := lipgloss.Width(notificationView) + + // Position in bottom right corner with padding + row = max(0, n.height-viewHeight-notificationPadding) + col = max(0, n.width-viewWidth-notificationPadding) + + return row, col +} + +func (n *Notification) IsVisible() bool { + return n.state != StateHidden +} diff --git a/pkg/tui/components/notification/notification_test.go b/pkg/tui/components/notification/notification_test.go new file mode 100644 index 000000000..add0b2bdc --- /dev/null +++ b/pkg/tui/components/notification/notification_test.go @@ -0,0 +1,57 @@ +package notification + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNotification_InitialState(t *testing.T) { + n := New() + + require.Equal(t, StateHidden, n.state) + require.Empty(t, n.text) + require.False(t, n.IsVisible()) +} + +func TestNotification_Show(t *testing.T) { + n := New() + + updated, _ := n.Update(ShowMsg{Text: "Test notification"}) + + require.Equal(t, StateVisible, updated.state) + require.Equal(t, "Test notification", updated.text) + require.True(t, updated.IsVisible()) + require.NotEmpty(t, updated.View()) +} + +func TestNotification_Hide(t *testing.T) { + n := New() + + updated, _ := n.Update(ShowMsg{Text: "Test"}) + updated, _ = updated.Update(HideMsg{}) + + require.Equal(t, StateHidden, updated.state) + require.Empty(t, updated.text) + require.False(t, updated.IsVisible()) + require.Empty(t, updated.View()) +} + +func TestNotification_Position(t *testing.T) { + n := New() + n.SetSize(100, 50) + updated, _ := n.Update(ShowMsg{Text: "Test"}) + row, col := updated.position() + + require.Equal(t, 45, row) + require.Equal(t, 90, col) +} + +func TestNotification_GetLayer(t *testing.T) { + n := New() + + require.Nil(t, n.GetLayer()) + + updated, _ := n.Update(ShowMsg{Text: "Test"}) + require.NotNil(t, updated.GetLayer()) +} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index fd57bc380..20813c474 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -172,7 +172,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, nil - case tea.MouseWheelMsg: + case tea.MouseWheelMsg, tea.MouseClickMsg, tea.MouseMotionMsg, tea.MouseReleaseMsg: // Always forward mouse wheel events to the chat component for scrolling model, cmd := p.messages.Update(msg) p.messages = model.(messages.Model) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 1ec2ba2bf..192f038af 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -12,6 +12,7 @@ import ( "github.com/docker/cagent/pkg/evaluation" "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/tui/components/messages" + "github.com/docker/cagent/pkg/tui/components/notification" "github.com/docker/cagent/pkg/tui/components/statusbar" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/dialog" @@ -41,8 +42,9 @@ type appModel struct { width, height int keyMap KeyMap - chatPage chatpage.Page - statusBar statusbar.StatusBar + chatPage chatpage.Page + statusBar statusbar.StatusBar + notification notification.Notification // Dialog system dialog dialog.Manager @@ -75,10 +77,11 @@ func DefaultKeyMap() KeyMap { // New creates and initializes a new TUI application model func New(a *app.App) tea.Model { t := &appModel{ - chatPage: chatpage.New(a), - keyMap: DefaultKeyMap(), - dialog: dialog.New(), - application: a, + chatPage: chatpage.New(a), + keyMap: DefaultKeyMap(), + dialog: dialog.New(), + notification: notification.New(), + application: a, } t.statusBar = statusbar.New(t) @@ -120,6 +123,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := a.handleWindowResize(msg.Width, msg.Height) return a, cmd + case notification.ShowMsg, notification.HideMsg: + updated, cmd := a.notification.Update(msg) + a.notification = updated + return a, cmd + case tea.KeyPressMsg: cmd := a.handleKeyPressMsg(msg) return a, cmd @@ -197,6 +205,9 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd { // Update status bar width a.statusBar.SetWidth(a.width) + // Update notification size + a.notification.SetSize(a.width, a.height) + return tea.Batch(cmds...) } @@ -271,12 +282,27 @@ func (a *appModel) View() tea.View { baseView := lipgloss.JoinVertical(lipgloss.Top, components...) - if a.dialog.HasDialog() { + // Check if we need to render any overlays (dialogs or notifications) + hasOverlays := a.dialog.HasDialog() || a.notification.IsVisible() + + if hasOverlays { baseLayer := lipgloss.NewLayer(baseView) - dialogLayers := a.dialog.GetLayers() + var allLayers []*lipgloss.Layer + allLayers = append(allLayers, baseLayer) - allLayers := []*lipgloss.Layer{baseLayer} - allLayers = append(allLayers, dialogLayers...) + // Add dialog layers + if a.dialog.HasDialog() { + dialogLayers := a.dialog.GetLayers() + allLayers = append(allLayers, dialogLayers...) + } + + // Add notification layer (should be on top) + if a.notification.IsVisible() { + notificationLayer := a.notification.GetLayer() + if notificationLayer != nil { + allLayers = append(allLayers, notificationLayer) + } + } canvas := lipgloss.NewCanvas(allLayers...) return toFullscreenView(canvas.Render())