package tui import ( "strings" "testing" "time" "github.com/smallnest/pigo/internal/cli/ui" ) // TestSubagentPanelLifecycle exercises the ordered add/update/remove set: rows // keep insertion order, a progress refreshes activity/tokens, a progress for an // unknown id adds a row (late/out-of-order safe), and remove drops exactly one // row (a no-op for an absent id). func TestSubagentPanelLifecycle(t *testing.T) { now := time.Now() var p subagentPanel p.add("a", "task A", now) p.add("b", "task B", now) if got := p.active(); got != 2 { t.Fatalf("active after two adds = %d, want 2", got) } if p.order[0] != "a" || p.order[1] != "b" { t.Errorf("order = %v, want [a b]", p.order) } p.update("a", "task A", "Editing", 120, now) if row := p.byID["a"]; row.activity != "Editing" || row.tokens != 120 { t.Errorf("row a after update = %+v, want activity=Editing tokens=120", row) } // A progress for an id that never started adds the row (SPEC 5.4). p.update("c", "task C", "Reading", 0, now) if got := p.active(); got != 3 { t.Fatalf("active after late progress = %d, want 3", got) } if p.order[2] != "c" { t.Errorf("order = %v, want c appended last", p.order) } // Removing a middle row preserves the order of the rest. p.remove("a") if got := p.active(); got != 2 { t.Fatalf("active after remove = %d, want 2", got) } if p.order[0] != "b" || p.order[1] != "c" { t.Errorf("order after remove(a) = %v, want [b c]", p.order) } // Removing an absent id is a no-op. p.remove("zzz") if got := p.active(); got != 2 { t.Errorf("active after remove(absent) = %d, want 2", got) } } // TestSubagentPanelEmptyView verifies an empty panel renders nothing — zero // lines, zero height — so the single-run layout is untouched. func TestSubagentPanelEmptyView(t *testing.T) { var p subagentPanel if got := p.view(DefaultTheme(), 80, time.Now()); got != "" { t.Errorf("empty panel view = %q, want empty", got) } // A non-empty panel with a non-positive width also renders nothing. p.add("a", "task", time.Now()) if got := p.view(DefaultTheme(), 0, time.Now()); got != "" { t.Errorf("zero-width view = %q, want empty", got) } } // TestSubagentPanelViewLines verifies one line per active sub-agent, each // carrying the description, activity, and token stat. func TestSubagentPanelViewLines(t *testing.T) { now := time.Now() var p subagentPanel p.add("a", "build parser", now) p.update("a", "build parser", "Editing", 1200, now) p.add("b", "run tests", now) p.update("b", "run tests", "Running bash", 0, now) view := p.view(DefaultTheme(), 200, now.Add(65*time.Second)) lines := strings.Split(view, "\n") if len(lines) != 2 { t.Fatalf("view has %d lines, want 2: %q", len(lines), view) } if !strings.Contains(lines[0], "build parser") || !strings.Contains(lines[0], "Editing") { t.Errorf("line[0] = %q, want desc + activity", lines[0]) } if !strings.Contains(lines[0], "1m 5s") { t.Errorf("line[0] = %q, want elapsed 1m 5s", lines[0]) } if !strings.Contains(lines[0], "1,200") { t.Errorf("line[0] = %q, want token stat 1,200", lines[0]) } if !strings.Contains(lines[1], "run tests") || !strings.Contains(lines[1], "Running bash") { t.Errorf("line[1] = %q, want desc + activity", lines[1]) } // A zero token estimate omits the ↓ stat. if strings.Contains(lines[1], "↓") { t.Errorf("line[1] = %q, should omit ↓ for zero tokens", lines[1]) } } // TestSubagentPanelViewTruncation verifies each rendered line is clipped to the // given terminal width (display columns), never exceeding it. func TestSubagentPanelViewTruncation(t *testing.T) { now := time.Now() var p subagentPanel p.add("a", strings.Repeat("very long description ", 10), now) p.update("a", "", "Searching", 42, now) const width = 30 view := p.view(DefaultTheme(), width, now) for _, line := range strings.Split(view, "\n") { if w := ui.Width(line); w > width { t.Errorf("line width = %d, want <= %d: %q", w, width, line) } } } // TestSubagentPanelViewBlankDescription verifies a row with no description still // renders (leading with the activity) rather than producing a dangling "·". func TestSubagentPanelViewBlankDescription(t *testing.T) { now := time.Now() var p subagentPanel p.add("a", "", now) p.update("a", "", "Thinking", 0, now) line := p.view(DefaultTheme(), 100, now) if !strings.Contains(line, "Thinking") { t.Errorf("view = %q, want activity", line) } if strings.Contains(line, " · Thinking") { t.Errorf("view = %q, blank desc should not leave a leading ' · '", line) } } // TestSubagentPanelSelection verifies the cursor navigation: a fresh panel has no // selection, the first ↓ lands on the top row and the first ↑ on the bottom row, // movement clamps at both ends, and clearSelection resets to no-cursor state. func TestSubagentPanelSelection(t *testing.T) { now := time.Now() var p subagentPanel p.add("a", "task A", now) p.add("b", "task B", now) p.add("c", "task C", now) if p.hasSelection() { t.Fatal("fresh panel should have no selection") } // First ↓ selects the top row; further ↓ advance and clamp at the bottom. p.selectDown() if !p.hasSelection() || p.selected != 0 { t.Fatalf("after first down: hasSelection=%v selected=%d, want true/0", p.hasSelection(), p.selected) } p.selectDown() p.selectDown() p.selectDown() // clamp if p.selected != 2 { t.Errorf("selected after clamp down = %d, want 2", p.selected) } // ↑ retreats and clamps at the top. p.selectUp() if p.selected != 1 { t.Errorf("selected after up = %d, want 1", p.selected) } p.selectUp() p.selectUp() // clamp if p.selected != 0 { t.Errorf("selected after clamp up = %d, want 0", p.selected) } p.clearSelection() if p.hasSelection() { t.Error("clearSelection should drop the cursor") } // From no selection, the first ↑ lands on the bottom row. p.selectUp() if !p.hasSelection() || p.selected != 2 { t.Errorf("first up from none: selected=%d, want 2", p.selected) } } // TestSubagentPanelExpandView verifies that expanding a selected row appends its // accumulated output below the status line, that the cursor marker is present, // that lineCount matches the rendered height, and that collapsing removes the // extra lines. func TestSubagentPanelExpandView(t *testing.T) { now := time.Now() var p subagentPanel p.add("a", "task A", now) p.add("b", "task B", now) p.appendOutput("b", "hello from B\nsecond line") p.selectDown() // selects "a" p.selectDown() // selects "b" if id := p.expandedID(); id != "" { t.Errorf("expandedID before toggle = %q, want empty", id) } p.toggleExpand() if id := p.expandedID(); id != "b" { t.Errorf("expandedID after toggle = %q, want b", id) } const width = 80 view := p.view(DefaultTheme(), width, now) lines := strings.Split(view, "\n") if got := p.lineCount(width); got != len(lines) { t.Errorf("lineCount = %d, rendered %d lines", got, len(lines)) } // Two status rows + two output lines. if len(lines) != 4 { t.Fatalf("expanded view has %d lines, want 4: %q", len(lines), view) } if !strings.Contains(view, "❯") { t.Errorf("expanded view missing selection cursor: %q", view) } if !strings.Contains(view, "hello from B") || !strings.Contains(view, "second line") { t.Errorf("expanded view missing output: %q", view) } // Collapsing reclaims the output lines. p.toggleExpand() if got := p.lineCount(width); got != 2 { t.Errorf("lineCount after collapse = %d, want 2", got) } } // TestSubagentPanelExpandTruncates verifies the inline output is wrapped and // tail-capped: every rendered line fits the width and no more than // maxExpandedLines output lines are shown. func TestSubagentPanelExpandTruncates(t *testing.T) { now := time.Now() var p subagentPanel p.add("a", "task A", now) // Many short lines exceed the cap; one very long line must wrap. p.appendOutput("a", strings.Repeat("x\n", 40)) p.appendOutput("a", strings.Repeat("y", 500)) p.selectDown() p.toggleExpand() const width = 40 view := p.view(DefaultTheme(), width, now) lines := strings.Split(view, "\n") for _, line := range lines { if w := ui.Width(line); w > width { t.Errorf("line width = %d, want <= %d: %q", w, width, line) } } // 1 status row + at most maxExpandedLines output lines. if len(lines) > 1+maxExpandedLines { t.Errorf("expanded view has %d lines, want <= %d", len(lines), 1+maxExpandedLines) } } // TestSubagentPanelRemoveClampsSelection verifies that removing rows keeps the // selection valid: removing the selected bottom row clamps the cursor to the new // last row, and removing the final row clears the selection entirely. func TestSubagentPanelRemoveClampsSelection(t *testing.T) { now := time.Now() var p subagentPanel p.add("a", "task A", now) p.add("b", "task B", now) p.selectDown() p.selectDown() // selects "b" (index 1) p.remove("b") if !p.hasSelection() || p.selected != 0 { t.Errorf("after remove(b): hasSelection=%v selected=%d, want true/0", p.hasSelection(), p.selected) } p.remove("a") if p.hasSelection() { t.Error("removing the last row should clear the selection") } if got := p.lineCount(80); got != 0 { t.Errorf("empty panel lineCount = %d, want 0", got) } } // TestSubagentPanelAppendOutputUnknown verifies deltas for an unknown id are // dropped (no phantom row, no panic). func TestSubagentPanelAppendOutputUnknown(t *testing.T) { var p subagentPanel p.appendOutput("ghost", "data") // must not panic or add a row if p.active() != 0 { t.Errorf("active after appendOutput to unknown id = %d, want 0", p.active()) } }