package app import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" "github.com/usestrix/strix/tui/internal/protocol" "github.com/usestrix/strix/tui/internal/render" ) type agentTreeEntry struct { index int depth int prefix string } // agentTreeEntries mirrors Textual Tree's depth-first ordering while retaining // each agent's snapshot index for event lookup and commands. func agentTreeEntries(agents []protocol.Agent, collapsed map[string]bool) []agentTreeEntry { indexByID := make(map[string]int, len(agents)) for i, agent := range agents { indexByID[agent.ID] = i } children := make(map[int][]int, len(agents)) var roots []int for i, agent := range agents { parentIndex := -1 if agent.ParentID != nil { if candidate, ok := indexByID[*agent.ParentID]; ok && candidate != i { parentIndex = candidate } } if parentIndex < 0 { roots = append(roots, i) } else { children[parentIndex] = append(children[parentIndex], i) } } entries := make([]agentTreeEntry, 0, len(agents)) visited := make(map[int]bool, len(agents)) var hideDescendants func(int) hideDescendants = func(index int) { for _, child := range children[index] { if visited[child] { continue } visited[child] = true hideDescendants(child) } } var walk func(int, int, []bool, bool) walk = func(index, depth int, continuations []bool, isLast bool) { if visited[index] { return } visited[index] = true var prefix strings.Builder if depth > 0 { for _, continues := range continuations { if continues { prefix.WriteString("│ ") } else { prefix.WriteString(" ") } } if isLast { prefix.WriteString("└─ ") } else { prefix.WriteString("├─ ") } } entries = append(entries, agentTreeEntry{index: index, depth: depth, prefix: prefix.String()}) if collapsed[agents[index].ID] { hideDescendants(index) return } nextContinuations := continuations if depth > 0 { nextContinuations = append(append([]bool(nil), continuations...), !isLast) } for i, child := range children[index] { walk(child, depth+1, nextContinuations, i == len(children[index])-1) } } for i, root := range roots { walk(root, 0, nil, i == len(roots)-1) } // Malformed cycles have no root. Keep their nodes visible rather than losing // them, treating the first unvisited node as another root. for i := range agents { if !visited[i] { walk(i, 0, nil, true) } } return entries } func hasAgentChildren(agentID string, agents []protocol.Agent) bool { for _, agent := range agents { if agent.ParentID != nil && *agent.ParentID == agentID { return true } } return false } func windowStart(offset, length, size int) int { return min(max(0, offset), max(0, length-size)) } func selectedAgentRow(entries []agentTreeEntry, selectedIndex int) int { for row, entry := range entries { if entry.index == selectedIndex { return row } } return 0 } func selectedAgentIndex(agents []protocol.Agent, selectedID string) int { if selectedID != "" { for i, agent := range agents { if agent.ID == selectedID { return i } } } return 0 } func (m Model) selectedAgentID() string { if m.selectedAgent >= 0 && m.selectedAgent < len(m.snapshot.Agents) { return m.snapshot.Agents[m.selectedAgent].ID } return "" } func (m Model) selectedAgentCanStop() bool { if m.selectedAgent < 0 || m.selectedAgent >= len(m.snapshot.Agents) { return false } switch m.snapshot.Agents[m.selectedAgent].Status { case "running", "waiting", "budget_paused": return true default: return false } } func (m Model) agentsView(width, height int) string { // The tree's root ("Agents") is hidden (show_root = False), so no header row // is drawn — only the agent nodes. var lines []string statusIcons := map[string]string{"running": "⚪", "waiting": "⏸", "budget_paused": "⏸", "completed": "🟢", "failed": "🔴", "crashed": "🔴", "stopped": "■"} entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) start := windowStart(m.agentOffset, len(entries), height) end := min(len(entries), start+height) for _, entry := range entries[start:end] { agent := m.snapshot.Agents[entry.index] icon := statusIcons[agent.Status] if icon == "" { icon = "○" } vulnSuffix := "" if count := m.agentVulnCount(agent.ID); count > 0 { vulnSuffix = fmt.Sprintf(" (%d)", count) } // Only a node with children carries a toggle; a leaf renders none at all, // so its icon sits where its parent's toggle would be. disclosure := "" if hasAgentChildren(agent.ID, m.snapshot.Agents) { disclosure = "▼ " if m.collapsedAgents[agent.ID] { disclosure = "▶ " } } label := disclosure + icon + " " + agent.Name + vulnSuffix // The guides are dim and stay outside the cursor; the cursor is a filled // block behind the label alone. labelStyle := lipgloss.NewStyle().Foreground(treeLabel) if entry.index == m.selectedAgent { labelStyle = labelStyle.Foreground(treeCursorFg).Background(treeCursorBg).Bold(true) } room := max(1, width-lipgloss.Width(entry.prefix)) lines = append(lines, lipgloss.NewStyle().Foreground(treeGuide).Render(entry.prefix)+ labelStyle.Render(truncate(label, room))) } return strings.Join(lines, "\n") } // agentVulnCount counts vulnerabilities attributed to an agent, matching the // " (N)" suffix _update_agent_node appends to each tree node. func (m Model) agentVulnCount(agentID string) int { count := 0 for _, vuln := range m.snapshot.Vulnerabilities { if render.StringValue(vuln["agent_id"]) == agentID { count++ } } return count } func (m *Model) ensureAgentVisible() { entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) if len(entries) == 0 { m.agentOffset = 0 return } _, _, agentHeight := m.sidebarHeights() rows := max(1, agentHeight-4) row := selectedAgentRow(entries, m.selectedAgent) if row < m.agentOffset { m.agentOffset = row } else if row >= m.agentOffset+rows { m.agentOffset = row - rows + 1 } m.agentOffset = min(m.agentOffset, max(0, len(entries)-rows)) } func (m Model) agentPageSize() int { _, _, agentHeight := m.sidebarHeights() return max(1, agentHeight-4) } func (m *Model) keepAgentSelectionInWindow() { entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) if len(entries) == 0 { return } rows := m.agentPageSize() row := selectedAgentRow(entries, m.selectedAgent) if row < m.agentOffset { m.selectedAgent = entries[m.agentOffset].index } else if row >= m.agentOffset+rows { m.selectedAgent = entries[min(len(entries)-1, m.agentOffset+rows-1)].index } } func (m Model) agentHasEvents(agentID string) bool { for _, event := range m.snapshot.Events { if event.AgentID == agentID { return true } } return false } // sweepView ports _get_sweep_animation: a triangle-wave sweep of six squares // across an 8-color palette (dimmest shows a "·"), matching the Python cadence // and motion exactly.