Skip to content

Commit da3f222

Browse files
authored
Merge pull request #1493 from krissetto/more-dynamic-sidebar
More dynamic sidebar
2 parents e844873 + 278bf7d commit da3f222

8 files changed

Lines changed: 530 additions & 145 deletions

File tree

pkg/tui/components/messages/selection.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ func (s *selectionState) detectClickType(line, col int) int {
7474
now := time.Now()
7575
colDiff := col - s.lastClickCol
7676
isConsecutive := !s.lastClickTime.IsZero() &&
77-
now.Sub(s.lastClickTime) < 500*time.Millisecond &&
77+
now.Sub(s.lastClickTime) < styles.DoubleClickThreshold &&
7878
line == s.lastClickLine &&
7979
colDiff >= -1 && colDiff <= 1
8080

pkg/tui/components/sidebar/layout.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,17 @@ const (
1414
// Line 0: tab title, Line 1: TabStyle top padding, Line 2: star + title.
1515
verticalStarY = 2
1616

17-
// headerLines is the number of lines reserved for non-scrollable header content.
18-
headerLines = 1
17+
// minGap is the minimum gap between elements when laying out side-by-side.
18+
minGap = 2
19+
20+
// DefaultWidth is the default sidebar width in vertical mode.
21+
DefaultWidth = 40
22+
23+
// MinWidth is the minimum sidebar width before auto-collapsing.
24+
MinWidth = 20
25+
26+
// MaxWidthPercent is the maximum sidebar width as a percentage of window.
27+
MaxWidthPercent = 0.5
1928
)
2029

2130
// LayoutConfig defines the spacing and sizing parameters for the sidebar.

pkg/tui/components/sidebar/sidebar.go

Lines changed: 183 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type Mode int
3232

3333
const (
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
231248
func (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

513636
func (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 {
9301053
func (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+
}

pkg/tui/dialog/model_picker.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,6 @@ func (d *modelPickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
179179
return d, nil
180180
}
181181

182-
// doubleClickThreshold is the maximum time between clicks to count as a double-click
183-
const doubleClickThreshold = 400 * time.Millisecond
184-
185182
// handleMouseClick handles mouse click events on the dialog
186183
func (d *modelPickerDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) {
187184
// Check if click is on the scrollbar
@@ -197,7 +194,7 @@ func (d *modelPickerDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Mode
197194
now := time.Now()
198195

199196
// Check for double-click: same index within threshold
200-
if modelIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < doubleClickThreshold {
197+
if modelIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold {
201198
// Double-click: confirm selection
202199
d.selected = modelIdx
203200
d.lastClickTime = time.Time{} // Reset to prevent triple-click

pkg/tui/messages/messages.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type (
1919
ToggleYoloMsg struct{}
2020
ToggleThinkingMsg struct{}
2121
ToggleHideToolResultsMsg struct{}
22+
ToggleSidebarMsg struct{} // Toggle sidebar visibility
2223
StartShellMsg struct{}
2324
SwitchAgentMsg struct{ AgentName string }
2425
OpenSessionBrowserMsg struct{}

0 commit comments

Comments
 (0)