feat: import agents_and_robots platform as unibots (Matrix-out, unibus transport)
Reemplaza el scaffold del echobot por la plataforma completa de bots traida desde ~/DataProyects/Github/agents_and_robots tras la operacion Matrix-out: los bots ya no hablan por Matrix sino por el bus unibus (modelo todo-rooms + E2E via shell/transportunibus sobre github.com/enmanuel/unibus/pkg/client). - go.mod: replace de unibus -> ../unibus y de fn-registry -> ../../../.. (paths relativos reajustados a la nueva ubicacion dentro de fn_registry). - app.md: bump a 0.2.0, descripcion + arquitectura + comandos + gotchas reales. - modulo Go conservado como github.com/enmanuel/agents (sin reescribir imports). agents_and_robots queda archivado como museo de la era Matrix.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
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 + launcher status.
|
||||
type MsgAgentsLoaded struct {
|
||||
Agents []AgentView
|
||||
LauncherRunning bool
|
||||
LauncherPID int
|
||||
LauncherUptime string
|
||||
LauncherMemory string
|
||||
LauncherCPU string
|
||||
LauncherLogSize string
|
||||
}
|
||||
|
||||
// MsgActionDone reports the result of an action (start/stop/enable/disable).
|
||||
type MsgActionDone struct {
|
||||
AgentID string
|
||||
Action string
|
||||
Err error
|
||||
}
|
||||
|
||||
// MsgLogsLoaded carries log lines for display.
|
||||
type MsgLogsLoaded struct{ Lines []string }
|
||||
|
||||
// MsgServerActionDone reports the result of a launcher action.
|
||||
type MsgServerActionDone struct {
|
||||
Action string
|
||||
Err error
|
||||
}
|
||||
|
||||
// MsgRebuildDone reports the result of a rebuild & restart cycle.
|
||||
type MsgRebuildDone struct {
|
||||
BuildOK bool
|
||||
BuildLog string // last lines of build output
|
||||
Started bool // launcher started after build
|
||||
Err error
|
||||
}
|
||||
|
||||
// MsgTestsDone reports the result of running tests.
|
||||
type MsgTestsDone struct {
|
||||
Kind TestKind // which test suite was executed
|
||||
Passed bool
|
||||
Output []string // lines of test output
|
||||
}
|
||||
|
||||
// MsgTick triggers a periodic refresh.
|
||||
type MsgTick struct{}
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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
|
||||
ScreenServer // server-wide process management
|
||||
ScreenTests // test type selection menu
|
||||
ScreenTestOutput // test run output
|
||||
)
|
||||
|
||||
// TestKind identifies which test suite to run.
|
||||
type TestKind int
|
||||
|
||||
const (
|
||||
TestKindNone TestKind = iota
|
||||
TestKindGo // go test -tags goolm -count=1 ./...
|
||||
TestKindE2E // ./dev-scripts/e2e/run.sh
|
||||
TestKindE2EHead // ./dev-scripts/e2e/run.sh --headed
|
||||
TestKindAll // Go tests + E2E sequential
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// Unified launcher state
|
||||
LauncherRunning bool
|
||||
LauncherPID int
|
||||
LauncherUptime string
|
||||
LauncherMemory string
|
||||
LauncherCPU string
|
||||
LauncherLogSize string
|
||||
|
||||
// Test state
|
||||
LastTestKind TestKind // which test to re-run with "r"
|
||||
}
|
||||
|
||||
// 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
|
||||
Instances int // number of running instances (>1 means duplicates)
|
||||
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: "Server", Desc: "Gestionar launcher unificado"},
|
||||
{Label: "Tests", Desc: "Ejecutar tests"},
|
||||
{Label: "Quit", Desc: "Salir"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestMenuOptions returns the available test types.
|
||||
func TestMenuOptions() []MenuOption {
|
||||
return []MenuOption{
|
||||
{Label: "Go Tests", Desc: "go test ./..."},
|
||||
{Label: "E2E Tests", Desc: "Playwright headless"},
|
||||
{Label: "E2E Tests (headed)", Desc: "Playwright con browser"},
|
||||
{Label: "All Tests", Desc: "Go + E2E secuencial"},
|
||||
}
|
||||
}
|
||||
|
||||
// ServerMenuOptions returns the available server-wide actions.
|
||||
func ServerMenuOptions(running bool) []MenuOption {
|
||||
if running {
|
||||
return []MenuOption{
|
||||
{Label: "Reload All", Desc: "Hot-reload de todos los agentes (SIGHUP)"},
|
||||
{Label: "Stop", Desc: "Detener el launcher"},
|
||||
{Label: "Restart", Desc: "Reiniciar el launcher"},
|
||||
{Label: "Kill", Desc: "SIGKILL forzado"},
|
||||
{Label: "Rebuild & Restart", Desc: "Build + reiniciar"},
|
||||
{Label: "Logs", Desc: "Ver log del launcher"},
|
||||
{Label: "Tests", Desc: "Ir a pantalla de tests"},
|
||||
}
|
||||
}
|
||||
return []MenuOption{
|
||||
{Label: "Start", Desc: "Iniciar el launcher unificado"},
|
||||
{Label: "Rebuild & Restart", Desc: "Build + iniciar"},
|
||||
{Label: "Tests", Desc: "Ir a pantalla de tests"},
|
||||
}
|
||||
}
|
||||
|
||||
// AgentActionOptions returns the available actions based on agent state.
|
||||
func AgentActionOptions(enabled bool) []MenuOption {
|
||||
if enabled {
|
||||
return []MenuOption{
|
||||
{Label: "Reload", Desc: "Hot-reload este agente (SIGHUP, sin interrumpir los demás)"},
|
||||
{Label: "Restart", Desc: "Reiniciar el launcher completo (todos los agentes)"},
|
||||
{Label: "Disable", Desc: "Desactivar agente (requiere restart)"},
|
||||
{Label: "Logs", Desc: "Ver log del launcher"},
|
||||
}
|
||||
}
|
||||
return []MenuOption{
|
||||
{Label: "Reload", Desc: "Hot-reload este agente (SIGHUP, sin interrumpir los demás)"},
|
||||
{Label: "Restart", Desc: "Reiniciar el launcher completo (todos los agentes)"},
|
||||
{Label: "Enable", Desc: "Activar agente (requiere restart)"},
|
||||
{Label: "Logs", Desc: "Ver log del launcher"},
|
||||
}
|
||||
}
|
||||
|
||||
// InitialModel returns the starting state.
|
||||
func InitialModel() Model {
|
||||
return Model{Screen: ScreenMain}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── TestMenuOptions ─────────────────────────────────────────────────────
|
||||
|
||||
func TestTestMenuOptions_Count(t *testing.T) {
|
||||
opts := TestMenuOptions()
|
||||
if len(opts) != 4 {
|
||||
t.Fatalf("expected 4 test menu options, got %d", len(opts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestMenuOptions_Labels(t *testing.T) {
|
||||
opts := TestMenuOptions()
|
||||
expected := []string{"Go Tests", "E2E Tests", "E2E Tests (headed)", "All Tests"}
|
||||
for i, want := range expected {
|
||||
if opts[i].Label != want {
|
||||
t.Errorf("option[%d]: expected label %q, got %q", i, want, opts[i].Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainMenuOptions_IncludesTests(t *testing.T) {
|
||||
opts := MainMenuOptions()
|
||||
found := false
|
||||
for _, opt := range opts {
|
||||
if opt.Label == "Tests" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("MainMenuOptions should include 'Tests'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainMenuOptions_TestsBeforeQuit(t *testing.T) {
|
||||
opts := MainMenuOptions()
|
||||
testsIdx, quitIdx := -1, -1
|
||||
for i, opt := range opts {
|
||||
if opt.Label == "Tests" {
|
||||
testsIdx = i
|
||||
}
|
||||
if opt.Label == "Quit" {
|
||||
quitIdx = i
|
||||
}
|
||||
}
|
||||
if testsIdx < 0 || quitIdx < 0 {
|
||||
t.Fatal("expected both Tests and Quit in menu")
|
||||
}
|
||||
if testsIdx >= quitIdx {
|
||||
t.Errorf("Tests (index %d) should come before Quit (index %d)", testsIdx, quitIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerMenuOptions_NoRunTests(t *testing.T) {
|
||||
for _, running := range []bool{true, false} {
|
||||
opts := ServerMenuOptions(running)
|
||||
for _, opt := range opts {
|
||||
if opt.Label == "Run Tests" {
|
||||
t.Errorf("ServerMenuOptions(running=%v) should not have 'Run Tests', found it", running)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerMenuOptions_HasTests(t *testing.T) {
|
||||
for _, running := range []bool{true, false} {
|
||||
opts := ServerMenuOptions(running)
|
||||
found := false
|
||||
for _, opt := range opts {
|
||||
if opt.Label == "Tests" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("ServerMenuOptions(running=%v) should have 'Tests'", running)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── updateTestsScreen ───────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateTestsScreen_Navigation(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests, Cursor: 0, WindowHeight: 40}
|
||||
|
||||
m, _ = Update(m, KeyMsg{Str: "down"})
|
||||
if m.Cursor != 1 {
|
||||
t.Errorf("expected cursor 1 after down, got %d", m.Cursor)
|
||||
}
|
||||
|
||||
m, _ = Update(m, KeyMsg{Str: "up"})
|
||||
if m.Cursor != 0 {
|
||||
t.Errorf("expected cursor 0 after up, got %d", m.Cursor)
|
||||
}
|
||||
|
||||
// Can't go below 0
|
||||
m, _ = Update(m, KeyMsg{Str: "up"})
|
||||
if m.Cursor != 0 {
|
||||
t.Errorf("expected cursor 0 clamped, got %d", m.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTestsScreen_Back(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests, Cursor: 2}
|
||||
m, _ = Update(m, KeyMsg{Str: "0"})
|
||||
if m.Screen != ScreenMain {
|
||||
t.Errorf("expected ScreenMain, got %d", m.Screen)
|
||||
}
|
||||
if m.Cursor != 0 {
|
||||
t.Errorf("expected cursor reset to 0, got %d", m.Cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTestsScreen_SelectGoTests(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests, Cursor: 0}
|
||||
m, intents := Update(m, KeyMsg{Str: "enter"})
|
||||
if len(intents) != 1 || intents[0].Kind != IntentRunGoTests {
|
||||
t.Errorf("expected IntentRunGoTests, got %v", intents)
|
||||
}
|
||||
if m.LastTestKind != TestKindGo {
|
||||
t.Errorf("expected LastTestKind=TestKindGo, got %d", m.LastTestKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTestsScreen_SelectE2ETests(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests, Cursor: 1}
|
||||
_, intents := Update(m, KeyMsg{Str: "enter"})
|
||||
if len(intents) != 1 || intents[0].Kind != IntentRunE2ETests {
|
||||
t.Errorf("expected IntentRunE2ETests, got %v", intents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTestsScreen_SelectE2EHeaded(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests, Cursor: 2}
|
||||
_, intents := Update(m, KeyMsg{Str: "enter"})
|
||||
if len(intents) != 1 || intents[0].Kind != IntentRunE2EHeadTests {
|
||||
t.Errorf("expected IntentRunE2EHeadTests, got %v", intents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTestsScreen_SelectAllTests(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests, Cursor: 3}
|
||||
_, intents := Update(m, KeyMsg{Str: "enter"})
|
||||
if len(intents) != 1 || intents[0].Kind != IntentRunAllTests {
|
||||
t.Errorf("expected IntentRunAllTests, got %v", intents)
|
||||
}
|
||||
}
|
||||
|
||||
// ── MsgTestsDone ────────────────────────────────────────────────────────
|
||||
|
||||
func TestMsgTestsDone_SetsKindAndStatus(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests}
|
||||
m, _ = Update(m, MsgTestsDone{Kind: TestKindE2E, Passed: true, Output: []string{"ok"}})
|
||||
if m.Screen != ScreenTestOutput {
|
||||
t.Errorf("expected ScreenTestOutput, got %d", m.Screen)
|
||||
}
|
||||
if m.LastTestKind != TestKindE2E {
|
||||
t.Errorf("expected LastTestKind=TestKindE2E, got %d", m.LastTestKind)
|
||||
}
|
||||
if !strings.Contains(m.StatusMsg, "PASSED") {
|
||||
t.Errorf("expected PASSED in status, got %q", m.StatusMsg)
|
||||
}
|
||||
if !strings.Contains(m.StatusMsg, "E2E") {
|
||||
t.Errorf("expected E2E in status, got %q", m.StatusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgTestsDone_Failed(t *testing.T) {
|
||||
m := Model{}
|
||||
m, _ = Update(m, MsgTestsDone{Kind: TestKindGo, Passed: false, Output: []string{"FAIL"}})
|
||||
if !strings.Contains(m.StatusMsg, "FAILED") {
|
||||
t.Errorf("expected FAILED in status, got %q", m.StatusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// ── updateTestOutput ────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateTestOutput_BackGoesToTests(t *testing.T) {
|
||||
m := Model{Screen: ScreenTestOutput, LastTestKind: TestKindGo}
|
||||
m, _ = Update(m, KeyMsg{Str: "0"})
|
||||
if m.Screen != ScreenTests {
|
||||
t.Errorf("expected ScreenTests, got %d", m.Screen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTestOutput_RerunUsesLastKind(t *testing.T) {
|
||||
m := Model{Screen: ScreenTestOutput, LastTestKind: TestKindE2E, WindowHeight: 40}
|
||||
_, intents := Update(m, KeyMsg{Str: "r"})
|
||||
if len(intents) != 1 || intents[0].Kind != IntentRunE2ETests {
|
||||
t.Errorf("expected IntentRunE2ETests, got %v", intents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTestOutput_RerunDefaultsToGo(t *testing.T) {
|
||||
m := Model{Screen: ScreenTestOutput, LastTestKind: TestKindNone, WindowHeight: 40}
|
||||
_, intents := Update(m, KeyMsg{Str: "r"})
|
||||
if len(intents) != 1 || intents[0].Kind != IntentRunGoTests {
|
||||
t.Errorf("expected IntentRunGoTests as default, got %v", intents)
|
||||
}
|
||||
}
|
||||
|
||||
// ── viewTests ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestViewTests_ShowsOptions(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests, Cursor: 0, WindowWidth: 80, WindowHeight: 40}
|
||||
out := View(m)
|
||||
if !strings.Contains(out, "Go Tests") {
|
||||
t.Error("expected 'Go Tests' in view")
|
||||
}
|
||||
if !strings.Contains(out, "E2E Tests") {
|
||||
t.Error("expected 'E2E Tests' in view")
|
||||
}
|
||||
if !strings.Contains(out, "All Tests") {
|
||||
t.Error("expected 'All Tests' in view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewTests_ShowsCursor(t *testing.T) {
|
||||
m := Model{Screen: ScreenTests, Cursor: 1, WindowWidth: 80, WindowHeight: 40}
|
||||
out := View(m)
|
||||
// Cursor on E2E Tests (index 1)
|
||||
lines := strings.Split(out, "\n")
|
||||
foundCursor := false
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "> ") && strings.Contains(line, "E2E Tests") && !strings.Contains(line, "(headed)") {
|
||||
foundCursor = true
|
||||
}
|
||||
}
|
||||
if !foundCursor {
|
||||
t.Error("expected cursor on E2E Tests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewTests_ShowsLastRun(t *testing.T) {
|
||||
m := Model{
|
||||
Screen: ScreenTests,
|
||||
Cursor: 0,
|
||||
WindowWidth: 80,
|
||||
WindowHeight: 40,
|
||||
LastTestKind: TestKindGo,
|
||||
StatusMsg: "Go Tests PASSED",
|
||||
}
|
||||
out := View(m)
|
||||
if !strings.Contains(out, "Last run:") {
|
||||
t.Error("expected 'Last run:' in view")
|
||||
}
|
||||
if !strings.Contains(out, "PASSED") {
|
||||
t.Error("expected PASSED in last run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewTestOutput_ShowsKindInTitle(t *testing.T) {
|
||||
m := Model{
|
||||
Screen: ScreenTestOutput,
|
||||
LastTestKind: TestKindE2E,
|
||||
StatusMsg: "E2E Tests PASSED",
|
||||
LogLines: []string{"ok"},
|
||||
WindowWidth: 80,
|
||||
WindowHeight: 40,
|
||||
}
|
||||
out := View(m)
|
||||
if !strings.Contains(out, "Test Results — E2E Tests") {
|
||||
t.Errorf("expected 'Test Results — E2E Tests' in view, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main menu Tests navigation ──────────────────────────────────────────
|
||||
|
||||
func TestMainMenu_TestsNavigation(t *testing.T) {
|
||||
m := Model{Screen: ScreenMain, Cursor: 2} // Tests is at index 2
|
||||
m, intents := Update(m, KeyMsg{Str: "enter"})
|
||||
if m.Screen != ScreenTests {
|
||||
t.Errorf("expected ScreenTests, got %d", m.Screen)
|
||||
}
|
||||
if len(intents) != 0 {
|
||||
t.Errorf("expected no intents for Tests nav, got %v", intents)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server menu Tests navigation ────────────────────────────────────────
|
||||
|
||||
func TestServerMenu_TestsNavigation(t *testing.T) {
|
||||
// "Tests" is the last option in both running/stopped menus
|
||||
opts := ServerMenuOptions(false)
|
||||
testsIdx := -1
|
||||
for i, o := range opts {
|
||||
if o.Label == "Tests" {
|
||||
testsIdx = i
|
||||
}
|
||||
}
|
||||
if testsIdx < 0 {
|
||||
t.Fatal("Tests not found in server menu")
|
||||
}
|
||||
|
||||
m := Model{Screen: ScreenServer, Cursor: testsIdx}
|
||||
m, _ = Update(m, KeyMsg{Str: "enter"})
|
||||
if m.Screen != ScreenTests {
|
||||
t.Errorf("expected ScreenTests, got %d", m.Screen)
|
||||
}
|
||||
}
|
||||
|
||||
// ── testKindLabel ───────────────────────────────────────────────────────
|
||||
|
||||
func TestTestKindLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
kind TestKind
|
||||
want string
|
||||
}{
|
||||
{TestKindGo, "Go Tests"},
|
||||
{TestKindE2E, "E2E Tests"},
|
||||
{TestKindE2EHead, "E2E Tests (headed)"},
|
||||
{TestKindAll, "All Tests"},
|
||||
{TestKindNone, "Tests"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := testKindLabel(tc.kind)
|
||||
if got != tc.want {
|
||||
t.Errorf("testKindLabel(%d) = %q, want %q", tc.kind, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── testKindIntent ──────────────────────────────────────────────────────
|
||||
|
||||
func TestTestKindIntent(t *testing.T) {
|
||||
cases := []struct {
|
||||
kind TestKind
|
||||
want IntentKind
|
||||
}{
|
||||
{TestKindGo, IntentRunGoTests},
|
||||
{TestKindE2E, IntentRunE2ETests},
|
||||
{TestKindE2EHead, IntentRunE2EHeadTests},
|
||||
{TestKindAll, IntentRunAllTests},
|
||||
{TestKindNone, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := testKindIntent(tc.kind)
|
||||
if got != tc.want {
|
||||
t.Errorf("testKindIntent(%d) = %q, want %q", tc.kind, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package tui
|
||||
|
||||
import "fmt"
|
||||
|
||||
// IntentKind represents a side effect the shell must perform.
|
||||
type IntentKind string
|
||||
|
||||
const (
|
||||
IntentLoadAgents IntentKind = "load_agents"
|
||||
IntentLoadLogs IntentKind = "load_logs"
|
||||
IntentTick IntentKind = "tick"
|
||||
IntentQuit IntentKind = "quit"
|
||||
|
||||
// Agent-level
|
||||
IntentEnableAgent IntentKind = "enable_agent"
|
||||
IntentDisableAgent IntentKind = "disable_agent"
|
||||
IntentReloadAgent IntentKind = "reload_agent" // hot-reload via SIGHUP (solo este agente)
|
||||
IntentReloadAll IntentKind = "reload_all" // hot-reload via SIGHUP (todos los agentes)
|
||||
IntentRestartAgent IntentKind = "restart_agent" // restart completo del launcher
|
||||
|
||||
// Unified launcher operations
|
||||
IntentStartLauncher IntentKind = "start_launcher"
|
||||
IntentStopLauncher IntentKind = "stop_launcher"
|
||||
IntentRestartLauncher IntentKind = "restart_launcher"
|
||||
IntentKillLauncher IntentKind = "kill_launcher"
|
||||
IntentRebuildRestart IntentKind = "rebuild_restart"
|
||||
IntentRunTests IntentKind = "run_tests"
|
||||
IntentRunGoTests IntentKind = "run_go_tests"
|
||||
IntentRunE2ETests IntentKind = "run_e2e_tests"
|
||||
IntentRunE2EHeadTests IntentKind = "run_e2e_head_tests"
|
||||
IntentRunAllTests IntentKind = "run_all_tests"
|
||||
)
|
||||
|
||||
// Intent is pure data describing a side effect to execute.
|
||||
type Intent struct {
|
||||
Kind IntentKind
|
||||
AgentID string
|
||||
}
|
||||
|
||||
// KeyMsg is the pure representation of a key press.
|
||||
// The bridge layer converts tea.KeyMsg into this.
|
||||
type KeyMsg struct {
|
||||
Str string // "up", "down", "enter", "0", "q", "r", etc.
|
||||
}
|
||||
|
||||
// WindowSizeMsg carries terminal dimensions.
|
||||
type WindowSizeMsg struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// Update is PURE: (Model, msg) → (Model, []Intent). No side effects.
|
||||
func Update(model Model, msg interface{}) (Model, []Intent) {
|
||||
switch m := msg.(type) {
|
||||
|
||||
case WindowSizeMsg:
|
||||
model.WindowWidth = m.Width
|
||||
model.WindowHeight = m.Height
|
||||
return model, nil
|
||||
|
||||
case MsgAgentsLoaded:
|
||||
model.Agents = m.Agents
|
||||
model.LauncherRunning = m.LauncherRunning
|
||||
model.LauncherPID = m.LauncherPID
|
||||
model.LauncherUptime = m.LauncherUptime
|
||||
model.LauncherMemory = m.LauncherMemory
|
||||
model.LauncherCPU = m.LauncherCPU
|
||||
model.LauncherLogSize = m.LauncherLogSize
|
||||
if model.Screen == ScreenAgentList {
|
||||
if model.Cursor >= len(model.Agents) && len(model.Agents) > 0 {
|
||||
model.Cursor = len(model.Agents) - 1
|
||||
}
|
||||
}
|
||||
return model, []Intent{{Kind: IntentTick}}
|
||||
|
||||
case MsgActionDone:
|
||||
if m.Err != nil {
|
||||
model.StatusMsg = fmt.Sprintf("Error: %s %s: %v", m.Action, m.AgentID, m.Err)
|
||||
} else if m.Action == "Reload" {
|
||||
model.StatusMsg = fmt.Sprintf("Reload OK — %s recargado sin interrupciones", m.AgentID)
|
||||
} else if m.Action == "Restart" {
|
||||
model.StatusMsg = "Restart OK — launcher reiniciado"
|
||||
} else {
|
||||
model.StatusMsg = fmt.Sprintf("%s %s OK — restart launcher to apply", m.Action, m.AgentID)
|
||||
}
|
||||
return model, []Intent{{Kind: IntentLoadAgents}}
|
||||
|
||||
case MsgServerActionDone:
|
||||
if m.Err != nil {
|
||||
model.StatusMsg = fmt.Sprintf("Error: %s: %v", m.Action, m.Err)
|
||||
} else if m.Action == "Reload All" {
|
||||
model.StatusMsg = "Reload All OK — SIGHUP enviado al launcher"
|
||||
} else {
|
||||
model.StatusMsg = fmt.Sprintf("%s OK", m.Action)
|
||||
}
|
||||
return model, []Intent{{Kind: IntentLoadAgents}}
|
||||
|
||||
case MsgRebuildDone:
|
||||
if !m.BuildOK {
|
||||
model.StatusMsg = fmt.Sprintf("Build failed: %s", m.BuildLog)
|
||||
} else if m.Err != nil {
|
||||
model.StatusMsg = fmt.Sprintf("Built OK, start failed: %v", m.Err)
|
||||
} else if m.Started {
|
||||
model.StatusMsg = "Built OK, launcher started"
|
||||
} else {
|
||||
model.StatusMsg = "Built OK"
|
||||
}
|
||||
return model, []Intent{{Kind: IntentLoadAgents}}
|
||||
|
||||
case MsgLogsLoaded:
|
||||
model.LogLines = m.Lines
|
||||
model.LogScroll = max(0, len(m.Lines)-visibleLogLines(model))
|
||||
return model, nil
|
||||
|
||||
case MsgTestsDone:
|
||||
model.Screen = ScreenTestOutput
|
||||
model.LogLines = m.Output
|
||||
model.LogScroll = 0
|
||||
model.Cursor = 0
|
||||
model.LastTestKind = m.Kind
|
||||
label := testKindLabel(m.Kind)
|
||||
if m.Passed {
|
||||
model.StatusMsg = label + " PASSED"
|
||||
} else {
|
||||
model.StatusMsg = label + " FAILED"
|
||||
}
|
||||
return model, nil
|
||||
|
||||
case MsgTick:
|
||||
return model, []Intent{{Kind: IntentLoadAgents}}
|
||||
|
||||
case KeyMsg:
|
||||
return updateKey(model, m)
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func updateKey(model Model, key KeyMsg) (Model, []Intent) {
|
||||
if key.Str == "q" && model.Screen == ScreenMain {
|
||||
return model, []Intent{{Kind: IntentQuit}}
|
||||
}
|
||||
if key.Str == "ctrl+c" {
|
||||
return model, []Intent{{Kind: IntentQuit}}
|
||||
}
|
||||
|
||||
switch model.Screen {
|
||||
case ScreenMain:
|
||||
return updateMainScreen(model, key)
|
||||
case ScreenAgentList:
|
||||
return updateAgentList(model, key)
|
||||
case ScreenAgentActions:
|
||||
return updateAgentActions(model, key)
|
||||
case ScreenLogs:
|
||||
return updateLogs(model, key)
|
||||
case ScreenServer:
|
||||
return updateServerScreen(model, key)
|
||||
case ScreenTests:
|
||||
return updateTestsScreen(model, key)
|
||||
case ScreenTestOutput:
|
||||
return updateTestOutput(model, key)
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func updateMainScreen(model Model, key KeyMsg) (Model, []Intent) {
|
||||
opts := MainMenuOptions()
|
||||
switch key.Str {
|
||||
case "up", "k":
|
||||
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
|
||||
case "down", "j":
|
||||
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
|
||||
case "enter":
|
||||
switch opts[model.Cursor].Label {
|
||||
case "Agents":
|
||||
model.Screen = ScreenAgentList
|
||||
model.Cursor = 0
|
||||
return model, []Intent{{Kind: IntentLoadAgents}}
|
||||
case "Server":
|
||||
model.Screen = ScreenServer
|
||||
model.Cursor = 0
|
||||
model.StatusMsg = ""
|
||||
return model, []Intent{{Kind: IntentLoadAgents}}
|
||||
case "Tests":
|
||||
model.Screen = ScreenTests
|
||||
model.Cursor = 0
|
||||
model.StatusMsg = ""
|
||||
return model, nil
|
||||
case "Quit":
|
||||
return model, []Intent{{Kind: IntentQuit}}
|
||||
}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func updateAgentList(model Model, key KeyMsg) (Model, []Intent) {
|
||||
switch key.Str {
|
||||
case "0":
|
||||
model.Screen = ScreenMain
|
||||
model.Cursor = 0
|
||||
model.StatusMsg = ""
|
||||
case "up", "k":
|
||||
model.Cursor = clamp(model.Cursor-1, 0, max(0, len(model.Agents)-1))
|
||||
case "down", "j":
|
||||
model.Cursor = clamp(model.Cursor+1, 0, max(0, len(model.Agents)-1))
|
||||
case "enter":
|
||||
if model.Cursor < len(model.Agents) {
|
||||
sel := model.Agents[model.Cursor]
|
||||
model.Selected = &sel
|
||||
model.Screen = ScreenAgentActions
|
||||
model.Cursor = 0
|
||||
model.StatusMsg = ""
|
||||
}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func updateAgentActions(model Model, key KeyMsg) (Model, []Intent) {
|
||||
if model.Selected == nil {
|
||||
model.Screen = ScreenAgentList
|
||||
return model, nil
|
||||
}
|
||||
|
||||
opts := AgentActionOptions(model.Selected.Enabled)
|
||||
|
||||
switch key.Str {
|
||||
case "0":
|
||||
model.Screen = ScreenAgentList
|
||||
model.Cursor = 0
|
||||
model.Selected = nil
|
||||
model.StatusMsg = ""
|
||||
return model, []Intent{{Kind: IntentLoadAgents}}
|
||||
case "up", "k":
|
||||
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
|
||||
case "down", "j":
|
||||
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
|
||||
case "enter":
|
||||
if model.Cursor < len(opts) {
|
||||
return executeAction(model, opts[model.Cursor].Label)
|
||||
}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func executeAction(model Model, action string) (Model, []Intent) {
|
||||
id := model.Selected.ID
|
||||
switch action {
|
||||
case "Enable":
|
||||
model.StatusMsg = "Enabling " + id + "..."
|
||||
return model, []Intent{{Kind: IntentEnableAgent, AgentID: id}}
|
||||
case "Disable":
|
||||
model.StatusMsg = "Disabling " + id + "..."
|
||||
return model, []Intent{{Kind: IntentDisableAgent, AgentID: id}}
|
||||
case "Reload":
|
||||
model.StatusMsg = "Hot-reloading " + id + "..."
|
||||
return model, []Intent{{Kind: IntentReloadAgent, AgentID: id}}
|
||||
case "Restart":
|
||||
model.StatusMsg = "Restarting launcher (all agents)..."
|
||||
return model, []Intent{{Kind: IntentRestartAgent, AgentID: id}}
|
||||
case "Logs":
|
||||
model.Screen = ScreenLogs
|
||||
model.LogLines = nil
|
||||
model.LogScroll = 0
|
||||
model.Cursor = 0
|
||||
return model, []Intent{{Kind: IntentLoadLogs, AgentID: id}}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func updateLogs(model Model, key KeyMsg) (Model, []Intent) {
|
||||
switch key.Str {
|
||||
case "0":
|
||||
if model.Selected != nil {
|
||||
model.Screen = ScreenAgentActions
|
||||
} else {
|
||||
model.Screen = ScreenServer
|
||||
}
|
||||
model.Cursor = 0
|
||||
model.LogLines = nil
|
||||
model.LogScroll = 0
|
||||
case "up", "k":
|
||||
model.LogScroll = max(0, model.LogScroll-1)
|
||||
case "down", "j":
|
||||
maxScroll := max(0, len(model.LogLines)-visibleLogLines(model))
|
||||
model.LogScroll = min(model.LogScroll+1, maxScroll)
|
||||
case "r":
|
||||
return model, []Intent{{Kind: IntentLoadLogs}}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func updateServerScreen(model Model, key KeyMsg) (Model, []Intent) {
|
||||
opts := ServerMenuOptions(model.LauncherRunning)
|
||||
|
||||
switch key.Str {
|
||||
case "0":
|
||||
model.Screen = ScreenMain
|
||||
model.Cursor = 0
|
||||
model.StatusMsg = ""
|
||||
case "up", "k":
|
||||
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
|
||||
case "down", "j":
|
||||
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
|
||||
case "enter":
|
||||
if model.Cursor < len(opts) {
|
||||
return executeServerAction(model, opts[model.Cursor].Label)
|
||||
}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func executeServerAction(model Model, action string) (Model, []Intent) {
|
||||
switch action {
|
||||
case "Reload All":
|
||||
model.StatusMsg = "Hot-reloading all agents..."
|
||||
return model, []Intent{{Kind: IntentReloadAll}}
|
||||
case "Start":
|
||||
model.StatusMsg = "Starting launcher..."
|
||||
return model, []Intent{{Kind: IntentStartLauncher}}
|
||||
case "Stop":
|
||||
model.StatusMsg = "Stopping launcher..."
|
||||
return model, []Intent{{Kind: IntentStopLauncher}}
|
||||
case "Restart":
|
||||
model.StatusMsg = "Restarting launcher..."
|
||||
return model, []Intent{{Kind: IntentRestartLauncher}}
|
||||
case "Kill":
|
||||
model.StatusMsg = "Killing launcher..."
|
||||
return model, []Intent{{Kind: IntentKillLauncher}}
|
||||
case "Rebuild & Restart":
|
||||
model.StatusMsg = "Building & restarting..."
|
||||
return model, []Intent{{Kind: IntentRebuildRestart}}
|
||||
case "Tests":
|
||||
model.Screen = ScreenTests
|
||||
model.Cursor = 0
|
||||
model.StatusMsg = ""
|
||||
return model, nil
|
||||
case "Logs":
|
||||
model.Screen = ScreenLogs
|
||||
model.LogLines = nil
|
||||
model.LogScroll = 0
|
||||
model.Selected = nil
|
||||
model.Cursor = 0
|
||||
return model, []Intent{{Kind: IntentLoadLogs}}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func updateTestOutput(model Model, key KeyMsg) (Model, []Intent) {
|
||||
switch key.Str {
|
||||
case "0":
|
||||
model.Screen = ScreenTests
|
||||
model.Cursor = 0
|
||||
model.LogLines = nil
|
||||
model.LogScroll = 0
|
||||
model.StatusMsg = ""
|
||||
case "up", "k":
|
||||
model.LogScroll = max(0, model.LogScroll-1)
|
||||
case "down", "j":
|
||||
maxScroll := max(0, len(model.LogLines)-visibleLogLines(model))
|
||||
model.LogScroll = min(model.LogScroll+1, maxScroll)
|
||||
case "r":
|
||||
intent := testKindIntent(model.LastTestKind)
|
||||
if intent == "" {
|
||||
intent = IntentRunGoTests
|
||||
}
|
||||
model.StatusMsg = "Running tests..."
|
||||
model.LogLines = nil
|
||||
model.LogScroll = 0
|
||||
return model, []Intent{{Kind: intent}}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func updateTestsScreen(model Model, key KeyMsg) (Model, []Intent) {
|
||||
opts := TestMenuOptions()
|
||||
|
||||
switch key.Str {
|
||||
case "0":
|
||||
model.Screen = ScreenMain
|
||||
model.Cursor = 0
|
||||
model.StatusMsg = ""
|
||||
case "up", "k":
|
||||
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
|
||||
case "down", "j":
|
||||
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
|
||||
case "enter":
|
||||
if model.Cursor < len(opts) {
|
||||
return executeTestAction(model, opts[model.Cursor].Label)
|
||||
}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func executeTestAction(model Model, action string) (Model, []Intent) {
|
||||
model.StatusMsg = "Running tests..."
|
||||
model.LogLines = nil
|
||||
model.LogScroll = 0
|
||||
switch action {
|
||||
case "Go Tests":
|
||||
model.LastTestKind = TestKindGo
|
||||
return model, []Intent{{Kind: IntentRunGoTests}}
|
||||
case "E2E Tests":
|
||||
model.LastTestKind = TestKindE2E
|
||||
return model, []Intent{{Kind: IntentRunE2ETests}}
|
||||
case "E2E Tests (headed)":
|
||||
model.LastTestKind = TestKindE2EHead
|
||||
return model, []Intent{{Kind: IntentRunE2EHeadTests}}
|
||||
case "All Tests":
|
||||
model.LastTestKind = TestKindAll
|
||||
return model, []Intent{{Kind: IntentRunAllTests}}
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// testKindIntent maps a TestKind to its corresponding IntentKind.
|
||||
func testKindIntent(k TestKind) IntentKind {
|
||||
switch k {
|
||||
case TestKindGo:
|
||||
return IntentRunGoTests
|
||||
case TestKindE2E:
|
||||
return IntentRunE2ETests
|
||||
case TestKindE2EHead:
|
||||
return IntentRunE2EHeadTests
|
||||
case TestKindAll:
|
||||
return IntentRunAllTests
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// testKindLabel returns a human-readable label for a TestKind.
|
||||
func testKindLabel(k TestKind) string {
|
||||
switch k {
|
||||
case TestKindGo:
|
||||
return "Go Tests"
|
||||
case TestKindE2E:
|
||||
return "E2E Tests"
|
||||
case TestKindE2EHead:
|
||||
return "E2E Tests (headed)"
|
||||
case TestKindAll:
|
||||
return "All Tests"
|
||||
default:
|
||||
return "Tests"
|
||||
}
|
||||
}
|
||||
|
||||
// ── pure helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
func visibleLogLines(m Model) int {
|
||||
lines := m.WindowHeight - 6
|
||||
if lines < 5 {
|
||||
return 5
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
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)
|
||||
case ScreenServer:
|
||||
return viewServer(model)
|
||||
case ScreenTests:
|
||||
return viewTests(model)
|
||||
case ScreenTestOutput:
|
||||
return viewTestOutput(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 = "●"
|
||||
if a.Instances > 1 {
|
||||
status = fmt.Sprintf("running %d instances", a.Instances)
|
||||
} else {
|
||||
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
|
||||
var icon string
|
||||
switch {
|
||||
case !a.Enabled:
|
||||
icon = " disabled"
|
||||
case a.Running:
|
||||
icon = "● enabled (running)"
|
||||
default:
|
||||
icon = "○ enabled (stopped)"
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("\n %s %s\n", a.ID, icon))
|
||||
b.WriteString(" " + strings.Repeat("─", 44) + "\n")
|
||||
|
||||
if a.Desc != "" {
|
||||
b.WriteString(" " + a.Desc + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
opts := AgentActionOptions(a.Enabled)
|
||||
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 := "Launcher"
|
||||
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 viewServer(m Model) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("\n Launcher Management\n")
|
||||
b.WriteString(" " + strings.Repeat("─", 44) + "\n")
|
||||
|
||||
// Launcher status
|
||||
if m.LauncherRunning {
|
||||
b.WriteString(fmt.Sprintf(" ● Launcher running PID %d\n", m.LauncherPID))
|
||||
parts := []string{}
|
||||
if m.LauncherUptime != "" {
|
||||
parts = append(parts, "uptime: "+m.LauncherUptime)
|
||||
}
|
||||
if m.LauncherMemory != "" {
|
||||
parts = append(parts, "mem: "+m.LauncherMemory)
|
||||
}
|
||||
if m.LauncherCPU != "" {
|
||||
parts = append(parts, "cpu: "+m.LauncherCPU)
|
||||
}
|
||||
if m.LauncherLogSize != "" {
|
||||
parts = append(parts, "log: "+m.LauncherLogSize)
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
b.WriteString(" " + strings.Join(parts, " ") + "\n")
|
||||
}
|
||||
} else {
|
||||
b.WriteString(" ○ Launcher stopped\n")
|
||||
}
|
||||
|
||||
// Agent summary
|
||||
_, _, disabled := countStatuses(m.Agents)
|
||||
enabled := len(m.Agents) - disabled
|
||||
if len(m.Agents) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\n %d agents (%d enabled, %d disabled)\n", len(m.Agents), enabled, disabled))
|
||||
for _, a := range m.Agents {
|
||||
icon := "●"
|
||||
if !a.Enabled {
|
||||
icon = "○"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", icon, a.ID))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
// Action menu
|
||||
for i, opt := range ServerMenuOptions(m.LauncherRunning) {
|
||||
cursor := " "
|
||||
if i == m.Cursor {
|
||||
cursor = "> "
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s%-20s %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 viewTests(m Model) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("\n Tests\n")
|
||||
b.WriteString(" " + strings.Repeat("─", 44) + "\n")
|
||||
|
||||
for i, opt := range TestMenuOptions() {
|
||||
cursor := " "
|
||||
if i == m.Cursor {
|
||||
cursor = "> "
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s%-22s %s\n", cursor, opt.Label, opt.Desc))
|
||||
}
|
||||
|
||||
if m.LastTestKind != TestKindNone {
|
||||
b.WriteString(fmt.Sprintf("\n Last run: %s", testKindLabel(m.LastTestKind)))
|
||||
if m.StatusMsg != "" && (strings.HasSuffix(m.StatusMsg, "PASSED") || strings.HasSuffix(m.StatusMsg, "FAILED")) {
|
||||
// Extract result from status
|
||||
if strings.HasSuffix(m.StatusMsg, "PASSED") {
|
||||
b.WriteString(" — PASSED")
|
||||
} else {
|
||||
b.WriteString(" — FAILED")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n ↑↓ navegar enter ejecutar 0 volver\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func viewTestOutput(m Model) string {
|
||||
var b strings.Builder
|
||||
|
||||
title := "Test Results"
|
||||
if m.LastTestKind != TestKindNone {
|
||||
title = "Test Results — " + testKindLabel(m.LastTestKind)
|
||||
}
|
||||
b.WriteString("\n " + title + "\n")
|
||||
b.WriteString(" " + strings.Repeat("─", 60) + "\n")
|
||||
|
||||
if m.StatusMsg != "" {
|
||||
b.WriteString(" " + m.StatusMsg + "\n\n")
|
||||
}
|
||||
|
||||
if len(m.LogLines) == 0 {
|
||||
b.WriteString(" Running tests...\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] {
|
||||
if len(line) > m.WindowWidth-4 && m.WindowWidth > 10 {
|
||||
line = line[:m.WindowWidth-7] + "..."
|
||||
}
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n ↑↓ scroll r re-ejecutar 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
|
||||
}
|
||||
Reference in New Issue
Block a user