All checks were successful
CI / build (push) Successful in 1m44s
- Remove Tab/Shift+Tab for tab switching (use only Ctrl+T) - Tab key now inserts tab character in chat/terminal input - Filter <think...> blocks from AI responses (MiniMax etc.) - Filter << >> reasoning blocks from AI responses 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
1708 lines
45 KiB
Go
1708 lines
45 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"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/installer"
|
|
"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/updater"
|
|
"github.com/muyue/muyue/internal/version"
|
|
"github.com/muyue/muyue/internal/workflow"
|
|
)
|
|
|
|
type tab int
|
|
|
|
const (
|
|
tabDashboard tab = iota
|
|
tabChat
|
|
tabWorkflow
|
|
tabTerminal
|
|
tabAgents
|
|
tabConfig
|
|
tabCount
|
|
)
|
|
|
|
var tabNames = []string{"Dashboard", "Chat", "Workflow", "Terminal", "Agents", "Config"}
|
|
|
|
var (
|
|
baseColor = lipgloss.Color("#FF6B9D")
|
|
accentColor = lipgloss.Color("#A0D2FF")
|
|
aiColor = lipgloss.Color("#C4B5FD")
|
|
successColor = lipgloss.Color("#4ADE80")
|
|
warningColor = lipgloss.Color("#FBBF24")
|
|
errorColor = lipgloss.Color("#FF6B6B")
|
|
mutedColor = lipgloss.Color("#666680")
|
|
dimColor = lipgloss.Color("#444460")
|
|
bgDark = lipgloss.Color("#1A1A2E")
|
|
bgPanel = lipgloss.Color("#16213E")
|
|
bgCard = lipgloss.Color("#1F2937")
|
|
|
|
sectionStyle = lipgloss.NewStyle().
|
|
Foreground(accentColor).
|
|
Bold(true)
|
|
|
|
itemOKStyle = lipgloss.NewStyle().
|
|
Foreground(successColor)
|
|
|
|
itemMissingStyle = lipgloss.NewStyle().
|
|
Foreground(errorColor)
|
|
|
|
itemWarnStyle = lipgloss.NewStyle().
|
|
Foreground(warningColor)
|
|
|
|
itemPendingStyle = lipgloss.NewStyle().
|
|
Foreground(mutedColor)
|
|
|
|
userMsgStyle = lipgloss.NewStyle().
|
|
Foreground(accentColor)
|
|
|
|
aiMsgStyle = lipgloss.NewStyle().
|
|
Foreground(aiColor)
|
|
|
|
errMsgStyle = lipgloss.NewStyle().
|
|
Foreground(errorColor)
|
|
|
|
inputStyle = lipgloss.NewStyle().
|
|
Foreground(baseColor)
|
|
|
|
stepDoneStyle = lipgloss.NewStyle().
|
|
Foreground(successColor)
|
|
|
|
stepPendingStyle = lipgloss.NewStyle().
|
|
Foreground(mutedColor)
|
|
|
|
stepCurrentStyle = lipgloss.NewStyle().
|
|
Foreground(baseColor).
|
|
Bold(true)
|
|
|
|
stepErrorStyle = lipgloss.NewStyle().
|
|
Foreground(errorColor)
|
|
|
|
statusBarStyle = lipgloss.NewStyle().
|
|
Background(bgDark).
|
|
Foreground(lipgloss.Color("#A0A0B0")).
|
|
Padding(0, 1)
|
|
|
|
confirmBoxStyle = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(baseColor).
|
|
Background(bgCard).
|
|
Foreground(lipgloss.Color("#FFFFFF")).
|
|
Padding(1, 3).
|
|
Bold(true)
|
|
|
|
confirmYesStyle = lipgloss.NewStyle().
|
|
Foreground(successColor).
|
|
Bold(true)
|
|
|
|
confirmNoStyle = lipgloss.NewStyle().
|
|
Foreground(mutedColor)
|
|
)
|
|
|
|
type aiResponseMsg struct{ content string }
|
|
type aiErrMsg struct{ err error }
|
|
type scanCompleteMsg struct{ result *scanner.ScanResult }
|
|
type installCompleteMsg struct{ results []installer.InstallResult }
|
|
type installProgressMsg struct {
|
|
tool string
|
|
current int
|
|
total int
|
|
}
|
|
type updateCheckMsg struct{ statuses []updater.UpdateStatus }
|
|
type previewReadyMsg struct{ url string }
|
|
type workflowPhaseMsg struct{ phase workflow.Phase }
|
|
|
|
type daemonLogMsg struct{ logs []string }
|
|
type lspScanMsg struct{ servers []lsp.LSPServer }
|
|
type mcpConfigMsg struct{ err error }
|
|
|
|
type skillsListMsg struct{ skills []skills.Skill }
|
|
|
|
type spinnerTickMsg struct{ time time.Time }
|
|
|
|
type termOutputMsg struct{ line string }
|
|
type termExitMsg struct{}
|
|
|
|
type Model struct {
|
|
config *config.MuyueConfig
|
|
scanResult *scanner.ScanResult
|
|
activeTab tab
|
|
width int
|
|
height int
|
|
viewport viewport.Model
|
|
ready bool
|
|
chatInput string
|
|
chatLog []string
|
|
chatLoading bool
|
|
orch *orchestrator.Orchestrator
|
|
proxyMgr *proxy.Manager
|
|
updateStatus []updater.UpdateStatus
|
|
installLog []string
|
|
previewURL string
|
|
previewSrv *preview.PreviewServer
|
|
daemon *daemon.Daemon
|
|
lspServers []lsp.LSPServer
|
|
mcpConfigured bool
|
|
skillList []skills.Skill
|
|
|
|
helpModel help.Model
|
|
progressBar progress.Model
|
|
spinner spinner.Model
|
|
|
|
showingQuit bool
|
|
confirmCursor int
|
|
showingTabMenu bool
|
|
tabMenuCursor int
|
|
|
|
ctrlCCount int
|
|
lastCtrlC time.Time
|
|
|
|
installing bool
|
|
installCurrent int
|
|
installTotal int
|
|
installTool string
|
|
|
|
termCmd *exec.Cmd
|
|
termInput string
|
|
termLog []string
|
|
termRunning bool
|
|
termCwd string
|
|
}
|
|
|
|
type keyMap struct {
|
|
Tab key.Binding
|
|
Prev key.Binding
|
|
Quit key.Binding
|
|
Confirm key.Binding
|
|
Cancel key.Binding
|
|
TabMenu key.Binding
|
|
Install key.Binding
|
|
Update key.Binding
|
|
Scan key.Binding
|
|
Enter key.Binding
|
|
Backspace key.Binding
|
|
}
|
|
|
|
var keys = keyMap{
|
|
Tab: key.NewBinding(
|
|
key.WithKeys("tab"),
|
|
key.WithHelp("tab", "indent"),
|
|
),
|
|
Prev: key.NewBinding(
|
|
key.WithKeys("shift+tab"),
|
|
key.WithHelp("shift+tab", "unindent"),
|
|
),
|
|
Quit: key.NewBinding(
|
|
key.WithKeys("ctrl+c"),
|
|
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(
|
|
key.WithKeys("ctrl+t"),
|
|
key.WithHelp("ctrl+t", "switch tab"),
|
|
),
|
|
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(
|
|
key.WithKeys("enter"),
|
|
key.WithHelp("enter", "send"),
|
|
),
|
|
Backspace: key.NewBinding(
|
|
key.WithKeys("backspace"),
|
|
key.WithHelp("backspace", "delete"),
|
|
),
|
|
}
|
|
|
|
func (k keyMap) ShortHelp() []key.Binding {
|
|
return []key.Binding{k.TabMenu, k.Tab, k.Quit}
|
|
}
|
|
|
|
func (k keyMap) FullHelp() [][]key.Binding {
|
|
return [][]key.Binding{
|
|
{k.TabMenu, k.Tab, k.Prev},
|
|
{k.Quit},
|
|
}
|
|
}
|
|
|
|
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 := workflow.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 {
|
|
inputH = 2
|
|
}
|
|
if 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) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.showingQuit {
|
|
return m.handleQuitConfirm(msg)
|
|
}
|
|
if m.showingTabMenu {
|
|
return m.handleTabMenu(msg)
|
|
}
|
|
|
|
if m.activeTab == tabTerminal {
|
|
return m.handleTerminalKey(msg)
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
now := time.Now()
|
|
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
|
|
return m, tea.Quit
|
|
}
|
|
m.ctrlCCount++
|
|
m.lastCtrlC = now
|
|
m.showingQuit = true
|
|
m.confirmCursor = 1
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "ctrl+t":
|
|
m.showingTabMenu = true
|
|
m.tabMenuCursor = int(m.activeTab)
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "enter":
|
|
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && m.chatInput != "" && !m.chatLoading {
|
|
return m.handleChatSubmit()
|
|
}
|
|
case "backspace":
|
|
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(m.chatInput) > 0 {
|
|
m.chatInput = m.chatInput[:len(m.chatInput)-1]
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
default:
|
|
if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && len(msg.String()) == 1 && !m.chatLoading {
|
|
m.chatInput += msg.String()
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
}
|
|
|
|
if m.activeTab == tabDashboard {
|
|
return m.handleDashboardKey(msg)
|
|
}
|
|
if m.activeTab == tabAgents {
|
|
return m.handleAgentsKey(msg)
|
|
}
|
|
if m.activeTab == tabWorkflow {
|
|
return m.handleWorkflowKey(msg)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleTerminalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
if m.termCmd != nil && m.termCmd.Process != nil {
|
|
m.termCmd.Process.Kill()
|
|
m.termRunning = false
|
|
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorColor).Render("^C"))
|
|
m.termCmd = nil
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
now := time.Now()
|
|
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
|
|
return m, tea.Quit
|
|
}
|
|
m.ctrlCCount++
|
|
m.lastCtrlC = now
|
|
m.showingQuit = true
|
|
m.confirmCursor = 1
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "ctrl+t":
|
|
m.showingTabMenu = true
|
|
m.tabMenuCursor = int(m.activeTab)
|
|
return m, nil
|
|
case "enter":
|
|
if m.termRunning {
|
|
return m, nil
|
|
}
|
|
input := strings.TrimSpace(m.termInput)
|
|
m.termInput = ""
|
|
if input == "" {
|
|
return m, nil
|
|
}
|
|
if input == "exit" || input == "quit" {
|
|
return m, nil
|
|
}
|
|
if input == "clear" {
|
|
m.termLog = nil
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
if strings.HasPrefix(input, "cd ") {
|
|
dir := strings.TrimPrefix(input, "cd ")
|
|
dir = strings.TrimSpace(dir)
|
|
if dir == "~" {
|
|
home, _ := os.UserHomeDir()
|
|
dir = home
|
|
}
|
|
if err := os.Chdir(dir); err == nil {
|
|
m.termCwd, _ = os.Getwd()
|
|
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input))
|
|
} else {
|
|
m.termLog = append(m.termLog, errMsgStyle.Render("cd: "+err.Error()))
|
|
}
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
return m, nil
|
|
}
|
|
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ ")+input)
|
|
m.viewport.SetContent(m.renderContent())
|
|
m.viewport.GotoBottom()
|
|
return m, m.runTermCommand(input)
|
|
case "backspace":
|
|
if len(m.termInput) > 0 {
|
|
m.termInput = m.termInput[:len(m.termInput)-1]
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
return m, nil
|
|
case "tab":
|
|
if m.activeTab == tabChat || m.activeTab == tabWorkflow {
|
|
m.chatInput += "\t"
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
default:
|
|
if len(msg.String()) == 1 {
|
|
m.termInput += msg.String()
|
|
m.viewport.SetContent(m.renderContent())
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) runTermCommand(input string) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
shell := os.Getenv("SHELL")
|
|
if shell == "" {
|
|
shell = "/bin/sh"
|
|
}
|
|
cmd := exec.Command(shell, "-c", input)
|
|
cmd.Dir = m.termCwd
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return termOutputMsg{line: string(out) + errMsgStyle.Render(err.Error())}
|
|
}
|
|
return termOutputMsg{line: string(out)}
|
|
})
|
|
}
|
|
|
|
func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "y", "Y", "o", "O":
|
|
m.showingQuit = false
|
|
return m, tea.Quit
|
|
case "n", "N", "esc":
|
|
m.showingQuit = false
|
|
m.ctrlCCount = 0
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "left", "h":
|
|
m.confirmCursor = 0
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "right", "l":
|
|
m.confirmCursor = 1
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "enter":
|
|
if m.confirmCursor == 0 {
|
|
m.showingQuit = false
|
|
return m, tea.Quit
|
|
}
|
|
m.showingQuit = false
|
|
m.ctrlCCount = 0
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "ctrl+c":
|
|
m.showingQuit = false
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
m.showingTabMenu = false
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
case "up", "k":
|
|
if m.tabMenuCursor > 0 {
|
|
m.tabMenuCursor--
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
if m.tabMenuCursor < int(tabCount)-1 {
|
|
m.tabMenuCursor++
|
|
}
|
|
return m, nil
|
|
case "enter":
|
|
m.activeTab = tab(m.tabMenuCursor)
|
|
m.showingTabMenu = false
|
|
m.resizeViewport()
|
|
return m, nil
|
|
default:
|
|
for i := 0; i < int(tabCount); i++ {
|
|
if msg.String() == fmt.Sprintf("%d", i+1) {
|
|
m.activeTab = tab(i)
|
|
m.showingTabMenu = false
|
|
m.resizeViewport()
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
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) 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)
|
|
}
|
|
|
|
func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "i":
|
|
if m.installing {
|
|
return m, nil
|
|
}
|
|
var missing []string
|
|
if m.scanResult != nil {
|
|
for _, t := range m.scanResult.Tools {
|
|
if !t.Installed {
|
|
missing = append(missing, t.Name)
|
|
}
|
|
}
|
|
}
|
|
if len(missing) == 0 {
|
|
m.installLog = append(m.installLog, itemOKStyle.Render("All tools already installed!"))
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
needsSudo := checkNeedsSudo(m.scanResult)
|
|
if needsSudo && !hasSudo() {
|
|
m.installLog = append(m.installLog, errMsgStyle.Render("Some tools require sudo. Run: sudo muyue install"))
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, nil
|
|
}
|
|
m.installing = true
|
|
m.installCurrent = 0
|
|
m.installTotal = len(missing)
|
|
m.installTool = missing[0]
|
|
m.progressBar.SetPercent(0)
|
|
m.viewport.SetContent(m.renderContent())
|
|
return m, startInstallCmd(m.config, missing, 0)
|
|
case "u":
|
|
return m, tea.Cmd(func() tea.Msg {
|
|
result := scanner.ScanSystem()
|
|
return updateCheckMsg{statuses: updater.CheckUpdates(result)}
|
|
})
|
|
case "s":
|
|
return m, tea.Cmd(func() tea.Msg {
|
|
return scanCompleteMsg{result: scanner.ScanSystem()}
|
|
})
|
|
case "l":
|
|
return m, tea.Cmd(func() tea.Msg {
|
|
servers := lsp.ScanServers()
|
|
return lspScanMsg{servers: servers}
|
|
})
|
|
case "m":
|
|
return m, tea.Cmd(func() tea.Msg {
|
|
err := mcp.ConfigureAll(m.config)
|
|
return mcpConfigMsg{err: err}
|
|
})
|
|
}
|
|
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("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) handlePreview(files []workflow.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"))
|
|
}
|
|
}
|
|
|
|
func (m *Model) resizeViewport() {
|
|
headerH := 1
|
|
footerH := 2
|
|
inputH := 0
|
|
if m.activeTab == tabChat || m.activeTab == tabWorkflow {
|
|
inputH = 2
|
|
}
|
|
if 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 checkNeedsSudo(scan *scanner.ScanResult) bool {
|
|
if scan == nil {
|
|
return false
|
|
}
|
|
sudoTools := map[string]bool{
|
|
"docker": true, "git": true, "gh": true, "node": true, "python": true,
|
|
}
|
|
for _, t := range scan.Tools {
|
|
if !t.Installed && sudoTools[t.Name] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasSudo() bool {
|
|
if os.Geteuid() == 0 {
|
|
return true
|
|
}
|
|
if _, err := exec.LookPath("sudo"); err == nil {
|
|
return true
|
|
}
|
|
if _, err := exec.LookPath("pkexec"); err == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func startInstallCmd(cfg *config.MuyueConfig, tools []string, index int) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
inst := installer.New(cfg)
|
|
result := inst.InstallTool(tools[index])
|
|
|
|
if index+1 < len(tools) {
|
|
return installBatchMsg{
|
|
result: result,
|
|
tools: tools,
|
|
index: index,
|
|
config: cfg,
|
|
}
|
|
}
|
|
return installCompleteMsg{results: []installer.InstallResult{result}}
|
|
})
|
|
}
|
|
|
|
type installBatchMsg struct {
|
|
result installer.InstallResult
|
|
tools []string
|
|
index int
|
|
config *config.MuyueConfig
|
|
}
|
|
|
|
func sendAIMessage(orch *orchestrator.Orchestrator, input string) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
if orch == nil {
|
|
return aiErrMsg{err: fmt.Errorf("orchestrator not configured")}
|
|
}
|
|
resp, err := orch.Send(input)
|
|
if err != nil {
|
|
return aiErrMsg{err: err}
|
|
}
|
|
return aiResponseMsg{content: resp}
|
|
})
|
|
}
|
|
|
|
func startWorkflowCmd(orch *orchestrator.Orchestrator, goal string) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
resp, err := orch.StartWorkflow(goal)
|
|
if err != nil {
|
|
return aiErrMsg{err: err}
|
|
}
|
|
return aiResponseMsg{content: resp}
|
|
})
|
|
}
|
|
|
|
func workflowChatCmd(orch *orchestrator.Orchestrator, input string) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
wf := orch.Workflow
|
|
switch wf.Phase {
|
|
case workflow.PhaseGathering:
|
|
resp, err := orch.AnswerQuestion(input)
|
|
if err != nil {
|
|
return aiErrMsg{err: err}
|
|
}
|
|
return aiResponseMsg{content: resp}
|
|
case workflow.PhaseReviewing:
|
|
approved, feedback := workflow.ParseApproval(input)
|
|
resp, err := orch.ReviewPlan(approved, feedback)
|
|
if err != nil {
|
|
return aiErrMsg{err: err}
|
|
}
|
|
return aiResponseMsg{content: resp}
|
|
default:
|
|
resp, err := orch.Send(input)
|
|
if err != nil {
|
|
return aiErrMsg{err: err}
|
|
}
|
|
return aiResponseMsg{content: resp}
|
|
}
|
|
})
|
|
}
|
|
|
|
func generatePlanCmd(orch *orchestrator.Orchestrator) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
resp, err := orch.GeneratePlan()
|
|
if err != nil {
|
|
return aiErrMsg{err: err}
|
|
}
|
|
return aiResponseMsg{content: resp}
|
|
})
|
|
}
|
|
|
|
func reviewPlanCmd(orch *orchestrator.Orchestrator, approved bool, feedback string) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
resp, err := orch.ReviewPlan(approved, feedback)
|
|
if err != nil {
|
|
return aiErrMsg{err: err}
|
|
}
|
|
return aiResponseMsg{content: resp}
|
|
})
|
|
}
|
|
|
|
func continueWorkflowCmd(orch *orchestrator.Orchestrator, output string) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
resp, err := orch.ContinueExecution(output)
|
|
if err != nil {
|
|
return aiErrMsg{err: err}
|
|
}
|
|
return aiResponseMsg{content: resp}
|
|
})
|
|
}
|
|
|
|
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) 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) 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) 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("")
|
|
}
|
|
|
|
func (m Model) renderDashboard() string {
|
|
colWidth := m.width / 2
|
|
if colWidth < 30 {
|
|
colWidth = 30
|
|
}
|
|
|
|
var left, right strings.Builder
|
|
|
|
left.WriteString(sectionStyle.Render("System"))
|
|
left.WriteString("\n")
|
|
if m.scanResult != nil {
|
|
sysInfo := m.scanResult.System.String()
|
|
left.WriteString(" ")
|
|
left.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(sysInfo))
|
|
}
|
|
left.WriteString("\n\n")
|
|
|
|
left.WriteString(sectionStyle.Render("Tools"))
|
|
left.WriteString("\n")
|
|
if m.scanResult != nil {
|
|
installed := 0
|
|
total := len(m.scanResult.Tools)
|
|
for _, t := range m.scanResult.Tools {
|
|
if t.Installed {
|
|
installed++
|
|
left.WriteString(" ")
|
|
left.WriteString(itemOKStyle.Render(" "))
|
|
left.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, extractVersion(t.Version)))
|
|
} else {
|
|
left.WriteString(" ")
|
|
left.WriteString(itemMissingStyle.Render(" "))
|
|
left.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, itemPendingStyle.Render("(not installed)")))
|
|
}
|
|
}
|
|
barWidth := 20
|
|
pct := 0
|
|
if total > 0 {
|
|
pct = (installed * barWidth) / total
|
|
}
|
|
bar := lipgloss.NewStyle().Foreground(successColor).Render(strings.Repeat("█", pct)) +
|
|
lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("░", barWidth-pct))
|
|
left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total))
|
|
}
|
|
left.WriteString("\n")
|
|
|
|
if m.installing {
|
|
left.WriteString(sectionStyle.Render("Installing..."))
|
|
left.WriteString("\n")
|
|
progBar := m.progressBar.View()
|
|
label := ""
|
|
if m.installTool != "" {
|
|
label = fmt.Sprintf(" %d/%d - %s", m.installCurrent+1, m.installTotal, m.installTool)
|
|
} else {
|
|
label = fmt.Sprintf(" %d/%d", m.installCurrent, m.installTotal)
|
|
}
|
|
left.WriteString(fmt.Sprintf(" %s%s\n", progBar, label))
|
|
left.WriteString("\n")
|
|
}
|
|
|
|
if len(m.installLog) > 0 {
|
|
left.WriteString(sectionStyle.Render("Install Log"))
|
|
left.WriteString("\n")
|
|
for _, l := range m.installLog {
|
|
left.WriteString(l + "\n")
|
|
}
|
|
left.WriteString("\n")
|
|
}
|
|
|
|
right.WriteString(sectionStyle.Render("Quick Actions"))
|
|
right.WriteString("\n")
|
|
actions := []struct {
|
|
key string
|
|
desc string
|
|
}{
|
|
{"i", "Install missing tools"},
|
|
{"u", "Check for updates"},
|
|
{"s", "Rescan system"},
|
|
{"l", "Scan LSP servers"},
|
|
{"m", "Configure MCP servers"},
|
|
}
|
|
for _, a := range actions {
|
|
right.WriteString(fmt.Sprintf(" %s %s\n",
|
|
lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("["+a.key+"]"),
|
|
a.desc))
|
|
}
|
|
right.WriteString("\n")
|
|
|
|
if len(m.updateStatus) > 0 {
|
|
right.WriteString(sectionStyle.Render("Updates"))
|
|
right.WriteString("\n")
|
|
for _, s := range m.updateStatus {
|
|
if s.NeedsUpdate {
|
|
right.WriteString(" ")
|
|
right.WriteString(itemWarnStyle.Render(" "))
|
|
right.WriteString(fmt.Sprintf(" %s: %s -> %s\n", s.Tool, s.Current, s.Latest))
|
|
} else if s.Error == "" {
|
|
right.WriteString(" ")
|
|
right.WriteString(itemOKStyle.Render(" "))
|
|
right.WriteString(fmt.Sprintf(" %s: up to date\n", s.Tool))
|
|
}
|
|
}
|
|
right.WriteString("\n")
|
|
}
|
|
|
|
if len(m.lspServers) > 0 {
|
|
right.WriteString(sectionStyle.Render("LSP Servers"))
|
|
right.WriteString("\n")
|
|
lspInstalled := 0
|
|
for _, s := range m.lspServers {
|
|
if s.Installed {
|
|
lspInstalled++
|
|
right.WriteString(" ")
|
|
right.WriteString(itemOKStyle.Render(" "))
|
|
right.WriteString(fmt.Sprintf(" %-22s (%s)\n", s.Name, s.Language))
|
|
} else {
|
|
right.WriteString(" ")
|
|
right.WriteString(itemPendingStyle.Render(" "))
|
|
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("\n")
|
|
}
|
|
|
|
if m.daemon != nil {
|
|
right.WriteString(sectionStyle.Render("Daemon"))
|
|
right.WriteString("\n")
|
|
if m.daemon.IsRunning() {
|
|
right.WriteString(" ")
|
|
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")
|
|
}
|
|
|
|
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())
|
|
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
|
|
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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) renderConfig() string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString(sectionStyle.Render("Profile"))
|
|
b.WriteString("\n")
|
|
if m.config != nil {
|
|
fields := []struct {
|
|
label string
|
|
value string
|
|
}{
|
|
{"Name", m.config.Profile.Name},
|
|
{"Pseudo", m.config.Profile.Pseudo},
|
|
{"Email", m.config.Profile.Email},
|
|
{"Editor", m.config.Profile.Preferences.Editor},
|
|
{"Shell", m.config.Profile.Preferences.Shell},
|
|
{"Theme", m.config.Profile.Preferences.Theme},
|
|
{"Default AI", m.config.Profile.Preferences.DefaultAI},
|
|
}
|
|
for _, f := range fields {
|
|
labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14)
|
|
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0"))
|
|
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render(f.label+":"), valueStyle.Render(f.value)))
|
|
}
|
|
if len(m.config.Profile.Languages) > 0 {
|
|
labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14)
|
|
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0"))
|
|
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Languages:"), valueStyle.Render(strings.Join(m.config.Profile.Languages, ", "))))
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
b.WriteString(sectionStyle.Render("AI Providers"))
|
|
b.WriteString("\n")
|
|
if m.config != nil {
|
|
for _, p := range m.config.AI.Providers {
|
|
active := ""
|
|
if p.Active {
|
|
active = itemOKStyle.Render(" active")
|
|
}
|
|
keyStatus := itemMissingStyle.Render("no key")
|
|
if p.APIKey != "" {
|
|
keyStatus = itemOKStyle.Render("configured")
|
|
}
|
|
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Bold(true)
|
|
b.WriteString(fmt.Sprintf(" %s model=%s key=%s%s\n",
|
|
nameStyle.Render(p.Name), p.Model, keyStatus, active))
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
b.WriteString(sectionStyle.Render("BMAD Method"))
|
|
b.WriteString("\n")
|
|
if m.config != nil {
|
|
installed := itemMissingStyle.Render("no")
|
|
if m.config.BMAD.Installed {
|
|
installed = itemOKStyle.Render("yes")
|
|
}
|
|
b.WriteString(fmt.Sprintf(" Installed: %s\n", installed))
|
|
b.WriteString(fmt.Sprintf(" Global: %v\n", m.config.BMAD.Global))
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
b.WriteString(sectionStyle.Render("Terminal"))
|
|
b.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 {
|
|
for _, s := range m.skillList {
|
|
target := s.Target
|
|
if target == "" {
|
|
target = "both"
|
|
}
|
|
b.WriteString(fmt.Sprintf(" %-20s %s %s\n",
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Name),
|
|
lipgloss.NewStyle().Foreground(aiColor).Render("["+target+"]"),
|
|
s.Description))
|
|
}
|
|
} else {
|
|
b.WriteString(" No skills. Run `muyue skills init` to install built-ins.\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
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 extractVersion(s string) string {
|
|
return versionRegex.FindString(s)
|
|
}
|
|
|
|
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
|