Documentation
¶
Overview ¶
Package ui centralizes agentsync's terminal presentation: semantic color, a curated glyph vocabulary, a labeled diagnostic vocabulary, and small layout primitives (sections, status lines, aligned labels). It is the single place that decides whether to emit ANSI, so every command renders through a *Printer and the color/glyph/spacing language stays consistent across `status`, `diff`, `doctor`, and `apply`.
Output splits into two kinds, and the distinction is the organizing rule of this package:
- A DIAGNOSTIC is a notice *about* the run — an error, a warning, an informational aside. Every one carries a level label (`✗ ERROR`, `⚠ WARN`, `ℹ INFO`) and goes to stderr. See diag.go for the vocabulary and slog.go for the handler that gives library-side slog calls the same shape.
- RESULT output is what the command was asked to produce — a status table, a diff, a `--json` payload, a list, or the one-line outcome of a mutating command. It carries no level label. Its success line instead leads with a curated emoji (`✅ added agent: claude`), because labeling an outcome "INFO" is noise that dilutes the labels that matter.
Two independent axes govern how any of it is styled:
- Color is TTY-gated. `--color=always|never` forces it; `auto` (the default) enables color only when the output is a terminal and NO_COLOR (https://no-color.org) is unset. Non-TTY output (pipes, files, tests) is therefore byte-for-byte plain — color never leaks into a redirect.
- Glyphs are always Unicode. The ✓ / ◐ / ✗ vocabulary already appears in the translation report and the capability matrix; keeping it unconditional means piped output reads the same as the screen and existing fixtures hold. Color, not glyph choice, is what degrades.
Color is reserved for state: a green ✓ means synced, a red ✗ means drift. It is never decoration. Everything still parses with color stripped.
Index ¶
- Constants
- func Pad(s string, width int) string
- func Sanitize(s string) string
- type ColorMode
- type Level
- type Printer
- func (p *Printer) Blue(s string) string
- func (p *Printer) Bold(s string) string
- func (p *Printer) Color() bool
- func (p *Printer) Cyan(s string) string
- func (p *Printer) Detailf(format string, args ...any)
- func (p *Printer) Diagf(l Level, format string, args ...any)
- func (p *Printer) Errorf(format string, args ...any)
- func (p *Printer) Faint(s string) string
- func (p *Printer) Fdetailf(w io.Writer, format string, args ...any)
- func (p *Printer) Fdiagf(w io.Writer, l Level, format string, args ...any)
- func (p *Printer) Fsuccessf(w io.Writer, emoji, format string, args ...any)
- func (p *Printer) Green(s string) string
- func (p *Printer) Infof(format string, args ...any)
- func (p *Printer) Red(s string) string
- func (p *Printer) Section(title string)
- func (p *Printer) Spin(label string) func()
- func (p *Printer) Spinner(label string) *Spinner
- func (p *Printer) Successf(emoji, format string, args ...any)
- func (p *Printer) Warnf(format string, args ...any)
- func (p *Printer) Yellow(s string) string
- type SlogHandler
- type Spinner
- type WarnWriter
Constants ¶
const ( EmojiSuccess = "✅" // generic: added / set / saved / validated / passed EmojiApplied = "🎉" // an apply wrote changes EmojiRemoved = "🧹" // removed / purged / pruned EmojiImported = "📥" // captured destination → canonical source EmojiReverted = "🔙" // rolled back to a checkpoint EmojiInit = "✨" // a new home or project tree was created )
Success emoji vocabulary. Success lines deliberately carry NO level word: an "INFO" on "added agent: claude" is noise, and the emoji already says both "this worked" and "this is the outcome line, not a diagnostic". Each is a default-emoji-presentation rune (no variation selector), so terminals render them consistently without a VS16 width surprise.
Pick by what happened, not by which command ran — `mcp remove` and `agent purge` are both EmojiRemoved.
const ( GlyphOK = "✓" // success / synced / clean GlyphPartial = "◐" // partial coverage (mirrors the capability matrix) GlyphErr = "✗" // failure / drift / missing GlyphWarn = "⚠" // warning / needs attention GlyphInfo = "•" // neutral bullet, inside a report body GlyphArrow = "→" // transition / "see" // The two glyphs below belong to the DIAGNOSTIC label vocabulary in diag.go // rather than to report layout, but they live here so the whole curated set // is visible in one place. // // GlyphBullet is the same rune as GlyphInfo and that is deliberate: a faint // bullet is the right mark for a DEBUG label and for a list item alike. They // are named separately so a future change to one cannot silently move the // other — the failure mode a shared constant invites. GlyphNote = "ℹ" // INFO label GlyphBullet = "•" // DEBUG label )
Curated glyph vocabulary. Always Unicode; each is one display column wide, so callers can align around them with plain rune/space counting (no runewidth).
Variables ¶
This section is empty.
Functions ¶
func Pad ¶
Pad left-justifies s to a fixed visible width, counting runes (the glyph set is single-width) rather than bytes, then returns the padded plain string. Callers color the RESULT so that ANSI bytes never throw off the column — padding is applied before any escape codes exist.
Alignment is best-effort for non-ASCII: counting is per-rune, so wide/ ambiguous-width runes (CJK, emoji) and combining marks make the rune count diverge from the actual display-cell width and skew the column. This is a deliberate, purely cosmetic limitation — bringing in a grapheme-aware width table (golang.org/x/text/width) was judged not worth the dependency for the curated, mostly-ASCII output here. Sanitize removes the security-relevant deceptive runes (bidi/zero-width) but does not normalize width.
func Sanitize ¶ added in v0.5.0
Sanitize strips control characters and deceptive format runes from a string so untrusted text can be rendered to a terminal without smuggling escape sequences or spoofed/hidden text. It is a thin re-export of untrusted.Sanitize (which owns the implementation and its full doc) for the display sites that hold a plain composite/built string rather than an untrusted.Text — a Text sanitizes itself via its String() method, so prefer printing a Text directly. Apply at the display boundary, before width/Pad calculation, so a stripped rune never throws off column alignment.
Types ¶
type ColorMode ¶
type ColorMode int
ColorMode is the resolved value of the global --color flag.
func ParseColorMode ¶
ParseColorMode maps the --color flag string to a ColorMode. An empty string defaults to auto so callers can pass the raw flag value.
type Level ¶ added in v0.13.0
type Level int
Diagnostic severity levels. These are the ONLY severities agentsync renders; they map 1:1 onto slog's levels so a slog.Warn from deep in the render pipeline and a hand-written p.Warnf in a command produce byte-identical lines (see slog.go).
Every diagnostic — anything that is a *notice about* the run rather than the run's own output — carries one. That is the whole point of the vocabulary: #211 shipped a fatal error as an unlabeled flat line directly under a WARN, and the eye had no way to tell which was which. A level word plus a glyph gives it two.
LevelDebug is deliberately the ZERO VALUE: a `var l Level` or a `Diagf(0, …)` then renders as the least-severe level rather than silently claiming ERROR.
func (Level) Label ¶ added in v0.13.0
Label renders the styled, fixed-width label for a level — glyph, space, then the level word padded to levelWordWidth. Padding is computed on the plain word and color applied to the result, so ANSI bytes never enter the width calculation (the same discipline Pad's doc describes).
Exported so a test can construct the exact prefix it expects rather than hardcoding a literal that silently rots when the vocabulary changes.
func (Level) String ¶ added in v0.13.0
String implements fmt.Stringer with the rendered level word, so a Level can be dropped into a message or a test failure without a switch. An out-of-range value names itself rather than borrowing word()'s least-severe fallback: a failure reading Level(9) beats one reading DEBUG.
type Printer ¶
Printer renders styled output to a pair of writers. Construct one per command invocation via New; the color decisions are frozen at construction.
func New ¶
New builds a Printer bound to out/err, resolving whether to emit color from mode, the NO_COLOR environment variable, and whether each writer is a terminal. The two streams are resolved independently — see the struct doc.
func (*Printer) Bold ¶
Semantic style helpers. Each returns s unchanged when color is disabled, so callers can compose them freely without branching.
func (*Printer) Color ¶
Color reports whether this Printer emits ANSI ON OUT. Every diagnostic goes to Err, whose decision is resolved separately (see the struct doc) and reached internally via styleForDiag — so this is deliberately NOT "does this Printer use color". Commands that hand a writer to a third-party renderer (e.g. the diff library's own colorizer) consult this to gate that output through the same decision, and every such caller renders to Out.
func (*Printer) Detailf ¶ added in v0.13.0
Detailf writes an unlabeled continuation line to Err, indented to the message column of the diagnostic above it. Use for the supporting detail of a diagnostic — an enumerated list, a remedy, a path — so the block hangs together under its label.
func (*Printer) Fdiagf ¶ added in v0.13.0
Fdiagf writes a labeled diagnostic at l to an explicit writer. Use it only when a diagnostic must land on a stream other than Err — routing a warning into a command's own report body, say. Prefer Diagf.
Embedded newlines in the formatted message are indented to the message column so a multi-line diagnostic reads as one block rather than one labeled line followed by orphaned text at column 0.
func (*Printer) Fsuccessf ¶ added in v0.13.0
Fsuccessf is Successf against an explicit writer, for commands that stream their result into a caller-supplied buffer.
func (*Printer) Infof ¶ added in v0.13.0
Infof writes a labeled INFO diagnostic to Err.
Diagnostics go to stderr — including INFO — so that a command's actual output (a status table, a `--json` payload, a list) stays cleanly redirectable. An informational line that is part of the *result* is not a diagnostic and should be printed to Out directly or via Successf.
func (*Printer) Section ¶
Section prints a heading (bold when colored, plain text otherwise) to Out.
func (*Printer) Spin ¶
Spin is the one-call helper: it starts a Spinner and returns the stop function. Typical use:
stop := p.Spin("fetching marketplace github")
result, err := fetcher.Fetch(src, cacheDir)
stop()
if err != nil { ... }
func (*Printer) Spinner ¶
Spinner builds a Spinner bound to p.Err and inheriting p's color decision. Call Start to begin and Stop to end; both are idempotent and safe to call in either order (a Stop without Start is a no-op).
func (*Printer) Successf ¶ added in v0.13.0
Successf writes a success line to Out: the emoji, then the message, green when color is on. No level word — see the emoji vocabulary above.
type SlogHandler ¶ added in v0.13.0
type SlogHandler struct {
// contains filtered or unexported fields
}
SlogHandler renders slog records through the diagnostic vocabulary in diag.go, so a library-side slog.Warn is indistinguishable from a command's p.Warnf.
Without it, the render and marketplace pipelines' slog.Warn calls fall through to slog's default and print through the standard log package — `2026/07/28 15:03:45 WARN …`, a shape nothing else in the CLI uses. See diag.go for why that mattered.
Records render as the level label, the message, then any attributes on a continuation line indented to the message column:
⚠ WARN plugin component frontmatter is not strict YAML; parsed leniently
path=/Users/me/.agentsync/.state/cache/plugins/x/agents/y.md
Attributes go on their own line because the ones agentsync logs are paths and wrapped errors — long enough that appending them inline pushes the message itself off the screen.
func NewSlogHandler ¶ added in v0.13.0
NewSlogHandler returns a handler that writes records to w styled by p at or above level. Pass p.Err as w for the normal case; the writer is explicit so a caller can tee logs somewhere else without restyling them.
func (*SlogHandler) Handle ¶ added in v0.13.0
Handle implements slog.Handler.
The record's time is deliberately dropped. agentsync's slog output is user-facing CLI diagnostics, not a log stream anyone greps by timestamp, and a wall clock on every warning was the single loudest thing wrong with the pre-existing output. Nothing here feeds a content hash or the drift classifier, so dropping it costs nothing downstream.
type Spinner ¶
type Spinner struct {
// contains filtered or unexported fields
}
Spinner is a lightweight in-place progress indicator for slow network ops (marketplace fetch, plugin pull). It animates only when its writer is a terminal; off a terminal (CI logs, piped stderr, captured-output tests) it is a complete no-op — no animation, no static fallback line — so byte-stable fixtures stay byte-stable and grep'd output stays clean. The success line a caller already prints carries the result.
type WarnWriter ¶
type WarnWriter struct {
// contains filtered or unexported fields
}
WarnWriter wraps a destination writer and rewrites "warning: " line prefixes into the shared WARN diagnostic label (see diag.go) so every warning — whether emitted by the CLI itself, by an adapter's Ingest, or by capture's re-reference path — is byte-identical to a p.Warnf and to a slog.Warn from the render pipeline. Lines that do not start with the literal "warning: " prefix (e.g. pre-styled ANSI lines, indented continuation lines, or already- labeled diagnostics) pass through verbatim. The writer is line-buffered so a callers' partial Write is held until a newline arrives — fmt.Fprintf in practice always finishes a line per call, but buffering keeps a chunked writer correct.
The "warning: " sentinel stays the emitter-side contract because the adapter packages must not depend on ui: an adapter writes a plain prefixed line into an io.Writer it was handed, and the styling happens here, at the one place that knows whether this terminal gets color.
Not safe for concurrent use: the line-assembly buffer is unsynchronized. One *WarnWriter per command invocation is the intended pattern.
func NewWarnWriter ¶
func NewWarnWriter(w io.Writer, p *Printer) *WarnWriter
NewWarnWriter returns a *WarnWriter that flushes styled lines to w using p. p's color decision is honored: with color off, the prefix degrades to a plain "⚠ WARN" (the glyph and the level word are content, not decoration — same rule as the curated glyph vocabulary above).
func (*WarnWriter) Flush ¶
func (s *WarnWriter) Flush()
Flush drains any buffered partial line, TERMINATING it. Call at end of command if you've routed a writer that may not always end in \n; every emitter today does terminate its lines, so this is defensive.
func (*WarnWriter) RouteTo ¶
func (s *WarnWriter) RouteTo(a any) func()
RouteTo wires this writer into anything that exposes a SetStderr(io.Writer) setter (matching adapter.WarnEmitter) and returns a restore function that detaches the writer when invoked. Idiomatic use pairs with defer:
defer warnW.RouteTo(a)()
The inner RouteTo(a) call evaluates immediately (wires the writer); the outer () is the deferred restore. The returned function is always safe to call — it's a no-op when the target doesn't implement the setter, when the target is a typed-nil pointer, or when the target was an untyped nil — so callers never need to type-assert or nil-check.
Non-implementor cases that resolve to a silent no-op:
- untyped nil (`any(nil)`): the type-assert misses because the interface value carries no concrete type.
- typed nil (`var a *T = nil; RouteTo(a)`): the type-assert SUCCEEDS because the interface value holds the method set of *T, but calling SetStderr would dereference the nil pointer. RouteTo guards against this via reflect.
- any value whose dynamic type doesn't implement SetStderr.