refactor: modularize TUI, improve error handling, add CI caching and tests
All checks were successful
CI / build (push) Successful in 2m41s
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:
@@ -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
11
.gitignore
vendored
@@ -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/
|
||||
|
||||
4
Makefile
4
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/
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +127,10 @@ 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"}},
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PreviewServer struct {
|
||||
@@ -26,6 +27,8 @@ func (p *PreviewServer) Start(port int) error {
|
||||
p.server = &http.Server{
|
||||
Addr: fmt.Sprintf("127.0.0.1:%d", port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
87
internal/tui/agents.go
Normal 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
|
||||
}
|
||||
1707
internal/tui/app.go
1707
internal/tui/app.go
File diff suppressed because it is too large
Load Diff
45
internal/tui/chat.go
Normal file
45
internal/tui/chat.go
Normal 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
129
internal/tui/commands.go
Normal 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
105
internal/tui/config_tab.go
Normal 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
161
internal/tui/dashboard.go
Normal 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
223
internal/tui/handlers.go
Normal 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
11
internal/tui/helpers.go
Normal 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
446
internal/tui/model.go
Normal 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
80
internal/tui/styles.go
Normal 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
125
internal/tui/terminal.go
Normal 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
181
internal/tui/types.go
Normal 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},
|
||||
}
|
||||
}
|
||||
213
internal/tui/workflow_tab.go
Normal file
213
internal/tui/workflow_tab.go
Normal 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()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user