diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7cfac59..86cde38 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,17 +21,37 @@ jobs: export PATH=/usr/local/go/bin:$PATH go version - - name: Build + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + /root/go/pkg/mod + /home/runner/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies run: | export PATH=/usr/local/go/bin:$PATH - go build -o muyue ./cmd/muyue/ - ./muyue version + go mod download - name: Vet run: | export PATH=/usr/local/go/bin:$PATH go vet ./... + - name: Test + run: | + export PATH=/usr/local/go/bin:$PATH + go test ./... -v -race -timeout 60s + + - name: Build + run: | + export PATH=/usr/local/go/bin:$PATH + go build -o muyue ./cmd/muyue/ + ./muyue version + - name: Build all platforms if: github.event_name == 'push' run: | diff --git a/.gitignore b/.gitignore index 3426056..0aa437d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,14 @@ /muyue dist/ +# Crush local data +.crush/ + +# Databases +*.db +*.db-wal +*.db-shm + # IDE .idea/ .vscode/ @@ -17,3 +25,6 @@ Thumbs.db *.test *.out vendor/ + +# Config with secrets +.muyue/ diff --git a/Makefile b/Makefile index e4eff4a..5579ee7 100644 --- a/Makefile +++ b/Makefile @@ -30,10 +30,10 @@ scan: build fmt: gofmt -w . - $(GO)imports -w . + which goimports > /dev/null 2>&1 && goimports -w . || true lint: - golangci-lint run + which golangci-lint > /dev/null 2>&1 && golangci-lint run || true build-all: GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY)-linux-amd64 ./cmd/muyue/ diff --git a/cmd/muyue/main.go b/cmd/muyue/main.go index b28e3dc..3e2b798 100644 --- a/cmd/muyue/main.go +++ b/cmd/muyue/main.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "os/exec" - "strings" tea "github.com/charmbracelet/bubbletea" "github.com/muyue/muyue/internal/config" @@ -518,22 +517,3 @@ func runSkills(args []string) { fmt.Println("Available: list, show, generate, deploy, init, delete") } } - -func checkConfigProviders(cfg *config.MuyueConfig) { - for i := range cfg.AI.Providers { - if cfg.AI.Providers[i].Active { - return - } - } - if len(cfg.AI.Providers) > 0 { - cfg.AI.Providers[0].Active = true - } -} - -func joinWithQuotes(items []string) string { - quoted := make([]string, len(items)) - for i, item := range items { - quoted[i] = fmt.Sprintf("%q", item) - } - return strings.Join(quoted, ", ") -} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index cea2d9f..2e9a986 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -32,7 +32,7 @@ func NewDaemon(cfg *config.MuyueConfig, interval time.Duration) *Daemon { return &Daemon{ config: cfg, interval: interval, - stopCh: make(chan struct{}), + stopCh: make(chan struct{}, 1), logs: []string{}, } } diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 60fb54e..edad4ff 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -210,10 +210,18 @@ func (i *Installer) installNode() InstallResult { } case platform.MacOS: cmd := exec.Command("brew", "install", "node") - cmd.Run() + if output, err := cmd.CombinedOutput(); err != nil { + return InstallResult{Tool: "node", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} + } case platform.Windows: cmd := exec.Command("winget", "install", "OpenJS.NodeJS.LTS") - cmd.Run() + if output, err := cmd.CombinedOutput(); err != nil { + return InstallResult{Tool: "node", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} + } + default: + return InstallResult{Tool: "node", Success: false, Message: "unsupported OS"} } return InstallResult{Tool: "node", Success: true, Message: "installed"} @@ -226,11 +234,22 @@ func (i *Installer) installPython() InstallResult { switch i.system.PackageManager { case "apt": - exec.Command("apt", "install", "-y", "python3", "python3-pip").Run() + if output, err := exec.Command("apt", "install", "-y", "python3", "python3-pip").CombinedOutput(); err != nil { + return InstallResult{Tool: "python", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} + } case "brew": - exec.Command("brew", "install", "python3").Run() + if output, err := exec.Command("brew", "install", "python3").CombinedOutput(); err != nil { + return InstallResult{Tool: "python", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} + } case "winget": - exec.Command("winget", "install", "Python.Python.3.12").Run() + if output, err := exec.Command("winget", "install", "Python.Python.3.12").CombinedOutput(); err != nil { + return InstallResult{Tool: "python", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} + } + default: + return InstallResult{Tool: "python", Success: false, Message: fmt.Sprintf("install via '%s' not supported", i.system.PackageManager)} } return InstallResult{Tool: "python", Success: true, Message: "installed"} @@ -243,11 +262,22 @@ func (i *Installer) installGit() InstallResult { switch i.system.PackageManager { case "apt": - exec.Command("apt", "install", "-y", "git").Run() + if output, err := exec.Command("apt", "install", "-y", "git").CombinedOutput(); err != nil { + return InstallResult{Tool: "git", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} + } case "brew": - exec.Command("brew", "install", "git").Run() + if output, err := exec.Command("brew", "install", "git").CombinedOutput(); err != nil { + return InstallResult{Tool: "git", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} + } case "winget": - exec.Command("winget", "install", "Git.Git").Run() + if output, err := exec.Command("winget", "install", "Git.Git").CombinedOutput(); err != nil { + return InstallResult{Tool: "git", Success: false, + Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} + } + default: + return InstallResult{Tool: "git", Success: false, Message: fmt.Sprintf("install via '%s' not supported", i.system.PackageManager)} } if i.config.Profile.Name != "" { diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index 4e66ce6..6e8ece5 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "github.com/muyue/muyue/internal/config" ) @@ -158,9 +157,9 @@ func GenerateCrushConfig(cfg *config.MuyueConfig) error { existing, err := os.ReadFile(lspPath) if err == nil { var existingConfig map[string]interface{} - if json.Unmarshal(existing, &existingConfig) == nil { + if unmarshalErr := json.Unmarshal(existing, &existingConfig); unmarshalErr == nil { var newConfig map[string]interface{} - if json.Unmarshal(data, &newConfig) == nil { + if unmarshalErr2 := json.Unmarshal(data, &newConfig); unmarshalErr2 == nil { for k, v := range newConfig { existingConfig[k] = v } @@ -194,16 +193,3 @@ func EnsureCrushConfig(cfg *config.MuyueConfig) error { return nil } - -func PlatformTool() string { - switch runtime.GOOS { - case "linux": - return "apt" - case "darwin": - return "brew" - case "windows": - return "winget" - default: - return "unknown" - } -} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 3b38ea6..8811a77 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -127,11 +127,11 @@ func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error { existing := map[string]interface{}{} data, err := os.ReadFile(crusherPath) if err == nil { - json.Unmarshal(data, &existing) + if jsonErr := json.Unmarshal(data, &existing); jsonErr != nil { + existing = map[string]interface{}{} + } } - mcps := map[string]interface{}{} - core := []MCPServer{ {Name: "filesystem", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}}, {Name: "fetch", Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"}}, @@ -157,6 +157,8 @@ func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error { } } + mcps := map[string]interface{}{} + for _, s := range core { entry := map[string]interface{}{ "command": s.Command, @@ -189,7 +191,9 @@ func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { existing := map[string]interface{}{} data, err := os.ReadFile(configPath) if err == nil { - json.Unmarshal(data, &existing) + if jsonErr := json.Unmarshal(data, &existing); jsonErr != nil { + existing = map[string]interface{}{} + } } mcpservers := map[string]interface{}{} @@ -241,7 +245,10 @@ func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { } func ConfigureAll(cfg *config.MuyueConfig) error { - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } if err := GenerateCrushMCPConfig(cfg, home); err != nil { return fmt.Errorf("crush MCP config: %w", err) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index d055599..d994cd7 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -14,6 +14,10 @@ import ( "github.com/muyue/muyue/internal/workflow" ) +var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) + +const maxHistorySize = 100 + type Message struct { Role string `json:"role"` Content string `json:"content"` @@ -78,6 +82,10 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { Content: userMessage, }) + if len(o.history) > maxHistorySize { + o.history = o.history[len(o.history)-maxHistorySize:] + } + reqBody := ChatRequest{ Model: o.provider.Model, Messages: o.history, @@ -139,11 +147,69 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { func (o *Orchestrator) StartWorkflow(goal string) (string, error) { o.Workflow.Start(goal) + prompt := fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal) o.history = []Message{ {Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)}, - {Role: "user", Content: fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me?", goal)}, + {Role: "user", Content: prompt}, } - return o.Send(fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal)) + + reqBody := ChatRequest{ + Model: o.provider.Model, + Messages: o.history, + Stream: false, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + baseURL := o.provider.BaseURL + if baseURL == "" { + baseURL = getProviderBaseURL(o.provider.Name) + } + + url := strings.TrimRight(baseURL, "/") + "/chat/completions" + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+o.provider.APIKey) + + resp, err := o.client.Do(req) + if err != nil { + return "", fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var chatResp ChatResponse + if err := json.Unmarshal(respBody, &chatResp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("no response from AI") + } + + content := cleanAIResponse(chatResp.Choices[0].Message.Content) + o.history = append(o.history, Message{ + Role: "assistant", + Content: content, + }) + + return content, nil } func (o *Orchestrator) AnswerQuestion(answer string) (string, error) { @@ -224,8 +290,7 @@ func (o *Orchestrator) ClearHistory() { } func cleanAIResponse(content string) string { - thinkRe := regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) - content = thinkRe.ReplaceAllString(content, "") + content = thinkRegex.ReplaceAllString(content, "") lines := strings.Split(content, "\n") var clean []string inBlock := false diff --git a/internal/preview/preview.go b/internal/preview/preview.go index a4ef1e3..1e5b155 100644 --- a/internal/preview/preview.go +++ b/internal/preview/preview.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "runtime" + "time" ) type PreviewServer struct { @@ -24,8 +25,10 @@ func (p *PreviewServer) Start(port int) error { mux.Handle("/", fs) p.server = &http.Server{ - Addr: fmt.Sprintf("127.0.0.1:%d", port), - Handler: mux, + Addr: fmt.Sprintf("127.0.0.1:%d", port), + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, } go func() { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 0911907..1e16c17 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -81,8 +81,16 @@ func (m *Manager) Start(agentType AgentType, args ...string) error { cmd := exec.CommandContext(ctx, cmdName, args...) cmd.Env = os.Environ() - stdout, _ := cmd.StdoutPipe() - stderr, _ := cmd.StderrPipe() + stdout, pipeErr := cmd.StdoutPipe() + if pipeErr != nil { + cancel() + return fmt.Errorf("stdout pipe: %w", pipeErr) + } + stderr, pipeErr := cmd.StderrPipe() + if pipeErr != nil { + cancel() + return fmt.Errorf("stderr pipe: %w", pipeErr) + } agent := &Agent{ Type: agentType, @@ -164,23 +172,6 @@ func (m *Manager) AllStatus() map[AgentType]AgentStatus { return statuses } -func (m *Manager) SendCommand(agentType AgentType, input string) error { - m.mu.RLock() - agent, exists := m.agents[agentType] - m.mu.RUnlock() - - if !exists || agent.Status != StatusRunning { - return fmt.Errorf("%s is not running", agentType) - } - - stdin, err := agent.cmd.StdinPipe() - if err != nil { - return fmt.Errorf("get stdin: %w", err) - } - - _, err = fmt.Fprintln(stdin, input) - return err -} func (a *Agent) captureOutput(reader io.Reader, level string) { scanner := bufio.NewScanner(reader) diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 0d3c552..22757e6 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -157,24 +157,39 @@ func Delete(name string) error { } func Deploy(skill *Skill) error { - home, _ := os.UserHomeDir() + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) { crushSkillsDir := filepath.Join(home, ".config", "crush", "skills") - os.MkdirAll(crushSkillsDir, 0755) + if err := os.MkdirAll(crushSkillsDir, 0755); err != nil { + return fmt.Errorf("create crush skills dir: %w", err) + } target := filepath.Join(crushSkillsDir, skill.Name) - os.MkdirAll(target, 0755) + if err := os.MkdirAll(target, 0755); err != nil { + return fmt.Errorf("create skill dir: %w", err) + } content := renderSkill(skill) - os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644) + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644); err != nil { + return fmt.Errorf("write crush skill: %w", err) + } } if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) { claudeSkillsDir := filepath.Join(home, ".claude", "skills") - os.MkdirAll(claudeSkillsDir, 0755) + if err := os.MkdirAll(claudeSkillsDir, 0755); err != nil { + return fmt.Errorf("create claude skills dir: %w", err) + } target := filepath.Join(claudeSkillsDir, skill.Name) - os.MkdirAll(target, 0755) + if err := os.MkdirAll(target, 0755); err != nil { + return fmt.Errorf("create skill dir: %w", err) + } content := renderSkill(skill) - os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644) + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte(content), 0644); err != nil { + return fmt.Errorf("write claude skill: %w", err) + } } return nil diff --git a/internal/tui/agents.go b/internal/tui/agents.go new file mode 100644 index 0000000..90f913a --- /dev/null +++ b/internal/tui/agents.go @@ -0,0 +1,87 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + tea "github.com/charmbracelet/bubbletea" + "github.com/muyue/muyue/internal/proxy" +) + +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) 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 +} diff --git a/internal/tui/app.go b/internal/tui/app.go deleted file mode 100644 index ee68682..0000000 --- a/internal/tui/app.go +++ /dev/null @@ -1,1707 +0,0 @@ -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 - tabTerminal - tabAgents - tabConfig - tabCount -) - -var tabNames = []string{"Dashboard", "Chat", "Workflow", "Terminal", "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") - - 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) - - stepDoneStyle = lipgloss.NewStyle(). - Foreground(successColor) - - stepPendingStyle = lipgloss.NewStyle(). - Foreground(mutedColor) - - stepCurrentStyle = lipgloss.NewStyle(). - Foreground(baseColor). - Bold(true) - - stepErrorStyle = lipgloss.NewStyle(). - Foreground(errorColor) - - 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) -) - -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 termOutputMsg struct{ line string } -type termExitMsg struct{} - -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 - showingTabMenu bool - tabMenuCursor int - - ctrlCCount int - lastCtrlC time.Time - - installing bool - installCurrent int - installTotal int - installTool string - - termCmd *exec.Cmd - termInput string - termLog []string - termRunning bool - termCwd string -} - -type keyMap struct { - Tab key.Binding - Prev key.Binding - Quit key.Binding - Confirm key.Binding - Cancel key.Binding - TabMenu 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", "indent"), - ), - Prev: key.NewBinding( - key.WithKeys("shift+tab"), - key.WithHelp("shift+tab", "unindent"), - ), - 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"), - ), - TabMenu: key.NewBinding( - key.WithKeys("ctrl+t"), - key.WithHelp("ctrl+t", "switch tab"), - ), - 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("backspace", "delete"), - ), -} - -func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.TabMenu, k.Tab, k.Quit} -} - -func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.TabMenu, 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")) - - cwd, _ := os.Getwd() - - 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 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, - showingTabMenu: false, - tabMenuCursor: 0, - termCwd: cwd, - } -} - -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 termOutputMsg: - m.termLog = append(m.termLog, msg.line) - if m.activeTab == tabTerminal { - m.viewport.SetContent(m.renderContent()) - m.viewport.GotoBottom() - } - return m, nil - case termExitMsg: - m.termRunning = false - m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(mutedColor).Render("(process exited)")) - m.termCmd = nil - if m.activeTab == tabTerminal { - m.viewport.SetContent(m.renderContent()) - } - return m, nil - 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 := 1 - footerH := 2 - inputH := 0 - if m.activeTab == tabChat || m.activeTab == tabWorkflow { - inputH = 2 - } - if m.activeTab == tabTerminal { - 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) - } - if m.showingTabMenu { - return m.handleTabMenu(msg) - } - - if m.activeTab == tabTerminal { - return m.handleTerminalKey(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 "ctrl+t": - m.showingTabMenu = true - m.tabMenuCursor = int(m.activeTab) - m.viewport.SetContent(m.renderContent()) - 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) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - if m.termCmd != nil && m.termCmd.Process != nil { - m.termCmd.Process.Kill() - m.termRunning = false - m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorColor).Render("^C")) - m.termCmd = nil - m.viewport.SetContent(m.renderContent()) - return m, nil - } - 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 "ctrl+t": - m.showingTabMenu = true - m.tabMenuCursor = int(m.activeTab) - return m, nil - case "enter": - if m.termRunning { - return m, nil - } - input := strings.TrimSpace(m.termInput) - m.termInput = "" - if input == "" { - return m, nil - } - if input == "exit" || input == "quit" { - return m, nil - } - if input == "clear" { - m.termLog = nil - m.viewport.SetContent(m.renderContent()) - return m, nil - } - if strings.HasPrefix(input, "cd ") { - dir := strings.TrimPrefix(input, "cd ") - dir = strings.TrimSpace(dir) - if dir == "~" { - home, _ := os.UserHomeDir() - dir = home - } - if err := os.Chdir(dir); err == nil { - m.termCwd, _ = os.Getwd() - m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input)) - } else { - m.termLog = append(m.termLog, errMsgStyle.Render("cd: "+err.Error())) - } - m.viewport.SetContent(m.renderContent()) - m.viewport.GotoBottom() - return m, nil - } - m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ ")+input) - m.viewport.SetContent(m.renderContent()) - m.viewport.GotoBottom() - return m, m.runTermCommand(input) - case "backspace": - if len(m.termInput) > 0 { - m.termInput = m.termInput[:len(m.termInput)-1] - m.viewport.SetContent(m.renderContent()) - } - return m, nil - case "tab": - if m.activeTab == tabChat || m.activeTab == tabWorkflow { - m.chatInput += "\t" - m.viewport.SetContent(m.renderContent()) - } - default: - if len(msg.String()) == 1 { - m.termInput += msg.String() - m.viewport.SetContent(m.renderContent()) - } - } - return m, nil -} - -func (m Model) runTermCommand(input string) tea.Cmd { - return tea.Cmd(func() tea.Msg { - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/sh" - } - cmd := exec.Command(shell, "-c", input) - cmd.Dir = m.termCwd - out, err := cmd.CombinedOutput() - if err != nil { - return termOutputMsg{line: string(out) + errMsgStyle.Render(err.Error())} - } - return termOutputMsg{line: string(out)} - }) -} - -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) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "esc": - m.showingTabMenu = false - m.viewport.SetContent(m.renderContent()) - return m, nil - case "up", "k": - if m.tabMenuCursor > 0 { - m.tabMenuCursor-- - } - return m, nil - case "down", "j": - if m.tabMenuCursor < int(tabCount)-1 { - m.tabMenuCursor++ - } - return m, nil - case "enter": - m.activeTab = tab(m.tabMenuCursor) - m.showingTabMenu = false - m.resizeViewport() - return m, nil - default: - for i := 0; i < int(tabCount); i++ { - if msg.String() == fmt.Sprintf("%d", i+1) { - m.activeTab = tab(i) - m.showingTabMenu = false - m.resizeViewport() - return m, nil - } - } - } - return m, nil -} - -func (m Model) renderTabMenuOverlay() string { - tabMenuStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(baseColor). - Background(bgCard). - Padding(1, 3) - - tabItemStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A0A0B0")). - Padding(0, 2) - - tabItemActiveStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Background(baseColor). - Bold(true). - Padding(0, 2) - - tabNumStyle := lipgloss.NewStyle(). - Foreground(dimColor). - Width(4) - - var items []string - descs := []string{"system overview & tools", "AI chat & conversation", "plan & execute workflows", "integrated shell", "background AI agents", "profile & settings"} - - for i, name := range tabNames { - num := tabNumStyle.Render(fmt.Sprintf(" %d.", i+1)) - if i == m.tabMenuCursor { - item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(mutedColor).Render(descs[i])) - items = append(items, tabItemActiveStyle.Render(">"+item)) - } else { - item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i])) - items = append(items, tabItemStyle.Render(" "+item)) - } - } - - content := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("Switch Tab") + - "\n\n" + - strings.Join(items, "\n") + - "\n\n" + - lipgloss.NewStyle().Foreground(dimColor).Render("up/down navigate enter/select esc cancel") - - box := tabMenuStyle.Render(content) - - return lipgloss.Place(m.width, m.height, - 0.5, 0.5, - box, - lipgloss.WithWhitespaceBackground(bgPanel), - lipgloss.WithWhitespaceForeground(dimColor), - ) -} - -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 := 1 - footerH := 2 - inputH := 0 - if m.activeTab == tabChat || m.activeTab == tabWorkflow { - inputH = 2 - } - if m.activeTab == tabTerminal { - 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() - } - - 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 == tabChat || m.activeTab == tabWorkflow { - b.WriteString("\n") - b.WriteString(m.renderChatInput()) - } - if m.activeTab == tabTerminal { - b.WriteString("\n") - b.WriteString(m.renderTermInput()) - } - 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 { - logoStyle := lipgloss.NewStyle().Foreground(baseColor).Bold(true) - badgeStyle := lipgloss.NewStyle(). - Background(baseColor). - Foreground(lipgloss.Color("#FFFFFF")). - Padding(0, 1) - - logo := logoStyle.Render("muyue") - badge := badgeStyle.Render("v" + version.Version) - - activeTabName := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render(tabNames[m.activeTab]) - separator := lipgloss.NewStyle().Foreground(dimColor).Render(" · ") - - rightPart := separator + activeTabName - - line := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render( - lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", badge, rightPart), - ) - - return line -} - -func (m Model) renderContent() string { - switch m.activeTab { - case tabDashboard: - return m.renderDashboard() - case tabChat: - return m.renderChat() - case tabWorkflow: - return m.renderWorkflow() - case tabTerminal: - return m.renderTerminal() - case tabAgents: - return m.renderAgents() - case tabConfig: - return m.renderConfig() - default: - return "" - } -} - -func (m Model) renderTerminal() string { - var b strings.Builder - - b.WriteString(sectionStyle.Render("Terminal")) - b.WriteString(" ") - b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd)) - b.WriteString("\n\n") - - for _, line := range m.termLog { - b.WriteString(line + "\n") - } - - return b.String() -} - -func (m Model) renderTermInput() string { - prompt := lipgloss.NewStyle().Foreground(successColor).Render("$ ") - return prompt + m.termInput + lipgloss.NewStyle().Foreground(baseColor).Render("") -} - -func (m Model) renderDashboard() string { - colWidth := m.width / 2 - if colWidth < 30 { - colWidth = 30 - } - - var left, right strings.Builder - - left.WriteString(sectionStyle.Render("System")) - left.WriteString("\n") - if m.scanResult != nil { - sysInfo := m.scanResult.System.String() - left.WriteString(" ") - left.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(sysInfo)) - } - left.WriteString("\n\n") - - left.WriteString(sectionStyle.Render("Tools")) - left.WriteString("\n") - if m.scanResult != nil { - installed := 0 - total := len(m.scanResult.Tools) - for _, t := range m.scanResult.Tools { - if t.Installed { - installed++ - left.WriteString(" ") - left.WriteString(itemOKStyle.Render(" ")) - left.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, extractVersion(t.Version))) - } else { - left.WriteString(" ") - left.WriteString(itemMissingStyle.Render(" ")) - left.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)) - left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total)) - } - left.WriteString("\n") - - if m.installing { - left.WriteString(sectionStyle.Render("Installing...")) - left.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) - } - left.WriteString(fmt.Sprintf(" %s%s\n", progBar, label)) - left.WriteString("\n") - } - - if len(m.installLog) > 0 { - left.WriteString(sectionStyle.Render("Install Log")) - left.WriteString("\n") - for _, l := range m.installLog { - left.WriteString(l + "\n") - } - left.WriteString("\n") - } - - right.WriteString(sectionStyle.Render("Quick Actions")) - right.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"}, - } - for _, a := range actions { - right.WriteString(fmt.Sprintf(" %s %s\n", - lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("["+a.key+"]"), - a.desc)) - } - right.WriteString("\n") - - if len(m.updateStatus) > 0 { - right.WriteString(sectionStyle.Render("Updates")) - right.WriteString("\n") - for _, s := range m.updateStatus { - if s.NeedsUpdate { - right.WriteString(" ") - right.WriteString(itemWarnStyle.Render(" ")) - right.WriteString(fmt.Sprintf(" %s: %s -> %s\n", s.Tool, s.Current, s.Latest)) - } else if s.Error == "" { - right.WriteString(" ") - right.WriteString(itemOKStyle.Render(" ")) - right.WriteString(fmt.Sprintf(" %s: up to date\n", s.Tool)) - } - } - right.WriteString("\n") - } - - if len(m.lspServers) > 0 { - right.WriteString(sectionStyle.Render("LSP Servers")) - right.WriteString("\n") - lspInstalled := 0 - for _, s := range m.lspServers { - if s.Installed { - lspInstalled++ - right.WriteString(" ") - right.WriteString(itemOKStyle.Render(" ")) - right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language)) - } else { - right.WriteString(" ") - right.WriteString(itemPendingStyle.Render(" ")) - right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language)) - } - } - right.WriteString(fmt.Sprintf("\n Installed: %d/%d\n", lspInstalled, len(m.lspServers))) - right.WriteString("\n") - } - - if m.daemon != nil { - right.WriteString(sectionStyle.Render("Daemon")) - right.WriteString("\n") - if m.daemon.IsRunning() { - right.WriteString(" ") - right.WriteString(itemOKStyle.Render("running")) - lastCheck := m.daemon.LastCheck() - if !lastCheck.IsZero() { - right.WriteString(fmt.Sprintf(" last: %s", lastCheck.Format("15:04:05"))) - } - } else { - right.WriteString(" ") - right.WriteString(itemPendingStyle.Render("stopped")) - } - right.WriteString("\n\n") - } - - mcpStatus := itemPendingStyle.Render("not configured") - if m.mcpConfigured { - mcpStatus = itemOKStyle.Render("configured") - } - right.WriteString(fmt.Sprintf("MCP: %s\n", mcpStatus)) - - leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String()) - rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String()) - - return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) -} - -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 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" - case tabChat, tabWorkflow: - helpText = "[ctrl+t] switch tab [ctrl+c] quit" - case tabTerminal: - helpText = "[enter] run [ctrl+c] kill [clear] clear" - case tabAgents: - helpText = "[c] crush [l] claude" - default: - helpText = "[ctrl+t] switch tab [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+`) diff --git a/internal/tui/chat.go b/internal/tui/chat.go new file mode 100644 index 0000000..a45e74a --- /dev/null +++ b/internal/tui/chat.go @@ -0,0 +1,45 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +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 +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..172955a --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,129 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/installer" + "github.com/muyue/muyue/internal/orchestrator" + "github.com/muyue/muyue/internal/workflow" +) + +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}} + }) +} + +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) 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) +} diff --git a/internal/tui/config_tab.go b/internal/tui/config_tab.go new file mode 100644 index 0000000..cf6c6ef --- /dev/null +++ b/internal/tui/config_tab.go @@ -0,0 +1,105 @@ +package tui + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) + +func extractVersion(s string) string { + return versionRegex.FindString(s) +} + +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() +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go new file mode 100644 index 0000000..d6695f7 --- /dev/null +++ b/internal/tui/dashboard.go @@ -0,0 +1,161 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) renderDashboard() string { + colWidth := m.width / 2 + if colWidth < 30 { + colWidth = 30 + } + + var left, right strings.Builder + + left.WriteString(sectionStyle.Render("System")) + left.WriteString("\n") + if m.scanResult != nil { + sysInfo := m.scanResult.System.String() + left.WriteString(" ") + left.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(sysInfo)) + } + left.WriteString("\n\n") + + left.WriteString(sectionStyle.Render("Tools")) + left.WriteString("\n") + if m.scanResult != nil { + installed := 0 + total := len(m.scanResult.Tools) + for _, t := range m.scanResult.Tools { + if t.Installed { + installed++ + left.WriteString(" ") + left.WriteString(itemOKStyle.Render(" ")) + left.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, extractVersion(t.Version))) + } else { + left.WriteString(" ") + left.WriteString(itemMissingStyle.Render(" ")) + left.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)) + left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total)) + } + left.WriteString("\n") + + if m.installing { + left.WriteString(sectionStyle.Render("Installing...")) + left.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) + } + left.WriteString(fmt.Sprintf(" %s%s\n", progBar, label)) + left.WriteString("\n") + } + + if len(m.installLog) > 0 { + left.WriteString(sectionStyle.Render("Install Log")) + left.WriteString("\n") + for _, l := range m.installLog { + left.WriteString(l + "\n") + } + left.WriteString("\n") + } + + right.WriteString(sectionStyle.Render("Quick Actions")) + right.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"}, + } + for _, a := range actions { + right.WriteString(fmt.Sprintf(" %s %s\n", + lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("["+a.key+"]"), + a.desc)) + } + right.WriteString("\n") + + if len(m.updateStatus) > 0 { + right.WriteString(sectionStyle.Render("Updates")) + right.WriteString("\n") + for _, s := range m.updateStatus { + if s.NeedsUpdate { + right.WriteString(" ") + right.WriteString(itemWarnStyle.Render(" ")) + right.WriteString(fmt.Sprintf(" %s: %s -> %s\n", s.Tool, s.Current, s.Latest)) + } else if s.Error == "" { + right.WriteString(" ") + right.WriteString(itemOKStyle.Render(" ")) + right.WriteString(fmt.Sprintf(" %s: up to date\n", s.Tool)) + } + } + right.WriteString("\n") + } + + if len(m.lspServers) > 0 { + right.WriteString(sectionStyle.Render("LSP Servers")) + right.WriteString("\n") + lspInstalled := 0 + for _, s := range m.lspServers { + if s.Installed { + lspInstalled++ + right.WriteString(" ") + right.WriteString(itemOKStyle.Render(" ")) + right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language)) + } else { + right.WriteString(" ") + right.WriteString(itemPendingStyle.Render(" ")) + right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language)) + } + } + right.WriteString(fmt.Sprintf("\n Installed: %d/%d\n", lspInstalled, len(m.lspServers))) + right.WriteString("\n") + } + + if m.daemon != nil { + right.WriteString(sectionStyle.Render("Daemon")) + right.WriteString("\n") + if m.daemon.IsRunning() { + right.WriteString(" ") + right.WriteString(itemOKStyle.Render("running")) + lastCheck := m.daemon.LastCheck() + if !lastCheck.IsZero() { + right.WriteString(fmt.Sprintf(" last: %s", lastCheck.Format("15:04:05"))) + } + } else { + right.WriteString(" ") + right.WriteString(itemPendingStyle.Render("stopped")) + } + right.WriteString("\n\n") + } + + mcpStatus := itemPendingStyle.Render("not configured") + if m.mcpConfigured { + mcpStatus = itemOKStyle.Render("configured") + } + right.WriteString(fmt.Sprintf("MCP: %s\n", mcpStatus)) + + leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String()) + rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String()) + + return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) +} diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go new file mode 100644 index 0000000..a313da5 --- /dev/null +++ b/internal/tui/handlers.go @@ -0,0 +1,223 @@ +package tui + +import ( + "fmt" + "os" + "os/exec" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/muyue/muyue/internal/lsp" + "github.com/muyue/muyue/internal/mcp" + "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/updater" +) + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.showingQuit { + return m.handleQuitConfirm(msg) + } + if m.showingTabMenu { + return m.handleTabMenu(msg) + } + + if m.activeTab == tabTerminal { + return m.handleTerminalKey(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 "ctrl+t": + m.showingTabMenu = true + m.tabMenuCursor = int(m.activeTab) + m.viewport.SetContent(m.renderContent()) + 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) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + m.showingTabMenu = false + m.viewport.SetContent(m.renderContent()) + return m, nil + case "up", "k": + if m.tabMenuCursor > 0 { + m.tabMenuCursor-- + } + return m, nil + case "down", "j": + if m.tabMenuCursor < int(tabCount)-1 { + m.tabMenuCursor++ + } + return m, nil + case "enter": + m.activeTab = tab(m.tabMenuCursor) + m.showingTabMenu = false + m.resizeViewport() + return m, nil + default: + for i := 0; i < int(tabCount); i++ { + if msg.String() == fmt.Sprintf("%d", i+1) { + m.activeTab = tab(i) + m.showingTabMenu = false + m.resizeViewport() + return m, nil + } + } + } + return m, nil +} + +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 checkNeedsSudo(scan *scanner.ScanResult) bool { + if scan == nil { + return false + } + sudoTools := map[string]bool{ + "docker": true, "git": true, "gh": true, "node": true, "python3": 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 +} diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go new file mode 100644 index 0000000..f4dcba9 --- /dev/null +++ b/internal/tui/helpers.go @@ -0,0 +1,11 @@ +package tui + +import ( + "github.com/muyue/muyue/internal/workflow" +) + +type previewFile = workflow.PreviewFile + +func parsePreviewFiles(response string) []previewFile { + return workflow.ParsePreviewFiles(response) +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..29cc413 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,446 @@ +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(baseColor) + + prog := progress.New(progress.WithGradient("#FF6B9D", "#A0D2FF")) + + cwd, _ := os.Getwd() + + 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 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, + showingTabMenu: false, + tabMenuCursor: 0, + termCwd: cwd, + } +} + +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 termOutputMsg: + m.termLog = append(m.termLog, msg.line) + if m.activeTab == tabTerminal { + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoBottom() + } + return m, nil + case termExitMsg: + m.termRunning = false + m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(mutedColor).Render("(process exited)")) + m.termCmd = nil + if m.activeTab == tabTerminal { + m.viewport.SetContent(m.renderContent()) + } + return m, nil + 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 := 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 := 1 + footerH := 2 + inputH := 0 + if m.activeTab == tabChat || m.activeTab == tabWorkflow || m.activeTab == tabTerminal { + 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 "Loading..." + } + + 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 == tabChat || m.activeTab == tabWorkflow { + b.WriteString("\n") + b.WriteString(m.renderChatInput()) + } + if m.activeTab == tabTerminal { + b.WriteString("\n") + b.WriteString(m.renderTermInput()) + } + b.WriteString("\n") + b.WriteString(m.renderFooter()) + + return b.String() +} + +func (m Model) renderHeader() string { + logoStyle := lipgloss.NewStyle().Foreground(baseColor).Bold(true) + badgeStyle := lipgloss.NewStyle(). + Background(baseColor). + Foreground(lipgloss.Color("#FFFFFF")). + Padding(0, 1) + + logo := logoStyle.Render("muyue") + badge := badgeStyle.Render("v" + version.Version) + + activeTabName := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render(tabNames[m.activeTab]) + separator := lipgloss.NewStyle().Foreground(dimColor).Render(" · ") + + rightPart := separator + activeTabName + + line := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render( + lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", badge, rightPart), + ) + + return line +} + +func (m Model) renderContent() string { + switch m.activeTab { + case tabDashboard: + return m.renderDashboard() + case tabChat: + return m.renderChat() + case tabWorkflow: + return m.renderWorkflow() + case tabTerminal: + return m.renderTerminal() + case tabAgents: + return m.renderAgents() + case tabConfig: + return m.renderConfig() + default: + return "" + } +} + +func (m *Model) resizeViewport() { + headerH := 1 + footerH := 2 + inputH := 0 + if m.activeTab == tabChat || m.activeTab == tabWorkflow || m.activeTab == tabTerminal { + 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", profile, version.Name) + leftR := statusBarStyle.Render(left) + + var helpText string + switch m.activeTab { + case tabDashboard: + helpText = "[i] install [u] update [s] scan" + case tabChat, tabWorkflow: + helpText = "[ctrl+t] switch tab [ctrl+c] quit" + case tabTerminal: + helpText = "[enter] run [ctrl+c] kill [clear] clear" + case tabAgents: + helpText = "[c] crush [l] claude" + default: + helpText = "[ctrl+t] switch tab [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 (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) renderTabMenuOverlay() string { + tabMenuStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(baseColor). + Background(bgCard). + Padding(1, 3) + + tabItemStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A0A0B0")). + Padding(0, 2) + + tabItemActiveStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(baseColor). + Bold(true). + Padding(0, 2) + + tabNumStyle := lipgloss.NewStyle(). + Foreground(dimColor). + Width(4) + + var items []string + descs := []string{"system overview & tools", "AI chat & conversation", "plan & execute workflows", "integrated shell", "background AI agents", "profile & settings"} + + for i, name := range tabNames { + num := tabNumStyle.Render(fmt.Sprintf(" %d.", i+1)) + if i == m.tabMenuCursor { + item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(mutedColor).Render(descs[i])) + items = append(items, tabItemActiveStyle.Render(">"+item)) + } else { + item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i])) + items = append(items, tabItemStyle.Render(" "+item)) + } + } + + content := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("Switch Tab") + + "\n\n" + + strings.Join(items, "\n") + + "\n\n" + + lipgloss.NewStyle().Foreground(dimColor).Render("up/down navigate enter/select esc cancel") + + box := tabMenuStyle.Render(content) + + return lipgloss.Place(m.width, m.height, + 0.5, 0.5, + box, + lipgloss.WithWhitespaceBackground(bgPanel), + 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 opened in browser: http://127.0.0.1:8765")) + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..d6804bb --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,80 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +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") + + 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) + + stepDoneStyle = lipgloss.NewStyle(). + Foreground(successColor) + + stepPendingStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + stepCurrentStyle = lipgloss.NewStyle(). + Foreground(baseColor). + Bold(true) + + stepErrorStyle = lipgloss.NewStyle(). + Foreground(errorColor) + + 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) +) diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go new file mode 100644 index 0000000..ed733eb --- /dev/null +++ b/internal/tui/terminal.go @@ -0,0 +1,125 @@ +package tui + +import ( + "os" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + if m.termCmd != nil && m.termCmd.Process != nil { + m.termCmd.Process.Kill() + m.termRunning = false + m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorColor).Render("^C")) + m.termCmd = nil + m.viewport.SetContent(m.renderContent()) + return m, nil + } + 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 "ctrl+t": + m.showingTabMenu = true + m.tabMenuCursor = int(m.activeTab) + return m, nil + case "enter": + if m.termRunning { + return m, nil + } + input := strings.TrimSpace(m.termInput) + m.termInput = "" + if input == "" { + return m, nil + } + if input == "exit" || input == "quit" { + return m, nil + } + if input == "clear" { + m.termLog = nil + m.viewport.SetContent(m.renderContent()) + return m, nil + } + if strings.HasPrefix(input, "cd ") { + dir := strings.TrimPrefix(input, "cd ") + dir = strings.TrimSpace(dir) + if dir == "~" { + home, _ := os.UserHomeDir() + dir = home + } + if err := os.Chdir(dir); err == nil { + m.termCwd, _ = os.Getwd() + m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input)) + } else { + m.termLog = append(m.termLog, errMsgStyle.Render("cd: "+err.Error())) + } + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoBottom() + return m, nil + } + m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ ")+input) + m.viewport.SetContent(m.renderContent()) + m.viewport.GotoBottom() + return m, m.runTermCommand(input) + case "backspace": + if len(m.termInput) > 0 { + m.termInput = m.termInput[:len(m.termInput)-1] + m.viewport.SetContent(m.renderContent()) + } + return m, nil + default: + if len(msg.String()) == 1 { + m.termInput += msg.String() + m.viewport.SetContent(m.renderContent()) + } + } + return m, nil +} + +func (m Model) runTermCommand(input string) tea.Cmd { + return tea.Cmd(func() tea.Msg { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + cmd := exec.Command(shell, "-c", input) + cmd.Dir = m.termCwd + out, err := cmd.CombinedOutput() + if err != nil { + return termOutputMsg{line: string(out) + errMsgStyle.Render(err.Error())} + } + return termOutputMsg{line: string(out)} + }) +} + +func (m Model) renderTerminal() string { + var b strings.Builder + + b.WriteString(sectionStyle.Render("Terminal")) + b.WriteString(" ") + b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd)) + b.WriteString("\n\n") + + for _, line := range m.termLog { + b.WriteString(line + "\n") + } + + return b.String() +} + +func (m Model) renderTermInput() string { + prompt := lipgloss.NewStyle().Foreground(successColor).Render("$ ") + return prompt + m.termInput + lipgloss.NewStyle().Foreground(baseColor).Render("") +} diff --git a/internal/tui/types.go b/internal/tui/types.go new file mode 100644 index 0000000..cae54e3 --- /dev/null +++ b/internal/tui/types.go @@ -0,0 +1,181 @@ +package tui + +import ( + "os/exec" + "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" + "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/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/workflow" +) + +type tab int + +const ( + tabDashboard tab = iota + tabChat + tabWorkflow + tabTerminal + tabAgents + tabConfig + tabCount +) + +var tabNames = []string{"Dashboard", "Chat", "Workflow", "Terminal", "Agents", "Config"} + +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 installBatchMsg struct { + result installer.InstallResult + tools []string + index int + config *config.MuyueConfig +} +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 termOutputMsg struct{ line string } +type termExitMsg struct{} + +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 + showingTabMenu bool + tabMenuCursor int + + ctrlCCount int + lastCtrlC time.Time + + installing bool + installCurrent int + installTotal int + installTool string + + termCmd *exec.Cmd + termInput string + termLog []string + termRunning bool + termCwd string +} + +type keyMap struct { + Tab key.Binding + Prev key.Binding + Quit key.Binding + Confirm key.Binding + Cancel key.Binding + TabMenu 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", "indent"), + ), + Prev: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "unindent"), + ), + 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"), + ), + TabMenu: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "switch tab"), + ), + 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("backspace", "delete"), + ), +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.TabMenu, k.Tab, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.TabMenu, k.Tab, k.Prev}, + {k.Quit}, + } +} diff --git a/internal/tui/workflow_tab.go b/internal/tui/workflow_tab.go new file mode 100644 index 0000000..b9aa545 --- /dev/null +++ b/internal/tui/workflow_tab.go @@ -0,0 +1,213 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muyue/muyue/internal/workflow" +) + +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) 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 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() +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index 2b915bb..564150b 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -67,10 +67,6 @@ func (w *Workflow) Start(goal string) { w.History = append(w.History, fmt.Sprintf("[started] %s", goal)) } -func (w *Workflow) SetQuestions(questions []string) { - w.Plan.Questions = questions -} - func (w *Workflow) AddAnswer(answer string) { w.Plan.Answers = append(w.Plan.Answers, answer) if len(w.Plan.Answers) >= len(w.Plan.Questions) {