fix(tui): give the targets box only rows the agents panel would get, and always count hidden targets

This commit is contained in:
Ian 2026-10-12 12:33:37 -04:00 • committed by Ahmed Allam
parent 710fe7b3b6
commit 442b3c3df5
3 changed files with 75 additions and 20 deletions

View file

@ -388,3 +388,44 @@ func TestTargetsBoxDropsOnShortTerminal(t *testing.T) {
t.Fatalf("sidebar renders %d rows on a %d-row terminal", got, m.height)
}
}
// The targets box only takes rows the agent tree would otherwise grow into, so
// the stats panel keeps the Caido URL and the other panels keep their heights.
func TestTargetsBoxNeverSqueezesPanels(t *testing.T) {
for height := 19; height <= 40; height++ {
m := panelsModel(t)
m.height = height
m.snapshot.Usage = map[string]any{"total_tokens": 1234, "cost": 0.5}
m.snapshot.CaidoURL = "http://127.0.0.1:8080"
m.resizeViewport()
stats, vuln, mcp, _ := m.sidebarHeights()
m.snapshot.Targets = []string{"a", "b", "c", "d", "e"}
m.snapshot.TargetCount = 5
m.resizeViewport()
gotStats, gotVuln, gotMcp, _ := m.sidebarHeights()
if gotStats != stats || gotVuln != vuln || gotMcp != mcp {
t.Fatalf("height %d: targets squeezed panels: stats %d->%d findings %d->%d mcp %d->%d",
height, stats, gotStats, vuln, gotVuln, mcp, gotMcp)
}
_, sidebarWidth, _, _ := m.layout()
if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got != m.height {
t.Fatalf("height %d: sidebar renders %d rows", height, got)
}
}
}
func TestTargetsViewCountsTheTargetsItCannotShow(t *testing.T) {
m := panelsModel(t)
m.snapshot.Targets = []string{"a", "b", "c", "d", "e"}
m.snapshot.TargetCount = 5
for rows, want := range map[int]string{
2: "Targets 5\n+5 more",
3: "Targets 5\n▸ a\n+4 more",
6: "Targets 5\n▸ a\n▸ b\n▸ c\n▸ d\n+1 more",
9: "Targets 5\n▸ a\n▸ b\n▸ c\n▸ d\n+1 more",
} {
if got := ansi.Strip(m.targetsView(20, rows)); got != want {
t.Fatalf("%d rows:\n%s\nwant:\n%s", rows, got, want)
}
}
}

View file

@ -421,7 +421,7 @@ func (m Model) setupComposer(width int) string {
inner := max(1, width-4)
body := m.highlightInputSelection(m.input.View())
body += "\n\n" + m.setupSummaryView(inner)
if targets := m.targetsView(inner); targets != "" {
if targets := m.targetsView(inner, maxTargetRows); targets != "" {
body += "\n" + targets
}
return lipgloss.NewStyle().Width(max(1, width-2)).Padding(0, 1).
@ -454,15 +454,22 @@ func (m Model) setupSummaryView(width int) string {
return truncate(strings.Join(chips, render.Dim().Render(" · ")), max(1, width))
}
// targetsView lists what the scan is pointed at, once anything is queued.
func (m Model) targetsView(width int) string {
if len(m.snapshot.Targets) == 0 {
// maxTargetRows fits the header, four targets and the count of the rest.
const maxTargetRows = 6
// targetsView lists what the scan is pointed at, once anything is queued, in at
// most maxRows rows. Targets that do not fit are counted on the last row.
func (m Model) targetsView(width, maxRows int) string {
if len(m.snapshot.Targets) == 0 || maxRows < 2 {
return ""
}
const visible = 4
total := max(m.snapshot.TargetCount, len(m.snapshot.Targets))
visible := min(len(m.snapshot.Targets), maxTargetRows-2, maxRows-1)
if visible < total {
visible = min(visible, maxRows-2)
}
rows := []string{render.Bold(green).Render("Targets") + render.Dim().Render(fmt.Sprintf(" %d", total))}
for _, target := range m.snapshot.Targets[:min(visible, len(m.snapshot.Targets))] {
for _, target := range m.snapshot.Targets[:visible] {
rows = append(rows, render.Col(dim).Render("▸ ")+render.Col(white).Render(truncate(target, max(1, width-2))))
}
if hidden := total - visible; hidden > 0 {

View file

@ -549,7 +549,7 @@ func (m Model) sidebarView(width, height int) string {
Border(lipgloss.RoundedBorder()).
BorderForeground(dark).
Padding(0, 1).
Render(fixedPanelBody(m.targetsView(width-4), width-4, targetsHeight-2)))
Render(m.targetsView(width-4, targetsHeight-2)))
}
agents := ""
if agentHeight > 1 {
@ -601,6 +601,13 @@ func (m Model) sidebarView(width, height int) string {
}
func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight int) {
_, statsHeight, vulnHeight, mcpHeight, agentHeight = m.sidebarLayout()
return
}
// sidebarLayout sizes the targets box and the panels. The targets box only
// takes rows no panel needs, so it never squeezes the panels below it.
func (m Model) sidebarLayout() (targetsHeight, statsHeight, vulnHeight, mcpHeight, agentHeight int) {
// Measure the stats panel the way its box will render it: a long model name
// wraps inside the sidebar, and counting only its newlines would size the
// box short and push the whole frame past the bottom of the terminal.
@ -620,7 +627,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight
h *int
floor int
}{{&statsHeight, 4}, {&mcpHeight, 5}, {&vulnHeight, 5}} {
over := m.sidebarTopHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height
over := m.viewerHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height
if over <= 0 {
break
}
@ -629,14 +636,22 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight
}
}
for _, h := range []*int{&mcpHeight, &vulnHeight, &statsHeight} {
if m.sidebarTopHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height {
if m.viewerHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height {
break
}
if *h > 1 {
*h = 1
}
}
spare := max(0, m.height-m.sidebarTopHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight)
spare := max(0, m.height-m.viewerHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight)
// The box needs its border and two rows: the header and a target or the
// count of hidden ones. A zoomed panel keeps the whole sidebar.
if targets := m.targetsView(m.sidebarInnerWidth(), maxTargetRows); targets != "" && m.zoomedPanel == panelNone {
if fit := min(lipgloss.Height(targets)+2, spare); fit >= 4 {
targetsHeight = fit
spare -= fit
}
}
switch {
case m.zoomedPanel == panelFindings && vulnHeight > 1:
vulnHeight += spare
@ -658,17 +673,9 @@ 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)
targetsHeight, _, _, _, _ := m.sidebarLayout()
return targetsHeight
}
func (m Model) sidebarInnerWidth() int {