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