Files
MuyueWorkspace/internal/tui/model.go
Augustin 035e923e6c refactor: redesign TUI with 4 tabs, red/rose theme, split layouts
- Dashboard: tools, agents status, updates, quick actions
- Studio: central chat + agents/workflows sidebar (Ctrl+S toggle)
- Shell: terminal + AI assistant panel side-by-side (Ctrl+A toggle)
- Config: profile, API keys, terminal/starship settings in 2 columns
- New red/rose color scheme (#E8364F → #FF6B8A → #FFB3C6)
- Animated header with visual tab bar and pulse loading
- Remove old chat.go, agents.go, workflow_tab.go (merged into studio.go)
- All tests pass, build clean

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 21:03:49 +02:00

517 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("▎"),
)
}