Skip to content

Commit 18b96fb

Browse files
authored
Merge pull request #1542 from krissetto/fix-title-edit-hitbox
double click to edit title
2 parents 8fb91f3 + 93e39f7 commit 18b96fb

3 files changed

Lines changed: 193 additions & 53 deletions

File tree

pkg/tui/components/sidebar/sidebar.go

Lines changed: 70 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"slices"
1010
"strings"
11+
"time"
1112

1213
"charm.land/bubbles/v2/textinput"
1314
tea "charm.land/bubbletea/v2"
@@ -71,6 +72,9 @@ type Model interface {
7172
SetPreferredWidth(width int)
7273
// ClampWidth ensures width is within valid bounds for the given window width
7374
ClampWidth(width, windowInnerWidth int) int
75+
// HandleTitleClick handles a click on the title area and returns true if
76+
// edit mode should start (on double-click)
77+
HandleTitleClick() bool
7478
// BeginTitleEdit starts inline editing of the session title
7579
BeginTitleEdit()
7680
// IsEditingTitle returns true if the title is being edited
@@ -130,6 +134,7 @@ type model struct {
130134
preferredWidth int // user's preferred width (persisted across collapse/expand)
131135
editingTitle bool // true when inline title editing is active
132136
titleInput textinput.Model
137+
lastTitleClickTime time.Time // for double-click detection on title
133138
}
134139

135140
// Option is a functional option for configuring the sidebar.
@@ -284,63 +289,77 @@ type ClickResult int
284289
const (
285290
ClickNone ClickResult = iota
286291
ClickStar
287-
ClickPencil
292+
ClickTitle // Click on the title area (use double-click to edit)
288293
)
289294

290-
// pencilIconWidth is the click target width for the pencil edit icon (includes padding)
291-
const pencilIconWidth = 3 // " ✎" = space + pencil character
292-
293295
// HandleClick checks if click is on the star or title and returns true if it was
294296
// x and y are coordinates relative to the sidebar's top-left corner
295297
// This does NOT toggle the state - caller should handle that
296298
func (m *model) HandleClick(x, y int) bool {
297299
return m.HandleClickType(x, y) != ClickNone
298300
}
299301

300-
// HandleClickType returns what was clicked (star, pencil icon, or nothing)
302+
// HandleClickType returns what was clicked (star, title, or nothing)
301303
func (m *model) HandleClickType(x, y int) ClickResult {
302304
// Account for left padding
303305
adjustedX := x - m.layoutCfg.PaddingLeft
306+
if adjustedX < 0 {
307+
return ClickNone
308+
}
304309

305310
if m.mode == ModeCollapsed {
306-
// In collapsed mode, star is at the beginning of first line (y=0)
307-
if y == 0 {
308-
if m.sessionHasContent && adjustedX >= 0 && adjustedX <= starClickWidth {
311+
// In collapsed mode, title starts at line 0
312+
titleLines := m.titleLineCount()
313+
314+
// Check if click is within the title area (line 0 to titleLines-1)
315+
if y >= 0 && y < titleLines {
316+
// Check if click is on the star (first line only, first few chars)
317+
if y == 0 && m.sessionHasContent && adjustedX <= starClickWidth {
309318
return ClickStar
310319
}
311-
// Check if click is on pencil icon (at the end of the title line)
312-
// Pencil is only shown when title has been generated
313-
if m.titleGenerated {
314-
starWidth := lipgloss.Width(m.starIndicator())
315-
titleWidth := lipgloss.Width(m.sessionTitle)
316-
pencilStart := starWidth + titleWidth
317-
if adjustedX >= pencilStart && adjustedX < pencilStart+pencilIconWidth {
318-
return ClickPencil
319-
}
320+
// Click is on title area (for double-click to edit)
321+
if m.titleGenerated && !m.editingTitle {
322+
return ClickTitle
320323
}
321324
}
322325
return ClickNone
323326
}
324327

325-
// In vertical mode, the title line is at verticalStarY
326-
if y == verticalStarY {
327-
if m.sessionHasContent && adjustedX >= 0 && adjustedX <= starClickWidth {
328+
// In vertical mode, the title starts at verticalStarY
329+
scrollOffset := m.scrollbar.GetScrollOffset()
330+
contentY := y + scrollOffset // Convert viewport Y to content Y
331+
titleLines := m.titleLineCount()
332+
333+
// Check if click is within the title area
334+
if contentY >= verticalStarY && contentY < verticalStarY+titleLines {
335+
// Check if click is on the star (first line only, first few chars)
336+
if contentY == verticalStarY && m.sessionHasContent && adjustedX <= starClickWidth {
328337
return ClickStar
329338
}
330-
// Check if click is on pencil icon (at the end of the title line)
331-
// Pencil is only shown when title has been generated
332-
if m.titleGenerated {
333-
starWidth := lipgloss.Width(m.starIndicator())
334-
titleWidth := lipgloss.Width(m.sessionTitle)
335-
pencilStart := starWidth + titleWidth
336-
if adjustedX >= pencilStart && adjustedX < pencilStart+pencilIconWidth {
337-
return ClickPencil
338-
}
339+
// Click is on title area (for double-click to edit)
340+
if m.titleGenerated && !m.editingTitle {
341+
return ClickTitle
339342
}
340343
}
341344
return ClickNone
342345
}
343346

347+
// titleLineCount returns the number of lines the title occupies when rendered.
348+
func (m *model) titleLineCount() int {
349+
if !m.titleGenerated || m.sessionTitle == "" {
350+
return 1
351+
}
352+
contentWidth := m.contentWidth(false)
353+
if contentWidth <= 0 {
354+
return 1
355+
}
356+
// Calculate width: star + title
357+
starWidth := lipgloss.Width(m.starIndicator())
358+
titleWidth := lipgloss.Width(m.sessionTitle)
359+
totalWidth := starWidth + titleWidth
360+
return max(1, (totalWidth+contentWidth-1)/contentWidth)
361+
}
362+
344363
// LoadFromSession loads sidebar state from a restored session
345364
func (m *model) LoadFromSession(sess *session.Session) {
346365
if sess == nil {
@@ -656,12 +675,7 @@ func (m *model) computeCollapsedLayout(contentWidth int) collapsedLayout {
656675
titleWithStar = star + m.titleInput.View()
657676
case m.titleRegenerating:
658677
titleWithStar = star + m.spinner.View() + styles.MutedStyle.Render(" Generating title…")
659-
case m.titleGenerated:
660-
// Title has been generated - show with pencil icon
661-
pencilIcon := styles.MutedStyle.Render(" ✎")
662-
titleWithStar = star + m.sessionTitle + pencilIcon
663678
default:
664-
// Title not yet generated - show without pencil icon
665679
titleWithStar = star + m.sessionTitle
666680
}
667681
h := collapsedLayout{
@@ -993,12 +1007,7 @@ func (m *model) sessionInfo(contentWidth int) string {
9931007
case m.titleRegenerating:
9941008
// Show spinner while regenerating title
9951009
titleLine = star + m.spinner.View() + styles.MutedStyle.Render(" Generating title…")
996-
case m.titleGenerated:
997-
// Title has been generated - show with pencil icon for editing
998-
pencilIcon := styles.MutedStyle.Render(" ✎")
999-
titleLine = star + m.sessionTitle + pencilIcon
10001010
default:
1001-
// Title not yet generated - show title without pencil icon
10021011
titleLine = star + m.sessionTitle
10031012
}
10041013

@@ -1260,10 +1269,32 @@ func (m *model) ClampWidth(width, windowInnerWidth int) int {
12601269
return max(MinWidth, min(width, maxWidth))
12611270
}
12621271

1272+
// HandleTitleClick handles a click on the title area and returns true if
1273+
// edit mode should start (on double-click).
1274+
func (m *model) HandleTitleClick() bool {
1275+
now := time.Now()
1276+
if now.Sub(m.lastTitleClickTime) < styles.DoubleClickThreshold {
1277+
m.lastTitleClickTime = time.Time{} // Reset to prevent triple-click
1278+
return true
1279+
}
1280+
m.lastTitleClickTime = now
1281+
return false
1282+
}
1283+
12631284
// BeginTitleEdit starts inline editing of the session title
12641285
func (m *model) BeginTitleEdit() {
12651286
m.editingTitle = true
12661287
m.titleInput.SetValue(m.sessionTitle)
1288+
1289+
// Calculate and set the input width based on current sidebar width
1290+
contentWidth := m.contentWidth(false)
1291+
starWidth := lipgloss.Width(m.starIndicator())
1292+
inputWidth := contentWidth - starWidth - 1
1293+
if inputWidth < 10 {
1294+
inputWidth = 10 // Minimum usable width
1295+
}
1296+
m.titleInput.SetWidth(inputWidth)
1297+
12671298
m.titleInput.Focus()
12681299
m.titleInput.CursorEnd()
12691300
}

pkg/tui/components/sidebar/title_edit_test.go

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,22 +100,20 @@ func TestSidebar_HandleClickType(t *testing.T) {
100100
result := sb.HandleClickType(paddingLeft+1, verticalStarY)
101101
assert.Equal(t, ClickStar, result, "click on star area should return ClickStar")
102102

103-
// Click on the pencil icon area (at the end of title)
104-
// For sessionHasContent=true, star indicator is "☆ " (2 chars)
105-
// Set a short title so we can calculate the pencil position
103+
// Set up a title with titleGenerated=true so ClickTitle can be returned
106104
m.sessionTitle = "Hi"
107-
m.titleGenerated = true // Pencil only shows when title has been generated
108-
// Star "☆ " = 2 chars, title "Hi" = 2 chars, pencil " ✎" starts at position 4
109-
// Add padding to get raw x coordinate
110-
pencilX := paddingLeft + 4
111-
result = sb.HandleClickType(pencilX, verticalStarY)
112-
assert.Equal(t, ClickPencil, result, "click on pencil icon should return ClickPencil")
105+
m.titleGenerated = true
113106

114-
// Click on the title text (not the star, not the pencil) should return ClickNone
115-
// Star ends at position 2, title starts at 2 and ends at 4
107+
// Click anywhere on the title area (after star) should return ClickTitle
108+
// Star "☆ " = 2 chars, so title area starts at position 2
116109
titleX := paddingLeft + 3 // middle of title
117110
result = sb.HandleClickType(titleX, verticalStarY)
118-
assert.Equal(t, ClickNone, result, "click on title text (not pencil) should return ClickNone")
111+
assert.Equal(t, ClickTitle, result, "click on title area should return ClickTitle")
112+
113+
// Click at the end (where pencil icon is) should also return ClickTitle
114+
pencilX := paddingLeft + 4
115+
result = sb.HandleClickType(pencilX, verticalStarY)
116+
assert.Equal(t, ClickTitle, result, "click on pencil icon area should return ClickTitle")
119117

120118
// Click elsewhere (wrong y)
121119
result = sb.HandleClickType(10, 0)
@@ -149,3 +147,111 @@ func TestSidebar_TitleRegenerating(t *testing.T) {
149147
assert.False(t, m.needsSpinner(), "should not need spinner after stopping regeneration")
150148
assert.Nil(t, cmd, "should return nil command when stopping")
151149
}
150+
151+
func TestSidebar_HandleClickType_WrappedTitle_Collapsed(t *testing.T) {
152+
t.Parallel()
153+
154+
sess := session.New()
155+
sessionState := service.NewSessionState(sess)
156+
sb := New(sessionState)
157+
158+
m := sb.(*model)
159+
m.sessionHasContent = true
160+
m.titleGenerated = true
161+
m.mode = ModeCollapsed
162+
163+
// Set a narrow width that will cause wrapping
164+
m.width = 10
165+
166+
// Use a title long enough to wrap: "☆ " (2) + "LongTitle" (9) + " ✎" (2) = 13 chars
167+
m.sessionTitle = "LongTitle"
168+
169+
paddingLeft := m.layoutCfg.PaddingLeft // 1
170+
171+
// Title wraps to multiple lines - clicks on any title line should return ClickTitle
172+
titleLines := m.titleLineCount()
173+
assert.Greater(t, titleLines, 1, "title should wrap to multiple lines")
174+
175+
// Click on line 0 (first title line) after star should return ClickTitle
176+
result := sb.HandleClickType(paddingLeft+3, 0)
177+
assert.Equal(t, ClickTitle, result, "click on first title line should return ClickTitle")
178+
179+
// Click on line 1 (wrapped title line) should also return ClickTitle
180+
result = sb.HandleClickType(paddingLeft+1, 1)
181+
assert.Equal(t, ClickTitle, result, "click on wrapped title line should return ClickTitle")
182+
183+
// Star should still be clickable on line 0
184+
result = sb.HandleClickType(paddingLeft+1, 0)
185+
assert.Equal(t, ClickStar, result, "star should still be clickable on line 0")
186+
}
187+
188+
func TestSidebar_HandleClickType_WrappedTitle_Vertical(t *testing.T) {
189+
t.Parallel()
190+
191+
sess := session.New()
192+
sessionState := service.NewSessionState(sess)
193+
sb := New(sessionState)
194+
195+
m := sb.(*model)
196+
m.sessionHasContent = true
197+
m.titleGenerated = true
198+
m.mode = ModeVertical
199+
200+
// Set a narrow width that will cause wrapping
201+
m.width = 10
202+
203+
// Use a title long enough to wrap
204+
m.sessionTitle = "LongTitle"
205+
206+
paddingLeft := m.layoutCfg.PaddingLeft // 1
207+
208+
// Title wraps to multiple lines
209+
titleLines := m.titleLineCount()
210+
assert.Greater(t, titleLines, 1, "title should wrap to multiple lines")
211+
212+
// In vertical mode, title starts at verticalStarY
213+
// Click on verticalStarY (first title line) after star should return ClickTitle
214+
result := sb.HandleClickType(paddingLeft+3, verticalStarY)
215+
assert.Equal(t, ClickTitle, result, "click on first title line should return ClickTitle")
216+
217+
// Click on verticalStarY+1 (wrapped title line) should also return ClickTitle
218+
result = sb.HandleClickType(paddingLeft+1, verticalStarY+1)
219+
assert.Equal(t, ClickTitle, result, "click on wrapped title line should return ClickTitle")
220+
221+
// Star should still be clickable on verticalStarY
222+
result = sb.HandleClickType(paddingLeft+1, verticalStarY)
223+
assert.Equal(t, ClickStar, result, "star should still be clickable on verticalStarY")
224+
}
225+
226+
func TestSidebar_HandleClickType_NoWrap(t *testing.T) {
227+
t.Parallel()
228+
229+
sess := session.New()
230+
sessionState := service.NewSessionState(sess)
231+
sb := New(sessionState)
232+
233+
m := sb.(*model)
234+
m.sessionHasContent = true
235+
m.titleGenerated = true
236+
m.mode = ModeVertical
237+
238+
// Use a wide enough width that title won't wrap
239+
m.width = 50
240+
241+
// Short title that won't wrap
242+
m.sessionTitle = "Hi"
243+
244+
paddingLeft := m.layoutCfg.PaddingLeft
245+
246+
// Title should be on a single line
247+
titleLines := m.titleLineCount()
248+
assert.Equal(t, 1, titleLines, "title should be on single line when it doesn't wrap")
249+
250+
// Click on the title area should return ClickTitle
251+
result := sb.HandleClickType(paddingLeft+3, verticalStarY)
252+
assert.Equal(t, ClickTitle, result, "click on title should return ClickTitle")
253+
254+
// Star should still be clickable
255+
result = sb.HandleClickType(paddingLeft+1, verticalStarY)
256+
assert.Equal(t, ClickStar, result, "star should still be clickable")
257+
}

pkg/tui/page/chat/input_handlers.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,11 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm
130130
return p, core.CmdHandler(msgtypes.ToggleSessionStarMsg{SessionID: sess.ID})
131131
}
132132
return p, nil
133-
case sidebar.ClickPencil:
134-
p.sidebar.BeginTitleEdit()
133+
case sidebar.ClickTitle:
134+
// Double-click on title to edit
135+
if p.sidebar.HandleTitleClick() {
136+
p.sidebar.BeginTitleEdit()
137+
}
135138
return p, nil
136139
}
137140
}

0 commit comments

Comments
 (0)