diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f53d2fe..ad64f4a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,4 +3,11 @@ repos: rev: v0.5.1 hooks: - id: go-fmt + - repo: local + hooks: - id: go-vet + name: go vet + entry: go vet ./... + language: system + pass_filenames: false + types: [go] diff --git a/internal/render/render.go b/internal/render/render.go index 114f419..9408903 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -291,6 +291,28 @@ func Render(w io.Writer, ctx *model.RenderContext, cfg *config.Config) { // to fill the remainder of the line with the default background. outLine := ansiReset + output + ansiReset + "\x1b[K" fmt.Fprintln(w, outLine) + + // Emit extra lines from widgets that produce multiple rows (e.g. agents). + for i, r := range results { + for _, extra := range r.ExtraLines { + var extraOutput string + switch mode { + case "powerline": + extraOutput = renderPowerline([]widget.WidgetResult{extra}, []string{names[i]}, cfg) + case "minimal": + extraOutput = renderMinimal([]widget.WidgetResult{extra}, line, cfg) + default: + extraOutput = applyWidgetStyle(extra, names[i], cfg) + } + if extraOutput == "" { + continue + } + if ctx.TerminalWidth >= minTruncateWidth { + extraOutput = ansi.Truncate(extraOutput, ctx.TerminalWidth, truncateSuffix) + } + fmt.Fprintln(w, ansiReset+extraOutput+ansiReset+"\x1b[K") + } + } } // Extra command output: rendered as a final line when non-empty. diff --git a/internal/render/render_test.go b/internal/render/render_test.go index e5be957..24dfbf8 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -984,3 +984,43 @@ func TestRender_CompletedAgentWithLowDurationHidden(t *testing.T) { t.Errorf("expected agent 'Ghost' with DurationMs=6 to be hidden, but found in %q", out) } } + +func TestRender_ExtraLinesEmittedPerMode(t *testing.T) { + agents := []model.AgentEntry{ + {Name: "Alpha", Status: "running", ColorIndex: 0, StartTime: time.Now()}, + {Name: "Beta", Status: "running", ColorIndex: 1, StartTime: time.Now()}, + {Name: "Gamma", Status: "running", ColorIndex: 2, StartTime: time.Now()}, + } + ctx := &model.RenderContext{ + TerminalWidth: 200, + Transcript: &model.TranscriptData{Agents: agents}, + } + + for _, mode := range []string{"plain", "powerline", "minimal"} { + t.Run(mode, func(t *testing.T) { + cfg := config.LoadHud() + cfg.Style.Icons = "ascii" + cfg.Style.Mode = mode + cfg.Lines = []config.Line{ + {Widgets: []string{"agents"}}, + } + + var buf bytes.Buffer + Render(&buf, ctx, cfg) + out := buf.String() + + // 3 agents should produce 3 output lines (1 main + 2 extra). + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 3 { + t.Errorf("mode %q: expected 3 output lines for 3 agents, got %d: %q", mode, len(lines), out) + } + + // All agent names must appear somewhere in the output. + for _, name := range []string{"Alpha", "Beta", "Gamma"} { + if !strings.Contains(out, name) { + t.Errorf("mode %q: expected agent %q in output, got %q", mode, name, out) + } + } + }) + } +} diff --git a/internal/render/widget/agents.go b/internal/render/widget/agents.go index 7c2b9a2..4e0d8c4 100644 --- a/internal/render/widget/agents.go +++ b/internal/render/widget/agents.go @@ -1,24 +1,21 @@ package widget import ( - "fmt" - "strings" "time" "github.com/Jason-Adam/vitals/internal/config" "github.com/Jason-Adam/vitals/internal/model" - "github.com/charmbracelet/x/ansi" ) -// agentSeparator is the text between agent entries. -const agentSeparator = " | " - // Agents renders running and recently-completed sub-agent entries. // Running agents show a colored robot icon, half-circle indicator, and elapsed time. // Completed agents show a dim colored robot icon, check mark, and duration. // Returns an empty WidgetResult when ctx.Transcript is nil or there are no agents to show. -// FgColor is left empty because the widget composes multiple styles internally; -// the renderer passes the pre-styled Text through as-is. +// +// The first agent is returned as the main WidgetResult; additional agents are +// stacked vertically via ExtraLines. FgColor is set to the first agent's +// palette color for theme/minimal mode compatibility. Each agent's Text field +// contains pre-styled ANSI with per-element colors (icon, name, elapsed). func Agents(ctx *model.RenderContext, cfg *config.Config) WidgetResult { if ctx.Transcript == nil { return WidgetResult{} @@ -53,70 +50,27 @@ func Agents(ctx *model.RenderContext, cfg *config.Config) WidgetResult { return WidgetResult{} } - var parts []string - var plainParts []string - for _, a := range toShow { - parts = append(parts, formatAgentEntry(a, icons)) - plainParts = append(plainParts, formatAgentEntryPlain(a, icons)) - } - - // When the terminal width is known, truncate trailing agents with a count - // indicator (e.g. "+2 more") instead of letting the render stage cut mid-entry - // with "...". This produces a more readable result when many agents are running. - if ctx.TerminalWidth > 0 { - parts, plainParts = truncateAgentEntries(parts, plainParts, ctx.TerminalWidth) - } + // Stack agents vertically: first agent is the main result, remaining + // agents are emitted as extra lines (one per row). + first := toShow[0] + fgColor := agentColors[first.ColorIndex%8] - // Use the first agent's palette color as the dominant fg. - fgColor := agentColors[toShow[0].ColorIndex%8] - - return WidgetResult{ - Text: strings.Join(parts, agentSeparator), - PlainText: strings.Join(plainParts, agentSeparator), + result := WidgetResult{ + Text: formatAgentEntry(first, icons), + PlainText: formatAgentEntryPlain(first, icons), FgColor: fgColor, } -} -// truncateAgentEntries drops trailing entries that would push the joined output -// beyond maxWidth, appending a "+N more" indicator to signal the hidden count. -// It measures width using the plain-text variants (no ANSI codes) so that -// wide characters and multi-byte icons are counted correctly. -func truncateAgentEntries(styled, plain []string, maxWidth int) ([]string, []string) { - // Rough overhead: ANSI reset prefix + reset suffix + erase-to-EOL added by - // the renderer. We leave a conservative 8-column margin for those bytes. - const rendererOverhead = 8 - - available := maxWidth - rendererOverhead - if available <= 0 { - available = maxWidth + for _, a := range toShow[1:] { + color := agentColors[a.ColorIndex%8] + result.ExtraLines = append(result.ExtraLines, WidgetResult{ + Text: formatAgentEntry(a, icons), + PlainText: formatAgentEntryPlain(a, icons), + FgColor: color, + }) } - total := len(plain) - for keep := total; keep > 0; keep-- { - candidate := strings.Join(plain[:keep], agentSeparator) - if keep < total { - candidate += agentSeparator + fmt.Sprintf("+%d more", total-keep) - } - if ansi.StringWidth(candidate) <= available { - if keep == total { - // Everything fits — return as-is. - return styled, plain - } - // Build new slices to avoid mutating the caller's backing arrays. - suffix := fmt.Sprintf("+%d more", total-keep) - outStyled := make([]string, keep+1) - copy(outStyled, styled[:keep]) - outStyled[keep] = DimStyle.Render(suffix) - outPlain := make([]string, keep+1) - copy(outPlain, plain[:keep]) - outPlain[keep] = suffix - return outStyled, outPlain - } - } - - // Fallback: not even one entry fits. Return just the first entry without - // a count indicator so the render stage can truncate it further. - return styled[:1], plain[:1] + return result } // agentStaleThreshold is how long after completion before an agent entry @@ -134,28 +88,13 @@ func isStaleAgent(a model.AgentEntry) bool { return time.Since(completedAt) > agentStaleThreshold } -// maxAgentNameWidth is the character budget for the name/description portion -// of an agent entry. The full entry also includes icon, model suffix, status -// indicator, and elapsed/duration, so the name must be capped to prevent a -// single verbose description from consuming the entire line. -const maxAgentNameWidth = 25 - -// truncateAgentName caps name to maxWidth visible characters, appending "…" -// when truncation is needed. Returns name unchanged when it fits. -func truncateAgentName(name string, maxWidth int) string { - if ansi.StringWidth(name) <= maxWidth { - return name - } - return ansi.Truncate(name, maxWidth-1, "") + "…" -} - // formatAgentEntryPlain renders a single agent entry as unstyled text. func formatAgentEntryPlain(a model.AgentEntry, icons Icons) string { displayName := a.Name if a.Description != "" { displayName = a.Description } - displayName = truncateAgentName(displayName, maxAgentNameWidth) + // No fixed width cap — the render pipeline truncates at terminal width. modelSuffix := modelFamilySuffix(a.Model) label := icons.Task + " " + displayName + modelSuffix @@ -180,7 +119,7 @@ func formatAgentEntry(a model.AgentEntry, icons Icons) string { if a.Description != "" { displayName = a.Description } - displayName = truncateAgentName(displayName, maxAgentNameWidth) + // No fixed width cap — the render pipeline truncates at terminal width. if a.Status == "running" { elapsed := formatElapsed(time.Since(a.StartTime)) diff --git a/internal/render/widget/agents_test.go b/internal/render/widget/agents_test.go index 047ab88..21f2e10 100644 --- a/internal/render/widget/agents_test.go +++ b/internal/render/widget/agents_test.go @@ -6,7 +6,6 @@ import ( "time" "github.com/Jason-Adam/vitals/internal/model" - "github.com/charmbracelet/x/ansi" ) func longAgent(name string, status string) model.AgentEntry { @@ -19,11 +18,9 @@ func longAgent(name string, status string) model.AgentEntry { } } -func TestAgents_ThreeLongDescriptions_Width120_TruncatedNames(t *testing.T) { +func TestAgents_SingleAgent_NoExtraLines(t *testing.T) { agents := []model.AgentEntry{ - longAgent("Implement comprehensive test coverage for parser", "running"), - longAgent("Refactor authentication middleware for OAuth2 flow", "running"), - longAgent("Review structural completeness of API endpoints", "running"), + longAgent("Explore", "running"), } ctx := &model.RenderContext{ TerminalWidth: 120, @@ -33,83 +30,79 @@ func TestAgents_ThreeLongDescriptions_Width120_TruncatedNames(t *testing.T) { result := Agents(ctx, cfg) if result.IsEmpty() { - t.Fatal("expected non-empty result for 3 agents at width 120") - } - - // Per-entry name truncation should have kicked in — original names are - // >25 chars and should not appear verbatim. - for _, a := range agents { - if strings.Contains(result.PlainText, a.Name) { - t.Errorf("expected agent name %q to be truncated, but found it verbatim", a.Name) - } + t.Fatal("expected non-empty result for 1 agent") } - // At least some entries should show the truncation ellipsis. - if !strings.Contains(result.PlainText, "…") { - t.Errorf("expected truncation ellipsis '…' in output, got %q", result.PlainText) + if len(result.ExtraLines) != 0 { + t.Errorf("expected no extra lines for single agent, got %d", len(result.ExtraLines)) } - // At width 120, at least 2 agents should be visible (with or without "+N more"). - // Each truncated entry is ~40 chars, so 2 fit comfortably. - entryCount := strings.Count(result.PlainText, "…") - if entryCount < 2 { - t.Errorf("expected at least 2 truncated agent entries at width 120, got %d in %q", entryCount, result.PlainText) + if !strings.Contains(result.PlainText, "Explore") { + t.Errorf("expected 'Explore' in PlainText, got %q", result.PlainText) } } -func TestAgents_ThreeLongDescriptions_Width80_TwoVisiblePlusMore(t *testing.T) { +func TestAgents_MultipleAgents_StackedVertically(t *testing.T) { agents := []model.AgentEntry{ - longAgent("Implement comprehensive test coverage for parser", "running"), - longAgent("Refactor authentication middleware for OAuth2 flow", "running"), - longAgent("Review structural completeness of API endpoints", "running"), + longAgent("Explore", "running"), + longAgent("Plan", "running"), + longAgent("Review", "running"), } ctx := &model.RenderContext{ - TerminalWidth: 80, + TerminalWidth: 120, Transcript: &model.TranscriptData{Agents: agents}, } cfg := defaultCfg() result := Agents(ctx, cfg) if result.IsEmpty() { - t.Fatal("expected non-empty result for 3 agents at width 80") + t.Fatal("expected non-empty result for 3 agents") } - // At width 80, not all 3 should fit — expect a "+N more" indicator. - if !strings.Contains(result.PlainText, "more") { - t.Errorf("expected '+N more' at width 80, got %q", result.PlainText) + // First agent in main result. + if !strings.Contains(result.PlainText, "Explore") { + t.Errorf("expected first agent 'Explore' in PlainText, got %q", result.PlainText) + } + + // Remaining agents in ExtraLines. + if len(result.ExtraLines) != 2 { + t.Fatalf("expected 2 extra lines, got %d", len(result.ExtraLines)) + } + + // Main result should NOT contain agents 2 and 3 — they're in ExtraLines. + if strings.Contains(result.PlainText, "Plan") { + t.Error("agent 'Plan' should be in ExtraLines, not PlainText") + } + if strings.Contains(result.PlainText, "Review") { + t.Error("agent 'Review' should be in ExtraLines, not PlainText") } } -func TestAgents_OneLongDescription_Truncated_NoMore(t *testing.T) { +func TestAgents_LongNames_NotTruncatedByWidget(t *testing.T) { agents := []model.AgentEntry{ longAgent("Implement comprehensive test coverage for the entire parser subsystem", "running"), } ctx := &model.RenderContext{ - TerminalWidth: 120, + TerminalWidth: 200, Transcript: &model.TranscriptData{Agents: agents}, } cfg := defaultCfg() result := Agents(ctx, cfg) if result.IsEmpty() { - t.Fatal("expected non-empty result for 1 agent") - } - - // Name should be truncated. - if strings.Contains(result.PlainText, agents[0].Name) { - t.Errorf("expected long name to be truncated, but found verbatim: %q", result.PlainText) + t.Fatal("expected non-empty result") } - // No "+N more" since there's only one agent. - if strings.Contains(result.PlainText, "more") { - t.Errorf("expected no '+N more' for single agent, got %q", result.PlainText) + // Long names should appear verbatim — no widget-level truncation. + if !strings.Contains(result.PlainText, agents[0].Name) { + t.Errorf("expected long name verbatim in PlainText, got %q", result.PlainText) } } -func TestAgents_ShortNames_NoTruncation(t *testing.T) { - agents := []model.AgentEntry{ - longAgent("Explore", "running"), - longAgent("Plan", "running"), +func TestAgents_MaxFiveTotal(t *testing.T) { + var agents []model.AgentEntry + for i := 0; i < 7; i++ { + agents = append(agents, longAgent("Agent"+string(rune('A'+i)), "running")) } ctx := &model.RenderContext{ TerminalWidth: 120, @@ -119,43 +112,11 @@ func TestAgents_ShortNames_NoTruncation(t *testing.T) { result := Agents(ctx, cfg) if result.IsEmpty() { - t.Fatal("expected non-empty result") - } - - // Short names should appear verbatim — no truncation ellipsis. - if !strings.Contains(result.PlainText, "Explore") { - t.Errorf("expected 'Explore' verbatim in output, got %q", result.PlainText) - } - if !strings.Contains(result.PlainText, "Plan") { - t.Errorf("expected 'Plan' verbatim in output, got %q", result.PlainText) - } -} - -func TestTruncateAgentName_Short(t *testing.T) { - name := "Explore" - got := truncateAgentName(name, maxAgentNameWidth) - if got != name { - t.Errorf("truncateAgentName(%q): got %q, want unchanged", name, got) + t.Fatal("expected non-empty result for 7 agents") } -} - -func TestTruncateAgentName_Long(t *testing.T) { - name := "Implement comprehensive test coverage for parser" - got := truncateAgentName(name, maxAgentNameWidth) - - if ansi.StringWidth(got) > maxAgentNameWidth { - t.Errorf("truncateAgentName: width %d exceeds max %d, got %q", ansi.StringWidth(got), maxAgentNameWidth, got) - } - if !strings.HasSuffix(got, "…") { - t.Errorf("truncateAgentName: expected '…' suffix, got %q", got) - } -} - -func TestTruncateAgentName_ExactFit(t *testing.T) { - // Build a name exactly maxAgentNameWidth chars — should not be truncated. - name := strings.Repeat("x", maxAgentNameWidth) - got := truncateAgentName(name, maxAgentNameWidth) - if got != name { - t.Errorf("truncateAgentName: exact-fit name should not be truncated, got %q", got) + // 1 main + ExtraLines should total at most 5. + total := 1 + len(result.ExtraLines) + if total > 5 { + t.Errorf("expected at most 5 agents total, got %d", total) } } diff --git a/internal/render/widget/widget.go b/internal/render/widget/widget.go index e37fdb8..e033098 100644 --- a/internal/render/widget/widget.go +++ b/internal/render/widget/widget.go @@ -23,6 +23,12 @@ type WidgetResult struct { PlainText string // unstyled text (for powerline/minimal modes) FgColor string // foreground color (lipgloss color string) for PlainText BgColor string // background color (lipgloss color string); empty means use theme + + // ExtraLines holds additional widget results emitted as separate output + // lines after the main line. Used by the agents widget to stack each + // agent on its own row. The render pipeline applies mode-appropriate + // styling (plain/powerline/minimal) and ANSI truncation to each entry. + ExtraLines []WidgetResult } // IsEmpty reports whether the result has no content to display. diff --git a/internal/render/widget/widget_test.go b/internal/render/widget/widget_test.go index ec49e3b..6b7fd54 100644 --- a/internal/render/widget/widget_test.go +++ b/internal/render/widget/widget_test.go @@ -824,18 +824,20 @@ func TestAgentsWidget_MaxFiveTotal(t *testing.T) { ctx := &model.RenderContext{Transcript: &model.TranscriptData{Agents: agents}} cfg := defaultCfg() - got := Agents(ctx, cfg).Text - // Count the " | " separators — 4 separators means 5 entries. - separators := strings.Count(got, " | ") - if separators > 4 { - t.Errorf("Agents: expected at most 5 entries (4 separators), got %d separators in %q", separators, got) + got := Agents(ctx, cfg) + if got.IsEmpty() { + t.Fatal("Agents: expected non-empty result for 7 agents") + } + // 1 main + ExtraLines should total at most 5. + total := 1 + len(got.ExtraLines) + if total > 5 { + t.Errorf("Agents: expected at most 5 entries, got %d", total) } } -func TestAgentsWidget_TruncatesWithPlusMoreWhenNarrow(t *testing.T) { - // With a narrow terminal width, agents that don't fit should be replaced - // with a "+N more" indicator rather than being cut mid-character by the - // render-level truncation. +func TestAgentsWidget_NarrowWidth_StillStacks(t *testing.T) { + // With vertical stacking, narrow width doesn't affect the widget itself — + // the render pipeline handles per-line truncation. agents := []model.AgentEntry{ {Name: "agent-alpha", Status: "running", ColorIndex: 0, StartTime: time.Now()}, {Name: "agent-beta", Status: "running", ColorIndex: 1, StartTime: time.Now()}, @@ -843,26 +845,23 @@ func TestAgentsWidget_TruncatesWithPlusMoreWhenNarrow(t *testing.T) { } ctx := &model.RenderContext{ Transcript: &model.TranscriptData{Agents: agents}, - TerminalWidth: 40, // narrow enough that 3 agents don't fit + TerminalWidth: 40, } cfg := defaultCfg() cfg.Style.Icons = "ascii" got := Agents(ctx, cfg) - // The plain text must contain "+N more" since 3 agents can't fit in 40 cols. - if !strings.Contains(got.PlainText, "more") { - t.Errorf("expected '+N more' in PlainText for narrow width, got %q", got.PlainText) - } - // The first agent must still appear. + // First agent in main result, remaining two in ExtraLines. if !strings.Contains(got.PlainText, "agent-alpha") { - t.Errorf("expected first agent 'agent-alpha' to remain, got %q", got.PlainText) + t.Errorf("expected first agent in PlainText, got %q", got.PlainText) + } + if len(got.ExtraLines) != 2 { + t.Errorf("expected 2 extra lines, got %d", len(got.ExtraLines)) } } -func TestAgentsWidget_NoTruncationWhenWidthZero(t *testing.T) { - // When TerminalWidth is 0 (unknown), the widget skips its own truncation - // and relies on the render-stage fallback width. All agents must appear. +func TestAgentsWidget_WidthZero_StillStacks(t *testing.T) { agents := []model.AgentEntry{ {Name: "alpha", Status: "running", ColorIndex: 0, StartTime: time.Now()}, {Name: "beta", Status: "running", ColorIndex: 1, StartTime: time.Now()}, @@ -877,45 +876,33 @@ func TestAgentsWidget_NoTruncationWhenWidthZero(t *testing.T) { got := Agents(ctx, cfg) - // All three agents must appear in plain text. - for _, name := range []string{"alpha", "beta", "gamma"} { - if !strings.Contains(got.PlainText, name) { - t.Errorf("expected all agents when width=0, missing %q in %q", name, got.PlainText) - } + // First agent in main result. + if !strings.Contains(got.PlainText, "alpha") { + t.Errorf("expected 'alpha' in PlainText, got %q", got.PlainText) } - // No "+N more" should be present since the widget skips truncation at width=0. - if strings.Contains(got.PlainText, "more") { - t.Errorf("expected no '+N more' when TerminalWidth=0, got %q", got.PlainText) + // Remaining agents in ExtraLines. + if len(got.ExtraLines) != 2 { + t.Errorf("expected 2 extra lines, got %d", len(got.ExtraLines)) } } -func TestTruncateAgentEntries_AllFit(t *testing.T) { - styled := []string{"A", "B"} - plain := []string{"A", "B"} - outStyled, outPlain := truncateAgentEntries(styled, plain, 200) - if len(outPlain) != 2 || outPlain[0] != "A" || outPlain[1] != "B" { - t.Errorf("all-fit: expected [A B], got %v", outPlain) +func TestAgentsWidget_ExtraLines(t *testing.T) { + agents := []model.AgentEntry{ + {Name: "A", Status: "running", StartTime: time.Now(), ColorIndex: 0}, + {Name: "B", Status: "running", StartTime: time.Now(), ColorIndex: 1}, } - _ = outStyled -} - -func TestTruncateAgentEntries_OneDropped(t *testing.T) { - // Width must accommodate "agent-alpha 1m30s | +1 more" (28 chars) - // plus the 8-column renderer overhead, so maxWidth=40 gives available=32. - plain := []string{"agent-alpha 1m30s", "agent-beta 0m45s"} - styled := plain // use same for simplicity - outStyled, outPlain := truncateAgentEntries(styled, plain, 40) - // Must have exactly 2 elements: first entry + "+1 more" - if len(outPlain) != 2 { - t.Errorf("expected 2 output elements (1 entry + indicator), got %d: %v", len(outPlain), outPlain) + ctx := &model.RenderContext{ + TerminalWidth: 120, + Transcript: &model.TranscriptData{Agents: agents}, } - if outPlain[0] != "agent-alpha 1m30s" { - t.Errorf("expected first entry preserved, got %q", outPlain[0]) + cfg := defaultCfg() + result := Agents(ctx, cfg) + if result.IsEmpty() { + t.Fatal("expected non-empty result") } - if outPlain[1] != "+1 more" { - t.Errorf("expected '+1 more', got %q", outPlain[1]) + if len(result.ExtraLines) != 1 { + t.Errorf("expected 1 extra line for 2 agents, got %d", len(result.ExtraLines)) } - _ = outStyled } // -- Todos widget -------------------------------------------------------------