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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/macaron/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/root-Manas/macaron/internal/app"
"github.com/root-Manas/macaron/internal/cfg"
"github.com/root-Manas/macaron/internal/cliui"
"github.com/root-Manas/macaron/internal/model"
"github.com/root-Manas/macaron/internal/ui"
"github.com/spf13/pflag"
Expand Down Expand Up @@ -248,6 +249,11 @@ func run() int {

start := time.Now()
modeVal := model.Mode(strings.ToLower(mode))
var renderer *cliui.LiveRenderer
if !quiet {
renderer = cliui.NewLiveRenderer(os.Stdout)
defer renderer.Close()
}
if !quiet {
fmt.Printf("Workflow profile: %s | mode=%s | stages=%s | rate=%d | threads=%d\n", profile, mode, stages, rate, threads)
}
Expand All @@ -259,6 +265,11 @@ func run() int {
Quiet: quiet,
EnabledStages: app.ParseStages(stages),
APIKeys: config.APIKeys,
Progress: func(ev model.StageEvent) {
if renderer != nil {
renderer.Handle(ev)
}
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "scan failed: %v\n", err)
Expand Down
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type ScanArgs struct {
Quiet bool
EnabledStages map[string]bool
APIKeys map[string]string
Progress func(model.StageEvent)
}

func New(home string) (*App, error) {
Expand All @@ -69,6 +70,7 @@ func (a *App) Scan(ctx context.Context, args ScanArgs) ([]model.ScanResult, erro
Quiet: args.Quiet,
EnabledStages: args.EnabledStages,
APIKeys: args.APIKeys,
Progress: args.Progress,
})
if err != nil {
return nil, err
Expand Down
202 changes: 202 additions & 0 deletions internal/cliui/live.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package cliui

import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"

"github.com/root-Manas/macaron/internal/model"
)

type LiveRenderer struct {
out io.Writer

mu sync.Mutex
color bool
spinnerOn bool
spinStop chan struct{}
spinFrame int
target string
stage string
message string
stageStart time.Time
scanStart time.Time
lastPrinted time.Time
}
Comment on lines +26 to +28
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastPrinted is written to but never read, which adds noise and makes it unclear whether throttling/printing logic is incomplete. Either remove lastPrinted or use it (e.g., to rate-limit spinner updates / avoid flicker).

Copilot uses AI. Check for mistakes.

func NewLiveRenderer(out io.Writer) *LiveRenderer {
if out == nil {
out = os.Stdout
}
useColor := strings.TrimSpace(os.Getenv("NO_COLOR")) == ""
return &LiveRenderer{
out: out,
color: useColor,
}
}

func (r *LiveRenderer) Handle(ev model.StageEvent) {
r.mu.Lock()
defer r.mu.Unlock()

switch ev.Type {
case model.EventTargetStart:
r.target = ev.Target
r.scanStart = chooseTime(ev.Timestamp, time.Now())
r.stage = ""
r.message = "initializing workflow"
r.stageStart = time.Now()
r.printLinef("%s target=%s", r.info("SCAN"), r.strong(ev.Target))
r.startSpinnerLocked()
case model.EventStageStart:
r.stage = ev.Stage
r.message = ev.Message
r.stageStart = chooseTime(ev.Timestamp, time.Now())
r.printLinef("%s stage=%s %s", r.info("RUN"), r.stageLabel(ev.Stage), r.dim(ev.Message))
case model.EventWarn:
msg := ev.Message
if strings.TrimSpace(msg) == "" {
msg = "warning"
}
if ev.Stage != "" {
r.printLinef("%s stage=%s %s", r.warn("WARN"), r.stageLabel(ev.Stage), msg)
} else {
r.printLinef("%s %s", r.warn("WARN"), msg)
}
case model.EventStageDone:
dur := time.Duration(ev.DurationMS) * time.Millisecond
if dur <= 0 && !r.stageStart.IsZero() {
dur = time.Since(r.stageStart)
}
r.printLinef("%s stage=%s count=%d in %s", r.ok("DONE"), r.stageLabel(ev.Stage), ev.Count, dur.Round(time.Millisecond))
case model.EventTargetDone:
total := time.Duration(ev.DurationMS) * time.Millisecond
if total <= 0 && !r.scanStart.IsZero() {
total = time.Since(r.scanStart)
}
r.stopSpinnerLocked()
r.printLinef("%s target=%s completed in %s", r.ok("COMPLETE"), r.strong(ev.Target), total.Round(time.Millisecond))
}
}

func (r *LiveRenderer) Close() {
r.mu.Lock()
defer r.mu.Unlock()
r.stopSpinnerLocked()
}

func (r *LiveRenderer) startSpinnerLocked() {
if r.spinnerOn {
return
}
r.spinnerOn = true
r.spinStop = make(chan struct{})
go r.spin()
}
Comment on lines +91 to +98
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spinner lifecycle has a concurrency bug: startSpinnerLocked overwrites r.spinStop and launches r.spin() which reads r.spinStop from the struct. If a new scan starts soon after a previous stop, the old goroutine can end up selecting on the new channel and never exit, resulting in multiple spinner goroutines writing to the same output. Fix by capturing the stop channel in a local variable and passing it into spin(stopCh) (and selecting on that channel), so each goroutine listens to the channel it was created with.

Copilot uses AI. Check for mistakes.

func (r *LiveRenderer) stopSpinnerLocked() {
if !r.spinnerOn {
return
}
close(r.spinStop)
r.spinnerOn = false
fmt.Fprint(r.out, "\r\033[2K")
}
Comment on lines +104 to +107
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ANSI clear-line sequences ("\r\033[2K") are written even when output is not a TTY or when NO_COLOR is set, which can pollute piped logs / redirected output (and may behave poorly on some terminals). Consider detecting terminal capability (e.g., using go-isatty which is already in go.mod) and disabling the spinner/clear-line behavior when not interactive.

Copilot uses AI. Check for mistakes.

func (r *LiveRenderer) spin() {
frames := []string{"|", "/", "-", `\`}
ticker := time.NewTicker(120 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.mu.Lock()
r.spinFrame = (r.spinFrame + 1) % len(frames)
stage := r.stage
if strings.TrimSpace(stage) == "" {
stage = "bootstrap"
}
msg := strings.TrimSpace(r.message)
if msg == "" {
msg = "working"
}
elapsed := "--"
if !r.stageStart.IsZero() {
elapsed = time.Since(r.stageStart).Round(time.Second).String()
}
line := fmt.Sprintf("%s %s %s %s %s",
r.spinStyle(frames[r.spinFrame]),
r.strong(r.target),
r.dim("stage="+stage),
msg,
r.dim("t="+elapsed),
)
fmt.Fprintf(r.out, "\r\033[2K%s", line)
r.lastPrinted = time.Now()
r.mu.Unlock()
case <-r.spinStop:
return
}
}
}

func (r *LiveRenderer) printLinef(format string, args ...any) {
// Avoid overwriting a spinner frame line.
fmt.Fprint(r.out, "\r\033[2K")
fmt.Fprintf(r.out, format+"\n", args...)
}

func (r *LiveRenderer) strong(v string) string {
if !r.color {
return v
}
return "\033[1;37m" + v + "\033[0m"
}

func (r *LiveRenderer) dim(v string) string {
if !r.color {
return v
}
return "\033[2;37m" + v + "\033[0m"
}

func (r *LiveRenderer) info(v string) string {
return r.paint(v, "36")
}

func (r *LiveRenderer) ok(v string) string {
return r.paint(v, "32")
}

func (r *LiveRenderer) warn(v string) string {
return r.paint(v, "33")
}

func (r *LiveRenderer) spinStyle(v string) string {
return r.paint(v, "35")
}

func (r *LiveRenderer) paint(v, code string) string {
if !r.color {
return "[" + v + "]"
}
return "\033[" + code + "m[" + v + "]\033[0m"
}

func (r *LiveRenderer) stageLabel(stage string) string {
stage = strings.TrimSpace(strings.ToLower(stage))
if stage == "" {
return "unknown"
}
return stage
}

func chooseTime(v time.Time, fallback time.Time) time.Time {
if v.IsZero() {
return fallback
}
return v
}
Loading
Loading