package modes import ( "math/rand" "time" "github.com/patriceckhart/zot/packages/tui" ) // spinner drives the busy animation shown in the status bar while a // turn is streaming. It rotates through a list of playful status // messages and a small frame animation. type spinner struct { frames []string messages []string interval time.Duration startedAt time.Time msgIdx int // fixedMsg overrides the rotating funnyWorkingLines message when // set. Used for auto-compaction so the spinner clearly says what's // happening instead of cycling jokes. fixedMsg string } // newSpinner constructs a fresh spinner. func newSpinner(th tui.Theme) *spinner { s := &spinner{} s.Configure(th) return s } func (s *spinner) Configure(th tui.Theme) { s.frames = append([]string(nil), th.SpinnerFrames...) if len(s.frames) == 0 { s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"} } s.messages = append([]string(nil), th.SpinnerMessages...) if len(s.messages) == 0 { s.messages = []string{"thinking"} } interval := th.SpinnerIntervalMS if interval <= 0 { interval = 80 } s.interval = time.Duration(interval) * time.Millisecond if s.msgIdx >= len(s.messages) { s.msgIdx = 0 } } // Start resets the spinner to the beginning of its animation and // picks a random message that stays fixed for the whole run. A // rotating rollodex of quips during a single turn felt noisy in // practice — you'd see five different phrases for one // long-running response, which implies progress that isn't // actually happening. One stable phrase per turn reads calmer // and the variety across turns (next Start picks another index) // still keeps the set fresh over a session. func (s *spinner) Start() { s.startedAt = time.Now() if len(s.messages) == 0 { s.messages = []string{"thinking"} } s.msgIdx = rand.Intn(len(s.messages)) s.fixedMsg = "" } // StartFixed is like Start but pins the status text to msg for the // duration of this spinner run. Cleared by the next Start() call. func (s *spinner) StartFixed(msg string) { s.startedAt = time.Now() s.fixedMsg = msg } // Frame returns the current spinner glyph for the running animation. func (s *spinner) Frame() string { if len(s.frames) == 0 { return "" } if s.startedAt.IsZero() { return s.frames[0] } interval := s.interval if interval <= 0 { interval = 80 * time.Millisecond } elapsed := time.Since(s.startedAt) idx := int(elapsed/interval) % len(s.frames) return s.frames[idx] } // Message returns the spinner's status text. One random phrase // per Start call, pinned until the next turn. When the spinner // was started via StartFixed, the pinned message is returned // unchanged. func (s *spinner) Message() string { if s.fixedMsg != "" { return s.fixedMsg } if len(s.messages) == 0 { return "thinking" } if s.msgIdx < 0 || s.msgIdx >= len(s.messages) { s.msgIdx = 0 } return s.messages[s.msgIdx] } // Elapsed returns the wall-clock duration the spinner has been running. func (s *spinner) Elapsed() time.Duration { if s.startedAt.IsZero() { return 0 } return time.Since(s.startedAt).Round(time.Second) }