Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
22 changes: 22 additions & 0 deletions internal/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions internal/render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
})
}
}
105 changes: 22 additions & 83 deletions internal/render/widget/agents.go
Original file line number Diff line number Diff line change
@@ -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{}
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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))
Expand Down
Loading
Loading