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 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 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+`)