feat: Ctrl+T tab switcher, minimal header, integrated terminal
All checks were successful
CI / build (push) Successful in 1m43s

- Fix Ctrl+M → Ctrl+T (Ctrl+M is Enter in terminals)
- Simplify header to single line: muyue v0.2.0 · Dashboard
- Add Terminal tab with shell command execution
  - Run commands, cd, clear, ctrl+c to kill process
  - Shows CWD and command history
- 6 tabs now: Dashboard, Chat, Workflow, Terminal, Agents, Config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-20 00:24:05 +02:00
parent bb3b3038d3
commit 2d6fc64b18
2 changed files with 239 additions and 127 deletions

View File

@@ -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)

View File

@@ -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)