diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index bca578821..f026d011e 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -8,6 +8,7 @@ import ( "os" "slices" "strings" + "time" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" @@ -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 @@ -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. @@ -284,12 +289,9 @@ 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 @@ -297,50 +299,67 @@ 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 { @@ -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{ @@ -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 } @@ -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() } diff --git a/pkg/tui/components/sidebar/title_edit_test.go b/pkg/tui/components/sidebar/title_edit_test.go index fc2567b78..2c95ec22f 100644 --- a/pkg/tui/components/sidebar/title_edit_test.go +++ b/pkg/tui/components/sidebar/title_edit_test.go @@ -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) @@ -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") +} diff --git a/pkg/tui/page/chat/input_handlers.go b/pkg/tui/page/chat/input_handlers.go index 733005f23..594a214d5 100644 --- a/pkg/tui/page/chat/input_handlers.go +++ b/pkg/tui/page/chat/input_handlers.go @@ -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 } }