Skip to content
Merged
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
74 changes: 60 additions & 14 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ type Model interface {
LoadFromSession(sess *session.Session)
// HandleClick checks if click is on the star or title and returns true if handled
HandleClick(x, y int) bool
// HandleClickType returns the type of click (star, title, or none)
HandleClickType(x, y int) ClickResult
// HandleClickType returns the type of click (star, title, agent, or none).
// For ClickAgent, the second return value is the agent name.
HandleClickType(x, y int) (ClickResult, string)
// IsCollapsed returns whether the sidebar is collapsed
IsCollapsed() bool
// ToggleCollapsed toggles the collapsed state
Expand Down Expand Up @@ -147,6 +148,9 @@ type model struct {
cachedWidth int // Width used for cached render
cachedNeedsScrollbar bool // Whether scrollbar is needed for cached render
cacheDirty bool // True when cache needs rebuild

// Agent click zones: maps content line index to agent name for click detection
agentClickZones map[int]string // content line -> agent name
}

// Option is a functional option for configuring the sidebar.
Expand Down Expand Up @@ -343,21 +347,24 @@ const (
ClickStar
ClickTitle // Click on the title area (use double-click to edit)
ClickWorkingDir // Click on the working directory line
ClickAgent // Click on an agent name in the sidebar
)

// HandleClick checks if click is on the star or title and returns true if it was
// x and y are coordinates relative to the sidebar's top-left corner
// This does NOT toggle the state - caller should handle that
func (m *model) HandleClick(x, y int) bool {
return m.HandleClickType(x, y) != ClickNone
result, _ := m.HandleClickType(x, y)
return result != ClickNone
}

// HandleClickType returns what was clicked (star, title, working dir, or nothing)
func (m *model) HandleClickType(x, y int) ClickResult {
// HandleClickType returns what was clicked (star, title, working dir, agent, or nothing).
// For ClickAgent, the second return value is the agent name.
func (m *model) HandleClickType(x, y int) (ClickResult, string) {
// Account for left padding
adjustedX := x - m.layoutCfg.PaddingLeft
if adjustedX < 0 {
return ClickNone
return ClickNone, ""
}

if m.mode == ModeCollapsed {
Expand All @@ -368,11 +375,11 @@ func (m *model) HandleClickType(x, y int) ClickResult {
if y >= 0 && y < titleLines {
// Check if click is on the star (first line only, first few chars)
if y == 0 && m.sessionHasContent && adjustedX <= starClickWidth {
return ClickStar
return ClickStar, ""
}
// Click is on title area (for double-click to edit)
if m.titleGenerated && !m.editingTitle {
return ClickTitle
return ClickTitle, ""
}
}

Expand All @@ -381,10 +388,10 @@ func (m *model) HandleClickType(x, y int) ClickResult {
wdStartY := vm.titleSectionLines()
wdLines := linesNeeded(lipgloss.Width(vm.WorkingDir), vm.ContentWidth)
if m.workingDirectory != "" && y >= wdStartY && y < wdStartY+wdLines {
return ClickWorkingDir
return ClickWorkingDir, ""
}

return ClickNone
return ClickNone, ""
}

// In vertical mode, the title starts at verticalStarY
Expand All @@ -396,20 +403,25 @@ func (m *model) HandleClickType(x, y int) ClickResult {
if contentY >= verticalStarY && contentY < verticalStarY+titleLines {
// Check if click is on the star (first line only, first few chars)
if contentY == verticalStarY && m.sessionHasContent && adjustedX <= starClickWidth {
return ClickStar
return ClickStar, ""
}
// Click is on title area (for double-click to edit)
if m.titleGenerated && !m.editingTitle {
return ClickTitle
return ClickTitle, ""
}
}

// Working dir is at: verticalStarY + titleLines (title) + 1 (empty separator)
if m.workingDirectory != "" && contentY == verticalStarY+titleLines+1 {
return ClickWorkingDir
return ClickWorkingDir, ""
}

// Check if click is on an agent name
if agentName, ok := m.agentClickZones[contentY]; ok {
return ClickAgent, agentName
}

return ClickNone
return ClickNone, ""
}

// titleLineCount returns the number of lines the title occupies when rendered.
Expand Down Expand Up @@ -903,7 +915,12 @@ func (m *model) renderSections(contentWidth int) []string {
appendSection(m.sessionInfo(contentWidth))
appendSection(m.tokenUsage(contentWidth))
appendSection(m.queueSection(contentWidth))

// Track where agent entries start so we can detect clicks on agent names
agentSectionStart := len(lines)
appendSection(m.agentInfo(contentWidth))
m.buildAgentClickZones(agentSectionStart, lines)

appendSection(m.toolsetInfo(contentWidth))

m.todoComp.SetSize(contentWidth)
Expand Down Expand Up @@ -1197,6 +1214,35 @@ func (m *model) renderAgentEntry(content *strings.Builder, agent runtime.AgentDe
content.WriteString(toolcommon.TruncateText("Model: "+agent.Model, maxWidth))
}

// buildAgentClickZones populates agentClickZones by scanning the rendered lines
// to find which lines belong to which agent. It relies on the structure produced
// by renderTab + agentInfo: a 2-line tab header, then agent blocks separated by
// visually blank lines. Each consecutive run of non-blank lines maps to the next
// agent in order. This avoids duplicating line-count logic from renderAgentEntry.
func (m *model) buildAgentClickZones(agentSectionStart int, lines []string) {
m.agentClickZones = make(map[int]string)
if len(m.availableAgents) == 0 {
return
}

const tabHeaderLines = 2 // tab title + TabStyle top padding
agentIdx := 0
inBlock := false

for i := agentSectionStart + tabHeaderLines; i < len(lines) && agentIdx < len(m.availableAgents); i++ {
if lipgloss.Width(lines[i]) == 0 {
// Blank line: if we were inside a block, advance to the next agent
if inBlock {
agentIdx++
inBlock = false
}
continue
}
inBlock = true
m.agentClickZones[i] = m.availableAgents[agentIdx].Name
}
}

// toolsetInfo renders the current toolset status information
func (m *model) toolsetInfo(contentWidth int) string {
var lines []string
Expand Down
34 changes: 17 additions & 17 deletions pkg/tui/components/sidebar/title_edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func TestSidebar_HandleClickType(t *testing.T) {

// In vertical mode, the title line is at verticalStarY
// Click on the star area (adjusted x = 0-2, so raw x = 1-3)
result := sb.HandleClickType(paddingLeft+1, verticalStarY)
result, _ := sb.HandleClickType(paddingLeft+1, verticalStarY)
assert.Equal(t, ClickStar, result, "click on star area should return ClickStar")

// Set up a title with titleGenerated=true so ClickTitle can be returned
Expand All @@ -107,16 +107,16 @@ func TestSidebar_HandleClickType(t *testing.T) {
// Click anywhere on the title area (after star) should return ClickTitle
// Star "☆ " = 2 chars, so title area starts at position 2
titleX := paddingLeft + 3 // middle of title
result = sb.HandleClickType(titleX, verticalStarY)
result, _ = sb.HandleClickType(titleX, verticalStarY)
assert.Equal(t, ClickTitle, result, "click on title area should return ClickTitle")

// Click at the end (where pencil icon is) should also return ClickTitle
pencilX := paddingLeft + 4
result = sb.HandleClickType(pencilX, verticalStarY)
result, _ = sb.HandleClickType(pencilX, verticalStarY)
assert.Equal(t, ClickTitle, result, "click on pencil icon area should return ClickTitle")

// Click elsewhere (wrong y)
result = sb.HandleClickType(10, 0)
result, _ = sb.HandleClickType(10, 0)
assert.Equal(t, ClickNone, result, "click elsewhere should return ClickNone")
}

Expand Down Expand Up @@ -173,15 +173,15 @@ func TestSidebar_HandleClickType_WrappedTitle_Collapsed(t *testing.T) {
assert.Greater(t, titleLines, 1, "title should wrap to multiple lines")

// Click on line 0 (first title line) after star should return ClickTitle
result := sb.HandleClickType(paddingLeft+3, 0)
result, _ := sb.HandleClickType(paddingLeft+3, 0)
assert.Equal(t, ClickTitle, result, "click on first title line should return ClickTitle")

// Click on line 1 (wrapped title line) should also return ClickTitle
result = sb.HandleClickType(paddingLeft+1, 1)
result, _ = sb.HandleClickType(paddingLeft+1, 1)
assert.Equal(t, ClickTitle, result, "click on wrapped title line should return ClickTitle")

// Star should still be clickable on line 0
result = sb.HandleClickType(paddingLeft+1, 0)
result, _ = sb.HandleClickType(paddingLeft+1, 0)
assert.Equal(t, ClickStar, result, "star should still be clickable on line 0")
}

Expand Down Expand Up @@ -211,15 +211,15 @@ func TestSidebar_HandleClickType_WrappedTitle_Vertical(t *testing.T) {

// In vertical mode, title starts at verticalStarY
// Click on verticalStarY (first title line) after star should return ClickTitle
result := sb.HandleClickType(paddingLeft+3, verticalStarY)
result, _ := sb.HandleClickType(paddingLeft+3, verticalStarY)
assert.Equal(t, ClickTitle, result, "click on first title line should return ClickTitle")

// Click on verticalStarY+1 (wrapped title line) should also return ClickTitle
result = sb.HandleClickType(paddingLeft+1, verticalStarY+1)
result, _ = sb.HandleClickType(paddingLeft+1, verticalStarY+1)
assert.Equal(t, ClickTitle, result, "click on wrapped title line should return ClickTitle")

// Star should still be clickable on verticalStarY
result = sb.HandleClickType(paddingLeft+1, verticalStarY)
result, _ = sb.HandleClickType(paddingLeft+1, verticalStarY)
assert.Equal(t, ClickStar, result, "star should still be clickable on verticalStarY")
}

Expand Down Expand Up @@ -248,11 +248,11 @@ func TestSidebar_HandleClickType_NoWrap(t *testing.T) {
assert.Equal(t, 1, titleLines, "title should be on single line when it doesn't wrap")

// Click on the title area should return ClickTitle
result := sb.HandleClickType(paddingLeft+3, verticalStarY)
result, _ := sb.HandleClickType(paddingLeft+3, verticalStarY)
assert.Equal(t, ClickTitle, result, "click on title should return ClickTitle")

// Star should still be clickable
result = sb.HandleClickType(paddingLeft+1, verticalStarY)
result, _ = sb.HandleClickType(paddingLeft+1, verticalStarY)
assert.Equal(t, ClickStar, result, "star should still be clickable")
}

Expand All @@ -278,15 +278,15 @@ func TestSidebar_HandleClickType_WorkingDir_Vertical(t *testing.T) {
wdY := verticalStarY + titleLines + 1

// Click on the working directory line
result := sb.HandleClickType(paddingLeft+3, wdY)
result, _ := sb.HandleClickType(paddingLeft+3, wdY)
assert.Equal(t, ClickWorkingDir, result, "click on working dir line should return ClickWorkingDir")

// Click on the title line should still return ClickTitle
result = sb.HandleClickType(paddingLeft+3, verticalStarY)
result, _ = sb.HandleClickType(paddingLeft+3, verticalStarY)
assert.Equal(t, ClickTitle, result, "click on title should still return ClickTitle")

// Click on the empty separator line should return ClickNone
result = sb.HandleClickType(paddingLeft+3, verticalStarY+titleLines)
result, _ = sb.HandleClickType(paddingLeft+3, verticalStarY+titleLines)
assert.Equal(t, ClickNone, result, "click on separator line should return ClickNone")
}

Expand All @@ -312,11 +312,11 @@ func TestSidebar_HandleClickType_WorkingDir_Collapsed(t *testing.T) {
assert.Equal(t, 1, titleLines, "title should be on single line")

// Click on the working directory line (right after title)
result := sb.HandleClickType(paddingLeft+3, titleLines)
result, _ := sb.HandleClickType(paddingLeft+3, titleLines)
assert.Equal(t, ClickWorkingDir, result, "click on working dir line should return ClickWorkingDir")

// Click on the title should still return ClickTitle
result = sb.HandleClickType(paddingLeft+3, 0)
result, _ = sb.HandleClickType(paddingLeft+3, 0)
assert.Equal(t, ClickTitle, result, "click on title should still return ClickTitle")
}

Expand Down
6 changes: 3 additions & 3 deletions pkg/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -954,8 +954,8 @@ func (p *chatPage) SetSidebarSettings(settings SidebarSettings) {
}

// handleSidebarClickType checks what was clicked in the sidebar area.
// Returns the type of click (star, title, working dir, or none).
func (p *chatPage) handleSidebarClickType(x, y int) sidebar.ClickResult {
// Returns the click type and, for ClickAgent, the agent name.
func (p *chatPage) handleSidebarClickType(x, y int) (sidebar.ClickResult, string) {
adjustedX := x - styles.AppPadding
sl := p.computeSidebarLayout()

Expand All @@ -968,7 +968,7 @@ func (p *chatPage) handleSidebarClickType(x, y int) sidebar.ClickResult {
}
}

return sidebar.ClickNone
return sidebar.ClickNone, ""
}

// routeMouseEvent routes mouse events to the appropriate component based on coordinates.
Expand Down
9 changes: 7 additions & 2 deletions pkg/tui/page/chat/hittest.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
TargetSidebarStar
TargetSidebarTitle
TargetSidebarWorkingDir
TargetSidebarAgent
TargetSidebarContent
TargetMessages
)
Expand All @@ -25,7 +26,8 @@ const (
// This centralizes all hit-testing logic in one place, making it easier
// to understand the clickable regions and their priorities.
type HitTest struct {
page *chatPage
page *chatPage
AgentName string // populated when At() returns TargetSidebarAgent
}

// NewHitTest creates a hit tester for the given chat page.
Expand Down Expand Up @@ -119,14 +121,17 @@ func ExtractCoords(msg tea.Msg) (x, y int, ok bool) {

// sidebarClickTarget determines the specific target within the sidebar area.
func (h *HitTest) sidebarClickTarget(x, y int) MouseTarget {
clickResult := h.page.handleSidebarClickType(x, y)
clickResult, agentName := h.page.handleSidebarClickType(x, y)
switch clickResult {
case sidebar.ClickStar:
return TargetSidebarStar
case sidebar.ClickTitle:
return TargetSidebarTitle
case sidebar.ClickWorkingDir:
return TargetSidebarWorkingDir
case sidebar.ClickAgent:
h.AgentName = agentName
return TargetSidebarAgent
default:
return TargetSidebarContent
}
Expand Down
8 changes: 8 additions & 0 deletions pkg/tui/page/chat/input_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm
return p, copyWorkingDirToClipboard(p.sidebar.WorkingDirectory())
}

case TargetSidebarAgent:
if msg.Button == tea.MouseLeft {
if hit.AgentName != "" {
return p, core.CmdHandler(msgtypes.SwitchAgentMsg{AgentName: hit.AgentName})
}
return p, nil
}

case TargetMessages:
if !p.messages.IsMouseOnScrollbar(msg.X, msg.Y) {
cmd := p.routeMouseEvent(msg, msg.Y)
Expand Down
Loading