package tui import "fmt" // IntentKind represents a side effect the shell must perform. type IntentKind string const ( IntentLoadAgents IntentKind = "load_agents" IntentLoadLogs IntentKind = "load_logs" IntentTick IntentKind = "tick" IntentQuit IntentKind = "quit" // Agent-level IntentEnableAgent IntentKind = "enable_agent" IntentDisableAgent IntentKind = "disable_agent" IntentReloadAgent IntentKind = "reload_agent" // hot-reload via SIGHUP (solo este agente) IntentReloadAll IntentKind = "reload_all" // hot-reload via SIGHUP (todos los agentes) IntentRestartAgent IntentKind = "restart_agent" // restart completo del launcher // Unified launcher operations IntentStartLauncher IntentKind = "start_launcher" IntentStopLauncher IntentKind = "stop_launcher" IntentRestartLauncher IntentKind = "restart_launcher" IntentKillLauncher IntentKind = "kill_launcher" IntentRebuildRestart IntentKind = "rebuild_restart" IntentRunTests IntentKind = "run_tests" IntentRunGoTests IntentKind = "run_go_tests" IntentRunE2ETests IntentKind = "run_e2e_tests" IntentRunE2EHeadTests IntentKind = "run_e2e_head_tests" IntentRunAllTests IntentKind = "run_all_tests" ) // Intent is pure data describing a side effect to execute. type Intent struct { Kind IntentKind AgentID string } // KeyMsg is the pure representation of a key press. // The bridge layer converts tea.KeyMsg into this. type KeyMsg struct { Str string // "up", "down", "enter", "0", "q", "r", etc. } // WindowSizeMsg carries terminal dimensions. type WindowSizeMsg struct { Width int Height int } // Update is PURE: (Model, msg) → (Model, []Intent). No side effects. func Update(model Model, msg interface{}) (Model, []Intent) { switch m := msg.(type) { case WindowSizeMsg: model.WindowWidth = m.Width model.WindowHeight = m.Height return model, nil case MsgAgentsLoaded: model.Agents = m.Agents model.LauncherRunning = m.LauncherRunning model.LauncherPID = m.LauncherPID model.LauncherUptime = m.LauncherUptime model.LauncherMemory = m.LauncherMemory model.LauncherCPU = m.LauncherCPU model.LauncherLogSize = m.LauncherLogSize if model.Screen == ScreenAgentList { if model.Cursor >= len(model.Agents) && len(model.Agents) > 0 { model.Cursor = len(model.Agents) - 1 } } return model, []Intent{{Kind: IntentTick}} case MsgActionDone: if m.Err != nil { model.StatusMsg = fmt.Sprintf("Error: %s %s: %v", m.Action, m.AgentID, m.Err) } else if m.Action == "Reload" { model.StatusMsg = fmt.Sprintf("Reload OK — %s recargado sin interrupciones", m.AgentID) } else if m.Action == "Restart" { model.StatusMsg = "Restart OK — launcher reiniciado" } else { model.StatusMsg = fmt.Sprintf("%s %s OK — restart launcher to apply", m.Action, m.AgentID) } return model, []Intent{{Kind: IntentLoadAgents}} case MsgServerActionDone: if m.Err != nil { model.StatusMsg = fmt.Sprintf("Error: %s: %v", m.Action, m.Err) } else if m.Action == "Reload All" { model.StatusMsg = "Reload All OK — SIGHUP enviado al launcher" } else { model.StatusMsg = fmt.Sprintf("%s OK", m.Action) } return model, []Intent{{Kind: IntentLoadAgents}} case MsgRebuildDone: if !m.BuildOK { model.StatusMsg = fmt.Sprintf("Build failed: %s", m.BuildLog) } else if m.Err != nil { model.StatusMsg = fmt.Sprintf("Built OK, start failed: %v", m.Err) } else if m.Started { model.StatusMsg = "Built OK, launcher started" } else { model.StatusMsg = "Built OK" } return model, []Intent{{Kind: IntentLoadAgents}} case MsgLogsLoaded: model.LogLines = m.Lines model.LogScroll = max(0, len(m.Lines)-visibleLogLines(model)) return model, nil case MsgTestsDone: model.Screen = ScreenTestOutput model.LogLines = m.Output model.LogScroll = 0 model.Cursor = 0 model.LastTestKind = m.Kind label := testKindLabel(m.Kind) if m.Passed { model.StatusMsg = label + " PASSED" } else { model.StatusMsg = label + " FAILED" } return model, nil case MsgTick: return model, []Intent{{Kind: IntentLoadAgents}} case KeyMsg: return updateKey(model, m) } return model, nil } func updateKey(model Model, key KeyMsg) (Model, []Intent) { if key.Str == "q" && model.Screen == ScreenMain { return model, []Intent{{Kind: IntentQuit}} } if key.Str == "ctrl+c" { return model, []Intent{{Kind: IntentQuit}} } switch model.Screen { case ScreenMain: return updateMainScreen(model, key) case ScreenAgentList: return updateAgentList(model, key) case ScreenAgentActions: return updateAgentActions(model, key) case ScreenLogs: return updateLogs(model, key) case ScreenServer: return updateServerScreen(model, key) case ScreenTests: return updateTestsScreen(model, key) case ScreenTestOutput: return updateTestOutput(model, key) } return model, nil } func updateMainScreen(model Model, key KeyMsg) (Model, []Intent) { opts := MainMenuOptions() switch key.Str { case "up", "k": model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1) case "down", "j": model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1) case "enter": switch opts[model.Cursor].Label { case "Agents": model.Screen = ScreenAgentList model.Cursor = 0 return model, []Intent{{Kind: IntentLoadAgents}} case "Server": model.Screen = ScreenServer model.Cursor = 0 model.StatusMsg = "" return model, []Intent{{Kind: IntentLoadAgents}} case "Tests": model.Screen = ScreenTests model.Cursor = 0 model.StatusMsg = "" return model, nil case "Quit": return model, []Intent{{Kind: IntentQuit}} } } return model, nil } func updateAgentList(model Model, key KeyMsg) (Model, []Intent) { switch key.Str { case "0": model.Screen = ScreenMain model.Cursor = 0 model.StatusMsg = "" case "up", "k": model.Cursor = clamp(model.Cursor-1, 0, max(0, len(model.Agents)-1)) case "down", "j": model.Cursor = clamp(model.Cursor+1, 0, max(0, len(model.Agents)-1)) case "enter": if model.Cursor < len(model.Agents) { sel := model.Agents[model.Cursor] model.Selected = &sel model.Screen = ScreenAgentActions model.Cursor = 0 model.StatusMsg = "" } } return model, nil } func updateAgentActions(model Model, key KeyMsg) (Model, []Intent) { if model.Selected == nil { model.Screen = ScreenAgentList return model, nil } opts := AgentActionOptions(model.Selected.Enabled) switch key.Str { case "0": model.Screen = ScreenAgentList model.Cursor = 0 model.Selected = nil model.StatusMsg = "" return model, []Intent{{Kind: IntentLoadAgents}} case "up", "k": model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1) case "down", "j": model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1) case "enter": if model.Cursor < len(opts) { return executeAction(model, opts[model.Cursor].Label) } } return model, nil } func executeAction(model Model, action string) (Model, []Intent) { id := model.Selected.ID switch action { case "Enable": model.StatusMsg = "Enabling " + id + "..." return model, []Intent{{Kind: IntentEnableAgent, AgentID: id}} case "Disable": model.StatusMsg = "Disabling " + id + "..." return model, []Intent{{Kind: IntentDisableAgent, AgentID: id}} case "Reload": model.StatusMsg = "Hot-reloading " + id + "..." return model, []Intent{{Kind: IntentReloadAgent, AgentID: id}} case "Restart": model.StatusMsg = "Restarting launcher (all agents)..." return model, []Intent{{Kind: IntentRestartAgent, AgentID: id}} case "Logs": model.Screen = ScreenLogs model.LogLines = nil model.LogScroll = 0 model.Cursor = 0 return model, []Intent{{Kind: IntentLoadLogs, AgentID: id}} } return model, nil } func updateLogs(model Model, key KeyMsg) (Model, []Intent) { switch key.Str { case "0": if model.Selected != nil { model.Screen = ScreenAgentActions } else { model.Screen = ScreenServer } model.Cursor = 0 model.LogLines = nil model.LogScroll = 0 case "up", "k": model.LogScroll = max(0, model.LogScroll-1) case "down", "j": maxScroll := max(0, len(model.LogLines)-visibleLogLines(model)) model.LogScroll = min(model.LogScroll+1, maxScroll) case "r": return model, []Intent{{Kind: IntentLoadLogs}} } return model, nil } func updateServerScreen(model Model, key KeyMsg) (Model, []Intent) { opts := ServerMenuOptions(model.LauncherRunning) switch key.Str { case "0": model.Screen = ScreenMain model.Cursor = 0 model.StatusMsg = "" case "up", "k": model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1) case "down", "j": model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1) case "enter": if model.Cursor < len(opts) { return executeServerAction(model, opts[model.Cursor].Label) } } return model, nil } func executeServerAction(model Model, action string) (Model, []Intent) { switch action { case "Reload All": model.StatusMsg = "Hot-reloading all agents..." return model, []Intent{{Kind: IntentReloadAll}} case "Start": model.StatusMsg = "Starting launcher..." return model, []Intent{{Kind: IntentStartLauncher}} case "Stop": model.StatusMsg = "Stopping launcher..." return model, []Intent{{Kind: IntentStopLauncher}} case "Restart": model.StatusMsg = "Restarting launcher..." return model, []Intent{{Kind: IntentRestartLauncher}} case "Kill": model.StatusMsg = "Killing launcher..." return model, []Intent{{Kind: IntentKillLauncher}} case "Rebuild & Restart": model.StatusMsg = "Building & restarting..." return model, []Intent{{Kind: IntentRebuildRestart}} case "Tests": model.Screen = ScreenTests model.Cursor = 0 model.StatusMsg = "" return model, nil case "Logs": model.Screen = ScreenLogs model.LogLines = nil model.LogScroll = 0 model.Selected = nil model.Cursor = 0 return model, []Intent{{Kind: IntentLoadLogs}} } return model, nil } func updateTestOutput(model Model, key KeyMsg) (Model, []Intent) { switch key.Str { case "0": model.Screen = ScreenTests model.Cursor = 0 model.LogLines = nil model.LogScroll = 0 model.StatusMsg = "" case "up", "k": model.LogScroll = max(0, model.LogScroll-1) case "down", "j": maxScroll := max(0, len(model.LogLines)-visibleLogLines(model)) model.LogScroll = min(model.LogScroll+1, maxScroll) case "r": intent := testKindIntent(model.LastTestKind) if intent == "" { intent = IntentRunGoTests } model.StatusMsg = "Running tests..." model.LogLines = nil model.LogScroll = 0 return model, []Intent{{Kind: intent}} } return model, nil } func updateTestsScreen(model Model, key KeyMsg) (Model, []Intent) { opts := TestMenuOptions() switch key.Str { case "0": model.Screen = ScreenMain model.Cursor = 0 model.StatusMsg = "" case "up", "k": model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1) case "down", "j": model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1) case "enter": if model.Cursor < len(opts) { return executeTestAction(model, opts[model.Cursor].Label) } } return model, nil } func executeTestAction(model Model, action string) (Model, []Intent) { model.StatusMsg = "Running tests..." model.LogLines = nil model.LogScroll = 0 switch action { case "Go Tests": model.LastTestKind = TestKindGo return model, []Intent{{Kind: IntentRunGoTests}} case "E2E Tests": model.LastTestKind = TestKindE2E return model, []Intent{{Kind: IntentRunE2ETests}} case "E2E Tests (headed)": model.LastTestKind = TestKindE2EHead return model, []Intent{{Kind: IntentRunE2EHeadTests}} case "All Tests": model.LastTestKind = TestKindAll return model, []Intent{{Kind: IntentRunAllTests}} } return model, nil } // testKindIntent maps a TestKind to its corresponding IntentKind. func testKindIntent(k TestKind) IntentKind { switch k { case TestKindGo: return IntentRunGoTests case TestKindE2E: return IntentRunE2ETests case TestKindE2EHead: return IntentRunE2EHeadTests case TestKindAll: return IntentRunAllTests default: return "" } } // testKindLabel returns a human-readable label for a TestKind. func testKindLabel(k TestKind) string { switch k { case TestKindGo: return "Go Tests" case TestKindE2E: return "E2E Tests" case TestKindE2EHead: return "E2E Tests (headed)" case TestKindAll: return "All Tests" default: return "Tests" } } // ── pure helpers ───────────────────────────────────────────────────────── func visibleLogLines(m Model) int { lines := m.WindowHeight - 6 if lines < 5 { return 5 } return lines } func clamp(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v }