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>
This commit is contained in:
Augustin
2026-04-20 21:03:49 +02:00
parent 3494f6b40d
commit 035e923e6c
12 changed files with 846 additions and 640 deletions

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