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.
This commit is contained in:
Ed Zynda
2026-06-07 17:45:06 +03:00
parent 00eab47218
commit 9f125f3400
4 changed files with 531 additions and 842 deletions
+35 -177
View File
@@ -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
+182 -46
View File
@@ -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 "> <query>" 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).
+131 -304
View File
@@ -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 {
+183 -315
View File
@@ -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"