mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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:
+35
-177
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user