Просмотр исходного кода

feat: add shimmer text rendering (#2027)

Ytzhak 6 месяцев назад
Родитель
Сommit
667ff90dd6

+ 19 - 0
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)

+ 11 - 4
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")
 			}

+ 10 - 2
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 := ""

+ 18 - 0
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()

+ 143 - 0
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]})
+}