@@ -32,7 +32,7 @@ type Mode int
3232
3333const (
3434 ModeVertical Mode = iota
35- ModeHorizontal
35+ ModeCollapsed
3636)
3737
3838// Model represents a sidebar component
@@ -54,6 +54,20 @@ type Model interface {
5454 LoadFromSession (sess * session.Session )
5555 // HandleClick checks if click is on the star and returns true if handled
5656 HandleClick (x , y int ) bool
57+ // IsCollapsed returns whether the sidebar is collapsed
58+ IsCollapsed () bool
59+ // ToggleCollapsed toggles the collapsed state
60+ ToggleCollapsed ()
61+ // SetCollapsed sets the collapsed state directly
62+ SetCollapsed (collapsed bool )
63+ // CollapsedHeight returns the number of lines needed for collapsed mode
64+ CollapsedHeight (contentWidth int ) int
65+ // GetPreferredWidth returns the user's preferred width (for resize persistence)
66+ GetPreferredWidth () int
67+ // SetPreferredWidth sets the user's preferred width
68+ SetPreferredWidth (width int )
69+ // ClampWidth ensures width is within valid bounds for the given window width
70+ ClampWidth (width , windowInnerWidth int ) int
5771}
5872
5973// ragIndexingState tracks per-strategy indexing progress
@@ -95,6 +109,8 @@ type model struct {
95109 queuedMessages []string // Truncated preview of queued messages
96110 streamCancelled bool // true after ESC cancel until next StreamStartedEvent
97111 reasoningSupported bool // true if current model supports reasoning (default: true / fail-open)
112+ collapsed bool // true when sidebar is collapsed
113+ preferredWidth int // user's preferred width (persisted across collapse/expand)
98114}
99115
100116// Option is a functional option for configuring the sidebar.
@@ -119,7 +135,8 @@ func New(sessionState *service.SessionState, opts ...Option) Model {
119135 sessionState : sessionState ,
120136 scrollbar : scrollbar .New (),
121137 workingDirectory : getCurrentWorkingDirectory (),
122- reasoningSupported : true , // Default to true (fail-open)
138+ reasoningSupported : true ,
139+ preferredWidth : DefaultWidth ,
123140 }
124141 for _ , opt := range opts {
125142 opt (m )
@@ -229,7 +246,7 @@ func (m *model) SetQueuedMessages(queuedMessages ...string) {
229246// x and y are coordinates relative to the sidebar's top-left corner
230247// This does NOT toggle the state - caller should handle that
231248func (m * model ) HandleClick (x , y int ) bool {
232- // Don't handle clicks if session has no content (star isn't shown)
249+ // Don't handle star clicks if session has no content (star isn't shown)
233250 if ! m .sessionHasContent {
234251 return false
235252 }
@@ -242,8 +259,8 @@ func (m *model) HandleClick(x, y int) bool {
242259 return false
243260 }
244261
245- if m .mode == ModeHorizontal {
246- // In horizontal mode, star is at the beginning of first line (y=0)
262+ if m .mode == ModeCollapsed {
263+ // In collapsed mode, star is at the beginning of first line (y=0)
247264 return y == 0
248265 }
249266 // In vertical mode, star is below tab title and TabStyle padding
@@ -469,7 +486,7 @@ func (m *model) View() string {
469486 if m .mode == ModeVertical {
470487 content = m .verticalView ()
471488 } else {
472- content = m .horizontalView ()
489+ content = m .collapsedView ()
473490 }
474491
475492 // Apply horizontal padding
@@ -495,23 +512,129 @@ func (m *model) starIndicator() string {
495512 return styles .StarIndicator (m .sessionStarred )
496513}
497514
498- func (m * model ) horizontalView () string {
499- // Compute content width (no scrollbar in horizontal mode)
500- contentWidth := m .contentWidth (false )
501- usageSummary := m .tokenUsageSummary ()
515+ // collapsedLayout holds the computed layout decisions for collapsed mode.
516+ // Computing this once avoids duplicating the layout logic between CollapsedHeight and collapsedView.
517+ type collapsedLayout struct {
518+ titleWithStar string
519+ workingIndicator string
520+ workingDir string
521+ usageSummary string
522+
523+ // Layout decisions
524+ titleAndIndicatorOnOneLine bool
525+ wdAndUsageOnOneLine bool
526+ contentWidth int
527+ }
528+
529+ func (m * model ) computeCollapsedLayout (contentWidth int ) collapsedLayout {
530+ h := collapsedLayout {
531+ titleWithStar : m .starIndicator () + m .sessionTitle ,
532+ workingIndicator : m .workingIndicatorCollapsed (),
533+ workingDir : m .workingDirectory ,
534+ usageSummary : m .tokenUsageSummary (),
535+ contentWidth : contentWidth ,
536+ }
502537
503- titleWithStar := m .starIndicator () + m .sessionTitle
538+ titleWidth := lipgloss .Width (h .titleWithStar )
539+ wiWidth := lipgloss .Width (h .workingIndicator )
540+ wdWidth := lipgloss .Width (h .workingDir )
541+ usageWidth := lipgloss .Width (h .usageSummary )
504542
505- wi := m .workingIndicatorHorizontal ()
506- titleGapWidth := contentWidth - lipgloss .Width (titleWithStar ) - lipgloss .Width (wi )
507- title := fmt .Sprintf ("%s%*s%s" , titleWithStar , titleGapWidth , "" , wi )
543+ // Title and indicator fit on one line if:
544+ // - no working indicator AND title fits, OR
545+ // - both fit together with gap
546+ h .titleAndIndicatorOnOneLine = (h .workingIndicator == "" && titleWidth <= contentWidth ) ||
547+ (h .workingIndicator != "" && titleWidth + minGap + wiWidth <= contentWidth )
548+ h .wdAndUsageOnOneLine = wdWidth + minGap + usageWidth <= contentWidth
508549
509- gapWidth := contentWidth - lipgloss .Width (m .workingDirectory ) - lipgloss .Width (usageSummary )
510- return lipgloss .JoinVertical (lipgloss .Top , title , fmt .Sprintf ("%s%*s%s" , styles .MutedStyle .Render (m .workingDirectory ), gapWidth , "" , usageSummary ))
550+ return h
551+ }
552+
553+ func (h collapsedLayout ) lineCount () int {
554+ lines := 1 // divider
555+
556+ switch {
557+ case h .titleAndIndicatorOnOneLine :
558+ lines ++
559+ case h .workingIndicator == "" :
560+ // No working indicator but title wraps
561+ lines += linesNeeded (lipgloss .Width (h .titleWithStar ), h .contentWidth )
562+ default :
563+ // Title and working indicator on separate lines, each may wrap
564+ lines += linesNeeded (lipgloss .Width (h .titleWithStar ), h .contentWidth )
565+ lines += linesNeeded (lipgloss .Width (h .workingIndicator ), h .contentWidth )
566+ }
567+
568+ if h .wdAndUsageOnOneLine {
569+ lines ++
570+ } else {
571+ lines += linesNeeded (lipgloss .Width (h .workingDir ), h .contentWidth )
572+ if h .usageSummary != "" {
573+ lines += linesNeeded (lipgloss .Width (h .usageSummary ), h .contentWidth )
574+ }
575+ }
576+
577+ return lines
578+ }
579+
580+ func (h collapsedLayout ) render () string {
581+ var lines []string
582+
583+ // Title line(s)
584+ switch {
585+ case h .titleAndIndicatorOnOneLine :
586+ if h .workingIndicator == "" {
587+ lines = append (lines , h .titleWithStar )
588+ } else {
589+ gap := h .contentWidth - lipgloss .Width (h .titleWithStar ) - lipgloss .Width (h .workingIndicator )
590+ lines = append (lines , fmt .Sprintf ("%s%*s%s" , h .titleWithStar , gap , "" , h .workingIndicator ))
591+ }
592+ case h .workingIndicator == "" :
593+ // No working indicator but title wraps - just output title (lipgloss will wrap)
594+ lines = append (lines , h .titleWithStar )
595+ default :
596+ // Title and working indicator on separate lines
597+ lines = append (lines , h .titleWithStar , h .workingIndicator )
598+ }
599+
600+ // Working directory + usage line(s)
601+ if h .wdAndUsageOnOneLine {
602+ gap := h .contentWidth - lipgloss .Width (h .workingDir ) - lipgloss .Width (h .usageSummary )
603+ lines = append (lines , fmt .Sprintf ("%s%*s%s" , styles .MutedStyle .Render (h .workingDir ), gap , "" , h .usageSummary ))
604+ } else {
605+ lines = append (lines , styles .MutedStyle .Render (h .workingDir ))
606+ if h .usageSummary != "" {
607+ lines = append (lines , h .usageSummary )
608+ }
609+ }
610+
611+ return lipgloss .JoinVertical (lipgloss .Top , lines ... )
612+ }
613+
614+ // linesNeeded calculates how many lines are needed to display text of given width
615+ // within a container of contentWidth. Returns at least 1 line.
616+ func linesNeeded (textWidth , contentWidth int ) int {
617+ if contentWidth <= 0 || textWidth <= 0 {
618+ return 1
619+ }
620+ return max (1 , (textWidth + contentWidth - 1 )/ contentWidth )
621+ }
622+
623+ // CollapsedHeight returns the number of lines needed for collapsed mode.
624+ func (m * model ) CollapsedHeight (outerWidth int ) int {
625+ contentWidth := outerWidth - m .layoutCfg .PaddingLeft - m .layoutCfg .PaddingRight
626+ if contentWidth < 1 {
627+ contentWidth = 1
628+ }
629+ return m .computeCollapsedLayout (contentWidth ).lineCount ()
630+ }
631+
632+ func (m * model ) collapsedView () string {
633+ return m .computeCollapsedLayout (m .contentWidth (false )).render ()
511634}
512635
513636func (m * model ) verticalView () string {
514- visibleLines := m .height - headerLines
637+ visibleLines := m .height
515638
516639 // Two-pass rendering: first check if scrollbar is needed
517640 // Pass 1: render without scrollbar to count lines
@@ -639,8 +762,8 @@ func (m *model) workingIndicator() string {
639762 return strings .Join (indicators , "\n " )
640763}
641764
642- // workingIndicatorHorizontal returns a single-line version of the working indicator for horizontal mode
643- func (m * model ) workingIndicatorHorizontal () string {
765+ // workingIndicatorCollapsed returns a single-line version of the working indicator for collapsed mode
766+ func (m * model ) workingIndicatorCollapsed () string {
644767 var labels []string
645768
646769 if m .mcpInit {
@@ -930,3 +1053,44 @@ func (m *model) metrics(scrollbarVisible bool) Metrics {
9301053func (m * model ) contentWidth (scrollbarVisible bool ) int {
9311054 return m .metrics (scrollbarVisible ).ContentWidth
9321055}
1056+
1057+ // IsCollapsed returns whether the sidebar is collapsed
1058+ func (m * model ) IsCollapsed () bool {
1059+ return m .collapsed
1060+ }
1061+
1062+ // ToggleCollapsed toggles the collapsed state of the sidebar.
1063+ // When expanding, if the preferred width is below minimum (e.g., after drag-to-collapse),
1064+ // it resets to the default width.
1065+ func (m * model ) ToggleCollapsed () {
1066+ m .collapsed = ! m .collapsed
1067+ if ! m .collapsed && m .preferredWidth < MinWidth {
1068+ m .preferredWidth = DefaultWidth
1069+ }
1070+ }
1071+
1072+ // SetCollapsed sets the collapsed state directly.
1073+ // When expanding, if the preferred width is below minimum (e.g., after drag-to-collapse),
1074+ // it resets to the default width.
1075+ func (m * model ) SetCollapsed (collapsed bool ) {
1076+ m .collapsed = collapsed
1077+ if ! collapsed && m .preferredWidth < MinWidth {
1078+ m .preferredWidth = DefaultWidth
1079+ }
1080+ }
1081+
1082+ // GetPreferredWidth returns the user's preferred width
1083+ func (m * model ) GetPreferredWidth () int {
1084+ return m .preferredWidth
1085+ }
1086+
1087+ // SetPreferredWidth sets the user's preferred width
1088+ func (m * model ) SetPreferredWidth (width int ) {
1089+ m .preferredWidth = width
1090+ }
1091+
1092+ // ClampWidth ensures width is within valid bounds for the given window inner width
1093+ func (m * model ) ClampWidth (width , windowInnerWidth int ) int {
1094+ maxWidth := min (int (float64 (windowInnerWidth )* MaxWidthPercent ), windowInnerWidth - 20 )
1095+ return max (MinWidth , min (width , maxWidth ))
1096+ }
0 commit comments