Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ It supports:
- **Observer dashboard** for stepping through mission events, switching perspectives, and injecting commands
- **Swarm-event logs** for follower assignments, reassignments, and formation changes
- **Per-tick simulation state metrics** including communication reliability, sensor noise, weather impact, and chaos-mode status
- **Interactive TUI** with hotkeys (`q` quit, `w` wrap, `s` scroll, `e` spawn enemy via `type,lat,lon,alt` + `Enter`; dialog auto-fills near the latest drone position for quick spawning)
- **Interactive TUI** with hotkeys (`q` quit, `w` wrap, `s` scroll, `e` spawn enemy via `type,lat,lon,alt` + `Enter`; dialog auto-fills near the latest drone position for quick spawning, `t` toggle summary footer)

This project was designed to support visualization dashboards (e.g., Grafana Geomap panel) and multi-cluster sync scenarios (mission clusters → command cluster).

Expand Down
123 changes: 98 additions & 25 deletions internal/sim/tui_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,28 +217,32 @@ func (w *TUIWriter) Close() error {
}

type tuiModel struct {
cfg *config.SimulationConfig
table table.Model
vp viewport.Model
detVP viewport.Model
swarmVP viewport.Model
logs []string
detLogs []string
swarmLogs []string
state telemetry.SimulationStateRow
admin bool
wrap bool
autoscroll bool
header string
headerHeight int
height int
missionColors map[string]string
enemies []enemy.Enemy
spawn func(enemy.Enemy)
enemyInput textinput.Model
enemyDialog bool
lastDrone telemetry.Position
haveDrone bool
cfg *config.SimulationConfig
table table.Model
vp viewport.Model
detVP viewport.Model
swarmVP viewport.Model
logs []string
detLogs []string
swarmLogs []string
state telemetry.SimulationStateRow
admin bool
wrap bool
autoscroll bool
header string
headerHeight int
height int
missionColors map[string]string
enemies []enemy.Enemy
spawn func(enemy.Enemy)
enemyInput textinput.Model
enemyDialog bool
lastDrone telemetry.Position
haveDrone bool
summary bool
droneBatteries map[string]float64
missionTotals map[string]int
missionCounts map[string]map[string]struct{}
}

func newTUIModel(cfg *config.SimulationConfig, missionColors map[string]string) tuiModel {
Expand All @@ -258,7 +262,22 @@ func newTUIModel(cfg *config.SimulationConfig, missionColors map[string]string)
vp := viewport.New(0, 0)
detVP := viewport.New(0, 0)
swarmVP := viewport.New(0, 0)
m := tuiModel{cfg: cfg, table: t, vp: vp, detVP: detVP, swarmVP: swarmVP, missionColors: missionColors, autoscroll: true}
missionTotals := make(map[string]int)
for _, f := range cfg.Fleets {
missionTotals[f.MissionID] += f.Count
}
m := tuiModel{
cfg: cfg,
table: t,
vp: vp,
detVP: detVP,
swarmVP: swarmVP,
missionColors: missionColors,
autoscroll: true,
droneBatteries: make(map[string]float64),
missionTotals: missionTotals,
missionCounts: make(map[string]map[string]struct{}),
}
return m
}

Expand Down Expand Up @@ -332,6 +351,10 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.enemyDialog = true
m.updateViewportHeight()
return m, nil
case "t":
m.summary = !m.summary
m.updateViewportHeight()
return m, nil
}
if !m.autoscroll {
switch msg.String() {
Expand Down Expand Up @@ -386,6 +409,17 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case telemetryMsg:
m.lastDrone = telemetry.Position{Lat: msg.Lat, Lon: msg.Lon, Alt: msg.Alt}
m.haveDrone = true
if m.droneBatteries == nil {
m.droneBatteries = make(map[string]float64)
}
m.droneBatteries[msg.DroneID] = msg.Battery
if m.missionCounts == nil {
m.missionCounts = make(map[string]map[string]struct{})
}
if m.missionCounts[msg.MissionID] == nil {
m.missionCounts[msg.MissionID] = make(map[string]struct{})
}
m.missionCounts[msg.MissionID][msg.DroneID] = struct{}{}
case stateMsg:
m.state = msg.SimulationStateRow
case adminMsg:
Expand Down Expand Up @@ -527,6 +561,36 @@ func renderMissionTree(cfg *config.SimulationConfig, colors map[string]string, w
return strings.TrimRight(b.String(), "\n")
}

func (m tuiModel) renderSummary() string {
total := len(m.droneBatteries)
var sum float64
for _, b := range m.droneBatteries {
sum += b
}
avg := 0.0
if total > 0 {
avg = sum / float64(total)
}
var missionParts []string
for _, ms := range m.cfg.Missions {
totalMission := m.missionTotals[ms.ID]
active := len(m.missionCounts[ms.ID])
pct := 0.0
if totalMission > 0 {
pct = float64(active) / float64(totalMission) * 100
}
c := m.missionColors[ms.ID]
part := fmt.Sprintf("%s%s%s=%d/%d(%.0f%%)%s", c, ms.ID, colorReset, active, totalMission, pct, colorReset)
missionParts = append(missionParts, part)
}
missions := strings.Join(missionParts, " ")
summary := fmt.Sprintf("%sSUMMARY%s %sdrones=%d%s %savg_batt=%.1f%s", colorBlue, colorReset, colorGreen, total, colorReset, colorCyan, avg, colorReset)
if missions != "" {
summary = fmt.Sprintf("%s %s", summary, missions)
}
return summary
}

func (m tuiModel) renderBottom() string {
adminColor := lipgloss.Color("9")
if m.admin {
Expand All @@ -543,15 +607,24 @@ func (m tuiModel) renderBottom() string {
adminIndicator := lipgloss.NewStyle().Foreground(adminColor).Render("●")
wrapIndicator := lipgloss.NewStyle().Foreground(wrapColor).Render("●")
scrollIndicator := lipgloss.NewStyle().Foreground(scrollColor).Render("●")
summaryColor := lipgloss.Color("9")
if m.summary {
summaryColor = lipgloss.Color("10")
}
summaryIndicator := lipgloss.NewStyle().Foreground(summaryColor).Render("●")
state := fmt.Sprintf("%sSTATE%s %scomm_loss=%.2f%s %smsgs=%d%s %ssensor=%.2f%s %sweather=%.2f%s %schaos=%t%s",
colorBlue, colorReset,
colorYellow, m.state.CommunicationLoss, colorReset,
colorGreen, m.state.MessagesSent, colorReset,
colorMagenta, m.state.SensorNoise, colorReset,
colorCyan, m.state.WeatherImpact, colorReset,
colorRed, m.state.ChaosMode, colorReset)
keys := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render("q:quit w:wrap s:scroll e:enemy")
return fmt.Sprintf("%s | Admin UI %s | Wrap %s | Scroll %s | %s", state, adminIndicator, wrapIndicator, scrollIndicator, keys)
keys := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render("q:quit w:wrap s:scroll e:enemy t:summary")
line := fmt.Sprintf("%s | Admin UI %s | Wrap %s | Scroll %s | Summary %s | %s", state, adminIndicator, wrapIndicator, scrollIndicator, summaryIndicator, keys)
if m.summary {
return fmt.Sprintf("%s\n%s", m.renderSummary(), line)
}
return line
}

func (m tuiModel) renderEnemies() string {
Expand Down
35 changes: 35 additions & 0 deletions internal/sim/tui_writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,41 @@ func TestEnemySpawnHint(t *testing.T) {
}
}

func TestSummaryToggle(t *testing.T) {
cfg := &config.SimulationConfig{
Missions: []config.Mission{{ID: "m1"}, {ID: "m2"}},
Fleets: []config.Fleet{{MissionID: "m1", Count: 2}, {MissionID: "m2", Count: 3}},
}
colors := map[string]string{"m1": colorRed, "m2": colorGreen}
m := newTUIModel(cfg, colors)
mi, _ := m.Update(telemetryMsg{telemetry.TelemetryRow{DroneID: "d1", MissionID: "m1", Battery: 80}})
m = mi.(tuiModel)
mi, _ = m.Update(telemetryMsg{telemetry.TelemetryRow{DroneID: "d2", MissionID: "m2", Battery: 40}})
m = mi.(tuiModel)
bottom := m.renderBottom()
if strings.Contains(bottom, "SUMMARY") {
t.Fatalf("summary should be hidden by default")
}
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}})
m = mi.(tuiModel)
bottom = m.renderBottom()
if !strings.Contains(bottom, "SUMMARY") {
t.Fatalf("summary not shown after toggle: %q", bottom)
}
if !strings.Contains(bottom, fmt.Sprintf("%sdrones=%d%s", colorGreen, 2, colorReset)) {
t.Fatalf("missing drone count: %q", bottom)
}
if !strings.Contains(bottom, fmt.Sprintf("%savg_batt=%.1f%s", colorCyan, 60.0, colorReset)) {
t.Fatalf("missing avg battery: %q", bottom)
}
if !strings.Contains(bottom, fmt.Sprintf("%s%s%s=1/2", colorRed, "m1", colorReset)) {
t.Fatalf("missing mission m1 progress: %q", bottom)
}
if !strings.Contains(bottom, fmt.Sprintf("%s%s%s=1/3", colorGreen, "m2", colorReset)) {
t.Fatalf("missing mission m2 progress: %q", bottom)
}
}

func TestUpdateViewportHeightClampsToZero(t *testing.T) {
cfg := &config.SimulationConfig{}
m := newTUIModel(cfg, map[string]string{})
Expand Down