Skip to content

Commit 667ff90

Browse files
authored
feat: add shimmer text rendering (sst#2027)
1 parent cd3d912 commit 667ff90

File tree

5 files changed

+201
-6
lines changed

5 files changed

+201
-6
lines changed

packages/tui/internal/app/app.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,25 @@ func (a *App) IsBusy() bool {
650650
return false
651651
}
652652

653+
func (a *App) HasAnimatingWork() bool {
654+
for _, msg := range a.Messages {
655+
switch casted := msg.Info.(type) {
656+
case opencode.AssistantMessage:
657+
if casted.Time.Completed == 0 {
658+
return true
659+
}
660+
}
661+
for _, p := range msg.Parts {
662+
if tp, ok := p.(opencode.ToolPart); ok {
663+
if tp.State.Status == opencode.ToolPartStateStatusPending {
664+
return true
665+
}
666+
}
667+
}
668+
}
669+
return false
670+
}
671+
653672
func (a *App) SaveState() tea.Cmd {
654673
return func() tea.Msg {
655674
err := SaveState(a.StatePath, a.State)

packages/tui/internal/components/chat/editor.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ func (m *editorComponent) Content() string {
339339
t := theme.CurrentTheme()
340340
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
341341
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
342+
342343
promptStyle := styles.NewStyle().Foreground(t.Primary()).
343344
Padding(0, 0, 0, 1).
344345
Bold(true)
@@ -381,17 +382,23 @@ func (m *editorComponent) Content() string {
381382
status = "waiting for permission"
382383
}
383384
if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" {
384-
hint = muted(
385-
status,
386-
) + m.spinner.View() + muted(
385+
bright := t.Accent()
386+
if status == "waiting for permission" {
387+
bright = t.Warning()
388+
}
389+
hint = util.Shimmer(status, t.Background(), t.TextMuted(), bright) + m.spinner.View() + muted(
387390
" ",
388391
) + base(
389392
keyText+" again",
390393
) + muted(
391394
" interrupt",
392395
)
393396
} else {
394-
hint = muted(status) + m.spinner.View()
397+
bright := t.Accent()
398+
if status == "waiting for permission" {
399+
bright = t.Warning()
400+
}
401+
hint = util.Shimmer(status, t.Background(), t.TextMuted(), bright) + m.spinner.View()
395402
if m.app.CurrentPermission.ID == "" {
396403
hint += muted(" ") + base(keyText) + muted(" interrupt")
397404
}

packages/tui/internal/components/chat/message.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,13 @@ func renderText(
234234
}
235235
content = util.ToMarkdown(text, width, backgroundColor)
236236
if isThinking {
237-
content = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking") + "\n\n" + content
237+
label := util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent())
238+
label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
239+
content = label + "\n\n" + content
240+
} else if strings.TrimSpace(text) == "Generating..." {
241+
label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text())
242+
label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
243+
content = label
238244
}
239245
case opencode.UserMessage:
240246
ts = time.UnixMilli(int64(casted.Time.Created))
@@ -779,7 +785,9 @@ func renderToolTitle(
779785
) string {
780786
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
781787
title := renderToolAction(toolCall.Tool)
782-
return styles.NewStyle().Width(width - 6).Render(title)
788+
t := theme.CurrentTheme()
789+
shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
790+
return styles.NewStyle().Width(width - 6).Render(shiny)
783791
}
784792

785793
toolArgs := ""

packages/tui/internal/components/chat/messages.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sort"
99
"strconv"
1010
"strings"
11+
"time"
1112

1213
tea "github.com/charmbracelet/bubbletea/v2"
1314
"github.com/charmbracelet/lipgloss/v2"
@@ -59,6 +60,7 @@ type messagesComponent struct {
5960
lineCount int
6061
selection *selection
6162
messagePositions map[string]int // map message ID to line position
63+
animating bool
6264
}
6365

6466
type selection struct {
@@ -99,6 +101,7 @@ func (s selection) coords(offset int) *selection {
99101

100102
type ToggleToolDetailsMsg struct{}
101103
type ToggleThinkingBlocksMsg struct{}
104+
type shimmerTickMsg struct{}
102105

103106
func (m *messagesComponent) Init() tea.Cmd {
104107
return tea.Batch(m.viewport.Init())
@@ -107,6 +110,15 @@ func (m *messagesComponent) Init() tea.Cmd {
107110
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108111
var cmds []tea.Cmd
109112
switch msg := msg.(type) {
113+
case shimmerTickMsg:
114+
if !m.app.HasAnimatingWork() {
115+
m.animating = false
116+
return m, nil
117+
}
118+
return m, tea.Sequence(
119+
m.renderView(),
120+
tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }),
121+
)
110122
case tea.MouseClickMsg:
111123
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
112124
y := msg.Y + m.viewport.YOffset
@@ -270,6 +282,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
270282
if m.dirty {
271283
cmds = append(cmds, m.renderView())
272284
}
285+
286+
// Start shimmer ticks if any assistant/tool is in-flight
287+
if !m.animating && m.app.HasAnimatingWork() {
288+
m.animating = true
289+
cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }))
290+
}
273291
}
274292

275293
m.tail = m.viewport.AtBottom()
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package util
2+
3+
import (
4+
"math"
5+
"os"
6+
"strings"
7+
"time"
8+
9+
"github.com/charmbracelet/lipgloss/v2"
10+
"github.com/charmbracelet/lipgloss/v2/compat"
11+
"github.com/sst/opencode/internal/styles"
12+
)
13+
14+
var shimmerStart = time.Now()
15+
16+
// Shimmer renders text with a moving foreground highlight.
17+
// bg is the background color, dim is the base text color, bright is the highlight color.
18+
func Shimmer(s string, bg compat.AdaptiveColor, _ compat.AdaptiveColor, _ compat.AdaptiveColor) string {
19+
if s == "" {
20+
return ""
21+
}
22+
23+
runes := []rune(s)
24+
n := len(runes)
25+
if n == 0 {
26+
return s
27+
}
28+
29+
pad := 10
30+
period := float64(n + pad*2)
31+
sweep := 2.5
32+
elapsed := time.Since(shimmerStart).Seconds()
33+
pos := (math.Mod(elapsed, sweep) / sweep) * period
34+
35+
half := 4.0
36+
37+
type seg struct {
38+
useHex bool
39+
hex string
40+
bold bool
41+
faint bool
42+
text string
43+
}
44+
var segs []seg
45+
46+
useHex := hasTrueColor()
47+
for i, r := range runes {
48+
ip := float64(i + pad)
49+
dist := math.Abs(ip - pos)
50+
t := 0.0
51+
if dist <= half {
52+
x := math.Pi * (dist / half)
53+
t = 0.5 * (1.0 + math.Cos(x))
54+
}
55+
// Cosine brightness: base + amp*t (quantized for grouping)
56+
base := 0.55
57+
amp := 0.45
58+
brightness := base
59+
if t > 0 {
60+
brightness = base + amp*t
61+
}
62+
lvl := int(math.Round(brightness * 255.0))
63+
if !useHex {
64+
step := 24 // ~11 steps across range for non-truecolor
65+
lvl = int(math.Round(float64(lvl)/float64(step))) * step
66+
}
67+
68+
bold := lvl >= 208
69+
faint := lvl <= 128
70+
71+
// truecolor if possible; else fallback to modifiers only
72+
hex := ""
73+
if useHex {
74+
if lvl < 0 {
75+
lvl = 0
76+
}
77+
if lvl > 255 {
78+
lvl = 255
79+
}
80+
hex = rgbHex(lvl, lvl, lvl)
81+
}
82+
83+
if len(segs) == 0 {
84+
segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)})
85+
} else {
86+
last := &segs[len(segs)-1]
87+
if last.useHex == useHex && last.hex == hex && last.bold == bold && last.faint == faint {
88+
last.text += string(r)
89+
} else {
90+
segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)})
91+
}
92+
}
93+
}
94+
95+
var b strings.Builder
96+
for _, g := range segs {
97+
st := styles.NewStyle().Background(bg)
98+
if g.useHex && g.hex != "" {
99+
c := compat.AdaptiveColor{Dark: lipgloss.Color(g.hex), Light: lipgloss.Color(g.hex)}
100+
st = st.Foreground(c)
101+
}
102+
if g.bold {
103+
st = st.Bold(true)
104+
}
105+
if g.faint {
106+
st = st.Faint(true)
107+
}
108+
b.WriteString(st.Render(g.text))
109+
}
110+
return b.String()
111+
}
112+
113+
func hasTrueColor() bool {
114+
c := strings.ToLower(os.Getenv("COLORTERM"))
115+
return strings.Contains(c, "truecolor") || strings.Contains(c, "24bit")
116+
}
117+
118+
func rgbHex(r, g, b int) string {
119+
if r < 0 {
120+
r = 0
121+
}
122+
if r > 255 {
123+
r = 255
124+
}
125+
if g < 0 {
126+
g = 0
127+
}
128+
if g > 255 {
129+
g = 255
130+
}
131+
if b < 0 {
132+
b = 0
133+
}
134+
if b > 255 {
135+
b = 255
136+
}
137+
return "#" + hex2(r) + hex2(g) + hex2(b)
138+
}
139+
140+
func hex2(v int) string {
141+
const digits = "0123456789abcdef"
142+
return string([]byte{digits[(v>>4)&0xF], digits[v&0xF]})
143+
}

0 commit comments

Comments
 (0)