From 9f125f3400072c5ae1a7577880856396ce2b350c Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sun, 7 Jun 2026 17:45:06 +0300 Subject: [PATCH] refactor(ui): standardize all popups on shared PopupList - Extend PopupList with FullScreen mode, RenderItem callback, and external-state setters (SetItems/SetCursor/SetSearch) so any popup can reuse the same chrome (border, title, search, scroll, footer). - Rewrite TreeSelector and SessionSelector as thin PopupList wrappers, dropping ~500 lines of duplicated rendering. Selector-specific keys (filter cycle, scope/named toggles, delete-confirm) are pre-handled; everything else delegates to PopupList. - Migrate the / and @ autocomplete popups in InputComponent to render through PopupList, replacing the bespoke renderer. - Fix /tree and /fork overflow with deep trees: measure tree-art prefix width via lipgloss.Width (handles multi-byte box drawing), truncate the prefix from the left with an ellipsis when it would push text off the row, and collapse multi-line message content to a single line so rows never wrap. - Fix broken selection highlight in /tree, /fork, /sessions: emit a plain string from RenderItem for the cursor row so the outer row style paints one continuous fg+bg span instead of being shredded by mid-row ANSI resets from inner Render calls. - Center the cursor in the visible window so context is always shown above and below the selection. --- internal/ui/input.go | 212 +++----------- internal/ui/popup_list.go | 228 ++++++++++++--- internal/ui/session_selector.go | 435 +++++++++------------------- internal/ui/tree_selector.go | 498 ++++++++++++-------------------- 4 files changed, 531 insertions(+), 842 deletions(-) diff --git a/internal/ui/input.go b/internal/ui/input.go index 2f6c982a..a182e149 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -44,6 +44,12 @@ type InputComponent struct { popupHeight int submitNext bool // defer submit one tick so popup dismisses cleanly + // popup is the shared PopupList used to render the / and @ autocomplete + // dropdowns. State (items, cursor, visible search-driven filter) is + // driven externally by InputComponent — we only use PopupList for the + // rendering chrome so all popups in the app look identical. + popup *PopupList + // Argument completion state. When the user types "/cmd " followed by // a partial argument and the command has a Complete function, the popup // switches to argument-completion mode showing suggestions from Complete. @@ -170,7 +176,7 @@ func NewInputComponent(width int, appCtrl AppController) *InputComponent { styles.Focused.CursorLine = lipgloss.NewStyle() ta.SetStyles(styles) - return &InputComponent{ + ic := &InputComponent{ textarea: ta, commands: commands.SlashCommands, width: width, @@ -178,6 +184,12 @@ func NewInputComponent(width int, appCtrl AppController) *InputComponent { appCtrl: appCtrl, hideHint: true, } + ic.popup = NewPopupList("", nil, width, 0) + ic.popup.ShowSearch = false + ic.popup.HideCount = true + ic.popup.MaxVisible = ic.popupHeight + ic.popup.FooterHint = "↑↓ navigate • tab complete • ↵ select • esc dismiss" + return ic } // SetCwd sets the working directory used for @file autocomplete suggestions @@ -700,191 +712,37 @@ func (s *InputComponent) View() tea.View { return tea.NewView(containerStyle.Render(view.String())) } -// renderPopup renders the autocomplete popup for slash command suggestions. -// When rendered inline (not centered), returns the styled popup content. -// RenderPopupCentered renders the popup as a centered overlay. +// RenderPopupCentered renders the autocomplete popup for / or @ as a +// centered overlay. Returns "" when the popup is not currently shown. +// The actual filtering / selection state lives on InputComponent — this +// method merely converts the filtered FuzzyMatch list into PopupItems +// and asks the shared PopupList to draw it. As a result the / popup, the +// @ popup, the model picker, the tree selector and the session selector +// all share identical chrome. func (s *InputComponent) RenderPopupCentered(termWidth, termHeight int) string { if !s.showPopup || len(s.filtered) == 0 { return "" } - popupContent := s.renderPopupWithOptions(true) - - // Center popup using lipgloss.Place - positioned := lipgloss.Place( - termWidth, - termHeight, - lipgloss.Center, - lipgloss.Center, - popupContent, - ) - - return positioned -} - -// renderPopupWithOptions renders the popup content with optional center styling. -func (s *InputComponent) renderPopupWithOptions(centered bool) string { - theme := style.GetTheme() - popupWidth := max(s.width-4, 20) - - // Use the theme background for the popup - the full-width item backgrounds - // and primary-colored selection will provide sufficient contrast - popupBg := theme.Background - - popupStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(theme.Primary). - Background(popupBg). - Padding(1, 2). - Width(popupWidth). - MarginLeft(0). - MarginBottom(1) // Visual depth/shadow effect - - // Inner content width: popup minus border (2) and horizontal padding (4). - innerWidth := max(popupWidth-6, 10) - - // Item background styles for high contrast - normalItemBg := lipgloss.NewStyle(). - Background(popupBg). - Foreground(theme.Text). - Width(innerWidth). - Padding(0, 1) - - selectedItemBg := lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(theme.Background). - Width(innerWidth). - Padding(0, 1). - Bold(true) - - var items []string - - visibleItems := min(len(s.filtered), s.popupHeight) - startIdx := 0 - if s.selected >= s.popupHeight { - startIdx = s.selected - s.popupHeight + 1 - } - endIdx := min(startIdx+visibleItems, len(s.filtered)) - - for i := startIdx; i < endIdx; i++ { - match := s.filtered[i] - sc := match.Command - - // Choose the appropriate background style - itemStyle := normalItemBg - if i == s.selected { - itemStyle = selectedItemBg + items := make([]PopupItem, len(s.filtered)) + for i, m := range s.filtered { + desc := "" + if m.Command != nil { + desc = m.Command.Description } - - // Build indicator with proper coloring - var indicator string - if i == s.selected { - indicator = "> " - } else { - indicator = " " + name := "" + if m.Command != nil { + name = m.Command.Name } - - // Build content with name and description - var content string - if s.fileMode { - // File mode: use full width for the path, show description inline - maxNameLen := max(innerWidth-16, 8) - displayName := sc.Name - if len(displayName) > maxNameLen && maxNameLen > 3 { - displayName = displayName[:maxNameLen-3] + "..." - } - - if sc.Description != "" && innerWidth > 30 { - content = indicator + displayName + " " + sc.Description - } else { - content = indicator + displayName - } - } else { - // Line layout: indicator(2) + name(nameWidth-2 visual) + desc - if innerWidth < 20 { - // Very narrow: show truncated name only - displayName := sc.Name - maxName := max(innerWidth-2, 3) - if len(displayName) > maxName { - displayName = displayName[:maxName-1] + "…" - } - content = indicator + displayName - } else { - // Compute nameWidth from the longest command name in the - // visible slice so we never truncate unnecessarily. - nameWidth := 0 - for _, fm := range s.filtered { - if n := len([]rune(fm.Command.Name)); n > nameWidth { - nameWidth = n - } - } - nameWidth += 3 // account for indicator prefix (2) + gap before description (1) - // Ensure descriptions still get at least 20 chars when possible. - maxForName := innerWidth - 20 - if maxForName < 8 { - maxForName = innerWidth * 2 / 3 - } - if nameWidth > maxForName { - nameWidth = maxForName - } - if nameWidth < 8 { - nameWidth = 8 - } - maxNameChars := nameWidth - 2 - displayName := sc.Name - if len(displayName) > maxNameChars { - displayName = displayName[:maxNameChars-1] + "…" - } - - // Description gets remaining space - maxDescLen := max(innerWidth-nameWidth, 0) - desc := sc.Description - if maxDescLen >= 4 && desc != "" { - if len(desc) > maxDescLen { - desc = desc[:maxDescLen-3] + "..." - } - content = indicator + lipgloss.NewStyle().Width(maxNameChars).Render(displayName) + desc - } else { - content = indicator + displayName - } - } + items[i] = PopupItem{ + Label: name, + Description: desc, } - - items = append(items, itemStyle.Render(content)) } - - // Add scroll indicators with background - scrollStyle := lipgloss.NewStyle(). - Background(popupBg). - Foreground(theme.VeryMuted). - Width(innerWidth). - Padding(0, 1) - - if startIdx > 0 { - items = append([]string{scrollStyle.Render(" ↑ more above")}, items...) - } - if endIdx < len(s.filtered) { - items = append(items, scrollStyle.Render(" ↓ more below")) - } - - content := strings.Join(items, "\n") - - // Adapt footer text to available width with background - var footerText string - if innerWidth >= 50 { - footerText = "↑↓ navigate • tab complete • ↵ select • esc dismiss" - } else if innerWidth >= 30 { - footerText = "↑↓ nav • tab • ↵ select • esc" - } else { - footerText = "↑↓ tab ↵ esc" - } - footer := lipgloss.NewStyle(). - Background(popupBg). - Foreground(theme.VeryMuted). - Italic(true). - Render(footerText) - - return popupStyle.Render(content + "\n\n" + footer) + s.popup.SetSize(termWidth, termHeight) + s.popup.SetItems(items) + s.popup.SetCursor(s.selected) + return s.popup.RenderCentered(termWidth, termHeight) } // completeArgs checks whether the input line matches a command with a Complete diff --git a/internal/ui/popup_list.go b/internal/ui/popup_list.go index 99157249..1ead7bb3 100644 --- a/internal/ui/popup_list.go +++ b/internal/ui/popup_list.go @@ -20,17 +20,23 @@ type PopupItem struct { Meta any // opaque data returned on selection } -// PopupList is a generic, themed, scrollable fuzzy-find popup list. It is -// rendered as a centered overlay on top of the normal TUI layout and can be -// reused by any feature that needs a selection popup (slash commands, model -// selector, session picker, extension-provided lists, etc.). +// PopupList is a generic, themed, scrollable popup list used by every +// list-style popup in the TUI (slash commands, @file autocomplete, model +// picker, session picker, tree navigation, etc.). // -// The caller is responsible for: -// - Building the initial item list -// - Providing a fuzzy-filter callback (or nil for substring matching) -// - Handling the result when the user selects or cancels +// Two layout modes: +// - Centered (default): bordered ~80-col box centered on the screen. Used +// for the input-bar popups (/ and @) and the model picker. +// - FullScreen: bordered panel filling almost the entire terminal. Used by +// /tree, /fork, /sessions and other browse-many-items popups. // -// Navigation: up/down to move, enter to select, esc to cancel, type to filter. +// Two usage modes: +// - Internal state: caller creates the list with items, calls HandleKey for +// navigation/search, and PopupList owns the cursor and search string. +// Used by selectors like ModelSelector, TreeSelector, SessionSelector. +// - External state: caller drives the items / cursor / search themselves +// (e.g. InputComponent, where typing in the textarea filters the list). +// Caller uses SetItems / SetCursor / SetSearch and only calls Render. type PopupList struct { // Title shown at the top of the popup. Title string @@ -38,20 +44,45 @@ type PopupList struct { Subtitle string // FooterHint overrides the default keyboard-hint footer. FooterHint string + // ExtraFooter is appended to the footer line (after the default hint). + // Used by selectors to surface mode info like the active filter. + ExtraFooter string - allItems []PopupItem // full unfiltered list - filtered []PopupItem // subset matching the current search - cursor int - search string + // FullScreen renders the popup at almost the full terminal size instead + // of a centered ~80-col box. Used by tree/session/fork selectors. + FullScreen bool + + // ShowSearch toggles the "> " search input line. Default true. + ShowSearch bool + + // HideCount suppresses the "(i/N)" count in the footer. + HideCount bool + + // MaxVisible caps the number of items visible at once. 0 = derive from + // available height. + MaxVisible int + + // RenderItem optionally renders a single item row. When nil, the + // built-in label + description + active-checkmark renderer is used. + // innerWidth is the usable line width inside the popup (after border + // and padding). The returned string must already be styled — the + // shared selection-row background is applied by the popup only when + // RenderItem is nil. + RenderItem func(item PopupItem, innerWidth int, isCursor bool) string // FilterFunc is called with (query, allItems) and should return the - // filtered+scored subset. When nil, a default substring match is used. + // filtered+scored subset. When nil, a default substring + fuzzy match + // is used. Only consulted in internal-state mode (via HandleKey). FilterFunc func(query string, items []PopupItem) []PopupItem - width int - height int - maxVisible int // max items visible at once (0 = auto from height) - showSearch bool + allItems []PopupItem // full unfiltered list (internal-state mode) + filtered []PopupItem // items currently rendered (driven by FilterFunc + // in internal-state mode, or set directly via SetItems in external mode) + cursor int + search string + + width int + height int } // PopupResult is returned by HandleKey to tell the caller what happened. @@ -72,7 +103,7 @@ func NewPopupList(title string, items []PopupItem, width, height int) *PopupList filtered: items, width: width, height: height, - showSearch: true, + ShowSearch: true, } // Position cursor on the active item if one exists. for i, item := range p.filtered { @@ -90,25 +121,102 @@ func (p *PopupList) SetSize(width, height int) { p.height = height } +// SetItems replaces the displayed item list and clamps the cursor. Used by +// external-state callers (e.g. InputComponent) that filter items themselves. +// In internal-state mode, this also replaces the unfiltered backing list. +func (p *PopupList) SetItems(items []PopupItem) { + p.allItems = items + p.filtered = items + if p.cursor >= len(p.filtered) { + p.cursor = max(len(p.filtered)-1, 0) + } + if p.cursor < 0 { + p.cursor = 0 + } +} + +// SetCursor moves the selection to the given index (clamped to range). +func (p *PopupList) SetCursor(i int) { + if len(p.filtered) == 0 { + p.cursor = 0 + return + } + if i < 0 { + i = 0 + } + if i >= len(p.filtered) { + i = len(p.filtered) - 1 + } + p.cursor = i +} + +// Cursor returns the current selection index. +func (p *PopupList) Cursor() int { return p.cursor } + +// SetSearch replaces the search string without rebuilding the filtered list. +// Used by external-state callers that filter items themselves. +func (p *PopupList) SetSearch(s string) { p.search = s } + +// Items returns the currently-visible (filtered) items. +func (p *PopupList) Items() []PopupItem { return p.filtered } + +// Search returns the current search string. +func (p *PopupList) Search() string { return p.search } + +// dimensions returns the (popupWidth, popupHeight, innerWidth, innerHeight) +// the popup will render at, given its current size and FullScreen flag. +func (p *PopupList) dimensions() (popupW, popupH, innerW, innerH int) { + if p.FullScreen { + // Leave a small margin so the border doesn't kiss the screen edge. + popupW = max(p.width-2, 20) + popupH = max(p.height-2, 10) + } else { + // Centered: cap at 80 cols, leave a 4-col margin. + popupW = max(min(p.width-4, 80), 20) + // Height is dynamic — let it grow with content within the screen. + popupH = 0 + } + // Border (2) + horizontal padding (4) = 6 chrome cols. + innerW = max(popupW-6, 10) + if popupH > 0 { + // Border (2) + vertical padding (2) = 4 chrome rows. + innerH = max(popupH-4, 6) + } + return +} + // visibleCount returns the number of items visible at once. func (p *PopupList) visibleCount() int { - if p.maxVisible > 0 { - return p.maxVisible + if p.MaxVisible > 0 { + return p.MaxVisible } - // Reserve: title(1) + subtitle(1) + search(1) + separator(1) + footer(2) + border(2) + padding(2) = 10 + if p.FullScreen { + _, _, _, innerH := p.dimensions() + // Reserve: title(1) + subtitle(0|1) + search(0|2) + sep(1) + footer(2) + overhead := 4 + if p.Subtitle != "" { + overhead++ + } + if p.ShowSearch { + overhead += 2 + } + return max(innerH-overhead, 3) + } + // Centered: derive from terminal height (legacy behaviour). overhead := 8 if p.Subtitle != "" { overhead++ } - if p.showSearch { - overhead += 2 // search line + separator + if p.ShowSearch { + overhead += 2 } return max(p.height/2-overhead, 3) } // HandleKey processes a single key event and returns the result. The caller // should inspect PopupResult to decide whether to re-render, close the popup, -// or act on a selection. +// or act on a selection. Internal-state mode only — external-state callers +// drive cursor/search themselves and never call this. // // keyName is the Bubble Tea key string (e.g. "up", "down", "enter", "esc"). // keyText is the printable text for character keys (e.g. "a", "1"). @@ -191,7 +299,7 @@ func (p *PopupList) HandleKey(keyName, keyText string) PopupResult { // as a centered overlay via lipgloss.Place + overlayContent. func (p *PopupList) Render() string { theme := style.GetTheme() - popupWidth := max(min(p.width-4, 80), 20) + popupW, popupH, innerW, _ := p.dimensions() popupBg := theme.Background popupStyle := lipgloss.NewStyle(). @@ -199,11 +307,12 @@ func (p *PopupList) Render() string { BorderForeground(theme.Primary). Background(popupBg). Padding(1, 2). - Width(popupWidth). - MarginBottom(1) - - // Inner content width: popup minus border (2) and horizontal padding (4). - innerWidth := max(popupWidth-6, 10) + Width(popupW) + if popupH > 0 { + popupStyle = popupStyle.Height(popupH) + } else { + popupStyle = popupStyle.MarginBottom(1) + } var b strings.Builder @@ -212,7 +321,7 @@ func (p *PopupList) Render() string { Bold(true). Foreground(theme.Accent). Background(popupBg). - Width(innerWidth) + Width(innerW) b.WriteString(titleStyle.Render(p.Title)) b.WriteString("\n") @@ -221,17 +330,17 @@ func (p *PopupList) Render() string { subtitleStyle := lipgloss.NewStyle(). Foreground(theme.Muted). Background(popupBg). - Width(innerWidth) + Width(innerW) b.WriteString(subtitleStyle.Render(p.Subtitle)) b.WriteString("\n") } // Search input. - if p.showSearch { + if p.ShowSearch { searchStyle := lipgloss.NewStyle(). Foreground(theme.Info). Background(popupBg). - Width(innerWidth) + Width(innerW) if p.search != "" { b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", p.search))) } else { @@ -243,7 +352,7 @@ func (p *PopupList) Render() string { sepStyle := lipgloss.NewStyle(). Foreground(theme.Muted). Background(popupBg) - b.WriteString(sepStyle.Render(strings.Repeat("─", innerWidth))) + b.WriteString(sepStyle.Render(strings.Repeat("─", innerW))) b.WriteString("\n") } @@ -251,20 +360,20 @@ func (p *PopupList) Render() string { normalItemBg := lipgloss.NewStyle(). Background(popupBg). Foreground(theme.Text). - Width(innerWidth). + Width(innerW). Padding(0, 1) selectedItemBg := lipgloss.NewStyle(). Background(theme.Primary). Foreground(theme.Background). - Width(innerWidth). + Width(innerW). Padding(0, 1). Bold(true) scrollStyle := lipgloss.NewStyle(). Background(popupBg). Foreground(theme.VeryMuted). - Width(innerWidth). + Width(innerW). Padding(0, 1) vis := p.visibleCount() @@ -274,7 +383,7 @@ func (p *PopupList) Render() string { emptyStyle := lipgloss.NewStyle(). Foreground(theme.Muted). Background(popupBg). - Width(innerWidth). + Width(innerW). Padding(0, 1) if p.search != "" { items = append(items, emptyStyle.Render("No matches for \""+p.search+"\"")) @@ -282,9 +391,14 @@ func (p *PopupList) Render() string { items = append(items, emptyStyle.Render("No items")) } } else { + // Center the cursor in the visible window so the user always sees + // context above and below. Clamp to bounds. startIdx := 0 - if p.cursor >= vis { - startIdx = p.cursor - vis + 1 + if len(p.filtered) > vis { + startIdx = max(p.cursor-vis/2, 0) + if startIdx+vis > len(p.filtered) { + startIdx = len(p.filtered) - vis + } } endIdx := min(startIdx+vis, len(p.filtered)) @@ -292,10 +406,27 @@ func (p *PopupList) Render() string { items = append(items, scrollStyle.Render(" ↑ more above")) } + // Account for the consumed padding (1 left + 1 right = 2 cols) + // when rendering item content so RenderItem callbacks can match. + itemContentWidth := max(innerW-2, 6) + for i := startIdx; i < endIdx; i++ { entry := p.filtered[i] isCursor := i == p.cursor + if p.RenderItem != nil { + // Custom renderer: caller produces the inner text. We still + // wrap it in a full-width row so the selection highlight + // covers the line edge-to-edge. + rowStyle := normalItemBg + if isCursor { + rowStyle = selectedItemBg + } + content := p.RenderItem(entry, itemContentWidth, isCursor) + items = append(items, rowStyle.Render(content)) + continue + } + itemStyle := normalItemBg if isCursor { itemStyle = selectedItemBg @@ -310,7 +441,7 @@ func (p *PopupList) Render() string { } // Build content: indicator + label + description + active checkmark. - content := p.renderItemContent(indicator, entry, innerWidth, isCursor) + content := p.renderItemContent(indicator, entry, itemContentWidth, isCursor) items = append(items, itemStyle.Render(content)) } @@ -323,19 +454,24 @@ func (p *PopupList) Render() string { // Footer with count and keyboard hints. var footerParts []string - footerParts = append(footerParts, fmt.Sprintf("(%d/%d)", p.cursor+1, len(p.filtered))) + if !p.HideCount { + footerParts = append(footerParts, fmt.Sprintf("(%d/%d)", p.cursor+1, len(p.filtered))) + } footerHint := p.FooterHint if footerHint == "" { - if innerWidth >= 50 { + if innerW >= 50 { footerHint = "↑↓ navigate • enter select • esc cancel • type to filter" - } else if innerWidth >= 30 { + } else if innerW >= 30 { footerHint = "↑↓ nav • ↵ select • esc" } else { footerHint = "↑↓ ↵ esc" } } footerParts = append(footerParts, footerHint) + if p.ExtraFooter != "" { + footerParts = append(footerParts, p.ExtraFooter) + } footer := lipgloss.NewStyle(). Background(popupBg). diff --git a/internal/ui/session_selector.go b/internal/ui/session_selector.go index 92086ddc..35ffd980 100644 --- a/internal/ui/session_selector.go +++ b/internal/ui/session_selector.go @@ -5,7 +5,6 @@ import ( "regexp" "strings" "time" - "unicode/utf8" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -62,17 +61,14 @@ func (m SessionFilterMode) String() string { // controlCharsRe matches ASCII control characters for stripping from previews. var controlCharsRe = regexp.MustCompile(`[\x00-\x1f\x7f]`) -// SessionSelectorComponent is a full-screen Bubble Tea component that lets -// the user browse and select from available sessions. Modeled after pi's -// session picker: right-aligned metadata, background-highlighted selection, -// scope/filter toggles, and inline search. +// SessionSelectorComponent is a Bubble Tea component that lets the user browse +// and select from available sessions. It wraps PopupList in FullScreen mode: +// PopupList owns the cursor/search/scroll math/chrome; this component owns +// the session list, scope/filter toggles, and delete-confirmation flow. type SessionSelectorComponent struct { allSessions []session.SessionInfo cwdSessions []session.SessionInfo - filtered []session.SessionInfo - - cursor int - search string + filtered []session.SessionInfo // matches popup.Items() 1:1 scope SessionScopeMode filter SessionFilterMode @@ -80,6 +76,7 @@ type SessionSelectorComponent struct { // currentPath is the active session file path for marking it in the list. currentPath string + popup *PopupList width int height int active bool @@ -110,7 +107,12 @@ func NewSessionSelector(cwd string, width, height int) *SessionSelectorComponent ss.scope = SessionScopeAll } - ss.rebuildFiltered() + ss.popup = NewPopupList("Resume Session", nil, width, height) + ss.popup.FullScreen = true + ss.popup.FooterHint = "↑↓ nav • ↵ open • esc cancel • tab scope • ^N named • d delete • type to search" + ss.popup.RenderItem = ss.renderEntry + + ss.rebuild() return ss } @@ -131,10 +133,11 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: ss.width = msg.Width ss.height = msg.Height + ss.popup.SetSize(msg.Width, msg.Height) return ss, nil case tea.KeyPressMsg: - // Delete confirmation mode. + // Delete confirmation mode swallows all keys until y/n. if ss.confirmDelete >= 0 { switch msg.String() { case "y", "Y": @@ -145,7 +148,7 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := session.DeleteSession(info.Path); err == nil { name := sessionDisplayName(info) ss.removeSession(info.Path) - ss.rebuildFiltered() + ss.rebuild() return ss, func() tea.Msg { return SessionDeletedMsg{Name: name} } @@ -159,64 +162,14 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch { - case key.Matches(msg, key.NewBinding(key.WithKeys("up"))): - if ss.cursor > 0 { - ss.cursor-- - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("down"))): - if ss.cursor < len(ss.filtered)-1 { - ss.cursor++ - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))): - ss.cursor -= ss.visibleHeight() - if ss.cursor < 0 { - ss.cursor = 0 - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))): - ss.cursor += ss.visibleHeight() - if ss.cursor >= len(ss.filtered) { - ss.cursor = len(ss.filtered) - 1 - } - if ss.cursor < 0 { - ss.cursor = 0 - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("home"))): - ss.cursor = 0 - - case key.Matches(msg, key.NewBinding(key.WithKeys("end"))): - ss.cursor = max(len(ss.filtered)-1, 0) - - case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): - if ss.cursor < len(ss.filtered) { - info := ss.filtered[ss.cursor] - ss.active = false - return ss, func() tea.Msg { - return SessionSelectedMsg{Path: info.Path} - } - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): - if ss.search != "" { - ss.search = "" - ss.rebuildFiltered() - } else { - ss.active = false - return ss, func() tea.Msg { - return SessionSelectorCancelledMsg{} - } - } - case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): if ss.scope == SessionScopeCwd { ss.scope = SessionScopeAll } else { ss.scope = SessionScopeCwd } - ss.rebuildFiltered() + ss.rebuild() + return ss, nil case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+n"))): if ss.filter == SessionFilterAll { @@ -224,25 +177,48 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { ss.filter = SessionFilterAll } - ss.rebuildFiltered() - - case key.Matches(msg, key.NewBinding(key.WithKeys("d"))): - if ss.cursor < len(ss.filtered) { - ss.confirmDelete = ss.cursor - } + ss.rebuild() return ss, nil - default: - if msg.Text != "" && len(msg.Text) == 1 { - ch := msg.Text[0] - if ch >= 32 && ch < 127 { - ss.search += string(ch) - ss.rebuildFiltered() + case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+d"))): + // Ctrl+D as an explicit delete shortcut. Plain "d" still works + // below when the search field is empty so it doesn't conflict + // with typing the letter 'd' into a query. + if c := ss.popup.Cursor(); c < len(ss.filtered) { + ss.confirmDelete = c + } + return ss, nil + } + + // Plain 'd' triggers delete only when there's no active search + // query (otherwise the user would never be able to type 'd' into + // a search like "doc"). + if msg.String() == "d" && !ss.popup.IsSearching() { + if c := ss.popup.Cursor(); c < len(ss.filtered) { + ss.confirmDelete = c + return ss, nil + } + } + + // Delegate everything else to the popup. + result := ss.popup.HandleKey(msg.String(), msg.Text) + if result.Changed { + ss.syncFiltered() + } + if result.Selected != nil { + cursor := ss.popup.Cursor() + if cursor < len(ss.filtered) { + info := ss.filtered[cursor] + ss.active = false + return ss, func() tea.Msg { + return SessionSelectedMsg{Path: info.Path} } } - if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ss.search) > 0 { - ss.search = ss.search[:len(ss.search)-1] - ss.rebuildFiltered() + } + if result.Cancelled { + ss.active = false + return ss, func() tea.Msg { + return SessionSelectorCancelledMsg{} } } } @@ -251,152 +227,17 @@ func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View implements tea.Model. func (ss *SessionSelectorComponent) View() tea.View { - theme := style.GetTheme() - - // Full-screen bordered container - uses entire terminal width and height - maxWidth := ss.width - 2 // Small margin on each side - if maxWidth < 20 { - maxWidth = ss.width - } - maxHeight := ss.height - 2 // Small margin top/bottom to prevent overflow - if maxHeight < 10 { - maxHeight = ss.height - } - horizontalPadding := 1 - innerWidth := maxWidth - 4 // Account for border (2) + padding (2) - innerHeight := maxHeight - 4 // Account for border (2) + padding (2) - - // Container style with border - full width/height like a framed panel - containerStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(theme.Primary). - Background(theme.Background). - Padding(1, horizontalPadding). - Width(maxWidth). - Height(maxHeight) - - var contentBuilder strings.Builder - - // ── Header: title + scope badges ───────────────────────────── - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Accent). - Background(theme.Background) - contentBuilder.WriteString(titleStyle.Render(fmt.Sprintf("Resume Session (%s)", ss.scope))) - contentBuilder.WriteString("\n") - - // ── Help / keybindings ─────────────────────────────────────── - helpStyle := lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background) - if innerWidth >= 75 { - contentBuilder.WriteString(helpStyle.Render("tab: scope N: named D: delete R: rename type to search esc: cancel")) - } else if innerWidth >= 50 { - contentBuilder.WriteString(helpStyle.Render("tab scope N named D del type to search esc")) - } else { - contentBuilder.WriteString(helpStyle.Render("tab N D esc")) - } - contentBuilder.WriteString("\n") - - // ── Search (only shown when active) ────────────────────────── - if ss.search != "" { - searchStyle := lipgloss.NewStyle(). - Foreground(theme.Info). - Background(theme.Background) - contentBuilder.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ss.search))) - contentBuilder.WriteString("\n") - } - - // Separator line - sepWidth := innerWidth - contentBuilder.WriteString( - lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background). - Render(strings.Repeat("─", sepWidth))) - contentBuilder.WriteString("\n") - - // ── Delete confirmation ────────────────────────────────────── + // Compose dynamic footer extras: scope + filter + (delete confirm). + extra := fmt.Sprintf("scope: %s • filter: %s", ss.scope, ss.filter) if ss.confirmDelete >= 0 && ss.confirmDelete < len(ss.filtered) { - warnStyle := lipgloss.NewStyle(). - Foreground(theme.Error). - Bold(true). - Background(theme.Background) - name := sessionDisplayName(ss.filtered[ss.confirmDelete]) - contentBuilder.WriteString(warnStyle.Render(fmt.Sprintf("Delete %q? (y/N)", truncateRunes(name, 40)))) - contentBuilder.WriteString("\n") + name := truncateRunes(sessionDisplayName(ss.filtered[ss.confirmDelete]), 30) + extra = fmt.Sprintf("delete %q? y/N", name) } + ss.popup.Title = fmt.Sprintf("Resume Session (%s)", ss.scope) + ss.popup.ExtraFooter = extra - // ── Session list ───────────────────────────────────────────── - if len(ss.filtered) == 0 { - emptyStyle := lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background) - if ss.search != "" { - contentBuilder.WriteString(emptyStyle.Render(fmt.Sprintf("No sessions matching %q", ss.search))) - } else if ss.filter == SessionFilterNamed { - contentBuilder.WriteString(emptyStyle.Render("No named sessions. Press N to show all.")) - } else if ss.scope == SessionScopeCwd { - contentBuilder.WriteString(emptyStyle.Render("No sessions in current folder. Press tab to view all.")) - } else { - contentBuilder.WriteString(emptyStyle.Render("No sessions found")) - } - contentBuilder.WriteString("\n") - } else { - // Compute visible window based on inner container height - // Chrome: header(2) + separator(1) + footer separator(1) + footer(1) = 5 - chromeLines := 5 - if ss.search != "" { - chromeLines++ - } - if ss.confirmDelete >= 0 { - chromeLines++ - } - visH := max(innerHeight-chromeLines, 3) - - // Center the cursor in the visible window. - startIdx := max(0, min(ss.cursor-visH/2, len(ss.filtered)-visH)) - endIdx := min(startIdx+visH, len(ss.filtered)) - - for i := startIdx; i < endIdx; i++ { - info := ss.filtered[i] - isCursor := i == ss.cursor - isCurrent := info.Path == ss.currentPath - isDeleting := i == ss.confirmDelete - line := ss.renderEntry(info, isCursor, isCurrent, isDeleting, innerWidth) - contentBuilder.WriteString(line) - contentBuilder.WriteString("\n") - } - - // Scroll position indicator. - if len(ss.filtered) > visH { - posStyle := lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background) - contentBuilder.WriteString(posStyle.Render(fmt.Sprintf("(%d/%d)", ss.cursor+1, len(ss.filtered)))) - contentBuilder.WriteString("\n") - } - } - - // Footer separator - contentBuilder.WriteString( - lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background). - Render(strings.Repeat("─", sepWidth))) - contentBuilder.WriteString("\n") - - // Footer with filter info - footerStyle := lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background) - contentBuilder.WriteString(footerStyle.Render(fmt.Sprintf("Filter: %s", ss.filter))) - - // Apply the bordered container - content := contentBuilder.String() - borderedContent := containerStyle.Render(content) - - v := tea.NewView(borderedContent) + rendered := ss.popup.RenderCentered(ss.width, ss.height) + v := tea.NewView(rendered) v.AltScreen = true return v } @@ -408,20 +249,9 @@ func (ss *SessionSelectorComponent) IsActive() bool { // --- Internal helpers --- -func (ss *SessionSelectorComponent) visibleHeight() int { - // Reserve: title(1) + help(1) + blank(1) + scroll indicator(1) = 4. - // Optional: search(1), delete confirm(1). - chrome := 4 - if ss.search != "" { - chrome++ - } - if ss.confirmDelete >= 0 { - chrome++ - } - return max(ss.height-chrome, 3) -} - -func (ss *SessionSelectorComponent) rebuildFiltered() { +// rebuild applies the scope and filter selections, then publishes the +// resulting session list to the popup. +func (ss *SessionSelectorComponent) rebuild() { var source []session.SessionInfo if ss.scope == SessionScopeCwd { source = ss.cwdSessions @@ -439,23 +269,33 @@ func (ss *SessionSelectorComponent) rebuildFiltered() { source = named } - if ss.search != "" { - query := strings.ToLower(ss.search) - var matches []session.SessionInfo - for _, s := range source { - haystack := strings.ToLower(s.Name + " " + s.FirstMessage + " " + s.Cwd) - if strings.Contains(haystack, query) { - matches = append(matches, s) - } + // Build PopupItems. The Label holds a haystack string (name + first + // message + cwd) so PopupList's default filter can match against any + // of those fields. We render each row with a custom RenderItem. + items := make([]PopupItem, len(source)) + for i, s := range source { + haystack := strings.TrimSpace(s.Name + " " + s.FirstMessage + " " + s.Cwd) + items[i] = PopupItem{ + Label: haystack, + Active: s.Path == ss.currentPath, + Meta: s, } - ss.filtered = matches - } else { - ss.filtered = source } + ss.popup.SetItems(items) + ss.syncFiltered() +} - if ss.cursor >= len(ss.filtered) { - ss.cursor = max(len(ss.filtered)-1, 0) +// syncFiltered refreshes the filtered slice from popup.Items() so cursor +// indices map back to session.SessionInfo for the parent. +func (ss *SessionSelectorComponent) syncFiltered() { + items := ss.popup.Items() + out := make([]session.SessionInfo, 0, len(items)) + for _, it := range items { + if s, ok := it.Meta.(session.SessionInfo); ok { + out = append(out, s) + } } + ss.filtered = out } func (ss *SessionSelectorComponent) removeSession(path string) { @@ -473,87 +313,74 @@ func removeByPath(sessions []session.SessionInfo, path string) []session.Session return result } -// renderEntry renders a single session line with right-aligned metadata. -// Layout: [cursor 2] [message ...variable...] [padding] [count age] [cwd?] -func (ss *SessionSelectorComponent) renderEntry(info session.SessionInfo, isCursor, isCurrent, isDeleting bool, width int) string { +// renderEntry is the RenderItem callback handed to PopupList. It produces a +// single-line entry with left-aligned message text and right-aligned +// metadata (message count + relative time, plus optional cwd in "All" scope). +// +// When isCursor we return a plain (unstyled) string so PopupList's outer +// row style can paint one continuous fg+bg span. Mixing inner lipgloss +// Render calls with an outer Background() breaks the highlight into bars, +// because each inner Render emits an ANSI reset that drops the background. +func (ss *SessionSelectorComponent) renderEntry(item PopupItem, innerWidth int, isCursor bool) string { theme := style.GetTheme() + info, ok := item.Meta.(session.SessionInfo) + if !ok { + return item.Label + } + isCurrent := info.Path == ss.currentPath + isDeleting := ss.confirmDelete >= 0 && ss.confirmDelete < len(ss.filtered) && + ss.filtered[ss.confirmDelete].Path == info.Path - // ── Cursor indicator (2 chars) ─────────────────────────────── - cursorStr := " " + // Cursor indicator (2 cells). + indicator := " " if isCursor { - cursorStr = lipgloss.NewStyle().Foreground(theme.Accent).Render("> ") + indicator = "> " } - const cursorW = 2 - // ── Right part: message count + relative time (+ optional cwd) ── + // Right-hand metadata. age := relativeTime(info.Modified) - msgCount := fmt.Sprintf("%d", info.MessageCount) - rightPart := msgCount + " " + age + right := fmt.Sprintf("%d %s", info.MessageCount, age) if ss.scope == SessionScopeAll && info.Cwd != "" { - shortCwd := shortenPath(info.Cwd) - if len(shortCwd) > 25 { - shortCwd = "..." + shortCwd[len(shortCwd)-22:] - } - rightPart = shortCwd + " " + rightPart + shortCwd := truncateRunes(shortenPath(info.Cwd), 25) + right = shortCwd + " " + right } - rightW := utf8.RuneCountInString(rightPart) + rightW := lipgloss.Width(right) + + // Message text width: innerWidth minus indicator(2) minus right minus gap(2). + availForMsg := max(innerWidth-2-rightW-2, 10) - // ── Message text ───────────────────────────────────────────── displayText := sessionDisplayName(info) - // Strip control characters and collapse whitespace. displayText = controlCharsRe.ReplaceAllString(displayText, " ") displayText = strings.Join(strings.Fields(displayText), " ") + displayText = truncateRunes(displayText, availForMsg) - availableForMsg := max(width-cursorW-rightW-2, 10) // 2 for min spacing - displayText = truncateRunes(displayText, availableForMsg) - msgW := utf8.RuneCountInString(displayText) + msgW := lipgloss.Width(displayText) + spacing := max(innerWidth-2-msgW-rightW, 1) - // ── Style the message ──────────────────────────────────────── - var msgStyle lipgloss.Style + // Selected row: raw string, outer row style paints it. + if isCursor { + return indicator + displayText + strings.Repeat(" ", spacing) + right + } + + // Color the message text by state. + var msgStyle, rightStyle lipgloss.Style switch { case isDeleting: msgStyle = lipgloss.NewStyle().Foreground(theme.Error) case isCurrent: - msgStyle = lipgloss.NewStyle().Foreground(theme.Accent) + msgStyle = lipgloss.NewStyle().Foreground(theme.Accent).Bold(true) case info.Name != "": msgStyle = lipgloss.NewStyle().Foreground(theme.Warning) default: msgStyle = lipgloss.NewStyle().Foreground(theme.Text) } - - // ── Style the right part ───────────────────────────────────── - rightColor := theme.Muted if isDeleting { - rightColor = theme.Error - } - var styledRight string - - // ── Assemble with spacing ──────────────────────────────────── - spacing := max(width-cursorW-msgW-rightW, 1) - - // If selected, use inverted colors like PopupList - if isCursor { - // Inverted colors for selected item - msgStyle = lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(theme.Background). - Bold(true) - styledRight = lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(rightColor). - Render(rightPart) - cursorStr = lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(theme.Accent). - Render("> ") + rightStyle = lipgloss.NewStyle().Foreground(theme.Error) } else { - styledRight = lipgloss.NewStyle().Foreground(rightColor).Render(rightPart) + rightStyle = lipgloss.NewStyle().Foreground(theme.Muted) } - styledMsg := msgStyle.Render(displayText) - line := cursorStr + styledMsg + strings.Repeat(" ", spacing) + styledRight - - return line + return indicator + msgStyle.Render(displayText) + strings.Repeat(" ", spacing) + rightStyle.Render(right) } // --- Package helpers --- @@ -570,7 +397,7 @@ func sessionDisplayName(info session.SessionInfo) string { return "(empty session)" } -// truncateRunes truncates a string to at most maxRunes runes, appending "..." +// truncateRunes truncates a string to at most maxRunes runes, appending "…" // if truncated. func truncateRunes(s string, maxRunes int) string { if maxRunes <= 0 { diff --git a/internal/ui/tree_selector.go b/internal/ui/tree_selector.go index 5a5bd198..ebf89776 100644 --- a/internal/ui/tree_selector.go +++ b/internal/ui/tree_selector.go @@ -53,16 +53,19 @@ type FlatNode struct { } // TreeSelectorComponent is a Bubble Tea component that renders the session -// tree as an ASCII art list with navigation and selection. +// tree as an ASCII art list with navigation and selection. It is a thin +// wrapper around PopupList (in FullScreen mode) — PopupList owns the cursor, +// search, scroll math, and chrome; TreeSelectorComponent supplies the +// filtered node list and a custom RenderItem that draws each tree node with +// its indentation prefix and role colors. type TreeSelectorComponent struct { tm *session.TreeManager - flatNodes []FlatNode - cursor int + flatNodes []FlatNode // visible nodes (matches popup.Items() 1:1) filter TreeFilterMode leafID string // real leaf for "active" marker + popup *PopupList width int height int - search string active bool selectedID string // set when user selects a node cancelled bool @@ -78,11 +81,12 @@ func NewTreeSelector(tm *session.TreeManager, width, height int) *TreeSelectorCo height: height, active: true, } - ts.rebuildFlatList() + ts.initPopup() + ts.rebuild() // Position cursor at the active leaf. for i, node := range ts.flatNodes { if node.ID == ts.leafID { - ts.cursor = i + ts.popup.SetCursor(i) break } } @@ -100,17 +104,25 @@ func NewTreeSelectorForFork(tm *session.TreeManager, width, height int) *TreeSel height: height, active: true, } - ts.rebuildFlatList() + ts.initPopup() + ts.rebuild() // Position cursor at the last user message before the leaf. for i := len(ts.flatNodes) - 1; i >= 0; i-- { if ts.isUserMessage(ts.flatNodes[i].Entry) { - ts.cursor = i + ts.popup.SetCursor(i) break } } return ts } +func (ts *TreeSelectorComponent) initPopup() { + ts.popup = NewPopupList("Session Tree", nil, ts.width, ts.height) + ts.popup.FullScreen = true + ts.popup.FooterHint = "↑↓ nav • ←→ page • ↵ select • esc cancel • ^O filter • type to search" + ts.popup.RenderItem = ts.renderNode +} + // Init implements tea.Model. func (ts *TreeSelectorComponent) Init() tea.Cmd { return nil @@ -122,96 +134,75 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: ts.width = msg.Width ts.height = msg.Height + ts.popup.SetSize(msg.Width, msg.Height) return ts, nil case tea.KeyPressMsg: + // Tree-specific keys we handle ourselves before delegating to popup. switch { - case key.Matches(msg, key.NewBinding(key.WithKeys("up"))): - if ts.cursor > 0 { - ts.cursor-- - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("down"))): - if ts.cursor < len(ts.flatNodes)-1 { - ts.cursor++ - } - case key.Matches(msg, key.NewBinding(key.WithKeys("left", "pgup"))): // Page up. - ts.cursor -= ts.visibleHeight() - if ts.cursor < 0 { - ts.cursor = 0 - } + result := ts.popup.HandleKey("pgup", "") + _ = result + return ts, nil case key.Matches(msg, key.NewBinding(key.WithKeys("right", "pgdown"))): - // Page down. - ts.cursor += ts.visibleHeight() - if ts.cursor >= len(ts.flatNodes) { - ts.cursor = len(ts.flatNodes) - 1 - } + result := ts.popup.HandleKey("pgdown", "") + _ = result + return ts, nil - case key.Matches(msg, key.NewBinding(key.WithKeys("home"))): - ts.cursor = 0 + case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+o"))): + ts.filter = (ts.filter + 1) % 5 + ts.rebuild() + return ts, nil - case key.Matches(msg, key.NewBinding(key.WithKeys("end"))): - ts.cursor = len(ts.flatNodes) - 1 + case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+d"))): + ts.filter = TreeFilterDefault + ts.rebuild() + return ts, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+t"))): + ts.filter = TreeFilterNoTools + ts.rebuild() + return ts, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+u"))): + ts.filter = TreeFilterUserOnly + ts.rebuild() + return ts, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+l"))): + ts.filter = TreeFilterLabelOnly + ts.rebuild() + return ts, nil + } - case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): - if ts.cursor < len(ts.flatNodes) { - ts.selectedID = ts.flatNodes[ts.cursor].ID + // Delegate everything else (nav, search, enter, esc) to the popup. + result := ts.popup.HandleKey(msg.String(), msg.Text) + + // Update our flatNodes view if popup filtered/changed search. + if result.Changed { + ts.syncFlatNodes() + } + + if result.Selected != nil { + cursor := ts.popup.Cursor() + if cursor < len(ts.flatNodes) { + node := ts.flatNodes[cursor] + ts.selectedID = node.ID ts.active = false return ts, func() tea.Msg { return core.TreeNodeSelectedMsg{ - ID: ts.selectedID, - Entry: ts.flatNodes[ts.cursor].Entry, - IsUser: ts.isUserMessage(ts.flatNodes[ts.cursor].Entry), - UserText: ts.extractUserText(ts.flatNodes[ts.cursor].Entry), + ID: node.ID, + Entry: node.Entry, + IsUser: ts.isUserMessage(node.Entry), + UserText: ts.extractUserText(node.Entry), } } } - - case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): - if ts.search != "" { - ts.search = "" - ts.rebuildFlatList() - } else { - ts.cancelled = true - ts.active = false - return ts, func() tea.Msg { - return core.TreeCancelledMsg{} - } - } - - // Filter cycle with ctrl+o. - case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+o"))): - ts.filter = (ts.filter + 1) % 5 - ts.rebuildFlatList() - - // Direct filter shortcuts. - case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+d"))): - ts.filter = TreeFilterDefault - ts.rebuildFlatList() - case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+t"))): - ts.filter = TreeFilterNoTools - ts.rebuildFlatList() - case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+u"))): - ts.filter = TreeFilterUserOnly - ts.rebuildFlatList() - case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+l"))): - ts.filter = TreeFilterLabelOnly - ts.rebuildFlatList() - default: - // Typing search. - if msg.Text != "" && len(msg.Text) == 1 { - ch := msg.Text[0] - if ch >= 32 && ch < 127 { - ts.search += string(ch) - ts.rebuildFlatList() - } - } - if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ts.search) > 0 { - ts.search = ts.search[:len(ts.search)-1] - ts.rebuildFlatList() + } + if result.Cancelled { + ts.cancelled = true + ts.active = false + return ts, func() tea.Msg { + return core.TreeCancelledMsg{} } } } @@ -220,128 +211,10 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View implements tea.Model. func (ts *TreeSelectorComponent) View() tea.View { - theme := GetTheme() - - // Full-screen bordered container - uses entire terminal width and height - maxWidth := ts.width - 2 // Small margin on each side - if maxWidth < 20 { - maxWidth = ts.width - } - maxHeight := ts.height - 2 // Small margin top/bottom to prevent overflow - if maxHeight < 10 { - maxHeight = ts.height - } - horizontalPadding := 1 - innerWidth := maxWidth - 4 // Account for border (2) + padding (2) - innerHeight := maxHeight - 4 // Account for border (2) + padding (2) - - // Container style with border - full width/height like a framed panel - containerStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(theme.Primary). - Background(theme.Background). - Padding(1, horizontalPadding). - Width(maxWidth). - Height(maxHeight) - - // Header style with background highlight (like PopupList title) - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(theme.Accent). - Background(theme.Background) - - // Help text style - helpStyle := lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background) - - var contentBuilder strings.Builder - - // Header row with title and help - headerRow := headerStyle.Render("Session Tree") - contentBuilder.WriteString(headerRow) - contentBuilder.WriteString("\n") - - // Help text - adapt to terminal width - var helpText string - if ts.width >= 70 { - helpText = "↑/↓: move ←/→: page enter: select esc: cancel ^O: cycle filter" - } else if ts.width >= 45 { - helpText = "↑↓ move ↵ select esc cancel ^O filter" - } else { - helpText = "↑↓ ↵ esc ^O" - } - contentBuilder.WriteString(helpStyle.Render(helpText)) - contentBuilder.WriteString("\n") - - // Search display (if active) - if ts.search != "" { - searchStyle := lipgloss.NewStyle(). - Foreground(theme.Info). - Background(theme.Background) - contentBuilder.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ts.search))) - contentBuilder.WriteString("\n") - } - - // Separator line - full width - sepWidth := innerWidth - contentBuilder.WriteString( - lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background). - Render(strings.Repeat("─", sepWidth))) - contentBuilder.WriteString("\n") - - // Tree content - if len(ts.flatNodes) == 0 { - emptyStyle := lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background) - contentBuilder.WriteString(emptyStyle.Render("No entries in session")) - contentBuilder.WriteString("\n") - } else { - // Compute visible window based on inner container height - // Chrome: header(2) + separator(1) + footer separator(1) + footer(1) = 5 - chromeLines := 5 - if ts.search != "" { - chromeLines++ - } - visH := max(innerHeight-chromeLines, 3) - - startIdx := 0 - if ts.cursor >= visH { - startIdx = ts.cursor - visH + 1 - } - endIdx := min(startIdx+visH, len(ts.flatNodes)) - - for i := startIdx; i < endIdx; i++ { - node := ts.flatNodes[i] - line := ts.renderNode(node, i == ts.cursor, node.ID == ts.leafID, innerWidth) - contentBuilder.WriteString(line) - contentBuilder.WriteString("\n") - } - } - - // Footer separator - contentBuilder.WriteString( - lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background). - Render(strings.Repeat("─", sepWidth))) - contentBuilder.WriteString("\n") - - // Footer with count and filter - footerStyle := lipgloss.NewStyle(). - Foreground(theme.Muted). - Background(theme.Background) - footer := fmt.Sprintf("(%d/%d) [%s]", ts.cursor+1, len(ts.flatNodes), ts.filter) - contentBuilder.WriteString(footerStyle.Render(footer)) - - // Apply the bordered container - full width, no centering - content := contentBuilder.String() - borderedContent := containerStyle.Render(content) - - v := tea.NewView(borderedContent) + // Update extra footer with current filter mode. + ts.popup.ExtraFooter = fmt.Sprintf("[%s]", ts.filter) + rendered := ts.popup.RenderCentered(ts.width, ts.height) + v := tea.NewView(rendered) v.AltScreen = true return v } @@ -353,38 +226,46 @@ func (ts *TreeSelectorComponent) IsActive() bool { // --- Internal helpers --- -func (ts *TreeSelectorComponent) visibleHeight() int { - // Chrome: header(1) + help(1) + separator(1) + entries + separator(1) + footer(1) = 5 fixed. - // Optional search line adds 1 more. Use 7 as a safe estimate. - const chromeLines = 7 - return max(ts.height-chromeLines, 3) -} - -func (ts *TreeSelectorComponent) rebuildFlatList() { - tree := ts.tm.GetTree() +// rebuild reflattens the tree under the current filter and reseeds the popup +// with PopupItems. Called on initial load and whenever the filter changes. +func (ts *TreeSelectorComponent) rebuild() { ts.flatNodes = ts.flatNodes[:0] + tree := ts.tm.GetTree() for i, root := range tree { isLast := i == len(tree)-1 ts.flattenNode(root, 0, isLast, "") } + ts.publishItems() +} - // Apply search filter. - if ts.search != "" { - query := strings.ToLower(ts.search) - filtered := make([]FlatNode, 0) - for _, node := range ts.flatNodes { - text := ts.entryDisplayText(node.Entry) - if strings.Contains(strings.ToLower(text), query) { - filtered = append(filtered, node) - } +// syncFlatNodes refreshes flatNodes from the popup's current filtered view. +// Called after a search-driven HandleKey result so the cursor index matches. +func (ts *TreeSelectorComponent) syncFlatNodes() { + items := ts.popup.Items() + newFlat := make([]FlatNode, len(items)) + for i, it := range items { + if fn, ok := it.Meta.(FlatNode); ok { + newFlat[i] = fn } - ts.flatNodes = filtered } + ts.flatNodes = newFlat +} - // Clamp cursor. - if ts.cursor >= len(ts.flatNodes) { - ts.cursor = max(len(ts.flatNodes)-1, 0) +// publishItems converts flatNodes → PopupItems and seeds the popup. We rely +// on PopupList's default substring filter against item.Label (which holds +// the display text) for search. +func (ts *TreeSelectorComponent) publishItems() { + items := make([]PopupItem, len(ts.flatNodes)) + for i, n := range ts.flatNodes { + items[i] = PopupItem{ + Label: ts.entryDisplayText(n.Entry), + Active: n.ID == ts.leafID, + Meta: n, + } } + ts.popup.SetItems(items) + // Mirror the popup's current view in flatNodes so cursor lookups work. + ts.syncFlatNodes() } func (ts *TreeSelectorComponent) flattenNode(node *session.TreeNode, depth int, isLast bool, gutterPrefix string) { @@ -473,35 +354,73 @@ func (ts *TreeSelectorComponent) passesFilter(node *session.TreeNode) bool { } } -func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool, innerWidth int) string { +// renderNode is the RenderItem callback handed to PopupList. PopupList wraps +// the returned string with a full-width row style. +// +// When isCursor we return a plain (unstyled) string so the outer row style +// can paint a single continuous fg+bg span across the line. Composing inner +// lipgloss.Render calls emits ANSI resets mid-string which knock the +// background back out, breaking the highlight into disjoint bars (issue +// observed with deep tool-interaction branches). +func (ts *TreeSelectorComponent) renderNode(item PopupItem, innerWidth int, isCursor bool) string { theme := GetTheme() + node, ok := item.Meta.(FlatNode) + if !ok { + return item.Label + } + isLeaf := node.ID == ts.leafID - // Cursor indicator - use ">" for selected (like PopupList) - var cursor string + // Indicator (2 cells). + indicator := " " if isCursor { - cursor = lipgloss.NewStyle().Foreground(theme.Accent).Render("> ") - } else { - cursor = " " + indicator = "> " } - // Role-colored content with background support for selection - text := ts.entryDisplayText(node.Entry) + // Prefix (tree art) — width measured in display cells via lipgloss. + prefix := node.Prefix + prefixW := lipgloss.Width(prefix) - // Calculate available width accounting for cursor, prefix, and markers - prefixLen := len(node.Prefix) - available := innerWidth - prefixLen - 4 // 4 for cursor and some padding - if available > 3 && len(text) > available { - trimLen := max(available-3, 1) - if trimLen < len(text) { - text = text[:trimLen] + "..." + // Compute right-side fixed parts: label badge + active marker. + var labelBadgeRaw, activeMarkerRaw string + if node.Label != "" { + labelBadgeRaw = " [" + node.Label + "]" + } + if isLeaf { + activeMarkerRaw = " ← active" + } + rightW := lipgloss.Width(labelBadgeRaw) + lipgloss.Width(activeMarkerRaw) + + // If the tree prefix is so deep it would push the text off the row, + // truncate the prefix from the LEFT and prepend an ellipsis. Keeping + // the right-most segment preserves the most recent depth indicator + // (└─ / ├─) so the user can still see this row's connection to its + // parent. We reserve at least 20 cells for the actual entry text. + const minTextWidth = 20 + budget := innerWidth - 2 - rightW - minTextWidth + if prefixW > budget && budget > 2 { + runes := []rune(prefix) + // Strip from the left until lipgloss.Width fits the budget. + for len(runes) > 0 && lipgloss.Width(string(runes)) > budget-1 { + runes = runes[1:] } + prefix = "…" + string(runes) + prefixW = lipgloss.Width(prefix) } - // Build the full line style - var lineStyle lipgloss.Style - var textStyle lipgloss.Style + // Reserve space for indicator(2) + prefix + right parts. + available := max(innerWidth-2-prefixW-rightW, 4) - // Base text color based on role + text := ts.entryDisplayText(node.Entry) + text = truncateRunes(text, available) + + // Selected row: emit raw text. The outer row style applies fg+bg in one + // uninterrupted span, keeping the highlight solid edge-to-edge. + if isCursor { + return indicator + prefix + text + labelBadgeRaw + activeMarkerRaw + } + + // Role-based text color. + var textStyle lipgloss.Style switch e := node.Entry.(type) { case *session.MessageEntry: switch e.Role { @@ -520,77 +439,27 @@ func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool textStyle = lipgloss.NewStyle().Foreground(theme.Muted) } - // Apply selection highlighting (like PopupList) - if isCursor { - // Inverted colors for selected item - matches PopupList style - lineStyle = lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(theme.Background). - Bold(true) - textStyle = lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(theme.Background). - Bold(true) - } - - // Render components - content := textStyle.Render(text) - - // Label badge. - var labelBadge string - if node.Label != "" { - labelStyle := lipgloss.NewStyle().Foreground(theme.Warning) - if isCursor { - labelStyle = lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(theme.Warning) - } - labelBadge = " " + labelStyle.Render("["+node.Label+"]") - } - - // Active marker - use Success color for better visibility - var activeMarker string - if isLeaf { - markerStyle := lipgloss.NewStyle().Foreground(theme.Success).Bold(true) - if isCursor { - markerStyle = lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(theme.Success). - Bold(true) - } - activeMarker = markerStyle.Render(" ← active") - } - - // Prefix (tree lines) - use MutedBorder for subtler appearance prefixStyle := lipgloss.NewStyle().Foreground(theme.MutedBorder) - if isCursor { - prefixStyle = lipgloss.NewStyle(). - Background(theme.Primary). - Foreground(theme.MutedBorder) + labelStyle := lipgloss.NewStyle().Foreground(theme.Warning) + markerStyle := lipgloss.NewStyle().Foreground(theme.Success).Bold(true) + + parts := indicator + prefixStyle.Render(prefix) + textStyle.Render(text) + if labelBadgeRaw != "" { + parts += labelStyle.Render(labelBadgeRaw) } - renderedPrefix := prefixStyle.Render(node.Prefix) - - // Combine all parts - line := cursor + renderedPrefix + content + labelBadge + activeMarker - - // If selected, apply the background to the entire line - if isCursor { - return lineStyle.Render(line) + if activeMarkerRaw != "" { + parts += markerStyle.Render(activeMarkerRaw) } - - return line + return parts } func (ts *TreeSelectorComponent) entryDisplayText(entry any) string { switch e := entry.(type) { case *session.MessageEntry: role := e.Role - text := extractTextFromParts(e.Parts) - if len(text) > 80 { - text = text[:80] + "..." - } + text := collapseToLine(extractTextFromParts(e.Parts)) + text = truncateRunes(text, 200) if text == "" { - // Tool call messages may not have text. text = "(tool interaction)" } return fmt.Sprintf("%s: %s", role, text) @@ -599,18 +468,10 @@ func (ts *TreeSelectorComponent) entryDisplayText(entry any) string { return fmt.Sprintf("model: %s/%s", e.Provider, e.ModelID) case *session.BranchSummaryEntry: - summary := e.Summary - if len(summary) > 60 { - summary = summary[:60] + "..." - } - return fmt.Sprintf("branch summary: %s", summary) + return fmt.Sprintf("branch summary: %s", truncateRunes(collapseToLine(e.Summary), 200)) case *session.CompactionEntry: - summary := e.Summary - if len(summary) > 60 { - summary = summary[:60] + "..." - } - return fmt.Sprintf("compaction: %s", summary) + return fmt.Sprintf("compaction: %s", truncateRunes(collapseToLine(e.Summary), 200)) case *session.LabelEntry: return fmt.Sprintf("label: %s", e.Label) @@ -623,6 +484,13 @@ func (ts *TreeSelectorComponent) entryDisplayText(entry any) string { } } +// collapseToLine flattens any multi-line string into a single line by +// replacing whitespace runs (including newlines and tabs) with single +// spaces. Used so popup rows never wrap and break the layout. +func collapseToLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + func (ts *TreeSelectorComponent) isUserMessage(entry any) bool { if me, ok := entry.(*session.MessageEntry); ok { return me.Role == "user"