From 667ff90dd6be20bf19d5424a80d57559d38352f5 Mon Sep 17 00:00:00 2001 From: Ytzhak Date: Mon, 18 Aug 2025 06:55:01 -0400 Subject: [PATCH] feat: add shimmer text rendering (#2027) --- packages/tui/internal/app/app.go | 19 +++ .../tui/internal/components/chat/editor.go | 15 +- .../tui/internal/components/chat/message.go | 12 +- .../tui/internal/components/chat/messages.go | 18 +++ packages/tui/internal/util/shimmer.go | 143 ++++++++++++++++++ 5 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 packages/tui/internal/util/shimmer.go diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index f046daae..af8157ad 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -650,6 +650,25 @@ func (a *App) IsBusy() bool { return false } +func (a *App) HasAnimatingWork() bool { + for _, msg := range a.Messages { + switch casted := msg.Info.(type) { + case opencode.AssistantMessage: + if casted.Time.Completed == 0 { + return true + } + } + for _, p := range msg.Parts { + if tp, ok := p.(opencode.ToolPart); ok { + if tp.State.Status == opencode.ToolPartStateStatusPending { + return true + } + } + } + } + return false +} + func (a *App) SaveState() tea.Cmd { return func() tea.Msg { err := SaveState(a.StatePath, a.State) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 38a57905..72daf288 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -339,6 +339,7 @@ func (m *editorComponent) Content() string { t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render + promptStyle := styles.NewStyle().Foreground(t.Primary()). Padding(0, 0, 0, 1). Bold(true) @@ -381,9 +382,11 @@ func (m *editorComponent) Content() string { status = "waiting for permission" } if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" { - hint = muted( - status, - ) + m.spinner.View() + muted( + bright := t.Accent() + if status == "waiting for permission" { + bright = t.Warning() + } + hint = util.Shimmer(status, t.Background(), t.TextMuted(), bright) + m.spinner.View() + muted( " ", ) + base( keyText+" again", @@ -391,7 +394,11 @@ func (m *editorComponent) Content() string { " interrupt", ) } else { - hint = muted(status) + m.spinner.View() + bright := t.Accent() + if status == "waiting for permission" { + bright = t.Warning() + } + hint = util.Shimmer(status, t.Background(), t.TextMuted(), bright) + m.spinner.View() if m.app.CurrentPermission.ID == "" { hint += muted(" ") + base(keyText) + muted(" interrupt") } diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index eecfe261..cb166246 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -234,7 +234,13 @@ func renderText( } content = util.ToMarkdown(text, width, backgroundColor) if isThinking { - content = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking") + "\n\n" + content + label := util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent()) + label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label) + content = label + "\n\n" + content + } else if strings.TrimSpace(text) == "Generating..." { + label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text()) + label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label) + content = label } case opencode.UserMessage: ts = time.UnixMilli(int64(casted.Time.Created)) @@ -779,7 +785,9 @@ func renderToolTitle( ) string { if toolCall.State.Status == opencode.ToolPartStateStatusPending { title := renderToolAction(toolCall.Tool) - return styles.NewStyle().Width(width - 6).Render(title) + t := theme.CurrentTheme() + shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent()) + return styles.NewStyle().Width(width - 6).Render(shiny) } toolArgs := "" diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 37525466..f63de16a 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -8,6 +8,7 @@ import ( "sort" "strconv" "strings" + "time" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" @@ -59,6 +60,7 @@ type messagesComponent struct { lineCount int selection *selection messagePositions map[string]int // map message ID to line position + animating bool } type selection struct { @@ -99,6 +101,7 @@ func (s selection) coords(offset int) *selection { type ToggleToolDetailsMsg struct{} type ToggleThinkingBlocksMsg struct{} +type shimmerTickMsg struct{} func (m *messagesComponent) Init() tea.Cmd { return tea.Batch(m.viewport.Init()) @@ -107,6 +110,15 @@ func (m *messagesComponent) Init() tea.Cmd { func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case shimmerTickMsg: + if !m.app.HasAnimatingWork() { + m.animating = false + return m, nil + } + return m, tea.Sequence( + m.renderView(), + tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }), + ) case tea.MouseClickMsg: slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset) y := msg.Y + m.viewport.YOffset @@ -270,6 +282,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.dirty { cmds = append(cmds, m.renderView()) } + + // Start shimmer ticks if any assistant/tool is in-flight + if !m.animating && m.app.HasAnimatingWork() { + m.animating = true + cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} })) + } } m.tail = m.viewport.AtBottom() diff --git a/packages/tui/internal/util/shimmer.go b/packages/tui/internal/util/shimmer.go new file mode 100644 index 00000000..88654ff0 --- /dev/null +++ b/packages/tui/internal/util/shimmer.go @@ -0,0 +1,143 @@ +package util + +import ( + "math" + "os" + "strings" + "time" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/compat" + "github.com/sst/opencode/internal/styles" +) + +var shimmerStart = time.Now() + +// Shimmer renders text with a moving foreground highlight. +// bg is the background color, dim is the base text color, bright is the highlight color. +func Shimmer(s string, bg compat.AdaptiveColor, _ compat.AdaptiveColor, _ compat.AdaptiveColor) string { + if s == "" { + return "" + } + + runes := []rune(s) + n := len(runes) + if n == 0 { + return s + } + + pad := 10 + period := float64(n + pad*2) + sweep := 2.5 + elapsed := time.Since(shimmerStart).Seconds() + pos := (math.Mod(elapsed, sweep) / sweep) * period + + half := 4.0 + + type seg struct { + useHex bool + hex string + bold bool + faint bool + text string + } + var segs []seg + + useHex := hasTrueColor() + for i, r := range runes { + ip := float64(i + pad) + dist := math.Abs(ip - pos) + t := 0.0 + if dist <= half { + x := math.Pi * (dist / half) + t = 0.5 * (1.0 + math.Cos(x)) + } + // Cosine brightness: base + amp*t (quantized for grouping) + base := 0.55 + amp := 0.45 + brightness := base + if t > 0 { + brightness = base + amp*t + } + lvl := int(math.Round(brightness * 255.0)) + if !useHex { + step := 24 // ~11 steps across range for non-truecolor + lvl = int(math.Round(float64(lvl)/float64(step))) * step + } + + bold := lvl >= 208 + faint := lvl <= 128 + + // truecolor if possible; else fallback to modifiers only + hex := "" + if useHex { + if lvl < 0 { + lvl = 0 + } + if lvl > 255 { + lvl = 255 + } + hex = rgbHex(lvl, lvl, lvl) + } + + if len(segs) == 0 { + segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)}) + } else { + last := &segs[len(segs)-1] + if last.useHex == useHex && last.hex == hex && last.bold == bold && last.faint == faint { + last.text += string(r) + } else { + segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)}) + } + } + } + + var b strings.Builder + for _, g := range segs { + st := styles.NewStyle().Background(bg) + if g.useHex && g.hex != "" { + c := compat.AdaptiveColor{Dark: lipgloss.Color(g.hex), Light: lipgloss.Color(g.hex)} + st = st.Foreground(c) + } + if g.bold { + st = st.Bold(true) + } + if g.faint { + st = st.Faint(true) + } + b.WriteString(st.Render(g.text)) + } + return b.String() +} + +func hasTrueColor() bool { + c := strings.ToLower(os.Getenv("COLORTERM")) + return strings.Contains(c, "truecolor") || strings.Contains(c, "24bit") +} + +func rgbHex(r, g, b int) string { + if r < 0 { + r = 0 + } + if r > 255 { + r = 255 + } + if g < 0 { + g = 0 + } + if g > 255 { + g = 255 + } + if b < 0 { + b = 0 + } + if b > 255 { + b = 255 + } + return "#" + hex2(r) + hex2(g) + hex2(b) +} + +func hex2(v int) string { + const digits = "0123456789abcdef" + return string([]byte{digits[(v>>4)&0xF], digits[v&0xF]}) +}