mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
9f125f3400
- 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.
441 lines
12 KiB
Go
441 lines
12 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/key"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"github.com/mark3labs/kit/internal/session"
|
|
"github.com/mark3labs/kit/internal/ui/style"
|
|
)
|
|
|
|
// SessionSelectedMsg is sent when the user selects a session from the picker.
|
|
type SessionSelectedMsg struct {
|
|
Path string // absolute path to the JSONL session file
|
|
}
|
|
|
|
// SessionSelectorCancelledMsg is sent when the user cancels the picker.
|
|
type SessionSelectorCancelledMsg struct{}
|
|
|
|
// SessionDeletedMsg is sent after a session is deleted so the parent can
|
|
// react (e.g. print a message).
|
|
type SessionDeletedMsg struct {
|
|
Name string
|
|
}
|
|
|
|
// SessionScopeMode controls which sessions are shown.
|
|
type SessionScopeMode int
|
|
|
|
const (
|
|
SessionScopeCwd SessionScopeMode = iota // current folder only
|
|
SessionScopeAll // all sessions across projects
|
|
)
|
|
|
|
func (m SessionScopeMode) String() string {
|
|
if m == SessionScopeAll {
|
|
return "All"
|
|
}
|
|
return "Current Folder"
|
|
}
|
|
|
|
// SessionFilterMode controls filtering of the session list.
|
|
type SessionFilterMode int
|
|
|
|
const (
|
|
SessionFilterAll SessionFilterMode = iota // show all sessions
|
|
SessionFilterNamed // only named sessions
|
|
)
|
|
|
|
func (m SessionFilterMode) String() string {
|
|
if m == SessionFilterNamed {
|
|
return "Named"
|
|
}
|
|
return "All"
|
|
}
|
|
|
|
// controlCharsRe matches ASCII control characters for stripping from previews.
|
|
var controlCharsRe = regexp.MustCompile(`[\x00-\x1f\x7f]`)
|
|
|
|
// 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 // matches popup.Items() 1:1
|
|
|
|
scope SessionScopeMode
|
|
filter SessionFilterMode
|
|
|
|
// currentPath is the active session file path for marking it in the list.
|
|
currentPath string
|
|
|
|
popup *PopupList
|
|
width int
|
|
height int
|
|
active bool
|
|
|
|
// confirmDelete is non-negative when a delete confirmation is pending.
|
|
confirmDelete int
|
|
}
|
|
|
|
// NewSessionSelector creates a session selector. It loads sessions for the
|
|
// current working directory and all sessions across projects. If cwd is
|
|
// empty, only "All" scope is available.
|
|
func NewSessionSelector(cwd string, width, height int) *SessionSelectorComponent {
|
|
ss := &SessionSelectorComponent{
|
|
width: width,
|
|
height: height,
|
|
active: true,
|
|
confirmDelete: -1,
|
|
}
|
|
|
|
// Load sessions (errors are swallowed — empty list is fine).
|
|
if cwd != "" {
|
|
ss.cwdSessions, _ = session.ListSessions(cwd)
|
|
ss.scope = SessionScopeCwd
|
|
}
|
|
ss.allSessions, _ = session.ListAllSessions()
|
|
|
|
if cwd == "" || len(ss.cwdSessions) == 0 {
|
|
ss.scope = SessionScopeAll
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// SetCurrentPath sets the currently active session path so the picker can
|
|
// highlight it in the list.
|
|
func (ss *SessionSelectorComponent) SetCurrentPath(path string) {
|
|
ss.currentPath = path
|
|
}
|
|
|
|
// Init implements tea.Model.
|
|
func (ss *SessionSelectorComponent) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update implements tea.Model.
|
|
func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
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 swallows all keys until y/n.
|
|
if ss.confirmDelete >= 0 {
|
|
switch msg.String() {
|
|
case "y", "Y":
|
|
idx := ss.confirmDelete
|
|
ss.confirmDelete = -1
|
|
if idx < len(ss.filtered) {
|
|
info := ss.filtered[idx]
|
|
if err := session.DeleteSession(info.Path); err == nil {
|
|
name := sessionDisplayName(info)
|
|
ss.removeSession(info.Path)
|
|
ss.rebuild()
|
|
return ss, func() tea.Msg {
|
|
return SessionDeletedMsg{Name: name}
|
|
}
|
|
}
|
|
}
|
|
return ss, nil
|
|
default:
|
|
ss.confirmDelete = -1
|
|
return ss, nil
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
|
if ss.scope == SessionScopeCwd {
|
|
ss.scope = SessionScopeAll
|
|
} else {
|
|
ss.scope = SessionScopeCwd
|
|
}
|
|
ss.rebuild()
|
|
return ss, nil
|
|
|
|
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+n"))):
|
|
if ss.filter == SessionFilterAll {
|
|
ss.filter = SessionFilterNamed
|
|
} else {
|
|
ss.filter = SessionFilterAll
|
|
}
|
|
ss.rebuild()
|
|
return ss, nil
|
|
|
|
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 result.Cancelled {
|
|
ss.active = false
|
|
return ss, func() tea.Msg {
|
|
return SessionSelectorCancelledMsg{}
|
|
}
|
|
}
|
|
}
|
|
return ss, nil
|
|
}
|
|
|
|
// View implements tea.Model.
|
|
func (ss *SessionSelectorComponent) View() tea.View {
|
|
// 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) {
|
|
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
|
|
|
|
rendered := ss.popup.RenderCentered(ss.width, ss.height)
|
|
v := tea.NewView(rendered)
|
|
v.AltScreen = true
|
|
return v
|
|
}
|
|
|
|
// IsActive returns whether the selector is still accepting input.
|
|
func (ss *SessionSelectorComponent) IsActive() bool {
|
|
return ss.active
|
|
}
|
|
|
|
// --- Internal helpers ---
|
|
|
|
// 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
|
|
} else {
|
|
source = ss.allSessions
|
|
}
|
|
|
|
if ss.filter == SessionFilterNamed {
|
|
var named []session.SessionInfo
|
|
for _, s := range source {
|
|
if s.Name != "" {
|
|
named = append(named, s)
|
|
}
|
|
}
|
|
source = named
|
|
}
|
|
|
|
// 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.popup.SetItems(items)
|
|
ss.syncFiltered()
|
|
}
|
|
|
|
// 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) {
|
|
ss.cwdSessions = removeByPath(ss.cwdSessions, path)
|
|
ss.allSessions = removeByPath(ss.allSessions, path)
|
|
}
|
|
|
|
func removeByPath(sessions []session.SessionInfo, path string) []session.SessionInfo {
|
|
result := make([]session.SessionInfo, 0, len(sessions))
|
|
for _, s := range sessions {
|
|
if s.Path != path {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// 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 cells).
|
|
indicator := " "
|
|
if isCursor {
|
|
indicator = "> "
|
|
}
|
|
|
|
// Right-hand metadata.
|
|
age := relativeTime(info.Modified)
|
|
right := fmt.Sprintf("%d %s", info.MessageCount, age)
|
|
if ss.scope == SessionScopeAll && info.Cwd != "" {
|
|
shortCwd := truncateRunes(shortenPath(info.Cwd), 25)
|
|
right = shortCwd + " " + right
|
|
}
|
|
rightW := lipgloss.Width(right)
|
|
|
|
// Message text width: innerWidth minus indicator(2) minus right minus gap(2).
|
|
availForMsg := max(innerWidth-2-rightW-2, 10)
|
|
|
|
displayText := sessionDisplayName(info)
|
|
displayText = controlCharsRe.ReplaceAllString(displayText, " ")
|
|
displayText = strings.Join(strings.Fields(displayText), " ")
|
|
displayText = truncateRunes(displayText, availForMsg)
|
|
|
|
msgW := lipgloss.Width(displayText)
|
|
spacing := max(innerWidth-2-msgW-rightW, 1)
|
|
|
|
// 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).Bold(true)
|
|
case info.Name != "":
|
|
msgStyle = lipgloss.NewStyle().Foreground(theme.Warning)
|
|
default:
|
|
msgStyle = lipgloss.NewStyle().Foreground(theme.Text)
|
|
}
|
|
if isDeleting {
|
|
rightStyle = lipgloss.NewStyle().Foreground(theme.Error)
|
|
} else {
|
|
rightStyle = lipgloss.NewStyle().Foreground(theme.Muted)
|
|
}
|
|
|
|
return indicator + msgStyle.Render(displayText) + strings.Repeat(" ", spacing) + rightStyle.Render(right)
|
|
}
|
|
|
|
// --- Package helpers ---
|
|
|
|
// sessionDisplayName returns the best display string for a session:
|
|
// the name if set, the first message, or a fallback.
|
|
func sessionDisplayName(info session.SessionInfo) string {
|
|
if info.Name != "" {
|
|
return info.Name
|
|
}
|
|
if info.FirstMessage != "" {
|
|
return info.FirstMessage
|
|
}
|
|
return "(empty session)"
|
|
}
|
|
|
|
// truncateRunes truncates a string to at most maxRunes runes, appending "…"
|
|
// if truncated.
|
|
func truncateRunes(s string, maxRunes int) string {
|
|
if maxRunes <= 0 {
|
|
return ""
|
|
}
|
|
runes := []rune(s)
|
|
if len(runes) <= maxRunes {
|
|
return s
|
|
}
|
|
if maxRunes <= 3 {
|
|
return string(runes[:maxRunes])
|
|
}
|
|
return string(runes[:maxRunes-1]) + "…"
|
|
}
|
|
|
|
// shortenPath replaces the user's home directory prefix with ~.
|
|
func shortenPath(path string) string {
|
|
return tildeHome(path)
|
|
}
|
|
|
|
// relativeTime formats a time as a short relative string like "5m", "2h", "3d".
|
|
func relativeTime(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
return fmt.Sprintf("%dh", int(d.Hours()))
|
|
case d < 7*24*time.Hour:
|
|
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
|
case d < 30*24*time.Hour:
|
|
return fmt.Sprintf("%dw", int(d.Hours()/(24*7)))
|
|
case d < 365*24*time.Hour:
|
|
return fmt.Sprintf("%dmo", int(d.Hours()/(24*30)))
|
|
default:
|
|
return fmt.Sprintf("%dy", int(d.Hours()/(24*365)))
|
|
}
|
|
}
|