From 5b8ad7c2ffa80b4a3e8d2ad24752853f541922c2 Mon Sep 17 00:00:00 2001 From: Ian Date: Fri, 9 Oct 2026 14:31:33 -0400 Subject: [PATCH] feat(tui): move the run's targets into their own sidebar box under the viewer --- strix/interface/tui/internal/app/panels.go | 4 +- .../interface/tui/internal/app/panels_test.go | 29 +++++++++++--- strix/interface/tui/internal/app/view.go | 40 ++++++++++++++----- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/strix/interface/tui/internal/app/panels.go b/strix/interface/tui/internal/app/panels.go index 86f0fac4..c9cba8a0 100644 --- a/strix/interface/tui/internal/app/panels.go +++ b/strix/interface/tui/internal/app/panels.go @@ -66,7 +66,7 @@ func panelFocus(panel sidebarPanel) (focusMode, bool) { func (m Model) sidebarPanels() []panelRect { statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights() - top := m.viewerHeight() + top := m.sidebarTopHeight() rects := []panelRect{{panelAgents, top, agentHeight}} top += agentHeight if vulnHeight > 0 { @@ -83,7 +83,7 @@ func (m Model) sidebarPanels() []panelRect { func (m Model) sidebarGap() int { statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights() - return max(0, m.height-m.viewerHeight()-agentHeight-vulnHeight-mcpHeight-statsHeight) + return max(0, m.height-m.sidebarTopHeight()-agentHeight-vulnHeight-mcpHeight-statsHeight) } func (m Model) panelAt(y int) (panelRect, bool) { diff --git a/strix/interface/tui/internal/app/panels_test.go b/strix/interface/tui/internal/app/panels_test.go index 3a2ef61c..c7d871ca 100644 --- a/strix/interface/tui/internal/app/panels_test.go +++ b/strix/interface/tui/internal/app/panels_test.go @@ -356,16 +356,35 @@ func TestLonePanelHasNoControls(t *testing.T) { } } -func TestStatsPanelListsTargets(t *testing.T) { +func TestTargetsBoxSitsUnderViewer(t *testing.T) { m := panelsModel(t) m.snapshot.Targets = []string{"https://example.com", "./app"} m.snapshot.TargetCount = 2 m.resizeViewport() _, sidebarWidth, _, _ := m.layout() - view := ansi.Strip(m.sidebarView(sidebarWidth, m.height)) - for _, want := range []string{"Targets 2", "▸ https://example.com", "▸ ./app"} { - if !strings.Contains(view, want) { - t.Fatalf("sidebar missing %q:\n%s", want, view) + rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n") + if len(rows) != m.height { + t.Fatalf("sidebar renders %d rows, want %d", len(rows), m.height) + } + top := m.viewerHeight() + for i, want := range []string{"Targets 2", "▸ https://example.com", "▸ ./app"} { + if !strings.Contains(rows[top+1+i], want) { + t.Fatalf("row %d missing %q:\n%s", top+1+i, want, strings.Join(rows, "\n")) } } + if !strings.Contains(rows[panelRectOf(t, m, panelAgents).top+1], "Agents") { + t.Fatalf("agents panel not where its rect says:\n%s", strings.Join(rows, "\n")) + } +} + +func TestTargetsBoxDropsOnShortTerminal(t *testing.T) { + m := panelsModel(t) + m.height = 19 + m.snapshot.Targets = []string{"a", "b", "c", "d", "e"} + m.snapshot.TargetCount = 5 + m.resizeViewport() + _, sidebarWidth, _, _ := m.layout() + if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got > m.height { + t.Fatalf("sidebar renders %d rows on a %d-row terminal", got, m.height) + } } diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index ade70f1b..c30251b2 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -542,6 +542,15 @@ func (m Model) sidebarView(width, height int) string { BorderForeground(dark). Padding(0, 1). Render(m.viewerBox(width - 4))} + if targetsHeight := m.targetsBoxHeight(); targetsHeight > 0 { + parts = append(parts, lipgloss.NewStyle(). + Width(width-2). + Height(targetsHeight-2). + Border(lipgloss.RoundedBorder()). + BorderForeground(dark). + Padding(0, 1). + Render(fixedPanelBody(m.targetsView(width-4), width-4, targetsHeight-2))) + } agents := "" if agentHeight > 1 { agentRows := max(1, agentHeight-4) @@ -611,7 +620,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight h *int floor int }{{&statsHeight, 4}, {&mcpHeight, 5}, {&vulnHeight, 5}} { - over := m.viewerHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height + over := m.sidebarTopHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height if over <= 0 { break } @@ -620,14 +629,14 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight } } for _, h := range []*int{&mcpHeight, &vulnHeight, &statsHeight} { - if m.viewerHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height { + if m.sidebarTopHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height { break } if *h > 1 { *h = 1 } } - spare := max(0, m.height-m.viewerHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight) + spare := max(0, m.height-m.sidebarTopHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight) switch { case m.zoomedPanel == panelFindings && vulnHeight > 1: vulnHeight += spare @@ -643,6 +652,25 @@ func (m Model) viewerHeight() int { return strings.Count(m.viewerView(m.viewerContentWidth()), "\n") + 3 } +// sidebarTopHeight is the rows above the panels: the viewer box and, when the +// run has targets, the targets box under it. +func (m Model) sidebarTopHeight() int { + return m.viewerHeight() + m.targetsBoxHeight() +} + +// targetsBoxHeight drops the box on terminals too short to also fit the agents +// box, the stats box at its floor and squeezed findings and MCP headers. +func (m Model) targetsBoxHeight() int { + if len(m.snapshot.Targets) == 0 { + return 0 + } + room := m.height - m.viewerHeight() - 5 - 4 - 2 + if room < 3 { + return 0 + } + return min(lipgloss.Height(m.targetsView(m.sidebarInnerWidth()))+2, room) +} + func (m Model) sidebarInnerWidth() int { _, sidebarWidth, _, _ := m.layout() if sidebarWidth == 0 { @@ -694,12 +722,6 @@ func (m Model) statsView() string { b.WriteString(w.Render(fmt.Sprintf(" · $%.2f", cost))) } } - if targets := m.targetsView(m.sidebarInnerWidth()); targets != "" { - if b.Len() > 0 { - b.WriteString("\n") - } - b.WriteString(targets) - } if caido := m.snapshot.CaidoURL; caido != "" { if b.Len() > 0 { b.WriteString("\n")