Some checks failed
Stable Release / stable (push) Failing after 22s
- Dark theme with red accents (cyberpunk aesthetic) - Epuré cyberpunk style: clean dark backgrounds, sharp red highlights - Full cyberpunk animations: glitch effect, scan line, typewriter - Mixed Unicode + ASCII icons - Rounded borders (╭ ╮ ╯ ╰) on cards and panels - ASCII art block titles (■) with red styling - Header: MUYUE branding, status indicators, live clock - Footer: shortcuts, version, update indicator - Tab transitions: glitch → scan → typewriter sequence - Extracted header.go, footer.go, animations.go as new files Controls unchanged: ctrl+t tabs, ctrl+s sidebar, ctrl+a AI panel file changes: - styles.go: new color palette (cyberRed, bgVoid, dimRed), block titles - types.go: added transition state, clock tick, glitch/scan/done messages - animations.go: new file with glitch, scan, typewriter, hex stream effects - header.go: new file with logo, tabs, status dots, live clock - footer.go: new file with shortcuts, version, update indicator - model.go: integrated transition state machine, clock updates - dashboard.go, studio.go, terminal.go, config_tab.go: updated icons/styles Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land> Co-authored-by: Augustin <muyue@legion-muyue.fr> Reviewed-on: #1
396 lines
10 KiB
Go
396 lines
10 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/progress"
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/muyue/muyue/internal/config"
|
|
"github.com/muyue/muyue/internal/daemon"
|
|
"github.com/muyue/muyue/internal/lsp"
|
|
"github.com/muyue/muyue/internal/mcp"
|
|
"github.com/muyue/muyue/internal/orchestrator"
|
|
"github.com/muyue/muyue/internal/preview"
|
|
"github.com/muyue/muyue/internal/proxy"
|
|
"github.com/muyue/muyue/internal/scanner"
|
|
"github.com/muyue/muyue/internal/skills"
|
|
)
|
|
|
|
func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
|
|
orch, _ := orchestrator.New(cfg)
|
|
proxyMgr := proxy.NewManager()
|
|
d := daemon.NewDaemon(cfg, 1*time.Hour)
|
|
|
|
lspServers := lsp.ScanServers()
|
|
skillList, _ := skills.List()
|
|
|
|
mcpConfigured := false
|
|
if err := mcp.ConfigureAll(cfg); err == nil {
|
|
mcpConfigured = true
|
|
}
|
|
|
|
if cfg.Profile.Preferences.AutoUpdate {
|
|
d.Start()
|
|
}
|
|
|
|
sp := spinner.New()
|
|
sp.Spinner = spinner.Dot
|
|
sp.Style = lipgloss.NewStyle().Foreground(cyberRed)
|
|
|
|
prog := progress.New(progress.WithGradient("#FF0033", "#FF1A5E"))
|
|
|
|
cwd, _ := os.Getwd()
|
|
|
|
return Model{
|
|
config: cfg,
|
|
scanResult: scan,
|
|
activeTab: tabDashboard,
|
|
chatLog: []string{
|
|
aiMsgStyle.Render(" >> Welcome to Studio! Chat with your AI assistant here."),
|
|
aiMsgStyle.Render(" >> Configure agents and workflows from the sidebar. Type /plan <goal> to start."),
|
|
},
|
|
orch: orch,
|
|
proxyMgr: proxyMgr,
|
|
chatInput: "",
|
|
chatLoading: false,
|
|
daemon: d,
|
|
lspServers: lspServers,
|
|
mcpConfigured: mcpConfigured,
|
|
skillList: skillList,
|
|
helpModel: help.New(),
|
|
progressBar: prog,
|
|
spinner: sp,
|
|
showingQuit: false,
|
|
confirmCursor: 1,
|
|
showingTabMenu: false,
|
|
tabMenuCursor: 0,
|
|
termCwd: cwd,
|
|
studioPanel: panelChat,
|
|
studioSidebarOpen: true,
|
|
termAIChat: []string{
|
|
aiMsgStyle.Render(" >> I know your system inside out. Ask me anything."),
|
|
},
|
|
termAIShow: true,
|
|
configSection: configProfile,
|
|
configField: 0,
|
|
animationFrame: 0,
|
|
currentTime: time.Now(),
|
|
transition: transitionNone,
|
|
}
|
|
}
|
|
|
|
func animTick() tea.Cmd {
|
|
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return animTickMsg{time: t}
|
|
})
|
|
}
|
|
|
|
func clockTick() tea.Cmd {
|
|
return tea.Tick(1*time.Second, func(t time.Time) tea.Msg {
|
|
return clockTickMsg{time: t}
|
|
})
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(spinner.Tick, animTick(), clockTick(), tea.EnterAltScreen)
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
case spinner.TickMsg:
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
case animTickMsg:
|
|
m.animationFrame++
|
|
|
|
if m.transition == transitionGlitch {
|
|
m.transitionTick++
|
|
if m.transitionTick > 5 {
|
|
m.transition = transitionScan
|
|
m.transitionTick = 0
|
|
}
|
|
} else if m.transition == transitionScan {
|
|
m.transitionTick++
|
|
if m.transitionTick > 8 {
|
|
m.transition = transitionTypewriter
|
|
m.transitionTick = 0
|
|
m.typewriterBuf = m.renderContent()
|
|
m.typewriterPos = 0
|
|
}
|
|
} else if m.transition == transitionTypewriter {
|
|
m.typewriterPos += 3
|
|
if m.typewriterPos >= len(m.typewriterBuf) {
|
|
m.transition = transitionNone
|
|
}
|
|
}
|
|
|
|
return m, animTick()
|
|
case clockTickMsg:
|
|
m.currentTime = msg.time
|
|
return m, clockTick()
|
|
case progress.FrameMsg:
|
|
pm, cmd := m.progressBar.Update(msg)
|
|
m.progressBar = pm.(progress.Model)
|
|
return m, cmd
|
|
case termOutputMsg:
|
|
m.termLog = append(m.termLog, msg.line)
|
|
if m.activeTab == tabShell {
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
}
|
|
return m, nil
|
|
case termExitMsg:
|
|
m.termRunning = false
|
|
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render("(process exited)"))
|
|
m.termCmd = nil
|
|
if m.activeTab == tabShell {
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
return m, nil
|
|
case aiResponseMsg:
|
|
m.chatLoading = false
|
|
m.termAILoading = false
|
|
content := msg.content
|
|
|
|
if m.activeTab == tabShell && m.termAIShow {
|
|
m.termAIChat = append(m.termAIChat, aiMsgStyle.Render(" "+content))
|
|
if m.activeTab == tabShell {
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
}
|
|
} else {
|
|
m.chatLog = append(m.chatLog, aiMsgStyle.Render(" "+content))
|
|
if m.orch != nil && m.orch.Workflow != nil {
|
|
previewFiles := parsePreviewFiles(content)
|
|
if len(previewFiles) > 0 {
|
|
m.handlePreview(previewFiles)
|
|
}
|
|
}
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
}
|
|
return m, nil
|
|
case aiErrMsg:
|
|
m.chatLoading = false
|
|
m.termAILoading = false
|
|
errText := errMsgStyle.Render(" [ERROR] " + msg.err.Error())
|
|
if m.activeTab == tabShell && m.termAIShow {
|
|
m.termAIChat = append(m.termAIChat, errText)
|
|
} else {
|
|
m.chatLog = append(m.chatLog, errText)
|
|
}
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
return m, nil
|
|
case scanCompleteMsg:
|
|
m.scanResult = msg.result
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case installCompleteMsg:
|
|
m.installing = false
|
|
for _, r := range msg.results {
|
|
status := itemOKStyle.Render("[OK]")
|
|
if !r.Success {
|
|
status = itemMissingStyle.Render("[--]")
|
|
}
|
|
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message))
|
|
}
|
|
m.scanResult = scanner.ScanSystem()
|
|
m.progressBar.SetPercent(1)
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case installProgressMsg:
|
|
status := itemOKStyle.Render("[OK]")
|
|
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool))
|
|
m.installCurrent = msg.current
|
|
m.installTool = ""
|
|
pct := float64(msg.current) / float64(max(msg.total, 1))
|
|
m.progressBar.SetPercent(pct)
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case installBatchMsg:
|
|
status := itemOKStyle.Render("[OK]")
|
|
if !msg.result.Success {
|
|
status = itemMissingStyle.Render("[--]")
|
|
}
|
|
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message))
|
|
m.installCurrent = msg.index + 1
|
|
m.installTotal = len(msg.tools)
|
|
pct := float64(m.installCurrent) / float64(max(m.installTotal, 1))
|
|
m.progressBar.SetPercent(pct)
|
|
if msg.index+1 < len(msg.tools) {
|
|
m.installTool = msg.tools[msg.index+1]
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, startInstallCmd(msg.config, msg.tools, msg.index+1)
|
|
}
|
|
m.installing = false
|
|
m.scanResult = scanner.ScanSystem()
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case updateCheckMsg:
|
|
m.updateStatus = msg.statuses
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case previewReadyMsg:
|
|
m.previewURL = msg.url
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case lspScanMsg:
|
|
m.lspServers = msg.servers
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case mcpConfigMsg:
|
|
if msg.err == nil {
|
|
m.mcpConfigured = true
|
|
}
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case daemonLogMsg:
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.helpModel.Width = msg.Width
|
|
headerH := 2
|
|
footerH := 2
|
|
inputH := 0
|
|
if m.activeTab == tabStudio || m.activeTab == tabShell {
|
|
inputH = 2
|
|
}
|
|
contentH := msg.Height - headerH - footerH - inputH
|
|
if contentH < 1 {
|
|
contentH = 1
|
|
}
|
|
m.viewport = viewport.New(msg.Width, contentH)
|
|
m.viewport.Width = msg.Width
|
|
m.viewport.Height = contentH
|
|
m.progressBar.Width = msg.Width - 20
|
|
m.ready = true
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
if !m.ready {
|
|
return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("Initializing muyue...")
|
|
}
|
|
|
|
if m.showingQuit {
|
|
return m.renderQuitOverlay()
|
|
}
|
|
|
|
if m.showingTabMenu {
|
|
return m.renderTabMenuOverlay()
|
|
}
|
|
|
|
if m.transition == transitionGlitch {
|
|
return renderGlitchEffect(m.width, m.height, m.transitionTick)
|
|
}
|
|
|
|
if m.transition == transitionScan {
|
|
return renderScanEffect(m.width, m.height, m.transitionTick)
|
|
}
|
|
|
|
if m.transition == transitionTypewriter {
|
|
var b strings.Builder
|
|
b.WriteString(m.renderHeader())
|
|
b.WriteString("\n")
|
|
b.WriteString(typewriterRender(m.typewriterBuf, m.typewriterPos))
|
|
if m.activeTab == tabStudio {
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderStudioInput())
|
|
}
|
|
if m.activeTab == tabShell {
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderShellInput())
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderFooter())
|
|
return b.String()
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(m.renderHeader())
|
|
b.WriteString("\n")
|
|
b.WriteString(m.viewport.View())
|
|
if m.activeTab == tabStudio {
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderStudioInput())
|
|
}
|
|
if m.activeTab == tabShell {
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderShellInput())
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderFooter())
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m Model) renderContent() string {
|
|
switch m.activeTab {
|
|
case tabDashboard:
|
|
return m.renderDashboard()
|
|
case tabStudio:
|
|
return m.renderStudio()
|
|
case tabShell:
|
|
return m.renderShell()
|
|
case tabConfig:
|
|
return m.renderConfig()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (m *Model) resizeViewport() {
|
|
headerH := 2
|
|
footerH := 2
|
|
inputH := 0
|
|
if m.activeTab == tabStudio || m.activeTab == tabShell {
|
|
inputH = 2
|
|
}
|
|
contentH := m.height - headerH - footerH - inputH
|
|
if contentH < 1 {
|
|
contentH = 1
|
|
}
|
|
m.viewport = viewport.New(m.width, contentH)
|
|
m.viewport.Width = m.width
|
|
m.viewport.Height = contentH
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
|
|
func (m *Model) handlePreview(files []previewFile) {
|
|
dir := filepath.Join(os.TempDir(), "muyue-preview")
|
|
os.RemoveAll(dir)
|
|
os.MkdirAll(dir, 0755)
|
|
|
|
for _, f := range files {
|
|
preview.CreatePreviewFile(dir, f.Filename, f.Content)
|
|
}
|
|
|
|
if m.previewSrv != nil {
|
|
m.previewSrv.Stop()
|
|
}
|
|
m.previewSrv = preview.NewPreviewServer(dir)
|
|
if err := m.previewSrv.Start(8765); err != nil {
|
|
m.chatLog = append(m.chatLog, errMsgStyle.Render(" preview error: "+err.Error()))
|
|
} else {
|
|
m.previewURL = "http://127.0.0.1:8765"
|
|
m.chatLog = append(m.chatLog, itemOKStyle.Render(" Preview: http://127.0.0.1:8765"))
|
|
}
|
|
}
|