From 6ca5640a7d84bbec967ba5fef9d77f1db977307a Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 3 Apr 2026 14:58:24 +0200 Subject: [PATCH] Underline URLs on mouse hover Track hovered URL span in the messages component and apply underline styling during rendering. Switch mouse mode from CellMotion to AllMotion so hover events fire without a button held. Assisted-By: docker-agent --- pkg/tui/components/messages/lineutil.go | 26 ++++++++++ pkg/tui/components/messages/messages.go | 11 +++- pkg/tui/components/messages/selection.go | 19 +------ pkg/tui/components/messages/urldetect.go | 51 +++++++++++++++++++ pkg/tui/components/messages/urldetect_test.go | 50 ++++++++++++++++++ pkg/tui/tui.go | 2 +- 6 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 pkg/tui/components/messages/lineutil.go diff --git a/pkg/tui/components/messages/lineutil.go b/pkg/tui/components/messages/lineutil.go new file mode 100644 index 000000000..d48ccf899 --- /dev/null +++ b/pkg/tui/components/messages/lineutil.go @@ -0,0 +1,26 @@ +package messages + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" +) + +// styleLineSegment applies a lipgloss style to the portion of a line between +// startCol and endCol (display columns), preserving the text before and after. +// ANSI codes in the styled segment are stripped so the style renders cleanly. +func styleLineSegment(line string, startCol, endCol int, style lipgloss.Style) string { + plainLine := ansi.Strip(line) + plainWidth := runewidth.StringWidth(plainLine) + + if startCol >= plainWidth || startCol >= endCol { + return line + } + endCol = min(endCol, plainWidth) + + before := ansi.Cut(line, 0, startCol) + segment := ansi.Strip(ansi.Cut(line, startCol, endCol)) + after := ansi.Cut(line, endCol, plainWidth) + + return before + style.Render(segment) + after +} diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 2d315de82..62274eb59 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -130,6 +130,9 @@ type model struct { // Hover state for showing copy button on assistant messages hoveredMessageIndex int // Index of message under mouse (-1 = none) + + // Hovered URL for underline-on-hover effect (nil = no URL hovered) + hoveredURL *hoveredURL } // New creates a new message list component @@ -365,7 +368,7 @@ func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd } // Track hovered message for showing copy button on assistant messages - line, _ := m.mouseToLineCol(msg.X, msg.Y) + line, col := m.mouseToLineCol(msg.X, msg.Y) newHovered := -1 if msgIdx, _ := m.globalLineToMessageLine(line); msgIdx >= 0 && msgIdx < len(m.messages) { if m.messages[msgIdx].Type == types.MessageTypeAssistant { @@ -384,6 +387,9 @@ func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd m.renderDirty = true } + // Track hovered URL for underline effect + m.updateHoveredURL(line, col) + return m, nil } @@ -566,6 +572,8 @@ func (m *model) View() string { visibleLines = m.applySelectionHighlight(visibleLines, startLine) } + visibleLines = m.applyURLUnderline(visibleLines, startLine) + // Sync scroll state and delegate rendering to scrollview which guarantees // fixed-width padding, pinned scrollbar, and exact height. m.scrollview.SetContent(m.renderedLines, m.totalScrollableHeight()) @@ -1239,6 +1247,7 @@ func (m *model) LoadFromSession(sess *session.Session) tea.Cmd { m.bottomSlack = 0 m.selectedMessageIndex = -1 m.hoveredMessageIndex = -1 + m.hoveredURL = nil var cmds []tea.Cmd diff --git a/pkg/tui/components/messages/selection.go b/pkg/tui/components/messages/selection.go index e053dfd2c..5f3dbcbbc 100644 --- a/pkg/tui/components/messages/selection.go +++ b/pkg/tui/components/messages/selection.go @@ -255,24 +255,7 @@ func (m *model) applySelectionHighlight(lines []string, viewportStartLine int) [ // highlightLine applies selection highlighting to a portion of a line func (m *model) highlightLine(line string, startCol, endCol int) string { - // Get plain text for boundary checks - plainLine := ansi.Strip(line) - plainWidth := runewidth.StringWidth(plainLine) - - // Validate and normalize boundaries - if startCol >= plainWidth || startCol >= endCol { - return line - } - endCol = min(endCol, plainWidth) - - // Extract the three parts while preserving ANSI codes - before := ansi.Cut(line, 0, startCol) - selectedText := ansi.Cut(line, startCol, endCol) - selectedPlain := ansi.Strip(selectedText) - selected := styles.SelectionStyle.Render(selectedPlain) - after := ansi.Cut(line, endCol, plainWidth) - - return before + selected + after + return styleLineSegment(line, startCol, endCol, styles.SelectionStyle) } // clearSelection resets the selection state diff --git a/pkg/tui/components/messages/urldetect.go b/pkg/tui/components/messages/urldetect.go index b63657ed1..ee285bac1 100644 --- a/pkg/tui/components/messages/urldetect.go +++ b/pkg/tui/components/messages/urldetect.go @@ -3,10 +3,20 @@ package messages import ( "strings" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/mattn/go-runewidth" ) +var underlineStyle = lipgloss.NewStyle().Underline(true) + +// hoveredURL tracks the URL currently under the mouse cursor. +type hoveredURL struct { + line int // global rendered line + startCol int // display column where URL starts + endCol int // display column where URL ends (exclusive) +} + // urlAtPosition extracts a URL from the rendered line at the given display column. // Returns the URL string if found, or empty string if the click position is not on a URL. func urlAtPosition(renderedLine string, col int) string { @@ -128,3 +138,44 @@ func (m *model) urlAt(line, col int) string { } return urlAtPosition(m.renderedLines[line], col) } + +// updateHoveredURL updates the hovered URL state based on mouse position. +func (m *model) updateHoveredURL(line, col int) { + m.ensureAllItemsRendered() + + if line >= 0 && line < len(m.renderedLines) { + plainLine := ansi.Strip(m.renderedLines[line]) + for _, span := range findURLSpans(plainLine) { + if col >= span.startCol && col < span.endCol { + newHover := &hoveredURL{line: line, startCol: span.startCol, endCol: span.endCol} + if m.hoveredURL == nil || *m.hoveredURL != *newHover { + m.hoveredURL = newHover + m.renderDirty = true + } + return + } + } + } + + if m.hoveredURL != nil { + m.hoveredURL = nil + m.renderDirty = true + } +} + +// applyURLUnderline underlines the hovered URL in the visible lines. +func (m *model) applyURLUnderline(lines []string, viewportStartLine int) []string { + if m.hoveredURL == nil { + return lines + } + + viewIdx := m.hoveredURL.line - viewportStartLine + if viewIdx < 0 || viewIdx >= len(lines) { + return lines + } + + result := make([]string, len(lines)) + copy(result, lines) + result[viewIdx] = styleLineSegment(lines[viewIdx], m.hoveredURL.startCol, m.hoveredURL.endCol, underlineStyle) + return result +} diff --git a/pkg/tui/components/messages/urldetect_test.go b/pkg/tui/components/messages/urldetect_test.go index eb977ddd8..d0ea09d73 100644 --- a/pkg/tui/components/messages/urldetect_test.go +++ b/pkg/tui/components/messages/urldetect_test.go @@ -1,8 +1,10 @@ package messages import ( + "strings" "testing" + "github.com/charmbracelet/x/ansi" "gotest.tools/v3/assert" ) @@ -162,3 +164,51 @@ func TestBalanceParens(t *testing.T) { }) } } + +func TestUnderlineLine(t *testing.T) { + tests := []struct { + name string + line string + startCol int + endCol int + wantSub string // substring that should appear underlined + }{ + { + name: "underlines URL portion", + line: "visit https://example.com for more", + startCol: 6, + endCol: 25, + wantSub: "https://example.com", + }, + { + name: "preserves text before and after", + line: "before https://x.com after", + startCol: 7, + endCol: 19, + wantSub: "https://x.com", + }, + { + name: "no-op when startCol >= endCol", + line: "hello world", + startCol: 5, + endCol: 5, + wantSub: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := styleLineSegment(tt.line, tt.startCol, tt.endCol, underlineStyle) + if tt.wantSub != "" { + // The underlined text should contain the ANSI underline escape + assert.Assert(t, strings.Contains(result, "\x1b["), "expected ANSI escape in result: %q", result) + // The plain text of the result should still contain the URL + plain := ansi.Strip(result) + assert.Assert(t, strings.Contains(plain, tt.wantSub), "expected %q in plain text: %q", tt.wantSub, plain) + } else { + // No change expected + assert.Equal(t, tt.line, result) + } + }) + } +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 2ed89fcf5..0269ce742 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -2447,7 +2447,7 @@ func getEditorDisplayNameFromEnv(visual, editorEnv string) string { func toFullscreenView(content, windowTitle string, working, leanMode bool) tea.View { view := tea.NewView(content) view.AltScreen = !leanMode - view.MouseMode = tea.MouseModeCellMotion + view.MouseMode = tea.MouseModeAllMotion view.BackgroundColor = styles.Background view.WindowTitle = windowTitle if working {