424 lines
12 KiB
Go
424 lines
12 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/spinner"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/charmbracelet/harmonica"
|
|
)
|
|
|
|
// Welcome animation phases
|
|
const (
|
|
WelcomePhaseLogo = iota
|
|
WelcomePhaseTagline
|
|
WelcomePhaseFeatures
|
|
WelcomePhaseReady
|
|
)
|
|
|
|
// WelcomeTickMsg triggers animation frame
|
|
type WelcomeTickMsg struct{}
|
|
|
|
// WelcomeModel holds the state for the welcome animation
|
|
type WelcomeModel struct {
|
|
phase int
|
|
logoAlpha float64
|
|
logoVel float64
|
|
taglineAlpha float64
|
|
taglineVel float64
|
|
featureIndex int
|
|
featureAlpha float64
|
|
featureVel float64
|
|
spring harmonica.Spring
|
|
spinner spinner.Model
|
|
isDark bool
|
|
ready bool
|
|
frame int
|
|
}
|
|
|
|
// taglines for rotation
|
|
var taglines = []string{
|
|
`ASK → PLAN → BUILD`,
|
|
`0.8B 4B 9B`,
|
|
`Small models · Big results`,
|
|
}
|
|
|
|
// featureList shows key features
|
|
var featureList = []struct {
|
|
icon string
|
|
label string
|
|
desc string
|
|
}{
|
|
{"◈", "Model Routing", "Auto-selects 0.8B → 9B based on task"},
|
|
{"◈", "MCP Native", "Connect any tool via Model Context Protocol"},
|
|
{"◈", "ICE Engine", "Cross-session memory & context"},
|
|
{"◈", "Auto-Memory", "Extracts facts, decisions, TODOs"},
|
|
{"◈", "Thinking/CoT", "Chain-of-thought reasoning display"},
|
|
{"◈", "Skills System", "Domain-specific knowledge injection"},
|
|
}
|
|
|
|
// NewWelcomeModel creates a new welcome animation model
|
|
func NewWelcomeModel(isDark bool) WelcomeModel {
|
|
s := spinner.New(
|
|
spinner.WithSpinner(spinner.MiniDot),
|
|
spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#88c0d0"))),
|
|
)
|
|
|
|
return WelcomeModel{
|
|
spring: harmonica.NewSpring(harmonica.FPS(60), 6.0, 0.8),
|
|
spinner: s,
|
|
isDark: isDark,
|
|
}
|
|
}
|
|
|
|
// Init starts the welcome animation
|
|
func (m WelcomeModel) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
tea.Tick(16*time.Millisecond, func(time.Time) tea.Msg {
|
|
return WelcomeTickMsg{}
|
|
}),
|
|
m.spinner.Tick,
|
|
)
|
|
}
|
|
|
|
// Update processes animation frames
|
|
func (m WelcomeModel) Update(msg tea.Msg) (WelcomeModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case WelcomeTickMsg:
|
|
m.frame++
|
|
|
|
// Phase transitions - slowed down for better viewing
|
|
if m.phase == WelcomePhaseLogo && m.logoAlpha >= 0.95 && m.frame > 60 {
|
|
// Wait at least 60 frames (~1 second) on logo
|
|
m.phase = WelcomePhaseTagline
|
|
}
|
|
if m.phase == WelcomePhaseTagline && m.taglineAlpha >= 0.95 && m.frame > 180 {
|
|
// Wait at least 180 frames (~3 seconds) on taglines
|
|
m.phase = WelcomePhaseFeatures
|
|
m.featureIndex = 0
|
|
}
|
|
if m.phase == WelcomePhaseFeatures && m.featureIndex >= len(featureList) {
|
|
m.phase = WelcomePhaseReady
|
|
m.ready = true
|
|
}
|
|
|
|
// Animate based on phase
|
|
switch m.phase {
|
|
case WelcomePhaseLogo:
|
|
target := 1.0
|
|
m.logoAlpha, m.logoVel = m.spring.Update(m.logoAlpha, m.logoVel, target)
|
|
|
|
case WelcomePhaseTagline:
|
|
target := 1.0
|
|
m.taglineAlpha, m.taglineVel = m.spring.Update(m.taglineAlpha, m.taglineVel, target)
|
|
|
|
case WelcomePhaseFeatures:
|
|
// Animate current feature in
|
|
target := 1.0
|
|
m.featureAlpha, m.featureVel = m.spring.Update(m.featureAlpha, m.featureVel, target)
|
|
|
|
// Move to next feature after delay (slower - every 90 frames)
|
|
if m.featureAlpha >= 0.95 && m.frame%90 == 0 {
|
|
m.featureIndex++
|
|
if m.featureIndex < len(featureList) {
|
|
m.featureAlpha = 0
|
|
m.featureVel = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update spinner
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
|
|
return m, tea.Batch(
|
|
tea.Tick(16*time.Millisecond, func(time.Time) tea.Msg {
|
|
return WelcomeTickMsg{}
|
|
}),
|
|
cmd,
|
|
)
|
|
|
|
case spinner.TickMsg:
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// View renders the welcome animation
|
|
func (m WelcomeModel) View() string {
|
|
var b strings.Builder
|
|
|
|
// Render logo with fade-in
|
|
if m.phase >= WelcomePhaseLogo {
|
|
logo := logoLines()
|
|
for i, line := range logo {
|
|
if m.phase == WelcomePhaseLogo && i < len(logo)-2 {
|
|
// Apply gradient fade-in effect during logo phase
|
|
alpha := m.logoAlpha
|
|
if alpha < 0.1 {
|
|
alpha = 0.1
|
|
}
|
|
b.WriteString(m.applyFade(line, alpha))
|
|
} else {
|
|
b.WriteString(m.renderLogoLine(line))
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Render animated tagline
|
|
if m.phase >= WelcomePhaseTagline {
|
|
b.WriteString("\n")
|
|
taglineIdx := (m.frame / 120) % len(taglines)
|
|
tagline := taglines[taglineIdx]
|
|
b.WriteString(m.renderTagline(tagline))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Render feature list with animation
|
|
if m.phase >= WelcomePhaseFeatures {
|
|
b.WriteString("\n")
|
|
featureTitle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#4c566a")).
|
|
Bold(true).
|
|
Render(" Features")
|
|
b.WriteString(featureTitle)
|
|
b.WriteString("\n\n")
|
|
|
|
// Show all features, highlight current one
|
|
for i, feat := range featureList {
|
|
if i <= m.featureIndex {
|
|
line := fmt.Sprintf(" %s %s — %s", feat.icon, feat.label, feat.desc)
|
|
|
|
if i == m.featureIndex && m.featureAlpha < 0.95 {
|
|
// Currently animating in
|
|
alpha := m.featureAlpha
|
|
if alpha < 0.2 {
|
|
alpha = 0.2
|
|
}
|
|
b.WriteString(m.applyFade(line, alpha))
|
|
} else if i == m.featureIndex {
|
|
// Current feature with accent
|
|
indicator := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#88c0d0")).
|
|
Render("▸ ")
|
|
b.WriteString(indicator + line[2:])
|
|
} else {
|
|
// Previous features in dim
|
|
dimStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#4c566a"))
|
|
b.WriteString(" " + dimStyle.Render(line[2:]))
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render ready state
|
|
if m.phase >= WelcomePhaseReady {
|
|
b.WriteString("\n")
|
|
checkStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a3be8c"))
|
|
readyLine := fmt.Sprintf(" %s Ready to go! Type a message or press ? for help", checkStyle.Render("✓"))
|
|
b.WriteString(readyLine)
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// renderLogoLine applies gradient colors to logo line
|
|
func (m WelcomeModel) renderLogoLine(line string) string {
|
|
if noColor {
|
|
return line
|
|
}
|
|
|
|
// Apply gradient to box drawing characters
|
|
colors := []string{"#88c0d0", "#81a1c1", "#5e81ac", "#b48ead"}
|
|
|
|
result := ""
|
|
for i, r := range line {
|
|
if r == '╭' || r == '─' || r == '╮' || r == '│' || r == '╰' || r == '╯' {
|
|
colorIdx := i % len(colors)
|
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color(colors[colorIdx]))
|
|
result += style.Render(string(r))
|
|
} else if r == '╔' || r == '╗' || r == '║' || r == '═' || r == '╚' || r == '╝' {
|
|
colorIdx := (i + 1) % len(colors)
|
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color(colors[colorIdx]))
|
|
result += style.Render(string(r))
|
|
} else {
|
|
result += string(r)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// renderTagline renders the tagline with animation
|
|
func (m WelcomeModel) renderTagline(tagline string) string {
|
|
if noColor {
|
|
return " " + tagline
|
|
}
|
|
|
|
// Split tagline into parts and apply gradient
|
|
parts := strings.Split(tagline, " ")
|
|
result := " "
|
|
|
|
for i, part := range parts {
|
|
colorIdx := i % 3
|
|
var color string
|
|
switch colorIdx {
|
|
case 0:
|
|
color = "#88c0d0"
|
|
case 1:
|
|
color = "#81a1c1"
|
|
case 2:
|
|
color = "#b48ead"
|
|
}
|
|
|
|
style := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(color)).
|
|
Bold(true)
|
|
result += style.Render(part) + " "
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// applyFade applies alpha blending to simulate fade
|
|
func (m WelcomeModel) applyFade(line string, alpha float64) string {
|
|
if noColor {
|
|
return line
|
|
}
|
|
|
|
// Use dimmer color based on alpha
|
|
baseColor := "#4c566a" // dim
|
|
if alpha > 0.7 {
|
|
baseColor = "#88c0d0" // bright
|
|
} else if alpha > 0.4 {
|
|
baseColor = "#5e81ac" // medium
|
|
}
|
|
|
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color(baseColor))
|
|
return style.Render(line)
|
|
}
|
|
|
|
// IsReady returns true when welcome animation is complete
|
|
func (m WelcomeModel) IsReady() bool {
|
|
return m.ready
|
|
}
|
|
|
|
// pulseEffect creates a subtle pulse animation for status indicators
|
|
type PulseModel struct {
|
|
alpha float64
|
|
vel float64
|
|
spring harmonica.Spring
|
|
target float64
|
|
}
|
|
|
|
func NewPulseModel() PulseModel {
|
|
return PulseModel{
|
|
spring: harmonica.NewSpring(harmonica.FPS(60), 3.0, 0.6),
|
|
target: 1.0,
|
|
}
|
|
}
|
|
|
|
type PulseTickMsg struct{}
|
|
|
|
func (m PulseModel) Init() tea.Cmd {
|
|
return tea.Tick(50*time.Millisecond, func(time.Time) tea.Msg {
|
|
return PulseTickMsg{}
|
|
})
|
|
}
|
|
|
|
func (m PulseModel) Update(msg tea.Msg) (PulseModel, tea.Cmd) {
|
|
if _, ok := msg.(PulseTickMsg); ok {
|
|
// Oscillate between 0.7 and 1.0
|
|
if m.target == 1.0 && m.alpha >= 0.95 {
|
|
m.target = 0.7
|
|
} else if m.target == 0.7 && m.alpha <= 0.75 {
|
|
m.target = 1.0
|
|
}
|
|
|
|
m.alpha, m.vel = m.spring.Update(m.alpha, m.vel, m.target)
|
|
return m, tea.Tick(50*time.Millisecond, func(time.Time) tea.Msg {
|
|
return PulseTickMsg{}
|
|
})
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m PulseModel) Alpha() float64 {
|
|
return m.alpha
|
|
}
|
|
|
|
// gradientText applies a horizontal gradient to text
|
|
func gradientText(text string, colors []string) string {
|
|
if noColor || len(colors) == 0 {
|
|
return text
|
|
}
|
|
|
|
result := ""
|
|
runes := []rune(text)
|
|
colorCount := len(colors)
|
|
|
|
for i, r := range runes {
|
|
colorIdx := int(float64(i) / float64(len(runes)) * float64(colorCount))
|
|
if colorIdx >= colorCount {
|
|
colorIdx = colorCount - 1
|
|
}
|
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color(colors[colorIdx]))
|
|
result += style.Render(string(r))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// slideInEffect creates a slide-in animation from left
|
|
type SlideInModel struct {
|
|
offset float64
|
|
vel float64
|
|
spring harmonica.Spring
|
|
target float64
|
|
}
|
|
|
|
func NewSlideInModel() SlideInModel {
|
|
return SlideInModel{
|
|
spring: harmonica.NewSpring(harmonica.FPS(60), 5.0, 0.7),
|
|
target: 0,
|
|
offset: -50, // Start off-screen left
|
|
}
|
|
}
|
|
|
|
type SlideInTickMsg struct{}
|
|
|
|
func (m SlideInModel) Init() tea.Cmd {
|
|
return tea.Tick(16*time.Millisecond, func(time.Time) tea.Msg {
|
|
return SlideInTickMsg{}
|
|
})
|
|
}
|
|
|
|
func (m SlideInModel) Update(msg tea.Msg) (SlideInModel, tea.Cmd) {
|
|
if _, ok := msg.(SlideInTickMsg); ok {
|
|
m.offset, m.vel = m.spring.Update(m.offset, m.vel, m.target)
|
|
return m, tea.Tick(16*time.Millisecond, func(time.Time) tea.Msg {
|
|
return SlideInTickMsg{}
|
|
})
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m SlideInModel) Offset() int {
|
|
return int(math.Max(0, m.offset))
|
|
}
|
|
|
|
func (m SlideInModel) IsComplete() bool {
|
|
return m.offset <= 0.5
|
|
}
|