diff --git a/cmd/muyue/main.go b/cmd/muyue/main.go index 61b69bb..e30b513 100644 --- a/cmd/muyue/main.go +++ b/cmd/muyue/main.go @@ -83,7 +83,7 @@ Commands: help Show this help TUI Controls: - Ctrl+M Open tab switcher (navigate with arrows, select with enter) + Ctrl+T Open tab switcher (navigate with arrows, select with enter) Tab / Shift+Tab Cycle tabs Ctrl+C Show quit confirmation (press twice quickly to force quit) diff --git a/internal/tui/app.go b/internal/tui/app.go index b2b4b20..e880acc 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -37,42 +37,26 @@ const ( tabDashboard tab = iota tabChat tabWorkflow + tabTerminal tabAgents tabConfig tabCount ) -var tabNames = []string{"Dashboard", "Chat", "Workflow", "Agents", "Config"} +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") - - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#FF6B9D")). - Background(bgDark). - Padding(0, 2) - - tabStyle = lipgloss.NewStyle(). - Padding(0, 2). - Foreground(mutedColor) - - activeTabStyle = lipgloss.NewStyle(). - Padding(0, 2). - Foreground(baseColor). - Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(baseColor). - Bold(true) + 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). @@ -102,10 +86,6 @@ var ( inputStyle = lipgloss.NewStyle(). Foreground(baseColor) - phaseStyle = lipgloss.NewStyle(). - Foreground(warningColor). - Bold(true) - stepDoneStyle = lipgloss.NewStyle(). Foreground(successColor) @@ -119,24 +99,18 @@ var ( stepErrorStyle = lipgloss.NewStyle(). Foreground(errorColor) - cardStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(dimColor). - Padding(0, 1). - BorderBackground(bgPanel) - 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) + Border(lipgloss.RoundedBorder()). + BorderForeground(baseColor). + Background(bgCard). + Foreground(lipgloss.Color("#FFFFFF")). + Padding(1, 3). + Bold(true) confirmYesStyle = lipgloss.NewStyle(). Foreground(successColor). @@ -144,19 +118,6 @@ var ( confirmNoStyle = lipgloss.NewStyle(). Foreground(mutedColor) - - gradientStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B9D")) - - logoStyle = lipgloss.NewStyle(). - Foreground(baseColor). - Bold(true) - - versionBadgeStyle = lipgloss.NewStyle(). - Background(baseColor). - Foreground(lipgloss.Color("#FFFFFF")). - Padding(0, 1). - Bold(true) ) type aiResponseMsg struct{ content string } @@ -180,6 +141,9 @@ 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 @@ -202,22 +166,28 @@ type Model struct { mcpConfigured bool skillList []skills.Skill - helpModel help.Model - progressBar progress.Model - spinner spinner.Model + helpModel help.Model + progressBar progress.Model + spinner spinner.Model - showingQuit bool - confirmCursor int + showingQuit bool + confirmCursor int showingTabMenu bool tabMenuCursor int - ctrlCCount int - lastCtrlC time.Time + ctrlCCount int + lastCtrlC time.Time - installing bool - installCurrent int - installTotal int - installTool string + installing bool + installCurrent int + installTotal int + installTool string + + termCmd *exec.Cmd + termInput string + termLog []string + termRunning bool + termCwd string } type keyMap struct { @@ -241,7 +211,7 @@ var keys = keyMap{ ), Prev: key.NewBinding( key.WithKeys("shift+tab"), - key.WithHelp("⇧+tab", "prev tab"), + key.WithHelp("shift+tab", "prev tab"), ), Quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -256,8 +226,8 @@ var keys = keyMap{ key.WithHelp("n/esc", "no"), ), TabMenu: key.NewBinding( - key.WithKeys("ctrl+m"), - key.WithHelp("ctrl+m", "switch tab"), + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "switch tab"), ), Install: key.NewBinding( key.WithKeys("i"), @@ -277,7 +247,7 @@ var keys = keyMap{ ), Backspace: key.NewBinding( key.WithKeys("backspace"), - key.WithHelp("⌫", "delete"), + key.WithHelp("backspace", "delete"), ), } @@ -316,6 +286,8 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model { prog := progress.New(progress.WithGradient("#FF6B9D", "#A0D2FF")) + cwd, _ := os.Getwd() + return Model{ config: cfg, scanResult: scan, @@ -339,6 +311,7 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model { confirmCursor: 1, showingTabMenu: false, tabMenuCursor: 0, + termCwd: cwd, } } @@ -358,6 +331,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -449,12 +437,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.helpModel.Width = msg.Width - headerH := 4 + 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 @@ -478,6 +469,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleTabMenu(msg) } + if m.activeTab == tabTerminal { + return m.handleTerminalKey(msg) + } + switch msg.String() { case "ctrl+c": now := time.Now() @@ -490,7 +485,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.confirmCursor = 1 m.viewport.SetContent(m.renderContent()) return m, nil - case "ctrl+m": + case "ctrl+t": m.showingTabMenu = true m.tabMenuCursor = int(m.activeTab) m.viewport.SetContent(m.renderContent()) @@ -532,6 +527,108 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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": + m.activeTab = (m.activeTab + 1) % tabCount + m.resizeViewport() + return m, nil + case "shift+tab": + m.activeTab = (m.activeTab - 1 + tabCount) % tabCount + m.resizeViewport() + return m, nil + 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": @@ -587,31 +684,15 @@ func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showingTabMenu = false m.resizeViewport() return m, nil - case "1": - m.activeTab = tabDashboard - m.showingTabMenu = false - m.resizeViewport() - return m, nil - case "2": - m.activeTab = tabChat - m.showingTabMenu = false - m.resizeViewport() - return m, nil - case "3": - m.activeTab = tabWorkflow - m.showingTabMenu = false - m.resizeViewport() - return m, nil - case "4": - m.activeTab = tabAgents - m.showingTabMenu = false - m.resizeViewport() - return m, nil - case "5": - m.activeTab = tabConfig - 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 } @@ -638,10 +719,9 @@ func (m Model) renderTabMenuOverlay() string { Width(4) var items []string - icons := []string{"Dashboard", "Chat", "Workflow", "Agents", "Config"} - descs := []string{"system overview & tools", "AI chat & conversation", "plan & execute workflows", "background AI agents", "profile & settings"} + descs := []string{"system overview & tools", "AI chat & conversation", "plan & execute workflows", "integrated shell", "background AI agents", "profile & settings"} - for i, name := range icons { + 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])) @@ -656,7 +736,7 @@ func (m Model) renderTabMenuOverlay() string { "\n\n" + strings.Join(items, "\n") + "\n\n" + - lipgloss.NewStyle().Foreground(dimColor).Render("↑↓ navigate enter/select esc cancel") + lipgloss.NewStyle().Foreground(dimColor).Render("up/down navigate enter/select esc cancel") box := tabMenuStyle.Render(content) @@ -826,12 +906,15 @@ func (m *Model) handlePreview(files []workflow.PreviewFile) { } func (m *Model) resizeViewport() { - headerH := 4 + 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 @@ -983,6 +1066,10 @@ func (m Model) View() string { return m.renderQuitOverlay() } + if m.showingTabMenu { + return m.renderTabMenuOverlay() + } + var b strings.Builder b.WriteString(m.renderHeader()) b.WriteString("\n") @@ -991,14 +1078,13 @@ func (m Model) View() string { 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()) - if m.showingTabMenu { - b = strings.Builder{} - b.WriteString(m.renderTabMenuOverlay()) - } - return b.String() } @@ -1026,23 +1112,25 @@ func (m Model) renderQuitOverlay() string { } 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 := versionBadgeStyle.Render(" v" + version.Version + " ") - separator := lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-lipgloss.Width(logo)-lipgloss.Width(badge)-4, 0))) + badge := badgeStyle.Render("v" + version.Version) - topLine := lipgloss.JoinHorizontal(lipgloss.Center, " ", logo, " ", badge, " ", separator) + activeTabName := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render(tabNames[m.activeTab]) + separator := lipgloss.NewStyle().Foreground(dimColor).Render(" · ") - tabs := make([]string, len(tabNames)) - for i, name := range tabNames { - if tab(i) == m.activeTab { - tabs[i] = activeTabStyle.Render(name) - } else { - tabs[i] = tabStyle.Render(name) - } - } - tabsRow := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...) + rightPart := separator + activeTabName - return lipgloss.JoinVertical(lipgloss.Left, topLine, tabsRow) + line := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render( + lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", badge, rightPart), + ) + + return line } func (m Model) renderContent() string { @@ -1053,6 +1141,8 @@ func (m Model) renderContent() string { return m.renderChat() case tabWorkflow: return m.renderWorkflow() + case tabTerminal: + return m.renderTerminal() case tabAgents: return m.renderAgents() case tabConfig: @@ -1062,6 +1152,26 @@ func (m Model) renderContent() string { } } +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 { var b strings.Builder @@ -1574,13 +1684,15 @@ func (m Model) renderFooter() string { var helpText string switch m.activeTab { case tabDashboard: - helpText = "[i] install [u] update [s] scan [l] lsp [m] mcp" + helpText = "[i] install [u] update [s] scan" case tabChat, tabWorkflow: - helpText = "[ctrl+m] switch tab [tab] next [ctrl+c] quit" + helpText = "[ctrl+t] tabs [tab] next [ctrl+c] quit" + case tabTerminal: + helpText = "[enter] run [ctrl+c] kill [clear] clear" case tabAgents: helpText = "[c] crush [l] claude" default: - helpText = "[ctrl+m] switch tab [tab] next [ctrl+c] quit" + helpText = "[ctrl+t] tabs [tab] next [ctrl+c] quit" } rightR := statusBarStyle.Render(helpText)