Skip to content

Commit 7801c53

Browse files
authored
Merge pull request #2316 from dgageot/underline-urls
Underline URLs on mouse hover
2 parents ead927e + 6ca5640 commit 7801c53

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)