Skip to content

Commit 93e39f7

Browse files
committed
double click to edit title
also fixes the title editor input area size calculation and moves some title specific code to the sidebar component Signed-off-by: krissetto <chrisjpetito@gmail.com>
1 parent 164c8d1 commit 93e39f7

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)