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" "github.com/muyue/muyue/internal/version" ) 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(primaryColor) prog := progress.New(progress.WithGradient("#E8364F", "#FF6B8A")) 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 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, } } func animTick() tea.Cmd { return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return animTickMsg{time: t} }) } func (m Model) Init() tea.Cmd { return tea.Batch(spinner.Tick, animTick(), 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++ return m, animTick() 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(dimColor).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("✓") 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("✓") 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("✓") 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(primaryColor).Bold(true).Render("Loading muyue...") } if m.showingQuit { return m.renderQuitOverlay() } if m.showingTabMenu { return m.renderTabMenuOverlay() } 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) renderHeader() string { var tabs []string for i, name := range tabNames { icon := tabIcons[i] if tab(i) == m.activeTab { tabStyle := lipgloss.NewStyle(). Background(primaryColor). Foreground(lipgloss.Color("#FFFFFF")). Bold(true). Padding(0, 2) tabs = append(tabs, tabStyle.Render(icon+" "+name)) } else { tabStyle := lipgloss.NewStyle(). Background(bgPanel). Foreground(textDimColor). Padding(0, 2) tabs = append(tabs, tabStyle.Render(icon+" "+name)) } } tabLine := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...)) badge := lipgloss.NewStyle(). Foreground(roseColor). Bold(true). Render("muyue") versionBadge := lipgloss.NewStyle(). Foreground(dimColor). Render("v" + version.Version) anim := lipgloss.NewStyle().Foreground(warmColor).Render(getAnimFrame(m.animationFrame)) logoLine := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render( lipgloss.JoinHorizontal(lipgloss.Center, badge, " ", versionBadge, " ", anim), ) return lipgloss.JoinVertical(lipgloss.Left, logoLine, tabLine) } 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) renderFooter() string { profile := "unknown" if m.config != nil && m.config.Profile.Pseudo != "" { profile = m.config.Profile.Pseudo } left := fmt.Sprintf(" %s@%s", lipgloss.NewStyle().Foreground(roseColor).Bold(true).Render(profile), lipgloss.NewStyle().Foreground(dimColor).Render(version.Name)) leftR := statusBarStyle.Render(left) var helpText string switch m.activeTab { case tabDashboard: helpText = "[i] install [u] update [s] scan [ctrl+t] tabs" case tabStudio: helpText = "[enter] send [ctrl+s] sidebar [ctrl+t] tabs" case tabShell: helpText = "[enter] run [ctrl+a] AI panel [ctrl+c] kill" case tabConfig: helpText = "[↑↓] sections [ctrl+t] tabs" default: helpText = "[ctrl+t] tabs [ctrl+c] quit" } rightR := statusBarStyle.Render(helpText) gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR) if gap < 0 { gap = 0 } statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom, leftR, strings.Repeat(" ", gap), rightR, ) return lipgloss.JoinVertical(lipgloss.Left, statusLine, lipgloss.NewStyle().Background(bgPanel).Foreground(dimColor).Render( lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys)))) } func (m Model) renderQuitOverlay() string { yesStyle := confirmNoStyle noStyle := confirmYesStyle if m.confirmCursor == 0 { yesStyle = confirmYesStyle noStyle = confirmNoStyle } frame := lipgloss.NewStyle().Foreground(primaryColor).Render(getAnimFrame(m.animationFrame)) box := fmt.Sprintf("\n\n %s Quit muyue?\n\n %s %s", frame, yesStyle.Render("[ Yes ]"), noStyle.Render("[ No ]"), ) content := confirmBoxStyle.Render(box) return lipgloss.Place(m.width, m.height, 0.5, 0.5, content, lipgloss.WithWhitespaceBackground(bgDark), lipgloss.WithWhitespaceForeground(dimColor), ) } func (m Model) renderTabMenuOverlay() string { menuStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(primaryColor). Background(bgCard). Padding(1, 3) tabItemStyle := lipgloss.NewStyle(). Foreground(textDimColor). Padding(0, 2) tabItemActiveStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFFFF")). Background(primaryColor). Bold(true). Padding(0, 2) descs := []string{ "tools, updates & system status", "chat, agents & workflows", "terminal + AI assistant", "profile, API keys & settings", } var items []string for i, name := range tabNames { num := lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %d.", i+1)) icon := tabIcons[i] + " " if i == m.tabMenuCursor { item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(roseLightColor).Render(descs[i])) items = append(items, tabItemActiveStyle.Render("▸"+item)) } else { item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i])) items = append(items, tabItemStyle.Render(" "+item)) } } header := lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Switch Tab") content := header + "\n\n" + strings.Join(items, "\n") + "\n\n" + lipgloss.NewStyle().Foreground(dimColor).Render("↑↓ navigate · enter select · esc cancel") box := menuStyle.Render(content) return lipgloss.Place(m.width, m.height, 0.5, 0.5, box, lipgloss.WithWhitespaceBackground(bgDark), lipgloss.WithWhitespaceForeground(dimColor), ) } 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")) } } func (m Model) renderStudioInput() string { if m.chatLoading { return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render( inputStyle.Render("⟩ ") + m.spinner.View() + lipgloss.NewStyle().Foreground(mutedColor).Render(" thinking..."), ) } cursor := lipgloss.NewStyle().Foreground(primaryColor).Render("▎") return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render( inputStyle.Render("⟩ ") + m.chatInput + cursor, ) } func (m Model) renderShellInput() string { prompt := lipgloss.NewStyle().Foreground(successColor).Render("❯ ") return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render( prompt + m.termInput + lipgloss.NewStyle().Foreground(primaryColor).Render("▎"), ) }