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,12 +37,13 @@ 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")
@@ -57,23 +58,6 @@ var (
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).
Bold(true) Bold(true)
@@ -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,12 +99,6 @@ 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")).
@@ -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
@@ -218,6 +182,12 @@ type Model struct {
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,32 +684,16 @@ 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
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.showingTabMenu = false
m.resizeViewport() m.resizeViewport()
return m, nil 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)