Skip to content

Commit 131e93d

Browse files
authored
Merge pull request #2356 from dgageot/board/keyboard-shortcut-to-cycle-sidebar-agent-40e801d4
feat: click on agent in sidebar to switch to it
2 parents d89fc58 + 08405d2 commit 131e93d

5 files changed

Lines changed: 95 additions & 36 deletions

File tree

pkg/tui/components/sidebar/sidebar.go

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ type Model interface {
5757
LoadFromSession(sess *session.Session)
5858
// HandleClick checks if click is on the star or title and returns true if handled
5959
HandleClick(x, y int) bool
60-
// HandleClickType returns the type of click (star, title, or none)
61-
HandleClickType(x, y int) ClickResult
60+
// HandleClickType returns the type of click (star, title, agent, or none).
61+
// For ClickAgent, the second return value is the agent name.
62+
HandleClickType(x, y int) (ClickResult, string)
6263
// IsCollapsed returns whether the sidebar is collapsed
6364
IsCollapsed() bool
6465
// ToggleCollapsed toggles the collapsed state
@@ -147,6 +148,9 @@ type model struct {
147148
cachedWidth int // Width used for cached render
148149
cachedNeedsScrollbar bool // Whether scrollbar is needed for cached render
149150
cacheDirty bool // True when cache needs rebuild
151+
152+
// Agent click zones: maps content line index to agent name for click detection
153+
agentClickZones map[int]string // content line -> agent name
150154
}
151155

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

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

355-
// HandleClickType returns what was clicked (star, title, working dir, or nothing)
356-
func (m *model) HandleClickType(x, y int) ClickResult {
361+
// HandleClickType returns what was clicked (star, title, working dir, agent, or nothing).
362+
// For ClickAgent, the second return value is the agent name.
363+
func (m *model) HandleClickType(x, y int) (ClickResult, string) {
357364
// Account for left padding
358365
adjustedX := x - m.layoutCfg.PaddingLeft
359366
if adjustedX < 0 {
360-
return ClickNone
367+
return ClickNone, ""
361368
}
362369

363370
if m.mode == ModeCollapsed {
@@ -368,11 +375,11 @@ func (m *model) HandleClickType(x, y int) ClickResult {
368375
if y >= 0 && y < titleLines {
369376
// Check if click is on the star (first line only, first few chars)
370377
if y == 0 && m.sessionHasContent && adjustedX <= starClickWidth {
371-
return ClickStar
378+
return ClickStar, ""
372379
}
373380
// Click is on title area (for double-click to edit)
374381
if m.titleGenerated && !m.editingTitle {
375-
return ClickTitle
382+
return ClickTitle, ""
376383
}
377384
}
378385

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

387-
return ClickNone
394+
return ClickNone, ""
388395
}
389396

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

407414
// Working dir is at: verticalStarY + titleLines (title) + 1 (empty separator)
408415
if m.workingDirectory != "" && contentY == verticalStarY+titleLines+1 {
409-
return ClickWorkingDir
416+
return ClickWorkingDir, ""
417+
}
418+
419+
// Check if click is on an agent name
420+
if agentName, ok := m.agentClickZones[contentY]; ok {
421+
return ClickAgent, agentName
410422
}
411423

412-
return ClickNone
424+
return ClickNone, ""
413425
}
414426

415427
// titleLineCount returns the number of lines the title occupies when rendered.
@@ -903,7 +915,12 @@ func (m *model) renderSections(contentWidth int) []string {
903915
appendSection(m.sessionInfo(contentWidth))
904916
appendSection(m.tokenUsage(contentWidth))
905917
appendSection(m.queueSection(contentWidth))
918+
919+
// Track where agent entries start so we can detect clicks on agent names
920+
agentSectionStart := len(lines)
906921
appendSection(m.agentInfo(contentWidth))
922+
m.buildAgentClickZones(agentSectionStart, lines)
923+
907924
appendSection(m.toolsetInfo(contentWidth))
908925

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

1217+
// buildAgentClickZones populates agentClickZones by scanning the rendered lines
1218+
// to find which lines belong to which agent. It relies on the structure produced
1219+
// by renderTab + agentInfo: a 2-line tab header, then agent blocks separated by
1220+
// visually blank lines. Each consecutive run of non-blank lines maps to the next
1221+
// agent in order. This avoids duplicating line-count logic from renderAgentEntry.
1222+
func (m *model) buildAgentClickZones(agentSectionStart int, lines []string) {
1223+
m.agentClickZones = make(map[int]string)
1224+
if len(m.availableAgents) == 0 {
1225+
return
1226+
}
1227+
1228+
const tabHeaderLines = 2 // tab title + TabStyle top padding
1229+
agentIdx := 0
1230+
inBlock := false
1231+
1232+
for i := agentSectionStart + tabHeaderLines; i < len(lines) && agentIdx < len(m.availableAgents); i++ {
1233+
if lipgloss.Width(lines[i]) == 0 {
1234+
// Blank line: if we were inside a block, advance to the next agent
1235+
if inBlock {
1236+
agentIdx++
1237+
inBlock = false
1238+
}
1239+
continue
1240+
}
1241+
inBlock = true
1242+
m.agentClickZones[i] = m.availableAgents[agentIdx].Name
1243+
}
1244+
}
1245+
12001246
// toolsetInfo renders the current toolset status information
12011247
func (m *model) toolsetInfo(contentWidth int) string {
12021248
var lines []string

pkg/tui/components/sidebar/title_edit_test.go

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func TestSidebar_HandleClickType(t *testing.T) {
9797

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

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

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

118118
// Click elsewhere (wrong y)
119-
result = sb.HandleClickType(10, 0)
119+
result, _ = sb.HandleClickType(10, 0)
120120
assert.Equal(t, ClickNone, result, "click elsewhere should return ClickNone")
121121
}
122122

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

pkg/tui/page/chat/chat.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -965,8 +965,8 @@ func (p *chatPage) SetSidebarSettings(settings SidebarSettings) {
965965
}
966966

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

@@ -979,7 +979,7 @@ func (p *chatPage) handleSidebarClickType(x, y int) sidebar.ClickResult {
979979
}
980980
}
981981

982-
return sidebar.ClickNone
982+
return sidebar.ClickNone, ""
983983
}
984984

985985
// routeMouseEvent routes mouse events to the appropriate component based on coordinates.

pkg/tui/page/chat/hittest.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
TargetSidebarStar
1818
TargetSidebarTitle
1919
TargetSidebarWorkingDir
20+
TargetSidebarAgent
2021
TargetSidebarContent
2122
TargetMessages
2223
)
@@ -25,7 +26,8 @@ const (
2526
// This centralizes all hit-testing logic in one place, making it easier
2627
// to understand the clickable regions and their priorities.
2728
type HitTest struct {
28-
page *chatPage
29+
page *chatPage
30+
AgentName string // populated when At() returns TargetSidebarAgent
2931
}
3032

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

120122
// sidebarClickTarget determines the specific target within the sidebar area.
121123
func (h *HitTest) sidebarClickTarget(x, y int) MouseTarget {
122-
clickResult := h.page.handleSidebarClickType(x, y)
124+
clickResult, agentName := h.page.handleSidebarClickType(x, y)
123125
switch clickResult {
124126
case sidebar.ClickStar:
125127
return TargetSidebarStar
126128
case sidebar.ClickTitle:
127129
return TargetSidebarTitle
128130
case sidebar.ClickWorkingDir:
129131
return TargetSidebarWorkingDir
132+
case sidebar.ClickAgent:
133+
h.AgentName = agentName
134+
return TargetSidebarAgent
130135
default:
131136
return TargetSidebarContent
132137
}

pkg/tui/page/chat/input_handlers.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm
148148
return p, copyWorkingDirToClipboard(p.sidebar.WorkingDirectory())
149149
}
150150

151+
case TargetSidebarAgent:
152+
if msg.Button == tea.MouseLeft {
153+
if hit.AgentName != "" {
154+
return p, core.CmdHandler(msgtypes.SwitchAgentMsg{AgentName: hit.AgentName})
155+
}
156+
return p, nil
157+
}
158+
151159
case TargetMessages:
152160
if !p.messages.IsMouseOnScrollbar(msg.X, msg.Y) {
153161
cmd := p.routeMouseEvent(msg, msg.Y)

0 commit comments

Comments
 (0)