// 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" "os/exec" "strings" "syscall" "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.IntentReloadAgent: return a.reloadAgent(intent.AgentID) case puretui.IntentReloadAll: return a.reloadAll() 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.runGoTests() case puretui.IntentRunGoTests: return a.runGoTests() case puretui.IntentRunE2ETests: return a.runE2ETests(false) case puretui.IntentRunE2EHeadTests: return a.runE2ETests(true) case puretui.IntentRunAllTests: return a.runAllTests() 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} } } // reloadAgent hot-reloads a single agent via SIGHUP without stopping the launcher. func (a *Adapter) reloadAgent(id string) tea.Cmd { return func() tea.Msg { pid := a.mgr.UnifiedPID() if pid <= 0 { return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: fmt.Errorf("el launcher no está corriendo")} } if id != "" { if err := os.WriteFile("run/reload.txt", []byte(id), 0o644); err != nil { return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: err} } } err := syscall.Kill(pid, syscall.SIGHUP) if err != nil { return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: err} } time.Sleep(1 * time.Second) return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: nil} } } // reloadAll hot-reloads all agents via SIGHUP (no reload.txt → reload all). func (a *Adapter) reloadAll() tea.Cmd { return func() tea.Msg { pid := a.mgr.UnifiedPID() if pid <= 0 { return puretui.MsgServerActionDone{Action: "Reload All", Err: fmt.Errorf("el launcher no está corriendo")} } // Remove stale reload.txt so the launcher reloads all agents. _ = os.Remove("run/reload.txt") err := syscall.Kill(pid, syscall.SIGHUP) if err != nil { return puretui.MsgServerActionDone{Action: "Reload All", Err: err} } time.Sleep(1 * time.Second) return puretui.MsgServerActionDone{Action: "Reload All", Err: nil} } } // restartAgent stops and restarts the whole launcher (full restart, all agents). 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) runGoTests() tea.Cmd { return func() tea.Msg { goBin, err := exec.LookPath("go") if err != nil { 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") return puretui.MsgTestsDone{Kind: puretui.TestKindGo, Passed: err == nil, Output: lines} } } func (a *Adapter) runE2ETests(headed bool) tea.Cmd { return func() tea.Msg { args := []string{"./dev-scripts/e2e/run.sh"} if headed { args = append(args, "--headed") } cmd := exec.Command("bash", args...) 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") kind := puretui.TestKindE2E if headed { kind = puretui.TestKindE2EHead } return puretui.MsgTestsDone{Kind: kind, Passed: err == nil, Output: lines} } } func (a *Adapter) runAllTests() tea.Cmd { return func() tea.Msg { var allLines []string // Go tests first goBin, err := exec.LookPath("go") if err != nil { goBin = "/usr/local/go/bin/go" } goCmd := exec.Command(goBin, "test", "-tags", "goolm", "-count=1", "./...") goCmd.Env = a.mgr.BuildEnv() goOut, goErr := goCmd.CombinedOutput() allLines = append(allLines, "═══ Go Tests ═══") goOutput := strings.TrimSpace(string(goOut)) if goOutput == "" && goErr != nil { goOutput = "Error: " + goErr.Error() } allLines = append(allLines, strings.Split(goOutput, "\n")...) if goErr != nil { allLines = append(allLines, "", "Go tests FAILED — skipping E2E") return puretui.MsgTestsDone{Kind: puretui.TestKindAll, Passed: false, Output: allLines} } // E2E tests allLines = append(allLines, "", "═══ E2E Tests ═══") e2eCmd := exec.Command("bash", "./dev-scripts/e2e/run.sh") e2eCmd.Env = a.mgr.BuildEnv() e2eOut, e2eErr := e2eCmd.CombinedOutput() e2eOutput := strings.TrimSpace(string(e2eOut)) if e2eOutput == "" && e2eErr != nil { e2eOutput = "Error: " + e2eErr.Error() } allLines = append(allLines, strings.Split(e2eOutput, "\n")...) return puretui.MsgTestsDone{Kind: puretui.TestKindAll, Passed: e2eErr == nil, Output: allLines} } } 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) } }