package tui import "fmt" // IntentKind represents a side effect the shell must perform. type IntentKind string const ( IntentLoadAgents IntentKind = "load_agents" IntentStartAgent IntentKind = "start_agent" IntentStopAgent IntentKind = "stop_agent" IntentKillAgent IntentKind = "kill_agent" IntentRestartAgent IntentKind = "restart_agent" IntentLoadLogs IntentKind = "load_logs" IntentTick IntentKind = "tick" IntentQuit IntentKind = "quit" // Server-wide bulk operations IntentStartAll IntentKind = "start_all" IntentStopAll IntentKind = "stop_all" IntentRestartAll IntentKind = "restart_all" IntentKillAll IntentKind = "kill_all" ) // 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 // Clamp cursor only on screens that use the agent list 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 { model.StatusMsg = fmt.Sprintf("%s %s OK", m.Action, m.AgentID) } return model, []Intent{{Kind: IntentLoadAgents}} case MsgServerActionDone: if m.Failed == 0 { model.StatusMsg = fmt.Sprintf("%s: %d agents OK", m.Action, m.Total) } else { model.StatusMsg = fmt.Sprintf("%s: %d/%d failed", m.Action, m.Failed, m.Total) } return model, []Intent{{Kind: IntentLoadAgents}} case MsgLogsLoaded: model.LogLines = m.Lines model.LogScroll = max(0, len(m.Lines)-visibleLogLines(model)) 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) { // Global quit 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) } 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 "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.Running) 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 "Start": model.StatusMsg = "Starting " + id + "..." return model, []Intent{{Kind: IntentStartAgent, AgentID: id}} case "Stop": model.StatusMsg = "Stopping " + id + "..." return model, []Intent{{Kind: IntentStopAgent, AgentID: id}} case "Restart": model.StatusMsg = "Restarting " + id + "..." return model, []Intent{{Kind: IntentRestartAgent, AgentID: id}} case "Kill": model.StatusMsg = "Killing " + id + "..." return model, []Intent{{Kind: IntentKillAgent, 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": model.Screen = ScreenAgentActions 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": if model.Selected != nil { return model, []Intent{{Kind: IntentLoadLogs, AgentID: model.Selected.ID}} } } return model, nil } func updateServerScreen(model Model, key KeyMsg) (Model, []Intent) { opts := ServerMenuOptions() 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 "Start All": model.StatusMsg = "Starting all agents..." return model, []Intent{{Kind: IntentStartAll}} case "Stop All": model.StatusMsg = "Stopping all agents..." return model, []Intent{{Kind: IntentStopAll}} case "Restart All": model.StatusMsg = "Restarting all agents..." return model, []Intent{{Kind: IntentRestartAll}} case "Kill All": model.StatusMsg = "Killing all agents..." return model, []Intent{{Kind: IntentKillAll}} } return model, nil } // ── pure helpers ───────────────────────────────────────────────────────── func visibleLogLines(m Model) int { lines := m.WindowHeight - 6 // header + footer 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 }