From bb3b3038d3deef27140391aeb33d4b49d26715d7 Mon Sep 17 00:00:00 2001 From: Augustin Date: Mon, 20 Apr 2026 00:14:16 +0200 Subject: [PATCH] feat: Ctrl+M tab switcher overlay menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Alt+1-5 with Ctrl+M that opens a centered overlay showing all tabs with descriptions. Navigate with arrows/j/k, select with enter or 1-5, cancel with esc. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- cmd/muyue/main.go | 2 +- internal/tui/app.go | 175 ++++++++++++++++++++++++++++++++------------ 2 files changed, 128 insertions(+), 49 deletions(-) diff --git a/cmd/muyue/main.go b/cmd/muyue/main.go index c4655e6..61b69bb 100644 --- a/cmd/muyue/main.go +++ b/cmd/muyue/main.go @@ -83,7 +83,7 @@ Commands: help Show this help TUI Controls: - Alt+1-5 Switch tabs (Dashboard/Chat/Workflow/Agents/Config) + Ctrl+M 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 38d29f0..b2b4b20 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -208,6 +208,8 @@ type Model struct { showingQuit bool confirmCursor int + showingTabMenu bool + tabMenuCursor int ctrlCCount int lastCtrlC time.Time @@ -224,11 +226,7 @@ type keyMap struct { Quit key.Binding Confirm key.Binding Cancel key.Binding - Dashboard key.Binding - Chat key.Binding - Workflow key.Binding - Agents key.Binding - Config key.Binding + TabMenu key.Binding Install key.Binding Update key.Binding Scan key.Binding @@ -257,25 +255,9 @@ var keys = keyMap{ key.WithKeys("n", "esc"), key.WithHelp("n/esc", "no"), ), - Dashboard: key.NewBinding( - key.WithKeys("alt+1"), - key.WithHelp("alt+1", "dashboard"), - ), - Chat: key.NewBinding( - key.WithKeys("alt+2"), - key.WithHelp("alt+2", "chat"), - ), - Workflow: key.NewBinding( - key.WithKeys("alt+3"), - key.WithHelp("alt+3", "workflow"), - ), - Agents: key.NewBinding( - key.WithKeys("alt+4"), - key.WithHelp("alt+4", "agents"), - ), - Config: key.NewBinding( - key.WithKeys("alt+5"), - key.WithHelp("alt+5", "config"), + TabMenu: key.NewBinding( + key.WithKeys("ctrl+m"), + key.WithHelp("ctrl+m", "switch tab"), ), Install: key.NewBinding( key.WithKeys("i"), @@ -300,13 +282,13 @@ var keys = keyMap{ } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Dashboard, k.Chat, k.Workflow, k.Agents, k.Config, k.Quit} + return []key.Binding{k.TabMenu, k.Tab, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Dashboard, k.Chat, k.Workflow, k.Agents, k.Config}, - {k.Tab, k.Prev, k.Quit}, + {k.TabMenu, k.Tab, k.Prev}, + {k.Quit}, } } @@ -355,6 +337,8 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model { spinner: sp, showingQuit: false, confirmCursor: 1, + showingTabMenu: false, + tabMenuCursor: 0, } } @@ -490,6 +474,9 @@ 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) + } switch msg.String() { case "ctrl+c": @@ -503,25 +490,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.confirmCursor = 1 m.viewport.SetContent(m.renderContent()) return m, nil - case "alt+1": - m.activeTab = tabDashboard - m.resizeViewport() - return m, nil - case "alt+2": - m.activeTab = tabChat - m.resizeViewport() - return m, nil - case "alt+3": - m.activeTab = tabWorkflow - m.resizeViewport() - return m, nil - case "alt+4": - m.activeTab = tabAgents - m.resizeViewport() - return m, nil - case "alt+5": - m.activeTab = tabConfig - m.resizeViewport() + case "ctrl+m": + m.showingTabMenu = true + m.tabMenuCursor = int(m.activeTab) + m.viewport.SetContent(m.renderContent()) return m, nil case "tab": m.activeTab = (m.activeTab + 1) % tabCount @@ -594,6 +566,108 @@ func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 + 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 + } + return m, nil +} + +func (m Model) renderTabMenuOverlay() string { + tabMenuStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(baseColor). + Background(bgCard). + Padding(1, 3) + + tabItemStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A0A0B0")). + Padding(0, 2) + + tabItemActiveStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(baseColor). + Bold(true). + Padding(0, 2) + + tabNumStyle := lipgloss.NewStyle(). + Foreground(dimColor). + 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"} + + for i, name := range icons { + 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])) + items = append(items, tabItemActiveStyle.Render(">"+item)) + } else { + item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i])) + items = append(items, tabItemStyle.Render(" "+item)) + } + } + + content := lipgloss.NewStyle().Foreground(accentColor).Bold(true).Render("Switch Tab") + + "\n\n" + + strings.Join(items, "\n") + + "\n\n" + + lipgloss.NewStyle().Foreground(dimColor).Render("↑↓ navigate enter/select esc cancel") + + box := tabMenuStyle.Render(content) + + return lipgloss.Place(m.width, m.height, + 0.5, 0.5, + box, + lipgloss.WithWhitespaceBackground(bgPanel), + lipgloss.WithWhitespaceForeground(dimColor), + ) +} + func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) { input := m.chatInput m.chatLog = append(m.chatLog, userMsgStyle.Render("you: "+input)) @@ -920,6 +994,11 @@ func (m Model) View() string { b.WriteString("\n") b.WriteString(m.renderFooter()) + if m.showingTabMenu { + b = strings.Builder{} + b.WriteString(m.renderTabMenuOverlay()) + } + return b.String() } @@ -1497,11 +1576,11 @@ func (m Model) renderFooter() string { case tabDashboard: helpText = "[i] install [u] update [s] scan [l] lsp [m] mcp" case tabChat, tabWorkflow: - helpText = "[alt+1-5] tabs [tab] next [ctrl+c] quit" + helpText = "[ctrl+m] switch tab [tab] next [ctrl+c] quit" case tabAgents: helpText = "[c] crush [l] claude" default: - helpText = "[alt+1-5] tabs [tab] next [ctrl+c] quit" + helpText = "[ctrl+m] switch tab [tab] next [ctrl+c] quit" } rightR := statusBarStyle.Render(helpText)