feat: redesign TUI + Ctrl+C quit confirm + version logic + sudo handling
All checks were successful
CI / build (push) Successful in 24s
All checks were successful
CI / build (push) Successful in 24s
- 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 <crush@charm.land>
This commit is contained in:
@@ -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]*')
|
||||
|
||||
|
||||
@@ -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 <goal> 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, ", ")
|
||||
}
|
||||
|
||||
1
go.mod
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@@ -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,6 +173,8 @@ 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
|
||||
@@ -142,6 +196,108 @@ type Model struct {
|
||||
lspServers []lsp.LSPServer
|
||||
mcpConfigured bool
|
||||
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,
|
||||
@@ -178,17 +340,30 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
|
||||
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":
|
||||
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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user