Files
MuyueWorkspace/internal/tui/handlers.go
Augustin ROUX cb8e3d0d26
Some checks failed
Stable Release / stable (push) Failing after 22s
feat: complete TUI redesign with cyberpunk theme (#1)
- Dark theme with red accents (cyberpunk aesthetic)
- Epuré cyberpunk style: clean dark backgrounds, sharp red highlights
- Full cyberpunk animations: glitch effect, scan line, typewriter
- Mixed Unicode + ASCII icons
- Rounded borders (╭ ╮ ╯ ╰) on cards and panels
- ASCII art block titles (■) with red styling
- Header: MUYUE branding, status indicators, live clock
- Footer: shortcuts, version, update indicator
- Tab transitions: glitch → scan → typewriter sequence
- Extracted header.go, footer.go, animations.go as new files

Controls unchanged: ctrl+t tabs, ctrl+s sidebar, ctrl+a AI panel

file changes:
- styles.go: new color palette (cyberRed, bgVoid, dimRed), block titles
- types.go: added transition state, clock tick, glitch/scan/done messages
- animations.go: new file with glitch, scan, typewriter, hex stream effects
- header.go: new file with logo, tabs, status dots, live clock
- footer.go: new file with shortcuts, version, update indicator
- model.go: integrated transition state machine, clock updates
- dashboard.go, studio.go, terminal.go, config_tab.go: updated icons/styles

Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>

Co-authored-by: Augustin <muyue@legion-muyue.fr>
Reviewed-on: #1
2026-04-20 20:17:06 +00:00

361 lines
8.4 KiB
Go

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.switchTab(tab(m.tabMenuCursor))
m.showingTabMenu = false
return m, nil
default:
for i := 0; i < int(tabCount); i++ {
if msg.String() == fmt.Sprintf("%d", i+1) {
m.switchTab(tab(i))
m.showingTabMenu = false
return m, nil
}
}
}
return m, nil
}
func (m *Model) switchTab(t tab) {
if t == m.activeTab {
return
}
m.prevTab = m.activeTab
m.activeTab = t
m.transition = transitionGlitch
m.transitionTick = 0
m.resizeViewport()
}
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("[OK] 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)
}