// Package tui is the impure shell layer for the TUI. // It converts pure Intent values into real I/O via tea.Cmd. package tui import ( "fmt" "time" tea "github.com/charmbracelet/bubbletea" puretui "github.com/enmanuel/agents/pkg/tui" "github.com/enmanuel/agents/shell/process" ) // Adapter bridges pure Intents with the process Manager. type Adapter struct { mgr *process.Manager } // NewAdapter creates an Adapter with the given Manager. func NewAdapter(mgr *process.Manager) *Adapter { return &Adapter{mgr: mgr} } // RunIntent converts a pure Intent into a bubbletea Cmd that performs I/O. func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd { switch intent.Kind { case puretui.IntentLoadAgents: return a.loadAgents() case puretui.IntentStartAgent: return a.startAgent(intent.AgentID) case puretui.IntentStopAgent: return a.stopAgent(intent.AgentID) case puretui.IntentKillAgent: return a.killAgent(intent.AgentID) case puretui.IntentRestartAgent: return a.restartAgent(intent.AgentID) case puretui.IntentLoadLogs: return a.loadLogs(intent.AgentID) case puretui.IntentStartAll: return a.startAll() case puretui.IntentStopAll: return a.stopAll() case puretui.IntentRestartAll: return a.restartAll() case puretui.IntentKillAll: return a.killAll() case puretui.IntentTick: return a.tick() case puretui.IntentQuit: return tea.Quit default: return nil } } func (a *Adapter) loadAgents() tea.Cmd { return func() tea.Msg { statuses, err := a.mgr.StatusAll() if err != nil { return puretui.MsgAgentsLoaded{} } views := make([]puretui.AgentView, len(statuses)) for i, s := range statuses { v := puretui.AgentView{ ID: s.ID, Name: s.Name, Version: s.Version, Desc: s.Desc, Enabled: s.Enabled, Running: s.Running, PID: s.PID, Instances: a.mgr.InstanceCount(s.ID), } if s.Running { if stats, err := a.mgr.Stats(s.ID); err == nil { v.Uptime = formatUptime(stats.UptimeSecs) v.Memory = formatBytes(stats.MemRSSKB * 1024) v.CPU = fmt.Sprintf("%.1f%%", stats.CPUPct) v.LogSize = formatBytes(stats.LogBytes) } } views[i] = v } return puretui.MsgAgentsLoaded{Agents: views} } } func (a *Adapter) startAgent(id string) tea.Cmd { return func() tea.Msg { agents, err := a.mgr.Scan() if err != nil { return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err} } for _, agent := range agents { if agent.ID == id { err = a.mgr.Start(agent) // Give the process a moment to start. if err == nil { time.Sleep(500 * time.Millisecond) } return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err} } } return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: fmt.Errorf("agent not found")} } } func (a *Adapter) stopAgent(id string) tea.Cmd { return func() tea.Msg { err := a.mgr.Stop(id) return puretui.MsgActionDone{AgentID: id, Action: "Stop", Err: err} } } func (a *Adapter) killAgent(id string) tea.Cmd { return func() tea.Msg { err := a.mgr.Kill(id) return puretui.MsgActionDone{AgentID: id, Action: "Kill", Err: err} } } func (a *Adapter) restartAgent(id string) tea.Cmd { return func() tea.Msg { // Stop first (ignore error if not running) _ = a.mgr.Stop(id) time.Sleep(300 * time.Millisecond) agents, err := a.mgr.Scan() if err != nil { return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err} } for _, agent := range agents { if agent.ID == id { err = a.mgr.Start(agent) if err == nil { time.Sleep(500 * time.Millisecond) } return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err} } } return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: fmt.Errorf("agent not found")} } } func (a *Adapter) startAll() tea.Cmd { return func() tea.Msg { agents, err := a.mgr.Scan() if err != nil { return puretui.MsgServerActionDone{Action: "Start All", Errors: []string{err.Error()}, Failed: 1} } var total, failed int var errs []string for _, agent := range agents { if !agent.Enabled { continue } if a.mgr.IsRunning(agent.ID) { continue } total++ if err := a.mgr.Start(agent); err != nil { failed++ errs = append(errs, fmt.Sprintf("%s: %v", agent.ID, err)) } } if total > 0 { time.Sleep(500 * time.Millisecond) } return puretui.MsgServerActionDone{Action: "Start All", Total: total, Failed: failed, Errors: errs} } } func (a *Adapter) stopAll() tea.Cmd { return func() tea.Msg { statuses, err := a.mgr.StatusAll() if err != nil { return puretui.MsgServerActionDone{Action: "Stop All", Errors: []string{err.Error()}, Failed: 1} } var total, failed int var errs []string for _, s := range statuses { if !s.Running { continue } total++ if err := a.mgr.Stop(s.ID); err != nil { failed++ errs = append(errs, fmt.Sprintf("%s: %v", s.ID, err)) } } return puretui.MsgServerActionDone{Action: "Stop All", Total: total, Failed: failed, Errors: errs} } } func (a *Adapter) restartAll() tea.Cmd { return func() tea.Msg { agents, err := a.mgr.Scan() if err != nil { return puretui.MsgServerActionDone{Action: "Restart All", Errors: []string{err.Error()}, Failed: 1} } // Stop all running first for _, agent := range agents { if agent.Enabled && a.mgr.IsRunning(agent.ID) { _ = a.mgr.Stop(agent.ID) } } time.Sleep(300 * time.Millisecond) // Start all enabled var total, failed int var errs []string for _, agent := range agents { if !agent.Enabled { continue } total++ if err := a.mgr.Start(agent); err != nil { failed++ errs = append(errs, fmt.Sprintf("%s: %v", agent.ID, err)) } } if total > 0 { time.Sleep(500 * time.Millisecond) } return puretui.MsgServerActionDone{Action: "Restart All", Total: total, Failed: failed, Errors: errs} } } func (a *Adapter) killAll() tea.Cmd { return func() tea.Msg { statuses, err := a.mgr.StatusAll() if err != nil { return puretui.MsgServerActionDone{Action: "Kill All", Errors: []string{err.Error()}, Failed: 1} } var total, failed int var errs []string for _, s := range statuses { if !s.Running { continue } total++ if err := a.mgr.Kill(s.ID); err != nil { failed++ errs = append(errs, fmt.Sprintf("%s: %v", s.ID, err)) } } return puretui.MsgServerActionDone{Action: "Kill All", Total: total, Failed: failed, Errors: errs} } } func (a *Adapter) loadLogs(id string) tea.Cmd { return func() tea.Msg { lines, err := a.mgr.LogTail(id, 100) if err != nil { return puretui.MsgLogsLoaded{Lines: []string{"Error: " + err.Error()}} } return puretui.MsgLogsLoaded{Lines: lines} } } func (a *Adapter) tick() tea.Cmd { return tea.Tick(3*time.Second, func(time.Time) tea.Msg { return puretui.MsgTick{} }) } // ── formatting helpers ─────────────────────────────────────────────────── func formatUptime(secs int64) string { if secs < 0 { return "n/a" } d := secs / 86400 h := (secs % 86400) / 3600 m := (secs % 3600) / 60 if d > 0 { return fmt.Sprintf("%dd %dh", d, h) } if h > 0 { return fmt.Sprintf("%dh %dm", h, m) } return fmt.Sprintf("%dm", m) } func formatBytes(bytes int64) string { switch { case bytes >= 1<<30: return fmt.Sprintf("%.1f GB", float64(bytes)/float64(1<<30)) case bytes >= 1<<20: return fmt.Sprintf("%.1f MB", float64(bytes)/float64(1<<20)) case bytes >= 1<<10: return fmt.Sprintf("%.1f KB", float64(bytes)/float64(1<<10)) default: return fmt.Sprintf("%d B", bytes) } }