diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d1ab4a7..e4f0e97 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -103,10 +103,21 @@ Secciones principales del config: `agent`, `personality`, `llm`, `tools`, `matri ./dev-scripts/list.sh # ver todos los bots y estado ./dev-scripts/start.sh [agent-id] # iniciar uno o todos ./dev-scripts/stop.sh [agent-id] # detener uno o todos +./dev-scripts/restart.sh [agent-id] # reiniciar uno o todos +./dev-scripts/ps.sh [agent-id] # procesos con detalle (PID, mem, CPU, uptime) ./dev-scripts/remove.sh # deshabilitar (sin borrar datos) ./dev-scripts/register.sh [name] # registrar bot en Matrix ./dev-scripts/logs.sh [agent-id] # tail -f de logs ./dev-scripts/new-agent.sh [name] # scaffold completo + +# Gestión unificada del servidor +./dev-scripts/server.sh start [id] # iniciar agentes +./dev-scripts/server.sh stop [id] # detener agentes +./dev-scripts/server.sh restart [id] # reiniciar agentes +./dev-scripts/server.sh status # resumen general del servidor +./dev-scripts/server.sh ps [id] # procesos con detalle +./dev-scripts/server.sh logs [id] # tail -f de logs +./dev-scripts/server.sh kill [id] # SIGKILL forzado (emergencia) ``` PID files: `run/.pid` | Log files: `run/.log` diff --git a/.claude/commands/git-push.md b/.claude/commands/git-push.md new file mode 100644 index 0000000..e1036c8 --- /dev/null +++ b/.claude/commands/git-push.md @@ -0,0 +1,71 @@ +# Command: git push + +Usa este comando para cerrar una tarea completa con sincronización, commits por bloques de cambio y publicación al remoto. + +## Flujo obligatorio + +1. Verificar rama y estado: + +```bash +git branch --show-current +git status --short +``` + +2. Sincronizar antes de preparar commits: + +```bash +git pull --rebase +``` + +3. Revisar cambios y separarlos por tema: + +```bash +git diff --stat +git diff +``` + +4. Si hay cambios de distinta naturaleza, crear varios commits: + +- Commit 1: refactor/código +- Commit 2: documentación +- Commit 3: reglas/configuración + +Comandos sugeridos: + +```bash +git add +git commit -m ": " -m "Descripción larga en español explicando qué cambia, por qué se hizo, impacto esperado y alcance del bloque." + +git add +git commit -m ": " -m "Descripción larga en español explicando qué cambia, por qué se hizo, impacto esperado y alcance del bloque." +``` + +5. Publicar commits: + +```bash +git push +``` + +## Convención de commits + +- `feat:` nueva funcionalidad +- `fix:` corrección de error +- `refactor:` cambio estructural sin cambio funcional +- `docs:` documentación +- `chore:` mantenimiento + +## Regla de mensajes + +- El título (`-m` corto) debe resumir el bloque. +- El cuerpo (`-m` largo) debe estar en español y explicar: + - qué se cambió, + - por qué se cambió, + - qué impacto tiene, + - qué no se tocó. + +## Checklist rápido + +- [ ] `git pull --rebase` ejecutado sin conflictos. +- [ ] Se separaron cambios distintos en commits diferentes. +- [ ] Cada commit tiene descripción larga en español. +- [ ] `git push` ejecutado correctamente. diff --git a/.gitignore b/.gitignore index 0b5dd1e..c9d4f72 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ launcher run/*.pid run/*.log -agentctl \ No newline at end of file +/agentctl +/dashboard \ No newline at end of file diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go new file mode 100644 index 0000000..64072ed --- /dev/null +++ b/cmd/dashboard/main.go @@ -0,0 +1,85 @@ +// Command dashboard provides an interactive TUI for managing bot agents. +// +// Usage: +// +// dashboard # launch the interactive TUI +// go run ./cmd/dashboard +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + puretui "github.com/enmanuel/agents/pkg/tui" + "github.com/enmanuel/agents/shell/process" + shelltui "github.com/enmanuel/agents/shell/tui" +) + +const ( + runDir = "run" + agentsGlob = "agents/*/config.yaml" +) + +func main() { + _ = os.MkdirAll(runDir, 0o755) + + mgr := process.NewManager(runDir, agentsGlob, "") + adapter := shelltui.NewAdapter(mgr) + + p := tea.NewProgram(newBridge(adapter), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +// bridge implements tea.Model and connects the pure Update with the impure Adapter. +type bridge struct { + model puretui.Model + adapter *shelltui.Adapter +} + +func newBridge(adapter *shelltui.Adapter) bridge { + return bridge{ + model: puretui.InitialModel(), + adapter: adapter, + } +} + +func (b bridge) Init() tea.Cmd { + return b.adapter.RunIntent(puretui.Intent{Kind: puretui.IntentLoadAgents}) +} + +func (b bridge) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Convert tea messages to pure messages. + var pureMsg interface{} + switch m := msg.(type) { + case tea.KeyMsg: + pureMsg = puretui.KeyMsg{Str: m.String()} + case tea.WindowSizeMsg: + pureMsg = puretui.WindowSizeMsg{Width: m.Width, Height: m.Height} + default: + // MsgAgentsLoaded, MsgActionDone, MsgLogsLoaded, MsgTick pass through. + pureMsg = msg + } + + // Pure update: no side effects. + newModel, intents := puretui.Update(b.model, pureMsg) + b.model = newModel + + // Convert pure intents to impure tea.Cmds. + cmds := make([]tea.Cmd, 0, len(intents)) + for _, intent := range intents { + if cmd := b.adapter.RunIntent(intent); cmd != nil { + cmds = append(cmds, cmd) + } + } + + return b, tea.Batch(cmds...) +} + +func (b bridge) View() string { + return puretui.View(b.model) +} diff --git a/go.mod b/go.mod index 0a2a26e..ae2914c 100644 --- a/go.mod +++ b/go.mod @@ -13,19 +13,34 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/zerolog v1.33.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.5 // indirect @@ -34,6 +49,7 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mau.fi/util v0.8.1 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect diff --git a/go.sum b/go.sum index 4a26e42..393aea5 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,31 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -26,6 +42,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/mcp-go v0.44.1 h1:2PKppYlT9X2fXnE8SNYQLAX4hNjfPB0oNLqQVcN6mE8= @@ -37,8 +55,18 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= @@ -48,6 +76,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -75,6 +106,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo= @@ -87,6 +120,7 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go new file mode 100644 index 0000000..9539122 --- /dev/null +++ b/pkg/tui/messages.go @@ -0,0 +1,20 @@ +package tui + +// Messages are pure data returned by the shell adapter. +// They carry the result of an I/O operation back into the pure Update. + +// MsgAgentsLoaded carries refreshed agent data. +type MsgAgentsLoaded struct{ Agents []AgentView } + +// MsgActionDone reports the result of an agent action (start/stop/kill/restart). +type MsgActionDone struct { + AgentID string + Action string + Err error +} + +// MsgLogsLoaded carries log lines for the selected agent. +type MsgLogsLoaded struct{ Lines []string } + +// MsgTick triggers a periodic refresh. +type MsgTick struct{} diff --git a/pkg/tui/model.go b/pkg/tui/model.go new file mode 100644 index 0000000..7b2f91c --- /dev/null +++ b/pkg/tui/model.go @@ -0,0 +1,76 @@ +// Package tui defines the pure TUI model, messages, update, and view. +// Zero I/O, zero side effects. Only data transformations. +package tui + +// Screen identifies the current TUI screen. +type Screen int + +const ( + ScreenMain Screen = iota + ScreenAgentList // list all agents with status + ScreenAgentActions // actions for a selected agent + ScreenLogs // tail log output +) + +// Model is the complete TUI state — pure data. +type Model struct { + Screen Screen + Agents []AgentView + Cursor int + Selected *AgentView // nil when no agent selected + LogLines []string + LogScroll int + StatusMsg string // flash message ("Started OK", "Error: ...") + WindowWidth int + WindowHeight int +} + +// AgentView is a pre-formatted projection of an agent for display. +type AgentView struct { + ID string + Name string + Version string + Desc string + Enabled bool + Running bool + PID int + Uptime string // formatted: "2h 15m" + Memory string // formatted: "42 MB" + CPU string // formatted: "1.2%" + LogSize string // formatted: "350 KB" +} + +// MenuOption represents a selectable menu item. +type MenuOption struct { + Label string + Desc string +} + +// MainMenuOptions returns the options for the main screen. +func MainMenuOptions() []MenuOption { + return []MenuOption{ + {Label: "Agents", Desc: "Gestionar agentes"}, + {Label: "Quit", Desc: "Salir"}, + } +} + +// AgentActionOptions returns the available actions based on agent state. +func AgentActionOptions(running bool) []MenuOption { + if running { + return []MenuOption{ + {Label: "Stop", Desc: "Detener el agente"}, + {Label: "Restart", Desc: "Reiniciar"}, + {Label: "Kill", Desc: "SIGKILL forzado"}, + {Label: "Logs", Desc: "Ver log del agente"}, + } + } + return []MenuOption{ + {Label: "Start", Desc: "Iniciar el agente"}, + {Label: "Logs", Desc: "Ver log del agente"}, + } +} + +// InitialModel returns the starting state. +func InitialModel() Model { + return Model{Screen: ScreenMain} +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go new file mode 100644 index 0000000..36fef68 --- /dev/null +++ b/pkg/tui/update.go @@ -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 +} diff --git a/pkg/tui/view.go b/pkg/tui/view.go new file mode 100644 index 0000000..55f7696 --- /dev/null +++ b/pkg/tui/view.go @@ -0,0 +1,192 @@ +package tui + +import ( + "fmt" + "strings" +) + +// View is PURE: Model → string. No side effects. +func View(model Model) string { + switch model.Screen { + case ScreenMain: + return viewMain(model) + case ScreenAgentList: + return viewAgentList(model) + case ScreenAgentActions: + return viewAgentActions(model) + case ScreenLogs: + return viewLogs(model) + default: + return "" + } +} + +func viewMain(m Model) string { + var b strings.Builder + + b.WriteString("\n Bot Server Dashboard\n") + b.WriteString(" " + strings.Repeat("─", 36) + "\n") + + // Summary + running, stopped, disabled := countStatuses(m.Agents) + total := len(m.Agents) + if total > 0 { + b.WriteString(fmt.Sprintf(" %d agents (%d running, %d stopped, %d disabled)\n\n", + total, running, stopped, disabled)) + } else { + b.WriteString(" Loading...\n\n") + } + + // Menu + for i, opt := range MainMenuOptions() { + cursor := " " + if i == m.Cursor { + cursor = "> " + } + b.WriteString(fmt.Sprintf(" %s%-16s %s\n", cursor, opt.Label, opt.Desc)) + } + + b.WriteString("\n ↑↓ navegar enter seleccionar q salir\n") + return b.String() +} + +func viewAgentList(m Model) string { + var b strings.Builder + + b.WriteString("\n Agents\n") + b.WriteString(" " + strings.Repeat("─", 60) + "\n") + + if len(m.Agents) == 0 { + b.WriteString(" No agents found.\n") + } + + for i, a := range m.Agents { + cursor := " " + if i == m.Cursor { + cursor = "> " + } + + icon := "○" + status := "stopped" + if !a.Enabled { + icon = " " + status = "disabled" + } else if a.Running { + icon = "●" + status = fmt.Sprintf("running PID %d", a.PID) + } + + b.WriteString(fmt.Sprintf(" %s%s %-20s %-8s %s\n", + cursor, icon, a.ID, a.Version, status)) + } + + if m.StatusMsg != "" { + b.WriteString("\n " + m.StatusMsg + "\n") + } + + b.WriteString("\n ↑↓ navegar enter acciones 0 volver\n") + return b.String() +} + +func viewAgentActions(m Model) string { + var b strings.Builder + + if m.Selected == nil { + return " No agent selected.\n" + } + + a := m.Selected + icon := "○ stopped" + if a.Running { + icon = fmt.Sprintf("● running PID %d", a.PID) + } + + b.WriteString(fmt.Sprintf("\n %s %s\n", a.ID, icon)) + b.WriteString(" " + strings.Repeat("─", 44) + "\n") + + // Stats line if running + if a.Running && (a.Memory != "" || a.CPU != "") { + parts := []string{} + if a.Uptime != "" { + parts = append(parts, "uptime: "+a.Uptime) + } + if a.Memory != "" { + parts = append(parts, "mem: "+a.Memory) + } + if a.CPU != "" { + parts = append(parts, "cpu: "+a.CPU) + } + if a.LogSize != "" { + parts = append(parts, "log: "+a.LogSize) + } + b.WriteString(" " + strings.Join(parts, " ") + "\n") + } + + b.WriteString("\n") + + opts := AgentActionOptions(a.Running) + for i, opt := range opts { + cursor := " " + if i == m.Cursor { + cursor = "> " + } + b.WriteString(fmt.Sprintf(" %s%-16s %s\n", cursor, opt.Label, opt.Desc)) + } + + if m.StatusMsg != "" { + b.WriteString("\n " + m.StatusMsg + "\n") + } + + b.WriteString("\n ↑↓ navegar enter ejecutar 0 volver\n") + return b.String() +} + +func viewLogs(m Model) string { + var b strings.Builder + + agentID := "?" + if m.Selected != nil { + agentID = m.Selected.ID + } + + b.WriteString(fmt.Sprintf("\n %s — Logs\n", agentID)) + b.WriteString(" " + strings.Repeat("─", 60) + "\n") + + if len(m.LogLines) == 0 { + b.WriteString(" (no log data)\n") + } else { + visible := visibleLogLines(m) + end := m.LogScroll + visible + if end > len(m.LogLines) { + end = len(m.LogLines) + } + start := m.LogScroll + if start >= len(m.LogLines) { + start = max(0, len(m.LogLines)-1) + } + for _, line := range m.LogLines[start:end] { + // Truncate long lines + if len(line) > m.WindowWidth-4 && m.WindowWidth > 10 { + line = line[:m.WindowWidth-7] + "..." + } + b.WriteString(" " + line + "\n") + } + } + + b.WriteString("\n ↑↓ scroll r recargar 0 volver\n") + return b.String() +} + +func countStatuses(agents []AgentView) (running, stopped, disabled int) { + for _, a := range agents { + switch { + case !a.Enabled: + disabled++ + case a.Running: + running++ + default: + stopped++ + } + } + return +} diff --git a/shell/tui/adapter.go b/shell/tui/adapter.go new file mode 100644 index 0000000..4ba0c13 --- /dev/null +++ b/shell/tui/adapter.go @@ -0,0 +1,195 @@ +// 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" + "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.IntentStartAgent: + return a.startAgent(intent.AgentID) + + case puretui.IntentStopAgent: + return a.stopAgent(intent.AgentID) + + case puretui.IntentKillAgent: + return a.killAgent(intent.AgentID) + + case puretui.IntentRestartAgent: + return a.restartAgent(intent.AgentID) + + case puretui.IntentLoadLogs: + return a.loadLogs(intent.AgentID) + + 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.StatusAll() + if err != nil { + return puretui.MsgAgentsLoaded{} + } + + views := make([]puretui.AgentView, len(statuses)) + for i, s := range statuses { + v := puretui.AgentView{ + ID: s.ID, + Name: s.Name, + Version: s.Version, + Desc: s.Desc, + Enabled: s.Enabled, + Running: s.Running, + PID: s.PID, + } + + if s.Running { + if stats, err := a.mgr.Stats(s.ID); err == nil { + v.Uptime = formatUptime(stats.UptimeSecs) + v.Memory = formatBytes(stats.MemRSSKB * 1024) + v.CPU = fmt.Sprintf("%.1f%%", stats.CPUPct) + v.LogSize = formatBytes(stats.LogBytes) + } + } + + views[i] = v + } + + return puretui.MsgAgentsLoaded{Agents: views} + } +} + +func (a *Adapter) startAgent(id string) tea.Cmd { + return func() tea.Msg { + agents, err := a.mgr.Scan() + if err != nil { + return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err} + } + for _, agent := range agents { + if agent.ID == id { + err = a.mgr.Start(agent) + // Give the process a moment to start. + if err == nil { + time.Sleep(500 * time.Millisecond) + } + return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err} + } + } + return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: fmt.Errorf("agent not found")} + } +} + +func (a *Adapter) stopAgent(id string) tea.Cmd { + return func() tea.Msg { + err := a.mgr.Stop(id) + return puretui.MsgActionDone{AgentID: id, Action: "Stop", Err: err} + } +} + +func (a *Adapter) killAgent(id string) tea.Cmd { + return func() tea.Msg { + err := a.mgr.Kill(id) + return puretui.MsgActionDone{AgentID: id, Action: "Kill", Err: err} + } +} + +func (a *Adapter) restartAgent(id string) tea.Cmd { + return func() tea.Msg { + // Stop first (ignore error if not running) + _ = a.mgr.Stop(id) + time.Sleep(300 * time.Millisecond) + + agents, err := a.mgr.Scan() + if err != nil { + return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err} + } + for _, agent := range agents { + if agent.ID == id { + err = a.mgr.Start(agent) + if err == nil { + time.Sleep(500 * time.Millisecond) + } + return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err} + } + } + return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: fmt.Errorf("agent not found")} + } +} + +func (a *Adapter) loadLogs(id string) tea.Cmd { + return func() tea.Msg { + lines, err := a.mgr.LogTail(id, 100) + if err != nil { + return puretui.MsgLogsLoaded{Lines: []string{"Error: " + err.Error()}} + } + return puretui.MsgLogsLoaded{Lines: 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) + } +}