package tui import ( "fmt" "strings" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) func sanitizeDetail(detail string) string { detail = strings.TrimSpace(detail) if len(detail) > 0 && (detail[0] == '{' || detail[0] == '[') { return "error details available in logs" } detail = strings.ReplaceAll(detail, "\n", " ") detail = strings.ReplaceAll(detail, "\r", " ") for strings.Contains(detail, " ") { detail = strings.ReplaceAll(detail, " ", " ") } return strings.TrimSpace(detail) } type SidePanelSectionKind int const ( SidePanelLogo SidePanelSectionKind = iota SidePanelModels SidePanelServers SidePanelICE SidePanelQuickActions SidePanelStartup ) type SidePanelItem struct { Title string Subtitle string Kind SidePanelSectionKind Icon string Status string ID string Selectable bool IsCurrent bool } func (i SidePanelItem) TitleText() string { prefix := "" if i.Icon != "" { prefix = i.Icon + " " } if i.IsCurrent { prefix = "→ " } return prefix + i.Title } func (i SidePanelItem) Description() string { return i.Subtitle } func (i SidePanelItem) FilterValue() string { return i.Title } type SidePanelSection struct { Title string Kind SidePanelSectionKind Items []SidePanelItem Expanded bool } type SidePanelModel struct { sections []SidePanelSection startupItems []StartupItem width int height int cursor int selected int styles SidePanelStyles spinner spinner.Model visible bool isDark bool } type StartupItem struct { Label string Status string Detail string } type SidePanelStyles struct { Border lipgloss.Style Title lipgloss.Style Section lipgloss.Style Item lipgloss.Style Selected lipgloss.Style Current lipgloss.Style Connected lipgloss.Style Failed lipgloss.Style Dimmed lipgloss.Style Logo lipgloss.Style LogoTagline lipgloss.Style } func DefaultSidePanelStyles(isDark bool) SidePanelStyles { return SidePanelStyles{ Border: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")), Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#88c0d0")), Section: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#81a1c1")), Item: lipgloss.NewStyle().Foreground(lipgloss.Color("#d8dee9")), Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#88c0d0")), Current: lipgloss.NewStyle().Foreground(lipgloss.Color("#a3be8c")), Connected: lipgloss.NewStyle().Foreground(lipgloss.Color("#a3be8c")), Failed: lipgloss.NewStyle().Foreground(lipgloss.Color("#bf616a")), Dimmed: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")), Logo: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#88c0d0")), LogoTagline: lipgloss.NewStyle().Foreground(lipgloss.Color("#4c566a")), } } func NewSidePanelModel(isDark bool) SidePanelModel { s := spinner.New( spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#88c0d0"))), ) return SidePanelModel{ visible: true, isDark: isDark, styles: DefaultSidePanelStyles(isDark), cursor: 0, selected: 0, spinner: s, } } func (m *SidePanelModel) SetDark(isDark bool) { m.isDark = isDark m.styles = DefaultSidePanelStyles(isDark) } func (m *SidePanelModel) SetSpinnerTick() { // No-op - spinner advances via Update with TickMsg } func (m *SidePanelModel) TickSpinner() tea.Cmd { return m.spinner.Tick } func (m *SidePanelModel) Tick() { m.spinner.Tick() } func (m *SidePanelModel) SetWidth(w int) { m.width = w } func (m *SidePanelModel) SetHeight(h int) { m.height = h } func (m *SidePanelModel) SetStartupItems(items []StartupItem) { m.startupItems = items } func (m *SidePanelModel) Toggle() { m.visible = !m.visible } func (m *SidePanelModel) Show() { m.visible = true } func (m *SidePanelModel) Hide() { m.visible = false } func (m *SidePanelModel) IsVisible() bool { return m.visible } func (m *SidePanelModel) UpdateSections(lang Lang, modelName string, modelList []string, serverCount int, toolCount int, iceEnabled bool, iceConversations int) { loc := Locale(lang) m.sections = []SidePanelSection{ { Title: loc.SidePanelAIAgent, Kind: SidePanelLogo, Expanded: true, Items: []SidePanelItem{ { Title: loc.SidePanelAIAgent, Subtitle: loc.SidePanelTagline, Kind: SidePanelLogo, Icon: "⬡", }, }, }, { Title: loc.SidePanelModels, Kind: SidePanelModels, Expanded: true, Items: []SidePanelItem{}, }, { Title: loc.SidePanelServers, Kind: SidePanelServers, Expanded: true, Items: []SidePanelItem{}, }, { Title: loc.SidePanelICE, Kind: SidePanelICE, Expanded: true, Items: []SidePanelItem{}, }, { Title: loc.SidePanelQuickActions, Kind: SidePanelQuickActions, Expanded: true, Items: []SidePanelItem{ {Title: loc.SidePanelHelp, Subtitle: loc.SidePanelHelpDesc, Kind: SidePanelQuickActions, Icon: "?", Selectable: true, ID: "help"}, {Title: loc.SidePanelServers, Subtitle: loc.SidePanelServersDesc, Kind: SidePanelQuickActions, Icon: "◈", Selectable: true, ID: "servers"}, {Title: loc.SidePanelModels, Subtitle: loc.SidePanelModelDesc, Kind: SidePanelQuickActions, Icon: "◈", Selectable: true, ID: "model"}, {Title: loc.SidePanelLoad, Subtitle: loc.SidePanelLoadDesc, Kind: SidePanelQuickActions, Icon: "◈", Selectable: true, ID: "load"}, {Title: loc.Language, Subtitle: loc.LanguageF2, Kind: SidePanelQuickActions, Icon: "◈", Selectable: true, ID: "language"}, }, }, } for _, model := range modelList { item := SidePanelItem{ Title: model, Kind: SidePanelModels, Icon: "◦", Selectable: true, ID: model, IsCurrent: model == modelName, } if model == modelName { item.Icon = "→" } m.sections[1].Items = append(m.sections[1].Items, item) } if serverCount > 0 { m.sections[2].Items = append(m.sections[2].Items, SidePanelItem{ Title: fmt.Sprintf(loc.ToolsConnected, toolCount), Kind: SidePanelServers, Icon: "✓", Selectable: false, }) } else { m.sections[2].Items = append(m.sections[2].Items, SidePanelItem{ Title: loc.NoServersConnected, Kind: SidePanelServers, Icon: "○", Selectable: false, }) } if iceEnabled { m.sections[3].Items = append(m.sections[3].Items, SidePanelItem{ Title: fmt.Sprintf(loc.ICEConversations, iceConversations), Subtitle: loc.ICECrossSessionActive, Kind: SidePanelICE, Icon: "✓", Selectable: false, }) } else { m.sections[3].Items = append(m.sections[3].Items, SidePanelItem{ Title: loc.ICEDisabled, Subtitle: loc.ICECrossSessionInactive, Kind: SidePanelICE, Icon: "○", Selectable: false, }) } } func (m *SidePanelModel) ToggleSection(index int) { if index >= 0 && index < len(m.sections) { m.sections[index].Expanded = !m.sections[index].Expanded } } func (m SidePanelModel) Init() tea.Cmd { return nil } func (m SidePanelModel) Update(msg tea.Msg) (SidePanelModel, tea.Cmd) { return m, nil } func (m SidePanelModel) View() string { if !m.visible { return "" } width := m.width if width < 25 { width = 25 } var b strings.Builder b.WriteString("\n") b.WriteString(m.styles.Logo.Render(" AI AGENT")) b.WriteString("\n") b.WriteString(m.styles.LogoTagline.Render(" 100% local")) b.WriteString("\n\n") if len(m.startupItems) > 0 { var hasPending bool for _, item := range m.startupItems { if item.Status == "connecting" || item.Status == "pending" { hasPending = true break } } if hasPending { b.WriteString(m.styles.Section.Render(" " + m.spinner.View() + " Connecting...")) b.WriteString("\n\n") } else { b.WriteString(m.styles.Section.Render(" Initializing...")) b.WriteString("\n\n") } for _, item := range m.startupItems { icon := "○" iconStyle := m.styles.Item switch item.Status { case "connecting": icon = "◌" iconStyle = m.styles.Section case "connected": icon = "✓" iconStyle = m.styles.Connected case "failed": icon = "✗" iconStyle = m.styles.Failed } line := fmt.Sprintf(" %s %s", icon, item.Label) if item.Detail != "" { detail := sanitizeDetail(item.Detail) maxDetail := m.width - 15 if len(detail) > maxDetail && maxDetail > 5 { detail = detail[:maxDetail-3] + "..." } line += m.styles.Dimmed.Render(" · " + detail) } b.WriteString(iconStyle.Render(line)) b.WriteString("\n") } b.WriteString("\n") } for sectionIdx := 1; sectionIdx < len(m.sections); sectionIdx++ { section := m.sections[sectionIdx] icon := "▶" if section.Expanded { icon = "▼" } header := fmt.Sprintf(" %s %s", icon, section.Title) b.WriteString(m.styles.Section.Render(header)) b.WriteString("\n") if section.Expanded { for itemIdx, item := range section.Items { prefix := " " if item.Icon != "" { prefix = fmt.Sprintf(" %s ", item.Icon) } itemStyle := m.styles.Item if item.IsCurrent { itemStyle = m.styles.Current } line := prefix + item.Title if item.Subtitle != "" && section.Kind != SidePanelLogo { subtitle := item.Subtitle maxSub := m.width - len(prefix) - len(item.Title) - 3 if len(subtitle) > maxSub && maxSub > 5 { subtitle = subtitle[:maxSub-3] + "..." } line += m.styles.Dimmed.Render(" · " + subtitle) } if section.Kind == SidePanelLogo && itemIdx == 0 { b.WriteString(m.styles.LogoTagline.Render(" " + item.Subtitle)) } else { b.WriteString(itemStyle.Render(line)) } b.WriteString("\n") } } b.WriteString("\n") } b.WriteString("\n") b.WriteString(m.styles.Dimmed.Render(" ────────────────────────")) b.WriteString("\n") b.WriteString(m.styles.Dimmed.Render(" ctrl+b: toggle")) return b.String() } func (s SidePanelSection) TitleText() string { return s.Title } func (s SidePanelSection) Description() string { return "" } func (s SidePanelSection) FilterValue() string { return s.Title }