ai-agent/internal/tui/welcome.go
admin 8dc496b626
Some checks failed
CI / test (push) Has been cancelled
Release / release (push) Failing after 4m36s
first commit
2026-03-08 15:40:34 +07:00

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
}