Some checks failed
Stable Release / stable (push) Failing after 22s
- 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
361 lines
8.4 KiB
Go
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)
|
|
}
|