// Package ui renders agent events to a terminal. The dependency runs one way: // ui imports agent, never the reverse, so the eval runner can drive the loop // with no terminal attached. package ui import ( "encoding/json" "fmt" "io" "os" "sort" "strings" "harness/internal/agent" "harness/internal/llm" ) // ANSI codes, blanked when the output is not a terminal or NO_COLOR is set. type palette struct{ dim, bold, red, cyan, reset string } var color = func() palette { info, err := os.Stdout.Stat() interactive := err == nil && info.Mode()&os.ModeCharDevice != 0 if !interactive || os.Getenv("NO_COLOR") != "" { return palette{} } return palette{dim: "\x1b[2m", bold: "\x1b[1m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m"} }() // Renderer prints the event stream. type Renderer struct { W io.Writer Verbose bool // print full tool results rather than a summary line } func New(w io.Writer) *Renderer { return &Renderer{W: w} } // Handle is the agent.Observe callback. func (r *Renderer) Handle(ev agent.Event) { switch ev.Kind { case agent.EventText: fmt.Fprintf(r.W, "%s\n", strings.TrimSpace(ev.Text)) case agent.EventToolStart: fmt.Fprintf(r.W, "%s⏺ %s%s%s(%s)%s\n", color.cyan, color.bold, ev.Tool, color.reset, summarizeInput(ev.Input), color.reset) case agent.EventToolResult: r.tool(ev) } } func (r *Renderer) tool(ev agent.Event) { prefix, c := " ⎿ ", color.dim if ev.IsError { prefix, c = " ⎿ error: ", color.red } body := strings.TrimRight(ev.Result, "\n") if !r.Verbose { body = firstLines(body, 4) } for i, line := range strings.Split(body, "\n") { if i == 0 { fmt.Fprintf(r.W, "%s%s%s%s\n", c, prefix, line, color.reset) continue } fmt.Fprintf(r.W, "%s %s%s\n", c, line, color.reset) } } // summarizeInput renders tool arguments as k=v, sorted so the same call always // prints the same way. func summarizeInput(raw json.RawMessage) string { var m map[string]any if len(raw) == 0 || json.Unmarshal(raw, &m) != nil { return string(raw) } keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { v := fmt.Sprintf("%v", m[k]) if len(v) > 60 { v = v[:60] + "…" } parts = append(parts, fmt.Sprintf("%s=%s", k, v)) } return strings.Join(parts, ", ") } func firstLines(s string, n int) string { lines := strings.Split(s, "\n") if len(lines) <= n { return s } return strings.Join(lines[:n], "\n") + fmt.Sprintf("\n… %d more lines", len(lines)-n) } // Usage prints the running token totals for the session. func (r *Renderer) Usage(u llm.Usage, turns int) { fmt.Fprintf(r.W, "%s%d turns · in %d (%d cached) · out %d%s\n", color.dim, turns, u.InputTokens, u.CacheReadInputTokens, u.OutputTokens, color.reset) } // Error prints a failure. func (r *Renderer) Error(err error) { fmt.Fprintf(r.W, "%serror: %v%s\n", color.red, err, color.reset) }