feat: add bubbletea TUI dashboard for bot server management
Implementa un dashboard interactivo con bubbletea siguiendo el patrón pure core / impure shell del proyecto: - pkg/tui/ (PURE): Model, Update, View — solo fmt y strings, cero I/O. Update produce Intent[] (datos puros) en vez de side effects. - shell/tui/ (IMPURE): Adapter convierte Intent[] en tea.Cmd[] con I/O real (process management, /proc stats, log tail). - cmd/dashboard/ (composición): Bridge conecta pure Update con shell Adapter usando la Elm Architecture de bubbletea. Pantallas: Main Menu → Agent List → Agent Actions (start/stop/restart/kill) → Logs. Navegación: flechas ↑↓, Enter seleccionar, 0 volver, q salir. Dependencias añadidas: bubbletea, lipgloss. Actualiza .gitignore para anclar binarios a raíz (/agentctl, /dashboard). Documenta nuevos scripts en CLAUDE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
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 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)
|
||||
}
|
||||
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 "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
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
Reference in New Issue
Block a user