chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
// Command demo drives the live progress renderer through the flagship
// sequences so the animation can be eyeballed (and its byte stream captured)
// without touching a real daemon or repo:
//
// go run ./internal/progress/demo # full showcase
// go run ./internal/progress/demo -scene=track|enrich|fail
// go run ./internal/progress/demo -plain # the --no-progress rendering
//
// It is a development aid, deliberately outside cmd/gortex so it never ships
// in the binary.
package main
import (
"errors"
"flag"
"fmt"
"os"
"time"
"github.com/zzet/gortex/internal/progress"
)
type phase struct {
run string
done string
target int64
unit string
note string
dur time.Duration
}
// The indexing storyboard: scan → parse → graph → cross-repo → communities.
var phases = []phase{
{"scanning workspace", "files discovered", 2431, "files", "", 900 * time.Millisecond},
{"parsing sources", "symbols extracted", 48210, "symbols", "go · ts · py · sql · yaml", 1500 * time.Millisecond},
{"building knowledge graph", "edges materialized", 612940, "edges", "", 1700 * time.Millisecond},
{"resolving cross-repo edges", "cross-repo links", 1284, "xrepo", "", 1100 * time.Millisecond},
{"detecting communities", "modules clustered", 7, "modules", "", 800 * time.Millisecond},
}
func main() {
scene := flag.String("scene", "all", "track|enrich|fail|all")
plain := flag.Bool("plain", false, "render the --no-progress plain output")
flag.Parse()
switch *scene {
case "track":
track(*plain)
case "enrich":
enrich(*plain)
case "fail":
fail(*plain)
default:
track(*plain)
fmt.Fprintln(os.Stderr)
enrich(*plain)
fmt.Fprintln(os.Stderr)
fail(*plain)
}
}
func newTracker(plain bool, opts ...progress.TrackerOption) *progress.Tracker {
if plain {
opts = append(opts, progress.WithoutAnimation())
}
return progress.NewTracker(os.Stderr, opts...)
}
// track is the banner-preset showcase: planned phases, counts easing up,
// the mark lighting dot by dot, then the ready line.
func track(plain bool) {
tr := newTracker(plain, progress.WithLogo())
tr.Start("gortex track .")
steps := make([]*progress.Step, len(phases))
for i, p := range phases {
steps[i] = tr.AddStep(p.run)
steps[i].SetUnit(p.unit)
}
for i, p := range phases {
tr.StartStep(p.run)
if p.note != "" {
steps[i].Note(p.note)
}
countUp(steps[i], p.target, p.dur)
steps[i].DoneAs(p.done)
}
tr.SetStatus("")
tr.Done("ready", "indexed 2,431 files · 48,210 symbols · 1 repo tracked")
}
// enrich is the compact preset driven purely through the Reporter interface —
// what every existing spinner call site gets for free.
func enrich(plain bool) {
tr := newTracker(plain)
tr.Start("gortex enrich all")
stages := []struct {
name string
total int
dur time.Duration
}{
{"stamping blame", 420, 700 * time.Millisecond},
{"loading coverage", 96, 400 * time.Millisecond},
{"linking releases", 31, 300 * time.Millisecond},
}
for _, s := range stages {
start := time.Now()
for time.Since(start) < s.dur {
frac := float64(time.Since(start)) / float64(s.dur)
tr.Report(s.name, int(frac*float64(s.total)), s.total)
time.Sleep(30 * time.Millisecond)
}
tr.Report(s.name, s.total, s.total)
}
tr.Done("", "3 enrichment passes complete")
}
// fail shows the failure state, including a log line above the live region.
func fail(plain bool) {
tr := newTracker(plain)
tr.Start("gortex enrich coverage")
s := tr.StartStep("parsing profile")
countUp(s, 1200, 500*time.Millisecond)
s.Done()
tr.Logf("warning: 3 segments referenced files outside the repo")
tr.StartStep("stamping nodes")
time.Sleep(600 * time.Millisecond)
tr.Fail(errors.New("daemon connection lost"))
}
// countUp eases a counter to target over dur with the design's cubic
// ease-out, ticking faster than the frame rate so the animation stays smooth.
func countUp(s *progress.Step, target int64, dur time.Duration) {
start := time.Now()
for {
t := float64(time.Since(start)) / float64(dur)
if t >= 1 {
break
}
u := 1 - t
eased := 1 - u*u*u
s.Progress(int64(eased*float64(target)), target)
time.Sleep(25 * time.Millisecond)
}
s.Progress(target, target)
}
+88
View File
@@ -0,0 +1,88 @@
package progress
import (
"fmt"
"time"
)
// Phase enumerates the typed sub-phases of an index / enrich run, so
// progress labels are consistent across reporters and surfaces instead of
// ad-hoc strings. They double as human-readable stage labels.
type Phase string
const (
PhaseDiscover Phase = "discovering files"
PhaseParse Phase = "parsing"
PhaseResolve Phase = "resolving references"
PhaseInfer Phase = "linking symbols"
PhasePersist Phase = "persisting"
)
// FormatElapsed renders a duration as a compact human string: "45s",
// "5m 12s", "1h 20m".
func FormatElapsed(d time.Duration) string {
if d < 0 {
d = 0
}
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
case d < time.Hour:
m := int(d / time.Minute)
s := int((d % time.Minute) / time.Second)
return fmt.Sprintf("%dm %02ds", m, s)
default:
h := int(d / time.Hour)
m := int((d % time.Hour) / time.Minute)
return fmt.Sprintf("%dh %02dm", h, m)
}
}
// ETA returns the throughput (items/sec) and estimated time remaining given
// a start time and within-phase counters. A zero/unknown total yields a
// zero eta. Rate is 0 until some time and work have elapsed.
func ETA(start time.Time, current, total int) (itemsPerSec float64, eta time.Duration) {
elapsed := time.Since(start)
if elapsed <= 0 || current <= 0 {
return 0, 0
}
itemsPerSec = float64(current) / elapsed.Seconds()
if total > current && itemsPerSec > 0 {
remaining := float64(total - current)
eta = time.Duration(remaining/itemsPerSec) * time.Second
}
return itemsPerSec, eta
}
// ProgressStats bundles the derived progress metrics for a phase, ready to
// drop into a readiness / health payload.
type ProgressStats struct {
Phase string `json:"phase"`
Current int `json:"current"`
Total int `json:"total"`
Percent int `json:"percent"`
ItemsPerSec float64 `json:"items_per_sec"`
Elapsed string `json:"elapsed"`
ETA string `json:"eta,omitempty"`
}
// Stats computes the derived progress metrics for a phase from its start
// time and counters — percent, throughput, human-formatted elapsed, and a
// human-formatted ETA (omitted when the total is unknown).
func Stats(phase string, start time.Time, current, total int) ProgressStats {
rate, eta := ETA(start, current, total)
ps := ProgressStats{
Phase: phase,
Current: current,
Total: total,
ItemsPerSec: rate,
Elapsed: FormatElapsed(time.Since(start)),
}
if total > 0 {
ps.Percent = int(float64(current) / float64(total) * 100)
}
if eta > 0 {
ps.ETA = FormatElapsed(eta)
}
return ps
}
+51
View File
@@ -0,0 +1,51 @@
package progress
import (
"testing"
"time"
)
func TestFormatElapsed(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{0, "0s"},
{30 * time.Second, "30s"},
{90 * time.Second, "1m 30s"},
{3700 * time.Second, "1h 01m"},
}
for _, c := range cases {
if got := FormatElapsed(c.d); got != c.want {
t.Errorf("FormatElapsed(%v) = %q, want %q", c.d, got, c.want)
}
}
}
func TestETAAndStats(t *testing.T) {
start := time.Now().Add(-10 * time.Second)
rate, eta := ETA(start, 100, 200)
if rate < 5 || rate > 20 { // ~10/s
t.Errorf("rate = %v, want ~10/s", rate)
}
if eta <= 0 {
t.Error("eta should be positive when total > current")
}
// Unknown total → no eta.
if _, eta2 := ETA(start, 100, 0); eta2 != 0 {
t.Errorf("unknown total must yield zero eta, got %v", eta2)
}
ps := Stats(string(PhaseParse), start, 100, 200)
if ps.Percent != 50 {
t.Errorf("percent = %d, want 50", ps.Percent)
}
if ps.ETA == "" {
t.Error("Stats must include an ETA when total known")
}
if ps.Phase != "parsing" {
t.Errorf("phase = %q", ps.Phase)
}
}
+70
View File
@@ -0,0 +1,70 @@
package progress
import (
"fmt"
"strconv"
"time"
)
// fmtCount renders n with thousands separators ("48,210"). Counters in the
// step rows read as magnitudes, not digit strings; the separator makes a
// six-digit symbol count legible at a glance.
func fmtCount(n int64) string {
s := strconv.FormatInt(n, 10)
neg := false
if len(s) > 0 && s[0] == '-' {
neg = true
s = s[1:]
}
if len(s) <= 3 {
if neg {
return "-" + s
}
return s
}
head := len(s) % 3
out := make([]byte, 0, len(s)+len(s)/3+1)
if neg {
out = append(out, '-')
}
if head > 0 {
out = append(out, s[:head]...)
}
for i := head; i < len(s); i += 3 {
if len(out) > 0 && out[len(out)-1] != '-' {
out = append(out, ',')
}
out = append(out, s[i:i+3]...)
}
return string(out)
}
// fmtDurationCompact renders a duration at the precision a progress line
// wants: sub-10s with one decimal ("3.2s"), sub-minute in whole seconds,
// then minute granularity. Sub-100ms rounds to "0.1s" floor-less so a fast
// step still shows a real number.
func fmtDurationCompact(d time.Duration) string {
if d < 0 {
d = 0
}
switch {
case d < time.Second:
ms := d.Milliseconds()
if ms < 1 {
ms = 1
}
return fmt.Sprintf("%dms", ms)
case d < 10*time.Second:
return fmt.Sprintf("%.1fs", d.Seconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
case d < time.Hour:
m := int(d / time.Minute)
s := int((d % time.Minute) / time.Second)
return fmt.Sprintf("%dm%02ds", m, s)
default:
h := int(d / time.Hour)
m := int((d % time.Hour) / time.Minute)
return fmt.Sprintf("%dh%02dm", h, m)
}
}
+48
View File
@@ -0,0 +1,48 @@
package progress
import (
"testing"
"time"
)
func TestFmtCount(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "0"},
{5, "5"},
{999, "999"},
{1000, "1,000"},
{48210, "48,210"},
{612940, "612,940"},
{1234567, "1,234,567"},
{-1234, "-1,234"},
{-12, "-12"},
}
for _, c := range cases {
if got := fmtCount(c.in); got != c.want {
t.Errorf("fmtCount(%d) = %q, want %q", c.in, got, c.want)
}
}
}
func TestFmtDurationCompact(t *testing.T) {
cases := []struct {
in time.Duration
want string
}{
{-time.Second, "1ms"},
{0, "1ms"},
{450 * time.Millisecond, "450ms"},
{3200 * time.Millisecond, "3.2s"},
{42 * time.Second, "42s"},
{72 * time.Second, "1m12s"},
{time.Hour + 5*time.Minute, "1h05m"},
}
for _, c := range cases {
if got := fmtDurationCompact(c.in); got != c.want {
t.Errorf("fmtDurationCompact(%v) = %q, want %q", c.in, got, c.want)
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package progress
import (
"os"
"runtime"
"github.com/charmbracelet/lipgloss"
)
// glyphSet is the small set of display glyphs that differ between a UTF-8
// terminal and a legacy OEM / ASCII one: the success / failure markers, the
// status dot, the mid-dot separator, and the box-drawing charset. Gortex
// renders more box-drawing than a check/cross-only CLI (the rounded card
// border), so the ASCII fallback has to cover the whole border too. The
// live tracker adds the progress-bar cells, the pending marker, and the
// busy / ready status dots to the same fallback contract.
type glyphSet struct {
OK string
Fail string
Dot string
DotDim string
Pending string
Sep string
Dash string
Ellipsis string
BarFull string
BarEmpty string
StatusBusy string
StatusReady string
Border lipgloss.Border
}
var (
unicodeGlyphs = glyphSet{
OK: "✓",
Fail: "✗",
Dot: "●",
DotDim: "●",
Pending: "·",
Sep: "·",
Dash: "—",
Ellipsis: "…",
BarFull: "█",
BarEmpty: "░",
StatusBusy: "◐",
StatusReady: "●",
Border: lipgloss.RoundedBorder(),
}
asciiGlyphs = glyphSet{
OK: "+",
Fail: "x",
Dot: "*",
DotDim: "o",
Pending: ".",
Sep: "-",
Dash: "-",
Ellipsis: "...",
BarFull: "#",
BarEmpty: "-",
StatusBusy: "o",
StatusReady: "*",
Border: lipgloss.ASCIIBorder(),
}
brailleSpin = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
asciiSpin = []string{"|", "/", "-", "\\"}
)
// activeGlyphs returns the glyph set appropriate to the current terminal:
// UTF-8 box-drawing / check glyphs when it can render them, ASCII otherwise.
// Resolved per call so a runtime override (or a test) takes effect immediately;
// the cost is a couple of env reads plus, on Windows only, one cheap codepage
// syscall.
func activeGlyphs() glyphSet {
if supportsUnicode() {
return unicodeGlyphs
}
return asciiGlyphs
}
// spinFrames returns the animated-spinner frame cycle. Braille frames need
// font coverage that the check / box-drawing glyphs don't: legacy conhost
// fonts (Consolas on a CP65001 console) render ✓ and █ but draw braille as
// boxes, so on Windows the braille cycle is reserved for terminals that
// declare themselves modern (Windows Terminal, ConEmu, an inherited TERM).
// Everything else animates with the four-frame ASCII cycle.
func spinFrames() []string {
if !supportsUnicode() {
return asciiSpin
}
if runtime.GOOS == "windows" && !windowsModernTerminal() {
return asciiSpin
}
return brailleSpin
}
// windowsModernTerminal reports whether the process runs under a Windows
// terminal with full glyph coverage: Windows Terminal (WT_SESSION), ConEmu
// (ConEmuANSI=ON), or an environment that carries a unix-style TERM (mintty,
// msys, ssh sessions).
func windowsModernTerminal() bool {
if os.Getenv("WT_SESSION") != "" {
return true
}
if os.Getenv("ConEmuANSI") == "ON" {
return true
}
return os.Getenv("TERM") != ""
}
// supportsUnicode reports whether the active terminal can render the UTF-8
// box-drawing / check glyphs. Explicit env overrides win (GORTEX_ASCII opt-out,
// GORTEX_UNICODE opt-in); a linux virtual console (TERM=linux, CP437-ish) and a
// non-UTF-8 Windows console codepage both fall back to ASCII. Every other
// terminal is assumed UTF-8-capable, the modern default.
func supportsUnicode() bool {
if envFlag("GORTEX_ASCII") {
return false
}
if envFlag("GORTEX_UNICODE") {
return true
}
if os.Getenv("TERM") == "linux" {
return false
}
if runtime.GOOS == "windows" {
return windowsConsoleUTF8()
}
return true
}
// envFlag reports whether the named env var is set to a truthy value.
func envFlag(name string) bool {
switch os.Getenv(name) {
case "1", "true", "TRUE", "yes", "on":
return true
}
return false
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package progress
// windowsConsoleUTF8 is only consulted on Windows; supportsUnicode gates the
// call behind a GOOS check, so on every other platform this stub merely keeps
// the build green.
func windowsConsoleUTF8() bool { return true }
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
package progress
import "syscall"
// windowsConsoleUTF8 reports whether the active Windows console output codepage
// is UTF-8 (65001). Legacy OEM codepages (437, 850, 866, …) — still the cmd.exe
// default — cannot render the box-drawing / check glyphs, so the caller falls
// back to ASCII. Uses the kernel32 GetConsoleOutputCP syscall directly to avoid
// pulling in golang.org/x/sys.
func windowsConsoleUTF8() bool {
const cpUTF8 = 65001
proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleOutputCP")
cp, _, _ := proc.Call()
return uint32(cp) == cpUTF8
}
+164
View File
@@ -0,0 +1,164 @@
package progress
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// The gortex mark is a 5×5 dot-matrix "G": an open ring with a tongue in the
// middle row whose tip — the accent dot — always renders in brand green. The
// live renderer lights the remaining dots progressively as work completes, so
// the logo itself is the coarse progress gauge; banners render it fully lit.
type gDot struct {
r, c int
accent bool
}
// gDots lists the mark's dots in row-major order — the order they light up.
var gDots = buildGDots()
func buildGDots() []gDot {
const cells = 5
mid := cells / 2
var dots []gDot
for r := 0; r < cells; r++ {
for c := 0; c < cells; c++ {
onTop := r == 0 && c >= 1 && c <= cells-2
onBot := r == cells-1 && c >= 1 && c <= cells-2
onLeft := c == 0 && r >= 1 && r <= cells-2
onTongue := r == mid && c >= mid && c <= cells-1
onTopRight := c == cells-1 && r == 1
onBotRight := c == cells-1 && r == cells-2
if onTop || onBot || onLeft || onTongue || onTopRight || onBotRight {
dots = append(dots, gDot{r: r, c: c, accent: r == mid && c == cells-1})
}
}
}
return dots
}
// litDotCount is the number of dots that participate in progressive lighting
// (every dot except the always-lit accent tip).
func litDotCount() int { return len(gDots) - 1 }
// logoLines renders the mark as 5 rows. frac in [0,1] lights that fraction of
// the dots bottom-up in row-major order; frac < 0 selects the indeterminate
// marquee — a short lit window orbiting the mark at the given tick. glint, when
// >= 0, additionally paints dot (glint % len) in accent green: the traveling
// highlight used by animated banners over a fully lit mark.
func logoLines(lit, dim, accent lipgloss.Style, g glyphSet, frac float64, tick, glint int) [5]string {
const cells = 5
byPos := make(map[[2]int]int, len(gDots))
for i, d := range gDots {
byPos[[2]int{d.r, d.c}] = i
}
litN := -1
if frac >= 0 {
if frac > 1 {
frac = 1
}
litN = int(frac*float64(litDotCount()) + 0.5)
}
// Non-accent ordinal per dot index, for both fill and marquee windows.
ordinal := make([]int, len(gDots))
ord := 0
for i, d := range gDots {
if d.accent {
ordinal[i] = -1
continue
}
ordinal[i] = ord
ord++
}
const marqueeWindow = 4
var lines [5]string
for r := 0; r < cells; r++ {
var row strings.Builder
for c := 0; c < cells; c++ {
if c > 0 {
row.WriteString(" ")
}
idx, isDot := byPos[[2]int{r, c}]
if !isDot {
row.WriteString(" ")
continue
}
d := gDots[idx]
switch {
case d.accent:
row.WriteString(accent.Render(g.Dot))
case glint >= 0 && idx == glint%len(gDots):
row.WriteString(accent.Render(g.Dot))
case litN >= 0 && ordinal[idx] < litN:
row.WriteString(lit.Render(g.Dot))
case litN < 0 && marqueeHit(ordinal[idx], tick, marqueeWindow):
row.WriteString(lit.Render(g.Dot))
default:
row.WriteString(dim.Render(g.DotDim))
}
}
lines[r] = row.String()
}
return lines
}
// marqueeHit reports whether the dot with the given non-accent ordinal falls
// inside the lit window at this tick.
func marqueeHit(ord, tick, window int) bool {
n := litDotCount()
if ord < 0 || n == 0 {
return false
}
start := tick % n
for i := 0; i < window; i++ {
if (start+i)%n == ord {
return true
}
}
return false
}
// MeshLogo renders one static frame of the gortex mark, fully lit, styled
// with the package palette. A positive tick adds a traveling green glint —
// animated banners (the wizard dashboard) pass a rising tick; static banners
// pass 0 for the pure mark.
func MeshLogo(tick int) string {
glint := -1
if tick > 0 {
glint = tick
}
lines := logoLines(stylePerim, styleInner, styleAccent, activeGlyphs(), 1, tick, glint)
return strings.Join(lines[:], "\n")
}
// MeshFrame returns the gortex mark with label (bold) and sub (dim) beside
// it. Used by watch loops or custom views that want the brand block without
// owning a live tracker.
func MeshFrame(tick int, label, sub string) string {
mesh := MeshLogo(tick)
if label == "" && sub == "" {
return mesh + "\n"
}
right := lipgloss.JoinVertical(
lipgloss.Left,
"",
styleLabel.Render(label),
"",
styleSub.Render(sub),
"",
)
return lipgloss.JoinHorizontal(lipgloss.Top, mesh, " ", right) + "\n"
}
// MeshLogoLines returns the number of vertical rows the mark occupies.
// Exported so wizard / dashboard layouts can reserve space without re-counting
// the constant.
func MeshLogoLines() int { return 5 }
// MeshLogoWidth returns the rendered visual width of the mark in cells.
// 5 cells × "● " spacing = 9 chars.
func MeshLogoWidth() int { return 9 }
+110
View File
@@ -0,0 +1,110 @@
package progress
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// TestGMarkShape pins the brand mark's geometry: 14 dots forming the
// dot-matrix G, accent at the tongue tip.
func TestGMarkShape(t *testing.T) {
if len(gDots) != 14 {
t.Fatalf("expected 14 dots in the mark, got %d", len(gDots))
}
want := map[[2]int]bool{
{0, 1}: true, {0, 2}: true, {0, 3}: true,
{1, 0}: true, {1, 4}: true,
{2, 0}: true, {2, 2}: true, {2, 3}: true, {2, 4}: true,
{3, 0}: true, {3, 4}: true,
{4, 1}: true, {4, 2}: true, {4, 3}: true,
}
accents := 0
for _, d := range gDots {
if !want[[2]int{d.r, d.c}] {
t.Errorf("unexpected dot at (%d,%d)", d.r, d.c)
}
if d.accent {
accents++
if d.r != 2 || d.c != 4 {
t.Errorf("accent dot must be the tongue tip (2,4), got (%d,%d)", d.r, d.c)
}
}
}
if accents != 1 {
t.Errorf("expected exactly one accent dot, got %d", accents)
}
}
// distinctGlyphs makes lit / dim / accent dots distinguishable without color.
var distinctGlyphs = glyphSet{Dot: "X", DotDim: "."}
func renderLogoGlyphs(frac float64, tick, glint int) string {
plain := lipgloss.NewStyle()
lines := logoLines(plain, plain, plain, distinctGlyphs, frac, tick, glint)
return strings.Join(lines[:], "\n")
}
func TestLogoProgressiveLighting(t *testing.T) {
// frac=0: only the accent tip is bright.
if got := strings.Count(renderLogoGlyphs(0, 0, -1), "X"); got != 1 {
t.Errorf("frac=0 must light only the accent tip, got %d bright dots", got)
}
// frac=1: every dot is bright.
if got := strings.Count(renderLogoGlyphs(1, 0, -1), "X"); got != 14 {
t.Errorf("frac=1 must light all 14 dots, got %d", got)
}
// Lighting is monotonic in frac.
prev := 0
for _, frac := range []float64{0, 0.25, 0.5, 0.75, 1} {
n := strings.Count(renderLogoGlyphs(frac, 0, -1), "X")
if n < prev {
t.Errorf("lighting regressed at frac=%.2f: %d < %d", frac, n, prev)
}
prev = n
}
}
func TestLogoMarqueeWindow(t *testing.T) {
// Indeterminate mode lights the accent tip plus a 4-dot window.
if got := strings.Count(renderLogoGlyphs(-1, 0, -1), "X"); got != 5 {
t.Errorf("marquee tick 0 must light tip + 4-dot window, got %d", got)
}
// The window moves with the tick.
if renderLogoGlyphs(-1, 0, -1) == renderLogoGlyphs(-1, 3, -1) {
t.Error("marquee frames must differ across ticks")
}
}
func TestLogoLinesWidthStable(t *testing.T) {
plain := lipgloss.NewStyle()
for _, frac := range []float64{-1, 0, 0.5, 1} {
lines := logoLines(plain, plain, plain, unicodeGlyphs, frac, 2, -1)
for i, ln := range lines {
if w := ansi.StringWidth(ln); w != MeshLogoWidth() {
t.Errorf("frac=%.1f line %d width = %d, want %d (%q)", frac, i, w, MeshLogoWidth(), ln)
}
}
}
}
func TestMeshLogoCompat(t *testing.T) {
t.Setenv("GORTEX_ASCII", "")
t.Setenv("GORTEX_UNICODE", "1")
logo := MeshLogo(0)
lines := strings.Split(logo, "\n")
if len(lines) != MeshLogoLines() {
t.Fatalf("MeshLogo renders %d lines, want %d", len(lines), MeshLogoLines())
}
for i, ln := range lines {
if w := ansi.StringWidth(ln); w != MeshLogoWidth() {
t.Errorf("line %d width = %d, want %d", i, w, MeshLogoWidth())
}
}
// Static banner logo is fully lit: all 14 dots present.
if got := strings.Count(ansi.Strip(logo), "●"); got != 14 {
t.Errorf("static mark must show all 14 dots, got %d", got)
}
}
+75
View File
@@ -0,0 +1,75 @@
package progress
import (
"io"
"os"
"sync/atomic"
"golang.org/x/term"
)
// IsTTY reports whether w is backed by a terminal file descriptor.
func IsTTY(w io.Writer) bool {
f, ok := w.(interface{ Fd() uintptr })
if !ok {
return false
}
return term.IsTerminal(int(f.Fd()))
}
func envDisablesColor() bool {
if os.Getenv("NO_COLOR") != "" {
return true
}
if t := os.Getenv("TERM"); t == "dumb" {
return true
}
return false
}
// animationAllowed decides whether w can host the animated renderer. The
// contract matches the --no-progress help text: NO_COLOR, TERM=dumb, a
// non-TTY writer, and CI all force plain output. On Windows it additionally
// requires the console to accept VT processing (legacy conhost without VT
// gets plain text rather than raw escape soup). GORTEX_FORCE_ANIMATION=1
// overrides everything — it exists for demos and PTY-less frame capture.
func animationAllowed(w io.Writer) bool {
if envFlag("GORTEX_FORCE_ANIMATION") {
return true
}
if !IsTTY(w) {
return false
}
if envDisablesColor() {
return false
}
if os.Getenv("CI") != "" {
return false
}
return enableVT(w)
}
// cursorHiddenOnStderr tracks whether an animated tracker writing to the
// process stderr currently has the cursor hidden. RestoreTerminal uses it to
// undo the hide from exit paths that bypass a tracker's own finish (fatal
// errors, panics unwinding main).
var cursorHiddenOnStderr atomic.Bool
// RestoreTerminal re-shows the cursor and resets SGR state on stderr if an
// animated tracker left the cursor hidden. Safe to call unconditionally and
// repeatedly; a no-op when nothing needs restoring. Wire it into every
// process exit path that can interrupt a live animation.
func RestoreTerminal() {
if cursorHiddenOnStderr.CompareAndSwap(true, false) {
_, _ = os.Stderr.WriteString(ansiShowCursor + ansiReset)
}
}
func markCursorHidden(w io.Writer) { setCursorFlag(w, true) }
func markCursorRestored(w io.Writer) { setCursorFlag(w, false) }
func setCursorFlag(w io.Writer, hidden bool) {
if f, ok := w.(*os.File); ok && f == os.Stderr {
cursorHiddenOnStderr.Store(hidden)
}
}
+44
View File
@@ -0,0 +1,44 @@
package progress
import (
"bytes"
"testing"
)
func TestEnvDisablesColor(t *testing.T) {
t.Setenv("NO_COLOR", "")
t.Setenv("TERM", "xterm-256color")
if envDisablesColor() {
t.Error("plain xterm env must not disable color")
}
t.Setenv("NO_COLOR", "1")
if !envDisablesColor() {
t.Error("NO_COLOR must disable color")
}
t.Setenv("NO_COLOR", "")
t.Setenv("TERM", "dumb")
if !envDisablesColor() {
t.Error("TERM=dumb must disable color")
}
}
func TestAnimationAllowedGates(t *testing.T) {
var buf bytes.Buffer
t.Setenv("GORTEX_FORCE_ANIMATION", "")
if animationAllowed(&buf) {
t.Error("a non-TTY writer must not animate")
}
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
if !animationAllowed(&buf) {
t.Error("GORTEX_FORCE_ANIMATION must override TTY detection")
}
}
func TestRestoreTerminalIdempotent(t *testing.T) {
cursorHiddenOnStderr.Store(true)
RestoreTerminal()
if cursorHiddenOnStderr.Load() {
t.Error("RestoreTerminal must clear the hidden-cursor flag")
}
RestoreTerminal() // second call is a no-op
}
+46
View File
@@ -0,0 +1,46 @@
// Package progress provides a small reporter interface for long-running
// operations (indexing, embedding, enrichment) to emit incremental updates.
// The MCP server uses this to convert indexer stage events into
// `notifications/progress` messages when the client supplies a progressToken.
package progress
import "context"
// Reporter receives progress updates. Implementations must be safe for
// concurrent use and must never panic — callers emit updates from hot paths.
type Reporter interface {
// Report a progress tick. stage is a short human-readable label.
// current and total are within-stage counters; total may be 0 when
// the total work is unknown (e.g., before a walk completes).
Report(stage string, current, total int)
}
// Nop is a Reporter that discards all updates. Returned by FromContext when
// no reporter is attached, so callers can unconditionally invoke Report.
type Nop struct{}
// Report does nothing.
func (Nop) Report(string, int, int) {}
type ctxKey struct{}
// WithReporter returns a context carrying the given reporter. Passing a nil
// reporter returns ctx unchanged so FromContext still yields Nop.
func WithReporter(ctx context.Context, r Reporter) context.Context {
if r == nil {
return ctx
}
return context.WithValue(ctx, ctxKey{}, r)
}
// FromContext returns the reporter in ctx, or Nop if none is attached.
// Never returns nil — callers may call Report directly on the result.
func FromContext(ctx context.Context) Reporter {
if ctx == nil {
return Nop{}
}
if r, ok := ctx.Value(ctxKey{}).(Reporter); ok && r != nil {
return r
}
return Nop{}
}
+66
View File
@@ -0,0 +1,66 @@
package progress
import (
"context"
"sync"
"testing"
)
func TestNop_NoPanic(t *testing.T) {
Nop{}.Report("anything", 0, 0)
Nop{}.Report("stage", 1, 100)
}
func TestFromContext_NilCtx_ReturnsNop(t *testing.T) {
r := FromContext(context.TODO())
if r == nil {
t.Fatal("FromContext must never return nil")
}
r.Report("x", 0, 0) // must not panic
}
func TestFromContext_NoReporter_ReturnsNop(t *testing.T) {
r := FromContext(context.Background())
if _, ok := r.(Nop); !ok {
t.Errorf("expected Nop when no reporter attached, got %T", r)
}
}
func TestWithReporter_Roundtrip(t *testing.T) {
rec := &recorder{}
ctx := WithReporter(context.Background(), rec)
FromContext(ctx).Report("parse", 3, 10)
if len(rec.events) != 1 {
t.Fatalf("expected 1 event, got %d", len(rec.events))
}
ev := rec.events[0]
if ev.stage != "parse" || ev.current != 3 || ev.total != 10 {
t.Errorf("unexpected event: %+v", ev)
}
}
func TestWithReporter_NilReporter_NoOp(t *testing.T) {
ctx := WithReporter(context.Background(), nil)
r := FromContext(ctx)
if _, ok := r.(Nop); !ok {
t.Errorf("nil reporter must not leak through context, got %T", r)
}
}
type recEvent struct {
stage string
current, total int
}
type recorder struct {
mu sync.Mutex
events []recEvent
}
func (r *recorder) Report(stage string, current, total int) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, recEvent{stage, current, total})
}
+110
View File
@@ -0,0 +1,110 @@
package progress
import (
"context"
"io"
)
// Spinner is the single-operation face of the Tracker, kept for the many
// call sites that want "animate this one label, then ✓ or ✗". It is a thin
// facade: on a capable terminal the Tracker renders a live compact header
// (spinner, title, status, elapsed) and turns Reporter stage transitions
// into a checklist of step rows; elsewhere it degrades to clean line output.
// Implements Reporter so it can be installed via WithReporter.
type Spinner struct {
t *Tracker
}
// NewSpinner constructs a Spinner bound to w. When w isn't a TTY (or NO_COLOR
// / TERM=dumb / CI is set), the spinner is created in plain-text mode.
func NewSpinner(w io.Writer) *Spinner {
return &Spinner{t: NewTracker(w)}
}
// Disable forces the spinner into plain-text mode. Effective before Start.
func (s *Spinner) Disable() { s.t.disable() }
// Enabled reports whether the spinner is animating.
func (s *Spinner) Enabled() bool { return s.t.Animated() }
// Start begins animating with the given label.
func (s *Spinner) Start(label string) { s.t.Start(label) }
// Set updates the label and sub-status mid-animation. Either may be empty to
// leave the existing value.
func (s *Spinner) Set(label, sub string) {
s.t.SetTitle(label)
s.t.SetStatus(sub)
}
// Report implements Reporter. Each distinct stage becomes its own live step
// row (with counters and, when the total is known, a progress bar); in plain
// mode stages print as start / finish line pairs with a slow heartbeat.
func (s *Spinner) Report(stage string, current, total int) {
s.t.Report(stage, current, total)
}
// Done stops the spinner and replaces the frame with a green ✓ summary.
func (s *Spinner) Done() { s.t.Done("", "") }
// Fail stops the spinner and replaces the frame with a red ✗ summary.
func (s *Spinner) Fail(err error) { s.t.Fail(err) }
// Tracker exposes the underlying tracker for call sites that outgrow the
// single-label surface (explicit steps, log lines above the animation).
func (s *Spinner) Tracker() *Tracker { return s.t }
// Multi fans out reporter ticks to all of rs. Nil entries are skipped.
func Multi(rs ...Reporter) Reporter {
out := make([]Reporter, 0, len(rs))
for _, r := range rs {
if r == nil {
continue
}
out = append(out, r)
}
switch len(out) {
case 0:
return Nop{}
case 1:
return out[0]
default:
return multiReporter(out)
}
}
type multiReporter []Reporter
func (m multiReporter) Report(stage string, current, total int) {
for _, r := range m {
r.Report(stage, current, total)
}
}
// Run animates a spinner around fn. The context passed to fn carries the
// spinner as a Reporter, so any progress.FromContext(ctx).Report(…) inside fn
// drives the live step rows. The spinner is finished (✓ or ✗) before Run
// returns.
func Run(ctx context.Context, w io.Writer, label string, fn func(context.Context) error) error {
sp := NewSpinner(w)
return runWith(ctx, sp, label, fn)
}
// RunDisabled is Run with the spinner forced into plain-text mode.
func RunDisabled(ctx context.Context, w io.Writer, label string, fn func(context.Context) error) error {
sp := NewSpinner(w)
sp.Disable()
return runWith(ctx, sp, label, fn)
}
func runWith(ctx context.Context, sp *Spinner, label string, fn func(context.Context) error) error {
sp.Start(label)
ctx = WithReporter(ctx, sp)
err := fn(ctx)
if err != nil {
sp.Fail(err)
} else {
sp.Done()
}
return err
}
+197
View File
@@ -0,0 +1,197 @@
package progress
import (
"bytes"
"errors"
"strings"
"testing"
)
func TestSpinnerDisabledModePrintsPlainText(t *testing.T) {
var buf bytes.Buffer
sp := NewSpinner(&buf)
sp.Disable()
sp.Start("Indexing repository")
sp.Report("walking files", 0, 0)
sp.Report("walking files", 50, 100) // same stage, must not re-emit
sp.Report("parsing", 0, 0) // new stage: finishes the previous, starts its own
sp.Done()
out := buf.String()
wants := []string{
"Indexing repository",
"walking files ...", // stage start
"✓ walking files · 50", // stage finish with final counter
"parsing ...", // next stage start
"✓ parsing", // finished by Done
"✓ Indexing repository", // overall summary
}
for _, w := range wants {
if !strings.Contains(out, w) {
t.Errorf("output missing %q\n--- got ---\n%s", w, out)
}
}
// Each stage prints exactly twice — one start line, one finish line —
// regardless of how many progress ticks arrive in between.
if got := strings.Count(out, "walking files"); got != 2 {
t.Errorf("expected stage 'walking files' to print exactly twice (start+finish), got %d\n--- got ---\n%s", got, out)
}
if idx := strings.Index(out, "parsing ..."); idx < strings.Index(out, "✓ walking files") {
t.Errorf("stage finish line must precede the next stage start\n--- got ---\n%s", out)
}
}
func TestSpinnerDisabledFailPrintsErrorLine(t *testing.T) {
var buf bytes.Buffer
sp := NewSpinner(&buf)
sp.Disable()
sp.Start("Stamping blame")
sp.Fail(errors.New("boom"))
out := buf.String()
if !strings.Contains(out, "✗ Stamping blame") {
t.Errorf("expected ✗ summary, got: %q", out)
}
if !strings.Contains(out, "boom") {
t.Errorf("expected error message in output, got: %q", out)
}
}
func TestSpinnerDoneIsIdempotent(t *testing.T) {
var buf bytes.Buffer
sp := NewSpinner(&buf)
sp.Disable()
sp.Start("Indexing")
sp.Done()
sp.Done() // must not double-print or panic
sp.Fail(errors.New("late")) // must be a no-op after Done
if got := strings.Count(buf.String(), "✓"); got != 1 {
t.Errorf("expected exactly one ✓, got %d in:\n%s", got, buf.String())
}
if strings.Contains(buf.String(), "✗") {
t.Errorf("expected no ✗ after successful Done, got: %s", buf.String())
}
}
func TestMultiFansOutAndSkipsNil(t *testing.T) {
a := &countingReporter{}
b := &countingReporter{}
r := Multi(a, nil, b)
r.Report("walk", 1, 10)
r.Report("parse", 0, 0)
if a.calls != 2 || b.calls != 2 {
t.Errorf("expected 2 calls each, got a=%d b=%d", a.calls, b.calls)
}
}
func TestMultiCollapsesToSingle(t *testing.T) {
a := &countingReporter{}
r := Multi(nil, a, nil)
if r != a {
t.Errorf("expected Multi with one non-nil to return that reporter directly")
}
}
func TestMultiAllNilReturnsNop(t *testing.T) {
r := Multi(nil, nil)
if _, ok := r.(Nop); !ok {
t.Errorf("expected Nop when all inputs nil, got %T", r)
}
}
type countingReporter struct{ calls int }
func (c *countingReporter) Report(string, int, int) { c.calls++ }
// TestASCIIGlyphFallbackOnOEMCodepage proves the F8 contract: a terminal that
// cannot render UTF-8 (a legacy OEM codepage, a linux virtual console, or an
// explicit GORTEX_ASCII) gets an ASCII glyph set for the spinner finish
// markers AND the box-drawing border — not just the check/cross glyphs.
func TestASCIIGlyphFallbackOnOEMCodepage(t *testing.T) {
t.Run("ascii_override_selects_ascii_set", func(t *testing.T) {
t.Setenv("GORTEX_UNICODE", "")
t.Setenv("GORTEX_ASCII", "1")
if supportsUnicode() {
t.Fatal("GORTEX_ASCII=1 must disable unicode")
}
g := activeGlyphs()
if g.OK != "+" || g.Fail != "x" {
t.Errorf("ascii markers = %q/%q, want +/x", g.OK, g.Fail)
}
// The border charset is ASCII too — the beat-codegraph axis.
if g.Border.TopLeft != "+" {
t.Errorf("ascii border TopLeft = %q, want +", g.Border.TopLeft)
}
})
t.Run("unicode_override_wins_over_term_linux", func(t *testing.T) {
t.Setenv("GORTEX_ASCII", "")
t.Setenv("TERM", "linux")
t.Setenv("GORTEX_UNICODE", "1")
if !supportsUnicode() {
t.Fatal("GORTEX_UNICODE=1 must enable unicode even on TERM=linux")
}
if activeGlyphs().OK != "✓" {
t.Error("unicode set must use ✓")
}
})
t.Run("term_linux_falls_back_to_ascii", func(t *testing.T) {
t.Setenv("GORTEX_ASCII", "")
t.Setenv("GORTEX_UNICODE", "")
t.Setenv("TERM", "linux")
if supportsUnicode() {
t.Fatal("TERM=linux must fall back to ASCII")
}
})
t.Run("spinner_done_uses_ascii_marker", func(t *testing.T) {
t.Setenv("GORTEX_UNICODE", "")
t.Setenv("GORTEX_ASCII", "1")
var buf bytes.Buffer
sp := NewSpinner(&buf) // not a TTY → disabled → direct Fprintf path
sp.Start("Indexing")
sp.Done()
out := buf.String()
if !strings.Contains(out, "+ Indexing") {
t.Errorf("done line should use the ASCII marker; got %q", out)
}
if strings.Contains(out, "✓") {
t.Errorf("done line must not contain the unicode check; got %q", out)
}
})
t.Run("spinner_fail_uses_ascii_cross", func(t *testing.T) {
t.Setenv("GORTEX_UNICODE", "")
t.Setenv("GORTEX_ASCII", "1")
var buf bytes.Buffer
sp := NewSpinner(&buf)
sp.Start("Indexing")
sp.Fail(errors.New("boom"))
out := buf.String()
if !strings.Contains(out, "x Indexing") {
t.Errorf("fail line should use the ASCII cross; got %q", out)
}
if strings.Contains(out, "✗") {
t.Errorf("fail line must not contain the unicode cross; got %q", out)
}
})
t.Run("card_border_is_ascii", func(t *testing.T) {
t.Setenv("GORTEX_UNICODE", "")
t.Setenv("GORTEX_ASCII", "1")
card := Card("", "hello")
if !strings.Contains(card, "+") {
t.Errorf("ascii card must use + corners; got %q", card)
}
if strings.ContainsAny(card, "╭╮╰╯─│") {
t.Errorf("ascii card must not use rounded box-drawing; got %q", card)
}
})
}
+75
View File
@@ -0,0 +1,75 @@
package progress
import (
"fmt"
"io"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/termenv"
"golang.org/x/term"
)
// ANSI control sequences used by the live renderer. Private-mode toggles that
// a terminal doesn't implement (the ?2026 synchronized-update pair) are
// ignored by spec, so they are safe to emit unconditionally.
const (
ansiHideCursor = "\x1b[?25l"
ansiShowCursor = "\x1b[?25h"
ansiClearEOL = "\x1b[K"
ansiClearBelow = "\x1b[J"
ansiSyncStart = "\x1b[?2026h"
ansiSyncEnd = "\x1b[?2026l"
ansiReset = "\x1b[0m"
)
// ansiUp moves the cursor n lines up (column unchanged). n <= 0 is a no-op.
func ansiUp(n int) string {
if n <= 0 {
return ""
}
return fmt.Sprintf("\x1b[%dA", n)
}
// colorProfileFor resolves the color depth for the animated renderer bound to
// w. termenv's per-platform detection covers COLORTERM / TERM on unix and the
// console-API probes on Windows; a writer that isn't a real terminal (frame
// capture under GORTEX_FORCE_ANIMATION) degrades to plain-byte styles unless
// the caller overrides the profile explicitly.
func colorProfileFor(w io.Writer) termenv.Profile {
return termenv.NewOutput(w).ColorProfile()
}
// termSize returns the terminal dimensions behind w, falling back to a
// conservative 80×24 when w has no descriptor or the ioctl fails (captures,
// tests). Queried per frame so a live resize re-clamps the next repaint
// without any platform-specific resize signal.
func termSize(w io.Writer) (width, height int) {
width, height = 80, 24
f, ok := w.(interface{ Fd() uintptr })
if !ok {
return width, height
}
cw, ch, err := term.GetSize(int(f.Fd()))
if err != nil || cw <= 0 || ch <= 0 {
return width, height
}
return cw, ch
}
// visibleWidth measures the printable cell width of a styled line,
// ANSI-aware.
func visibleWidth(s string) int { return ansi.StringWidth(s) }
// clampLine hard-truncates a styled line to width cells, ANSI-aware, so a
// frame line can never soft-wrap. Soft wrap is fatal to a repaint renderer:
// one wrapped line desyncs the cursor-up arithmetic and every later frame
// smears. The ellipsis tail marks the cut.
func clampLine(s string, width int, ellipsis string) string {
if width <= 0 {
return ""
}
if ansi.StringWidth(s) <= width {
return s
}
return ansi.Truncate(s, width, ellipsis)
}
+71
View File
@@ -0,0 +1,71 @@
package progress
import "github.com/charmbracelet/lipgloss"
// Cozy palette — gortex mark. One source of truth for brand color across the
// binary; the tracker binds these to its own writer-scoped renderer, while
// the static helpers below use the package-global styles.
var (
colPerim = lipgloss.Color("#F0F0F0")
colInner = lipgloss.Color("#46464E")
colAccent = lipgloss.Color("#5FD67A")
colAccentDim = lipgloss.Color("#4FB268")
colFg = lipgloss.Color("#F4F3EF")
colFgDim = lipgloss.Color("#969699")
colErr = lipgloss.Color("#F06E64")
stylePerim = lipgloss.NewStyle().Foreground(colPerim)
styleInner = lipgloss.NewStyle().Foreground(colInner)
styleAccent = lipgloss.NewStyle().Foreground(colAccent)
styleLabel = lipgloss.NewStyle().Bold(true).Foreground(colFg)
styleSub = lipgloss.NewStyle().Foreground(colFgDim)
styleOK = lipgloss.NewStyle().Foreground(colAccent)
styleX = lipgloss.NewStyle().Foreground(colErr)
)
// Exported palette — kept as a thin re-export layer over the package-private
// `col*` constants so the rest of the binary (cmd/gortex, internal/tui) can
// share one source of truth for brand colour without each callsite hard-coding
// hex literals.
//
// Anything inside internal/progress can keep using the short private names;
// callers outside the package should reach for these exported variants.
var (
ColorAccent = colAccent
ColorPerim = colPerim
ColorInner = colInner
ColorFg = colFg
ColorFgDim = colFgDim
ColorErr = colErr
ColorWarn = colWarn
ColorMuted = colMuted
ColorBorder = colBorder
ColorInfoSoft = colInfoSoft
)
// Exported styles. Lipgloss styles are immutable on chaining (every setter
// returns a new copy) so exposing the package-level globals is safe — callers
// cannot mutate them in place.
var (
StyleAccent = styleAccent
StyleLabel = styleLabel
StyleSub = styleSub
StyleOK = styleOK
StyleErr = styleX
StyleHeading = styleHeading
StyleCount = styleCount
StyleKey = styleKey
StyleVal = styleVal
StyleHint = styleHint
StyleStep = styleStep
StyleStrong = styleStrong
StyleBox = styleBox
)
// PaletteFg / PaletteAccent / PaletteErr expose the resolved lipgloss colors
// for callers that need to apply them to a freshly-built style (rather than
// re-using one of the pre-composed styles above). Returned values are
// lipgloss.Color, ready to feed into any lipgloss.NewStyle().Foreground call.
func PaletteFg() lipgloss.Color { return colFg }
func PaletteAccent() lipgloss.Color { return colAccent }
func PaletteErr() lipgloss.Color { return colErr }
+102
View File
@@ -0,0 +1,102 @@
package progress
import (
"fmt"
"io"
"sync"
"time"
)
// TimingReporter records the first-seen timestamp of each stage and,
// when printed, emits a per-stage duration breakdown. Shows where
// wall-clock time is being spent during a full index pass.
//
// Stage transitions are detected by the reporter seeing a *new* stage
// label arrive — subsequent ticks for the same stage (progress updates
// like "parsing 3000/5000") don't create a new entry. This matches the
// indexer's usage where it calls Report once at stage entry and then
// again with counter updates.
type TimingReporter struct {
mu sync.Mutex
start time.Time
stages []stageEntry
seen map[string]int // stage → index in stages (also suppresses duplicates)
}
type stageEntry struct {
name string
seen time.Time
ticks int
}
// NewTimingReporter returns a reporter with its clock anchored at now.
func NewTimingReporter() *TimingReporter {
return &TimingReporter{
start: time.Now(),
seen: make(map[string]int),
}
}
// Report records a stage tick. The first tick for a given stage name
// is treated as the stage's start timestamp.
func (r *TimingReporter) Report(stage string, _, _ int) {
if stage == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if idx, ok := r.seen[stage]; ok {
r.stages[idx].ticks++
return
}
r.seen[stage] = len(r.stages)
r.stages = append(r.stages, stageEntry{name: stage, seen: time.Now(), ticks: 1})
}
// WriteReport prints a two-column breakdown: per-stage duration and
// cumulative time since the reporter was created. end defaults to
// time.Now() when zero; pass an explicit value when the caller
// finishes before a final "indexing complete" stage tick is emitted.
func (r *TimingReporter) WriteReport(w io.Writer, end time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
if end.IsZero() {
end = time.Now()
}
if len(r.stages) == 0 {
fmt.Fprintln(w, "no stages recorded")
return
}
fmt.Fprintf(w, "%-32s %12s %12s\n", "stage", "duration", "cumulative")
fmt.Fprintf(w, "%-32s %12s %12s\n",
"--------------------------------", "------------", "------------")
for i, s := range r.stages {
var nextStart time.Time
if i+1 < len(r.stages) {
nextStart = r.stages[i+1].seen
} else {
nextStart = end
}
duration := nextStart.Sub(s.seen)
cumulative := nextStart.Sub(r.start)
fmt.Fprintf(w, "%-32s %12s %12s\n",
s.name, formatMs(duration), formatMs(cumulative))
}
}
// formatMs formats a duration as "123ms" / "4.56s" / "1m23s" depending
// on magnitude. Small-enough to be readable in a CLI dump.
func formatMs(d time.Duration) string {
switch {
case d < time.Second:
return fmt.Sprintf("%dms", d.Milliseconds())
case d < time.Minute:
return fmt.Sprintf("%.2fs", d.Seconds())
default:
m := int(d / time.Minute)
s := int((d % time.Minute) / time.Second)
return fmt.Sprintf("%dm%02ds", m, s)
}
}
+922
View File
@@ -0,0 +1,922 @@
package progress
import (
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/charmbracelet/lipgloss"
)
// Tracker is the CLI's live progress surface: a flicker-free animated region
// on a capable terminal, and clean line-oriented text everywhere else (pipes,
// CI, NO_COLOR, TERM=dumb, --no-progress, a Windows console without VT).
//
// The animated region is composed of an optional brand header (the gortex
// mark lighting up with overall progress plus a title and a live status
// line) followed by one row per step — pending, active (spinner, counters,
// progress bar), done (check, final count, duration), failed, or skipped.
// The whole region repaints at a fixed cadence inside terminal synchronized-
// update brackets with every line hard-clamped to the terminal width, so the
// frame arithmetic can never desync — the classic source of spinner ghosting.
//
// A Tracker is safe for concurrent use. Hot loops may call Report thousands
// of times per second: updates only mutate state under the lock, and the
// render goroutine samples that state at the frame rate.
type Tracker struct {
mu sync.Mutex
w io.Writer
st styleSet
animated bool
logo bool
fps time.Duration
title string
status string
steps []*Step
hasExplicit bool
started bool
finished bool
failed bool
finErr error
finHead string
finSummary string
startAt time.Time
stopAt time.Time
tick int
lastLines int
flushQueue []string
stopCh chan struct{}
loopDone chan struct{}
// Test seams.
sizeFn func() (int, int)
now func() time.Time
}
// stepStatus is the lifecycle of a single tracked step.
type stepStatus int
const (
stepPending stepStatus = iota
stepActive
stepDone
stepFailed
stepSkipped
)
// Step is one row of the tracker: a phase of the overall run. Handles are
// returned by AddStep / StartStep and stay valid for the tracker's lifetime.
type Step struct {
t *Tracker
label string
doneLabel string
status stepStatus
cur int64
total int64
unit string
note string
err error
skipNote string
auto bool
started time.Time
stopped time.Time
lastBeat time.Time
}
// TrackerOption customizes a Tracker at construction.
type TrackerOption func(*Tracker)
// WithLogo selects the banner preset: the animated region opens with the
// gortex mark, the title, and a live status line. Flagship multi-phase flows
// (init, install) use it; short single-op commands keep the compact preset.
func WithLogo() TrackerOption {
return func(t *Tracker) { t.logo = true }
}
// WithoutAnimation forces plain line output regardless of terminal
// capabilities — the hook for the global --no-progress flag.
func WithoutAnimation() TrackerOption {
return func(t *Tracker) { t.animated = false }
}
// NewTracker builds a tracker bound to w. Animation is auto-detected from the
// writer and environment (see animationAllowed); pass WithoutAnimation to
// force plain output.
func NewTracker(w io.Writer, opts ...TrackerOption) *Tracker {
t := &Tracker{
w: w,
fps: 80 * time.Millisecond,
now: time.Now,
}
t.sizeFn = func() (int, int) { return termSize(w) }
t.animated = animationAllowed(w)
for _, o := range opts {
o(t)
}
t.st = newStyleSet(w)
return t
}
// Animated reports whether the tracker renders the live animated region.
func (t *Tracker) Animated() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.animated
}
// disable forces plain mode; effective only before Start.
func (t *Tracker) disable() {
t.mu.Lock()
defer t.mu.Unlock()
if !t.started {
t.animated = false
}
}
// Start opens the surface with the given title and, on animated terminals,
// begins the render loop. Idempotent.
func (t *Tracker) Start(title string) {
t.mu.Lock()
defer t.mu.Unlock()
if t.started {
return
}
t.started = true
t.title = title
t.startAt = t.now()
if !t.animated {
if title != "" {
fmt.Fprintf(t.w, " %s\n", title)
}
return
}
t.stopCh = make(chan struct{})
t.loopDone = make(chan struct{})
_, _ = io.WriteString(t.w, ansiHideCursor)
markCursorHidden(t.w)
t.paintLocked(false)
go t.loop()
}
// SetTitle replaces the header title mid-run.
func (t *Tracker) SetTitle(title string) {
if title == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.title = title
}
// SetStatus replaces the live status line (banner preset) or the inline
// detail after the title (compact preset). Plain mode records it silently —
// it surfaces on the finish line.
func (t *Tracker) SetStatus(status string) {
t.mu.Lock()
defer t.mu.Unlock()
t.status = status
}
// AddStep appends a pending step row — the design's "planned but not yet
// running" state. Planned steps also make overall progress computable, which
// drives the logo fill.
func (t *Tracker) AddStep(label string) *Step {
t.mu.Lock()
defer t.mu.Unlock()
t.hasExplicit = true
return t.newStepLocked(label, false)
}
// StartStep activates the pending step with the given label, creating it
// first when it was never planned. Returns the step handle.
func (t *Tracker) StartStep(label string) *Step {
t.mu.Lock()
defer t.mu.Unlock()
t.hasExplicit = true
for _, s := range t.steps {
if s.status == stepPending && s.label == label {
t.activateStepLocked(s)
return s
}
}
s := t.newStepLocked(label, false)
t.activateStepLocked(s)
return s
}
func (t *Tracker) newStepLocked(label string, auto bool) *Step {
s := &Step{t: t, label: label, auto: auto}
t.steps = append(t.steps, s)
return s
}
func (t *Tracker) activateStepLocked(s *Step) {
if s.status != stepPending {
return
}
s.status = stepActive
s.started = t.now()
s.lastBeat = s.started
if !t.animated && !t.finished {
fmt.Fprintf(t.w, " %s ...\n", s.label)
}
}
// lastActiveLocked returns the most recently activated still-active step.
func (t *Tracker) lastActiveLocked() *Step {
for i := len(t.steps) - 1; i >= 0; i-- {
if t.steps[i].status == stepActive {
return t.steps[i]
}
}
return nil
}
// Progress updates the step's counters. total may be 0 when unknown.
func (s *Step) Progress(cur, total int64) {
t := s.t
t.mu.Lock()
defer t.mu.Unlock()
s.cur, s.total = cur, total
s.plainBeatLocked()
}
// SetUnit sets the dim unit suffix rendered after the counter ("files",
// "symbols", "edges").
func (s *Step) SetUnit(unit string) {
s.t.mu.Lock()
defer s.t.mu.Unlock()
s.unit = unit
}
// Note sets a dim annotation rendered after the counters — a sub-stage name,
// the language mix, the current file.
func (s *Step) Note(note string) {
s.t.mu.Lock()
defer s.t.mu.Unlock()
s.note = note
}
// Done completes the step, keeping its running label.
func (s *Step) Done() { s.DoneAs("") }
// DoneAs completes the step and swaps the label for its past-tense form
// ("parsing sources" → "symbols extracted"), matching the design language.
func (s *Step) DoneAs(doneLabel string) {
t := s.t
t.mu.Lock()
defer t.mu.Unlock()
t.completeStepLocked(s, doneLabel, nil)
}
// Skip marks a planned step as intentionally not run.
func (s *Step) Skip(reason string) {
t := s.t
t.mu.Lock()
defer t.mu.Unlock()
if s.status == stepDone || s.status == stepFailed || s.status == stepSkipped {
return
}
s.status = stepSkipped
s.skipNote = reason
s.stopped = t.now()
if !t.animated && !t.finished {
note := ""
if reason != "" {
note = ": " + reason
}
fmt.Fprintf(t.w, " %s %s %s skipped%s\n", t.st.g.Pending, s.label, t.st.g.Dash, note)
}
}
// Fail marks the step failed with err.
func (s *Step) Fail(err error) {
t := s.t
t.mu.Lock()
defer t.mu.Unlock()
t.completeStepLocked(s, "", err)
}
func (t *Tracker) completeStepLocked(s *Step, doneLabel string, err error) {
if s.status == stepDone || s.status == stepFailed || s.status == stepSkipped {
return
}
if s.status == stepPending {
s.started = t.now()
}
s.stopped = t.now()
if err != nil {
s.status = stepFailed
s.err = err
if !t.animated && !t.finished {
fmt.Fprintf(t.w, " %s %s: %v\n", t.st.g.Fail, s.label, err)
}
return
}
s.status = stepDone
if doneLabel != "" {
s.doneLabel = doneLabel
}
if !t.animated && !t.finished {
fmt.Fprintf(t.w, " %s %s%s%s\n",
t.st.g.OK, s.finalLabel(), t.plainCounts(s), t.plainDuration(s.stopped.Sub(s.started)))
}
}
func (s *Step) finalLabel() string {
if s.doneLabel != "" {
return s.doneLabel
}
return s.label
}
// plainBeatLocked emits a throttled heartbeat line for a long-running step in
// plain mode, so CI logs show liveness (and where a run hangs) without being
// flooded by per-item ticks.
const plainHeartbeatEvery = 10 * time.Second
func (s *Step) plainBeatLocked() {
t := s.t
if t.animated || t.finished || s.status != stepActive {
return
}
now := t.now()
if now.Sub(s.lastBeat) < plainHeartbeatEvery {
return
}
s.lastBeat = now
fmt.Fprintf(t.w, " %s%s (%s)\n", s.label, t.plainCounts(s), fmtDurationCompact(now.Sub(s.started)))
}
// Report implements Reporter. Without explicit steps, every distinct stage
// label materializes as its own step row — the previous stage completes with
// its final counters and duration, the new one starts spinning. With explicit
// steps (an orchestrator owns the checklist), stage ticks feed the active
// step's note and counters instead.
func (t *Tracker) Report(stage string, current, total int) {
if stage == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.finished {
return
}
if t.hasExplicit {
if s := t.lastActiveLocked(); s != nil {
s.note = stage
s.cur, s.total = int64(current), int64(total)
s.plainBeatLocked()
return
}
t.status = stage
return
}
if s := t.lastActiveLocked(); s != nil && s.auto {
if s.label == stage {
s.cur, s.total = int64(current), int64(total)
s.plainBeatLocked()
return
}
t.completeStepLocked(s, "", nil)
}
s := t.newStepLocked(stage, true)
t.activateStepLocked(s)
s.cur, s.total = int64(current), int64(total)
}
// Println writes a permanent line above the live region (or straight through
// in plain mode). Use it for warnings and notes that must survive the
// animation instead of writing to the tracker's writer directly.
func (t *Tracker) Println(a ...any) {
t.Logf("%s", strings.TrimRight(fmt.Sprintln(a...), "\n"))
}
// Logf is Println with formatting.
func (t *Tracker) Logf(format string, a ...any) {
msg := fmt.Sprintf(format, a...)
t.mu.Lock()
defer t.mu.Unlock()
if !t.animated || !t.started || t.finished {
fmt.Fprintln(t.w, msg)
return
}
t.flushQueue = append(t.flushQueue, strings.Split(msg, "\n")...)
t.paintLocked(false)
}
// Done finishes the run successfully. headline defaults to the title
// (compact) or "ready" (banner); summary, when non-empty, is rendered as the
// closing line ("indexed 2,431 files · 48,210 symbols"). Idempotent, and a
// later Fail is ignored.
func (t *Tracker) Done(headline, summary string) { t.finish(headline, summary, nil) }
// Fail finishes the run with an error: the active step (if any) is marked
// failed and the header switches to the failure state. Idempotent.
func (t *Tracker) Fail(err error) { t.finish("", "", err) }
func (t *Tracker) finish(headline, summary string, err error) {
t.mu.Lock()
if t.finished || !t.started {
t.mu.Unlock()
return
}
t.finished = true
t.failed = err != nil
t.finErr = err
t.finHead = headline
t.finSummary = summary
t.stopAt = t.now()
// Close out the still-running step so the final frame has no orphan
// spinner rows: success completes it, failure pins the error on it.
if s := t.lastActiveLocked(); s != nil {
wasAnimated := t.animated
if err != nil {
// Attribute the error to the step row; the header carries it too.
s.status = stepFailed
s.err = err
s.stopped = t.stopAt
} else if s.auto {
s.status = stepDone
s.stopped = t.stopAt
if !wasAnimated {
fmt.Fprintf(t.w, " %s %s%s%s\n",
t.st.g.OK, s.finalLabel(), t.plainCounts(s), t.plainDuration(t.stopAt.Sub(s.started)))
}
} else {
s.status = stepDone
s.stopped = t.stopAt
}
}
if !t.animated {
t.plainFinishLocked()
t.mu.Unlock()
return
}
t.paintLocked(true)
markCursorRestored(t.w)
stop := t.stopCh
done := t.loopDone
t.mu.Unlock()
if stop != nil {
close(stop)
<-done
}
}
func (t *Tracker) plainFinishLocked() {
elapsed := t.plainDuration(t.stopAt.Sub(t.startAt))
if t.failed {
if t.finErr != nil {
fmt.Fprintf(t.w, " %s %s: %v\n", t.st.g.Fail, t.title, t.finErr)
} else {
fmt.Fprintf(t.w, " %s %s\n", t.st.g.Fail, t.title)
}
return
}
head := t.finHead
if head == "" {
head = t.title
}
summary := t.finSummary
if summary == "" {
summary = t.status
}
line := " " + t.st.g.OK + " " + head
if summary != "" {
line += " " + t.st.g.Dash + " " + summary
}
fmt.Fprintln(t.w, line+elapsed)
}
// plainDuration renders " (dur)" for durations worth mentioning; sub-100ms
// operations stay clean (and unit-test output deterministic).
func (t *Tracker) plainDuration(d time.Duration) string {
if d < 100*time.Millisecond {
return ""
}
return " (" + fmtDurationCompact(d) + ")"
}
func (t *Tracker) plainCounts(s *Step) string {
if s.cur <= 0 && (s.status != stepActive || s.total <= 0) {
return ""
}
out := " " + t.st.g.Sep + " " + fmtCount(s.cur)
if s.status == stepActive && s.total > 0 {
out += " / " + fmtCount(s.total)
}
if s.unit != "" {
out += " " + s.unit
}
return out
}
// ---- animated rendering ---------------------------------------------------
func (t *Tracker) loop() {
defer close(t.loopDone)
defer func() {
if r := recover(); r != nil {
_, _ = io.WriteString(t.w, ansiShowCursor+ansiReset)
markCursorRestored(t.w)
panic(r)
}
}()
tk := time.NewTicker(t.fps)
defer tk.Stop()
for {
select {
case <-t.stopCh:
return
case <-tk.C:
t.mu.Lock()
if t.started && !t.finished {
t.tick++
t.paintLocked(false)
}
t.mu.Unlock()
}
}
}
// paintLocked repaints the live region. Protocol invariant: after every
// paint the cursor parks at column 0 on the line just below the region, so
// the next paint reaches the region top with a bare CR + cursor-up. Frames
// are wrapped in synchronized-update brackets and every line is width-clamped
// (a soft-wrapped line would corrupt the cursor arithmetic for all later
// frames — the failure mode this renderer exists to eliminate).
func (t *Tracker) paintLocked(final bool) {
width, height := t.sizeFn()
if width < 20 {
width = 20
}
maxRegion := height - 2
if maxRegion < 4 {
maxRegion = 4
}
lines := t.buildLinesLocked(width)
for len(lines) > maxRegion {
if s := t.oldestFinishedStepLocked(); s != nil {
// Retire the oldest finished row to permanent output above the
// region so the live region always fits the terminal.
t.flushQueue = append(t.flushQueue, t.stepRowLocked(s, width))
t.removeStepLocked(s)
lines = t.buildLinesLocked(width)
continue
}
lines = lines[len(lines)-maxRegion:]
break
}
var b strings.Builder
b.WriteString(ansiSyncStart)
b.WriteString("\r")
b.WriteString(ansiUp(t.lastLines))
if len(t.flushQueue) > 0 {
b.WriteString(ansiClearBelow)
for _, fl := range t.flushQueue {
b.WriteString(clampLine(fl, width-1, t.st.g.Ellipsis))
b.WriteString("\r\n")
}
t.flushQueue = nil
t.lastLines = 0
}
for _, ln := range lines {
b.WriteString(clampLine(ln, width-1, t.st.g.Ellipsis))
b.WriteString(ansiClearEOL)
b.WriteString("\r\n")
}
if len(lines) < t.lastLines {
b.WriteString(ansiClearBelow)
}
t.lastLines = len(lines)
if final {
b.WriteString(ansiShowCursor)
b.WriteString(ansiReset)
}
b.WriteString(ansiSyncEnd)
_, _ = io.WriteString(t.w, b.String())
}
func (t *Tracker) oldestFinishedStepLocked() *Step {
for _, s := range t.steps {
if s.status == stepDone || s.status == stepSkipped || s.status == stepFailed {
return s
}
}
return nil
}
func (t *Tracker) removeStepLocked(target *Step) {
for i, s := range t.steps {
if s == target {
t.steps = append(t.steps[:i], t.steps[i+1:]...)
return
}
}
}
func (t *Tracker) buildLinesLocked(width int) []string {
var lines []string
if t.logo {
frac := t.logoFracLocked()
logo := logoLines(t.st.logoLit, t.st.logoDim, t.st.accent, t.st.g, frac, t.tick, -1)
title := ""
if t.title != "" {
title = " " + t.st.title.Render(t.title)
}
lines = append(lines,
" "+logo[0],
" "+logo[1]+title,
" "+logo[2]+" "+t.statusRowLocked(),
" "+logo[3],
" "+logo[4],
)
if len(t.steps) > 0 {
lines = append(lines, "")
}
} else {
lines = append(lines, t.compactHeaderLocked())
}
for _, s := range t.steps {
lines = append(lines, t.stepRowLocked(s, width))
}
if t.logo && t.finished && !t.failed && t.finSummary != "" {
lines = append(lines, "", " "+t.st.accent.Render(t.st.g.OK)+" "+t.st.dim.Render(t.finSummary))
}
if t.logo && t.finished && t.failed && t.finErr != nil && t.lastFailedStepLocked() == nil {
lines = append(lines, "", " "+t.st.err.Render(t.st.g.Fail+" "+t.finErr.Error()))
}
return lines
}
func (t *Tracker) lastFailedStepLocked() *Step {
for i := len(t.steps) - 1; i >= 0; i-- {
if t.steps[i].status == stepFailed {
return t.steps[i]
}
}
return nil
}
// logoFracLocked derives the logo fill fraction. Planned checklists light the
// mark progressively (done steps plus the active step's own fraction);
// unplanned auto-step runs use the indeterminate marquee until they finish.
func (t *Tracker) logoFracLocked() float64 {
if t.finished && !t.failed {
return 1
}
if !t.hasExplicit || len(t.steps) == 0 {
if t.finished {
return 0 // failed with no checklist: dim mark, the ✗ carries the news
}
return -1
}
var done, active float64
for _, s := range t.steps {
switch s.status {
case stepDone, stepSkipped, stepFailed:
done++
case stepActive:
if s.total > 0 {
f := float64(s.cur) / float64(s.total)
if f > 1 {
f = 1
}
if f > active {
active = f
}
}
}
}
return (done + active) / float64(len(t.steps))
}
func (t *Tracker) elapsedLocked() string {
end := t.now()
if t.finished {
end = t.stopAt
}
return fmtDurationCompact(end.Sub(t.startAt))
}
func (t *Tracker) statusRowLocked() string {
elapsed := t.st.dimmer.Render(" " + t.st.g.Sep + " " + t.elapsedLocked())
switch {
case t.finished && t.failed:
return t.st.err.Render(t.st.g.Fail+" failed") + elapsed
case t.finished:
head := t.finHead
if head == "" || head == t.title {
head = "ready"
}
return t.st.accent.Render(t.st.g.StatusReady) + " " + t.st.accentBold.Render(head) + elapsed
default:
text := t.status
if text == "" {
text = t.defaultStatusLocked()
}
spin := t.st.spin[t.tick%len(t.st.spin)]
return t.st.accentDim.Render(spin) + " " + t.st.dim.Render(text) + elapsed
}
}
// defaultStatusLocked derives a status when the caller set none: the phase
// position for a planned checklist ("phase 3 / 5"), the active stage label
// for reporter-driven runs, "working" as the last resort.
func (t *Tracker) defaultStatusLocked() string {
if t.hasExplicit && len(t.steps) > 0 {
done := 0
anyActive := false
for _, s := range t.steps {
switch s.status {
case stepDone, stepSkipped, stepFailed:
done++
case stepActive:
anyActive = true
}
}
cur := done
if anyActive && cur < len(t.steps) {
cur++
}
if cur == 0 {
cur = 1
}
return fmt.Sprintf("phase %d / %d", cur, len(t.steps))
}
if s := t.lastActiveLocked(); s != nil {
return s.label
}
return "working"
}
func (t *Tracker) compactHeaderLocked() string {
elapsed := t.st.dimmer.Render(" " + t.st.g.Sep + " " + t.elapsedLocked())
switch {
case t.finished && t.failed:
line := " " + t.st.err.Render(t.st.g.Fail) + " " + t.st.title.Render(t.title)
if t.finErr != nil {
line += t.st.err.Render(": " + t.finErr.Error())
}
return line + elapsed
case t.finished:
head := t.finHead
if head == "" {
head = t.title
}
line := " " + t.st.accent.Render(t.st.g.OK) + " " + t.st.title.Render(head)
summary := t.finSummary
if summary == "" {
summary = t.status
}
if summary != "" {
line += t.st.dim.Render(" " + t.st.g.Dash + " " + summary)
}
return line + elapsed
default:
mark := t.st.accentDim.Render(t.st.spin[t.tick%len(t.st.spin)])
if len(t.steps) > 0 {
mark = t.st.dimmer.Render(t.st.g.StatusBusy)
}
line := " " + mark + " " + t.st.title.Render(t.title)
if t.status != "" {
line += t.st.dim.Render(" " + t.st.g.Dash + " " + t.status)
}
return line + elapsed
}
}
// barCells is the progress bar width, matching the design's 22-cell bar.
const barCells = 22
func (t *Tracker) stepRowLocked(s *Step, width int) string {
switch s.status {
case stepPending:
return " " + t.st.dimmer.Render(t.st.g.Pending) + " " + t.st.dimmer.Render(s.label)
case stepSkipped:
note := ""
if s.skipNote != "" {
note = ": " + s.skipNote
}
return " " + t.st.dimmer.Render(t.st.g.Pending) + " " +
t.st.dimmer.Render(s.label+" "+t.st.g.Dash+" skipped"+note)
case stepFailed:
msg := ""
if s.err != nil {
msg = t.st.err.Render(" " + t.st.g.Dash + " " + s.err.Error())
}
return " " + t.st.err.Render(t.st.g.Fail) + " " + t.st.fg.Render(s.label) + msg
case stepDone:
row := " " + t.st.accent.Render(t.st.g.OK) + " " + t.st.fg.Render(s.finalLabel()) + t.countsLocked(s, true)
if d := s.stopped.Sub(s.started); d >= 100*time.Millisecond {
row += t.st.dimmer.Render(" (" + fmtDurationCompact(d) + ")")
}
return row
default: // active
spin := t.st.spin[t.tick%len(t.st.spin)]
base := " " + t.st.accentDim.Render(spin) + " " + t.st.fg.Render(s.label) + t.countsLocked(s, false)
note := ""
if s.note != "" {
note = t.st.dimmer.Render(" " + s.note)
}
bar := ""
if s.total > 0 {
bar = t.barLocked(s)
}
// Fit by priority instead of mid-bar truncation: full row, then drop
// the note, then drop the bar. The label and counters always stay.
for _, row := range []string{base + note + bar, base + bar, base + note} {
if visibleWidth(row) <= width-1 {
return row
}
}
return base
}
}
func (t *Tracker) countsLocked(s *Step, done bool) string {
if s.cur <= 0 && (done || s.total <= 0) {
return ""
}
out := t.st.dim.Render(" "+t.st.g.Sep+" ") + t.st.fg.Render(fmtCount(s.cur))
if !done && s.total > 0 {
out += t.st.dim.Render(" / " + fmtCount(s.total))
}
if s.unit != "" {
out += t.st.dim.Render(" " + s.unit)
}
return out
}
func (t *Tracker) barLocked(s *Step) string {
frac := float64(s.cur) / float64(s.total)
if frac < 0 {
frac = 0
}
if frac > 1 {
frac = 1
}
fill := int(frac*barCells + 0.5)
pct := int(frac*100 + 0.5)
return " " + t.st.accent.Render(strings.Repeat(t.st.g.BarFull, fill)) +
t.st.dimmer.Render(strings.Repeat(t.st.g.BarEmpty, barCells-fill)) +
t.st.dim.Render(fmt.Sprintf(" %3d%%", pct))
}
// ---- styles ---------------------------------------------------------------
// styleSet bundles the glyphs, spinner frames, and writer-bound styles one
// tracker instance renders with. Styles are bound to the tracker's own writer
// (not the process stdout) so `gortex … | tee log` keeps colors on the
// animated stderr region.
type styleSet struct {
g glyphSet
spin []string
fg lipgloss.Style
dim lipgloss.Style
dimmer lipgloss.Style
accent lipgloss.Style
accentBold lipgloss.Style
accentDim lipgloss.Style
err lipgloss.Style
title lipgloss.Style
logoLit lipgloss.Style
logoDim lipgloss.Style
}
func newStyleSet(w io.Writer) styleSet {
r := lipgloss.NewRenderer(w)
r.SetColorProfile(colorProfileFor(w))
mk := func(c lipgloss.Color) lipgloss.Style { return r.NewStyle().Foreground(c) }
return styleSet{
g: activeGlyphs(),
spin: spinFrames(),
fg: mk(colFg),
dim: mk(colFgDim),
dimmer: mk(colMuted),
accent: mk(colAccent),
accentBold: r.NewStyle().Foreground(colAccent).Bold(true),
accentDim: mk(colAccentDim),
err: mk(colErr),
title: r.NewStyle().Foreground(colFg).Bold(true),
logoLit: mk(colPerim),
logoDim: mk(colInner),
}
}
+327
View File
@@ -0,0 +1,327 @@
package progress
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/charmbracelet/x/ansi"
)
// forceUnicode pins the glyph set so assertions are stable regardless of the
// host environment.
func forceUnicode(t *testing.T) {
t.Helper()
t.Setenv("GORTEX_ASCII", "")
t.Setenv("GORTEX_UNICODE", "1")
}
// newAnimatedTestTracker returns a tracker that renders the animated protocol
// into buf: animation forced (no TTY in tests), the frame ticker frozen so
// paints happen only at Start / Logf / finish, and a fixed terminal size.
func newAnimatedTestTracker(t *testing.T, buf *bytes.Buffer, w, h int, opts ...TrackerOption) *Tracker {
t.Helper()
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
tr := NewTracker(buf, opts...)
tr.fps = time.Hour
tr.sizeFn = func() (int, int) { return w, h }
return tr
}
func TestTrackerPlainExplicitStepsLifecycle(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := NewTracker(&buf, WithoutAnimation())
tr.Start("gortex init")
scan := tr.AddStep("scan repository")
parse := tr.AddStep("parse sources")
analyze := tr.AddStep("analyze codebase")
broken := tr.AddStep("configure adapters")
if strings.Contains(buf.String(), "scan repository") {
t.Fatalf("pending steps must stay silent in plain mode, got:\n%s", buf.String())
}
tr.StartStep("scan repository")
scan.Progress(1338, 0)
scan.SetUnit("files")
scan.DoneAs("files discovered")
tr.StartStep("parse sources")
parse.Progress(48210, 0)
parse.SetUnit("symbols")
parse.Done()
analyze.Skip("disabled by flag")
tr.StartStep("configure adapters")
broken.Fail(errors.New("no adapters selected"))
tr.Done("ready", "indexed 1,338 files · 48,210 symbols")
out := buf.String()
wants := []string{
" gortex init",
" scan repository ...",
" ✓ files discovered · 1,338 files",
" parse sources ...",
" ✓ parse sources · 48,210 symbols",
" · analyze codebase — skipped: disabled by flag",
" configure adapters ...",
" ✗ configure adapters: no adapters selected",
" ✓ ready — indexed 1,338 files · 48,210 symbols",
}
for _, w := range wants {
if !strings.Contains(out, w) {
t.Errorf("plain output missing %q\n--- got ---\n%s", w, out)
}
}
}
func TestTrackerPlainHeartbeatThrottled(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := NewTracker(&buf, WithoutAnimation())
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
tr.now = func() time.Time { return clock }
tr.Start("indexing")
s := tr.StartStep("resolving references")
s.Progress(1, 10)
s.Progress(2, 10) // within the throttle window — silent
if got := strings.Count(buf.String(), "resolving references"); got != 1 {
t.Fatalf("ticks inside the throttle window must not print, got %d occurrences:\n%s", got, buf.String())
}
clock = clock.Add(11 * time.Second)
s.Progress(5, 10)
if !strings.Contains(buf.String(), " resolving references · 5 / 10 (11s)") {
t.Errorf("expected a heartbeat line after the throttle window, got:\n%s", buf.String())
}
s.Progress(6, 10) // window reset — silent again
if got := strings.Count(buf.String(), "resolving references"); got != 2 {
t.Errorf("heartbeat must rearm the throttle, got %d occurrences:\n%s", got, buf.String())
}
}
func TestTrackerAnimatedFrameProtocol(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 80, 24)
tr.Start("gortex enrich")
s := tr.StartStep("stamping blame")
s.Progress(120, 400)
s.SetUnit("nodes")
s.Done()
tr.Done("", "400 nodes stamped")
out := buf.String()
if !strings.HasPrefix(out, ansiHideCursor) {
t.Errorf("animated stream must open by hiding the cursor, got prefix %q", out[:min(20, len(out))])
}
if !strings.Contains(out, ansiSyncStart) || !strings.Contains(out, ansiSyncEnd) {
t.Error("frames must be wrapped in synchronized-update brackets")
}
if strings.Count(out, ansiSyncStart) != strings.Count(out, ansiSyncEnd) {
t.Error("unbalanced synchronized-update brackets")
}
if !strings.Contains(out, ansiShowCursor) {
t.Error("final frame must re-show the cursor")
}
if !strings.Contains(out, ansiReset) {
t.Error("final frame must reset SGR state")
}
// The final frame carries the completed checklist and the summary.
for _, w := range []string{"✓ stamping blame", "400 nodes stamped", "gortex enrich"} {
if !strings.Contains(ansi.Strip(out), w) {
t.Errorf("final frame missing %q\n--- stripped ---\n%s", w, ansi.Strip(out))
}
}
// Every frame line terminates with clear-to-EOL + CRLF so repaints can
// never leave residue and the cursor arithmetic stays exact.
frames := strings.Split(out, ansiSyncStart)
for _, fr := range frames[1:] {
body := strings.TrimSuffix(fr, ansiSyncEnd)
for _, ln := range strings.Split(body, "\r\n") {
if strings.Contains(ln, "\n") {
t.Errorf("bare LF inside a frame line: %q", ln)
}
}
}
}
func TestTrackerAnimatedLinesNeverExceedWidth(t *testing.T) {
forceUnicode(t)
const width = 34
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, width, 24)
tr.Start("gortex enrich with an extremely long command title that cannot fit")
s := tr.StartStep("a step label that is much longer than the terminal width for sure")
s.Progress(123456, 654321)
s.SetUnit("symbols")
s.Note("annotation that also overflows the width")
tr.Done("", "")
for _, raw := range strings.Split(buf.String(), "\r\n") {
if w := ansi.StringWidth(raw); w > width-1 {
t.Errorf("frame line exceeds clamp width %d (got %d): %q", width-1, w, ansi.Strip(raw))
}
}
}
func TestTrackerOverflowRetiresFinishedRows(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 80, 8) // maxRegion = 6
tr.Start("bulk run")
for i := 0; i < 10; i++ {
s := tr.StartStep(fmt.Sprintf("phase-%02d", i))
s.Done()
}
tr.Done("", "")
stripped := ansi.Strip(buf.String())
for i := 0; i < 10; i++ {
label := fmt.Sprintf("phase-%02d", i)
if !strings.Contains(stripped, label) {
t.Errorf("finished step %s lost during overflow handling\n--- got ---\n%s", label, stripped)
}
}
// The final live region must fit the clamped height. Region lines are the
// ones terminated by clear-to-EOL; retired rows above the region are
// written without it.
frames := strings.Split(buf.String(), ansiSyncStart)
last := frames[len(frames)-1]
if lines := strings.Count(last, ansiClearEOL); lines > 6 {
t.Errorf("final live region paints %d lines, exceeding the clamp of 6", lines)
}
}
func TestTrackerLogfWritesAboveRegion(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 80, 24)
tr.Start("watching")
tr.Logf("warning: adapter %s misbehaved", "codex")
tr.Done("", "")
out := ansi.Strip(buf.String())
warnIdx := strings.Index(out, "warning: adapter codex misbehaved")
if warnIdx < 0 {
t.Fatalf("log line missing from animated stream:\n%s", out)
}
// The region is repainted after the log line, so the title must appear
// again later in the stream.
if !strings.Contains(out[warnIdx:], "watching") {
t.Errorf("live region not repainted after log line:\n%s", out)
}
}
func TestTrackerReporterBuildsChecklistAnimated(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 100, 24)
tr.Start("indexing repository")
tr.Report("discovering files", 0, 0)
tr.Report("discovering files", 900, 0)
tr.Report("parsing", 10, 900)
tr.Report("parsing", 900, 900)
tr.Done("", "")
out := ansi.Strip(buf.String())
if !strings.Contains(out, "✓ discovering files · 900") {
t.Errorf("previous stage must complete with its final counter:\n%s", out)
}
if !strings.Contains(out, "✓ parsing · 900") {
t.Errorf("last stage must be completed by Done:\n%s", out)
}
}
func TestTrackerBannerPresetRendersLogoAndStatus(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := newAnimatedTestTracker(t, &buf, 100, 30, WithLogo())
tr.Start("gortex init")
tr.AddStep("index repository")
tr.AddStep("configure adapters")
tr.StartStep("index repository").Done()
tr.SetStatus("configuring")
tr.StartStep("configure adapters").Done()
tr.Done("ready", "indexed everything")
out := ansi.Strip(buf.String())
if got := strings.Count(out, "●"); got < 10 {
t.Errorf("banner preset must render the dot-matrix mark, found %d dots:\n%s", got, out)
}
for _, w := range []string{"gortex init", "ready", "indexed everything", "✓ index repository", "✓ configure adapters"} {
if !strings.Contains(out, w) {
t.Errorf("banner output missing %q:\n%s", w, out)
}
}
}
func TestTrackerConcurrentReportsAreSafe(t *testing.T) {
forceUnicode(t)
t.Setenv("GORTEX_FORCE_ANIMATION", "1")
var buf bytes.Buffer
tr := NewTracker(&buf)
tr.fps = 2 * time.Millisecond
tr.sizeFn = func() (int, int) { return 80, 24 }
tr.Start("stress")
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < 200; i++ {
tr.Report(fmt.Sprintf("stage-%d", g%3), i, 200)
}
}(g)
}
wg.Wait()
time.Sleep(10 * time.Millisecond) // let the ticker paint at least once
tr.Done("", "")
if !strings.Contains(ansi.Strip(buf.String()), "stress") {
t.Error("expected output from the concurrent run")
}
}
func TestTrackerFinishIdempotent(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
tr := NewTracker(&buf, WithoutAnimation())
tr.Start("run")
tr.Done("", "")
tr.Done("", "")
tr.Fail(errors.New("late"))
out := buf.String()
if got := strings.Count(out, "✓"); got != 1 {
t.Errorf("expected exactly one ✓, got %d:\n%s", got, out)
}
if strings.Contains(out, "✗") {
t.Errorf("Fail after Done must be ignored:\n%s", out)
}
}
func TestSpinnerCompatStatusRidesFinishLine(t *testing.T) {
forceUnicode(t)
var buf bytes.Buffer
sp := NewSpinner(&buf)
sp.Disable()
sp.Start("Enriched via daemon")
sp.Set("", "42 nodes stamped")
sp.Done()
if !strings.Contains(buf.String(), "✓ Enriched via daemon — 42 nodes stamped") {
t.Errorf("finish line must carry the last status, got:\n%s", buf.String())
}
}
+159
View File
@@ -0,0 +1,159 @@
package progress
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Extended palette — used by the post-summary helpers.
var (
colWarn = lipgloss.Color("#E8C66B")
colInfoSoft = lipgloss.Color("#6EAAD4")
colMuted = lipgloss.Color("#5A5A60")
colBorder = lipgloss.Color("#3A3A40")
)
// Composed styles.
var (
styleHeading = lipgloss.NewStyle().Foreground(colFgDim).Bold(true)
styleCount = lipgloss.NewStyle().Foreground(colMuted)
styleKey = lipgloss.NewStyle().Foreground(colFg).Bold(true)
styleVal = lipgloss.NewStyle().Foreground(colFgDim)
styleHint = lipgloss.NewStyle().Foreground(colMuted).Italic(true)
styleStep = lipgloss.NewStyle().Foreground(colInfoSoft)
styleStrong = lipgloss.NewStyle().Foreground(colFg).Bold(true)
// styleBox carries everything but the border charset; Card applies the
// active (rounded or ASCII) border at render time so an OEM-codepage
// terminal gets a box it can actually draw.
styleBox = lipgloss.NewStyle().
BorderForeground(colBorder).
Padding(0, 1)
)
// Heading returns a section header on its own line: lowercase title,
// optional dim count chip. No leading indent — caller controls layout.
func Heading(title string, count ...string) string {
t := styleHeading.Render(strings.ToLower(title))
if len(count) > 0 && count[0] != "" {
t += " " + styleCount.Render(activeGlyphs().Sep+" "+count[0])
}
return t
}
// Caption renders a one-line dim hint.
func Caption(text string) string {
return styleHint.Render(text)
}
// Row renders one row of a 2-column listing: bold key (padded to keyWidth)
// followed by dim details. No leading indent.
func Row(key, details string, keyWidth int) string {
return styleKey.Render(padRight(key, keyWidth)) + " " + styleVal.Render(details)
}
// NumberedStep renders a numbered step (" 1. text") with the index in soft
// blue. Used by post-run summary cards for "next steps" hints.
func NumberedStep(n int, text string) string {
return styleStep.Render(fmt.Sprintf("%d.", n)) + " " + styleVal.Render(text)
}
// Chips renders items as " · " separated dim text, fitted to width if width
// is positive (otherwise a single-line list).
func Chips(items []string, width int) string {
if len(items) == 0 {
return ""
}
sep := " " + activeGlyphs().Sep + " "
if width <= 0 {
return styleVal.Render(strings.Join(items, sep))
}
// Width-aware multi-line wrap.
var lines []string
var cur strings.Builder
for i, it := range items {
piece := it
if i > 0 {
piece = sep + piece
}
if cur.Len()+lipgloss.Width(piece) > width && cur.Len() > 0 {
lines = append(lines, cur.String())
cur.Reset()
cur.WriteString(it)
} else {
cur.WriteString(piece)
}
}
if cur.Len() > 0 {
lines = append(lines, cur.String())
}
for i := range lines {
lines[i] = styleVal.Render(lines[i])
}
return strings.Join(lines, "\n")
}
// Stat renders "value unit" with an emphasised value (bold, given color)
// and dim unit. Pass an empty unit to skip the suffix.
type StatSeverity int
const (
StatNeutral StatSeverity = iota
StatGood
StatWarn
StatBad
)
func Stat(value, unit string, sev StatSeverity) string {
color := colFg
switch sev {
case StatGood:
color = colAccent
case StatWarn:
color = colWarn
case StatBad:
color = colErr
}
v := lipgloss.NewStyle().Foreground(color).Bold(true).Render(value)
if unit == "" {
return v
}
return v + " " + styleVal.Render(unit)
}
// StatStrip renders multiple Stats joined with a mid-dot separator.
func StatStrip(stats ...string) string {
return strings.Join(stats, styleVal.Render(" "+activeGlyphs().Sep+" "))
}
// Card wraps body in a dim rounded box with an optional title row above. Pad
// is the inner padding rows. Returns the box plus a trailing newline.
func Card(title, body string) string {
if title != "" {
body = styleStrong.Render(title) + "\n" + body
}
return styleBox.Border(activeGlyphs().Border).Render(body) + "\n"
}
// Indent prefixes every line of s with the given number of spaces.
func Indent(s string, n int) string {
pad := strings.Repeat(" ", n)
return pad + strings.ReplaceAll(s, "\n", "\n"+pad)
}
// SortStrings is a small convenience used by callers preparing chip lists.
func SortStrings(s []string) []string {
out := append([]string(nil), s...)
sort.Strings(out)
return out
}
func padRight(s string, n int) string {
w := lipgloss.Width(s)
if w >= n {
return s
}
return s + strings.Repeat(" ", n-w)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package progress
import "io"
// enableVT is a no-op outside Windows: every unix terminal that passed the
// TTY gate interprets VT sequences natively.
func enableVT(io.Writer) bool { return true }
+30
View File
@@ -0,0 +1,30 @@
//go:build windows
package progress
import (
"io"
"golang.org/x/sys/windows"
)
// enableVT switches the console behind w into VT-processing mode so the
// animated renderer's escape sequences are interpreted instead of echoed.
// Windows Terminal and recent conhost builds accept it; a console that
// refuses (pre-1511 conhost, exotic redirectors) returns false and the
// caller stays on plain output.
func enableVT(w io.Writer) bool {
f, ok := w.(interface{ Fd() uintptr })
if !ok {
return false
}
h := windows.Handle(f.Fd())
var mode uint32
if windows.GetConsoleMode(h, &mode) != nil {
return false
}
if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 {
return true
}
return windows.SetConsoleMode(h, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil
}
+114
View File
@@ -0,0 +1,114 @@
package progress
import (
"context"
"sync"
"time"
"go.uber.org/zap"
)
// ZapReporter logs every Report call as a zap INFO line. Used in
// non-TTY environments (the daemon, CI) where the Spinner is
// silent so progress is invisible. Stage transitions get logged
// immediately; intra-stage progress (current/total) gets logged on
// transition AND every progressInterval seconds so a slow stage
// emits a heartbeat instead of going quiet.
type ZapReporter struct {
logger *zap.Logger
prefix string
interval time.Duration
mu sync.Mutex
lastStage string
stageStart time.Time
lastEmitted time.Time
lastCur int
lastTotal int
}
// NewZapReporter creates a reporter that logs to the given logger.
// prefix is added to every log line ("indexer", "multi-repo", …).
// interval is the heartbeat cadence for intra-stage progress
// (0 disables heartbeats — only stage transitions log).
func NewZapReporter(logger *zap.Logger, prefix string, interval time.Duration) *ZapReporter {
if logger == nil {
logger = zap.NewNop()
}
return &ZapReporter{
logger: logger,
prefix: prefix,
interval: interval,
}
}
// Report records a stage advancement. Always logs on a stage
// transition; logs intra-stage updates at most once per interval.
func (r *ZapReporter) Report(stage string, cur, total int) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
if stage != r.lastStage {
if r.lastStage != "" {
r.logger.Info(r.prefix+": stage end",
zap.String("stage", r.lastStage),
zap.Duration("elapsed", now.Sub(r.stageStart)),
)
}
r.lastStage = stage
r.stageStart = now
r.lastEmitted = now
r.lastCur = cur
r.lastTotal = total
r.logger.Info(r.prefix+": stage start",
zap.String("stage", stage),
zap.Int("current", cur),
zap.Int("total", total),
)
return
}
// Same stage — heartbeat at most once per interval.
if r.interval > 0 && now.Sub(r.lastEmitted) < r.interval {
return
}
r.lastEmitted = now
r.lastCur = cur
r.lastTotal = total
r.logger.Info(r.prefix+": stage progress",
zap.String("stage", stage),
zap.Int("current", cur),
zap.Int("total", total),
zap.Duration("elapsed", now.Sub(r.stageStart)),
)
}
// StartHeartbeat runs a goroutine that logs an "alive" line every
// interval until the context is done. Useful when the indexer is
// inside a long-running phase that doesn't call Report itself
// (e.g. the disk backend's bulk writes during a slow drain).
func StartHeartbeat(ctx context.Context, logger *zap.Logger, prefix string, interval time.Duration, snapshot func() map[string]any) {
if logger == nil || interval <= 0 {
return
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
start := time.Now()
for {
select {
case <-ctx.Done():
return
case <-t.C:
fields := []zap.Field{
zap.Duration("elapsed", time.Since(start)),
}
if snapshot != nil {
for k, v := range snapshot() {
fields = append(fields, zap.Any(k, v))
}
}
logger.Info(prefix+": heartbeat", fields...)
}
}
}()
}