summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorYtzhak <[email protected]>2025-08-18 06:55:01 -0400
committerGitHub <[email protected]>2025-08-18 05:55:01 -0500
commit667ff90dd6be20bf19d5424a80d57559d38352f5 (patch)
tree1be3df414954011ff3caa859702679acbe619a7e
parentcd3d91209ab843a5a84eec8aae371510ab9e3178 (diff)
downloadopencode-667ff90dd6be20bf19d5424a80d57559d38352f5.tar.gz
opencode-667ff90dd6be20bf19d5424a80d57559d38352f5.zip
feat: add shimmer text rendering (#2027)
-rw-r--r--packages/tui/internal/app/app.go19
-rw-r--r--packages/tui/internal/components/chat/editor.go15
-rw-r--r--packages/tui/internal/components/chat/message.go12
-rw-r--r--packages/tui/internal/components/chat/messages.go18
-rw-r--r--packages/tui/internal/util/shimmer.go143
5 files changed, 201 insertions, 6 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index f046daaef..af8157adc 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 38a579057..72daf2886 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 eecfe2611..cb166246e 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 375254668..f63de16a1 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 000000000..88654ff07
--- /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]})
+}