Files
MuyueWorkspace/internal/tui/app.go
Augustin e6fdec4ff5
All checks were successful
CI / build (push) Successful in 1m45s
fix: docker version check, uv PATH, install progress bar
- Fix Docker latest version: use moby/moby instead of docker/compose
- Fix uv install: add ~/.local/bin to PATH in shell rc after install
- Add progress bar during tool installation in dashboard
- Show spinner + per-tool progress with X/Y counter
- Disable install key while installation is running

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 00:07:37 +02:00

1529 lines
40 KiB
Go

package tui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"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/installer"
"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/updater"
"github.com/muyue/muyue/internal/version"
"github.com/muyue/muyue/internal/workflow"
)
type tab int
const (
tabDashboard tab = iota
tabChat
tabWorkflow
tabAgents
tabConfig
tabCount
)
var tabNames = []string{"Dashboard", "Chat", "Workflow", "Agents", "Config"}
var (
baseColor = lipgloss.Color("#FF6B9D")
accentColor = lipgloss.Color("#A0D2FF")
aiColor = lipgloss.Color("#C4B5FD")
successColor = lipgloss.Color("#4ADE80")
warningColor = lipgloss.Color("#FBBF24")
errorColor = lipgloss.Color("#FF6B6B")
mutedColor = lipgloss.Color("#666680")
dimColor = lipgloss.Color("#444460")
bgDark = lipgloss.Color("#1A1A2E")
bgPanel = lipgloss.Color("#16213E")
bgCard = lipgloss.Color("#1F2937")
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FF6B9D")).
Background(bgDark).
Padding(0, 2)
tabStyle = lipgloss.NewStyle().
Padding(0, 2).
Foreground(mutedColor)
activeTabStyle = lipgloss.NewStyle().
Padding(0, 2).
Foreground(baseColor).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(baseColor).
Bold(true)
sectionStyle = lipgloss.NewStyle().
Foreground(accentColor).
Bold(true)
itemOKStyle = lipgloss.NewStyle().
Foreground(successColor)
itemMissingStyle = lipgloss.NewStyle().
Foreground(errorColor)
itemWarnStyle = lipgloss.NewStyle().
Foreground(warningColor)
itemPendingStyle = lipgloss.NewStyle().
Foreground(mutedColor)
userMsgStyle = lipgloss.NewStyle().
Foreground(accentColor)
aiMsgStyle = lipgloss.NewStyle().
Foreground(aiColor)
errMsgStyle = lipgloss.NewStyle().
Foreground(errorColor)
inputStyle = lipgloss.NewStyle().
Foreground(baseColor)
phaseStyle = lipgloss.NewStyle().
Foreground(warningColor).
Bold(true)
stepDoneStyle = lipgloss.NewStyle().
Foreground(successColor)
stepPendingStyle = lipgloss.NewStyle().
Foreground(mutedColor)
stepCurrentStyle = lipgloss.NewStyle().
Foreground(baseColor).
Bold(true)
stepErrorStyle = lipgloss.NewStyle().
Foreground(errorColor)
cardStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(dimColor).
Padding(0, 1).
BorderBackground(bgPanel)
statusBarStyle = lipgloss.NewStyle().
Background(bgDark).
Foreground(lipgloss.Color("#A0A0B0")).
Padding(0, 1)
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(baseColor).
Background(bgCard).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(1, 3).
Bold(true)
confirmYesStyle = lipgloss.NewStyle().
Foreground(successColor).
Bold(true)
confirmNoStyle = lipgloss.NewStyle().
Foreground(mutedColor)
gradientStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF6B9D"))
logoStyle = lipgloss.NewStyle().
Foreground(baseColor).
Bold(true)
versionBadgeStyle = lipgloss.NewStyle().
Background(baseColor).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Bold(true)
)
type aiResponseMsg struct{ content string }
type aiErrMsg struct{ err error }
type scanCompleteMsg struct{ result *scanner.ScanResult }
type installCompleteMsg struct{ results []installer.InstallResult }
type installProgressMsg struct {
tool string
current int
total int
}
type updateCheckMsg struct{ statuses []updater.UpdateStatus }
type previewReadyMsg struct{ url string }
type workflowPhaseMsg struct{ phase workflow.Phase }
type daemonLogMsg struct{ logs []string }
type lspScanMsg struct{ servers []lsp.LSPServer }
type mcpConfigMsg struct{ err error }
type skillsListMsg struct{ skills []skills.Skill }
type spinnerTickMsg struct{ time time.Time }
type Model struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
activeTab tab
width int
height int
viewport viewport.Model
ready bool
chatInput string
chatLog []string
chatLoading bool
orch *orchestrator.Orchestrator
proxyMgr *proxy.Manager
updateStatus []updater.UpdateStatus
installLog []string
previewURL string
previewSrv *preview.PreviewServer
daemon *daemon.Daemon
lspServers []lsp.LSPServer
mcpConfigured bool
skillList []skills.Skill
helpModel help.Model
progressBar progress.Model
spinner spinner.Model
showingQuit bool
confirmCursor int
ctrlCCount int
lastCtrlC time.Time
installing bool
installCurrent int
installTotal int
installTool string
}
type keyMap struct {
Tab key.Binding
Prev key.Binding
Quit key.Binding
Confirm key.Binding
Cancel key.Binding
Dashboard key.Binding
Chat key.Binding
Workflow key.Binding
Agents key.Binding
Config key.Binding
Install key.Binding
Update key.Binding
Scan key.Binding
Enter key.Binding
Backspace key.Binding
}
var keys = keyMap{
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "next tab"),
),
Prev: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("⇧+tab", "prev tab"),
),
Quit: key.NewBinding(
key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"),
),
Confirm: key.NewBinding(
key.WithKeys("y"),
key.WithHelp("y", "yes"),
),
Cancel: key.NewBinding(
key.WithKeys("n", "esc"),
key.WithHelp("n/esc", "no"),
),
Dashboard: key.NewBinding(
key.WithKeys("alt+1"),
key.WithHelp("alt+1", "dashboard"),
),
Chat: key.NewBinding(
key.WithKeys("alt+2"),
key.WithHelp("alt+2", "chat"),
),
Workflow: key.NewBinding(
key.WithKeys("alt+3"),
key.WithHelp("alt+3", "workflow"),
),
Agents: key.NewBinding(
key.WithKeys("alt+4"),
key.WithHelp("alt+4", "agents"),
),
Config: key.NewBinding(
key.WithKeys("alt+5"),
key.WithHelp("alt+5", "config"),
),
Install: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "install"),
),
Update: key.NewBinding(
key.WithKeys("u"),
key.WithHelp("u", "update"),
),
Scan: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "scan"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "send"),
),
Backspace: key.NewBinding(
key.WithKeys("backspace"),
key.WithHelp("⌫", "delete"),
),
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Dashboard, k.Chat, k.Workflow, k.Agents, k.Config, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Dashboard, k.Chat, k.Workflow, k.Agents, k.Config},
{k.Tab, k.Prev, k.Quit},
}
}
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(baseColor)
prog := progress.New(progress.WithGradient("#FF6B9D", "#A0D2FF"))
return Model{
config: cfg,
scanResult: scan,
activeTab: tabDashboard,
chatLog: []string{
aiMsgStyle.Render("muyue: Welcome! I'm your AI development environment assistant."),
aiMsgStyle.Render("muyue: Type /plan <goal> to start a structured workflow, or just chat."),
},
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,
}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(spinner.Tick, 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 progress.FrameMsg:
pm, cmd := m.progressBar.Update(msg)
m.progressBar = pm.(progress.Model)
return m, cmd
case aiResponseMsg:
m.chatLoading = false
content := msg.content
m.chatLog = append(m.chatLog, aiMsgStyle.Render("muyue: "+content))
if m.orch != nil && m.orch.Workflow != nil {
previewFiles := workflow.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.chatLog = append(m.chatLog, errMsgStyle.Render("error: "+msg.err.Error()))
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("[FAIL]")
}
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("[FAIL]")
}
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 := 4
footerH := 2
inputH := 0
if m.activeTab == tabChat || m.activeTab == tabWorkflow {
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) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.showingQuit {
return m.handleQuitConfirm(msg)
}
switch msg.String() {
case "ctrl+c":
now := time.Now()
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
return m, tea.Quit
}
m.ctrlCCount++
m.lastCtrlC = now
m.showingQuit = true
m.confirmCursor = 1
m.viewport.SetContent(m.renderContent())
return m, nil
case "alt+1":
m.activeTab = tabDashboard
m.resizeViewport()
return m, nil
case "alt+2":
m.activeTab = tabChat
m.resizeViewport()
return m, nil
case "alt+3":
m.activeTab = tabWorkflow
m.resizeViewport()
return m, nil
case "alt+4":
m.activeTab = tabAgents
m.resizeViewport()
return m, nil
case "alt+5":
m.activeTab = tabConfig
m.resizeViewport()
return m, nil
case "tab":
m.activeTab = (m.activeTab + 1) % tabCount
m.resizeViewport()
return m, nil
case "shift+tab":
m.activeTab = (m.activeTab - 1 + tabCount) % tabCount
m.resizeViewport()
return m, nil
case "enter":
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && m.chatInput != "" && !m.chatLoading {
return m.handleChatSubmit()
}
case "backspace":
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(m.chatInput) > 0 {
m.chatInput = m.chatInput[:len(m.chatInput)-1]
m.viewport.SetContent(m.renderContent())
}
default:
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(msg.String()) == 1 && !m.chatLoading {
m.chatInput += msg.String()
m.viewport.SetContent(m.renderContent())
}
}
if m.activeTab == tabDashboard {
return m.handleDashboardKey(msg)
}
if m.activeTab == tabAgents {
return m.handleAgentsKey(msg)
}
if m.activeTab == tabWorkflow {
return m.handleWorkflowKey(msg)
}
return m, nil
}
func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "y", "Y", "o", "O":
m.showingQuit = false
return m, tea.Quit
case "n", "N", "esc":
m.showingQuit = false
m.ctrlCCount = 0
m.viewport.SetContent(m.renderContent())
return m, nil
case "left", "h":
m.confirmCursor = 0
m.viewport.SetContent(m.renderContent())
return m, nil
case "right", "l":
m.confirmCursor = 1
m.viewport.SetContent(m.renderContent())
return m, nil
case "enter":
if m.confirmCursor == 0 {
m.showingQuit = false
return m, tea.Quit
}
m.showingQuit = false
m.ctrlCCount = 0
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+c":
m.showingQuit = false
return m, tea.Quit
}
return m, nil
}
func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
input := m.chatInput
m.chatLog = append(m.chatLog, userMsgStyle.Render("you: "+input))
m.chatInput = ""
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
if strings.HasPrefix(input, "/plan ") {
goal := strings.TrimPrefix(input, "/plan ")
return m, startWorkflowCmd(m.orch, goal)
}
if m.orch != nil && m.orch.Workflow != nil && m.orch.Workflow.Phase != workflow.PhaseIdle {
return m, workflowChatCmd(m.orch, input)
}
return m, sendAIMessage(m.orch, input)
}
func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "i":
if m.installing {
return m, nil
}
var missing []string
if m.scanResult != nil {
for _, t := range m.scanResult.Tools {
if !t.Installed {
missing = append(missing, t.Name)
}
}
}
if len(missing) == 0 {
m.installLog = append(m.installLog, itemOKStyle.Render("All tools already installed!"))
m.viewport.SetContent(m.renderContent())
return m, nil
}
needsSudo := checkNeedsSudo(m.scanResult)
if needsSudo && !hasSudo() {
m.installLog = append(m.installLog, errMsgStyle.Render("Some tools require sudo. Run: sudo muyue install"))
m.viewport.SetContent(m.renderContent())
return m, nil
}
m.installing = true
m.installCurrent = 0
m.installTotal = len(missing)
m.installTool = missing[0]
m.progressBar.SetPercent(0)
m.viewport.SetContent(m.renderContent())
return m, startInstallCmd(m.config, missing, 0)
case "u":
return m, tea.Cmd(func() tea.Msg {
result := scanner.ScanSystem()
return updateCheckMsg{statuses: updater.CheckUpdates(result)}
})
case "s":
return m, tea.Cmd(func() tea.Msg {
return scanCompleteMsg{result: scanner.ScanSystem()}
})
case "l":
return m, tea.Cmd(func() tea.Msg {
servers := lsp.ScanServers()
return lspScanMsg{servers: servers}
})
case "m":
return m, tea.Cmd(func() tea.Msg {
err := mcp.ConfigureAll(m.config)
return mcpConfigMsg{err: err}
})
}
return m, nil
}
func (m Model) handleAgentsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "c":
if m.proxyMgr.IsAvailable(proxy.AgentCrush) {
m.proxyMgr.Start(proxy.AgentCrush)
}
m.viewport.SetContent(m.renderContent())
case "l":
if m.proxyMgr.IsAvailable(proxy.AgentClaude) {
m.proxyMgr.Start(proxy.AgentClaude)
}
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.orch == nil || m.orch.Workflow == nil {
return m, nil
}
wf := m.orch.Workflow
switch msg.String() {
case "a":
if wf.Phase == workflow.PhaseReviewing {
m.chatLog = append(m.chatLog, userMsgStyle.Render("you: [Plan approved]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, reviewPlanCmd(m.orch, true, "")
}
case "r":
if wf.Phase == workflow.PhaseReviewing {
m.chatInput = ""
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Type your rejection feedback below:"))
m.viewport.SetContent(m.renderContent())
}
case "g":
if wf.Phase == workflow.PhaseGathering && len(wf.Plan.Questions) > 0 && len(wf.Plan.Answers) >= len(wf.Plan.Questions) {
m.chatLog = append(m.chatLog, userMsgStyle.Render("you: [Generate plan]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, generatePlanCmd(m.orch)
}
case "n":
if wf.Phase == workflow.PhaseExecuting {
current := wf.CurrentStep()
if current != nil {
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, continueWorkflowCmd(m.orch, "proceeding")
}
}
case "x":
wf.Reset()
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Workflow reset."))
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func (m *Model) handlePreview(files []workflow.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 opened in browser: http://127.0.0.1:8765"))
}
}
func (m *Model) resizeViewport() {
headerH := 4
footerH := 2
inputH := 0
if m.activeTab == tabChat || m.activeTab == tabWorkflow {
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 checkNeedsSudo(scan *scanner.ScanResult) bool {
if scan == nil {
return false
}
sudoTools := map[string]bool{
"docker": true, "git": true, "gh": true, "node": true, "python": true,
}
for _, t := range scan.Tools {
if !t.Installed && sudoTools[t.Name] {
return true
}
}
return false
}
func hasSudo() bool {
if os.Geteuid() == 0 {
return true
}
if _, err := exec.LookPath("sudo"); err == nil {
return true
}
if _, err := exec.LookPath("pkexec"); err == nil {
return true
}
return false
}
func startInstallCmd(cfg *config.MuyueConfig, tools []string, index int) tea.Cmd {
return tea.Cmd(func() tea.Msg {
inst := installer.New(cfg)
result := inst.InstallTool(tools[index])
if index+1 < len(tools) {
return installBatchMsg{
result: result,
tools: tools,
index: index,
config: cfg,
}
}
return installCompleteMsg{results: []installer.InstallResult{result}}
})
}
type installBatchMsg struct {
result installer.InstallResult
tools []string
index int
config *config.MuyueConfig
}
func sendAIMessage(orch *orchestrator.Orchestrator, input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
if orch == nil {
return aiErrMsg{err: fmt.Errorf("orchestrator not configured")}
}
resp, err := orch.Send(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func startWorkflowCmd(orch *orchestrator.Orchestrator, goal string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.StartWorkflow(goal)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func workflowChatCmd(orch *orchestrator.Orchestrator, input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
wf := orch.Workflow
switch wf.Phase {
case workflow.PhaseGathering:
resp, err := orch.AnswerQuestion(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
case workflow.PhaseReviewing:
approved, feedback := workflow.ParseApproval(input)
resp, err := orch.ReviewPlan(approved, feedback)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
default:
resp, err := orch.Send(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
}
})
}
func generatePlanCmd(orch *orchestrator.Orchestrator) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.GeneratePlan()
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func reviewPlanCmd(orch *orchestrator.Orchestrator, approved bool, feedback string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.ReviewPlan(approved, feedback)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func continueWorkflowCmd(orch *orchestrator.Orchestrator, output string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.ContinueExecution(output)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func (m Model) View() string {
if !m.ready {
return "Loading..."
}
if m.showingQuit {
return m.renderQuitOverlay()
}
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(m.viewport.View())
if m.activeTab == tabChat || m.activeTab == tabWorkflow {
b.WriteString("\n")
b.WriteString(m.renderChatInput())
}
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
func (m Model) renderQuitOverlay() string {
yesStyle := confirmNoStyle
noStyle := confirmYesStyle
if m.confirmCursor == 0 {
yesStyle = confirmYesStyle
noStyle = confirmNoStyle
}
box := fmt.Sprintf("\n\n Quit muyue?\n\n %s %s",
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) renderHeader() string {
logo := logoStyle.Render("muyue")
badge := versionBadgeStyle.Render(" v" + version.Version + " ")
separator := lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-lipgloss.Width(logo)-lipgloss.Width(badge)-4, 0)))
topLine := lipgloss.JoinHorizontal(lipgloss.Center, " ", logo, " ", badge, " ", separator)
tabs := make([]string, len(tabNames))
for i, name := range tabNames {
if tab(i) == m.activeTab {
tabs[i] = activeTabStyle.Render(name)
} else {
tabs[i] = tabStyle.Render(name)
}
}
tabsRow := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...)
return lipgloss.JoinVertical(lipgloss.Left, topLine, tabsRow)
}
func (m Model) renderContent() string {
switch m.activeTab {
case tabDashboard:
return m.renderDashboard()
case tabChat:
return m.renderChat()
case tabWorkflow:
return m.renderWorkflow()
case tabAgents:
return m.renderAgents()
case tabConfig:
return m.renderConfig()
default:
return ""
}
}
func (m Model) renderDashboard() string {
var b strings.Builder
b.WriteString(sectionStyle.Render("System"))
b.WriteString("\n")
if m.scanResult != nil {
sysInfo := m.scanResult.System.String()
b.WriteString(" ")
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(sysInfo))
}
b.WriteString("\n\n")
b.WriteString(sectionStyle.Render("Tools"))
b.WriteString("\n")
if m.scanResult != nil {
installed := 0
total := len(m.scanResult.Tools)
for _, t := range m.scanResult.Tools {
if t.Installed {
installed++
b.WriteString(" ")
b.WriteString(itemOKStyle.Render(" "))
b.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, extractVersion(t.Version)))
} else {
b.WriteString(" ")
b.WriteString(itemMissingStyle.Render(" "))
b.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, itemPendingStyle.Render("(not installed)")))
}
}
barWidth := 20
pct := 0
if total > 0 {
pct = (installed * barWidth) / total
}
bar := lipgloss.NewStyle().Foreground(successColor).Render(strings.Repeat("█", pct)) +
lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("░", barWidth-pct))
b.WriteString(fmt.Sprintf("\n %s %d/%d tools installed\n", bar, installed, total))
}
b.WriteString("\n")
if len(m.updateStatus) > 0 {
b.WriteString(sectionStyle.Render("Updates"))
b.WriteString("\n")
for _, s := range m.updateStatus {
if s.NeedsUpdate {
b.WriteString(" ")
b.WriteString(itemWarnStyle.Render(" "))
b.WriteString(fmt.Sprintf(" %s: %s -> %s\n", s.Tool, s.Current, s.Latest))
} else if s.Error == "" {
b.WriteString(" ")
b.WriteString(itemOKStyle.Render(" "))
b.WriteString(fmt.Sprintf(" %s: up to date\n", s.Tool))
}
}
b.WriteString("\n")
}
if m.installing {
b.WriteString(sectionStyle.Render("Installing..."))
b.WriteString("\n")
progBar := m.progressBar.View()
label := ""
if m.installTool != "" {
label = fmt.Sprintf(" %d/%d - %s", m.installCurrent+1, m.installTotal, m.installTool)
} else {
label = fmt.Sprintf(" %d/%d", m.installCurrent, m.installTotal)
}
b.WriteString(fmt.Sprintf(" %s%s\n", progBar, label))
b.WriteString("\n")
}
if len(m.installLog) > 0 {
b.WriteString(sectionStyle.Render("Install Log"))
b.WriteString("\n")
for _, l := range m.installLog {
b.WriteString(l + "\n")
}
b.WriteString("\n")
}
actions := []struct {
key string
desc string
}{
{"i", "Install missing tools"},
{"u", "Check for updates"},
{"s", "Rescan system"},
{"l", "Scan LSP servers"},
{"m", "Configure MCP servers"},
}
b.WriteString(sectionStyle.Render("Quick Actions"))
b.WriteString("\n")
for _, a := range actions {
b.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("["+a.key+"]"),
a.desc))
}
b.WriteString("\n")
if len(m.lspServers) > 0 {
b.WriteString(sectionStyle.Render("LSP Servers"))
b.WriteString("\n")
lspInstalled := 0
for _, s := range m.lspServers {
if s.Installed {
lspInstalled++
b.WriteString(" ")
b.WriteString(itemOKStyle.Render(" "))
b.WriteString(fmt.Sprintf(" %-28s (%s)\n", s.Name, s.Language))
} else {
b.WriteString(" ")
b.WriteString(itemPendingStyle.Render(" "))
b.WriteString(fmt.Sprintf(" %-28s (%s)\n", s.Name, s.Language))
}
}
b.WriteString(fmt.Sprintf("\n Installed: %d/%d\n", lspInstalled, len(m.lspServers)))
b.WriteString("\n")
}
if m.daemon != nil {
b.WriteString(sectionStyle.Render("Update Daemon"))
b.WriteString("\n")
if m.daemon.IsRunning() {
b.WriteString(" ")
b.WriteString(itemOKStyle.Render("running"))
lastCheck := m.daemon.LastCheck()
if !lastCheck.IsZero() {
b.WriteString(fmt.Sprintf(" last check: %s", lastCheck.Format("15:04:05")))
}
} else {
b.WriteString(" ")
b.WriteString(itemPendingStyle.Render("stopped"))
}
b.WriteString("\n")
logs := m.daemon.Logs()
if len(logs) > 3 {
logs = logs[len(logs)-3:]
}
for _, l := range logs {
b.WriteString(" " + itemPendingStyle.Render(l) + "\n")
}
b.WriteString("\n")
}
mcpStatus := itemPendingStyle.Render("not configured")
if m.mcpConfigured {
mcpStatus = itemOKStyle.Render("configured")
}
b.WriteString(fmt.Sprintf("MCP Servers: %s\n", mcpStatus))
return b.String()
}
func (m Model) renderChat() string {
var b strings.Builder
header := sectionStyle.Render("Chat")
header += " "
header += lipgloss.NewStyle().Foreground(mutedColor).Render("(" + m.config.Profile.Preferences.DefaultAI + ")")
if m.chatLoading {
header += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warningColor).Render("thinking...")
}
b.WriteString(header)
b.WriteString("\n\n")
separator := lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-4, 10)))
b.WriteString(separator)
b.WriteString("\n\n")
for _, msg := range m.chatLog {
b.WriteString(msg)
b.WriteString("\n\n")
}
if m.previewURL != "" {
b.WriteString(itemOKStyle.Render(fmt.Sprintf("Preview: %s", m.previewURL)))
b.WriteString("\n\n")
}
return b.String()
}
func (m Model) renderChatInput() string {
if m.chatLoading {
return inputStyle.Render("> ") + m.spinner.View() + lipgloss.NewStyle().Foreground(mutedColor).Render(" waiting for response...")
}
cursor := lipgloss.NewStyle().Foreground(baseColor).Render("")
return inputStyle.Render("> ") + m.chatInput + cursor
}
func (m Model) renderWorkflow() string {
var b strings.Builder
if m.orch == nil || m.orch.Workflow == nil {
b.WriteString("Workflow engine not available.")
return b.String()
}
wf := m.orch.Workflow
b.WriteString(sectionStyle.Render("Workflow"))
b.WriteString(" ")
phaseColors := map[workflow.Phase]lipgloss.Style{
workflow.PhaseIdle: lipgloss.NewStyle().Foreground(mutedColor),
workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warningColor).Bold(true),
workflow.PhasePlanning: lipgloss.NewStyle().Foreground(accentColor).Bold(true),
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(aiColor).Bold(true),
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(baseColor).Bold(true),
workflow.PhaseDone: lipgloss.NewStyle().Foreground(successColor).Bold(true),
workflow.PhaseError: lipgloss.NewStyle().Foreground(errorColor).Bold(true),
}
if style, ok := phaseColors[wf.Phase]; ok {
b.WriteString(style.Render(string(wf.Phase)))
}
b.WriteString("\n\n")
if wf.Plan.Goal != "" {
b.WriteString(fmt.Sprintf("Goal: %s\n\n", wf.Plan.Goal))
}
switch wf.Phase {
case workflow.PhaseIdle:
b.WriteString("No active workflow.\n")
b.WriteString("Type /plan <goal> to start a structured workflow.\n")
b.WriteString("Example: /plan Create a REST API in Go\n")
case workflow.PhaseGathering:
b.WriteString(sectionStyle.Render("Gathering Requirements"))
b.WriteString("\n")
for i, q := range wf.Plan.Questions {
icon := itemPendingStyle.Render(" ")
if i < len(wf.Plan.Answers) {
icon = itemOKStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q))
b.WriteString(fmt.Sprintf(" A: %s\n", wf.Plan.Answers[i]))
} else {
b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q))
}
}
if len(wf.Plan.Answers) >= len(wf.Plan.Questions) && len(wf.Plan.Questions) > 0 {
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[g] Generate plan"))
b.WriteString("\n")
}
case workflow.PhasePlanning:
b.WriteString(m.spinner.View())
b.WriteString(" ")
b.WriteString(itemWarnStyle.Render("Generating plan..."))
b.WriteString("\n")
case workflow.PhaseReviewing:
b.WriteString(sectionStyle.Render("Plan (review before execution)"))
b.WriteString("\n\n")
for i, s := range wf.Plan.Steps {
numStyle := lipgloss.NewStyle().Foreground(accentColor).Bold(true)
icon := stepPendingStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s %s %s\n", icon, numStyle.Render("#"+s.ID+":"), s.Title))
b.WriteString(fmt.Sprintf(" %s\n", lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Description)))
agentStyle := lipgloss.NewStyle().Foreground(aiColor).Render(s.Agent)
b.WriteString(fmt.Sprintf(" Agent: %s\n", agentStyle))
if i < len(wf.Plan.Steps)-1 {
b.WriteString("\n")
}
}
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[a] Approve plan"))
b.WriteString(" ")
b.WriteString(itemMissingStyle.Render("[r] Reject with feedback"))
b.WriteString("\n")
if len(wf.Plan.PreviewFiles) > 0 {
b.WriteString("\n ")
b.WriteString(itemWarnStyle.Render("Preview files available (opened in browser)"))
b.WriteString("\n")
}
case workflow.PhaseExecuting:
b.WriteString(sectionStyle.Render("Executing Plan"))
b.WriteString("\n\n")
done, total := wf.Progress()
m.progressBar.SetPercent(float64(done) / float64(max(total, 1)))
fmt.Fprintf(&b, " %s %d/%d\n\n", m.progressBar.View(), done, total)
for _, s := range wf.Plan.Steps {
var icon string
switch s.Status {
case "done":
icon = stepDoneStyle.Render(" ")
case "error":
icon = stepErrorStyle.Render(" ")
default:
if wf.Plan.Steps[wf.Plan.StepIndex].ID == s.ID {
icon = stepCurrentStyle.Render(">")
} else {
icon = stepPendingStyle.Render(" ")
}
}
b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title))
if s.Output != "" {
output := s.Output
if len(output) > 80 {
output = output[:80] + "..."
}
b.WriteString(fmt.Sprintf(" %s\n", output))
}
}
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[n] Next step"))
b.WriteString(" ")
b.WriteString(itemMissingStyle.Render("[x] Cancel workflow"))
b.WriteString("\n")
case workflow.PhaseDone:
b.WriteString(itemOKStyle.Render("Workflow completed!"))
b.WriteString("\n\n")
for _, s := range wf.Plan.Steps {
icon := stepDoneStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title))
}
b.WriteString("\n [x] Reset workflow\n")
case workflow.PhaseError:
b.WriteString(itemMissingStyle.Render("Workflow encountered an error."))
b.WriteString("\n [x] Reset workflow\n")
}
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-4, 10))))
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Chat"))
b.WriteString("\n")
for _, msg := range m.chatLog {
lines := strings.Split(msg, "\n")
for _, line := range lines {
if len(line) > m.width-4 {
line = line[:m.width-7] + "..."
}
b.WriteString(" " + line + "\n")
}
}
return b.String()
}
func (m Model) renderAgents() string {
var b strings.Builder
b.WriteString(sectionStyle.Render("Background Agents"))
b.WriteString("\n\n")
agents := []struct {
name string
agentType proxy.AgentType
tool string
}{
{"Crush", proxy.AgentCrush, "Z.AI GLM"},
{"Claude Code", proxy.AgentClaude, "Anthropic Claude"},
}
for _, a := range agents {
status, logs := m.proxyMgr.Status(a.agentType)
available := m.proxyMgr.IsAvailable(a.agentType)
var statusStr string
switch status {
case proxy.StatusRunning:
statusStr = itemWarnStyle.Render(" running")
case proxy.StatusStopped:
statusStr = itemMissingStyle.Render(" stopped")
case proxy.StatusError:
statusStr = itemMissingStyle.Render(" error")
default:
if available {
statusStr = itemOKStyle.Render(" available")
} else {
statusStr = itemMissingStyle.Render(" not installed")
}
}
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Bold(true)
b.WriteString(fmt.Sprintf(" %s %s %s\n", nameStyle.Render(a.name), statusStr,
lipgloss.NewStyle().Foreground(mutedColor).Render("("+a.tool+")")))
if logs != nil && len(logs) > 0 {
lastLogs := logs
if len(logs) > 5 {
lastLogs = logs[len(logs)-5:]
}
for _, l := range lastLogs {
b.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(dimColor).Render(l.Timestamp.Format("15:04:05")),
l.Message))
}
}
}
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Actions"))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s Start Crush\n", lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("[c]")))
b.WriteString(fmt.Sprintf(" %s Start Claude Code\n", lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("[l]")))
return b.String()
}
func (m Model) renderConfig() string {
var b strings.Builder
b.WriteString(sectionStyle.Render("Profile"))
b.WriteString("\n")
if m.config != nil {
fields := []struct {
label string
value string
}{
{"Name", m.config.Profile.Name},
{"Pseudo", m.config.Profile.Pseudo},
{"Email", m.config.Profile.Email},
{"Editor", m.config.Profile.Preferences.Editor},
{"Shell", m.config.Profile.Preferences.Shell},
{"Theme", m.config.Profile.Preferences.Theme},
{"Default AI", m.config.Profile.Preferences.DefaultAI},
}
for _, f := range fields {
labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14)
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0"))
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render(f.label+":"), valueStyle.Render(f.value)))
}
if len(m.config.Profile.Languages) > 0 {
labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14)
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0"))
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Languages:"), valueStyle.Render(strings.Join(m.config.Profile.Languages, ", "))))
}
}
b.WriteString("\n")
b.WriteString(sectionStyle.Render("AI Providers"))
b.WriteString("\n")
if m.config != nil {
for _, p := range m.config.AI.Providers {
active := ""
if p.Active {
active = itemOKStyle.Render(" active")
}
keyStatus := itemMissingStyle.Render("no key")
if p.APIKey != "" {
keyStatus = itemOKStyle.Render("configured")
}
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Bold(true)
b.WriteString(fmt.Sprintf(" %s model=%s key=%s%s\n",
nameStyle.Render(p.Name), p.Model, keyStatus, active))
}
}
b.WriteString("\n")
b.WriteString(sectionStyle.Render("BMAD Method"))
b.WriteString("\n")
if m.config != nil {
installed := itemMissingStyle.Render("no")
if m.config.BMAD.Installed {
installed = itemOKStyle.Render("yes")
}
b.WriteString(fmt.Sprintf(" Installed: %s\n", installed))
b.WriteString(fmt.Sprintf(" Global: %v\n", m.config.BMAD.Global))
}
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Terminal"))
b.WriteString("\n")
if m.config != nil {
b.WriteString(fmt.Sprintf(" Custom Prompt: %v\n", m.config.Terminal.CustomPrompt))
b.WriteString(fmt.Sprintf(" Prompt Theme: %s\n", m.config.Terminal.PromptTheme))
}
b.WriteString("\n")
b.WriteString(sectionStyle.Render(fmt.Sprintf("Skills (%d)", len(m.skillList))))
b.WriteString("\n")
if len(m.skillList) > 0 {
for _, s := range m.skillList {
target := s.Target
if target == "" {
target = "both"
}
b.WriteString(fmt.Sprintf(" %-20s %s %s\n",
lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Name),
lipgloss.NewStyle().Foreground(aiColor).Render("["+target+"]"),
s.Description))
}
} else {
b.WriteString(" No skills. Run `muyue skills init` to install built-ins.\n")
}
return b.String()
}
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", profile, version.Name)
leftR := statusBarStyle.Render(left)
var helpText string
switch m.activeTab {
case tabDashboard:
helpText = "[i] install [u] update [s] scan [l] lsp [m] mcp"
case tabChat, tabWorkflow:
helpText = "[alt+1-5] tabs [tab] next [ctrl+c] quit"
case tabAgents:
helpText = "[c] crush [l] claude"
default:
helpText = "[alt+1-5] tabs [tab] next [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().Foreground(dimColor).Render(
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys))))
}
func extractVersion(s string) string {
return versionRegex.FindString(s)
}
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)