# Display rolling-window throughput while a side is actively transferring

The displayed rate was averaged over the side's whole active window (stats start → last byte), which dilutes the streaming portion with the auth + ref-listing setup time before bytes start flowing. For a sync that spends ~3s on setup then streams a pack at 44 MB/s for 10s, the headline reads ~33 MB/s and only slowly drifts upward — much lower than what the user's network monitor reports.

Take a sample on every render tick and keep the last sampleCapacity (10 → ~2s at the default 200ms interval) per side. Compute the displayed rate from the oldest-vs-newest delta within that window, so the number tracks recent wire throughput instead of a session-wide average. Once a side goes idle (no bytes for >idleThreshold) or on the final frame, fall back to the active-window average — a stable post-transfer headline that won't jump around as the ring drains.

The end-of-run "throughput:" line still uses the active-window average (formatSide called with instantRate=0), which is the right summary for a completed sync.

## Sessions

## Changes

3

- internal/syncer

- Mprogress.go+106/-17

- Mprogress_test.go+97/-3

- Msyncer.go+4/-1

// cursor-up + erase-to-end-of-screen to overwrite the whole region in
// place, so '\r'-terminated sideband updates from go-git read as a
// single updating row instead of scrolling line by line.
//
// On every render we also push the current per-side byte total into a
// short ring buffer (samples) so the displayed rate reflects recent
// throughput rather than a session-wide average — the latter
// undercounts the actual transfer rate because the divisor includes
// auth and ref-listing time when no pack data is flowing.
type progressReporter struct {
    out      io.Writer
    stats    *statsCollector
    done     chan struct{}  
    mu        sync.Mutex
    rowsDrawn int    // rows currently occupying the live region
    lastLine  string // last progress line, kept so setTransient can redraw without re-sampling
    transient string // current sideband-progress line, "" when none
    samples   map[string]*sampleRing // per-side sliding window of recent (time, bytes) snapshots
}

func newProgressReporter(out io.Writer, stats *statsCollector, interval time.Duration) *progressReporter {
    return &progressReporter{
        out:      out,
        stats:    stats,
        done:     make(chan struct{}),
        samples:  map[string]*sampleRing{},
    }
}

// formatSide renders a single side as host + bytes + rate, with a flow
// arrow positioned to indicate direction: source on the left of its
// counter, target on the right of its counter. Sides with neither label
// fall back to "name: bytes @ rate".
func formatSide(side SideBytes, fallbackDur time.Duration, instantBytesPerSec float64, forceDone bool) string {
    name := side.Display
    if name == "" {
        name = side.Label
    }
    name = truncateHost(name, maxHostnameWidth)

rateDur := fallbackDur
    if side.ActiveNanos > 0 {
        rateDur = time.Duration(side.ActiveNanos)
    }
    rate := formatBytes(side.Bytes) + " @ " + formatRate(side.Bytes, rateDur)

done := side.Bytes > 0 && (forceDone || time.Duration(side.IdleNanos) >= idleThreshold)

var rateText string
    if !done && instantBytesPerSec > 0 {
        rateText = formatBytes(int64(instantBytesPerSec)) + "/s"
    } else {
        rateDur := fallbackDur
        if side.ActiveNanos > 0 {
            rateDur = time.Duration(side.ActiveNanos)
        }
        rateText = formatRate(side.Bytes, rateDur)
    }

rate := formatBytes(side.Bytes) + " @ " + rateText
    if done {
        rate += doneMark
    }

return rate
}
