- 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>
242 lines
7.2 KiB
Go
242 lines
7.2 KiB
Go
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())
|
|
}
|