From 278bf7df54be2c81f301a9209267b867e32bda12 Mon Sep 17 00:00:00 2001 From: Christopher Petito Date: Sat, 24 Jan 2026 18:06:53 +0100 Subject: [PATCH] More dynamic sidebar - Rename "horizontal" -> collapsed to be a bit more coherent with "sidebar" - Allows sidebar to be collapsed manually with alt-s or a click on >> - Makes collapsed sidebar view calculate width and height dynamically, to avoid breaking the TUI on very low width terminals, long titles, etc - Fixes rendering of usage in the collapsed sidebar view - Allows sidebar to be resizable by dragging its edge (works, but isn't visible atm, to be designed better to fit with the look of scrollbars) - Adds a small visual separator at the bottom of the collapsed sidebar Signed-off-by: Christopher Petito --- pkg/tui/components/messages/selection.go | 2 +- pkg/tui/components/sidebar/layout.go | 13 +- pkg/tui/components/sidebar/sidebar.go | 202 +++++++++++-- pkg/tui/dialog/model_picker.go | 5 +- pkg/tui/messages/messages.go | 1 + pkg/tui/page/chat/chat.go | 365 ++++++++++++++++------- pkg/tui/page/chat/input_handlers.go | 79 ++++- pkg/tui/styles/styles.go | 8 +- 8 files changed, 530 insertions(+), 145 deletions(-) diff --git a/pkg/tui/components/messages/selection.go b/pkg/tui/components/messages/selection.go index c62b321f6..a53793a06 100644 --- a/pkg/tui/components/messages/selection.go +++ b/pkg/tui/components/messages/selection.go @@ -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 diff --git a/pkg/tui/components/sidebar/layout.go b/pkg/tui/components/sidebar/layout.go index 458610d8d..da2d0cd3d 100644 --- a/pkg/tui/components/sidebar/layout.go +++ b/pkg/tui/components/sidebar/layout.go @@ -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. diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index c9ee159dc..01bb67c9c 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -32,7 +32,7 @@ type Mode int const ( ModeVertical Mode = iota - ModeHorizontal + ModeCollapsed ) // Model represents a sidebar component @@ -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 @@ -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. @@ -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) @@ -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 } @@ -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 @@ -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 @@ -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 @@ -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 { @@ -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)) +} diff --git a/pkg/tui/dialog/model_picker.go b/pkg/tui/dialog/model_picker.go index 595af8d4a..e595c1662 100644 --- a/pkg/tui/dialog/model_picker.go +++ b/pkg/tui/dialog/model_picker.go @@ -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 @@ -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 diff --git a/pkg/tui/messages/messages.go b/pkg/tui/messages/messages.go index d552d7b90..b6a38129d 100644 --- a/pkg/tui/messages/messages.go +++ b/pkg/tui/messages/messages.go @@ -19,6 +19,7 @@ type ( ToggleYoloMsg struct{} ToggleThinkingMsg struct{} ToggleHideToolResultsMsg struct{} + ToggleSidebarMsg struct{} // Toggle sidebar visibility StartShellMsg struct{} SwitchAgentMsg struct{ AgentName string } OpenSessionBrowserMsg struct{} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 81eeaedc8..7258bffcb 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -38,13 +38,63 @@ const ( PanelChat FocusedPanel = "chat" PanelEditor FocusedPanel = "editor" - sidebarWidth = 40 - // Hide sidebar if window width is less than this + // minWindowWidth is the threshold below which sidebar switches to horizontal mode minWindowWidth = 120 - // Width of the draggable center portion of the resize handle + // resizeHandleWidth is the width of the draggable center portion of the resize handle resizeHandleWidth = 8 + // dragThreshold is pixels of movement needed to distinguish click from drag + dragThreshold = 3 + // toggleColumnWidth is the width of the sidebar toggle/resize handle column + toggleColumnWidth = 1 + // appPaddingHorizontal is total horizontal padding from AppStyle (left + right) + appPaddingHorizontal = 2 + // reservedVerticalLines accounts for resize handle (1) + status bar spacing (1) + reservedVerticalLines = 2 ) +// sidebarLayoutMode represents how the sidebar is displayed +type sidebarLayoutMode int + +const ( + // sidebarVertical: wide window, sidebar on right side + sidebarVertical sidebarLayoutMode = iota + // sidebarCollapsed: wide window but user collapsed sidebar, shown at top with toggle + sidebarCollapsed + // sidebarCollapsedNarrow: narrow window, shown at top without toggle + sidebarCollapsedNarrow +) + +// sidebarLayout holds computed layout values for the current frame. +// Computing this once per update avoids repeating calculations across View, SetSize, and input handlers. +type sidebarLayout struct { + mode sidebarLayoutMode + innerWidth int // window width minus app padding + chatWidth int // width available for chat/messages + sidebarWidth int // actual sidebar width (varies by mode) + sidebarStartX int // X coordinate where sidebar content starts (relative to innerWidth) + handleX int // X coordinate of resize handle column (only valid in vertical mode) + chatHeight int // height available for chat area + sidebarHeight int // height of sidebar +} + +// isOnHandle returns true if adjustedX (already adjusted for app padding) is on the resize handle. +func (l sidebarLayout) isOnHandle(adjustedX int) bool { + return l.mode == sidebarVertical && adjustedX == l.handleX +} + +// isInSidebar returns true if adjustedX is within the sidebar area. +func (l sidebarLayout) isInSidebar(adjustedX int) bool { + if l.mode != sidebarVertical { + return false + } + return adjustedX >= l.sidebarStartX +} + +// showToggle returns true if a toggle glyph should be shown. +func (l sidebarLayout) showToggle() bool { + return l.mode == sidebarVertical || l.mode == sidebarCollapsed +} + // EditorHeightChangedMsg is emitted when the editor height changes (e.g., during resize) type EditorHeightChangedMsg struct { Height int @@ -130,6 +180,56 @@ type chatPage struct { isDragging bool isHoveringHandle bool editorLines int + + // Sidebar drag state + isDraggingSidebar bool // True while dragging the sidebar resize handle + sidebarDragStartX int // X position when drag started + sidebarDragStartWidth int // Sidebar preferred width when drag started + sidebarDragMoved bool // True if mouse moved beyond threshold during drag +} + +// computeSidebarLayout calculates the layout based on current state. +func (p *chatPage) computeSidebarLayout() sidebarLayout { + innerWidth := p.width - appPaddingHorizontal + + var mode sidebarLayoutMode + switch { + case p.width >= minWindowWidth && !p.sidebar.IsCollapsed(): + mode = sidebarVertical + case p.width >= minWindowWidth: + mode = sidebarCollapsed + default: + mode = sidebarCollapsedNarrow + } + + l := sidebarLayout{ + mode: mode, + innerWidth: innerWidth, + } + + switch mode { + case sidebarVertical: + l.sidebarWidth = p.sidebar.ClampWidth(p.sidebar.GetPreferredWidth(), innerWidth) + l.chatWidth = max(1, innerWidth-l.sidebarWidth) + l.handleX = l.chatWidth + l.sidebarStartX = l.chatWidth + toggleColumnWidth + l.chatHeight = max(1, p.height-p.inputHeight-reservedVerticalLines) + l.sidebarHeight = l.chatHeight + + case sidebarCollapsed: + l.sidebarWidth = innerWidth - toggleColumnWidth + l.chatWidth = innerWidth + l.sidebarHeight = p.sidebar.CollapsedHeight(l.sidebarWidth) + l.chatHeight = max(1, p.height-p.inputHeight-l.sidebarHeight-reservedVerticalLines) + + case sidebarCollapsedNarrow: + l.sidebarWidth = innerWidth + l.chatWidth = innerWidth + l.sidebarHeight = p.sidebar.CollapsedHeight(l.sidebarWidth) + l.chatHeight = max(1, p.height-p.inputHeight-l.sidebarHeight-reservedVerticalLines) + } + + return l } // KeyMap defines key bindings for the chat page @@ -140,6 +240,7 @@ type KeyMap struct { CtrlJ key.Binding ExternalEditor key.Binding ToggleSplitDiff key.Binding + ToggleSidebar key.Binding } // getEditorDisplayNameFromEnv returns a friendly display name for the configured editor. @@ -237,6 +338,10 @@ func defaultKeyMap() KeyMap { key.WithKeys("ctrl+t"), key.WithHelp("Ctrl+t", "toggle split diff mode"), ), + ToggleSidebar: key.NewBinding( + key.WithKeys("alt+s"), + key.WithHelp("Alt+s", "toggle sidebar"), + ), } } @@ -248,17 +353,16 @@ func New(a *app.App, sessionState *service.SessionState) Page { } p := &chatPage{ - sidebar: sidebar.New(sessionState), - messages: messages.New(sessionState), - editor: editor.New(a, historyStore), - spinner: spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle), - pendingSpinner: spinner.New(spinner.ModeBoth, styles.SpinnerDotsAccentStyle), - focusedPanel: PanelEditor, - app: a, - keyMap: defaultKeyMap(), - history: historyStore, - sessionState: sessionState, - // Default to no keyboard enhancements (will be updated if msg is received) + sidebar: sidebar.New(sessionState), + messages: messages.New(sessionState), + editor: editor.New(a, historyStore), + spinner: spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle), + pendingSpinner: spinner.New(spinner.ModeBoth, styles.SpinnerDotsAccentStyle), + focusedPanel: PanelEditor, + app: a, + keyMap: defaultKeyMap(), + history: historyStore, + sessionState: sessionState, keyboardEnhancementsSupported: false, editorLines: 3, } @@ -431,20 +535,41 @@ func (p *chatPage) setPendingResponse(pending bool) tea.Cmd { return nil } +// renderCollapsedSidebar renders the sidebar in collapsed mode (at top of screen). +func (p *chatPage) renderCollapsedSidebar(sl sidebarLayout) string { + sidebarView := p.sidebar.View() + sidebarLines := strings.Split(sidebarView, "\n") + + // Add toggle glyph at right edge of first line if in collapsed mode on wide terminal + if sl.showToggle() && sl.mode != sidebarVertical && len(sidebarLines) > 0 { + toggleGlyph := styles.MutedStyle.Render("«") + sidebarLines[0] += toggleGlyph + } + + // Replace the last line with a subtle divider + divider := styles.FadingStyle.Render(strings.Repeat("─", sl.innerWidth)) + if len(sidebarLines) >= sl.sidebarHeight { + sidebarLines[sl.sidebarHeight-1] = divider + } else { + sidebarLines = append(sidebarLines, divider) + } + + sidebarWithDivider := strings.Join(sidebarLines, "\n") + + return lipgloss.NewStyle(). + Width(sl.innerWidth). + Height(sl.sidebarHeight). + Align(lipgloss.Left, lipgloss.Top). + Render(sidebarWithDivider) +} + // View renders the chat page func (p *chatPage) View() string { - // Main chat content area (without input) - // Account for app padding to match SetSize calculations - // AppStyle adds 2 characters of padding (left=1, right=1) - innerWidth := p.width - 2 // subtract left/right padding - - var bodyContent string + sl := p.computeSidebarLayout() // Build messages view with optional pending response spinner messagesView := p.messages.View() if p.pendingResponse { - // Append pending spinner below the messages (outside the message list) - // Uses pendingSpinner which has ModeBoth with funny phrases to match original style pendingIndicator := p.pendingSpinner.View() if messagesView != "" { messagesView = messagesView + "\n\n" + pendingIndicator @@ -453,66 +578,69 @@ func (p *chatPage) View() string { } } - if p.width >= minWindowWidth { - // Ensure we don't exceed available space - chatWidth := max(1, innerWidth-sidebarWidth) + var bodyContent string + switch sl.mode { + case sidebarVertical: chatView := styles.ChatStyle. - Height(p.chatHeight). - Width(chatWidth). + Height(sl.chatHeight). + Width(sl.chatWidth). Render(messagesView) + toggleCol := p.renderSidebarHandle(sl.chatHeight) + sidebarView := lipgloss.NewStyle(). - Width(sidebarWidth). - Height(p.chatHeight). + Width(sl.sidebarWidth-toggleColumnWidth). + Height(sl.chatHeight). Align(lipgloss.Left, lipgloss.Top). Render(p.sidebar.View()) - bodyContent = lipgloss.JoinHorizontal( - lipgloss.Left, - chatView, - sidebarView, - ) - } else { - sidebarWidth, sidebarHeight := p.sidebar.GetSize() + bodyContent = lipgloss.JoinHorizontal(lipgloss.Left, chatView, toggleCol, sidebarView) + + case sidebarCollapsed, sidebarCollapsedNarrow: + sidebarRendered := p.renderCollapsedSidebar(sl) chatView := styles.ChatStyle. - Height(p.chatHeight). - Width(innerWidth). + Height(sl.chatHeight). + Width(sl.innerWidth). Render(messagesView) - sidebarView := lipgloss.NewStyle(). - Width(sidebarWidth). - Height(sidebarHeight). - Align(lipgloss.Left, lipgloss.Top). - Render(p.sidebar.View()) - - bodyContent = lipgloss.JoinVertical( - lipgloss.Top, - sidebarView, - chatView, - ) + bodyContent = lipgloss.JoinVertical(lipgloss.Top, sidebarRendered, chatView) } - // Resize handle between messages and editor - resizeHandle := p.renderResizeHandle(innerWidth) - - // Input field spans full width below everything + resizeHandle := p.renderResizeHandle(sl.innerWidth) input := p.editor.View() - // Create a full-height layout with header, body, resize handle, and input - content := lipgloss.JoinVertical( - lipgloss.Left, - bodyContent, - resizeHandle, - input, - ) + content := lipgloss.JoinVertical(lipgloss.Left, bodyContent, resizeHandle, input) return styles.AppStyle. Height(p.height). Render(content) } +// renderSidebarHandle renders the sidebar toggle/resize handle. +// When collapsed: shows just « at top. +// When expanded: shows » at top, rest is empty space (draggable for resize). +func (p *chatPage) renderSidebarHandle(height int) string { + lines := make([]string, height) + + if p.sidebar.IsCollapsed() { + // Collapsed: just the toggle glyph, no vertical line + lines[0] = styles.MutedStyle.Render("«") + for i := 1; i < height; i++ { + lines[i] = " " + } + } else { + // Expanded: just the toggle at top, rest is empty space (still draggable) + lines[0] = styles.MutedStyle.Render("»") + for i := 1; i < height; i++ { + lines[i] = " " + } + } + + return strings.Join(lines, "\n") +} + func (p *chatPage) SetSize(width, height int) tea.Cmd { p.width = width p.height = height @@ -520,13 +648,11 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd { var cmds []tea.Cmd // Calculate heights accounting for padding - // Clamp editor lines between 4 (min) and half screen (max) minLines := 4 - maxLines := max(minLines, (height-6)/2) // Leave room for messages + maxLines := max(minLines, (height-6)/2) p.editorLines = max(minLines, min(p.editorLines, maxLines)) - // Account for horizontal padding in width - innerWidth := width - 2 // subtract left/right padding + innerWidth := width - appPaddingHorizontal targetEditorHeight := p.editorLines - 1 editorCmd := p.editor.SetSize(innerWidth, targetEditorHeight) @@ -535,36 +661,30 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd { _, actualEditorHeight := p.editor.GetSize() p.inputHeight = actualEditorHeight - // Emit height change message so completion popup can adjust position cmds = append(cmds, core.CmdHandler(EditorHeightChangedMsg{Height: actualEditorHeight})) - var mainWidth int - if width >= minWindowWidth { - // Ensure we don't exceed available space after accounting for sidebar - mainWidth = max(1, innerWidth-sidebarWidth) - p.chatHeight = max(1, height-actualEditorHeight-2) // -1 for resize handle, -1 for empty line before status bar + // Compute layout once and use it for all sizing + sl := p.computeSidebarLayout() + p.chatHeight = sl.chatHeight + + switch sl.mode { + case sidebarVertical: p.sidebar.SetMode(sidebar.ModeVertical) cmds = append(cmds, - p.sidebar.SetSize(sidebarWidth, p.chatHeight), - p.sidebar.SetPosition(styles.AppPaddingLeft+mainWidth, 0), + p.sidebar.SetSize(sl.sidebarWidth-toggleColumnWidth, sl.chatHeight), + p.sidebar.SetPosition(styles.AppPaddingLeft+sl.sidebarStartX, 0), p.messages.SetPosition(0, 0), ) - } else { - const horizontalSidebarHeight = 3 - mainWidth = max(innerWidth, 1) - p.chatHeight = max(1, height-actualEditorHeight-horizontalSidebarHeight-2) // -1 for resize handle, -1 for empty line before status bar - p.sidebar.SetMode(sidebar.ModeHorizontal) + case sidebarCollapsed, sidebarCollapsedNarrow: + p.sidebar.SetMode(sidebar.ModeCollapsed) cmds = append(cmds, - p.sidebar.SetSize(innerWidth, horizontalSidebarHeight), + p.sidebar.SetSize(sl.sidebarWidth, sl.sidebarHeight), p.sidebar.SetPosition(styles.AppPaddingLeft, 0), - p.messages.SetPosition(0, horizontalSidebarHeight), + p.messages.SetPosition(0, sl.sidebarHeight), ) } - // Set component sizes - cmds = append(cmds, - p.messages.SetSize(mainWidth, p.chatHeight), - ) + cmds = append(cmds, p.messages.SetSize(sl.chatWidth, sl.chatHeight)) return tea.Batch(cmds...) } @@ -815,39 +935,31 @@ func (p *chatPage) SetSessionStarred(starred bool) { p.sidebar.SetSessionStarred(starred) } -// handleSidebarClick checks if a click in the sidebar area should toggle the star -// Returns true if the click was handled (star was toggled) +// handleSidebarClick checks if a click in the sidebar area should toggle the star. +// Returns true if the click was handled. func (p *chatPage) handleSidebarClick(x, y int) bool { - // Account for AppStyle padding (left padding = 1) adjustedX := x - styles.AppPaddingLeft + sl := p.computeSidebarLayout() - if p.width < minWindowWidth { - // Horizontal mode - sidebar is at the top (y=0 to sidebarHeight) - // The sidebar view is rendered directly without additional offsets + switch sl.mode { + case sidebarCollapsedNarrow, sidebarCollapsed: return p.sidebar.HandleClick(adjustedX, y) + case sidebarVertical: + if sl.isInSidebar(adjustedX) { + return p.sidebar.HandleClick(adjustedX-sl.sidebarStartX, y) + } } - // Vertical mode - sidebar is on the right side - innerWidth := p.width - 2 // subtract left/right padding from AppStyle - chatWidth := max(1, innerWidth-sidebarWidth) - - // Check if click is in the sidebar area (right side) - // Sidebar now owns its own left padding via layoutCfg - if adjustedX >= chatWidth { - // Calculate x relative to sidebar's outer boundary - sidebarX := adjustedX - chatWidth - return p.sidebar.HandleClick(sidebarX, y) - } return false } -// routeMouseEvent routes mouse events to editor (bottom), sidebar (right), or messages (top-left) based on coordinates. +// routeMouseEvent routes mouse events to the appropriate component based on coordinates. func (p *chatPage) routeMouseEvent(msg tea.Msg, y int) tea.Cmd { editorTop := p.height - p.inputHeight if y < editorTop { - // Check if event is in sidebar area (vertical mode only) - if p.width >= minWindowWidth { - // Get x coordinate from the message + sl := p.computeSidebarLayout() + + if sl.mode == sidebarVertical && !p.sidebar.IsCollapsed() { var x int switch m := msg.(type) { case tea.MouseClickMsg: @@ -859,11 +971,7 @@ func (p *chatPage) routeMouseEvent(msg tea.Msg, y int) tea.Cmd { } adjustedX := x - styles.AppPaddingLeft - innerWidth := p.width - 2 - chatWidth := max(1, innerWidth-sidebarWidth) - - // Route to sidebar if in sidebar area - if adjustedX >= chatWidth { + if sl.isInSidebar(adjustedX) { model, cmd := p.sidebar.Update(msg) p.sidebar = model.(sidebar.Model) return cmd @@ -899,6 +1007,39 @@ func (p *chatPage) isOnResizeLine(y int) bool { return y == p.height-editorHeight-2 } +// isOnSidebarHandle checks if (x, y) is on the sidebar resize handle column. +func (p *chatPage) isOnSidebarHandle(x, y int) bool { + sl := p.computeSidebarLayout() + if sl.mode != sidebarVertical { + return false + } + if y < 0 || y >= sl.chatHeight { + return false + } + adjustedX := x - styles.AppPaddingLeft + return sl.isOnHandle(adjustedX) +} + +// isOnSidebarToggleGlyph checks if (x, y) is on the sidebar toggle glyph. +func (p *chatPage) isOnSidebarToggleGlyph(x, y int) bool { + sl := p.computeSidebarLayout() + if !sl.showToggle() { + return false + } + + if sl.mode == sidebarVertical { + // Toggle is at y=0 on the handle column + return y == 0 && p.isOnSidebarHandle(x, y) + } + + // Collapsed horizontal: toggle is at right edge of first line + if y != 0 { + return false + } + adjustedX := x - styles.AppPaddingLeft + return adjustedX == sl.innerWidth-toggleColumnWidth +} + // isOnResizeHandle checks if (x, y) is on the draggable center of the resize handle. func (p *chatPage) isOnResizeHandle(x, y int) bool { if !p.isOnResizeLine(y) { @@ -941,7 +1082,7 @@ func (p *chatPage) renderResizeHandle(width int) string { // Always center handle on full width fullLine := lipgloss.PlaceHorizontal( - max(0, width-2), lipgloss.Center, handle, + max(0, width-appPaddingHorizontal), lipgloss.Center, handle, lipgloss.WithWhitespaceChars("─"), lipgloss.WithWhitespaceStyle(styles.ResizeHandleStyle), ) @@ -956,7 +1097,7 @@ func (p *chatPage) renderResizeHandle(width int) string { cancelKeyPart := styles.HighlightWhiteStyle.Render(p.keyMap.Cancel.Help().Key) suffix += " (" + cancelKeyPart + " to interrupt)" suffixWidth := lipgloss.Width(suffix) - truncated := lipgloss.NewStyle().MaxWidth(width - 2 - suffixWidth).Render(fullLine) + truncated := lipgloss.NewStyle().MaxWidth(width - appPaddingHorizontal - suffixWidth).Render(fullLine) return truncated + suffix } @@ -965,7 +1106,7 @@ func (p *chatPage) renderResizeHandle(width int) string { queueText := fmt.Sprintf("%d queued", queueLen) suffix := " " + styles.WarningStyle.Render(queueText) + " " suffixWidth := lipgloss.Width(suffix) - truncated := lipgloss.NewStyle().MaxWidth(width - 2 - suffixWidth).Render(fullLine) + truncated := lipgloss.NewStyle().MaxWidth(width - appPaddingHorizontal - suffixWidth).Render(fullLine) return truncated + suffix } diff --git a/pkg/tui/page/chat/input_handlers.go b/pkg/tui/page/chat/input_handlers.go index 873987894..14cc969fa 100644 --- a/pkg/tui/page/chat/input_handlers.go +++ b/pkg/tui/page/chat/input_handlers.go @@ -39,6 +39,11 @@ func (p *chatPage) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd, b model, cmd := p.messages.Update(editfile.ToggleDiffViewMsg{}) p.messages = model.(messages.Model) return p, cmd, true + + case key.Matches(msg, p.keyMap.ToggleSidebar): + p.sidebar.ToggleCollapsed() + cmd := p.SetSize(p.width, p.height) + return p, cmd, true } // Route other keys to focused component @@ -62,15 +67,32 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm p.isDragging = true return p, nil } + + // Handle sidebar toggle glyph click + if msg.Button == tea.MouseLeft && p.isOnSidebarToggleGlyph(msg.X, msg.Y) { + p.sidebar.ToggleCollapsed() + cmd := p.SetSize(p.width, p.height) + return p, cmd + } + + // Handle sidebar resize handle click (starts potential drag) + if msg.Button == tea.MouseLeft && p.isOnSidebarHandle(msg.X, msg.Y) { + p.isDraggingSidebar = true + p.sidebarDragStartX = msg.X + p.sidebarDragStartWidth = p.sidebar.GetPreferredWidth() + p.sidebarDragMoved = false + return p, nil + } + // Check if click is on the star in sidebar if msg.Button == tea.MouseLeft && p.handleSidebarClick(msg.X, msg.Y) { - // Emit toggle message to persist the change sess := p.app.Session() if sess != nil { return p, core.CmdHandler(msgtypes.ToggleSessionStarMsg{SessionID: sess.ID}) } return p, nil } + cmd := p.routeMouseEvent(msg, msg.Y) return p, cmd } @@ -81,6 +103,17 @@ func (p *chatPage) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea. cmd := p.handleResize(msg.Y) return p, cmd } + if p.isDraggingSidebar { + delta := p.sidebarDragStartX - msg.X + if max(delta, -delta) >= dragThreshold { + p.sidebarDragMoved = true + } + if p.sidebarDragMoved { + cmd := p.handleSidebarResize(msg.X) + return p, cmd + } + return p, nil + } p.isHoveringHandle = p.isOnResizeLine(msg.Y) cmd := p.routeMouseEvent(msg, msg.Y) return p, cmd @@ -92,23 +125,57 @@ func (p *chatPage) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, te p.isDragging = false return p, nil } + if p.isDraggingSidebar { + p.isDraggingSidebar = false + return p, nil + } cmd := p.routeMouseEvent(msg, msg.Y) return p, cmd } // handleMouseWheel handles mouse wheel events. func (p *chatPage) handleMouseWheel(msg tea.MouseWheelMsg) (layout.Model, tea.Cmd) { - // Check if mouse is over the sidebar in vertical mode - if p.width >= minWindowWidth { + sl := p.computeSidebarLayout() + + if sl.mode == sidebarVertical && !p.sidebar.IsCollapsed() { adjustedX := msg.X - styles.AppPaddingLeft - innerWidth := p.width - 2 - chatWidth := max(1, innerWidth-sidebarWidth) - if adjustedX >= chatWidth { + if sl.isInSidebar(adjustedX) { model, cmd := p.sidebar.Update(msg) p.sidebar = model.(sidebar.Model) return p, cmd } } + cmd := p.routeMouseEvent(msg, msg.Y) return p, cmd } + +// handleSidebarResize adjusts sidebar width based on drag position. +func (p *chatPage) handleSidebarResize(x int) tea.Cmd { + innerWidth := p.width - appPaddingHorizontal + delta := p.sidebarDragStartX - x + newWidth := p.sidebarDragStartWidth + delta + + // Auto-collapse if dragged below minimum + if newWidth < sidebar.MinWidth { + if !p.sidebar.IsCollapsed() { + // Set preferredWidth to 0 so expanding resets to default + p.sidebar.SetPreferredWidth(0) + p.sidebar.SetCollapsed(true) + return p.SetSize(p.width, p.height) + } + return nil + } + + // Auto-expand if dragged back above minimum + if p.sidebar.IsCollapsed() { + p.sidebar.SetCollapsed(false) + } + + newWidth = p.sidebar.ClampWidth(newWidth, innerWidth) + if newWidth != p.sidebar.GetPreferredWidth() { + p.sidebar.SetPreferredWidth(newWidth) + return p.SetSize(p.width, p.height) + } + return nil +} diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index f7c732ff0..0bc7baaee 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -2,6 +2,7 @@ package styles import ( "strings" + "time" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" @@ -156,7 +157,12 @@ var ( ) // Base Styles -const AppPaddingLeft = 1 // Keep in sync with AppStyle padding +const ( + AppPaddingLeft = 1 // Keep in sync with AppStyle padding + + // DoubleClickThreshold is the maximum time between clicks to register as a double-click + DoubleClickThreshold = 400 * time.Millisecond +) var ( NoStyle = lipgloss.NewStyle()