- 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>
517 lines
14 KiB
Go
517 lines
14 KiB
Go
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/charmbracelet/bubbles/help"
|
||
"github.com/charmbracelet/bubbles/progress"
|
||
"github.com/charmbracelet/bubbles/spinner"
|
||
"github.com/charmbracelet/bubbles/viewport"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/muyue/muyue/internal/config"
|
||
"github.com/muyue/muyue/internal/daemon"
|
||
"github.com/muyue/muyue/internal/lsp"
|
||
"github.com/muyue/muyue/internal/mcp"
|
||
"github.com/muyue/muyue/internal/orchestrator"
|
||
"github.com/muyue/muyue/internal/preview"
|
||
"github.com/muyue/muyue/internal/proxy"
|
||
"github.com/muyue/muyue/internal/scanner"
|
||
"github.com/muyue/muyue/internal/skills"
|
||
"github.com/muyue/muyue/internal/version"
|
||
)
|
||
|
||
func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
|
||
orch, _ := orchestrator.New(cfg)
|
||
proxyMgr := proxy.NewManager()
|
||
d := daemon.NewDaemon(cfg, 1*time.Hour)
|
||
|
||
lspServers := lsp.ScanServers()
|
||
skillList, _ := skills.List()
|
||
|
||
mcpConfigured := false
|
||
if err := mcp.ConfigureAll(cfg); err == nil {
|
||
mcpConfigured = true
|
||
}
|
||
|
||
if cfg.Profile.Preferences.AutoUpdate {
|
||
d.Start()
|
||
}
|
||
|
||
sp := spinner.New()
|
||
sp.Spinner = spinner.Dot
|
||
sp.Style = lipgloss.NewStyle().Foreground(primaryColor)
|
||
|
||
prog := progress.New(progress.WithGradient("#E8364F", "#FF6B8A"))
|
||
|
||
cwd, _ := os.Getwd()
|
||
|
||
return Model{
|
||
config: cfg,
|
||
scanResult: scan,
|
||
activeTab: tabDashboard,
|
||
chatLog: []string{
|
||
aiMsgStyle.Render(" Welcome to Studio! Chat with your AI assistant here."),
|
||
aiMsgStyle.Render(" Configure agents and workflows from the sidebar. Type /plan <goal> to start."),
|
||
},
|
||
orch: orch,
|
||
proxyMgr: proxyMgr,
|
||
chatInput: "",
|
||
chatLoading: false,
|
||
daemon: d,
|
||
lspServers: lspServers,
|
||
mcpConfigured: mcpConfigured,
|
||
skillList: skillList,
|
||
helpModel: help.New(),
|
||
progressBar: prog,
|
||
spinner: sp,
|
||
showingQuit: false,
|
||
confirmCursor: 1,
|
||
showingTabMenu: false,
|
||
tabMenuCursor: 0,
|
||
termCwd: cwd,
|
||
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 {
|
||
return tea.Batch(spinner.Tick, animTick(), tea.EnterAltScreen)
|
||
}
|
||
|
||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
return m.handleKey(msg)
|
||
case spinner.TickMsg:
|
||
var cmd tea.Cmd
|
||
m.spinner, cmd = m.spinner.Update(msg)
|
||
return m, cmd
|
||
case animTickMsg:
|
||
m.animationFrame++
|
||
return m, animTick()
|
||
case progress.FrameMsg:
|
||
pm, cmd := m.progressBar.Update(msg)
|
||
m.progressBar = pm.(progress.Model)
|
||
return m, cmd
|
||
case termOutputMsg:
|
||
m.termLog = append(m.termLog, msg.line)
|
||
if m.activeTab == tabShell {
|
||
m.viewport.SetContent(m.renderContent())
|
||
m.viewport.GotoBottom()
|
||
}
|
||
return m, nil
|
||
case termExitMsg:
|
||
m.termRunning = false
|
||
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render("(process exited)"))
|
||
m.termCmd = nil
|
||
if m.activeTab == tabShell {
|
||
m.viewport.SetContent(m.renderContent())
|
||
}
|
||
return m, nil
|
||
case aiResponseMsg:
|
||
m.chatLoading = false
|
||
m.termAILoading = false
|
||
content := msg.content
|
||
|
||
if m.activeTab == tabShell && m.termAIShow {
|
||
m.termAIChat = append(m.termAIChat, aiMsgStyle.Render(" "+content))
|
||
if m.activeTab == tabShell {
|
||
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()
|
||
}
|
||
return m, nil
|
||
case aiErrMsg:
|
||
m.chatLoading = false
|
||
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.GotoBottom()
|
||
return m, nil
|
||
case scanCompleteMsg:
|
||
m.scanResult = msg.result
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case installCompleteMsg:
|
||
m.installing = false
|
||
for _, r := range msg.results {
|
||
status := itemOKStyle.Render("✓")
|
||
if !r.Success {
|
||
status = itemMissingStyle.Render("✗")
|
||
}
|
||
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message))
|
||
}
|
||
m.scanResult = scanner.ScanSystem()
|
||
m.progressBar.SetPercent(1)
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case installProgressMsg:
|
||
status := itemOKStyle.Render("✓")
|
||
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool))
|
||
m.installCurrent = msg.current
|
||
m.installTool = ""
|
||
pct := float64(msg.current) / float64(max(msg.total, 1))
|
||
m.progressBar.SetPercent(pct)
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case installBatchMsg:
|
||
status := itemOKStyle.Render("✓")
|
||
if !msg.result.Success {
|
||
status = itemMissingStyle.Render("✗")
|
||
}
|
||
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message))
|
||
m.installCurrent = msg.index + 1
|
||
m.installTotal = len(msg.tools)
|
||
pct := float64(m.installCurrent) / float64(max(m.installTotal, 1))
|
||
m.progressBar.SetPercent(pct)
|
||
if msg.index+1 < len(msg.tools) {
|
||
m.installTool = msg.tools[msg.index+1]
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, startInstallCmd(msg.config, msg.tools, msg.index+1)
|
||
}
|
||
m.installing = false
|
||
m.scanResult = scanner.ScanSystem()
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case updateCheckMsg:
|
||
m.updateStatus = msg.statuses
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case previewReadyMsg:
|
||
m.previewURL = msg.url
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case lspScanMsg:
|
||
m.lspServers = msg.servers
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case mcpConfigMsg:
|
||
if msg.err == nil {
|
||
m.mcpConfigured = true
|
||
}
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case daemonLogMsg:
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
case tea.WindowSizeMsg:
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
m.helpModel.Width = msg.Width
|
||
headerH := 2
|
||
footerH := 2
|
||
inputH := 0
|
||
if m.activeTab == tabStudio || m.activeTab == tabShell {
|
||
inputH = 2
|
||
}
|
||
contentH := msg.Height - headerH - footerH - inputH
|
||
if contentH < 1 {
|
||
contentH = 1
|
||
}
|
||
m.viewport = viewport.New(msg.Width, contentH)
|
||
m.viewport.Width = msg.Width
|
||
m.viewport.Height = contentH
|
||
m.progressBar.Width = msg.Width - 20
|
||
m.ready = true
|
||
m.viewport.SetContent(m.renderContent())
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m Model) View() string {
|
||
if !m.ready {
|
||
return lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Loading muyue...")
|
||
}
|
||
|
||
if m.showingQuit {
|
||
return m.renderQuitOverlay()
|
||
}
|
||
|
||
if m.showingTabMenu {
|
||
return m.renderTabMenuOverlay()
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString(m.renderHeader())
|
||
b.WriteString("\n")
|
||
b.WriteString(m.viewport.View())
|
||
if m.activeTab == tabStudio {
|
||
b.WriteString("\n")
|
||
b.WriteString(m.renderStudioInput())
|
||
}
|
||
if m.activeTab == tabShell {
|
||
b.WriteString("\n")
|
||
b.WriteString(m.renderShellInput())
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(m.renderFooter())
|
||
|
||
return b.String()
|
||
}
|
||
|
||
func (m Model) renderHeader() string {
|
||
var tabs []string
|
||
for i, name := range tabNames {
|
||
icon := tabIcons[i]
|
||
if tab(i) == m.activeTab {
|
||
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))
|
||
}
|
||
}
|
||
|
||
tabLine := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...))
|
||
|
||
badge := lipgloss.NewStyle().
|
||
Foreground(roseColor).
|
||
Bold(true).
|
||
Render("muyue")
|
||
versionBadge := lipgloss.NewStyle().
|
||
Foreground(dimColor).
|
||
Render("v" + version.Version)
|
||
|
||
anim := lipgloss.NewStyle().Foreground(warmColor).Render(getAnimFrame(m.animationFrame))
|
||
|
||
logoLine := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render(
|
||
lipgloss.JoinHorizontal(lipgloss.Center, badge, " ", versionBadge, " ", anim),
|
||
)
|
||
|
||
return lipgloss.JoinVertical(lipgloss.Left, logoLine, tabLine)
|
||
}
|
||
|
||
func (m Model) renderContent() string {
|
||
switch m.activeTab {
|
||
case tabDashboard:
|
||
return m.renderDashboard()
|
||
case tabStudio:
|
||
return m.renderStudio()
|
||
case tabShell:
|
||
return m.renderShell()
|
||
case tabConfig:
|
||
return m.renderConfig()
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func (m *Model) resizeViewport() {
|
||
headerH := 2
|
||
footerH := 2
|
||
inputH := 0
|
||
if m.activeTab == tabStudio || m.activeTab == tabShell {
|
||
inputH = 2
|
||
}
|
||
contentH := m.height - headerH - footerH - inputH
|
||
if contentH < 1 {
|
||
contentH = 1
|
||
}
|
||
m.viewport = viewport.New(m.width, contentH)
|
||
m.viewport.Width = m.width
|
||
m.viewport.Height = contentH
|
||
m.viewport.SetContent(m.renderContent())
|
||
}
|
||
|
||
func (m Model) renderFooter() string {
|
||
profile := "unknown"
|
||
if m.config != nil && m.config.Profile.Pseudo != "" {
|
||
profile = m.config.Profile.Pseudo
|
||
}
|
||
|
||
left := fmt.Sprintf(" %s@%s",
|
||
lipgloss.NewStyle().Foreground(roseColor).Bold(true).Render(profile),
|
||
lipgloss.NewStyle().Foreground(dimColor).Render(version.Name))
|
||
leftR := statusBarStyle.Render(left)
|
||
|
||
var helpText string
|
||
switch m.activeTab {
|
||
case tabDashboard:
|
||
helpText = "[i] install [u] update [s] scan [ctrl+t] tabs"
|
||
case tabStudio:
|
||
helpText = "[enter] send [ctrl+s] sidebar [ctrl+t] tabs"
|
||
case tabShell:
|
||
helpText = "[enter] run [ctrl+a] AI panel [ctrl+c] kill"
|
||
case tabConfig:
|
||
helpText = "[↑↓] sections [ctrl+t] tabs"
|
||
default:
|
||
helpText = "[ctrl+t] tabs [ctrl+c] quit"
|
||
}
|
||
rightR := statusBarStyle.Render(helpText)
|
||
|
||
gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR)
|
||
if gap < 0 {
|
||
gap = 0
|
||
}
|
||
|
||
statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
|
||
leftR,
|
||
strings.Repeat(" ", gap),
|
||
rightR,
|
||
)
|
||
|
||
return lipgloss.JoinVertical(lipgloss.Left, statusLine,
|
||
lipgloss.NewStyle().Background(bgPanel).Foreground(dimColor).Render(
|
||
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys))))
|
||
}
|
||
|
||
func (m Model) renderQuitOverlay() string {
|
||
yesStyle := confirmNoStyle
|
||
noStyle := confirmYesStyle
|
||
if m.confirmCursor == 0 {
|
||
yesStyle = confirmYesStyle
|
||
noStyle = confirmNoStyle
|
||
}
|
||
|
||
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 ]"),
|
||
noStyle.Render("[ No ]"),
|
||
)
|
||
|
||
content := confirmBoxStyle.Render(box)
|
||
|
||
return lipgloss.Place(m.width, m.height,
|
||
0.5, 0.5,
|
||
content,
|
||
lipgloss.WithWhitespaceBackground(bgDark),
|
||
lipgloss.WithWhitespaceForeground(dimColor),
|
||
)
|
||
}
|
||
|
||
func (m Model) renderTabMenuOverlay() string {
|
||
menuStyle := lipgloss.NewStyle().
|
||
Border(lipgloss.RoundedBorder()).
|
||
BorderForeground(primaryColor).
|
||
Background(bgCard).
|
||
Padding(1, 3)
|
||
|
||
tabItemStyle := lipgloss.NewStyle().
|
||
Foreground(textDimColor).
|
||
Padding(0, 2)
|
||
|
||
tabItemActiveStyle := lipgloss.NewStyle().
|
||
Foreground(lipgloss.Color("#FFFFFF")).
|
||
Background(primaryColor).
|
||
Bold(true).
|
||
Padding(0, 2)
|
||
|
||
descs := []string{
|
||
"tools, updates & system status",
|
||
"chat, agents & workflows",
|
||
"terminal + AI assistant",
|
||
"profile, API keys & settings",
|
||
}
|
||
|
||
var items []string
|
||
for i, name := range tabNames {
|
||
num := lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %d.", i+1))
|
||
icon := tabIcons[i] + " "
|
||
if i == m.tabMenuCursor {
|
||
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(roseLightColor).Render(descs[i]))
|
||
items = append(items, tabItemActiveStyle.Render("▸"+item))
|
||
} else {
|
||
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i]))
|
||
items = append(items, tabItemStyle.Render(" "+item))
|
||
}
|
||
}
|
||
|
||
header := lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Switch Tab")
|
||
content := header + "\n\n" +
|
||
strings.Join(items, "\n") +
|
||
"\n\n" +
|
||
lipgloss.NewStyle().Foreground(dimColor).Render("↑↓ navigate · enter select · esc cancel")
|
||
|
||
box := menuStyle.Render(content)
|
||
|
||
return lipgloss.Place(m.width, m.height,
|
||
0.5, 0.5,
|
||
box,
|
||
lipgloss.WithWhitespaceBackground(bgDark),
|
||
lipgloss.WithWhitespaceForeground(dimColor),
|
||
)
|
||
}
|
||
|
||
func (m *Model) handlePreview(files []previewFile) {
|
||
dir := filepath.Join(os.TempDir(), "muyue-preview")
|
||
os.RemoveAll(dir)
|
||
os.MkdirAll(dir, 0755)
|
||
|
||
for _, f := range files {
|
||
preview.CreatePreviewFile(dir, f.Filename, f.Content)
|
||
}
|
||
|
||
if m.previewSrv != nil {
|
||
m.previewSrv.Stop()
|
||
}
|
||
m.previewSrv = preview.NewPreviewServer(dir)
|
||
if err := m.previewSrv.Start(8765); err != nil {
|
||
m.chatLog = append(m.chatLog, errMsgStyle.Render(" preview error: "+err.Error()))
|
||
} else {
|
||
m.previewURL = "http://127.0.0.1:8765"
|
||
m.chatLog = append(m.chatLog, itemOKStyle.Render(" Preview: 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("▎"),
|
||
)
|
||
}
|