Skip to content
Open
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
2 changes: 1 addition & 1 deletion pkg/tui/components/messages/selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func (s *selectionState) detectClickType(line, col int) int {
now := time.Now()
colDiff := col - s.lastClickCol
isConsecutive := !s.lastClickTime.IsZero() &&
now.Sub(s.lastClickTime) < 500*time.Millisecond &&
now.Sub(s.lastClickTime) < styles.DoubleClickThreshold &&
line == s.lastClickLine &&
colDiff >= -1 && colDiff <= 1

Expand Down
13 changes: 11 additions & 2 deletions pkg/tui/components/sidebar/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,17 @@ const (
// Line 0: tab title, Line 1: TabStyle top padding, Line 2: star + title.
verticalStarY = 2

// headerLines is the number of lines reserved for non-scrollable header content.
headerLines = 1
// minGap is the minimum gap between elements when laying out side-by-side.
minGap = 2

// DefaultWidth is the default sidebar width in vertical mode.
DefaultWidth = 40

// MinWidth is the minimum sidebar width before auto-collapsing.
MinWidth = 20

// MaxWidthPercent is the maximum sidebar width as a percentage of window.
MaxWidthPercent = 0.5
)

// LayoutConfig defines the spacing and sizing parameters for the sidebar.
Expand Down
202 changes: 183 additions & 19 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type Mode int

const (
ModeVertical Mode = iota
ModeHorizontal
ModeCollapsed
)

// Model represents a sidebar component
Expand All @@ -54,6 +54,20 @@ type Model interface {
LoadFromSession(sess *session.Session)
// HandleClick checks if click is on the star and returns true if handled
HandleClick(x, y int) bool
// IsCollapsed returns whether the sidebar is collapsed
IsCollapsed() bool
// ToggleCollapsed toggles the collapsed state
ToggleCollapsed()
// SetCollapsed sets the collapsed state directly
SetCollapsed(collapsed bool)
// CollapsedHeight returns the number of lines needed for collapsed mode
CollapsedHeight(contentWidth int) int
// GetPreferredWidth returns the user's preferred width (for resize persistence)
GetPreferredWidth() int
// SetPreferredWidth sets the user's preferred width
SetPreferredWidth(width int)
// ClampWidth ensures width is within valid bounds for the given window width
ClampWidth(width, windowInnerWidth int) int
}

// ragIndexingState tracks per-strategy indexing progress
Expand Down Expand Up @@ -94,6 +108,8 @@ type model struct {
queuedMessages []string // Truncated preview of queued messages
streamCancelled bool // true after ESC cancel until next StreamStartedEvent
reasoningSupported bool // true if current model supports reasoning (default: true / fail-open)
collapsed bool // true when sidebar is collapsed
preferredWidth int // user's preferred width (persisted across collapse/expand)
}

// Option is a functional option for configuring the sidebar.
Expand All @@ -118,7 +134,8 @@ func New(sessionState *service.SessionState, opts ...Option) Model {
sessionState: sessionState,
scrollbar: scrollbar.New(),
workingDirectory: getCurrentWorkingDirectory(),
reasoningSupported: true, // Default to true (fail-open)
reasoningSupported: true,
preferredWidth: DefaultWidth,
}
for _, opt := range opts {
opt(m)
Expand Down Expand Up @@ -200,7 +217,7 @@ func (m *model) SetQueuedMessages(queuedMessages ...string) {
// 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 {
// Don't handle clicks if session has no content (star isn't shown)
// Don't handle star clicks if session has no content (star isn't shown)
if !m.sessionHasContent {
return false
}
Expand All @@ -213,8 +230,8 @@ func (m *model) HandleClick(x, y int) bool {
return false
}

if m.mode == ModeHorizontal {
// In horizontal mode, star is at the beginning of first line (y=0)
if m.mode == ModeCollapsed {
// In collapsed mode, star is at the beginning of first line (y=0)
return y == 0
}
// In vertical mode, star is below tab title and TabStyle padding
Expand Down Expand Up @@ -420,7 +437,7 @@ func (m *model) View() string {
if m.mode == ModeVertical {
content = m.verticalView()
} else {
content = m.horizontalView()
content = m.collapsedView()
}

// Apply horizontal padding
Expand All @@ -446,23 +463,129 @@ func (m *model) starIndicator() string {
return styles.StarIndicator(m.sessionStarred)
}

func (m *model) horizontalView() string {
// Compute content width (no scrollbar in horizontal mode)
contentWidth := m.contentWidth(false)
usageSummary := m.tokenUsageSummary()
// collapsedLayout holds the computed layout decisions for collapsed mode.
// Computing this once avoids duplicating the layout logic between CollapsedHeight and collapsedView.
type collapsedLayout struct {
titleWithStar string
workingIndicator string
workingDir string
usageSummary string

// Layout decisions
titleAndIndicatorOnOneLine bool
wdAndUsageOnOneLine bool
contentWidth int
}

func (m *model) computeCollapsedLayout(contentWidth int) collapsedLayout {
h := collapsedLayout{
titleWithStar: m.starIndicator() + m.sessionTitle,
workingIndicator: m.workingIndicatorCollapsed(),
workingDir: m.workingDirectory,
usageSummary: m.tokenUsageSummary(),
contentWidth: contentWidth,
}

titleWithStar := m.starIndicator() + m.sessionTitle
titleWidth := lipgloss.Width(h.titleWithStar)
wiWidth := lipgloss.Width(h.workingIndicator)
wdWidth := lipgloss.Width(h.workingDir)
usageWidth := lipgloss.Width(h.usageSummary)

wi := m.workingIndicatorHorizontal()
titleGapWidth := contentWidth - lipgloss.Width(titleWithStar) - lipgloss.Width(wi)
title := fmt.Sprintf("%s%*s%s", titleWithStar, titleGapWidth, "", wi)
// Title and indicator fit on one line if:
// - no working indicator AND title fits, OR
// - both fit together with gap
h.titleAndIndicatorOnOneLine = (h.workingIndicator == "" && titleWidth <= contentWidth) ||
(h.workingIndicator != "" && titleWidth+minGap+wiWidth <= contentWidth)
h.wdAndUsageOnOneLine = wdWidth+minGap+usageWidth <= contentWidth

gapWidth := contentWidth - lipgloss.Width(m.workingDirectory) - lipgloss.Width(usageSummary)
return lipgloss.JoinVertical(lipgloss.Top, title, fmt.Sprintf("%s%*s%s", styles.MutedStyle.Render(m.workingDirectory), gapWidth, "", usageSummary))
return h
}

func (h collapsedLayout) lineCount() int {
lines := 1 // divider

switch {
case h.titleAndIndicatorOnOneLine:
lines++
case h.workingIndicator == "":
// No working indicator but title wraps
lines += linesNeeded(lipgloss.Width(h.titleWithStar), h.contentWidth)
default:
// Title and working indicator on separate lines, each may wrap
lines += linesNeeded(lipgloss.Width(h.titleWithStar), h.contentWidth)
lines += linesNeeded(lipgloss.Width(h.workingIndicator), h.contentWidth)
}

if h.wdAndUsageOnOneLine {
lines++
} else {
lines += linesNeeded(lipgloss.Width(h.workingDir), h.contentWidth)
if h.usageSummary != "" {
lines += linesNeeded(lipgloss.Width(h.usageSummary), h.contentWidth)
}
}

return lines
}

func (h collapsedLayout) render() string {
var lines []string

// Title line(s)
switch {
case h.titleAndIndicatorOnOneLine:
if h.workingIndicator == "" {
lines = append(lines, h.titleWithStar)
} else {
gap := h.contentWidth - lipgloss.Width(h.titleWithStar) - lipgloss.Width(h.workingIndicator)
lines = append(lines, fmt.Sprintf("%s%*s%s", h.titleWithStar, gap, "", h.workingIndicator))
}
case h.workingIndicator == "":
// No working indicator but title wraps - just output title (lipgloss will wrap)
lines = append(lines, h.titleWithStar)
default:
// Title and working indicator on separate lines
lines = append(lines, h.titleWithStar, h.workingIndicator)
}

// Working directory + usage line(s)
if h.wdAndUsageOnOneLine {
gap := h.contentWidth - lipgloss.Width(h.workingDir) - lipgloss.Width(h.usageSummary)
lines = append(lines, fmt.Sprintf("%s%*s%s", styles.MutedStyle.Render(h.workingDir), gap, "", h.usageSummary))
} else {
lines = append(lines, styles.MutedStyle.Render(h.workingDir))
if h.usageSummary != "" {
lines = append(lines, h.usageSummary)
}
}

return lipgloss.JoinVertical(lipgloss.Top, lines...)
}

// linesNeeded calculates how many lines are needed to display text of given width
// within a container of contentWidth. Returns at least 1 line.
func linesNeeded(textWidth, contentWidth int) int {
if contentWidth <= 0 || textWidth <= 0 {
return 1
}
return max(1, (textWidth+contentWidth-1)/contentWidth)
}

// CollapsedHeight returns the number of lines needed for collapsed mode.
func (m *model) CollapsedHeight(outerWidth int) int {
contentWidth := outerWidth - m.layoutCfg.PaddingLeft - m.layoutCfg.PaddingRight
if contentWidth < 1 {
contentWidth = 1
}
return m.computeCollapsedLayout(contentWidth).lineCount()
}

func (m *model) collapsedView() string {
return m.computeCollapsedLayout(m.contentWidth(false)).render()
}

func (m *model) verticalView() string {
visibleLines := m.height - headerLines
visibleLines := m.height

// Two-pass rendering: first check if scrollbar is needed
// Pass 1: render without scrollbar to count lines
Expand Down Expand Up @@ -590,8 +713,8 @@ func (m *model) workingIndicator() string {
return strings.Join(indicators, "\n")
}

// workingIndicatorHorizontal returns a single-line version of the working indicator for horizontal mode
func (m *model) workingIndicatorHorizontal() string {
// workingIndicatorCollapsed returns a single-line version of the working indicator for collapsed mode
func (m *model) workingIndicatorCollapsed() string {
var labels []string

if m.mcpInit {
Expand Down Expand Up @@ -881,3 +1004,44 @@ func (m *model) metrics(scrollbarVisible bool) Metrics {
func (m *model) contentWidth(scrollbarVisible bool) int {
return m.metrics(scrollbarVisible).ContentWidth
}

// IsCollapsed returns whether the sidebar is collapsed
func (m *model) IsCollapsed() bool {
return m.collapsed
}

// ToggleCollapsed toggles the collapsed state of the sidebar.
// When expanding, if the preferred width is below minimum (e.g., after drag-to-collapse),
// it resets to the default width.
func (m *model) ToggleCollapsed() {
m.collapsed = !m.collapsed
if !m.collapsed && m.preferredWidth < MinWidth {
m.preferredWidth = DefaultWidth
}
}

// SetCollapsed sets the collapsed state directly.
// When expanding, if the preferred width is below minimum (e.g., after drag-to-collapse),
// it resets to the default width.
func (m *model) SetCollapsed(collapsed bool) {
m.collapsed = collapsed
if !collapsed && m.preferredWidth < MinWidth {
m.preferredWidth = DefaultWidth
}
}

// GetPreferredWidth returns the user's preferred width
func (m *model) GetPreferredWidth() int {
return m.preferredWidth
}

// SetPreferredWidth sets the user's preferred width
func (m *model) SetPreferredWidth(width int) {
m.preferredWidth = width
}

// ClampWidth ensures width is within valid bounds for the given window inner width
func (m *model) ClampWidth(width, windowInnerWidth int) int {
maxWidth := min(int(float64(windowInnerWidth)*MaxWidthPercent), windowInnerWidth-20)
return max(MinWidth, min(width, maxWidth))
}
5 changes: 1 addition & 4 deletions pkg/tui/dialog/model_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,6 @@ func (d *modelPickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
return d, nil
}

// doubleClickThreshold is the maximum time between clicks to count as a double-click
const doubleClickThreshold = 400 * time.Millisecond

// handleMouseClick handles mouse click events on the dialog
func (d *modelPickerDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) {
// Check if click is on the scrollbar
Expand All @@ -197,7 +194,7 @@ func (d *modelPickerDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Mode
now := time.Now()

// Check for double-click: same index within threshold
if modelIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < doubleClickThreshold {
if modelIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold {
// Double-click: confirm selection
d.selected = modelIdx
d.lastClickTime = time.Time{} // Reset to prevent triple-click
Expand Down
1 change: 1 addition & 0 deletions pkg/tui/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type (
ToggleYoloMsg struct{}
ToggleThinkingMsg struct{}
ToggleHideToolResultsMsg struct{}
ToggleSidebarMsg struct{} // Toggle sidebar visibility
StartShellMsg struct{}
SwitchAgentMsg struct{ AgentName string }
OpenSessionBrowserMsg struct{}
Expand Down
Loading
Loading