Skip to content

Commit 6ca5640

Browse files
committed
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
1 parent 8a128f5 commit 6ca5640

File tree

6 files changed

+139
-20
lines changed

6 files changed

+139
-20
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package messages
2+
3+
import (
4+
"charm.land/lipgloss/v2"
5+
"github.com/charmbracelet/x/ansi"
6+
"github.com/mattn/go-runewidth"
7+
)
8+
9+
// styleLineSegment applies a lipgloss style to the portion of a line between
10+
// startCol and endCol (display columns), preserving the text before and after.
11+
// ANSI codes in the styled segment are stripped so the style renders cleanly.
12+
func styleLineSegment(line string, startCol, endCol int, style lipgloss.Style) string {
13+
plainLine := ansi.Strip(line)
14+
plainWidth := runewidth.StringWidth(plainLine)
15+
16+
if startCol >= plainWidth || startCol >= endCol {
17+
return line
18+
}
19+
endCol = min(endCol, plainWidth)
20+
21+
before := ansi.Cut(line, 0, startCol)
22+
segment := ansi.Strip(ansi.Cut(line, startCol, endCol))
23+
after := ansi.Cut(line, endCol, plainWidth)
24+
25+
return before + style.Render(segment) + after
26+
}

pkg/tui/components/messages/messages.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ type model struct {
130130

131131
// Hover state for showing copy button on assistant messages
132132
hoveredMessageIndex int // Index of message under mouse (-1 = none)
133+
134+
// Hovered URL for underline-on-hover effect (nil = no URL hovered)
135+
hoveredURL *hoveredURL
133136
}
134137

135138
// New creates a new message list component
@@ -365,7 +368,7 @@ func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd
365368
}
366369

367370
// Track hovered message for showing copy button on assistant messages
368-
line, _ := m.mouseToLineCol(msg.X, msg.Y)
371+
line, col := m.mouseToLineCol(msg.X, msg.Y)
369372
newHovered := -1
370373
if msgIdx, _ := m.globalLineToMessageLine(line); msgIdx >= 0 && msgIdx < len(m.messages) {
371374
if m.messages[msgIdx].Type == types.MessageTypeAssistant {
@@ -384,6 +387,9 @@ func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd
384387
m.renderDirty = true
385388
}
386389

390+
// Track hovered URL for underline effect
391+
m.updateHoveredURL(line, col)
392+
387393
return m, nil
388394
}
389395

@@ -566,6 +572,8 @@ func (m *model) View() string {
566572
visibleLines = m.applySelectionHighlight(visibleLines, startLine)
567573
}
568574

575+
visibleLines = m.applyURLUnderline(visibleLines, startLine)
576+
569577
// Sync scroll state and delegate rendering to scrollview which guarantees
570578
// fixed-width padding, pinned scrollbar, and exact height.
571579
m.scrollview.SetContent(m.renderedLines, m.totalScrollableHeight())
@@ -1239,6 +1247,7 @@ func (m *model) LoadFromSession(sess *session.Session) tea.Cmd {
12391247
m.bottomSlack = 0
12401248
m.selectedMessageIndex = -1
12411249
m.hoveredMessageIndex = -1
1250+
m.hoveredURL = nil
12421251

12431252
var cmds []tea.Cmd
12441253

pkg/tui/components/messages/selection.go

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -255,24 +255,7 @@ func (m *model) applySelectionHighlight(lines []string, viewportStartLine int) [
255255

256256
// highlightLine applies selection highlighting to a portion of a line
257257
func (m *model) highlightLine(line string, startCol, endCol int) string {
258-
// Get plain text for boundary checks
259-
plainLine := ansi.Strip(line)
260-
plainWidth := runewidth.StringWidth(plainLine)
261-
262-
// Validate and normalize boundaries
263-
if startCol >= plainWidth || startCol >= endCol {
264-
return line
265-
}
266-
endCol = min(endCol, plainWidth)
267-
268-
// Extract the three parts while preserving ANSI codes
269-
before := ansi.Cut(line, 0, startCol)
270-
selectedText := ansi.Cut(line, startCol, endCol)
271-
selectedPlain := ansi.Strip(selectedText)
272-
selected := styles.SelectionStyle.Render(selectedPlain)
273-
after := ansi.Cut(line, endCol, plainWidth)
274-
275-
return before + selected + after
258+
return styleLineSegment(line, startCol, endCol, styles.SelectionStyle)
276259
}
277260

278261
// clearSelection resets the selection state

pkg/tui/components/messages/urldetect.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@ package messages
33
import (
44
"strings"
55

6+
"charm.land/lipgloss/v2"
67
"github.com/charmbracelet/x/ansi"
78
"github.com/mattn/go-runewidth"
89
)
910

11+
var underlineStyle = lipgloss.NewStyle().Underline(true)
12+
13+
// hoveredURL tracks the URL currently under the mouse cursor.
14+
type hoveredURL struct {
15+
line int // global rendered line
16+
startCol int // display column where URL starts
17+
endCol int // display column where URL ends (exclusive)
18+
}
19+
1020
// urlAtPosition extracts a URL from the rendered line at the given display column.
1121
// Returns the URL string if found, or empty string if the click position is not on a URL.
1222
func urlAtPosition(renderedLine string, col int) string {
@@ -128,3 +138,44 @@ func (m *model) urlAt(line, col int) string {
128138
}
129139
return urlAtPosition(m.renderedLines[line], col)
130140
}
141+
142+
// updateHoveredURL updates the hovered URL state based on mouse position.
143+
func (m *model) updateHoveredURL(line, col int) {
144+
m.ensureAllItemsRendered()
145+
146+
if line >= 0 && line < len(m.renderedLines) {
147+
plainLine := ansi.Strip(m.renderedLines[line])
148+
for _, span := range findURLSpans(plainLine) {
149+
if col >= span.startCol && col < span.endCol {
150+
newHover := &hoveredURL{line: line, startCol: span.startCol, endCol: span.endCol}
151+
if m.hoveredURL == nil || *m.hoveredURL != *newHover {
152+
m.hoveredURL = newHover
153+
m.renderDirty = true
154+
}
155+
return
156+
}
157+
}
158+
}
159+
160+
if m.hoveredURL != nil {
161+
m.hoveredURL = nil
162+
m.renderDirty = true
163+
}
164+
}
165+
166+
// applyURLUnderline underlines the hovered URL in the visible lines.
167+
func (m *model) applyURLUnderline(lines []string, viewportStartLine int) []string {
168+
if m.hoveredURL == nil {
169+
return lines
170+
}
171+
172+
viewIdx := m.hoveredURL.line - viewportStartLine
173+
if viewIdx < 0 || viewIdx >= len(lines) {
174+
return lines
175+
}
176+
177+
result := make([]string, len(lines))
178+
copy(result, lines)
179+
result[viewIdx] = styleLineSegment(lines[viewIdx], m.hoveredURL.startCol, m.hoveredURL.endCol, underlineStyle)
180+
return result
181+
}

pkg/tui/components/messages/urldetect_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package messages
22

33
import (
4+
"strings"
45
"testing"
56

7+
"github.com/charmbracelet/x/ansi"
68
"gotest.tools/v3/assert"
79
)
810

@@ -162,3 +164,51 @@ func TestBalanceParens(t *testing.T) {
162164
})
163165
}
164166
}
167+
168+
func TestUnderlineLine(t *testing.T) {
169+
tests := []struct {
170+
name string
171+
line string
172+
startCol int
173+
endCol int
174+
wantSub string // substring that should appear underlined
175+
}{
176+
{
177+
name: "underlines URL portion",
178+
line: "visit https://example.com for more",
179+
startCol: 6,
180+
endCol: 25,
181+
wantSub: "https://example.com",
182+
},
183+
{
184+
name: "preserves text before and after",
185+
line: "before https://x.com after",
186+
startCol: 7,
187+
endCol: 19,
188+
wantSub: "https://x.com",
189+
},
190+
{
191+
name: "no-op when startCol >= endCol",
192+
line: "hello world",
193+
startCol: 5,
194+
endCol: 5,
195+
wantSub: "",
196+
},
197+
}
198+
199+
for _, tt := range tests {
200+
t.Run(tt.name, func(t *testing.T) {
201+
result := styleLineSegment(tt.line, tt.startCol, tt.endCol, underlineStyle)
202+
if tt.wantSub != "" {
203+
// The underlined text should contain the ANSI underline escape
204+
assert.Assert(t, strings.Contains(result, "\x1b["), "expected ANSI escape in result: %q", result)
205+
// The plain text of the result should still contain the URL
206+
plain := ansi.Strip(result)
207+
assert.Assert(t, strings.Contains(plain, tt.wantSub), "expected %q in plain text: %q", tt.wantSub, plain)
208+
} else {
209+
// No change expected
210+
assert.Equal(t, tt.line, result)
211+
}
212+
})
213+
}
214+
}

pkg/tui/tui.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2447,7 +2447,7 @@ func getEditorDisplayNameFromEnv(visual, editorEnv string) string {
24472447
func toFullscreenView(content, windowTitle string, working, leanMode bool) tea.View {
24482448
view := tea.NewView(content)
24492449
view.AltScreen = !leanMode
2450-
view.MouseMode = tea.MouseModeCellMotion
2450+
view.MouseMode = tea.MouseModeAllMotion
24512451
view.BackgroundColor = styles.Background
24522452
view.WindowTitle = windowTitle
24532453
if working {

0 commit comments

Comments
 (0)