Skip to content
Open
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
26 changes: 26 additions & 0 deletions pkg/tui/components/messages/lineutil.go
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 10 additions & 1 deletion pkg/tui/components/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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

Expand Down
19 changes: 1 addition & 18 deletions pkg/tui/components/messages/selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions pkg/tui/components/messages/urldetect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
50 changes: 50 additions & 0 deletions pkg/tui/components/messages/urldetect_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package messages

import (
"strings"
"testing"

"github.com/charmbracelet/x/ansi"
"gotest.tools/v3/assert"
)

Expand Down Expand Up @@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion pkg/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading