feat(tui): move the run's targets into their own sidebar box under the viewer

This commit is contained in:
Ian 2026-10-09 14:31:33 -04:00
parent 0265487b48
commit 5b8ad7c2ff
3 changed files with 57 additions and 16 deletions

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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")