Compare commits

..

6 Commits

Author SHA1 Message Date
CI Bot
8ea7418684 chore: update CHANGELOG for v0.2.1 2026-04-20 19:22:23 +00:00
Augustin
ec33ff4e4d Merge branch 'main' of https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace
All checks were successful
Stable Release / stable (push) Successful in 28s
Beta Release / beta (push) Successful in 26s
2026-04-20 21:21:32 +02:00
Augustin
22fb2823ce chore: bump version to 0.2.1, update README for TUI redesign
All checks were successful
Beta Release / beta (push) Successful in 32s
- Document 4-tab layout (Dashboard, Studio, Shell, Config)
- Add keyboard shortcuts table for new tabs
- Update version references from 0.2.0 to 0.2.1

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 21:21:03 +02:00
CI Bot
6dad84067d chore: update CHANGELOG for v0.2.0 2026-04-20 19:08:34 +00:00
Augustin
b475348816 Merge branch 'feat/redesign-tui' into develop
All checks were successful
Beta Release / beta (push) Successful in 37s
Stable Release / stable (push) Successful in 28s
2026-04-20 21:03:57 +02:00
Augustin
035e923e6c refactor: redesign TUI with 4 tabs, red/rose theme, split layouts
- Dashboard: tools, agents status, updates, quick actions
- Studio: central chat + agents/workflows sidebar (Ctrl+S toggle)
- Shell: terminal + AI assistant panel side-by-side (Ctrl+A toggle)
- Config: profile, API keys, terminal/starship settings in 2 columns
- New red/rose color scheme (#E8364F → #FF6B8A → #FFB3C6)
- Animated header with visual tab bar and pulse loading
- Remove old chat.go, agents.go, workflow_tab.go (merged into studio.go)
- All tests pass, build clean

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 21:03:49 +02:00
15 changed files with 1002 additions and 661 deletions

View File

@@ -4,6 +4,117 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.2.1
### Changes since v0.2.0
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.0
### Changes since start
- refactor: redesign TUI with 4 tabs, red/rose theme, split layouts (035e923)
- feat: GitFlow workflow with beta/stable CI pipelines (bbdac6c)
- feat: security hardening, tests, doctor command, CI update, CHANGELOG (3494f6b)
- refactor: modularize TUI, improve error handling, add CI caching and tests (4469122)
- fix: remove tab switching, filter AI thinking from responses (5a33dfc)
- fix: enable text selection, dashboard multi-column layout (82b2816)
- feat: Ctrl+T tab switcher, minimal header, integrated terminal (2d6fc64)
- feat: Ctrl+M tab switcher overlay menu (bb3b303)
- fix: docker version check, uv PATH, install progress bar (e6fdec4)
- feat: smart setup wizard - sort choices by system detection (1be4fc0)
- fix: use Alt+1-5 for tab navigation to free number keys for input (825b429)
- ci: add install instructions for all platforms in release body (ac35ff2)
- ci: add build + release steps with push-only conditions (bcb9aa0)
- ci: restore exact working ci.yml from e58e00d for testing (0a91cef)
- fix: rename workflow back to CI (slash in name breaks Gitea 1.25) (461122a)
- ci: trigger workflow run (ea59c2c)
- fix: remove workflow_dispatch + add push-only conditions on release steps (9cd583f)
- ci: single job - build + vet + release latest in one pass (92275be)
- ci: merge CI and Release into single workflow (f2c0996)
- fix: release workflow - delete old release before creating new one (5eb237f)
- feat: redesign TUI + Ctrl+C quit confirm + version logic + sudo handling (e3cd618)
- feat: add mouse support + install pnpm, uv, docker, gh (e58e00d)
- fix: use GITEATOKEN secret name (no underscores in Gitea 1.25) (8e3f8b8)
- fix: make release delete step resilient + check GITEA_TOKEN (69ca5c6)
- fix: remove redundant newline in profiler.go (go vet) (2d421fe)
- fix: export PATH in every step for Gitea runner compatibility (3f8e01f)
- ci: restore actions/checkout + simplify workflows (4db69e4)
- fix: add missing cmd/muyue/main.go and fix .gitignore (f650988)
- ci: fix Gitea Actions - native checkout + auto-release on push (78c7239)
- ci: migrate workflows to Gitea Actions with self-hosted runner (811a9aa)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [0.2.0] - 2026-04-20 ## [0.2.0] - 2026-04-20
### Added ### Added

View File

@@ -76,25 +76,49 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill muyue skills delete <name> # Delete a skill
``` ```
## TUI Controls ## TUI — 4 Tabs
| Key | Action | The TUI is organized into 4 tabs with a red/rose theme (`#E8364F``#FF6B8A`):
|-----|--------|
| `Ctrl+T` | Open tab switcher |
| `Tab` / `Shift+Tab` | Cycle tabs |
| `Ctrl+C` | Quit confirmation |
| `i` (Dashboard) | Install missing tools |
| `u` (Dashboard) | Check for updates |
| `s` (Dashboard) | Rescan system |
| `a` (Workflow) | Approve plan |
| `r` (Workflow) | Reject plan |
| `g` (Workflow) | Generate plan |
| `n` (Workflow) | Next step |
| `x` (Workflow) | Cancel workflow |
### Chat Commands ### ◉ Dashboard
- `/plan <goal>` — Start a structured Plan→Execute workflow System overview: installed tools with status, active agents, updates, LSP/MCP/daemon status, and quick actions (install, update, scan).
### ◈ Studio
Central AI chat with a collapsible sidebar (`Ctrl+S`) containing 3 panels:
| Panel | Shortcut | Description |
|-------|----------|-------------|
| **Chat** | `1` | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | `2` | Start/stop Crush and Claude Code agents |
| **Workflows** | `3` | Plan→Execute workflow controls (approve, reject, next step) |
### ▶ Shell
Split-view terminal with an AI assistant panel (`Ctrl+A` to toggle). The AI knows your system and suggests commands you can easily copy into the terminal.
### ⚙ Config
Profile, API providers, terminal/starship settings, BMAD, and skills — displayed in a two-column layout.
### Keyboard Shortcuts
| Key | Context | Action |
|-----|---------|--------|
| `Ctrl+T` | Global | Open tab switcher |
| `Ctrl+S` | Studio | Toggle sidebar |
| `Ctrl+A` | Shell | Toggle AI assistant panel |
| `Ctrl+C` | Global | Quit confirmation |
| `i` | Dashboard | Install missing tools |
| `u` | Dashboard | Check for updates |
| `s` | Dashboard | Rescan system |
| `1` `2` `3` | Studio sidebar | Switch panels (Chat/Agents/Workflows) |
| `a` | Workflow | Approve plan |
| `r` | Workflow | Reject plan |
| `g` | Workflow | Generate plan |
| `n` | Workflow | Next step |
| `x` | Workflow | Cancel workflow |
## Configuration ## Configuration
@@ -179,7 +203,7 @@ git push -u origin feature/my-feature
```bash ```bash
# 1. Bump the version in internal/version/version.go # 1. Bump the version in internal/version/version.go
# Change: Version = "0.2.0" → Version = "0.3.0" # Change: Version = "0.2.1" → Version = "0.3.0"
# Commit on develop: # Commit on develop:
git checkout develop git checkout develop
# (edit internal/version/version.go) # (edit internal/version/version.go)
@@ -222,7 +246,7 @@ git push
```go ```go
const ( const (
Version = "0.2.0" // ← bump this before a release Version = "0.2.1" // ← bump this before a release
) )
``` ```
@@ -236,11 +260,11 @@ Binary version is injected at build time via `-ldflags`:
```bash ```bash
# Beta build (automatic in CI) # Beta build (automatic in CI)
go build -ldflags="-X github.com/muyue/muyue/internal/version.Prerelease=beta.3" ./cmd/muyue/ go build -ldflags="-X github.com/muyue/muyue/internal/version.Prerelease=beta.3" ./cmd/muyue/
# → muyue v0.2.0-beta.3 # → muyue v0.2.1-beta.3
# Stable build (automatic in CI) # Stable build (automatic in CI)
go build -ldflags="-s -w" ./cmd/muyue/ go build -ldflags="-s -w" ./cmd/muyue/
# → muyue v0.2.0 # → muyue v0.2.1
``` ```
### Conventional commits ### Conventional commits

View File

@@ -1,87 +0,0 @@
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
}

View File

@@ -1,45 +0,0 @@
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
}

View File

@@ -2,7 +2,6 @@ package tui
import ( import (
"fmt" "fmt"
"strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
@@ -107,23 +106,3 @@ func continueWorkflowCmd(orch *orchestrator.Orchestrator, output string) tea.Cmd
return aiResponseMsg{content: resp} 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)
}

View File

@@ -15,10 +15,15 @@ func extractVersion(s string) string {
} }
func (m Model) renderConfig() string { func (m Model) renderConfig() string {
var b strings.Builder colWidth := m.width / 2
if colWidth < 30 {
colWidth = 30
}
b.WriteString(sectionStyle.Render("Profile")) var left, right strings.Builder
b.WriteString("\n")
left.WriteString(renderSectionWithIcon("Profile", "👤"))
left.WriteString("\n")
if m.config != nil { if m.config != nil {
fields := []struct { fields := []struct {
label string label string
@@ -33,73 +38,84 @@ func (m Model) renderConfig() string {
{"Default AI", m.config.Profile.Preferences.DefaultAI}, {"Default AI", m.config.Profile.Preferences.DefaultAI},
} }
for _, f := range fields { for _, f := range fields {
labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14) left.WriteString(fmt.Sprintf(" %s %s\n",
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")) labelStyle.Render(f.label+":"),
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render(f.label+":"), valueStyle.Render(f.value))) valueStyle.Render(f.value)))
} }
if len(m.config.Profile.Languages) > 0 { if len(m.config.Profile.Languages) > 0 {
labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14) left.WriteString(fmt.Sprintf(" %s %s\n",
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")) labelStyle.Render("Languages:"),
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Languages:"), valueStyle.Render(strings.Join(m.config.Profile.Languages, ", ")))) valueStyle.Render(strings.Join(m.config.Profile.Languages, ", "))))
} }
} }
b.WriteString("\n") left.WriteString("\n")
b.WriteString(sectionStyle.Render("AI Providers")) left.WriteString(renderSectionWithIcon("AI Providers", "◆"))
b.WriteString("\n") left.WriteString("\n")
if m.config != nil { if m.config != nil {
for _, p := range m.config.AI.Providers { for _, p := range m.config.AI.Providers {
active := "" active := ""
if p.Active { if p.Active {
active = itemOKStyle.Render(" active") active = lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" ")
} }
keyStatus := itemMissingStyle.Render("no key") keyStatus := itemMissingStyle.Render("no key")
if p.APIKey != "" { if p.APIKey != "" {
keyStatus = itemOKStyle.Render("configured") keyStatus = itemOKStyle.Render("configured")
} }
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Bold(true) nameStyle := lipgloss.NewStyle().Foreground(textColor).Bold(true)
b.WriteString(fmt.Sprintf(" %s model=%s key=%s%s\n", left.WriteString(fmt.Sprintf(" %s %s key=%s%s\n",
nameStyle.Render(p.Name), p.Model, keyStatus, active)) nameStyle.Render(p.Name),
lipgloss.NewStyle().Foreground(dimColor).Render("model="+p.Model),
keyStatus, active))
} }
} }
b.WriteString("\n") left.WriteString("\n")
b.WriteString(sectionStyle.Render("BMAD Method")) right.WriteString(renderSectionWithIcon("Terminal", "▶"))
b.WriteString("\n") right.WriteString("\n")
if m.config != nil {
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Custom Prompt:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Terminal.CustomPrompt))))
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Prompt Theme:"), valueStyle.Render(m.config.Terminal.PromptTheme)))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Auto Update:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.AutoUpdate))))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Check on Start:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.CheckOnStart))))
}
right.WriteString("\n")
right.WriteString(renderSectionWithIcon("BMAD Method", "◈"))
right.WriteString("\n")
if m.config != nil { if m.config != nil {
installed := itemMissingStyle.Render("no") installed := itemMissingStyle.Render("no")
if m.config.BMAD.Installed { if m.config.BMAD.Installed {
installed = itemOKStyle.Render("yes") installed = itemOKStyle.Render("yes")
} }
b.WriteString(fmt.Sprintf(" Installed: %s\n", installed)) right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Installed:"), installed))
b.WriteString(fmt.Sprintf(" Global: %v\n", m.config.BMAD.Global)) right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Global:"), valueStyle.Render(fmt.Sprintf("%v", m.config.BMAD.Global))))
if m.config.BMAD.Version != "" {
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Version:"), valueStyle.Render(m.config.BMAD.Version)))
}
} }
b.WriteString("\n") right.WriteString("\n")
b.WriteString(sectionStyle.Render("Terminal")) right.WriteString(renderSectionWithIcon(fmt.Sprintf("Skills (%d)", len(m.skillList)), "⚡"))
b.WriteString("\n") right.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 { if len(m.skillList) > 0 {
for _, s := range m.skillList { for _, s := range m.skillList {
target := s.Target target := s.Target
if target == "" { if target == "" {
target = "both" target = "both"
} }
b.WriteString(fmt.Sprintf(" %-20s %s %s\n", right.WriteString(fmt.Sprintf(" %s %s %s\n",
lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Name), lipgloss.NewStyle().Foreground(textColor).Render(s.Name),
lipgloss.NewStyle().Foreground(aiColor).Render("["+target+"]"), lipgloss.NewStyle().Foreground(primaryColor).Render("["+target+"]"),
s.Description)) lipgloss.NewStyle().Foreground(dimColor).Render(s.Description)))
} }
} else { } else {
b.WriteString(" No skills. Run `muyue skills init` to install built-ins.\n") right.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(" No skills. Run `muyue skills init`."))
right.WriteString("\n")
} }
return b.String() leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
} }

View File

@@ -15,16 +15,16 @@ func (m Model) renderDashboard() string {
var left, right strings.Builder var left, right strings.Builder
left.WriteString(sectionStyle.Render("System")) left.WriteString(renderSectionWithIcon("System", "◉"))
left.WriteString("\n") left.WriteString("\n")
if m.scanResult != nil { if m.scanResult != nil {
sysInfo := m.scanResult.System.String() sysInfo := m.scanResult.System.String()
left.WriteString(" ") left.WriteString(" ")
left.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(sysInfo)) left.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(sysInfo))
} }
left.WriteString("\n\n") left.WriteString("\n\n")
left.WriteString(sectionStyle.Render("Tools")) left.WriteString(renderSectionWithIcon("Installed Tools", "◆"))
left.WriteString("\n") left.WriteString("\n")
if m.scanResult != nil { if m.scanResult != nil {
installed := 0 installed := 0
@@ -33,27 +33,32 @@ func (m Model) renderDashboard() string {
if t.Installed { if t.Installed {
installed++ installed++
left.WriteString(" ") left.WriteString(" ")
left.WriteString(itemOKStyle.Render(" ")) left.WriteString(itemOKStyle.Render(" "))
left.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, extractVersion(t.Version))) left.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(t.Name))
left.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %s", extractVersion(t.Version))))
left.WriteString("\n")
} else { } else {
left.WriteString(" ") left.WriteString(" ")
left.WriteString(itemMissingStyle.Render(" ")) left.WriteString(itemMissingStyle.Render(" "))
left.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, itemPendingStyle.Render("(not installed)"))) left.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(t.Name))
left.WriteString(itemPendingStyle.Render(" (missing)"))
left.WriteString("\n")
} }
} }
barWidth := 20 barWidth := 20
pct := 0 pct := 0
if total > 0 { if total > 0 {
pct = (installed * barWidth) / total pct = (installed * barWidth) / total
} }
bar := lipgloss.NewStyle().Foreground(successColor).Render(strings.Repeat("█", pct)) + bar := lipgloss.NewStyle().Foreground(primaryColor).Render(strings.Repeat("█", pct)) +
lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("░", barWidth-pct)) lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("░", barWidth-pct))
left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total)) left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total))
} }
left.WriteString("\n") left.WriteString("\n")
if m.installing { if m.installing {
left.WriteString(sectionStyle.Render("Installing...")) left.WriteString(renderSectionWithIcon("Installing", "⏳"))
left.WriteString("\n") left.WriteString("\n")
progBar := m.progressBar.View() progBar := m.progressBar.View()
label := "" label := ""
@@ -67,7 +72,7 @@ func (m Model) renderDashboard() string {
} }
if len(m.installLog) > 0 { if len(m.installLog) > 0 {
left.WriteString(sectionStyle.Render("Install Log")) left.WriteString(renderSectionWithIcon("Install Log", "📋"))
left.WriteString("\n") left.WriteString("\n")
for _, l := range m.installLog { for _, l := range m.installLog {
left.WriteString(l + "\n") left.WriteString(l + "\n")
@@ -75,87 +80,102 @@ func (m Model) renderDashboard() string {
left.WriteString("\n") left.WriteString("\n")
} }
right.WriteString(sectionStyle.Render("Quick Actions")) right.WriteString(renderSectionWithIcon("Quick Actions", "⚡"))
right.WriteString("\n") right.WriteString("\n")
actions := []struct { actions := []struct {
key string key string
desc string desc string
color lipgloss.Color
}{ }{
{"i", "Install missing tools"}, {"i", "Install missing tools", primaryColor},
{"u", "Check for updates"}, {"u", "Check for updates", warmColor},
{"s", "Rescan system"}, {"s", "Rescan system", roseColor},
{"l", "Scan LSP servers"}, {"l", "Scan LSP servers", accentColor},
{"m", "Configure MCP servers"}, {"m", "Configure MCP servers", roseLightColor},
} }
for _, a := range actions { for _, a := range actions {
right.WriteString(fmt.Sprintf(" %s %s\n", right.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("["+a.key+"]"), lipgloss.NewStyle().Foreground(a.color).Bold(true).Render("["+a.key+"]"),
a.desc)) lipgloss.NewStyle().Foreground(textColor).Render(a.desc)))
}
right.WriteString("\n")
right.WriteString(renderSectionWithIcon("Active Agents", "◉"))
right.WriteString("\n")
agents := []struct {
name string
}{
{"Crush"},
{"Claude Code"},
}
for _, a := range agents {
right.WriteString(" ")
right.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("● "))
right.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(a.name + " "))
right.WriteString(itemPendingStyle.Render("stopped"))
right.WriteString("\n")
} }
right.WriteString("\n") right.WriteString("\n")
if len(m.updateStatus) > 0 { if len(m.updateStatus) > 0 {
right.WriteString(sectionStyle.Render("Updates")) right.WriteString(renderSectionWithIcon("Updates", "↻"))
right.WriteString("\n") right.WriteString("\n")
for _, s := range m.updateStatus { for _, s := range m.updateStatus {
if s.NeedsUpdate { if s.NeedsUpdate {
right.WriteString(" ") right.WriteString(" ")
right.WriteString(itemWarnStyle.Render(" ")) right.WriteString(itemWarnStyle.Render(" "))
right.WriteString(fmt.Sprintf(" %s: %s -> %s\n", s.Tool, s.Current, s.Latest)) right.WriteString(fmt.Sprintf("%s: %s %s\n", s.Tool, s.Current, s.Latest))
} else if s.Error == "" { } else if s.Error == "" {
right.WriteString(" ") right.WriteString(" ")
right.WriteString(itemOKStyle.Render(" ")) right.WriteString(itemOKStyle.Render(" "))
right.WriteString(fmt.Sprintf(" %s: up to date\n", s.Tool)) right.WriteString(fmt.Sprintf("%s: up to date\n", s.Tool))
} }
} }
right.WriteString("\n") right.WriteString("\n")
} }
if len(m.lspServers) > 0 { if len(m.lspServers) > 0 {
right.WriteString(sectionStyle.Render("LSP Servers")) right.WriteString(renderSectionWithIcon("LSP Servers", "§"))
right.WriteString("\n") right.WriteString("\n")
lspInstalled := 0 lspInstalled := 0
for _, s := range m.lspServers { for _, s := range m.lspServers {
if s.Installed { if s.Installed {
lspInstalled++ lspInstalled++
right.WriteString(" ") right.WriteString(" ")
right.WriteString(itemOKStyle.Render(" ")) right.WriteString(itemOKStyle.Render(" "))
right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language)) right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
} else { } else {
right.WriteString(" ") right.WriteString(" ")
right.WriteString(itemPendingStyle.Render(" ")) right.WriteString(itemPendingStyle.Render(" "))
right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language)) 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(fmt.Sprintf("\n %d/%d available\n", lspInstalled, len(m.lspServers)))
right.WriteString("\n") right.WriteString("\n")
} }
mcpStatus := itemPendingStyle.Render("○ not configured")
if m.mcpConfigured {
mcpStatus = itemOKStyle.Render("✓ configured")
}
right.WriteString(fmt.Sprintf(" MCP: %s\n", mcpStatus))
if m.daemon != nil { if m.daemon != nil {
right.WriteString(sectionStyle.Render("Daemon")) daemonStatus := itemPendingStyle.Render("○ stopped")
right.WriteString("\n")
if m.daemon.IsRunning() { if m.daemon.IsRunning() {
right.WriteString(" ") daemonStatus = itemOKStyle.Render("✓ running")
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") right.WriteString(fmt.Sprintf(" Daemon: %s\n", daemonStatus))
} }
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()) leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String()) rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
} }
func renderSectionWithIcon(title string, icon string) string {
return lipgloss.NewStyle().Foreground(primaryColor).Render(icon+" ") +
sectionStyle.Render(title)
}

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"strings"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -12,6 +13,7 @@ import (
"github.com/muyue/muyue/internal/proxy" "github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater" "github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/workflow"
) )
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -22,8 +24,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleTabMenu(msg) return m.handleTabMenu(msg)
} }
if m.activeTab == tabTerminal { if m.activeTab == tabShell {
return m.handleTerminalKey(msg) return m.handleShellKey(msg)
} }
switch msg.String() { switch msg.String() {
@@ -43,17 +45,22 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tabMenuCursor = int(m.activeTab) m.tabMenuCursor = int(m.activeTab)
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
case "ctrl+s":
if m.activeTab == tabStudio {
m.studioSidebarOpen = !m.studioSidebarOpen
m.viewport.SetContent(m.renderContent())
}
case "enter": case "enter":
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && m.chatInput != "" && !m.chatLoading { if m.activeTab == tabStudio && m.chatInput != "" && !m.chatLoading {
return m.handleChatSubmit() return m.handleChatSubmit()
} }
case "backspace": case "backspace":
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(m.chatInput) > 0 { if m.activeTab == tabStudio && len(m.chatInput) > 0 {
m.chatInput = m.chatInput[:len(m.chatInput)-1] m.chatInput = m.chatInput[:len(m.chatInput)-1]
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
} }
default: default:
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(msg.String()) == 1 && !m.chatLoading { if m.activeTab == tabStudio && len(msg.String()) == 1 && !m.chatLoading {
m.chatInput += msg.String() m.chatInput += msg.String()
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
} }
@@ -62,11 +69,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.activeTab == tabDashboard { if m.activeTab == tabDashboard {
return m.handleDashboardKey(msg) return m.handleDashboardKey(msg)
} }
if m.activeTab == tabAgents { if m.activeTab == tabStudio {
return m.handleAgentsKey(msg) return m.handleStudioKey(msg)
}
if m.activeTab == tabWorkflow {
return m.handleWorkflowKey(msg)
} }
return m, nil return m, nil
@@ -170,13 +174,13 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
} }
if len(missing) == 0 { if len(missing) == 0 {
m.installLog = append(m.installLog, itemOKStyle.Render("All tools already installed!")) m.installLog = append(m.installLog, itemOKStyle.Render("All tools already installed!"))
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
} }
needsSudo := checkNeedsSudo(m.scanResult) needsSudo := checkNeedsSudo(m.scanResult)
if needsSudo && !hasSudo() { if needsSudo && !hasSudo() {
m.installLog = append(m.installLog, errMsgStyle.Render("Some tools require sudo. Run: sudo muyue install")) m.installLog = append(m.installLog, errMsgStyle.Render("Some tools require sudo. Run: sudo muyue install"))
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
} }
@@ -210,6 +214,94 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m Model) handleStudioKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if !m.studioSidebarOpen {
return m, nil
}
switch msg.String() {
case "1":
m.studioPanel = panelChat
m.viewport.SetContent(m.renderContent())
case "2":
m.studioPanel = panelAgents
m.viewport.SetContent(m.renderContent())
case "3":
m.studioPanel = panelWorkflows
m.viewport.SetContent(m.renderContent())
}
if m.studioPanel == panelAgents {
return m.handleAgentsKey(msg)
}
if m.studioPanel == panelWorkflows {
return m.handleWorkflowKey(msg)
}
return m, nil
}
func (m Model) handleAgentsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "c":
if m.proxyMgr.IsAvailable(proxy.AgentCrush) {
m.proxyMgr.Start(proxy.AgentCrush)
}
m.viewport.SetContent(m.renderContent())
case "l":
if m.proxyMgr.IsAvailable(proxy.AgentClaude) {
m.proxyMgr.Start(proxy.AgentClaude)
}
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.orch == nil || m.orch.Workflow == nil {
return m, nil
}
wf := m.orch.Workflow
switch msg.String() {
case "a":
if wf.Phase == workflow.PhaseReviewing {
m.chatLog = append(m.chatLog, userMsgStyle.Render("⟩ [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("⟩ [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 checkNeedsSudo(scan *scanner.ScanResult) bool { func checkNeedsSudo(scan *scanner.ScanResult) bool {
if scan == nil { if scan == nil {
return false return false
@@ -237,3 +329,23 @@ func hasSudo() bool {
} }
return false return false
} }
func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
input := m.chatInput
m.chatLog = append(m.chatLog, userMsgStyle.Render("⟩ "+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)
}

View File

@@ -31,7 +31,6 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
d := daemon.NewDaemon(cfg, 1*time.Hour) d := daemon.NewDaemon(cfg, 1*time.Hour)
lspServers := lsp.ScanServers() lspServers := lsp.ScanServers()
skillList, _ := skills.List() skillList, _ := skills.List()
mcpConfigured := false mcpConfigured := false
@@ -45,19 +44,19 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
sp := spinner.New() sp := spinner.New()
sp.Spinner = spinner.Dot sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(baseColor) sp.Style = lipgloss.NewStyle().Foreground(primaryColor)
prog := progress.New(progress.WithGradient("#FF6B9D", "#A0D2FF")) prog := progress.New(progress.WithGradient("#E8364F", "#FF6B8A"))
cwd, _ := os.Getwd() cwd, _ := os.Getwd()
return Model{ return Model{
config: cfg, config: cfg,
scanResult: scan, scanResult: scan,
activeTab: tabDashboard, activeTab: tabDashboard,
chatLog: []string{ chatLog: []string{
aiMsgStyle.Render("muyue: Welcome! I'm your AI development environment assistant."), aiMsgStyle.Render(" Welcome to Studio! Chat with your AI assistant here."),
aiMsgStyle.Render("muyue: Type /plan <goal> to start a structured workflow, or just chat."), aiMsgStyle.Render(" Configure agents and workflows from the sidebar. Type /plan <goal> to start."),
}, },
orch: orch, orch: orch,
proxyMgr: proxyMgr, proxyMgr: proxyMgr,
@@ -75,11 +74,26 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
showingTabMenu: false, showingTabMenu: false,
tabMenuCursor: 0, tabMenuCursor: 0,
termCwd: cwd, termCwd: cwd,
studioPanel: panelChat,
studioSidebarOpen: true,
termAIChat: []string{
aiMsgStyle.Render(" I know your system inside out. Ask me anything."),
},
termAIShow: true,
configSection: configProfile,
configField: 0,
animationFrame: 0,
} }
} }
func animTick() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return animTickMsg{time: t}
})
}
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch(spinner.Tick, tea.EnterAltScreen) return tea.Batch(spinner.Tick, animTick(), tea.EnterAltScreen)
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -90,43 +104,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg) m.spinner, cmd = m.spinner.Update(msg)
return m, cmd return m, cmd
case animTickMsg:
m.animationFrame++
return m, animTick()
case progress.FrameMsg: case progress.FrameMsg:
pm, cmd := m.progressBar.Update(msg) pm, cmd := m.progressBar.Update(msg)
m.progressBar = pm.(progress.Model) m.progressBar = pm.(progress.Model)
return m, cmd return m, cmd
case termOutputMsg: case termOutputMsg:
m.termLog = append(m.termLog, msg.line) m.termLog = append(m.termLog, msg.line)
if m.activeTab == tabTerminal { if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom() m.viewport.GotoBottom()
} }
return m, nil return m, nil
case termExitMsg: case termExitMsg:
m.termRunning = false m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(mutedColor).Render("(process exited)")) m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render("(process exited)"))
m.termCmd = nil m.termCmd = nil
if m.activeTab == tabTerminal { if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
} }
return m, nil return m, nil
case aiResponseMsg: case aiResponseMsg:
m.chatLoading = false m.chatLoading = false
m.termAILoading = false
content := msg.content content := msg.content
m.chatLog = append(m.chatLog, aiMsgStyle.Render("muyue: "+content))
if m.orch != nil && m.orch.Workflow != nil { if m.activeTab == tabShell && m.termAIShow {
previewFiles := parsePreviewFiles(content) m.termAIChat = append(m.termAIChat, aiMsgStyle.Render(" "+content))
if len(previewFiles) > 0 { if m.activeTab == tabShell {
m.handlePreview(previewFiles) m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
} }
} else {
m.chatLog = append(m.chatLog, aiMsgStyle.Render(" "+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()
} }
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil return m, nil
case aiErrMsg: case aiErrMsg:
m.chatLoading = false m.chatLoading = false
m.chatLog = append(m.chatLog, errMsgStyle.Render("error: "+msg.err.Error())) m.termAILoading = false
errText := errMsgStyle.Render(" error: " + msg.err.Error())
if m.activeTab == tabShell && m.termAIShow {
m.termAIChat = append(m.termAIChat, errText)
} else {
m.chatLog = append(m.chatLog, errText)
}
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom() m.viewport.GotoBottom()
return m, nil return m, nil
@@ -137,9 +168,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case installCompleteMsg: case installCompleteMsg:
m.installing = false m.installing = false
for _, r := range msg.results { for _, r := range msg.results {
status := itemOKStyle.Render("[OK]") status := itemOKStyle.Render("")
if !r.Success { if !r.Success {
status = itemMissingStyle.Render("[FAIL]") status = itemMissingStyle.Render("")
} }
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message)) m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message))
} }
@@ -148,7 +179,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
case installProgressMsg: case installProgressMsg:
status := itemOKStyle.Render("[OK]") status := itemOKStyle.Render("")
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool)) m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool))
m.installCurrent = msg.current m.installCurrent = msg.current
m.installTool = "" m.installTool = ""
@@ -157,9 +188,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
case installBatchMsg: case installBatchMsg:
status := itemOKStyle.Render("[OK]") status := itemOKStyle.Render("")
if !msg.result.Success { if !msg.result.Success {
status = itemMissingStyle.Render("[FAIL]") status = itemMissingStyle.Render("")
} }
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message)) m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message))
m.installCurrent = msg.index + 1 m.installCurrent = msg.index + 1
@@ -200,10 +231,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.helpModel.Width = msg.Width m.helpModel.Width = msg.Width
headerH := 1 headerH := 2
footerH := 2 footerH := 2
inputH := 0 inputH := 0
if m.activeTab == tabChat || m.activeTab == tabWorkflow || m.activeTab == tabTerminal { if m.activeTab == tabStudio || m.activeTab == tabShell {
inputH = 2 inputH = 2
} }
contentH := msg.Height - headerH - footerH - inputH contentH := msg.Height - headerH - footerH - inputH
@@ -223,7 +254,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) View() string { func (m Model) View() string {
if !m.ready { if !m.ready {
return "Loading..." return lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Loading muyue...")
} }
if m.showingQuit { if m.showingQuit {
@@ -238,13 +269,13 @@ func (m Model) View() string {
b.WriteString(m.renderHeader()) b.WriteString(m.renderHeader())
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.viewport.View()) b.WriteString(m.viewport.View())
if m.activeTab == tabChat || m.activeTab == tabWorkflow { if m.activeTab == tabStudio {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.renderChatInput()) b.WriteString(m.renderStudioInput())
} }
if m.activeTab == tabTerminal { if m.activeTab == tabShell {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.renderTermInput()) b.WriteString(m.renderShellInput())
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.renderFooter()) b.WriteString(m.renderFooter())
@@ -253,39 +284,52 @@ func (m Model) View() string {
} }
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
logoStyle := lipgloss.NewStyle().Foreground(baseColor).Bold(true) var tabs []string
badgeStyle := lipgloss.NewStyle(). for i, name := range tabNames {
Background(baseColor). icon := tabIcons[i]
Foreground(lipgloss.Color("#FFFFFF")). if tab(i) == m.activeTab {
Padding(0, 1) tabStyle := lipgloss.NewStyle().
Background(primaryColor).
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
} else {
tabStyle := lipgloss.NewStyle().
Background(bgPanel).
Foreground(textDimColor).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
}
}
logo := logoStyle.Render("muyue") tabLine := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...))
badge := badgeStyle.Render("v" + version.Version)
activeTabName := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render(tabNames[m.activeTab]) badge := lipgloss.NewStyle().
separator := lipgloss.NewStyle().Foreground(dimColor).Render(" · ") Foreground(roseColor).
Bold(true).
Render("muyue")
versionBadge := lipgloss.NewStyle().
Foreground(dimColor).
Render("v" + version.Version)
rightPart := separator + activeTabName anim := lipgloss.NewStyle().Foreground(warmColor).Render(getAnimFrame(m.animationFrame))
line := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render( logoLine := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", badge, rightPart), lipgloss.JoinHorizontal(lipgloss.Center, badge, " ", versionBadge, " ", anim),
) )
return line return lipgloss.JoinVertical(lipgloss.Left, logoLine, tabLine)
} }
func (m Model) renderContent() string { func (m Model) renderContent() string {
switch m.activeTab { switch m.activeTab {
case tabDashboard: case tabDashboard:
return m.renderDashboard() return m.renderDashboard()
case tabChat: case tabStudio:
return m.renderChat() return m.renderStudio()
case tabWorkflow: case tabShell:
return m.renderWorkflow() return m.renderShell()
case tabTerminal:
return m.renderTerminal()
case tabAgents:
return m.renderAgents()
case tabConfig: case tabConfig:
return m.renderConfig() return m.renderConfig()
default: default:
@@ -294,10 +338,10 @@ func (m Model) renderContent() string {
} }
func (m *Model) resizeViewport() { func (m *Model) resizeViewport() {
headerH := 1 headerH := 2
footerH := 2 footerH := 2
inputH := 0 inputH := 0
if m.activeTab == tabChat || m.activeTab == tabWorkflow || m.activeTab == tabTerminal { if m.activeTab == tabStudio || m.activeTab == tabShell {
inputH = 2 inputH = 2
} }
contentH := m.height - headerH - footerH - inputH contentH := m.height - headerH - footerH - inputH
@@ -316,21 +360,23 @@ func (m Model) renderFooter() string {
profile = m.config.Profile.Pseudo profile = m.config.Profile.Pseudo
} }
left := fmt.Sprintf(" %s@%s", profile, version.Name) left := fmt.Sprintf(" %s@%s",
lipgloss.NewStyle().Foreground(roseColor).Bold(true).Render(profile),
lipgloss.NewStyle().Foreground(dimColor).Render(version.Name))
leftR := statusBarStyle.Render(left) leftR := statusBarStyle.Render(left)
var helpText string var helpText string
switch m.activeTab { switch m.activeTab {
case tabDashboard: case tabDashboard:
helpText = "[i] install [u] update [s] scan" helpText = "[i] install [u] update [s] scan [ctrl+t] tabs"
case tabChat, tabWorkflow: case tabStudio:
helpText = "[ctrl+t] switch tab [ctrl+c] quit" helpText = "[enter] send [ctrl+s] sidebar [ctrl+t] tabs"
case tabTerminal: case tabShell:
helpText = "[enter] run [ctrl+c] kill [clear] clear" helpText = "[enter] run [ctrl+a] AI panel [ctrl+c] kill"
case tabAgents: case tabConfig:
helpText = "[c] crush [l] claude" helpText = "[↑↓] sections [ctrl+t] tabs"
default: default:
helpText = "[ctrl+t] switch tab [ctrl+c] quit" helpText = "[ctrl+t] tabs [ctrl+c] quit"
} }
rightR := statusBarStyle.Render(helpText) rightR := statusBarStyle.Render(helpText)
@@ -346,7 +392,7 @@ func (m Model) renderFooter() string {
) )
return lipgloss.JoinVertical(lipgloss.Left, statusLine, return lipgloss.JoinVertical(lipgloss.Left, statusLine,
lipgloss.NewStyle().Foreground(dimColor).Render( lipgloss.NewStyle().Background(bgPanel).Foreground(dimColor).Render(
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys)))) lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys))))
} }
@@ -358,7 +404,10 @@ func (m Model) renderQuitOverlay() string {
noStyle = confirmNoStyle noStyle = confirmNoStyle
} }
box := fmt.Sprintf("\n\n Quit muyue?\n\n %s %s", frame := lipgloss.NewStyle().Foreground(primaryColor).Render(getAnimFrame(m.animationFrame))
box := fmt.Sprintf("\n\n %s Quit muyue?\n\n %s %s",
frame,
yesStyle.Render("[ Yes ]"), yesStyle.Render("[ Yes ]"),
noStyle.Render("[ No ]"), noStyle.Render("[ No ]"),
) )
@@ -374,52 +423,54 @@ func (m Model) renderQuitOverlay() string {
} }
func (m Model) renderTabMenuOverlay() string { func (m Model) renderTabMenuOverlay() string {
tabMenuStyle := lipgloss.NewStyle(). menuStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(baseColor). BorderForeground(primaryColor).
Background(bgCard). Background(bgCard).
Padding(1, 3) Padding(1, 3)
tabItemStyle := lipgloss.NewStyle(). tabItemStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#A0A0B0")). Foreground(textDimColor).
Padding(0, 2) Padding(0, 2)
tabItemActiveStyle := lipgloss.NewStyle(). tabItemActiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")). Foreground(lipgloss.Color("#FFFFFF")).
Background(baseColor). Background(primaryColor).
Bold(true). Bold(true).
Padding(0, 2) Padding(0, 2)
tabNumStyle := lipgloss.NewStyle(). descs := []string{
Foreground(dimColor). "tools, updates & system status",
Width(4) "chat, agents & workflows",
"terminal + AI assistant",
"profile, API keys & settings",
}
var items []string 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 { for i, name := range tabNames {
num := tabNumStyle.Render(fmt.Sprintf(" %d.", i+1)) num := lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %d.", i+1))
icon := tabIcons[i] + " "
if i == m.tabMenuCursor { if i == m.tabMenuCursor {
item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(mutedColor).Render(descs[i])) item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(roseLightColor).Render(descs[i]))
items = append(items, tabItemActiveStyle.Render(">"+item)) items = append(items, tabItemActiveStyle.Render(""+item))
} else { } else {
item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i])) item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i]))
items = append(items, tabItemStyle.Render(" "+item)) items = append(items, tabItemStyle.Render(" "+item))
} }
} }
content := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("Switch Tab") + header := lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Switch Tab")
"\n\n" + content := header + "\n\n" +
strings.Join(items, "\n") + strings.Join(items, "\n") +
"\n\n" + "\n\n" +
lipgloss.NewStyle().Foreground(dimColor).Render("up/down navigate enter/select esc cancel") lipgloss.NewStyle().Foreground(dimColor).Render("↑↓ navigate · enter select · esc cancel")
box := tabMenuStyle.Render(content) box := menuStyle.Render(content)
return lipgloss.Place(m.width, m.height, return lipgloss.Place(m.width, m.height,
0.5, 0.5, 0.5, 0.5,
box, box,
lipgloss.WithWhitespaceBackground(bgPanel), lipgloss.WithWhitespaceBackground(bgDark),
lipgloss.WithWhitespaceForeground(dimColor), lipgloss.WithWhitespaceForeground(dimColor),
) )
} }
@@ -438,9 +489,28 @@ func (m *Model) handlePreview(files []previewFile) {
} }
m.previewSrv = preview.NewPreviewServer(dir) m.previewSrv = preview.NewPreviewServer(dir)
if err := m.previewSrv.Start(8765); err != nil { if err := m.previewSrv.Start(8765); err != nil {
m.chatLog = append(m.chatLog, errMsgStyle.Render("preview error: "+err.Error())) m.chatLog = append(m.chatLog, errMsgStyle.Render(" preview error: "+err.Error()))
} else { } else {
m.previewURL = "http://127.0.0.1:8765" 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")) m.chatLog = append(m.chatLog, itemOKStyle.Render(" Preview: http://127.0.0.1:8765"))
} }
} }
func (m Model) renderStudioInput() string {
if m.chatLoading {
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render("⟩ ") + m.spinner.View() + lipgloss.NewStyle().Foreground(mutedColor).Render(" thinking..."),
)
}
cursor := lipgloss.NewStyle().Foreground(primaryColor).Render("▎")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render("⟩ ") + m.chatInput + cursor,
)
}
func (m Model) renderShellInput() string {
prompt := lipgloss.NewStyle().Foreground(successColor).Render(" ")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
prompt + m.termInput + lipgloss.NewStyle().Foreground(primaryColor).Render("▎"),
)
}

241
internal/tui/studio.go Normal file
View File

@@ -0,0 +1,241 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/workflow"
)
func (m Model) renderStudio() string {
if m.studioSidebarOpen {
sidebarWidth := 28
chatWidth := m.width - sidebarWidth - 2
if chatWidth < 20 {
chatWidth = 20
sidebarWidth = m.width - chatWidth - 2
}
sidebar := m.renderStudioSidebar(sidebarWidth)
chat := m.renderStudioChat(chatWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, chat)
}
return m.renderStudioChat(m.width)
}
func (m Model) renderStudioSidebar(width int) string {
var b strings.Builder
b.WriteString(renderSectionWithIcon("Studio", "◈"))
b.WriteString("\n\n")
panels := []struct {
name string
panel studioPanel
icon string
}{
{"Chat", panelChat, "💬"},
{"Agents", panelAgents, "◉"},
{"Workflows", panelWorkflows, "⟐"},
}
for _, p := range panels {
if m.studioPanel == p.panel {
activeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(primaryColor).
Bold(true).
Padding(0, 1)
b.WriteString(activeStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
} else {
inactiveStyle := lipgloss.NewStyle().
Foreground(textDimColor).
Padding(0, 1)
b.WriteString(inactiveStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", width-4)))
b.WriteString("\n\n")
switch m.studioPanel {
case panelAgents:
m.renderAgentsSidebar(&b, width)
case panelWorkflows:
m.renderWorkflowSidebar(&b, width)
default:
m.renderChatSidebar(&b, width)
}
return sidebarStyle.Width(width).Render(b.String())
}
func (m Model) renderChatSidebar(b *strings.Builder, width int) {
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Active Provider"))
b.WriteString("\n")
provider := "none"
if m.config != nil {
provider = m.config.Profile.Preferences.DefaultAI
}
b.WriteString(lipgloss.NewStyle().Foreground(roseColor).Bold(true).Render(" " + provider))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Commands"))
b.WriteString("\n")
cmds := []string{"/plan <goal>", "/help"}
for _, c := range cmds {
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(" " + c))
b.WriteString("\n")
}
if m.previewURL != "" {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Preview"))
b.WriteString("\n")
b.WriteString(itemOKStyle.Render(" " + m.previewURL))
b.WriteString("\n")
}
}
func (m Model) renderAgentsSidebar(b *strings.Builder, width int) {
agents := []struct {
name string
agentType proxy.AgentType
tool string
}{
{"Crush", proxy.AgentCrush, "GLM"},
{"Claude Code", proxy.AgentClaude, "Anthropic"},
}
for _, a := range agents {
status, _ := m.proxyMgr.Status(a.agentType)
available := m.proxyMgr.IsAvailable(a.agentType)
var statusIcon string
switch status {
case proxy.StatusRunning:
statusIcon = lipgloss.NewStyle().Foreground(warmColor).Render("● running")
case proxy.StatusStopped:
statusIcon = lipgloss.NewStyle().Foreground(mutedColor).Render("○ stopped")
case proxy.StatusError:
statusIcon = lipgloss.NewStyle().Foreground(errorColor).Render("✗ error")
default:
if available {
statusIcon = lipgloss.NewStyle().Foreground(successColor).Render("✓ available")
} else {
statusIcon = lipgloss.NewStyle().Foreground(dimColor).Render("✗ not installed")
}
}
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Bold(true).Render(a.name))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", statusIcon))
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %s\n", a.tool)))
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Actions"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" [c]"))
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(" Start Crush"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" [l]"))
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(" Start Claude"))
b.WriteString("\n")
}
func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
if m.orch == nil || m.orch.Workflow == nil {
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("No active workflow."))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("Use /plan <goal> in chat"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("to start a workflow."))
b.WriteString("\n")
return
}
wf := m.orch.Workflow
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(roseColor).Bold(true),
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(accentColor).Bold(true),
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(primaryColor).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(lipgloss.NewStyle().Foreground(mutedColor).Render("Goal"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(wf.Plan.Goal))
b.WriteString("\n\n")
}
if wf.Phase == workflow.PhaseExecuting {
done, total := wf.Progress()
m.progressBar.SetPercent(float64(done) / float64(max(total, 1)))
b.WriteString(m.progressBar.View())
b.WriteString(fmt.Sprintf(" %d/%d", done, total))
b.WriteString("\n\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Controls"))
b.WriteString("\n")
controls := []struct {
key string
desc string
}{
{"[a]", "Approve plan"},
{"[r]", "Reject plan"},
{"[g]", "Generate plan"},
{"[n]", "Next step"},
{"[x]", "Cancel"},
}
for _, c := range controls {
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" " + c.key))
b.WriteString(lipgloss.NewStyle().Foreground(textDimColor).Render(" " + c.desc))
b.WriteString("\n")
}
}
func (m Model) renderStudioChat(width int) string {
var b strings.Builder
chatHeader := renderSectionWithIcon("Chat", "💬")
if m.chatLoading {
chatHeader += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warningColor).Render("thinking...")
}
b.WriteString(chatHeader)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n\n")
for _, msg := range m.chatLog {
b.WriteString(msg)
b.WriteString("\n\n")
}
return b.String()
}
func (m Model) handleStudioPanelSwitch(panel studioPanel) {
m.studioPanel = panel
m.viewport.SetContent(m.renderContent())
}

View File

@@ -5,20 +5,37 @@ import (
) )
var ( var (
baseColor = lipgloss.Color("#FF6B9D") primaryColor = lipgloss.Color("#E8364F")
accentColor = lipgloss.Color("#A0D2FF") roseColor = lipgloss.Color("#FF6B8A")
aiColor = lipgloss.Color("#C4B5FD") roseLightColor = lipgloss.Color("#FFB3C6")
successColor = lipgloss.Color("#4ADE80") accentColor = lipgloss.Color("#FF8FA3")
warningColor = lipgloss.Color("#FBBF24") warmColor = lipgloss.Color("#FF4D6D")
errorColor = lipgloss.Color("#FF6B6B") successColor = lipgloss.Color("#4ADE80")
mutedColor = lipgloss.Color("#666680") warningColor = lipgloss.Color("#FBBF24")
dimColor = lipgloss.Color("#444460") errorColor = lipgloss.Color("#FF4D4D")
bgDark = lipgloss.Color("#1A1A2E") mutedColor = lipgloss.Color("#8B7E8E")
bgPanel = lipgloss.Color("#16213E") dimColor = lipgloss.Color("#5A4F5E")
bgCard = lipgloss.Color("#1F2937") textColor = lipgloss.Color("#F0E6E8")
textDimColor = lipgloss.Color("#B8A9AD")
bgDark = lipgloss.Color("#0D0A0B")
bgPanel = lipgloss.Color("#1A1215")
bgCard = lipgloss.Color("#231A1D")
bgInput = lipgloss.Color("#2A2023")
bgHover = lipgloss.Color("#332528")
borderColor = lipgloss.Color("#3D2E32")
borderAccent = lipgloss.Color("#E8364F")
tabActiveBg = lipgloss.Color("#E8364F")
tabInactiveBg = lipgloss.Color("#1A1215")
sectionStyle = lipgloss.NewStyle(). sectionStyle = lipgloss.NewStyle().
Foreground(accentColor). Foreground(roseColor).
Bold(true)
sectionIconStyle = lipgloss.NewStyle().
Foreground(primaryColor).
Bold(true) Bold(true)
itemOKStyle = lipgloss.NewStyle(). itemOKStyle = lipgloss.NewStyle().
@@ -34,16 +51,16 @@ var (
Foreground(mutedColor) Foreground(mutedColor)
userMsgStyle = lipgloss.NewStyle(). userMsgStyle = lipgloss.NewStyle().
Foreground(accentColor) Foreground(roseLightColor)
aiMsgStyle = lipgloss.NewStyle(). aiMsgStyle = lipgloss.NewStyle().
Foreground(aiColor) Foreground(textColor)
errMsgStyle = lipgloss.NewStyle(). errMsgStyle = lipgloss.NewStyle().
Foreground(errorColor) Foreground(errorColor)
inputStyle = lipgloss.NewStyle(). inputStyle = lipgloss.NewStyle().
Foreground(baseColor) Foreground(roseColor)
stepDoneStyle = lipgloss.NewStyle(). stepDoneStyle = lipgloss.NewStyle().
Foreground(successColor) Foreground(successColor)
@@ -52,22 +69,22 @@ var (
Foreground(mutedColor) Foreground(mutedColor)
stepCurrentStyle = lipgloss.NewStyle(). stepCurrentStyle = lipgloss.NewStyle().
Foreground(baseColor). Foreground(primaryColor).
Bold(true) Bold(true)
stepErrorStyle = lipgloss.NewStyle(). stepErrorStyle = lipgloss.NewStyle().
Foreground(errorColor) Foreground(errorColor)
statusBarStyle = lipgloss.NewStyle(). statusBarStyle = lipgloss.NewStyle().
Background(bgDark). Background(bgPanel).
Foreground(lipgloss.Color("#A0A0B0")). Foreground(textDimColor).
Padding(0, 1) Padding(0, 1)
confirmBoxStyle = lipgloss.NewStyle(). confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(baseColor). BorderForeground(primaryColor).
Background(bgCard). Background(bgCard).
Foreground(lipgloss.Color("#FFFFFF")). Foreground(textColor).
Padding(1, 3). Padding(1, 3).
Bold(true) Bold(true)
@@ -77,4 +94,38 @@ var (
confirmNoStyle = lipgloss.NewStyle(). confirmNoStyle = lipgloss.NewStyle().
Foreground(mutedColor) Foreground(mutedColor)
cardStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(borderColor).
Padding(0, 1)
sidebarStyle = lipgloss.NewStyle().
Background(bgPanel).
Border(lipgloss.Border{Right: "│"}).
BorderForeground(borderColor).
Padding(0, 1)
badgeStyle = lipgloss.NewStyle().
Background(primaryColor).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Bold(true)
labelStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Width(14)
valueStyle = lipgloss.NewStyle().
Foreground(textColor)
tabBarStyle = lipgloss.NewStyle().
Background(bgPanel)
pulseFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}
) )
func getAnimFrame(frame int) string {
return pulseFrames[frame%len(pulseFrames)]
}

View File

@@ -33,7 +33,78 @@ func isDangerousCommand(input string) bool {
return false return false
} }
func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) renderShell() string {
if m.termAIShow {
aiWidth := 36
termWidth := m.width - aiWidth - 2
if termWidth < 20 {
termWidth = 20
aiWidth = m.width - termWidth - 2
}
termPanel := m.renderTermPanel(termWidth)
aiPanel := m.renderAIPanel(aiWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, termPanel, aiPanel)
}
return m.renderTermPanel(m.width)
}
func (m Model) renderTermPanel(width int) string {
var b strings.Builder
cwdStyle := lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd)
b.WriteString(renderSectionWithIcon("Terminal", "▶"))
b.WriteString(" ")
b.WriteString(cwdStyle)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n")
for _, line := range m.termLog {
b.WriteString(line + "\n")
}
return b.String()
}
func (m Model) renderAIPanel(width int) string {
var b strings.Builder
b.WriteString(renderSectionWithIcon("AI Assistant", "◈"))
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n\n")
for _, msg := range m.termAIChat {
b.WriteString(msg)
b.WriteString("\n\n")
}
if m.termAILoading {
b.WriteString(lipgloss.NewStyle().Foreground(warmColor).Render(" " + getAnimFrame(m.animationFrame) + " thinking..."))
b.WriteString("\n")
}
inputLabel := lipgloss.NewStyle().Foreground(roseColor).Render("⟩ ")
b.WriteString(inputLabel)
b.WriteString(m.termAIInput)
return lipgloss.NewStyle().
Background(bgPanel).
Border(lipgloss.Border{Left: "│"}).
BorderForeground(borderColor).
Width(width).
Padding(0, 1).
Render(b.String())
}
func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
if m.termCmd != nil && m.termCmd.Process != nil { if m.termCmd != nil && m.termCmd.Process != nil {
@@ -58,6 +129,10 @@ func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showingTabMenu = true m.showingTabMenu = true
m.tabMenuCursor = int(m.activeTab) m.tabMenuCursor = int(m.activeTab)
return m, nil return m, nil
case "ctrl+a":
m.termAIShow = !m.termAIShow
m.viewport.SetContent(m.renderContent())
return m, nil
case "enter": case "enter":
if m.termRunning { if m.termRunning {
return m, nil return m, nil
@@ -76,7 +151,7 @@ func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if isDangerousCommand(input) { if isDangerousCommand(input) {
m.termLog = append(m.termLog, errMsgStyle.Render("blocked: potentially dangerous command")) m.termLog = append(m.termLog, errMsgStyle.Render(" blocked: potentially dangerous command"))
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom() m.viewport.GotoBottom()
return m, nil return m, nil
@@ -92,7 +167,7 @@ func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.termCwd, _ = os.Getwd() m.termCwd, _ = os.Getwd()
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input)) m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input))
} else { } else {
m.termLog = append(m.termLog, errMsgStyle.Render("cd: "+err.Error())) m.termLog = append(m.termLog, errMsgStyle.Render(" cd: "+err.Error()))
} }
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom() m.viewport.GotoBottom()
@@ -132,23 +207,3 @@ func (m Model) runTermCommand(input string) tea.Cmd {
return termOutputMsg{line: string(out)} 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("")
}

View File

@@ -26,15 +26,14 @@ type tab int
const ( const (
tabDashboard tab = iota tabDashboard tab = iota
tabChat tabStudio
tabWorkflow tabShell
tabTerminal
tabAgents
tabConfig tabConfig
tabCount tabCount
) )
var tabNames = []string{"Dashboard", "Chat", "Workflow", "Terminal", "Agents", "Config"} var tabNames = []string{"Dashboard", "Studio", "Shell", "Config"}
var tabIcons = []string{"◉", "◈", "▶", "⚙"}
type aiResponseMsg struct{ content string } type aiResponseMsg struct{ content string }
type aiErrMsg struct{ err error } type aiErrMsg struct{ err error }
@@ -61,20 +60,40 @@ type skillsListMsg struct{ skills []skills.Skill }
type spinnerTickMsg struct{ time time.Time } type spinnerTickMsg struct{ time time.Time }
type termOutputMsg struct{ line string } type termOutputMsg struct{ line string }
type termExitMsg struct{} type termExitMsg struct{}
type animTickMsg struct{ time time.Time }
type studioPanel int
const (
panelChat studioPanel = iota
panelAgents
panelWorkflows
)
type configSection int
const (
configProfile configSection = iota
configProviders
configTerminal
configSkills
)
type Model struct { type Model struct {
config *config.MuyueConfig config *config.MuyueConfig
scanResult *scanner.ScanResult scanResult *scanner.ScanResult
activeTab tab activeTab tab
width int width int
height int height int
viewport viewport.Model viewport viewport.Model
ready bool ready bool
chatInput string
chatLog []string chatInput string
chatLoading bool chatLog []string
orch *orchestrator.Orchestrator chatLoading bool
proxyMgr *proxy.Manager orch *orchestrator.Orchestrator
proxyMgr *proxy.Manager
updateStatus []updater.UpdateStatus updateStatus []updater.UpdateStatus
installLog []string installLog []string
previewURL string previewURL string
@@ -106,18 +125,26 @@ type Model struct {
termLog []string termLog []string
termRunning bool termRunning bool
termCwd string termCwd string
studioPanel studioPanel
studioSidebarOpen bool
termAIChat []string
termAIInput string
termAILoading bool
termAIShow bool
configSection configSection
configField int
animationFrame int
} }
type keyMap struct { type keyMap struct {
Tab key.Binding Tab key.Binding
Prev key.Binding Prev key.Binding
Quit key.Binding Quit key.Binding
Confirm key.Binding
Cancel key.Binding
TabMenu key.Binding TabMenu key.Binding
Install key.Binding
Update key.Binding
Scan key.Binding
Enter key.Binding Enter key.Binding
Backspace key.Binding Backspace key.Binding
} }
@@ -125,39 +152,19 @@ type keyMap struct {
var keys = keyMap{ var keys = keyMap{
Tab: key.NewBinding( Tab: key.NewBinding(
key.WithKeys("tab"), key.WithKeys("tab"),
key.WithHelp("tab", "indent"), key.WithHelp("tab", "next"),
), ),
Prev: key.NewBinding( Prev: key.NewBinding(
key.WithKeys("shift+tab"), key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "unindent"), key.WithHelp("shift+tab", "prev"),
), ),
Quit: key.NewBinding( Quit: key.NewBinding(
key.WithKeys("ctrl+c"), key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"), 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( TabMenu: key.NewBinding(
key.WithKeys("ctrl+t"), key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "switch tab"), key.WithHelp("ctrl+t", "tabs"),
),
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( Enter: key.NewBinding(
key.WithKeys("enter"), key.WithKeys("enter"),

View File

@@ -1,213 +0,0 @@
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

@@ -2,7 +2,7 @@ package version
const ( const (
Name = "muyue" Name = "muyue"
Version = "0.2.0" Version = "0.2.1"
Author = "La Légion de Muyue" Author = "La Légion de Muyue"
License = "MIT" License = "MIT"
) )