From e3cd618cb109a96857382f9060ddb6c25421c0ee Mon Sep 17 00:00:00 2001 From: Augustin Date: Sun, 19 Apr 2026 23:22:04 +0200 Subject: [PATCH] feat: redesign TUI + Ctrl+C quit confirm + version logic + sudo handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 'q' quit with Ctrl+C confirmation dialog (Yes/No overlay) Second Ctrl+C within 2s force quits - Redesign TUI with Charm bubbles components: spinner, progress bar, help bar, key bindings, better color palette, rounded borders - Add Shift+Tab to cycle tabs backward - Fix version: bump to 0.2.0, release workflow checks existing tags before publishing (no more overwriting releases) - Handle sudo: CLI auto-relaunches with sudo/pkexec for tools that need elevated privileges, TUI shows clear error message πŸ’˜ Generated with Crush Assisted-by: GLM-5.1 via Crush --- .gitea/workflows/release.yml | 92 ++--- cmd/muyue/main.go | 77 ++++- go.mod | 1 + go.sum | 2 + internal/tui/app.go | 641 +++++++++++++++++++++++++++-------- internal/version/version.go | 2 +- 6 files changed, 624 insertions(+), 191 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 97bdce8..a1a84ed 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -22,19 +22,43 @@ jobs: export PATH=/usr/local/go/bin:$PATH go version - - name: Get version + - name: Compute version id: info run: | - VERSION=$(grep 'Version =' internal/version/version.go | cut -d'"' -f2) + BASE_VERSION=$(grep 'Version =' internal/version/version.go | cut -d'"' -f2) + COMMIT_COUNT=$(git rev-list --count HEAD) SHORT_SHA=$(git rev-parse --short HEAD) - echo "version=v${VERSION}" >> $GITHUB_OUTPUT + FULL_VERSION="${BASE_VERSION}-dev.${COMMIT_COUNT}+${SHORT_SHA}" + echo "base_version=v${BASE_VERSION}" >> $GITHUB_OUTPUT + echo "full_version=v${FULL_VERSION}" >> $GITHUB_OUTPUT echo "sha=${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "commit_count=${COMMIT_COUNT}" >> $GITHUB_OUTPUT + + - name: Check if tag exists + id: check + env: + GITEA_TOKEN: ${{ secrets.GITEATOKEN }} + run: | + if [ -z "$GITEA_TOKEN" ]; then + echo "skip=false" >> $GITHUB_OUTPUT + exit 0 + fi + TAG="${{ steps.info.outputs.base_version }}" + API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/${TAG}" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token ${GITEA_TOKEN}" "${API}") + if [ "$HTTP_CODE" = "200" ]; then + echo "Tag ${TAG} already exists, skipping release." + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT + fi - name: Build all platforms + if: steps.check.outputs.skip != 'true' run: | export PATH=/usr/local/go/bin:$PATH mkdir -p dist - VERSION=${{ steps.info.outputs.version }} + VERSION=${{ steps.info.outputs.full_version }} SHA=${{ steps.info.outputs.sha }} LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Version=${VERSION}-${SHA}" @@ -59,6 +83,7 @@ jobs: ls -lh dist/ - name: Create checksums and archives + if: steps.check.outputs.skip != 'true' run: | cd dist sha256sum * > checksums.txt @@ -72,27 +97,8 @@ jobs: rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe ls -lh - - name: Delete old release - env: - GITEA_TOKEN: ${{ secrets.GITEATOKEN }} - run: | - if [ -z "$GITEA_TOKEN" ]; then - echo "Warning: GITEA_TOKEN not set, skipping delete" - exit 0 - fi - API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" - RELEASES=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" "${API}" 2>/dev/null || echo "") - if [ -n "$RELEASES" ]; then - echo "$RELEASES" | grep -o '"id":[0-9]*' | while read line; do - ID=$(echo "$line" | grep -o '[0-9]*') - curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${ID}" > /dev/null 2>&1 || true - echo "Deleted release ${ID}" - done || true - fi - curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ - "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags/latest" > /dev/null 2>&1 || true - - name: Create release + if: steps.check.outputs.skip != 'true' env: GITEA_TOKEN: ${{ secrets.GITEATOKEN }} run: | @@ -101,32 +107,36 @@ jobs: echo "Go to Settings > Actions > Secrets and add GITEA_TOKEN" exit 1 fi - VERSION=${{ steps.info.outputs.version }} + VERSION=${{ steps.info.outputs.full_version }} + BASE_TAG=${{ steps.info.outputs.base_version }} SHA=${{ steps.info.outputs.sha }} + COMMIT_COUNT=${{ steps.info.outputs.commit_count }} API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" - BODY="## muyue ${VERSION} (${SHA}) + BODY="## muyue ${VERSION} - | Platform | File | - |----------|------| - | Linux x86_64 | muyue-linux-amd64.tar.gz | - | Linux ARM64 | muyue-linux-arm64.tar.gz | - | macOS Intel | muyue-darwin-amd64.tar.gz | - | macOS Apple Silicon | muyue-darwin-arm64.tar.gz | - | Windows x86_64 | muyue-windows-amd64.zip | - | Windows ARM64 | muyue-windows-arm64.zip | +Build \#${COMMIT_COUNT} from \`${SHA}\` - ### Install (Linux amd64) - \`\`\`bash - curl -sL ${{ github.server_url }}/${{ github.repository }}/releases/download/latest/muyue-linux-amd64.tar.gz | tar xz - chmod +x muyue-linux-amd64 - sudo mv muyue-linux-amd64 /usr/local/bin/muyue - \`\`\`" +| Platform | File | +|----------|------| +| Linux x86_64 | muyue-linux-amd64.tar.gz | +| Linux ARM64 | muyue-linux-arm64.tar.gz | +| macOS Intel | muyue-darwin-amd64.tar.gz | +| macOS Apple Silicon | muyue-darwin-arm64.tar.gz | +| Windows x86_64 | muyue-windows-amd64.zip | +| Windows ARM64 | muyue-windows-arm64.zip | + +### Install (Linux amd64) +\`\`\`bash +curl -sL ${{ github.server_url }}/${{ github.repository }}/releases/download/${BASE_TAG}/muyue-linux-amd64.tar.gz | tar xz +chmod +x muyue-linux-amd64 +sudo mv muyue-linux-amd64 /usr/local/bin/muyue +\`\`\`" RESPONSE=$(curl -s -X POST "${API}" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"latest\",\"target_commitish\":\"main\",\"name\":\"muyue ${VERSION} (${SHA})\",\"body\":$(echo "$BODY" | jq -Rs .),\"draft\":false,\"prerelease\":false}") + -d "{\"tag_name\":\"${BASE_TAG}\",\"target_commitish\":\"main\",\"name\":\"muyue ${VERSION}\",\"body\":$(echo "$BODY" | jq -Rs .),\"draft\":false,\"prerelease\":false}") RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') diff --git a/cmd/muyue/main.go b/cmd/muyue/main.go index 8acff81..a95358c 100644 --- a/cmd/muyue/main.go +++ b/cmd/muyue/main.go @@ -3,6 +3,8 @@ package main import ( "fmt" "os" + "os/exec" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/muyue/muyue/internal/config" @@ -71,7 +73,7 @@ Usage: Commands: version Show version scan Scan your system for tools and runtimes - install [tools] Install missing tools (crush, claude, bmad, starship, go, node, python, git) + install [tools] Install missing tools (needs sudo for some tools) update Check and apply updates for all tools setup Run first-time setup wizard config Show current configuration @@ -82,8 +84,8 @@ Commands: TUI Controls: 1-5 Switch tabs (Dashboard/Chat/Workflow/Agents/Config) - Tab Cycle to next tab - q / Ctrl+C Quit + Tab / Shift+Tab Cycle tabs + Ctrl+C Show quit confirmation (press twice quickly to force quit) Chat Commands: /plan Start a structured Planβ†’Execute workflow @@ -94,6 +96,10 @@ Workflow Controls: [g] Generate plan (after answering questions) [n] Execute next step [x] Cancel/reset workflow + +Note: + Some tools (docker, gh, etc.) require elevated privileges. + Run 'sudo muyue install' or use 'pkexec muyue install' if needed. `, version.FullVersion()) } @@ -174,6 +180,38 @@ func runInstall(tools []string) { tools = missing } + if needsSudo(tools) && os.Geteuid() != 0 { + fmt.Println("Some tools require elevated privileges.") + if path, err := exec.LookPath("sudo"); err == nil { + fmt.Printf("Re-running with sudo...\n") + cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "sudo install failed: %v\n", err) + os.Exit(1) + } + config.Save(cfg) + return + } + if path, err := exec.LookPath("pkexec"); err == nil { + fmt.Printf("Re-running with pkexec...\n") + cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "pkexec install failed: %v\n", err) + os.Exit(1) + } + config.Save(cfg) + return + } + fmt.Println("Neither sudo nor pkexec found. Some installs may fail.") + fmt.Println("Try running: sudo muyue install") + } + results := inst.InstallAll(tools) for _, r := range results { status := "[OK]" @@ -186,6 +224,18 @@ func runInstall(tools []string) { config.Save(cfg) } +func needsSudo(tools []string) bool { + sudoTools := map[string]bool{ + "docker": true, "git": true, "gh": true, "node": true, "python": true, + } + for _, t := range tools { + if sudoTools[t] { + return true + } + } + return false +} + func runUpdate() { fmt.Println("Checking for updates...") result := scanner.ScanSystem() @@ -432,7 +482,7 @@ func runSkills(args []string) { Description: description, Content: resp, Author: "muyue-generated", - Version: "1.0.0", + Version: "0.1.0", Target: target, Tags: []string{"generated"}, } @@ -468,3 +518,22 @@ func runSkills(args []string) { fmt.Println("Available: list, show, generate, deploy, init, delete") } } + +func checkConfigProviders(cfg *config.MuyueConfig) { + for i := range cfg.AI.Providers { + if cfg.AI.Providers[i].Active { + return + } + } + if len(cfg.AI.Providers) > 0 { + cfg.AI.Providers[0].Active = true + } +} + +func joinWithQuotes(items []string) string { + quoted := make([]string, len(items)) + for i, item := range items { + quoted[i] = fmt.Sprintf("%q", item) + } + return strings.Join(quoted, ", ") +} diff --git a/go.mod b/go.mod index 72ede25..61a2949 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect diff --git a/go.sum b/go.sum index 05e1d54..a0bbf61 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= diff --git a/internal/tui/app.go b/internal/tui/app.go index 391a205..528168b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -3,11 +3,16 @@ package tui import ( "fmt" "os" + "os/exec" "path/filepath" "regexp" "strings" "time" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -40,71 +45,118 @@ const ( var tabNames = []string{"Dashboard", "Chat", "Workflow", "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(lipgloss.Color("#2D2D3F")). + Background(bgDark). Padding(0, 2) tabStyle = lipgloss.NewStyle(). Padding(0, 2). - Foreground(lipgloss.Color("#666680")) + Foreground(mutedColor) activeTabStyle = lipgloss.NewStyle(). Padding(0, 2). - Foreground(lipgloss.Color("#FF6B9D")). + Foreground(baseColor). Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(lipgloss.Color("#FF6B9D")) - - statusBarStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#2D2D3F")). - Foreground(lipgloss.Color("#A0A0B0")). - Padding(0, 1) + BorderForeground(baseColor). + Bold(true) sectionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A0D2FF")). + Foreground(accentColor). Bold(true) itemOKStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#4ADE80")) + Foreground(successColor) itemMissingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B6B")) + Foreground(errorColor) itemWarnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FBBF24")) + Foreground(warningColor) itemPendingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666680")) + Foreground(mutedColor) userMsgStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A0D2FF")) + Foreground(accentColor) aiMsgStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C4B5FD")) + Foreground(aiColor) errMsgStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B6B")) + Foreground(errorColor) inputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B9D")) + Foreground(baseColor) phaseStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FBBF24")). + Foreground(warningColor). Bold(true) stepDoneStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#4ADE80")) + Foreground(successColor) stepPendingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666680")) + Foreground(mutedColor) stepCurrentStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B9D")). + Foreground(baseColor). Bold(true) stepErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B6B")) + 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) + + confirmYesStyle = lipgloss.NewStyle(). + Foreground(successColor). + Bold(true) + + 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 } @@ -121,27 +173,131 @@ type mcpConfigMsg struct{ err error } type skillsListMsg struct{ skills []skills.Skill } +type spinnerTickMsg struct{ time time.Time } + type Model struct { - config *config.MuyueConfig - scanResult *scanner.ScanResult - activeTab tab - width int - height int - viewport viewport.Model - ready bool - chatInput string - chatLog []string - chatLoading bool - orch *orchestrator.Orchestrator - proxyMgr *proxy.Manager - updateStatus []updater.UpdateStatus - installLog []string - previewURL string - previewSrv *preview.PreviewServer - daemon *daemon.Daemon - lspServers []lsp.LSPServer + config *config.MuyueConfig + scanResult *scanner.ScanResult + activeTab tab + width int + height int + viewport viewport.Model + ready bool + chatInput string + chatLog []string + chatLoading bool + orch *orchestrator.Orchestrator + proxyMgr *proxy.Manager + updateStatus []updater.UpdateStatus + installLog []string + previewURL string + previewSrv *preview.PreviewServer + daemon *daemon.Daemon + lspServers []lsp.LSPServer mcpConfigured bool - skillList []skills.Skill + skillList []skills.Skill + + helpModel help.Model + progressBar progress.Model + spinner spinner.Model + + showingQuit bool + confirmCursor int + + ctrlCCount int + lastCtrlC time.Time +} + +type keyMap struct { + Tab key.Binding + Prev key.Binding + Quit key.Binding + Confirm key.Binding + Cancel key.Binding + Dashboard key.Binding + Chat key.Binding + Workflow key.Binding + Agents key.Binding + Config key.Binding + Install key.Binding + Update key.Binding + Scan key.Binding + Enter key.Binding + Backspace key.Binding +} + +var keys = keyMap{ + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next tab"), + ), + Prev: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("⇧+tab", "prev tab"), + ), + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + Confirm: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "yes"), + ), + Cancel: key.NewBinding( + key.WithKeys("n", "esc"), + key.WithHelp("n/esc", "no"), + ), + Dashboard: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "dashboard"), + ), + Chat: key.NewBinding( + key.WithKeys("2"), + key.WithHelp("2", "chat"), + ), + Workflow: key.NewBinding( + key.WithKeys("3"), + key.WithHelp("3", "workflow"), + ), + Agents: key.NewBinding( + key.WithKeys("4"), + key.WithHelp("4", "agents"), + ), + Config: key.NewBinding( + key.WithKeys("5"), + key.WithHelp("5", "config"), + ), + Install: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "install"), + ), + Update: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "update"), + ), + Scan: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "scan"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ), + Backspace: key.NewBinding( + key.WithKeys("backspace"), + key.WithHelp("⌫", "delete"), + ), +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Dashboard, k.Chat, k.Workflow, k.Agents, k.Config, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Dashboard, k.Chat, k.Workflow, k.Agents, k.Config}, + {k.Tab, k.Prev, k.Quit}, + } } func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model { @@ -162,6 +318,12 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model { d.Start() } + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(baseColor) + + prog := progress.New(progress.WithGradient("#FF6B9D", "#A0D2FF")) + return Model{ config: cfg, scanResult: scan, @@ -170,25 +332,38 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model { aiMsgStyle.Render("muyue: Welcome! I'm your AI development environment assistant."), aiMsgStyle.Render("muyue: Type /plan to start a structured workflow, or just chat."), }, - orch: orch, - proxyMgr: proxyMgr, - chatInput: "", - chatLoading: false, - daemon: d, - lspServers: lspServers, - mcpConfigured: mcpConfigured, - skillList: skillList, + orch: orch, + proxyMgr: proxyMgr, + chatInput: "", + chatLoading: false, + daemon: d, + lspServers: lspServers, + mcpConfigured: mcpConfigured, + skillList: skillList, + helpModel: help.New(), + progressBar: prog, + spinner: sp, + showingQuit: false, + confirmCursor: 1, } } func (m Model) Init() tea.Cmd { - return tea.EnterAltScreen + return tea.Batch(spinner.Tick, tea.EnterAltScreen) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleKey(msg) + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case progress.FrameMsg: + pm, cmd := m.progressBar.Update(msg) + m.progressBar = pm.(progress.Model) + return m, cmd case aiResponseMsg: m.chatLoading = false content := msg.content @@ -249,8 +424,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - headerH := 3 - footerH := 1 + m.helpModel.Width = msg.Width + headerH := 4 + footerH := 2 inputH := 0 if m.activeTab == tabChat || m.activeTab == tabWorkflow { inputH = 2 @@ -262,6 +438,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport = viewport.New(msg.Width, contentH) m.viewport.Width = msg.Width m.viewport.Height = contentH + m.progressBar.Width = msg.Width - 20 m.ready = true m.viewport.SetContent(m.renderContent()) return m, nil @@ -270,9 +447,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.showingQuit { + return m.handleQuitConfirm(msg) + } + switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit + case "ctrl+c": + 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 "1": m.activeTab = tabDashboard m.resizeViewport() @@ -297,6 +487,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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 case "enter": if (m.activeTab == tabChat || m.activeTab == tabWorkflow) && m.chatInput != "" && !m.chatLoading { return m.handleChatSubmit() @@ -326,6 +520,40 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y", "o", "O": + m.showingQuit = false + return m, tea.Quit + case "n", "N", "esc": + m.showingQuit = false + m.ctrlCCount = 0 + m.viewport.SetContent(m.renderContent()) + return m, nil + case "left", "h": + m.confirmCursor = 0 + m.viewport.SetContent(m.renderContent()) + return m, nil + case "right", "l": + m.confirmCursor = 1 + m.viewport.SetContent(m.renderContent()) + return m, nil + case "enter": + if m.confirmCursor == 0 { + m.showingQuit = false + return m, tea.Quit + } + m.showingQuit = false + m.ctrlCCount = 0 + m.viewport.SetContent(m.renderContent()) + return m, nil + case "ctrl+c": + m.showingQuit = false + return m, tea.Quit + } + return m, nil +} + func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) { input := m.chatInput m.chatLog = append(m.chatLog, userMsgStyle.Render("you: "+input)) @@ -350,6 +578,12 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "i": return m, tea.Cmd(func() tea.Msg { + needsSudo := checkNeedsSudo(m.scanResult) + if needsSudo && !hasSudo() { + return installCompleteMsg{results: []installer.InstallResult{ + {Tool: "system", Success: false, Message: "some tools require sudo. Run: muyue install, or relaunch with sudo/pkexec"}, + }} + } inst := installer.New(m.config) var missing []string if m.scanResult != nil { @@ -470,8 +704,8 @@ func (m *Model) handlePreview(files []workflow.PreviewFile) { } func (m *Model) resizeViewport() { - headerH := 3 - footerH := 1 + headerH := 4 + footerH := 2 inputH := 0 if m.activeTab == tabChat || m.activeTab == tabWorkflow { inputH = 2 @@ -486,6 +720,34 @@ func (m *Model) resizeViewport() { m.viewport.SetContent(m.renderContent()) } +func checkNeedsSudo(scan *scanner.ScanResult) bool { + if scan == nil { + return false + } + sudoTools := map[string]bool{ + "docker": true, "git": true, "gh": true, "node": true, "python": true, + } + for _, t := range scan.Tools { + if !t.Installed && sudoTools[t.Name] { + return true + } + } + return false +} + +func hasSudo() bool { + if os.Geteuid() == 0 { + return true + } + if _, err := exec.LookPath("sudo"); err == nil { + return true + } + if _, err := exec.LookPath("pkexec"); err == nil { + return true + } + return false +} + func sendAIMessage(orch *orchestrator.Orchestrator, input string) tea.Cmd { return tea.Cmd(func() tea.Msg { if orch == nil { @@ -571,6 +833,10 @@ func (m Model) View() string { return "Loading..." } + if m.showingQuit { + return m.renderQuitOverlay() + } + var b strings.Builder b.WriteString(m.renderHeader()) b.WriteString("\n") @@ -585,8 +851,35 @@ func (m Model) View() string { return b.String() } +func (m Model) renderQuitOverlay() string { + yesStyle := confirmNoStyle + noStyle := confirmYesStyle + if m.confirmCursor == 0 { + yesStyle = confirmYesStyle + noStyle = confirmNoStyle + } + + box := fmt.Sprintf("\n\n Quit muyue?\n\n %s %s", + yesStyle.Render("[ Yes ]"), + noStyle.Render("[ No ]"), + ) + + content := confirmBoxStyle.Render(box) + + return lipgloss.Place(m.width, m.height, + 0.5, 0.5, + content, + lipgloss.WithWhitespaceBackground(bgDark), + lipgloss.WithWhitespaceForeground(dimColor), + ) +} + func (m Model) renderHeader() string { - title := titleStyle.Render(" " + version.FullVersion() + " ") + 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))) + + topLine := lipgloss.JoinHorizontal(lipgloss.Center, " ", logo, " ", badge, " ", separator) tabs := make([]string, len(tabNames)) for i, name := range tabNames { @@ -598,7 +891,7 @@ func (m Model) renderHeader() string { } tabsRow := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...) - return lipgloss.JoinVertical(lipgloss.Left, title, tabsRow) + return lipgloss.JoinVertical(lipgloss.Left, topLine, tabsRow) } func (m Model) renderContent() string { @@ -624,25 +917,37 @@ func (m Model) renderDashboard() string { b.WriteString(sectionStyle.Render("System")) b.WriteString("\n") if m.scanResult != nil { + sysInfo := m.scanResult.System.String() b.WriteString(" ") - b.WriteString(m.scanResult.System.String()) + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(sysInfo)) } b.WriteString("\n\n") b.WriteString(sectionStyle.Render("Tools")) b.WriteString("\n") if m.scanResult != nil { + installed := 0 + total := len(m.scanResult.Tools) for _, t := range m.scanResult.Tools { if t.Installed { + installed++ b.WriteString(" ") - b.WriteString(itemOKStyle.Render("[v]")) - b.WriteString(fmt.Sprintf(" %s %s\n", t.Name, extractVersion(t.Version))) + b.WriteString(itemOKStyle.Render(" ")) + b.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, extractVersion(t.Version))) } else { b.WriteString(" ") - b.WriteString(itemMissingStyle.Render("[ ]")) - b.WriteString(fmt.Sprintf(" %s\n", t.Name)) + b.WriteString(itemMissingStyle.Render(" ")) + b.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, itemPendingStyle.Render("(not installed)"))) } } + barWidth := 20 + pct := 0 + if total > 0 { + pct = (installed * barWidth) / total + } + bar := lipgloss.NewStyle().Foreground(successColor).Render(strings.Repeat("β–ˆ", pct)) + + lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("β–‘", barWidth-pct)) + b.WriteString(fmt.Sprintf("\n %s %d/%d tools installed\n", bar, installed, total)) } b.WriteString("\n") @@ -652,11 +957,11 @@ func (m Model) renderDashboard() string { for _, s := range m.updateStatus { if s.NeedsUpdate { b.WriteString(" ") - b.WriteString(itemWarnStyle.Render("[!]")) + b.WriteString(itemWarnStyle.Render(" ")) b.WriteString(fmt.Sprintf(" %s: %s -> %s\n", s.Tool, s.Current, s.Latest)) } else if s.Error == "" { b.WriteString(" ") - b.WriteString(itemOKStyle.Render("[v]")) + b.WriteString(itemOKStyle.Render(" ")) b.WriteString(fmt.Sprintf(" %s: up to date\n", s.Tool)) } } @@ -672,32 +977,42 @@ func (m Model) renderDashboard() string { b.WriteString("\n") } + actions := []struct { + key string + desc string + }{ + {"i", "Install missing tools"}, + {"u", "Check for updates"}, + {"s", "Rescan system"}, + {"l", "Scan LSP servers"}, + {"m", "Configure MCP servers"}, + } b.WriteString(sectionStyle.Render("Quick Actions")) b.WriteString("\n") - b.WriteString(" [i] Install missing tools\n") - b.WriteString(" [u] Check for updates\n") - b.WriteString(" [s] Rescan system\n") - b.WriteString(" [l] Scan LSP servers\n") - b.WriteString(" [m] Configure MCP servers\n") + for _, a := range actions { + b.WriteString(fmt.Sprintf(" %s %s\n", + lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("["+a.key+"]"), + a.desc)) + } b.WriteString("\n") if len(m.lspServers) > 0 { b.WriteString(sectionStyle.Render("LSP Servers")) b.WriteString("\n") - installed := 0 + lspInstalled := 0 for _, s := range m.lspServers { if s.Installed { - installed++ + lspInstalled++ b.WriteString(" ") - b.WriteString(itemOKStyle.Render("[v]")) - b.WriteString(fmt.Sprintf(" %-30s (%s)\n", s.Name, s.Language)) + b.WriteString(itemOKStyle.Render(" ")) + b.WriteString(fmt.Sprintf(" %-28s (%s)\n", s.Name, s.Language)) } else { b.WriteString(" ") - b.WriteString(itemPendingStyle.Render("[ ]")) - b.WriteString(fmt.Sprintf(" %-30s (%s)\n", s.Name, s.Language)) + b.WriteString(itemPendingStyle.Render(" ")) + b.WriteString(fmt.Sprintf(" %-28s (%s)\n", s.Name, s.Language)) } } - b.WriteString(fmt.Sprintf("\n Installed: %d/%d\n", installed, len(m.lspServers))) + b.WriteString(fmt.Sprintf("\n Installed: %d/%d\n", lspInstalled, len(m.lspServers))) b.WriteString("\n") } @@ -706,14 +1021,14 @@ func (m Model) renderDashboard() string { b.WriteString("\n") if m.daemon.IsRunning() { b.WriteString(" ") - b.WriteString(itemOKStyle.Render("● running")) + b.WriteString(itemOKStyle.Render("running")) lastCheck := m.daemon.LastCheck() if !lastCheck.IsZero() { b.WriteString(fmt.Sprintf(" last check: %s", lastCheck.Format("15:04:05"))) } } else { b.WriteString(" ") - b.WriteString(itemPendingStyle.Render("β—‹ stopped")) + b.WriteString(itemPendingStyle.Render("stopped")) } b.WriteString("\n") @@ -727,7 +1042,7 @@ func (m Model) renderDashboard() string { b.WriteString("\n") } - mcpStatus := "not configured" + mcpStatus := itemPendingStyle.Render("not configured") if m.mcpConfigured { mcpStatus = itemOKStyle.Render("configured") } @@ -739,13 +1054,19 @@ func (m Model) renderDashboard() string { func (m Model) renderChat() string { var b strings.Builder - header := sectionStyle.Render("Chat β€” " + m.config.Profile.Preferences.DefaultAI) + header := sectionStyle.Render("Chat") + header += " " + header += lipgloss.NewStyle().Foreground(mutedColor).Render("(" + m.config.Profile.Preferences.DefaultAI + ")") if m.chatLoading { - header += " " + itemWarnStyle.Render("thinking...") + header += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warningColor).Render("thinking...") } b.WriteString(header) b.WriteString("\n\n") + separator := lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-4, 10))) + b.WriteString(separator) + b.WriteString("\n\n") + for _, msg := range m.chatLog { b.WriteString(msg) b.WriteString("\n\n") @@ -760,11 +1081,11 @@ func (m Model) renderChat() string { } func (m Model) renderChatInput() string { - prompt := inputStyle.Render("> ") if m.chatLoading { - return prompt + itemWarnStyle.Render("waiting for response...") + return inputStyle.Render("> ") + m.spinner.View() + lipgloss.NewStyle().Foreground(mutedColor).Render(" waiting for response...") } - return prompt + m.chatInput + "β–ˆ" + cursor := lipgloss.NewStyle().Foreground(baseColor).Render("") + return inputStyle.Render("> ") + m.chatInput + cursor } func (m Model) renderWorkflow() string { @@ -779,7 +1100,20 @@ func (m Model) renderWorkflow() string { b.WriteString(sectionStyle.Render("Workflow")) b.WriteString(" ") - b.WriteString(phaseStyle.Render(string(wf.Phase))) + + phaseColors := map[workflow.Phase]lipgloss.Style{ + workflow.PhaseIdle: lipgloss.NewStyle().Foreground(mutedColor), + workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warningColor).Bold(true), + workflow.PhasePlanning: lipgloss.NewStyle().Foreground(accentColor).Bold(true), + workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(aiColor).Bold(true), + workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(baseColor).Bold(true), + workflow.PhaseDone: lipgloss.NewStyle().Foreground(successColor).Bold(true), + workflow.PhaseError: lipgloss.NewStyle().Foreground(errorColor).Bold(true), + } + + if style, ok := phaseColors[wf.Phase]; ok { + b.WriteString(style.Render(string(wf.Phase))) + } b.WriteString("\n\n") if wf.Plan.Goal != "" { @@ -796,9 +1130,9 @@ func (m Model) renderWorkflow() string { b.WriteString(sectionStyle.Render("Gathering Requirements")) b.WriteString("\n") for i, q := range wf.Plan.Questions { - icon := itemPendingStyle.Render("[ ]") + icon := itemPendingStyle.Render(" ") if i < len(wf.Plan.Answers) { - icon = itemOKStyle.Render("[v]") + icon = itemOKStyle.Render(" ") b.WriteString(fmt.Sprintf(" %s Q: %s\n", icon, q)) b.WriteString(fmt.Sprintf(" A: %s\n", wf.Plan.Answers[i])) } else { @@ -812,6 +1146,8 @@ func (m Model) renderWorkflow() string { } case workflow.PhasePlanning: + b.WriteString(m.spinner.View()) + b.WriteString(" ") b.WriteString(itemWarnStyle.Render("Generating plan...")) b.WriteString("\n") @@ -819,10 +1155,12 @@ func (m Model) renderWorkflow() string { b.WriteString(sectionStyle.Render("Plan (review before execution)")) b.WriteString("\n\n") for i, s := range wf.Plan.Steps { - icon := stepPendingStyle.Render("[ ]") - b.WriteString(fmt.Sprintf(" %s Step %s: %s\n", icon, s.ID, s.Title)) - b.WriteString(fmt.Sprintf(" %s\n", s.Description)) - b.WriteString(fmt.Sprintf(" Agent: %s\n", s.Agent)) + numStyle := lipgloss.NewStyle().Foreground(accentColor).Bold(true) + icon := stepPendingStyle.Render(" ") + b.WriteString(fmt.Sprintf(" %s %s %s\n", icon, numStyle.Render("#"+s.ID+":"), s.Title)) + b.WriteString(fmt.Sprintf(" %s\n", lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Description))) + agentStyle := lipgloss.NewStyle().Foreground(aiColor).Render(s.Agent) + b.WriteString(fmt.Sprintf(" Agent: %s\n", agentStyle)) if i < len(wf.Plan.Steps)-1 { b.WriteString("\n") } @@ -843,21 +1181,22 @@ func (m Model) renderWorkflow() string { b.WriteString(sectionStyle.Render("Executing Plan")) b.WriteString("\n\n") done, total := wf.Progress() - progressBar := renderProgressBar(done, total, 30) - b.WriteString(fmt.Sprintf(" Progress: %s %d/%d\n\n", progressBar, done, total)) + + m.progressBar.SetPercent(float64(done) / float64(max(total, 1))) + fmt.Fprintf(&b, " %s %d/%d\n\n", m.progressBar.View(), done, total) for _, s := range wf.Plan.Steps { var icon string switch s.Status { case "done": - icon = stepDoneStyle.Render("[v]") + icon = stepDoneStyle.Render(" ") case "error": - icon = stepErrorStyle.Render("[x]") + icon = stepErrorStyle.Render(" ") default: if wf.Plan.Steps[wf.Plan.StepIndex].ID == s.ID { - icon = stepCurrentStyle.Render("[>]") + icon = stepCurrentStyle.Render(">") } else { - icon = stepPendingStyle.Render("[ ]") + icon = stepPendingStyle.Render(" ") } } b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title)) @@ -879,7 +1218,7 @@ func (m Model) renderWorkflow() string { b.WriteString(itemOKStyle.Render("Workflow completed!")) b.WriteString("\n\n") for _, s := range wf.Plan.Steps { - icon := stepDoneStyle.Render("[v]") + icon := stepDoneStyle.Render(" ") b.WriteString(fmt.Sprintf(" %s %s: %s\n", icon, s.ID, s.Title)) } b.WriteString("\n [x] Reset workflow\n") @@ -890,6 +1229,8 @@ func (m Model) renderWorkflow() string { } b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("─", max(m.width-4, 10)))) + b.WriteString("\n") b.WriteString(sectionStyle.Render("Chat")) b.WriteString("\n") for _, msg := range m.chatLog { @@ -905,18 +1246,6 @@ func (m Model) renderWorkflow() string { return b.String() } -func renderProgressBar(done, total, width int) string { - if total == 0 { - return "[" + strings.Repeat(" ", width) + "]" - } - filled := (done * width) / total - if filled > width { - filled = width - } - bar := strings.Repeat("β–ˆ", filled) + strings.Repeat("β–‘", width-filled) - return "[" + bar + "]" -} - func (m Model) renderAgents() string { var b strings.Builder @@ -939,20 +1268,22 @@ func (m Model) renderAgents() string { var statusStr string switch status { case proxy.StatusRunning: - statusStr = itemWarnStyle.Render("● running") + statusStr = itemWarnStyle.Render(" running") case proxy.StatusStopped: - statusStr = itemMissingStyle.Render("β–  stopped") + statusStr = itemMissingStyle.Render(" stopped") case proxy.StatusError: - statusStr = itemMissingStyle.Render("βœ• error") + statusStr = itemMissingStyle.Render(" error") default: if available { - statusStr = itemOKStyle.Render("β—‹ available") + statusStr = itemOKStyle.Render(" available") } else { - statusStr = itemMissingStyle.Render("β—‹ not installed") + statusStr = itemMissingStyle.Render(" not installed") } } - b.WriteString(fmt.Sprintf(" %-15s %s (%s)\n", a.name, statusStr, a.tool)) + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Bold(true) + b.WriteString(fmt.Sprintf(" %s %s %s\n", nameStyle.Render(a.name), statusStr, + lipgloss.NewStyle().Foreground(mutedColor).Render("("+a.tool+")"))) if logs != nil && len(logs) > 0 { lastLogs := logs @@ -961,7 +1292,8 @@ func (m Model) renderAgents() string { } for _, l := range lastLogs { b.WriteString(fmt.Sprintf(" %s %s\n", - l.Timestamp.Format("15:04:05"), l.Message)) + lipgloss.NewStyle().Foreground(dimColor).Render(l.Timestamp.Format("15:04:05")), + l.Message)) } } } @@ -969,8 +1301,8 @@ func (m Model) renderAgents() string { b.WriteString("\n") b.WriteString(sectionStyle.Render("Actions")) b.WriteString("\n") - b.WriteString(" [c] Start Crush\n") - b.WriteString(" [l] Start Claude Code\n") + b.WriteString(fmt.Sprintf(" %s Start Crush\n", lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("[c]"))) + b.WriteString(fmt.Sprintf(" %s Start Claude Code\n", lipgloss.NewStyle().Foreground(baseColor).Bold(true).Render("[l]"))) return b.String() } @@ -981,15 +1313,27 @@ func (m Model) renderConfig() string { b.WriteString(sectionStyle.Render("Profile")) b.WriteString("\n") if m.config != nil { - b.WriteString(fmt.Sprintf(" Name: %s\n", m.config.Profile.Name)) - b.WriteString(fmt.Sprintf(" Pseudo: %s\n", m.config.Profile.Pseudo)) - b.WriteString(fmt.Sprintf(" Email: %s\n", m.config.Profile.Email)) - b.WriteString(fmt.Sprintf(" Editor: %s\n", m.config.Profile.Preferences.Editor)) - b.WriteString(fmt.Sprintf(" Shell: %s\n", m.config.Profile.Preferences.Shell)) - b.WriteString(fmt.Sprintf(" Theme: %s\n", m.config.Profile.Preferences.Theme)) - b.WriteString(fmt.Sprintf(" Default AI: %s\n", m.config.Profile.Preferences.DefaultAI)) + fields := []struct { + label string + value string + }{ + {"Name", m.config.Profile.Name}, + {"Pseudo", m.config.Profile.Pseudo}, + {"Email", m.config.Profile.Email}, + {"Editor", m.config.Profile.Preferences.Editor}, + {"Shell", m.config.Profile.Preferences.Shell}, + {"Theme", m.config.Profile.Preferences.Theme}, + {"Default AI", m.config.Profile.Preferences.DefaultAI}, + } + for _, f := range fields { + labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")) + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render(f.label+":"), valueStyle.Render(f.value))) + } if len(m.config.Profile.Languages) > 0 { - b.WriteString(fmt.Sprintf(" Languages: %s\n", strings.Join(m.config.Profile.Languages, ", "))) + labelStyle := lipgloss.NewStyle().Foreground(mutedColor).Width(14) + valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")) + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Languages:"), valueStyle.Render(strings.Join(m.config.Profile.Languages, ", ")))) } } b.WriteString("\n") @@ -1000,14 +1344,15 @@ func (m Model) renderConfig() string { for _, p := range m.config.AI.Providers { active := "" if p.Active { - active = itemOKStyle.Render(" (active)") + active = itemOKStyle.Render(" active") } - keyStatus := "no key" + keyStatus := itemMissingStyle.Render("no key") if p.APIKey != "" { - keyStatus = "configured" + keyStatus = itemOKStyle.Render("configured") } - b.WriteString(fmt.Sprintf(" %-12s model=%-25s key=%s%s\n", - p.Name, p.Model, keyStatus, active)) + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Bold(true) + b.WriteString(fmt.Sprintf(" %s model=%s key=%s%s\n", + nameStyle.Render(p.Name), p.Model, keyStatus, active)) } } b.WriteString("\n") @@ -1015,7 +1360,7 @@ func (m Model) renderConfig() string { b.WriteString(sectionStyle.Render("BMAD Method")) b.WriteString("\n") if m.config != nil { - installed := "no" + installed := itemMissingStyle.Render("no") if m.config.BMAD.Installed { installed = itemOKStyle.Render("yes") } @@ -1040,7 +1385,10 @@ func (m Model) renderConfig() string { if target == "" { target = "both" } - b.WriteString(fmt.Sprintf(" %-20s [%s] %s\n", s.Name, target, s.Description)) + b.WriteString(fmt.Sprintf(" %-20s %s %s\n", + lipgloss.NewStyle().Foreground(lipgloss.Color("#E0E0E0")).Render(s.Name), + lipgloss.NewStyle().Foreground(aiColor).Render("["+target+"]"), + s.Description)) } } else { b.WriteString(" No skills. Run `muyue skills init` to install built-ins.\n") @@ -1056,32 +1404,35 @@ func (m Model) renderFooter() string { } left := fmt.Sprintf(" %s@%s", profile, version.Name) + leftR := statusBarStyle.Render(left) - var right string + var helpText string switch m.activeTab { case tabDashboard: - right = "[i] install [u] update [s] scan " + helpText = "[i] install [u] update [s] scan [l] lsp [m] mcp" case tabChat, tabWorkflow: - right = "[1-5] tabs [tab] next [q] quit " + helpText = "[1-5] tabs [tab] next [ctrl+c] quit" case tabAgents: - right = "[c] crush [l] claude " + helpText = "[c] crush [l] claude" default: - right = "[1-5] tabs [tab] next [q] quit " + helpText = "[1-5] tabs [tab] next [ctrl+c] quit" } - - leftR := statusBarStyle.Render(left) - rightR := statusBarStyle.Render(right) + rightR := statusBarStyle.Render(helpText) gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR) if gap < 0 { gap = 0 } - return lipgloss.JoinHorizontal(lipgloss.Bottom, + statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom, leftR, strings.Repeat(" ", gap), rightR, ) + + return lipgloss.JoinVertical(lipgloss.Left, statusLine, + lipgloss.NewStyle().Foreground(dimColor).Render( + lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys)))) } func extractVersion(s string) string { diff --git a/internal/version/version.go b/internal/version/version.go index a456bcc..c93ef96 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,7 +2,7 @@ package version const ( Name = "muyue" - Version = "0.1.0" + Version = "0.2.0" Author = "La LΓ©gion de Muyue" License = "MIT" )