All checks were successful
CI / build (push) Successful in 2m41s
Split monolithic app.go into focused modules (dashboard, chat, workflow, config, agents, terminal, commands, handlers). Add proper error handling for installer commands, proxy pipes, and MCP config parsing. Fix daemon channel buffer, cap orchestrator history, compile think regex once, and set HTTP timeouts on preview server. Improve CI with Go module caching, dependency download step, and test stage with race detection. 😘 Generated with Crush Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
447 lines
12 KiB
Go
447 lines
12 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(baseColor)
|
|
|
|
prog := progress.New(progress.WithGradient("#FF6B9D", "#A0D2FF"))
|
|
|
|
cwd, _ := os.Getwd()
|
|
|
|
return Model{
|
|
config: cfg,
|
|
scanResult: scan,
|
|
activeTab: tabDashboard,
|
|
chatLog: []string{
|
|
aiMsgStyle.Render("muyue: Welcome! I'm your AI development environment assistant."),
|
|
aiMsgStyle.Render("muyue: Type /plan <goal> to start a structured workflow, or just chat."),
|
|
},
|
|
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,
|
|
}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(spinner.Tick, 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 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 == tabTerminal {
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
}
|
|
return m, nil
|
|
case termExitMsg:
|
|
m.termRunning = false
|
|
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(mutedColor).Render("(process exited)"))
|
|
m.termCmd = nil
|
|
if m.activeTab == tabTerminal {
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
return m, nil
|
|
case aiResponseMsg:
|
|
m.chatLoading = false
|
|
content := msg.content
|
|
m.chatLog = append(m.chatLog, aiMsgStyle.Render("muyue: "+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.chatLog = append(m.chatLog, errMsgStyle.Render("error: "+msg.err.Error()))
|
|
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("[OK]")
|
|
if !r.Success {
|
|
status = itemMissingStyle.Render("[FAIL]")
|
|
}
|
|
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("[OK]")
|
|
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("[OK]")
|
|
if !msg.result.Success {
|
|
status = itemMissingStyle.Render("[FAIL]")
|
|
}
|
|
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 := 1
|
|
footerH := 2
|
|
inputH := 0
|
|
if m.activeTab == tabChat || m.activeTab == tabWorkflow || m.activeTab == tabTerminal {
|
|
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 "Loading..."
|
|
}
|
|
|
|
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 == tabChat || m.activeTab == tabWorkflow {
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderChatInput())
|
|
}
|
|
if m.activeTab == tabTerminal {
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderTermInput())
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(m.renderFooter())
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m Model) renderHeader() string {
|
|
logoStyle := lipgloss.NewStyle().Foreground(baseColor).Bold(true)
|
|
badgeStyle := lipgloss.NewStyle().
|
|
Background(baseColor).
|
|
Foreground(lipgloss.Color("#FFFFFF")).
|
|
Padding(0, 1)
|
|
|
|
logo := logoStyle.Render("muyue")
|
|
badge := badgeStyle.Render("v" + version.Version)
|
|
|
|
activeTabName := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render(tabNames[m.activeTab])
|
|
separator := lipgloss.NewStyle().Foreground(dimColor).Render(" · ")
|
|
|
|
rightPart := separator + activeTabName
|
|
|
|
line := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render(
|
|
lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", badge, rightPart),
|
|
)
|
|
|
|
return line
|
|
}
|
|
|
|
func (m Model) renderContent() string {
|
|
switch m.activeTab {
|
|
case tabDashboard:
|
|
return m.renderDashboard()
|
|
case tabChat:
|
|
return m.renderChat()
|
|
case tabWorkflow:
|
|
return m.renderWorkflow()
|
|
case tabTerminal:
|
|
return m.renderTerminal()
|
|
case tabAgents:
|
|
return m.renderAgents()
|
|
case tabConfig:
|
|
return m.renderConfig()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (m *Model) resizeViewport() {
|
|
headerH := 1
|
|
footerH := 2
|
|
inputH := 0
|
|
if m.activeTab == tabChat || m.activeTab == tabWorkflow || m.activeTab == tabTerminal {
|
|
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", profile, version.Name)
|
|
leftR := statusBarStyle.Render(left)
|
|
|
|
var helpText string
|
|
switch m.activeTab {
|
|
case tabDashboard:
|
|
helpText = "[i] install [u] update [s] scan"
|
|
case tabChat, tabWorkflow:
|
|
helpText = "[ctrl+t] switch tab [ctrl+c] quit"
|
|
case tabTerminal:
|
|
helpText = "[enter] run [ctrl+c] kill [clear] clear"
|
|
case tabAgents:
|
|
helpText = "[c] crush [l] claude"
|
|
default:
|
|
helpText = "[ctrl+t] switch tab [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().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
|
|
}
|
|
|
|
box := fmt.Sprintf("\n\n Quit muyue?\n\n %s %s",
|
|
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 {
|
|
tabMenuStyle := lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(baseColor).
|
|
Background(bgCard).
|
|
Padding(1, 3)
|
|
|
|
tabItemStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#A0A0B0")).
|
|
Padding(0, 2)
|
|
|
|
tabItemActiveStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FFFFFF")).
|
|
Background(baseColor).
|
|
Bold(true).
|
|
Padding(0, 2)
|
|
|
|
tabNumStyle := lipgloss.NewStyle().
|
|
Foreground(dimColor).
|
|
Width(4)
|
|
|
|
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 {
|
|
num := tabNumStyle.Render(fmt.Sprintf(" %d.", i+1))
|
|
if i == m.tabMenuCursor {
|
|
item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(mutedColor).Render(descs[i]))
|
|
items = append(items, tabItemActiveStyle.Render(">"+item))
|
|
} else {
|
|
item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i]))
|
|
items = append(items, tabItemStyle.Render(" "+item))
|
|
}
|
|
}
|
|
|
|
content := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("Switch Tab") +
|
|
"\n\n" +
|
|
strings.Join(items, "\n") +
|
|
"\n\n" +
|
|
lipgloss.NewStyle().Foreground(dimColor).Render("up/down navigate enter/select esc cancel")
|
|
|
|
box := tabMenuStyle.Render(content)
|
|
|
|
return lipgloss.Place(m.width, m.height,
|
|
0.5, 0.5,
|
|
box,
|
|
lipgloss.WithWhitespaceBackground(bgPanel),
|
|
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 opened in browser: http://127.0.0.1:8765"))
|
|
}
|
|
}
|