refactor: modularize TUI, improve error handling, add CI caching and tests
All checks were successful
CI / build (push) Successful in 2m41s

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 <crush@charm.land>
This commit is contained in:
Augustin
2026-04-20 19:13:48 +02:00
parent 5a33dfcd73
commit 44691225e7
26 changed files with 2001 additions and 1798 deletions

View File

@@ -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: |

11
.gitignore vendored
View File

@@ -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/

View File

@@ -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/

View File

@@ -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, ", ")
}

View File

@@ -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{},
}
}

View File

@@ -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 != "" {

View File

@@ -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"
}
}

View File

@@ -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)

View File

@@ -14,6 +14,10 @@ import (
"github.com/muyue/muyue/internal/workflow"
)
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[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[^>]*>.*?</[Tt]hink>`)
content = thinkRe.ReplaceAllString(content, "")
content = thinkRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n")
var clean []string
inBlock := false

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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

87
internal/tui/agents.go Normal file
View File

@@ -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
}

File diff suppressed because it is too large Load Diff

45
internal/tui/chat.go Normal file
View File

@@ -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
}

129
internal/tui/commands.go Normal file
View File

@@ -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)
}

105
internal/tui/config_tab.go Normal file
View File

@@ -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()
}

161
internal/tui/dashboard.go Normal file
View File

@@ -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)
}

223
internal/tui/handlers.go Normal file
View File

@@ -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
}

11
internal/tui/helpers.go Normal file
View File

@@ -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)
}

446
internal/tui/model.go Normal file
View File

@@ -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 <goal> to start a structured workflow, or just chat."),
},
orch: orch,
proxyMgr: proxyMgr,
chatInput: "",
chatLoading: false,
daemon: d,
lspServers: lspServers,
mcpConfigured: mcpConfigured,
skillList: skillList,
helpModel: help.New(),
progressBar: prog,
spinner: sp,
showingQuit: false,
confirmCursor: 1,
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"))
}
}

80
internal/tui/styles.go Normal file
View File

@@ -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)
)

125
internal/tui/terminal.go Normal file
View File

@@ -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("")
}

181
internal/tui/types.go Normal file
View File

@@ -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},
}
}

View File

@@ -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 <goal> to start a structured workflow.\n")
b.WriteString("Example: /plan Create a REST API in Go\n")
case workflow.PhaseGathering:
b.WriteString(sectionStyle.Render("Gathering Requirements"))
b.WriteString("\n")
for i, q := range wf.Plan.Questions {
icon := itemPendingStyle.Render(" ")
if i < len(wf.Plan.Answers) {
icon = itemOKStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q))
b.WriteString(fmt.Sprintf(" A: %s\n", wf.Plan.Answers[i]))
} else {
b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q))
}
}
if len(wf.Plan.Answers) >= len(wf.Plan.Questions) && len(wf.Plan.Questions) > 0 {
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[g] Generate plan"))
b.WriteString("\n")
}
case workflow.PhasePlanning:
b.WriteString(m.spinner.View())
b.WriteString(" ")
b.WriteString(itemWarnStyle.Render("Generating plan..."))
b.WriteString("\n")
case workflow.PhaseReviewing:
b.WriteString(sectionStyle.Render("Plan (review before execution)"))
b.WriteString("\n\n")
for i, s := range wf.Plan.Steps {
numStyle := lipgloss.NewStyle().Foreground(accentColor).Bold(true)
icon := stepPendingStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s %s %s\n", icon, numStyle.Render("#"+s.ID+":"), s.Title))
b.WriteString(fmt.Sprintf(" %s\n", lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Description)))
agentStyle := lipgloss.NewStyle().Foreground(aiColor).Render(s.Agent)
b.WriteString(fmt.Sprintf(" Agent: %s\n", agentStyle))
if i < len(wf.Plan.Steps)-1 {
b.WriteString("\n")
}
}
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[a] Approve plan"))
b.WriteString(" ")
b.WriteString(itemMissingStyle.Render("[r] Reject with feedback"))
b.WriteString("\n")
if len(wf.Plan.PreviewFiles) > 0 {
b.WriteString("\n ")
b.WriteString(itemWarnStyle.Render("Preview files available (opened in browser)"))
b.WriteString("\n")
}
case workflow.PhaseExecuting:
b.WriteString(sectionStyle.Render("Executing Plan"))
b.WriteString("\n\n")
done, total := wf.Progress()
m.progressBar.SetPercent(float64(done) / float64(max(total, 1)))
fmt.Fprintf(&b, " %s %d/%d\n\n", m.progressBar.View(), done, total)
for _, s := range wf.Plan.Steps {
var icon string
switch s.Status {
case "done":
icon = stepDoneStyle.Render(" ")
case "error":
icon = stepErrorStyle.Render(" ")
default:
if wf.Plan.Steps[wf.Plan.StepIndex].ID == s.ID {
icon = stepCurrentStyle.Render(">")
} else {
icon = stepPendingStyle.Render(" ")
}
}
b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title))
if s.Output != "" {
output := s.Output
if len(output) > 80 {
output = output[:80] + "..."
}
b.WriteString(fmt.Sprintf(" %s\n", output))
}
}
b.WriteString("\n ")
b.WriteString(itemOKStyle.Render("[n] Next step"))
b.WriteString(" ")
b.WriteString(itemMissingStyle.Render("[x] Cancel workflow"))
b.WriteString("\n")
case workflow.PhaseDone:
b.WriteString(itemOKStyle.Render("Workflow completed!"))
b.WriteString("\n\n")
for _, s := range wf.Plan.Steps {
icon := stepDoneStyle.Render(" ")
b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title))
}
b.WriteString("\n [x] Reset workflow\n")
case workflow.PhaseError:
b.WriteString(itemMissingStyle.Render("Workflow encountered an error."))
b.WriteString("\n [x] Reset workflow\n")
}
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-4, 10))))
b.WriteString("\n")
b.WriteString(sectionStyle.Render("Chat"))
b.WriteString("\n")
for _, msg := range m.chatLog {
lines := strings.Split(msg, "\n")
for _, line := range lines {
if len(line) > m.width-4 {
line = line[:m.width-7] + "..."
}
b.WriteString(" " + line + "\n")
}
}
return b.String()
}

View File

@@ -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) {