// 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" "os/exec" "strings" "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.IntentEnableAgent: return a.enableAgent(intent.AgentID) case puretui.IntentDisableAgent: return a.disableAgent(intent.AgentID) case puretui.IntentRestartAgent: return a.restartAgent(intent.AgentID) case puretui.IntentLoadLogs: return a.loadLogs(intent.AgentID) case puretui.IntentStartLauncher: return a.startLauncher() case puretui.IntentStopLauncher: return a.stopLauncher() case puretui.IntentRestartLauncher: return a.restartLauncher() case puretui.IntentKillLauncher: return a.killLauncher() case puretui.IntentRebuildRestart: return a.rebuildRestart() case puretui.IntentRunTests: return a.runTests() 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.StatusAllUnified() if err != nil { return puretui.MsgAgentsLoaded{} } views := make([]puretui.AgentView, len(statuses)) for i, s := range statuses { views[i] = puretui.AgentView{ ID: s.ID, Name: s.Name, Version: s.Version, Desc: s.Desc, Enabled: s.Enabled, Running: s.Running, PID: s.PID, } } msg := puretui.MsgAgentsLoaded{ Agents: views, LauncherRunning: a.mgr.IsUnifiedRunning(), LauncherPID: a.mgr.UnifiedPID(), } // Launcher stats if msg.LauncherRunning { if stats, err := a.mgr.UnifiedStats(); err == nil { msg.LauncherUptime = formatUptime(stats.UptimeSecs) msg.LauncherMemory = formatBytes(stats.MemRSSKB * 1024) msg.LauncherCPU = fmt.Sprintf("%.1f%%", stats.CPUPct) msg.LauncherLogSize = formatBytes(stats.LogBytes) } } return msg } } func (a *Adapter) enableAgent(id string) tea.Cmd { return func() tea.Msg { err := a.mgr.ToggleEnabled(id, true) return puretui.MsgActionDone{AgentID: id, Action: "Enable", Err: err} } } func (a *Adapter) disableAgent(id string) tea.Cmd { return func() tea.Msg { err := a.mgr.ToggleEnabled(id, false) return puretui.MsgActionDone{AgentID: id, Action: "Disable", Err: err} } } func (a *Adapter) restartAgent(id string) tea.Cmd { return func() tea.Msg { _ = a.mgr.StopUnified() time.Sleep(500 * time.Millisecond) err := a.mgr.StartUnified() if err == nil { time.Sleep(500 * time.Millisecond) } return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err} } } func (a *Adapter) startLauncher() tea.Cmd { return func() tea.Msg { err := a.mgr.StartUnified() if err == nil { time.Sleep(500 * time.Millisecond) } return puretui.MsgServerActionDone{Action: "Start", Err: err} } } func (a *Adapter) stopLauncher() tea.Cmd { return func() tea.Msg { err := a.mgr.StopUnified() return puretui.MsgServerActionDone{Action: "Stop", Err: err} } } func (a *Adapter) restartLauncher() tea.Cmd { return func() tea.Msg { _ = a.mgr.StopUnified() time.Sleep(500 * time.Millisecond) err := a.mgr.StartUnified() if err == nil { time.Sleep(500 * time.Millisecond) } return puretui.MsgServerActionDone{Action: "Restart", Err: err} } } func (a *Adapter) killLauncher() tea.Cmd { return func() tea.Msg { err := a.mgr.KillUnified() return puretui.MsgServerActionDone{Action: "Kill", Err: err} } } func (a *Adapter) rebuildRestart() tea.Cmd { return func() tea.Msg { wasRunning := a.mgr.IsUnifiedRunning() // Stop if running if wasRunning { _ = a.mgr.StopUnified() time.Sleep(500 * time.Millisecond) } // Build buildOut, buildErr := a.mgr.Build() if buildErr != nil { // Build failed — try to restart if was running if wasRunning { _ = a.mgr.StartUnified() } lines := strings.Split(strings.TrimSpace(buildOut), "\n") tail := buildOut if len(lines) > 5 { tail = strings.Join(lines[len(lines)-5:], "\n") } return puretui.MsgRebuildDone{BuildOK: false, BuildLog: tail} } // Restart launcher started := false var startErr error if wasRunning { startErr = a.mgr.StartUnified() if startErr == nil { started = true time.Sleep(500 * time.Millisecond) } } return puretui.MsgRebuildDone{ BuildOK: true, Started: started, Err: startErr, } } } func (a *Adapter) loadLogs(id string) tea.Cmd { return func() tea.Msg { var lines []string var err error if id == "" { // Launcher logs lines, err = a.mgr.UnifiedLogTail(100) } else { // Agent logs — in unified mode, all go to launcher log lines, err = a.mgr.UnifiedLogTail(100) } if err != nil { return puretui.MsgLogsLoaded{Lines: []string{"Error: " + err.Error()}} } return puretui.MsgLogsLoaded{Lines: lines} } } func (a *Adapter) runTests() tea.Cmd { return func() tea.Msg { goBin, err := exec.LookPath("go") if err != nil { // Fallback to known location goBin = "/usr/local/go/bin/go" } cmd := exec.Command(goBin, "test", "-tags", "goolm", "-count=1", "./...") cmd.Env = a.mgr.BuildEnv() out, err := cmd.CombinedOutput() output := strings.TrimSpace(string(out)) if output == "" && err != nil { output = "Error: " + err.Error() } lines := strings.Split(output, "\n") passed := err == nil return puretui.MsgTestsDone{Passed: passed, Output: 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) } }