package tui import ( "fmt" "os" "os/exec" "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" ) 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 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 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 }