mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
3cfb6437f9
- Buffer session JSONL writes with bufio.Writer, flush at sync points; ForkToNewSession and AddLLMMessages now batch N entries into ~1 syscall - Cache lipgloss styles in style.CachedStyles, lazily built and invalidated on SetTheme; eliminates ~15 NewStyle() calls per frame in hot render paths (reasoning blocks, spinner, tool headers, margins) - Cache git ls-files results for @file suggestions with 3s TTL; typing @filename no longer spawns 3 subprocesses per keystroke - Use strings.Builder for StreamingMessageItem.content; eliminates O(n²) string copying during LLM response streaming
600 lines
19 KiB
Go
600 lines
19 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/indaco/herald"
|
|
|
|
"github.com/mark3labs/kit/internal/app"
|
|
"github.com/mark3labs/kit/internal/ui/style"
|
|
)
|
|
|
|
// knightRiderFrames generates a KITT-style scanning animation where a bright
|
|
// light bounces back and forth across a row of dots with a trailing glow.
|
|
// Colors are derived from the active theme. Used by StreamComponent (TUI
|
|
// inline spinner) and Spinner (stderr goroutine spinner).
|
|
func knightRiderFrames() []string {
|
|
const numDots = 8
|
|
const dot = "▪"
|
|
|
|
cs := style.GetCachedStyles()
|
|
bright := cs.SpinnerBright
|
|
med := cs.SpinnerMed
|
|
dim := cs.SpinnerDim
|
|
off := cs.SpinnerOff
|
|
|
|
// Scanner bounces: 0→7→0
|
|
positions := make([]int, 0, 2*numDots-2)
|
|
for i := range numDots {
|
|
positions = append(positions, i)
|
|
}
|
|
for i := numDots - 2; i > 0; i-- {
|
|
positions = append(positions, i)
|
|
}
|
|
|
|
frames := make([]string, len(positions))
|
|
for f, pos := range positions {
|
|
var b strings.Builder
|
|
for i := range numDots {
|
|
d := pos - i
|
|
if d < 0 {
|
|
d = -d
|
|
}
|
|
switch d {
|
|
case 0:
|
|
b.WriteString(bright.Render(dot))
|
|
case 1:
|
|
b.WriteString(med.Render(dot))
|
|
case 2:
|
|
b.WriteString(dim.Render(dot))
|
|
default:
|
|
b.WriteString(off.Render(dot))
|
|
}
|
|
}
|
|
frames[f] = b.String()
|
|
}
|
|
return frames
|
|
}
|
|
|
|
// streamSpinnerTickMsg is the internal tick message that drives the KITT-style
|
|
// spinner animation inside StreamComponent. The generation field ties each tick
|
|
// to the spinner session that created it so that stale ticks from a previous
|
|
// start/stop cycle are silently discarded instead of creating a second
|
|
// concurrent tick loop (which doubles the animation speed).
|
|
type streamSpinnerTickMsg struct {
|
|
generation uint64
|
|
}
|
|
|
|
// streamSpinnerTickCmd returns a tea.Cmd that fires streamSpinnerTickMsg at the
|
|
// KITT animation frame rate (14 fps). The generation parameter is embedded in
|
|
// the message so the receiver can verify it matches the current spinner session.
|
|
func streamSpinnerTickCmd(generation uint64) tea.Cmd {
|
|
return tea.Tick(time.Second/14, func(_ time.Time) tea.Msg {
|
|
return streamSpinnerTickMsg{generation: generation}
|
|
})
|
|
}
|
|
|
|
// streamFlushTickMsg fires when it's time to commit pending chunks to the
|
|
// main content builders and trigger a re-render. This coalesces rapid
|
|
// streaming chunks into fewer expensive markdown re-renders.
|
|
//
|
|
// generation ties the tick to the pending flush session that created it so
|
|
// stale ticks from a prior Reset() are discarded.
|
|
type streamFlushTickMsg struct {
|
|
generation uint64
|
|
}
|
|
|
|
// streamFlushInterval is the coalescing window for stream chunks. Chunks
|
|
// arriving within this window are batched into a single render pass.
|
|
// 16ms ≈ 60 fps — fast enough to appear smooth, slow enough to coalesce
|
|
// bursts from the LLM provider.
|
|
const streamFlushInterval = 16 * time.Millisecond
|
|
|
|
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
|
|
// the coalescing interval.
|
|
func streamFlushTickCmd(generation uint64) tea.Cmd {
|
|
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
|
|
return streamFlushTickMsg{generation: generation}
|
|
})
|
|
}
|
|
|
|
// streamPhase tracks what the StreamComponent is currently displaying.
|
|
type streamPhase int
|
|
|
|
const (
|
|
// streamPhaseIdle is the initial state — nothing to display.
|
|
streamPhaseIdle streamPhase = iota
|
|
|
|
// streamPhaseActive means content is being displayed (streaming text
|
|
// and/or spinner animation).
|
|
streamPhaseActive
|
|
)
|
|
|
|
// StreamComponent is the Bubble Tea child model responsible for the stream
|
|
// region: it renders a KITT-style spinner when the agent is working, and
|
|
// displays live text as StreamChunkEvents arrive. The spinner remains visible
|
|
// alongside streaming text until the step completes and Reset() is called.
|
|
//
|
|
// Tool calls, tool results, user messages, and other non-streaming content
|
|
// are added to the ScrollList by the parent AppModel. The StreamComponent
|
|
// only handles the live streaming text and spinner display.
|
|
//
|
|
// Lifecycle is managed entirely by the parent AppModel:
|
|
// - Parent calls Reset() between agent steps to clear state.
|
|
// - Content is displayed via StreamingMessageItem in the ScrollList.
|
|
// - StreamComponent never calls tea.Quit.
|
|
//
|
|
// Events handled:
|
|
// - app.SpinnerEvent{Show:true} → start spinner tick loop
|
|
// - app.StreamChunkEvent → append text
|
|
// - app.ToolExecutionEvent → show execution label on spinner
|
|
type StreamComponent struct {
|
|
// phase tracks whether the component is idle or active.
|
|
phase streamPhase
|
|
|
|
// spinning is true while the KITT animation tick loop is running.
|
|
// It is orthogonal to whether streaming text is present: the spinner
|
|
// remains visible alongside streaming text until Reset().
|
|
spinning bool
|
|
|
|
// spinnerGeneration is incremented each time a new spinner tick loop
|
|
// is started. Tick messages carry the generation they were created for;
|
|
// if a tick's generation doesn't match the current one, it is a stale
|
|
// tick from a previous start/stop cycle and is silently discarded.
|
|
// This prevents multiple concurrent tick loops from accumulating when
|
|
// the spinner is rapidly stopped and restarted (e.g. SpinnerEvent
|
|
// hide → ToolExecutionEvent start before the old tick fires).
|
|
spinnerGeneration uint64
|
|
|
|
// spinnerFrames are the pre-rendered KITT animation frames.
|
|
spinnerFrames []string
|
|
|
|
// spinnerFrame is the current frame index.
|
|
spinnerFrame int
|
|
|
|
// activeTools maps ToolCallID -> display label for currently running tools.
|
|
activeTools map[string]string
|
|
|
|
// activeToolOrder preserves deterministic display order for active tools.
|
|
activeToolOrder []string
|
|
|
|
// streamContent holds committed streaming text (flushed from pending).
|
|
streamContent strings.Builder
|
|
|
|
// reasoningContent holds committed reasoning text (flushed from pending).
|
|
reasoningContent strings.Builder
|
|
|
|
// pendingStream accumulates streaming text chunks between flush ticks.
|
|
// Chunks are written here immediately on arrival, then moved to
|
|
// streamContent when the flush tick fires.
|
|
pendingStream strings.Builder
|
|
|
|
// pendingReasoning accumulates reasoning chunks between flush ticks.
|
|
pendingReasoning strings.Builder
|
|
|
|
// flushPending is true while a flush tick is in-flight. Prevents
|
|
// scheduling duplicate ticks when multiple chunks arrive within
|
|
// the same coalescing window.
|
|
flushPending bool
|
|
|
|
// flushGeneration is incremented when stream state resets so stale flush
|
|
// ticks from a previous step can be discarded.
|
|
flushGeneration uint64
|
|
|
|
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
|
|
thinkingVisible bool
|
|
|
|
// reasoningStartTime records when the first reasoning chunk was received.
|
|
reasoningStartTime time.Time
|
|
|
|
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
|
|
reasoningDuration time.Duration
|
|
|
|
// renderer renders streaming assistant text.
|
|
renderer Renderer
|
|
|
|
// modelName is displayed in the streaming text header.
|
|
modelName string
|
|
|
|
// timestamp records when the current step started (used for message headers).
|
|
timestamp time.Time
|
|
|
|
// width is the current terminal column count.
|
|
width int
|
|
|
|
// height constrains the render output to at most this many lines.
|
|
// 0 means unconstrained.
|
|
height int
|
|
|
|
// ty provides typography functions for rendering text.
|
|
ty *herald.Typography
|
|
}
|
|
|
|
// NewStreamComponent creates a new StreamComponent ready to be embedded in AppModel.
|
|
func NewStreamComponent(width int, modelName string) *StreamComponent {
|
|
if width == 0 {
|
|
width = 80
|
|
}
|
|
|
|
renderer := newMessageRenderer(width, false)
|
|
|
|
return &StreamComponent{
|
|
spinnerFrames: knightRiderFrames(),
|
|
modelName: modelName,
|
|
renderer: renderer,
|
|
width: width,
|
|
ty: createTypography(GetTheme()),
|
|
}
|
|
}
|
|
|
|
// SetHeight constrains the stream region render height. When height > 0, the
|
|
// render output is clamped to that many lines (trailing lines are discarded).
|
|
// A value of 0 means unconstrained.
|
|
func (s *StreamComponent) SetHeight(h int) {
|
|
if h < 0 {
|
|
h = 0
|
|
}
|
|
if s.height != h {
|
|
s.height = h
|
|
}
|
|
}
|
|
|
|
// Reset clears all accumulated state so the component is ready for the next
|
|
// agent step. Called by AppModel after a step completes or errors.
|
|
func (s *StreamComponent) Reset() {
|
|
s.phase = streamPhaseIdle
|
|
s.spinning = false
|
|
s.spinnerGeneration++ // invalidate any in-flight tick commands
|
|
s.spinnerFrame = 0
|
|
s.activeTools = nil
|
|
s.activeToolOrder = nil
|
|
s.streamContent.Reset()
|
|
s.reasoningContent.Reset()
|
|
s.pendingStream.Reset()
|
|
s.pendingReasoning.Reset()
|
|
s.flushPending = false
|
|
s.flushGeneration++
|
|
s.timestamp = time.Time{}
|
|
s.reasoningStartTime = time.Time{}
|
|
s.reasoningDuration = 0
|
|
}
|
|
|
|
// ConsumeOverflow is a no-op in alt screen mode. Overflow is handled by the
|
|
// ScrollList viewport. Retained to satisfy streamComponentIface.
|
|
func (s *StreamComponent) ConsumeOverflow() string {
|
|
return ""
|
|
}
|
|
|
|
// GetRenderedContent returns the rendered assistant message from the accumulated
|
|
// streaming text. Returns empty string if no text has been accumulated. Used by
|
|
// the parent AppModel to flush stream content before resetting.
|
|
//
|
|
// This commits any pending chunks first so the output includes all received
|
|
// content, not just what has been flushed by the tick.
|
|
func (s *StreamComponent) GetRenderedContent() string {
|
|
// Commit any pending chunks so the final output is complete.
|
|
s.commitPending()
|
|
|
|
var sections []string
|
|
|
|
// Include rendered reasoning block if present.
|
|
if reasoning := s.reasoningContent.String(); reasoning != "" {
|
|
sections = append(sections, s.renderReasoningBlock(reasoning))
|
|
}
|
|
|
|
text := s.streamContent.String()
|
|
if text != "" {
|
|
rendered := s.renderStreamingText(text)
|
|
sections = append(sections, rendered)
|
|
}
|
|
|
|
if len(sections) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(sections, "\n")
|
|
}
|
|
|
|
// commitPending moves any pending chunks to the committed content builders.
|
|
// Called before reading content for output or on flush tick.
|
|
func (s *StreamComponent) commitPending() {
|
|
if s.pendingStream.Len() > 0 {
|
|
s.streamContent.WriteString(s.pendingStream.String())
|
|
s.pendingStream.Reset()
|
|
}
|
|
if s.pendingReasoning.Len() > 0 {
|
|
s.reasoningContent.WriteString(s.pendingReasoning.String())
|
|
s.pendingReasoning.Reset()
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// tea.Model interface
|
|
// --------------------------------------------------------------------------
|
|
|
|
// Init implements tea.Model. No startup command needed; SpinnerEvent drives
|
|
// the ticker.
|
|
func (s *StreamComponent) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update implements tea.Model. Routes app-layer events and internal tick msgs.
|
|
func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
|
|
case tea.WindowSizeMsg:
|
|
s.width = msg.Width
|
|
if s.renderer != nil {
|
|
s.renderer.SetWidth(s.width)
|
|
}
|
|
|
|
case streamSpinnerTickMsg:
|
|
// Only continue the tick loop if this tick belongs to the current
|
|
// spinner session. Stale ticks from a previous start/stop cycle
|
|
// are silently dropped, preventing duplicate concurrent tick loops
|
|
// that would double (or worse) the animation speed.
|
|
if s.spinning && msg.generation == s.spinnerGeneration {
|
|
s.spinnerFrame++
|
|
return s, streamSpinnerTickCmd(s.spinnerGeneration)
|
|
}
|
|
// Spinning stopped or generation mismatch; let the tick loop die.
|
|
|
|
// ── App-layer events ──────────────────────────────────────────────────
|
|
|
|
case app.SpinnerEvent:
|
|
if msg.Show && !s.spinning {
|
|
s.phase = streamPhaseActive
|
|
s.spinning = true
|
|
s.spinnerGeneration++ // new session; invalidate any stale ticks
|
|
s.spinnerFrame = 0
|
|
if s.timestamp.IsZero() {
|
|
s.timestamp = time.Now()
|
|
}
|
|
return s, streamSpinnerTickCmd(s.spinnerGeneration)
|
|
} else if !msg.Show && s.spinning {
|
|
s.spinning = false
|
|
// Bump generation so any in-flight tick from this session is
|
|
// discarded if spinning is restarted before it fires.
|
|
s.spinnerGeneration++
|
|
}
|
|
|
|
case streamFlushTickMsg:
|
|
if msg.generation != s.flushGeneration {
|
|
break
|
|
}
|
|
s.flushPending = false
|
|
s.commitPending()
|
|
|
|
case app.ReasoningChunkEvent:
|
|
s.phase = streamPhaseActive
|
|
if s.timestamp.IsZero() {
|
|
s.timestamp = time.Now()
|
|
}
|
|
if s.reasoningStartTime.IsZero() {
|
|
s.reasoningStartTime = time.Now()
|
|
}
|
|
s.pendingReasoning.WriteString(msg.Delta)
|
|
if !s.flushPending {
|
|
s.flushPending = true
|
|
return s, streamFlushTickCmd(s.flushGeneration)
|
|
}
|
|
|
|
case app.ReasoningCompleteEvent:
|
|
// Freeze reasoning duration when reasoning finishes (before text streaming starts).
|
|
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
|
|
s.reasoningDuration = time.Since(s.reasoningStartTime)
|
|
}
|
|
// Flush any remaining pending reasoning content.
|
|
if s.pendingReasoning.Len() > 0 {
|
|
s.reasoningContent.WriteString(s.pendingReasoning.String())
|
|
s.pendingReasoning.Reset()
|
|
}
|
|
|
|
case app.StreamChunkEvent:
|
|
s.phase = streamPhaseActive
|
|
if s.timestamp.IsZero() {
|
|
s.timestamp = time.Now()
|
|
}
|
|
// Freeze reasoning duration on transition from reasoning to streaming.
|
|
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
|
|
s.reasoningDuration = time.Since(s.reasoningStartTime)
|
|
}
|
|
|
|
// <think> tag filtering is handled at the agent layer — chunks here
|
|
// are already clean text.
|
|
s.pendingStream.WriteString(msg.Content)
|
|
|
|
if !s.flushPending && s.pendingStream.Len() > 0 {
|
|
s.flushPending = true
|
|
return s, streamFlushTickCmd(s.flushGeneration)
|
|
}
|
|
|
|
case app.ToolExecutionEvent:
|
|
toolID := msg.ToolCallID
|
|
if toolID == "" {
|
|
// Defensive fallback for older/third-party emitters that may omit
|
|
// ToolCallID. Best-effort only: same-name+args concurrent calls can
|
|
// still collide without a stable ID.
|
|
toolID = fmt.Sprintf("%s|%s", msg.ToolName, msg.ToolArgs)
|
|
}
|
|
if msg.IsStarting {
|
|
if s.activeTools == nil {
|
|
s.activeTools = make(map[string]string)
|
|
}
|
|
if _, exists := s.activeTools[toolID]; !exists {
|
|
s.activeToolOrder = append(s.activeToolOrder, toolID)
|
|
}
|
|
s.activeTools[toolID] = formatToolExecutionMessage(msg.ToolName)
|
|
s.spinnerFrame = 0
|
|
if !s.spinning {
|
|
s.phase = streamPhaseActive
|
|
s.spinning = true
|
|
s.spinnerGeneration++ // new session; invalidate stale ticks
|
|
return s, streamSpinnerTickCmd(s.spinnerGeneration)
|
|
}
|
|
} else {
|
|
if s.activeTools != nil {
|
|
delete(s.activeTools, toolID)
|
|
}
|
|
s.activeToolOrder = removeToolID(s.activeToolOrder, toolID)
|
|
}
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// View implements tea.Model. Returns an empty view since rendering is handled
|
|
// by StreamingMessageItem in the ScrollList. Retained to satisfy tea.Model.
|
|
func (s *StreamComponent) View() tea.View {
|
|
return tea.NewView("")
|
|
}
|
|
|
|
// renderReasoningBlock renders the reasoning/thinking content using blockquote.
|
|
// When collapsed, shows the last 10 lines with a truncation hint. When
|
|
// expanded, shows all lines. Includes a "Thought for Xs" duration footer.
|
|
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
|
|
lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n")
|
|
|
|
var parts []string
|
|
|
|
// When collapsed and content exceeds 10 lines, show only the last 10
|
|
// with a truncation hint.
|
|
const maxCollapsedLines = 10
|
|
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
|
|
hidden := len(lines) - maxCollapsedLines
|
|
parts = append(parts, s.ty.Italic(fmt.Sprintf("... (%d lines hidden)", hidden)))
|
|
lines = lines[len(lines)-maxCollapsedLines:]
|
|
}
|
|
|
|
// Main content using Italic with Muted color for visual distinction.
|
|
content := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
|
|
// Soft-wrap to the available width so long lines don't get cut off.
|
|
if s.width > 4 {
|
|
content = lipgloss.NewStyle().Width(s.width - 4).Render(content)
|
|
}
|
|
cs := style.GetCachedStyles()
|
|
parts = append(parts, cs.Muted.Render(s.ty.Italic(content)))
|
|
|
|
// Duration footer with VeryMuted label and Accent duration.
|
|
var duration time.Duration
|
|
if s.reasoningDuration > 0 {
|
|
duration = s.reasoningDuration
|
|
} else if !s.reasoningStartTime.IsZero() {
|
|
duration = time.Since(s.reasoningStartTime)
|
|
}
|
|
if duration > 0 {
|
|
var durationStr string
|
|
if duration < time.Second {
|
|
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
|
|
} else {
|
|
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
|
|
}
|
|
label := cs.VeryMuted.Render("Thought for ")
|
|
durationStyled := cs.Accent.Render(durationStr)
|
|
parts = append(parts, label+durationStyled)
|
|
}
|
|
|
|
// Concatenate parts with newline between blockquote and footer
|
|
var result string
|
|
if len(parts) == 1 {
|
|
result = parts[0]
|
|
} else if len(parts) == 2 {
|
|
result = parts[0] + "\n" + parts[1]
|
|
} else {
|
|
result = strings.Join(parts, "\n")
|
|
}
|
|
return styleMarginBottom1.Render(result)
|
|
}
|
|
|
|
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
|
|
func (s *StreamComponent) SetThinkingVisible(visible bool) {
|
|
if s.thinkingVisible != visible {
|
|
s.thinkingVisible = visible
|
|
}
|
|
}
|
|
|
|
// HasReasoning returns true if any reasoning content has been accumulated
|
|
// (committed or pending).
|
|
func (s *StreamComponent) HasReasoning() bool {
|
|
return s.reasoningContent.Len() > 0 || s.pendingReasoning.Len() > 0
|
|
}
|
|
|
|
// SpinnerView returns the rendered spinner line for the parent to embed in the
|
|
// status bar. Returns "" when the spinner is not active.
|
|
func (s *StreamComponent) SpinnerView() string {
|
|
if !s.spinning {
|
|
return ""
|
|
}
|
|
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
|
|
tools := s.activeToolDisplays()
|
|
if len(tools) == 0 {
|
|
return " " + frame
|
|
}
|
|
theme := GetTheme()
|
|
msgStyle := lipgloss.NewStyle().
|
|
Foreground(theme.Text).
|
|
Italic(true)
|
|
|
|
// Format active tools list
|
|
var toolsMsg string
|
|
if len(tools) == 1 {
|
|
toolsMsg = tools[0]
|
|
} else {
|
|
toolsMsg = "Running: " + strings.Join(tools, ", ")
|
|
}
|
|
return " " + frame + " " + msgStyle.Render(toolsMsg)
|
|
}
|
|
|
|
// renderStreamingText renders the accumulated streaming text as a live assistant
|
|
// message using the configured renderer.
|
|
func (s *StreamComponent) renderStreamingText(text string) string {
|
|
ts := s.timestamp
|
|
if ts.IsZero() {
|
|
ts = time.Now()
|
|
}
|
|
if s.renderer == nil {
|
|
return text
|
|
}
|
|
msg := s.renderer.RenderAssistantMessage(text, ts, s.modelName)
|
|
return msg.Content
|
|
}
|
|
|
|
func (s *StreamComponent) activeToolDisplays() []string {
|
|
if len(s.activeTools) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(s.activeToolOrder))
|
|
for _, id := range s.activeToolOrder {
|
|
if display, ok := s.activeTools[id]; ok {
|
|
out = append(out, display)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// removeToolID removes the first occurrence of a tool ID from a slice.
|
|
func removeToolID(ids []string, id string) []string {
|
|
for i, v := range ids {
|
|
if v == id {
|
|
return append(ids[:i], ids[i+1:]...)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
|
|
func formatToolExecutionMessage(toolName string) string {
|
|
return toolName
|
|
}
|
|
|
|
// UpdateTheme refreshes the component's typography instance and spinner
|
|
// animation frames with colors from the current theme. This is called when
|
|
// the user changes themes via /theme.
|
|
func (s *StreamComponent) UpdateTheme() {
|
|
s.ty = createTypography(GetTheme())
|
|
s.spinnerFrames = knightRiderFrames()
|
|
}
|