From 44691225e7ece05a21d986b275cc5d2cb54821f2 Mon Sep 17 00:00:00 2001 From: Augustin Date: Mon, 20 Apr 2026 19:13:48 +0200 Subject: [PATCH] refactor: modularize TUI, improve error handling, add CI caching and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split monolithic app.go into focused modules (dashboard, chat, workflow, config, agents, terminal, commands, handlers). Add proper error handling for installer commands, proxy pipes, and MCP config parsing. Fix daemon channel buffer, cap orchestrator history, compile think regex once, and set HTTP timeouts on preview server. Improve CI with Go module caching, dependency download step, and test stage with race detection. ๐Ÿ˜˜ Generated with Crush Assisted-by: GLM-5-Turbo via Crush --- .gitea/workflows/ci.yml | 26 +- .gitignore | 11 + Makefile | 4 +- cmd/muyue/main.go | 20 - internal/daemon/daemon.go | 2 +- internal/installer/installer.go | 46 +- internal/lsp/lsp.go | 18 +- internal/mcp/mcp.go | 17 +- internal/orchestrator/orchestrator.go | 73 +- internal/preview/preview.go | 7 +- internal/proxy/proxy.go | 29 +- internal/skills/skills.go | 29 +- internal/tui/agents.go | 87 ++ internal/tui/app.go | 1707 ------------------------- internal/tui/chat.go | 45 + internal/tui/commands.go | 129 ++ internal/tui/config_tab.go | 105 ++ internal/tui/dashboard.go | 161 +++ internal/tui/handlers.go | 223 ++++ internal/tui/helpers.go | 11 + internal/tui/model.go | 446 +++++++ internal/tui/styles.go | 80 ++ internal/tui/terminal.go | 125 ++ internal/tui/types.go | 181 +++ internal/tui/workflow_tab.go | 213 +++ internal/workflow/workflow.go | 4 - 26 files changed, 2001 insertions(+), 1798 deletions(-) create mode 100644 internal/tui/agents.go delete mode 100644 internal/tui/app.go create mode 100644 internal/tui/chat.go create mode 100644 internal/tui/commands.go create mode 100644 internal/tui/config_tab.go create mode 100644 internal/tui/dashboard.go create mode 100644 internal/tui/handlers.go create mode 100644 internal/tui/helpers.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/terminal.go create mode 100644 internal/tui/types.go create mode 100644 internal/tui/workflow_tab.go 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) {