package tui import ( "fmt" "os" "os/exec" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/proxy" "github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/updater" "github.com/muyue/muyue/internal/workflow" ) 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 == tabShell { return m.handleShellKey(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 "ctrl+s": if m.activeTab == tabStudio { m.studioSidebarOpen = !m.studioSidebarOpen m.viewport.SetContent(m.renderContent()) } case "enter": if m.activeTab == tabStudio && m.chatInput != "" && !m.chatLoading { return m.handleChatSubmit() } case "backspace": if m.activeTab == tabStudio && len(m.chatInput) > 0 { m.chatInput = m.chatInput[:len(m.chatInput)-1] m.viewport.SetContent(m.renderContent()) } default: if m.activeTab == tabStudio && 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 == tabStudio { return m.handleStudioKey(msg) } return m, nil } func cleanup(m Model) { if m.daemon != nil { m.daemon.Stop() } if m.previewSrv != nil { m.previewSrv.Stop() } for _, agentType := range []proxy.AgentType{proxy.AgentCrush, proxy.AgentClaude} { m.proxyMgr.Stop(agentType) } } func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "y", "Y", "o", "O": m.showingQuit = false cleanup(m) 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 cleanup(m) return m, tea.Quit } m.showingQuit = false m.ctrlCCount = 0 m.viewport.SetContent(m.renderContent()) return m, nil case "ctrl+c": m.showingQuit = false cleanup(m) 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) 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) handleStudioKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !m.studioSidebarOpen { return m, nil } switch msg.String() { case "1": m.studioPanel = panelChat m.viewport.SetContent(m.renderContent()) case "2": m.studioPanel = panelAgents m.viewport.SetContent(m.renderContent()) case "3": m.studioPanel = panelWorkflows m.viewport.SetContent(m.renderContent()) } if m.studioPanel == panelAgents { return m.handleAgentsKey(msg) } if m.studioPanel == panelWorkflows { return m.handleWorkflowKey(msg) } 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("⟩ [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("⟩ [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 checkNeedsSudo(scan *scanner.ScanResult) bool { if scan == nil { return false } sudoTools := map[string]bool{ "docker": true, "git": true, "gh": true, "node": true, "python3": 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 (m Model) handleChatSubmit() (tea.Model, tea.Cmd) { input := m.chatInput m.chatLog = append(m.chatLog, userMsgStyle.Render("⟩ "+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) }