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:
@@ -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/list.sh # ver todos los bots y estado
|
||||||
./dev-scripts/start.sh [agent-id] # iniciar uno o todos
|
./dev-scripts/start.sh [agent-id] # iniciar uno o todos
|
||||||
./dev-scripts/stop.sh [agent-id] # detener 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 <agent-id> # deshabilitar (sin borrar datos)
|
./dev-scripts/remove.sh <agent-id> # deshabilitar (sin borrar datos)
|
||||||
./dev-scripts/register.sh <id> [name] # registrar bot en Matrix
|
./dev-scripts/register.sh <id> [name] # registrar bot en Matrix
|
||||||
./dev-scripts/logs.sh [agent-id] # tail -f de logs
|
./dev-scripts/logs.sh [agent-id] # tail -f de logs
|
||||||
./dev-scripts/new-agent.sh <id> [name] # scaffold completo
|
./dev-scripts/new-agent.sh <id> [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/<id>.pid` | Log files: `run/<id>.log`
|
PID files: `run/<id>.pid` | Log files: `run/<id>.log`
|
||||||
|
|||||||
@@ -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 <archivos_del_bloque_1>
|
||||||
|
git commit -m "<tipo>: <resumen breve>" -m "Descripción larga en español explicando qué cambia, por qué se hizo, impacto esperado y alcance del bloque."
|
||||||
|
|
||||||
|
git add <archivos_del_bloque_2>
|
||||||
|
git commit -m "<tipo>: <resumen breve>" -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.
|
||||||
+2
-1
@@ -7,4 +7,5 @@ launcher
|
|||||||
run/*.pid
|
run/*.pid
|
||||||
run/*.log
|
run/*.log
|
||||||
|
|
||||||
agentctl
|
/agentctl
|
||||||
|
/dashboard
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -13,19 +13,34 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // 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/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/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/invopop/jsonschema v0.13.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/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/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/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
|
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/rs/zerolog v1.33.0 // indirect
|
||||||
github.com/spf13/cast v1.7.1 // indirect
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // 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/pretty v1.2.0 // indirect
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // 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
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.mau.fi/util v0.8.1 // indirect
|
go.mau.fi/util v0.8.1 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
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 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
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/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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
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=
|
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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mark3labs/mcp-go v0.44.1 h1:2PKppYlT9X2fXnE8SNYQLAX4hNjfPB0oNLqQVcN6mE8=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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=
|
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/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 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
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 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
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=
|
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/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 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -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{}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+192
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user