ai-agent/internal/tui/sidepanel.go
admin 8dc496b626
Some checks failed
CI / test (push) Has been cancelled
Release / release (push) Failing after 4m36s
first commit
2026-03-08 15:40:34 +07:00

403 lines
12 KiB
Go

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
}