Files
wehub-resource-sync a06f331eb8
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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

268 lines
9.0 KiB
Go

package main
import (
"context"
"fmt"
"io"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/zzet/gortex/internal/progress"
"github.com/zzet/gortex/internal/tui"
)
// initProgress abstracts the two ways `gortex init` renders work-in-progress:
// the legacy single-line mesh Spinner (default / non-TTY) and the new
// multi-pane Dashboard (wizard path). The orchestration body in runInit calls
// into this interface so the same code drives both surfaces.
//
// The dashboard variant ignores Start (the dashboard owns its own banner) and
// treats Stage / Sub as a stage transition; the spinner variant treats Stage
// as the new top label and Sub as the sub-status, preserving today's UX for
// non-interactive callers.
type initProgress interface {
// Start opens the surface with an initial label. No-op for the dashboard.
Start(label string)
// Stage advances to a new high-level stage. sub is an optional detail.
Stage(name, sub string)
// Sub updates the sub-status of the current stage.
Sub(sub string)
// StageDone marks a stage complete, optionally with a final sub-line.
StageDone(name, sub string)
// Reporter returns a progress.Reporter ready to attach to the indexer.
Reporter() progress.Reporter
// Enabled reports whether the surface is rendering an animation. Used to
// gate the chatter-capture redirect that hides adapter logs.
Enabled() bool
// Done shuts down cleanly.
Done()
// Fail shuts down with an error mark.
Fail(err error)
}
// trackerProgress drives the banner-preset Tracker: the gortex mark lighting
// up across the planned stages, a live status line, and one checklist row per
// stage. It is the default init / install surface; with --no-progress (or on
// a pipe) the same driver degrades to clean line output.
type trackerProgress struct {
t *progress.Tracker
steps map[string]*progress.Step
}
// newTrackerProgress plans the given stages up front so the checklist shows
// the whole run from the first frame — pending rows fill in as stages start,
// and the planned count is what lets the logo light up proportionally.
func newTrackerProgress(w io.Writer, stages []string, plain bool) *trackerProgress {
opts := []progress.TrackerOption{progress.WithLogo()}
if plain {
opts = append(opts, progress.WithoutAnimation())
}
t := progress.NewTracker(w, opts...)
tp := &trackerProgress{t: t, steps: make(map[string]*progress.Step, len(stages))}
for _, s := range stages {
tp.steps[s] = t.AddStep(s)
}
return tp
}
func (p *trackerProgress) Start(label string) { p.t.Start(label) }
func (p *trackerProgress) Stage(name, sub string) {
p.steps[name] = p.t.StartStep(name)
p.t.SetStatus(sub)
}
func (p *trackerProgress) Sub(sub string) { p.t.SetStatus(sub) }
func (p *trackerProgress) StageDone(name, sub string) {
if s, ok := p.steps[name]; ok {
s.Done()
}
if sub != "" {
p.t.SetStatus(sub)
}
}
func (p *trackerProgress) Reporter() progress.Reporter { return p.t }
func (p *trackerProgress) Enabled() bool { return p.t.Animated() }
func (p *trackerProgress) Done() { p.t.Done("ready", "") }
func (p *trackerProgress) Fail(err error) { p.t.Fail(err) }
// dashboardProgress wraps a running Dashboard tea.Program so it satisfies
// initProgress. Wait blocks until the program exits.
type dashboardProgress struct {
session *initDashboardSession
rep progress.Reporter
}
func newDashboardProgress(s *initDashboardSession) *dashboardProgress {
return &dashboardProgress{
session: s,
rep: reporterFromSession(s),
}
}
func (d *dashboardProgress) Start(_ string) {} // dashboard renders its own header
func (d *dashboardProgress) Stage(name, sub string) {
if d.session != nil {
d.session.controller.SetActive(name, sub)
}
}
func (d *dashboardProgress) Sub(sub string) {
if d.session != nil {
d.session.controller.Sub(sub)
}
}
func (d *dashboardProgress) StageDone(name, sub string) {
if d.session != nil {
d.session.controller.Done(name, sub)
}
}
func (d *dashboardProgress) Reporter() progress.Reporter { return d.rep }
func (d *dashboardProgress) Enabled() bool { return d.session != nil }
func (d *dashboardProgress) Done() {
if d.session != nil {
d.session.controller.Finish(nil)
<-d.session.done
}
}
func (d *dashboardProgress) Fail(err error) {
if d.session != nil {
d.session.controller.Finish(err)
<-d.session.done
}
}
// initDashboardStages is the high-level checklist the dashboard renders down
// the right column. Each entry maps to a single wizard "phase" — the indexer
// emits many finer-grained sub-stages that ride on top via dashReporter.
//
// Stages are added / removed dynamically by buildInitStages depending on
// which optional passes (analyze, skills) the user picked.
var (
stageIndex = "Index repository"
stageAnalyze = "Analyze codebase"
stageSkills = "Generate skills"
stageAdapters = "Configure adapters"
)
// buildInitStages returns the ordered stage labels the dashboard will show
// given the per-pass flags. The orchestration code calls SetActive on each
// stage in this exact order; an empty list means "no dashboard, fall back to
// the legacy plain-text path."
func buildInitStages(analyze, skills bool) []string {
stages := make([]string, 0, 4)
if analyze || skills {
stages = append(stages, stageIndex)
}
if analyze {
stages = append(stages, stageAnalyze)
}
if skills {
stages = append(stages, stageSkills)
}
stages = append(stages, stageAdapters)
return stages
}
// initDashboardSession bundles the running tea.Program + its controller and
// the goroutine that hosts it. Closing the session cleanly tears down the
// program and waits for it to flush, so subsequent stderr writes (the agent
// summary card) start on a clean line.
type initDashboardSession struct {
prog *tea.Program
controller *tui.DashboardController
done chan struct{}
}
// startInitDashboard spawns the dashboard tea.Program against w and returns
// a session the orchestration code uses to drive it. Returns (nil, nil) when
// w isn't a TTY — the caller falls back to the legacy Spinner.
func startInitDashboard(w io.Writer, stages []string) *initDashboardSession {
if !progress.IsTTY(w) {
return nil
}
model := tui.NewDashboard("gortex init", stages)
prog := tea.NewProgram(model,
tea.WithOutput(w),
tea.WithoutSignalHandler(),
)
controller := tui.NewDashboardController(prog)
done := make(chan struct{})
go func() {
defer close(done)
_, _ = prog.Run()
}()
return &initDashboardSession{prog: prog, controller: controller, done: done}
}
// dashReporter adapts the progress.Reporter interface onto a running dashboard
// controller. Stage names from the indexer become the *sub-label* of whatever
// the dashboard considers the active stage — the orchestration code owns the
// SetActive transitions.
type dashReporter struct {
mu sync.Mutex
ctl *tui.DashboardController
}
func (r *dashReporter) Report(stage string, current, total int) {
if r == nil || r.ctl == nil || stage == "" {
return
}
sub := stage
switch {
case total > 0:
sub = fmt.Sprintf("%s · %d / %d", stage, current, total)
case current > 0:
sub = fmt.Sprintf("%s · %d", stage, current)
}
r.mu.Lock()
defer r.mu.Unlock()
r.ctl.Sub(sub)
}
// reporterFromSession returns a progress.Reporter that drives the dashboard
// session. Returns Nop when session is nil so callers can install the reporter
// unconditionally with WithReporter.
func reporterFromSession(s *initDashboardSession) progress.Reporter {
if s == nil || s.controller == nil {
return progress.Nop{}
}
return &dashReporter{ctl: s.controller}
}
// wizardSelectedDashboard is set to true when the bubbletea wizard finishes
// successfully — selectInitProgress reads it to decide whether to start the
// multi-pane Dashboard (wizard path) or fall back to the legacy Spinner.
// It's a package-level flag rather than threading a parameter because every
// path that mutates the init globals also mutates this flag, and runInit
// reads it once just before kicking off orchestration.
var wizardSelectedDashboard bool
// selectInitProgress returns the right initProgress surface for the current
// run. Order of precedence:
// - --no-progress: the tracker in plain line mode (no animation, no dashboard).
// - Wizard ran successfully: dashboard.
// - Otherwise: the banner tracker — gortex mark, status line, and the
// planned stage checklist (TTY-detected internally).
//
// The dashboard is only worth standing up after the wizard has cleared the
// alt-screen — otherwise it competes with the wizard's own draw cycle.
func selectInitProgress(w io.Writer) initProgress {
stages := buildInitStages(initAnalyze, initSkills)
if noProgress {
return newTrackerProgress(w, stages, true)
}
if wizardSelectedDashboard {
if s := startInitDashboard(w, stages); s != nil {
return newDashboardProgress(s)
}
}
return newTrackerProgress(w, stages, false)
}
// ensure unused-import linter doesn't trip on context until callers wire it
// back in. context is still part of the package; this keeps the import.
var _ = context.Background