mirror of
https://github.com/sst/opencode.git
synced 2025-08-22 14:04:07 +00:00
feat: add shimmer text rendering (#2027)
This commit is contained in:
parent
cd3d91209a
commit
667ff90dd6
5 changed files with 201 additions and 6 deletions
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 := ""
|
||||
|
|
|
@ -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()
|
||||
|
|
143
packages/tui/internal/util/shimmer.go
Normal file
143
packages/tui/internal/util/shimmer.go
Normal 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]})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue