forked from molecule-ai/molecule-core
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1) with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo. Brand: Starfire → Molecule AI. Slug: starfire / agent-molecule → molecule. Env vars: STARFIRE_* → MOLECULE_*. Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform. Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent. DB: agentmolecule → molecule. History truncated; see public repo for prior commits and contributor attribution. Verified green: go test -race ./... (platform), pytest (workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
163 lines
3.6 KiB
Go
163 lines
3.6 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// Tab represents the active view tab.
|
|
type Tab int
|
|
|
|
const (
|
|
TabAgents Tab = iota
|
|
TabEvents
|
|
TabHealth
|
|
tabCount // sentinel: always equals the number of tabs
|
|
)
|
|
|
|
func (t Tab) String() string {
|
|
switch t {
|
|
case TabAgents:
|
|
return "Agents"
|
|
case TabEvents:
|
|
return "Events"
|
|
case TabHealth:
|
|
return "Health"
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
// Model is the top-level bubbletea model.
|
|
type Model struct {
|
|
// Data
|
|
workspaces []WorkspaceInfo
|
|
events []WSEvent // Real-time events (from WS + initial fetch)
|
|
eventIDs map[string]struct{} // Deduplicates HTTP-fetched EventInfo by ID; WS events (no ID) bypass this
|
|
|
|
// UI state
|
|
activeTab Tab
|
|
selected int // Index in filtered workspace list
|
|
filter string // Name filter text
|
|
filtering bool // Whether filter input is active
|
|
|
|
// Connection state.
|
|
// wsConn is a pointer to mutable shared state — intentional, since bubbletea
|
|
// copies Model by value but the pointer keeps the connection shared between
|
|
// the model and the background listenWS goroutine.
|
|
baseURL string
|
|
client *PlatformClient
|
|
wsConn *websocket.Conn
|
|
wsReady bool
|
|
wsGen int // connection generation; incremented on each new WS connection
|
|
|
|
// Dimensions
|
|
width int
|
|
height int
|
|
|
|
// Status
|
|
lastRefresh *time.Time // nil until first successful fetch
|
|
errMsg string
|
|
|
|
// Confirm delete
|
|
confirmDelete bool
|
|
|
|
// Spawn form (multi-step)
|
|
spawning bool
|
|
spawnStep int // 0=name, 1=role, 2=tier
|
|
spawnName string
|
|
spawnRole string
|
|
spawnTierStr string
|
|
|
|
// Edit form (multi-step, pre-filled from selected workspace)
|
|
editing bool
|
|
editStep int // 0=name, 1=role, 2=tier
|
|
editName string
|
|
editRole string
|
|
editTierStr string
|
|
|
|
// Event log scroll offset (Events tab)
|
|
eventScroll int
|
|
|
|
// Chat mode (A2A)
|
|
chatting bool
|
|
chatWorkspaceID string
|
|
chatWorkspaceName string
|
|
chatURL string
|
|
chatHistory []ChatMsg
|
|
chatInput string
|
|
chatWaiting bool
|
|
chatScroll int // how many lines scrolled up from bottom
|
|
}
|
|
|
|
// ChatMsg is a single turn in a chat session.
|
|
type ChatMsg struct {
|
|
Role string // "you" or "agent"
|
|
Text string
|
|
}
|
|
|
|
// NewModel creates the initial model.
|
|
func NewModel(baseURL string) Model {
|
|
return Model{
|
|
baseURL: baseURL,
|
|
client: NewPlatformClient(baseURL),
|
|
eventIDs: make(map[string]struct{}),
|
|
wsGen: 1, // start at 1 so Gen==0 is never valid — no special case needed
|
|
}
|
|
}
|
|
|
|
// filteredWorkspaces returns workspaces matching the current filter.
|
|
func (m Model) filteredWorkspaces() []WorkspaceInfo {
|
|
if m.filter == "" {
|
|
return m.workspaces
|
|
}
|
|
f := strings.ToLower(m.filter)
|
|
var result []WorkspaceInfo
|
|
for _, w := range m.workspaces {
|
|
if strings.Contains(strings.ToLower(w.Name), f) {
|
|
result = append(result, w)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// selectedWorkspace returns the currently selected workspace, or nil.
|
|
func (m Model) selectedWorkspace() *WorkspaceInfo {
|
|
filtered := m.filteredWorkspaces()
|
|
if m.selected < 0 || m.selected >= len(filtered) {
|
|
return nil
|
|
}
|
|
ws := filtered[m.selected]
|
|
return &ws
|
|
}
|
|
|
|
// statusCounts returns counts by status.
|
|
func (m Model) statusCounts() (online, degraded, offline, provisioning int) {
|
|
for _, w := range m.workspaces {
|
|
switch w.Status {
|
|
case "online":
|
|
online++
|
|
case "degraded":
|
|
degraded++
|
|
case "offline":
|
|
offline++
|
|
case "provisioning":
|
|
provisioning++
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// clampSelected ensures selected index is within bounds.
|
|
func (m *Model) clampSelected() {
|
|
filtered := m.filteredWorkspaces()
|
|
if m.selected >= len(filtered) {
|
|
m.selected = len(filtered) - 1
|
|
}
|
|
if m.selected < 0 {
|
|
m.selected = 0
|
|
}
|
|
}
|