Sync Progress UI Design
Date: 2026-04-20 Status: Approved Scope: Unified Bubbletea progress display for the sap-devs sync command
Problem
The sync command has 8 logical phases (content, company, markers, events, YouTube, Discovery Center, tutorials, learning), but only the archive fetch prints a status line and marker expansion has a Bubbletea progress UI. Phases 3-7 (events, YouTube, Discovery Center, tutorials, learning) are completely silent and can each take seconds to complete, creating long pauses with no terminal feedback.
Solution
Replace all sync output with a single Bubbletea inline program that renders a progress bar and phase status list throughout the entire sync lifecycle. The existing marker expansion detail integrates as sub-items under the markers phase. When stdout is not a TTY, fall back to plain text progress lines.
Visual Design
Syncing SAP developer content
[████████░░░░░░░░] 50%
content ✓ fetched archive
company ─ skipped
markers expanding...
cap › CAP release notes ✓ (42 lines)
btp-core › BTP service updates fetching...
events ✓ 2 event types (0.4s)
youtube ● syncing...
discovery ─ pending
tutorials ─ pending
learning ─ pendingStyling (SAP Fiori Horizon Evening palette)
✓done: FioriGreen (#00D68F)●active spinner: FioriBlue (#4DB8FF)✗failed: FioriRed (#FF5C5C)─pending/skipped: FioriMuted (#8C9BAA)- Progress bar fill: FioriBlue (#4DB8FF), empty: FioriMuted (#8C9BAA)
All styling uses github.com/charmbracelet/lipgloss v1 (matching internal/ui/progress.go). Define local color constants in sync_progress.go rather than importing internal/theme (which mixes lipgloss v1 and v2 types).
Non-TTY Fallback
Before launching the Bubbletea program, check term.IsTerminal(int(os.Stdout.Fd())) (same pattern as cmd/inject.go). When stdout is not a TTY:
- Skip Bubbletea entirely
- Print plain text progress lines to
outas each phase starts/completes:" ✓ content"," ✓ events (2 types)"," ✗ discovery (fetch failed)" - This ensures
inject --sync, CI pipelines, and piped output work correctly
--category Filtering
When --category is set, the phase list shown in the UI is filtered to only the relevant phase(s). For example, sync --category events shows only the events phase row and the progress bar fills from 0% to 100% for that single phase. Skipped phases for other categories are not rendered.
Architecture
Message Protocol
Typed messages flow from a sync worker goroutine to the Bubbletea program:
type PhaseID int
const (
PhaseContent PhaseID = iota
PhaseCompany
PhaseMarkers
PhaseChangelog
PhaseEvents
PhaseYouTube
PhaseDiscovery
PhaseTutorials
PhaseLearning
)Company + changelog coupling: In the current code, company sync and changelog collection happen inside the archiveNeedsSync block, tightly coupled to the content phase. In the new model, PhaseContent, PhaseCompany, PhaseMarkers, and PhaseChangelog are all sub-phases of the archive sync block. The syncWorker runs them sequentially within the archive-needs-sync conditional, and skips all four together when the archive is fresh. Changelog collection receives both the official and company cache directories, exactly as today.
type PhaseStartMsg struct{ ID PhaseID }
type PhaseDoneMsg struct{ ID PhaseID; Summary string; Err error }
type PhaseSkipMsg struct{ ID PhaseID }
type SyncDoneMsg struct{ FatalErr error }
// MarkerDoneMsg — reused from existing codePhase States
Each phase transitions through: pending → active → done/failed, or directly to skipped.
Progress bar percent = (done + skipped) / total.
total is set to the number of visible phases in this sync run. Every PhaseID that is included in the phase list must produce exactly one terminal message (PhaseDoneMsg or PhaseSkipMsg) to ensure done + skipped == total at completion. PhaseChangelog is a hidden sub-phase — it is not counted in total and does not appear as a row. PhaseCompany is included in total when a company repo is configured; otherwise it is omitted from the phase list entirely (not shown as skipped).
Bubbletea Model
Single syncModel in internal/ui/sync_progress.go:
type syncModel struct {
phases []phaseState // ordered, one per visible phase
markers []markerItem // sub-items under PhaseMarkers (reused)
frame int // spinner frame counter (ticked by tea.Tick)
done int // phases completed + skipped
total int // total visible phase count
fatalErr error // propagated back to caller
}No bubbles dependency. The progress bar and spinner are rendered manually using lipgloss v1 styled strings, matching the approach in the existing progressModel in progress.go:
- Progress bar: Hand-built from
█(filled) and░(empty) characters, width 20, styled with lipgloss v1. - Spinner: Rotating dot sequence (
⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏), advanced bytea.Tickat 100ms intervals. This tick is what keeps the display alive during long-running phases.
Sync Orchestration
runSync in cmd/sync.go is refactored into two parts:
- Setup — load config, resolve TTLs, build phase plan (which phases run vs skip)
- Launch — start Bubbletea program inline, kick off
syncWorkergoroutine
func runSync(ctx context.Context, force bool, out io.Writer) error {
// 1. Load config, resolve TTLs, determine phase plan
// 2. If nothing stale: print "up to date", return (no Bubbletea)
// 3. Launch Bubbletea program (inline)
// 4. syncWorker goroutine sends messages as phases execute
// 5. p.Run() blocks until SyncDoneMsg
// 6. Return fatalErr if any
}The syncWorker runs each phase sequentially, sending PhaseStartMsg before and PhaseDoneMsg after each:
func syncWorker(p *tea.Program, plan syncPlan) {
// For each active phase:
// p.Send(PhaseStartMsg{ID: ...})
// err := existingRunFunc(...)
// p.Send(PhaseDoneMsg{ID: ..., Summary: "...", Err: err})
// For skipped phases (sent upfront):
// p.Send(PhaseSkipMsg{ID: ...})
// Finally:
// p.Send(SyncDoneMsg{FatalErr: err})
}Marker Integration
runMarkerExpansion is updated to accept a *tea.Program parameter instead of creating its own. Marker fetch goroutines send MarkerDoneMsg directly to the parent program. The syncModel renders marker sub-items indented under the markers phase.
File Changes
New files
internal/ui/sync_progress.go—syncModel, message types,View(),RunSyncProgress()entry point
Modified files
cmd/sync.go— refactorrunSyncinto setup + Bubbletea launch +syncWorker; updaterunMarkerExpansionto accept*tea.Program; add TTY detection gating Bubbletea vs plain text fallbackinternal/ui/progress.go— removeRunMarkerExpansion(program logic moves tosync_progress.go); keepMarkerDoneMsgand marker item types for reuse
No new dependencies. Progress bar and spinner are hand-rendered with lipgloss v1 strings.
Unchanged
internal/sync/*— engine, fetcher, marker, state all untouchedinternal/theme/fiori.go— consumed, not modified- All
run*Fetchfunction internals — same behavior, just no longer print to stdout
Edge Cases
- Non-TTY (CI, pipe,
inject --sync): Detected viaterm.IsTerminal(int(os.Stdout.Fd())). Falls back to plainfmt.Fprintln(out, ...)lines per phase — no Bubbletea, no spinner. This matches the existing pattern incmd/inject.go:374. --categoryfilter: Phase list shows only the targeted phase(s). Progress bar fills 0→100% for the subset.- "Up to date" fast path: If nothing is stale, skip Bubbletea entirely, print existing i18n message directly.
- Fatal error (archive fetch fails):
SyncDoneMsg{FatalErr: err}quits the program, error propagates to caller. - Non-fatal phase errors: Rendered as
✗ failedwith warning text; sync continues.
Scope Boundary
Explicitly NOT included:
- Per-phase elapsed time display
- Parallel phase execution (phases stay sequential)
- New external dependencies (no
bubbles— progress bar and spinner are hand-rendered) - Changes to
inject --syncflow (it callsrunSync, gets the new UI automatically when TTY; plain text fallback otherwise)