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,12 +37,13 @@ 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")
@@ -57,23 +58,6 @@ var (
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)
sectionStyle = lipgloss.NewStyle().
Foreground(accentColor).
Bold(true)
@@ -102,10 +86,6 @@ var (
inputStyle = lipgloss.NewStyle().
Foreground(baseColor)
phaseStyle = lipgloss.NewStyle().
Foreground(warningColor).
Bold(true)
stepDoneStyle = lipgloss.NewStyle().
Foreground(successColor)
@@ -119,12 +99,6 @@ 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")).
@@ -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
@@ -218,6 +182,12 @@ type Model struct {
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,32 +684,16 @@ 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
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)