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 help Show this help
TUI Controls: 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 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

@@ -37,42 +37,26 @@ const (
tabDashboard tab = iota tabDashboard tab = iota
tabChat tabChat
tabWorkflow tabWorkflow
tabTerminal
tabAgents tabAgents
tabConfig tabConfig
tabCount tabCount
) )
var tabNames = []string{"Dashboard", "Chat", "Workflow", "Agents", "Config"} var tabNames = []string{"Dashboard", "Chat", "Workflow", "Terminal", "Agents", "Config"}
var ( var (
baseColor = lipgloss.Color("#FF6B9D") baseColor = lipgloss.Color("#FF6B9D")
accentColor = lipgloss.Color("#A0D2FF") accentColor = lipgloss.Color("#A0D2FF")
aiColor = lipgloss.Color("#C4B5FD") aiColor = lipgloss.Color("#C4B5FD")
successColor = lipgloss.Color("#4ADE80") successColor = lipgloss.Color("#4ADE80")
warningColor = lipgloss.Color("#FBBF24") warningColor = lipgloss.Color("#FBBF24")
errorColor = lipgloss.Color("#FF6B6B") errorColor = lipgloss.Color("#FF6B6B")
mutedColor = lipgloss.Color("#666680") mutedColor = lipgloss.Color("#666680")
dimColor = lipgloss.Color("#444460") dimColor = lipgloss.Color("#444460")
bgDark = lipgloss.Color("#1A1A2E") bgDark = lipgloss.Color("#1A1A2E")
bgPanel = lipgloss.Color("#16213E") bgPanel = lipgloss.Color("#16213E")
bgCard = lipgloss.Color("#1F2937") 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)
sectionStyle = lipgloss.NewStyle(). sectionStyle = lipgloss.NewStyle().
Foreground(accentColor). Foreground(accentColor).
@@ -102,10 +86,6 @@ var (
inputStyle = lipgloss.NewStyle(). inputStyle = lipgloss.NewStyle().
Foreground(baseColor) Foreground(baseColor)
phaseStyle = lipgloss.NewStyle().
Foreground(warningColor).
Bold(true)
stepDoneStyle = lipgloss.NewStyle(). stepDoneStyle = lipgloss.NewStyle().
Foreground(successColor) Foreground(successColor)
@@ -119,24 +99,18 @@ var (
stepErrorStyle = lipgloss.NewStyle(). stepErrorStyle = lipgloss.NewStyle().
Foreground(errorColor) Foreground(errorColor)
cardStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(dimColor).
Padding(0, 1).
BorderBackground(bgPanel)
statusBarStyle = lipgloss.NewStyle(). statusBarStyle = lipgloss.NewStyle().
Background(bgDark). Background(bgDark).
Foreground(lipgloss.Color("#A0A0B0")). Foreground(lipgloss.Color("#A0A0B0")).
Padding(0, 1) Padding(0, 1)
confirmBoxStyle = lipgloss.NewStyle(). confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(baseColor). BorderForeground(baseColor).
Background(bgCard). Background(bgCard).
Foreground(lipgloss.Color("#FFFFFF")). Foreground(lipgloss.Color("#FFFFFF")).
Padding(1, 3). Padding(1, 3).
Bold(true) Bold(true)
confirmYesStyle = lipgloss.NewStyle(). confirmYesStyle = lipgloss.NewStyle().
Foreground(successColor). Foreground(successColor).
@@ -144,19 +118,6 @@ var (
confirmNoStyle = lipgloss.NewStyle(). confirmNoStyle = lipgloss.NewStyle().
Foreground(mutedColor) 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 } type aiResponseMsg struct{ content string }
@@ -180,6 +141,9 @@ type skillsListMsg struct{ skills []skills.Skill }
type spinnerTickMsg struct{ time time.Time } type spinnerTickMsg struct{ time time.Time }
type termOutputMsg struct{ line string }
type termExitMsg struct{}
type Model struct { type Model struct {
config *config.MuyueConfig config *config.MuyueConfig
scanResult *scanner.ScanResult scanResult *scanner.ScanResult
@@ -202,22 +166,28 @@ type Model struct {
mcpConfigured bool mcpConfigured bool
skillList []skills.Skill skillList []skills.Skill
helpModel help.Model helpModel help.Model
progressBar progress.Model progressBar progress.Model
spinner spinner.Model spinner spinner.Model
showingQuit bool showingQuit bool
confirmCursor int confirmCursor int
showingTabMenu bool showingTabMenu bool
tabMenuCursor int tabMenuCursor int
ctrlCCount int ctrlCCount int
lastCtrlC time.Time lastCtrlC time.Time
installing bool installing bool
installCurrent int installCurrent int
installTotal int installTotal int
installTool string installTool string
termCmd *exec.Cmd
termInput string
termLog []string
termRunning bool
termCwd string
} }
type keyMap struct { type keyMap struct {
@@ -241,7 +211,7 @@ var keys = keyMap{
), ),
Prev: key.NewBinding( Prev: key.NewBinding(
key.WithKeys("shift+tab"), key.WithKeys("shift+tab"),
key.WithHelp("+tab", "prev tab"), key.WithHelp("shift+tab", "prev tab"),
), ),
Quit: key.NewBinding( Quit: key.NewBinding(
key.WithKeys("ctrl+c"), key.WithKeys("ctrl+c"),
@@ -256,8 +226,8 @@ var keys = keyMap{
key.WithHelp("n/esc", "no"), key.WithHelp("n/esc", "no"),
), ),
TabMenu: key.NewBinding( TabMenu: key.NewBinding(
key.WithKeys("ctrl+m"), key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+m", "switch tab"), key.WithHelp("ctrl+t", "switch tab"),
), ),
Install: key.NewBinding( Install: key.NewBinding(
key.WithKeys("i"), key.WithKeys("i"),
@@ -277,7 +247,7 @@ var keys = keyMap{
), ),
Backspace: key.NewBinding( Backspace: key.NewBinding(
key.WithKeys("backspace"), 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")) prog := progress.New(progress.WithGradient("#FF6B9D", "#A0D2FF"))
cwd, _ := os.Getwd()
return Model{ return Model{
config: cfg, config: cfg,
scanResult: scan, scanResult: scan,
@@ -339,6 +311,7 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
confirmCursor: 1, confirmCursor: 1,
showingTabMenu: false, showingTabMenu: false,
tabMenuCursor: 0, 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) pm, cmd := m.progressBar.Update(msg)
m.progressBar = pm.(progress.Model) m.progressBar = pm.(progress.Model)
return m, cmd 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: case aiResponseMsg:
m.chatLoading = false m.chatLoading = false
content := msg.content content := msg.content
@@ -449,12 +437,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.helpModel.Width = msg.Width m.helpModel.Width = msg.Width
headerH := 4 headerH := 1
footerH := 2 footerH := 2
inputH := 0 inputH := 0
if m.activeTab == tabChat || m.activeTab == tabWorkflow { if m.activeTab == tabChat || m.activeTab == tabWorkflow {
inputH = 2 inputH = 2
} }
if m.activeTab == tabTerminal {
inputH = 2
}
contentH := msg.Height - headerH - footerH - inputH contentH := msg.Height - headerH - footerH - inputH
if contentH < 1 { if contentH < 1 {
contentH = 1 contentH = 1
@@ -478,6 +469,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleTabMenu(msg) return m.handleTabMenu(msg)
} }
if m.activeTab == tabTerminal {
return m.handleTerminalKey(msg)
}
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
now := time.Now() now := time.Now()
@@ -490,7 +485,7 @@ 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 "ctrl+m": case "ctrl+t":
m.showingTabMenu = true m.showingTabMenu = true
m.tabMenuCursor = int(m.activeTab) m.tabMenuCursor = int(m.activeTab)
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
@@ -532,6 +527,108 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "y", "Y", "o", "O": case "y", "Y", "o", "O":
@@ -587,31 +684,15 @@ func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showingTabMenu = false m.showingTabMenu = false
m.resizeViewport() m.resizeViewport()
return m, nil return m, nil
case "1": default:
m.activeTab = tabDashboard for i := 0; i < int(tabCount); i++ {
m.showingTabMenu = false if msg.String() == fmt.Sprintf("%d", i+1) {
m.resizeViewport() m.activeTab = tab(i)
return m, nil m.showingTabMenu = false
case "2": m.resizeViewport()
m.activeTab = tabChat return m, nil
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 return m, nil
} }
@@ -638,10 +719,9 @@ func (m Model) renderTabMenuOverlay() string {
Width(4) Width(4)
var items []string var items []string
icons := []string{"Dashboard", "Chat", "Workflow", "Agents", "Config"} descs := []string{"system overview & tools", "AI chat & conversation", "plan & execute workflows", "integrated shell", "background AI agents", "profile & settings"}
descs := []string{"system overview & tools", "AI chat & conversation", "plan & execute workflows", "background AI agents", "profile & settings"}
for i, name := range icons { for i, name := range tabNames {
num := tabNumStyle.Render(fmt.Sprintf(" %d.", i+1)) num := tabNumStyle.Render(fmt.Sprintf(" %d.", i+1))
if i == m.tabMenuCursor { if i == m.tabMenuCursor {
item := fmt.Sprintf("%s %-12s %s", num, name, lipgloss.NewStyle().Foreground(mutedColor).Render(descs[i])) 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" + "\n\n" +
strings.Join(items, "\n") + strings.Join(items, "\n") +
"\n\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) box := tabMenuStyle.Render(content)
@@ -826,12 +906,15 @@ func (m *Model) handlePreview(files []workflow.PreviewFile) {
} }
func (m *Model) resizeViewport() { func (m *Model) resizeViewport() {
headerH := 4 headerH := 1
footerH := 2 footerH := 2
inputH := 0 inputH := 0
if m.activeTab == tabChat || m.activeTab == tabWorkflow { if m.activeTab == tabChat || m.activeTab == tabWorkflow {
inputH = 2 inputH = 2
} }
if m.activeTab == tabTerminal {
inputH = 2
}
contentH := m.height - headerH - footerH - inputH contentH := m.height - headerH - footerH - inputH
if contentH < 1 { if contentH < 1 {
contentH = 1 contentH = 1
@@ -983,6 +1066,10 @@ func (m Model) View() string {
return m.renderQuitOverlay() return m.renderQuitOverlay()
} }
if m.showingTabMenu {
return m.renderTabMenuOverlay()
}
var b strings.Builder var b strings.Builder
b.WriteString(m.renderHeader()) b.WriteString(m.renderHeader())
b.WriteString("\n") b.WriteString("\n")
@@ -991,14 +1078,13 @@ func (m Model) View() string {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.renderChatInput()) b.WriteString(m.renderChatInput())
} }
if m.activeTab == tabTerminal {
b.WriteString("\n")
b.WriteString(m.renderTermInput())
}
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()
} }
@@ -1026,23 +1112,25 @@ func (m Model) renderQuitOverlay() string {
} }
func (m Model) renderHeader() 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") logo := logoStyle.Render("muyue")
badge := versionBadgeStyle.Render(" v" + version.Version + " ") badge := badgeStyle.Render("v" + version.Version)
separator := lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-lipgloss.Width(logo)-lipgloss.Width(badge)-4, 0)))
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)) rightPart := separator + activeTabName
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...)
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 { func (m Model) renderContent() string {
@@ -1053,6 +1141,8 @@ func (m Model) renderContent() string {
return m.renderChat() return m.renderChat()
case tabWorkflow: case tabWorkflow:
return m.renderWorkflow() return m.renderWorkflow()
case tabTerminal:
return m.renderTerminal()
case tabAgents: case tabAgents:
return m.renderAgents() return m.renderAgents()
case tabConfig: 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 { func (m Model) renderDashboard() string {
var b strings.Builder var b strings.Builder
@@ -1574,13 +1684,15 @@ func (m Model) renderFooter() string {
var helpText string var helpText string
switch m.activeTab { switch m.activeTab {
case tabDashboard: case tabDashboard:
helpText = "[i] install [u] update [s] scan [l] lsp [m] mcp" helpText = "[i] install [u] update [s] scan"
case tabChat, tabWorkflow: 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: case tabAgents:
helpText = "[c] crush [l] claude" helpText = "[c] crush [l] claude"
default: default:
helpText = "[ctrl+m] switch tab [tab] next [ctrl+c] quit" helpText = "[ctrl+t] tabs [tab] next [ctrl+c] quit"
} }
rightR := statusBarStyle.Render(helpText) rightR := statusBarStyle.Render(helpText)