feat: add shimmer text rendering (#2027)

This commit is contained in:
Ytzhak 2025-08-18 06:55:01 -04:00 committed by GitHub
parent cd3d91209a
commit 667ff90dd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 201 additions and 6 deletions

View file

@ -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)

View file

@ -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")
}

View file

@ -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 := ""

View file

@ -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()

View file

@ -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]})
}