feat: Ctrl+M tab switcher overlay menu
All checks were successful
CI / build (push) Successful in 1m45s

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 <crush@charm.land>
This commit is contained in:
Augustin
2026-04-20 00:14:16 +02:00
parent e6fdec4ff5
commit bb3b3038d3
2 changed files with 128 additions and 49 deletions

View File

@@ -83,7 +83,7 @@ Commands:
help Show this help help Show this help
TUI Controls: 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 Tab / Shift+Tab Cycle tabs
Ctrl+C Show quit confirmation (press twice quickly to force quit) Ctrl+C Show quit confirmation (press twice quickly to force quit)

View File

@@ -208,6 +208,8 @@ type Model struct {
showingQuit bool showingQuit bool
confirmCursor int confirmCursor int
showingTabMenu bool
tabMenuCursor int
ctrlCCount int ctrlCCount int
lastCtrlC time.Time lastCtrlC time.Time
@@ -224,11 +226,7 @@ type keyMap struct {
Quit key.Binding Quit key.Binding
Confirm key.Binding Confirm key.Binding
Cancel key.Binding Cancel key.Binding
Dashboard key.Binding TabMenu key.Binding
Chat key.Binding
Workflow key.Binding
Agents key.Binding
Config key.Binding
Install key.Binding Install key.Binding
Update key.Binding Update key.Binding
Scan key.Binding Scan key.Binding
@@ -257,25 +255,9 @@ var keys = keyMap{
key.WithKeys("n", "esc"), key.WithKeys("n", "esc"),
key.WithHelp("n/esc", "no"), key.WithHelp("n/esc", "no"),
), ),
Dashboard: key.NewBinding( TabMenu: key.NewBinding(
key.WithKeys("alt+1"), key.WithKeys("ctrl+m"),
key.WithHelp("alt+1", "dashboard"), key.WithHelp("ctrl+m", "switch tab"),
),
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"),
), ),
Install: key.NewBinding( Install: key.NewBinding(
key.WithKeys("i"), key.WithKeys("i"),
@@ -300,13 +282,13 @@ var keys = keyMap{
} }
func (k keyMap) ShortHelp() []key.Binding { 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 { func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{ return [][]key.Binding{
{k.Dashboard, k.Chat, k.Workflow, k.Agents, k.Config}, {k.TabMenu, k.Tab, k.Prev},
{k.Tab, k.Prev, k.Quit}, {k.Quit},
} }
} }
@@ -355,6 +337,8 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
spinner: sp, spinner: sp,
showingQuit: false, showingQuit: false,
confirmCursor: 1, confirmCursor: 1,
showingTabMenu: false,
tabMenuCursor: 0,
} }
} }
@@ -490,6 +474,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.showingQuit { if m.showingQuit {
return m.handleQuitConfirm(msg) return m.handleQuitConfirm(msg)
} }
if m.showingTabMenu {
return m.handleTabMenu(msg)
}
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
@@ -503,25 +490,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.confirmCursor = 1 m.confirmCursor = 1
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
case "alt+1": case "ctrl+m":
m.activeTab = tabDashboard m.showingTabMenu = true
m.resizeViewport() m.tabMenuCursor = int(m.activeTab)
return m, nil m.viewport.SetContent(m.renderContent())
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()
return m, nil return m, nil
case "tab": case "tab":
m.activeTab = (m.activeTab + 1) % tabCount m.activeTab = (m.activeTab + 1) % tabCount
@@ -594,6 +566,108 @@ func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
input := m.chatInput input := m.chatInput
m.chatLog = append(m.chatLog, userMsgStyle.Render("you: "+input)) m.chatLog = append(m.chatLog, userMsgStyle.Render("you: "+input))
@@ -920,6 +994,11 @@ func (m Model) View() string {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.renderFooter()) b.WriteString(m.renderFooter())
if m.showingTabMenu {
b = strings.Builder{}
b.WriteString(m.renderTabMenuOverlay())
}
return b.String() return b.String()
} }
@@ -1497,11 +1576,11 @@ func (m Model) renderFooter() string {
case tabDashboard: case tabDashboard:
helpText = "[i] install [u] update [s] scan [l] lsp [m] mcp" helpText = "[i] install [u] update [s] scan [l] lsp [m] mcp"
case tabChat, tabWorkflow: 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: case tabAgents:
helpText = "[c] crush [l] claude" helpText = "[c] crush [l] claude"
default: 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) rightR := statusBarStyle.Render(helpText)