feat: Ctrl+T tab switcher, minimal header, integrated terminal
All checks were successful
CI / build (push) Successful in 1m43s
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user