403 lines
12 KiB
Go
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
|
|
}
|