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
109 changes: 70 additions & 39 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"slices"
"strings"
"time"

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

// Option is a functional option for configuring the sidebar.
Expand Down Expand Up @@ -284,63 +289,77 @@ type ClickResult int
const (
ClickNone ClickResult = iota
ClickStar
ClickPencil
ClickTitle // Click on the title area (use double-click to edit)
)

// pencilIconWidth is the click target width for the pencil edit icon (includes padding)
const pencilIconWidth = 3 // " ✎" = space + pencil character

// 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
}

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

if m.mode == ModeCollapsed {
// In collapsed mode, star is at the beginning of first line (y=0)
if y == 0 {
if m.sessionHasContent && adjustedX >= 0 && adjustedX <= starClickWidth {
// In collapsed mode, title starts at line 0
titleLines := m.titleLineCount()

// Check if click is within the title area (line 0 to titleLines-1)
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
}
// Check if click is on pencil icon (at the end of the title line)
// Pencil is only shown when title has been generated
if m.titleGenerated {
starWidth := lipgloss.Width(m.starIndicator())
titleWidth := lipgloss.Width(m.sessionTitle)
pencilStart := starWidth + titleWidth
if adjustedX >= pencilStart && adjustedX < pencilStart+pencilIconWidth {
return ClickPencil
}
// Click is on title area (for double-click to edit)
if m.titleGenerated && !m.editingTitle {
return ClickTitle
}
}
return ClickNone
}

// In vertical mode, the title line is at verticalStarY
if y == verticalStarY {
if m.sessionHasContent && adjustedX >= 0 && adjustedX <= starClickWidth {
// In vertical mode, the title starts at verticalStarY
scrollOffset := m.scrollbar.GetScrollOffset()
contentY := y + scrollOffset // Convert viewport Y to content Y
titleLines := m.titleLineCount()

// Check if click is within the title area
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
}
// Check if click is on pencil icon (at the end of the title line)
// Pencil is only shown when title has been generated
if m.titleGenerated {
starWidth := lipgloss.Width(m.starIndicator())
titleWidth := lipgloss.Width(m.sessionTitle)
pencilStart := starWidth + titleWidth
if adjustedX >= pencilStart && adjustedX < pencilStart+pencilIconWidth {
return ClickPencil
}
// Click is on title area (for double-click to edit)
if m.titleGenerated && !m.editingTitle {
return ClickTitle
}
}
return ClickNone
}

// titleLineCount returns the number of lines the title occupies when rendered.
func (m *model) titleLineCount() int {
if !m.titleGenerated || m.sessionTitle == "" {
return 1
}
contentWidth := m.contentWidth(false)
if contentWidth <= 0 {
return 1
}
// Calculate width: star + title
starWidth := lipgloss.Width(m.starIndicator())
titleWidth := lipgloss.Width(m.sessionTitle)
totalWidth := starWidth + titleWidth
return max(1, (totalWidth+contentWidth-1)/contentWidth)
}

// LoadFromSession loads sidebar state from a restored session
func (m *model) LoadFromSession(sess *session.Session) {
if sess == nil {
Expand Down Expand Up @@ -656,12 +675,7 @@ func (m *model) computeCollapsedLayout(contentWidth int) collapsedLayout {
titleWithStar = star + m.titleInput.View()
case m.titleRegenerating:
titleWithStar = star + m.spinner.View() + styles.MutedStyle.Render(" Generating title…")
case m.titleGenerated:
// Title has been generated - show with pencil icon
pencilIcon := styles.MutedStyle.Render(" ✎")
titleWithStar = star + m.sessionTitle + pencilIcon
default:
// Title not yet generated - show without pencil icon
titleWithStar = star + m.sessionTitle
}
h := collapsedLayout{
Expand Down Expand Up @@ -993,12 +1007,7 @@ func (m *model) sessionInfo(contentWidth int) string {
case m.titleRegenerating:
// Show spinner while regenerating title
titleLine = star + m.spinner.View() + styles.MutedStyle.Render(" Generating title…")
case m.titleGenerated:
// Title has been generated - show with pencil icon for editing
pencilIcon := styles.MutedStyle.Render(" ✎")
titleLine = star + m.sessionTitle + pencilIcon
default:
// Title not yet generated - show title without pencil icon
titleLine = star + m.sessionTitle
}

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

// HandleTitleClick handles a click on the title area and returns true if
// edit mode should start (on double-click).
func (m *model) HandleTitleClick() bool {
now := time.Now()
if now.Sub(m.lastTitleClickTime) < styles.DoubleClickThreshold {
m.lastTitleClickTime = time.Time{} // Reset to prevent triple-click
return true
}
m.lastTitleClickTime = now
return false
}

// BeginTitleEdit starts inline editing of the session title
func (m *model) BeginTitleEdit() {
m.editingTitle = true
m.titleInput.SetValue(m.sessionTitle)

// Calculate and set the input width based on current sidebar width
contentWidth := m.contentWidth(false)
starWidth := lipgloss.Width(m.starIndicator())
inputWidth := contentWidth - starWidth - 1
if inputWidth < 10 {
inputWidth = 10 // Minimum usable width
}
m.titleInput.SetWidth(inputWidth)

m.titleInput.Focus()
m.titleInput.CursorEnd()
}
Expand Down
130 changes: 118 additions & 12 deletions pkg/tui/components/sidebar/title_edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,22 +100,20 @@ func TestSidebar_HandleClickType(t *testing.T) {
result := sb.HandleClickType(paddingLeft+1, verticalStarY)
assert.Equal(t, ClickStar, result, "click on star area should return ClickStar")

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

// Click on the title text (not the star, not the pencil) should return ClickNone
// Star ends at position 2, title starts at 2 and ends at 4
// 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)
assert.Equal(t, ClickNone, result, "click on title text (not pencil) should return ClickNone")
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)
assert.Equal(t, ClickTitle, result, "click on pencil icon area should return ClickTitle")

// Click elsewhere (wrong y)
result = sb.HandleClickType(10, 0)
Expand Down Expand Up @@ -149,3 +147,111 @@ func TestSidebar_TitleRegenerating(t *testing.T) {
assert.False(t, m.needsSpinner(), "should not need spinner after stopping regeneration")
assert.Nil(t, cmd, "should return nil command when stopping")
}

func TestSidebar_HandleClickType_WrappedTitle_Collapsed(t *testing.T) {
t.Parallel()

sess := session.New()
sessionState := service.NewSessionState(sess)
sb := New(sessionState)

m := sb.(*model)
m.sessionHasContent = true
m.titleGenerated = true
m.mode = ModeCollapsed

// Set a narrow width that will cause wrapping
m.width = 10

// Use a title long enough to wrap: "☆ " (2) + "LongTitle" (9) + " ✎" (2) = 13 chars
m.sessionTitle = "LongTitle"

paddingLeft := m.layoutCfg.PaddingLeft // 1

// Title wraps to multiple lines - clicks on any title line should return ClickTitle
titleLines := m.titleLineCount()
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)
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)
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)
assert.Equal(t, ClickStar, result, "star should still be clickable on line 0")
}

func TestSidebar_HandleClickType_WrappedTitle_Vertical(t *testing.T) {
t.Parallel()

sess := session.New()
sessionState := service.NewSessionState(sess)
sb := New(sessionState)

m := sb.(*model)
m.sessionHasContent = true
m.titleGenerated = true
m.mode = ModeVertical

// Set a narrow width that will cause wrapping
m.width = 10

// Use a title long enough to wrap
m.sessionTitle = "LongTitle"

paddingLeft := m.layoutCfg.PaddingLeft // 1

// Title wraps to multiple lines
titleLines := m.titleLineCount()
assert.Greater(t, titleLines, 1, "title should wrap to multiple lines")

// In vertical mode, title starts at verticalStarY
// Click on verticalStarY (first title line) after star should return ClickTitle
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)
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)
assert.Equal(t, ClickStar, result, "star should still be clickable on verticalStarY")
}

func TestSidebar_HandleClickType_NoWrap(t *testing.T) {
t.Parallel()

sess := session.New()
sessionState := service.NewSessionState(sess)
sb := New(sessionState)

m := sb.(*model)
m.sessionHasContent = true
m.titleGenerated = true
m.mode = ModeVertical

// Use a wide enough width that title won't wrap
m.width = 50

// Short title that won't wrap
m.sessionTitle = "Hi"

paddingLeft := m.layoutCfg.PaddingLeft

// Title should be on a single line
titleLines := m.titleLineCount()
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)
assert.Equal(t, ClickTitle, result, "click on title should return ClickTitle")

// Star should still be clickable
result = sb.HandleClickType(paddingLeft+1, verticalStarY)
assert.Equal(t, ClickStar, result, "star should still be clickable")
}
7 changes: 5 additions & 2 deletions pkg/tui/page/chat/input_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,11 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm
return p, core.CmdHandler(msgtypes.ToggleSessionStarMsg{SessionID: sess.ID})
}
return p, nil
case sidebar.ClickPencil:
p.sidebar.BeginTitleEdit()
case sidebar.ClickTitle:
// Double-click on title to edit
if p.sidebar.HandleTitleClick() {
p.sidebar.BeginTitleEdit()
}
return p, nil
}
}
Expand Down