mirror of
https://github.com/sst/opencode.git
synced 2025-08-22 22:14:14 +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
|
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 {
|
func (a *App) SaveState() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
err := SaveState(a.StatePath, a.State)
|
err := SaveState(a.StatePath, a.State)
|
||||||
|
|
|
@ -339,6 +339,7 @@ func (m *editorComponent) Content() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||||
|
|
||||||
promptStyle := styles.NewStyle().Foreground(t.Primary()).
|
promptStyle := styles.NewStyle().Foreground(t.Primary()).
|
||||||
Padding(0, 0, 0, 1).
|
Padding(0, 0, 0, 1).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
@ -381,9 +382,11 @@ func (m *editorComponent) Content() string {
|
||||||
status = "waiting for permission"
|
status = "waiting for permission"
|
||||||
}
|
}
|
||||||
if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" {
|
if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" {
|
||||||
hint = muted(
|
bright := t.Accent()
|
||||||
status,
|
if status == "waiting for permission" {
|
||||||
) + m.spinner.View() + muted(
|
bright = t.Warning()
|
||||||
|
}
|
||||||
|
hint = util.Shimmer(status, t.Background(), t.TextMuted(), bright) + m.spinner.View() + muted(
|
||||||
" ",
|
" ",
|
||||||
) + base(
|
) + base(
|
||||||
keyText+" again",
|
keyText+" again",
|
||||||
|
@ -391,7 +394,11 @@ func (m *editorComponent) Content() string {
|
||||||
" interrupt",
|
" interrupt",
|
||||||
)
|
)
|
||||||
} else {
|
} 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 == "" {
|
if m.app.CurrentPermission.ID == "" {
|
||||||
hint += muted(" ") + base(keyText) + muted(" interrupt")
|
hint += muted(" ") + base(keyText) + muted(" interrupt")
|
||||||
}
|
}
|
||||||
|
|
|
@ -234,7 +234,13 @@ func renderText(
|
||||||
}
|
}
|
||||||
content = util.ToMarkdown(text, width, backgroundColor)
|
content = util.ToMarkdown(text, width, backgroundColor)
|
||||||
if isThinking {
|
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:
|
case opencode.UserMessage:
|
||||||
ts = time.UnixMilli(int64(casted.Time.Created))
|
ts = time.UnixMilli(int64(casted.Time.Created))
|
||||||
|
@ -779,7 +785,9 @@ func renderToolTitle(
|
||||||
) string {
|
) string {
|
||||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
||||||
title := renderToolAction(toolCall.Tool)
|
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 := ""
|
toolArgs := ""
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
|
@ -59,6 +60,7 @@ type messagesComponent struct {
|
||||||
lineCount int
|
lineCount int
|
||||||
selection *selection
|
selection *selection
|
||||||
messagePositions map[string]int // map message ID to line position
|
messagePositions map[string]int // map message ID to line position
|
||||||
|
animating bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type selection struct {
|
type selection struct {
|
||||||
|
@ -99,6 +101,7 @@ func (s selection) coords(offset int) *selection {
|
||||||
|
|
||||||
type ToggleToolDetailsMsg struct{}
|
type ToggleToolDetailsMsg struct{}
|
||||||
type ToggleThinkingBlocksMsg struct{}
|
type ToggleThinkingBlocksMsg struct{}
|
||||||
|
type shimmerTickMsg struct{}
|
||||||
|
|
||||||
func (m *messagesComponent) Init() tea.Cmd {
|
func (m *messagesComponent) Init() tea.Cmd {
|
||||||
return tea.Batch(m.viewport.Init())
|
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) {
|
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
switch msg := msg.(type) {
|
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:
|
case tea.MouseClickMsg:
|
||||||
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
|
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
|
||||||
y := msg.Y + 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 {
|
if m.dirty {
|
||||||
cmds = append(cmds, m.renderView())
|
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()
|
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