Compare commits

...

2 Commits

Author SHA1 Message Date
Augustin
aa0ff199c6 refactor: remove TUI, desktop web UI is now the default and only mode
All checks were successful
Beta Release / beta (push) Successful in 2m17s
- Remove internal/tui/ entirely (2600+ lines of Bubble Tea code)
- Remove bubbletea, bubbles, lipgloss direct dependencies
- `muyue` now launches desktop web UI (opens browser)
- CLI subcommands preserved (scan, install, setup, etc.)
- Unknown args passed as desktop flags (--port, --no-open)
- Update help text to reflect new default behavior

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:10:31 +02:00
Augustin
34636056da refactor: unify into single muyue binary with embedded desktop mode
All checks were successful
Beta Release / beta (push) Successful in 37s
- Merge muyue + muyue-desktop into one binary (13MB)
- `muyue` starts TUI, `muyue desktop` launches web UI in browser
- Move frontend from cmd/muyue-desktop/frontend/ to web/ (standard Go layout)
- Add web/embed.go with //go:embed all:dist for frontend assets
- Add internal/desktop/ package (server, browser open, SPA routing, signals)
- Split internal/api/api.go into server.go + handlers.go
- Add internal/desktop/desktop.go with SPA fallback and --port/--no-open flags
- Clean package.json: remove unused @xterm/xterm, switch to ESM
- Fix vite.config.js proxy to use port 8095 for dev mode
- Add Makefile targets: frontend, desktop, dev-desktop
- Update all CI workflows: single binary build, web/ paths
- Remove cmd/muyue-desktop/ entirely

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:04:47 +02:00
42 changed files with 332 additions and 2784 deletions

View File

@@ -35,8 +35,8 @@ jobs:
- name: Cache Node modules - name: Cache Node modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: cmd/muyue-desktop/frontend/node_modules path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('cmd/muyue-desktop/frontend/package-lock.json') }} key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
@@ -45,7 +45,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: | run: |
cd cmd/muyue-desktop/frontend cd web
npm ci npm ci
npm run build npm run build
@@ -68,7 +68,7 @@ jobs:
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
echo "Building beta release: ${VERSION}" echo "Building beta release: ${VERSION}"
- name: Build CLI (all platforms) - name: Build (all platforms)
run: | run: |
mkdir -p dist mkdir -p dist
VERSION=${{ steps.version.outputs.version }} VERSION=${{ steps.version.outputs.version }}
@@ -80,12 +80,6 @@ jobs:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/ CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Build Desktop (linux amd64)
run: |
VERSION=${{ steps.version.outputs.version }}
LDFLAGS="-s -w -X github.com/muyue/muyue/internal/version.Prerelease=${VERSION#v}"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-linux-amd64 ./cmd/muyue-desktop/
- name: Package archives - name: Package archives
run: | run: |
cd dist cd dist
@@ -94,10 +88,9 @@ jobs:
tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64 tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64
tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64 tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64
tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64 tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64
tar czf muyue-desktop-linux-amd64.tar.gz muyue-desktop-linux-amd64
zip muyue-windows-amd64.zip muyue-windows-amd64.exe zip muyue-windows-amd64.zip muyue-windows-amd64.exe
zip muyue-windows-arm64.zip muyue-windows-arm64.exe zip muyue-windows-arm64.zip muyue-windows-arm64.exe
rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe muyue-desktop-linux-amd64 rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe
- name: Generate changelog - name: Generate changelog
id: changelog id: changelog

View File

@@ -35,8 +35,8 @@ jobs:
- name: Cache Node modules - name: Cache Node modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: cmd/muyue-desktop/frontend/node_modules path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('cmd/muyue-desktop/frontend/package-lock.json') }} key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
@@ -45,7 +45,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: | run: |
cd cmd/muyue-desktop/frontend cd web
npm ci npm ci
npm run build npm run build
@@ -64,7 +64,7 @@ jobs:
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
echo "Building stable release: ${VERSION}" echo "Building stable release: ${VERSION}"
- name: Build CLI (all platforms) - name: Build (all platforms)
run: | run: |
mkdir -p dist mkdir -p dist
LDFLAGS="-s -w" LDFLAGS="-s -w"
@@ -75,16 +75,6 @@ jobs:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-amd64.exe ./cmd/muyue/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/ CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-windows-arm64.exe ./cmd/muyue/
- name: Build Desktop (all platforms)
run: |
LDFLAGS="-s -w"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-linux-amd64 ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-linux-arm64 ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-darwin-amd64 ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-darwin-arm64 ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-windows-amd64.exe ./cmd/muyue-desktop/
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/muyue-desktop-windows-arm64.exe ./cmd/muyue-desktop/
- name: Package archives - name: Package archives
run: | run: |
cd dist cd dist
@@ -93,15 +83,9 @@ jobs:
tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64 tar czf muyue-linux-arm64.tar.gz muyue-linux-arm64
tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64 tar czf muyue-darwin-amd64.tar.gz muyue-darwin-amd64
tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64 tar czf muyue-darwin-arm64.tar.gz muyue-darwin-arm64
tar czf muyue-desktop-linux-amd64.tar.gz muyue-desktop-linux-amd64
tar czf muyue-desktop-linux-arm64.tar.gz muyue-desktop-linux-arm64
tar czf muyue-desktop-darwin-amd64.tar.gz muyue-desktop-darwin-amd64
tar czf muyue-desktop-darwin-arm64.tar.gz muyue-desktop-darwin-arm64
zip muyue-windows-amd64.zip muyue-windows-amd64.exe zip muyue-windows-amd64.zip muyue-windows-amd64.exe
zip muyue-windows-arm64.zip muyue-windows-arm64.exe zip muyue-windows-arm64.zip muyue-windows-arm64.exe
zip muyue-desktop-windows-amd64.zip muyue-desktop-windows-amd64.exe rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe
zip muyue-desktop-windows-arm64.zip muyue-desktop-windows-arm64.exe
rm -f muyue-linux-amd64 muyue-linux-arm64 muyue-darwin-amd64 muyue-darwin-arm64 muyue-windows-amd64.exe muyue-windows-arm64.exe muyue-desktop-linux-amd64 muyue-desktop-linux-arm64 muyue-desktop-darwin-amd64 muyue-desktop-darwin-arm64 muyue-desktop-windows-amd64.exe muyue-desktop-windows-arm64.exe
- name: Generate changelog - name: Generate changelog
id: changelog id: changelog
@@ -135,16 +119,8 @@ jobs:
echo "| Windows x86_64 | [muyue-windows-amd64.zip](${DL_URL}/muyue-windows-amd64.zip) |" echo "| Windows x86_64 | [muyue-windows-amd64.zip](${DL_URL}/muyue-windows-amd64.zip) |"
echo "| Windows ARM64 | [muyue-windows-arm64.zip](${DL_URL}/muyue-windows-arm64.zip) |" echo "| Windows ARM64 | [muyue-windows-arm64.zip](${DL_URL}/muyue-windows-arm64.zip) |"
echo "" echo ""
echo "### Downloads (Desktop)" echo "The binary includes both CLI and Desktop modes."
echo "" echo "Run \`muyue\` for TUI, \`muyue desktop\` for web UI."
echo "| Platform | File |"
echo "|----------|------|"
echo "| Linux x86_64 | [muyue-desktop-linux-amd64.tar.gz](${DL_URL}/muyue-desktop-linux-amd64.tar.gz) |"
echo "| Linux ARM64 | [muyue-desktop-linux-arm64.tar.gz](${DL_URL}/muyue-desktop-linux-arm64.tar.gz) |"
echo "| macOS Intel | [muyue-desktop-darwin-amd64.tar.gz](${DL_URL}/muyue-desktop-darwin-amd64.tar.gz) |"
echo "| macOS Apple Silicon | [muyue-desktop-darwin-arm64.tar.gz](${DL_URL}/muyue-desktop-darwin-arm64.tar.gz) |"
echo "| Windows x86_64 | [muyue-desktop-windows-amd64.zip](${DL_URL}/muyue-desktop-windows-amd64.zip) |"
echo "| Windows ARM64 | [muyue-desktop-windows-arm64.zip](${DL_URL}/muyue-desktop-windows-arm64.zip) |"
echo "" echo ""
echo "### Install" echo "### Install"
echo "" echo ""

View File

@@ -33,8 +33,8 @@ jobs:
- name: Cache Node modules - name: Cache Node modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: cmd/muyue-desktop/frontend/node_modules path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('cmd/muyue-desktop/frontend/package-lock.json') }} key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
@@ -43,7 +43,7 @@ jobs:
- name: Build frontend - name: Build frontend
run: | run: |
cd cmd/muyue-desktop/frontend cd web
npm ci npm ci
npm run build npm run build
@@ -56,5 +56,4 @@ jobs:
- name: Build - name: Build
run: | run: |
go build -o muyue ./cmd/muyue/ go build -o muyue ./cmd/muyue/
go build -o muyue-desktop ./cmd/muyue-desktop/
./muyue version ./muyue version

4
.gitignore vendored
View File

@@ -28,4 +28,6 @@ vendor/
# Config with secrets # Config with secrets
.muyue/ .muyue/
frontend/node_modules/
# Frontend (web/.gitignore handles specifics)
web/node_modules/

View File

@@ -3,10 +3,16 @@ GOBIN ?= $(GOPATH)/bin
BINARY = muyue BINARY = muyue
BUILD_DIR = . BUILD_DIR = .
GO = go GO = go
NODE ?= node
NPM ?= npm
WEB_DIR = web
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet .PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop
build: frontend:
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
build: frontend
$(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/ $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/
install: build install: build
@@ -18,6 +24,8 @@ install-local: build
clean: clean:
rm -f $(BUILD_DIR)/$(BINARY) rm -f $(BUILD_DIR)/$(BINARY)
rm -rf $(WEB_DIR)/dist
rm -rf $(WEB_DIR)/node_modules
test: test:
$(GO) test ./... -v -count=1 $(GO) test ./... -v -count=1
@@ -31,6 +39,12 @@ vet:
run: build run: build
./$(BINARY) ./$(BINARY)
desktop: build
./$(BINARY) desktop
dev-desktop:
cd $(WEB_DIR) && $(NPM) run dev
scan: build scan: build
./$(BINARY) scan ./$(BINARY) scan
@@ -41,7 +55,7 @@ fmt:
lint: lint:
which golangci-lint > /dev/null 2>&1 && golangci-lint run || true which golangci-lint > /dev/null 2>&1 && golangci-lint run || true
build-all: build-all: frontend
GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY)-linux-amd64 ./cmd/muyue/ GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY)-linux-amd64 ./cmd/muyue/
GOOS=linux GOARCH=arm64 $(GO) build -o dist/$(BINARY)-linux-arm64 ./cmd/muyue/ GOOS=linux GOARCH=arm64 $(GO) build -o dist/$(BINARY)-linux-arm64 ./cmd/muyue/
GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY)-darwin-amd64 ./cmd/muyue/ GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY)-darwin-amd64 ./cmd/muyue/

View File

@@ -1,26 +0,0 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"xterm": "^5.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.9"
}
}

View File

@@ -1,31 +0,0 @@
const API_BASE = '/api'
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
return res.json()
}
export function useAPI() {
return {
getInfo: () => request('/info'),
getSystem: () => request('/system'),
getTools: () => request('/tools'),
getConfig: () => request('/config'),
getProviders: () => request('/providers'),
getSkills: () => request('/skills'),
getLSP: () => request('/lsp'),
getMCP: () => request('/mcp'),
getUpdates: () => request('/updates'),
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
}
}

View File

@@ -1,116 +0,0 @@
package main
import (
"embed"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/muyue/muyue/internal/api"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/version"
)
//go:embed frontend/dist/*
var frontendFS embed.FS
func main() {
if len(os.Args) > 1 && os.Args[1] == "--help" {
fmt.Printf("%s Desktop v%s\n", version.Name, version.Version)
fmt.Println("Usage: muyue-desktop [options]")
fmt.Println()
fmt.Println("Options:")
fmt.Println(" --help Show this help")
fmt.Println(" --port Specify port (default: auto)")
fmt.Println(" --no-open Don't open browser")
return
}
cfg := loadConfig()
srv := api.NewServer(cfg)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatalf("Failed to bind: %v", err)
}
addr := listener.Addr().(*net.TCPAddr)
port := addr.Port
frontendDist, err := fs.Sub(frontendFS, "frontend/dist")
if err != nil {
log.Fatalf("Failed to load frontend: %v", err)
}
fileServer := http.FileServer(http.FS(frontendDist))
mux := http.NewServeMux()
mux.Handle("/api/", srv)
mux.Handle("/", fileServer)
go func() {
log.Printf("%s Desktop v%s", version.Name, version.Version)
log.Printf("Listening on http://127.0.0.1:%d", port)
if err := http.Serve(listener, mux); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
noOpen := false
for _, arg := range os.Args[1:] {
if arg == "--no-open" {
noOpen = true
}
}
if !noOpen {
url := fmt.Sprintf("http://127.0.0.1:%d", port)
openBrowser(url)
log.Printf("Opened %s in browser", url)
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
}
func loadConfig() *config.MuyueConfig {
if !config.Exists() {
fmt.Println("No config found. Run `muyue setup` first.")
os.Exit(1)
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config error: %v\n", err)
os.Exit(1)
}
return cfg
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch {
case commandExists("xdg-open"):
cmd = exec.Command("xdg-open", url)
case commandExists("open"):
cmd = exec.Command("open", url)
case commandExists("cmd"):
cmd = exec.Command("cmd", "/c", "start", url)
default:
fmt.Printf("Open manually: %s\n", url)
return
}
cmd.Start()
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -5,8 +5,8 @@ import (
"os" "os"
"os/exec" "os/exec"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/installer" "github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/mcp"
@@ -14,26 +14,33 @@ import (
"github.com/muyue/muyue/internal/profiler" "github.com/muyue/muyue/internal/profiler"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills" "github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/tui"
"github.com/muyue/muyue/internal/updater" "github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version" "github.com/muyue/muyue/internal/version"
) )
func main() { func main() {
if len(os.Args) > 1 { if len(os.Args) > 1 {
handleCommand(os.Args[1:]) if isCommand(os.Args[1]) {
return handleCommand(os.Args[1:])
return
}
} }
runTUI() runDesktop(os.Args[1:])
}
func isCommand(arg string) bool {
switch arg {
case "version", "-v", "--version",
"scan", "install", "update", "setup",
"config", "doctor", "lsp", "mcp", "skills",
"help", "-h", "--help":
return true
}
return false
} }
func handleCommand(args []string) { func handleCommand(args []string) {
if len(args) == 0 {
runTUI()
return
}
switch args[0] { switch args[0] {
case "version", "-v", "--version": case "version", "-v", "--version":
fmt.Println(version.FullVersion()) fmt.Println(version.FullVersion())
@@ -57,10 +64,6 @@ func handleCommand(args []string) {
runSkills(args[1:]) runSkills(args[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
printHelp() printHelp()
default:
fmt.Printf("Unknown command: %s\n", args[0])
printHelp()
os.Exit(1)
} }
} }
@@ -68,9 +71,13 @@ func printHelp() {
fmt.Printf(`%s - AI-powered development environment assistant fmt.Printf(`%s - AI-powered development environment assistant
Usage: Usage:
muyue Start the interactive TUI muyue Launch desktop app (opens browser)
muyue <command> Run a specific command muyue <command> Run a specific command
Options:
--port=PORT Specify port (default: auto)
--no-open Don't open browser automatically
Commands: Commands:
version Show version version Show version
scan Scan your system for tools and runtimes scan Scan your system for tools and runtimes
@@ -84,35 +91,15 @@ Commands:
skills [list|generate|deploy|init|delete] Manage AI coding skills skills [list|generate|deploy|init|delete] Manage AI coding skills
help Show this help help Show this help
TUI Controls:
Ctrl+T Open tab switcher (navigate with arrows, select with enter)
Tab / Shift+Tab Cycle tabs
Ctrl+C Show quit confirmation (press twice quickly to force quit)
Chat Commands:
/plan <goal> Start a structured Plan→Execute workflow
Workflow Controls:
[a] Approve plan
[r] Reject plan (type feedback)
[g] Generate plan (after answering questions)
[n] Execute next step
[x] Cancel/reset workflow
Note: Note:
Some tools (docker, gh, etc.) require elevated privileges. Some tools (docker, gh, etc.) require elevated privileges.
Run 'sudo muyue install' or use 'pkexec muyue install' if needed. Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
`, version.FullVersion()) `, version.FullVersion())
} }
func runTUI() { func runDesktop(args []string) {
cfg := loadOrSetupConfig() cfg := loadOrSetupConfig()
result := scanner.ScanSystem() if err := desktop.Run(cfg, args); err != nil {
model := tui.NewModel(cfg, result)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }

7
go.mod
View File

@@ -3,10 +3,7 @@ module github.com/muyue/muyue
go 1.24.3 go 1.24.3
require ( require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -14,8 +11,10 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect

2
go.sum
View File

@@ -14,8 +14,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 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 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= 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 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=

View File

@@ -4,9 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os/exec" "os/exec"
"strings"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
@@ -15,50 +13,6 @@ import (
"github.com/muyue/muyue/internal/version" "github.com/muyue/muyue/internal/version"
) )
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
}
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
}
s.scanResult = scanner.ScanSystem()
s.routes()
return s
}
func (s *Server) routes() {
s.mux.HandleFunc("/api/info", s.handleInfo)
s.mux.HandleFunc("/api/system", s.handleSystem)
s.mux.HandleFunc("/api/tools", s.handleTools)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/providers", s.handleProviders)
s.mux.HandleFunc("/api/skills", s.handleSkills)
s.mux.HandleFunc("/api/lsp", s.handleLSP)
s.mux.HandleFunc("/api/mcp", s.handleMCP)
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
s.mux.ServeHTTP(w, r)
}
func writeJSON(w http.ResponseWriter, data interface{}) { func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)
} }
@@ -85,20 +39,19 @@ func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
}) })
} }
type ToolInfo struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Version string `json:"version"`
Path string `json:"path"`
}
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil { if s.scanResult == nil {
s.scanResult = scanner.ScanSystem() s.scanResult = scanner.ScanSystem()
} }
tools := make([]ToolInfo, len(s.scanResult.Tools)) type toolInfo struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Version string `json:"version"`
Path string `json:"path"`
}
tools := make([]toolInfo, len(s.scanResult.Tools))
for i, t := range s.scanResult.Tools { for i, t := range s.scanResult.Tools {
tools[i] = ToolInfo{ tools[i] = toolInfo{
Name: t.Name, Name: t.Name,
Installed: t.Installed, Installed: t.Installed,
Version: t.Version, Version: t.Version,
@@ -117,9 +70,9 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"profile": s.config.Profile, "profile": s.config.Profile,
"terminal": s.config.Terminal, "terminal": s.config.Terminal,
"bmad": s.config.BMAD, "bmad": s.config.BMAD,
}) })
} }
@@ -155,7 +108,7 @@ func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
servers := mcp.ScanServers() servers := mcp.ScanServers()
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"servers": servers, "servers": servers,
"configured": true, "configured": true,
}) })
} }
@@ -165,8 +118,7 @@ func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
writeError(w, "POST only", http.StatusMethodNotAllowed) writeError(w, "POST only", http.StatusMethodNotAllowed)
return return
} }
err := mcp.ConfigureAll(s.config) if err := mcp.ConfigureAll(s.config); err != nil {
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -222,11 +174,6 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
type TermResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed) writeError(w, "POST only", http.StatusMethodNotAllowed)
@@ -240,17 +187,14 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
return return
} }
if body.Command == "" { if body.Command == "" {
writeError(w, "no command", http.StatusBadRequest) writeError(w, "no command", http.StatusBadRequest)
return return
} }
shell := "/bin/sh" shell := "/bin/sh"
if sh := strings.TrimSpace(body.Command); sh != "" { if s, err := exec.LookPath("bash"); err == nil {
if s, err := exec.LookPath("bash"); err == nil { shell = s
shell = s
}
} }
cmd := exec.Command(shell, "-c", body.Command) cmd := exec.Command(shell, "-c", body.Command)
@@ -258,7 +202,12 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
cmd.Dir = body.Cwd cmd.Dir = body.Cwd
} }
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
result := TermResult{Output: string(out)}
type termResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
result := termResult{Output: string(out)}
if err != nil { if err != nil {
result.Error = err.Error() result.Error = err.Error()
} }

52
internal/api/server.go Normal file
View File

@@ -0,0 +1,52 @@
package api
import (
"net/http"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
)
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
}
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
}
s.scanResult = scanner.ScanSystem()
s.routes()
return s
}
func (s *Server) routes() {
s.mux.HandleFunc("/api/info", s.handleInfo)
s.mux.HandleFunc("/api/system", s.handleSystem)
s.mux.HandleFunc("/api/tools", s.handleTools)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/providers", s.handleProviders)
s.mux.HandleFunc("/api/skills", s.handleSkills)
s.mux.HandleFunc("/api/lsp", s.handleLSP)
s.mux.HandleFunc("/api/mcp", s.handleMCP)
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
s.mux.ServeHTTP(w, r)
}

131
internal/desktop/desktop.go Normal file
View File

@@ -0,0 +1,131 @@
package desktop
import (
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/muyue/muyue/internal/api"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/version"
"github.com/muyue/muyue/web"
)
type options struct {
port int
noOpen bool
}
type option func(*options)
func WithPort(port int) option {
return func(o *options) { o.port = port }
}
func WithNoOpen(noOpen bool) option {
return func(o *options) { o.noOpen = noOpen }
}
func parseFlags(args []string) []option {
var opts []option
for _, arg := range args {
switch {
case arg == "--no-open":
opts = append(opts, WithNoOpen(true))
case strings.HasPrefix(arg, "--port="):
if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil {
opts = append(opts, WithPort(p))
}
case arg == "--port":
// handled as prefix case
}
}
return opts
}
func Run(cfg *config.MuyueConfig, args []string) error {
o := options{}
for _, opt := range parseFlags(args) {
opt(&o)
}
log.Printf("%s Desktop v%s", version.Name, version.Version)
srv := api.NewServer(cfg)
frontendFS, err := fs.Sub(web.Assets, "dist")
if err != nil {
return fmt.Errorf("frontend assets: %w", err)
}
mux := http.NewServeMux()
mux.Handle("/api/", srv)
mux.Handle("/", spaHandler(http.FileServer(http.FS(frontendFS))))
addr := fmt.Sprintf("127.0.0.1:%d", o.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("bind %s: %w", addr, err)
}
port := listener.Addr().(*net.TCPAddr).Port
go func() {
if err := http.Serve(listener, mux); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
log.Printf("Listening on %s", url)
if !o.noOpen {
openBrowser(url)
log.Printf("Opened browser")
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
return nil
}
func spaHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path != "/" && !strings.Contains(path, ".") {
r.URL.Path = "/"
}
next.ServeHTTP(w, r)
})
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch {
case exists("xdg-open"):
cmd = exec.Command("xdg-open", url)
case exists("open"):
cmd = exec.Command("open", url)
case exists("cmd"):
cmd = exec.Command("cmd", "/c", "start", url)
default:
fmt.Printf("Open manually: %s\n", url)
return
}
_ = cmd.Start()
}
func exists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -1,115 +0,0 @@
package tui
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
var glitchChars = "!@#$%^&*()_+-=[]{}|;':,./<>?~`"
func init() {
rand.Seed(time.Now().UnixNano())
}
func randomGlitchChar() string {
return string(glitchChars[rand.Intn(len(glitchChars))])
}
func glitchText(text string, intensity int) string {
runes := []rune(text)
for i := 0; i < intensity; i++ {
pos := rand.Intn(len(runes))
if runes[pos] != ' ' && runes[pos] != '\n' {
runes[pos] = []rune(randomGlitchChar())[0]
}
}
return string(runes)
}
func generateScanLine(width int, frame int) string {
pos := frame % width
line := strings.Repeat(" ", pos) +
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(strings.Repeat("━", min(20, width-pos)))
if pos+20 < width {
line += strings.Repeat(" ", width-pos-20)
}
return line[:min(width, len(line))]
}
func typewriterRender(text string, pos int) string {
if pos >= len(text) {
return text
}
if pos <= 0 {
return ""
}
shown := text[:pos]
cursor := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("█")
return shown + cursor
}
func renderGlitchEffect(width, height, frame int) string {
var b strings.Builder
for y := 0; y < height; y++ {
line := ""
for x := 0; x < width; x++ {
if rand.Float64() < 0.15 {
c := randomGlitchChar()
style := lipgloss.NewStyle()
r := rand.Float64()
if r < 0.4 {
style = style.Foreground(cyberRed)
} else if r < 0.7 {
style = style.Foreground(cyberPink)
} else {
style = style.Foreground(textMuted)
}
line += style.Render(c)
} else {
line += " "
}
}
b.WriteString(line)
if y < height-1 {
b.WriteString("\n")
}
}
return b.String()
}
func renderScanEffect(width, height, frame int) string {
var b strings.Builder
scanY := frame % (height + 10)
for y := 0; y < height; y++ {
if y == scanY || y == scanY+1 {
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Render(strings.Repeat("━", width)))
} else if y == scanY-1 || y == scanY+2 {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(strings.Repeat("─", width)))
} else if y < scanY {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf("%*s", width, "")))
} else {
b.WriteString(strings.Repeat(" ", width))
}
if y < height-1 {
b.WriteString("\n")
}
}
return b.String()
}
func generateHexStream(width, lines int) string {
var b strings.Builder
for y := 0; y < lines; y++ {
for x := 0; x < width/3; x++ {
b.WriteString(fmt.Sprintf("%02X", rand.Intn(256)))
}
if y < lines-1 {
b.WriteString("\n")
}
}
return lipgloss.NewStyle().Foreground(dimRed).Render(b.String())
}

View File

@@ -1,108 +0,0 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/workflow"
)
func startInstallCmd(cfg *config.MuyueConfig, tools []string, index int) tea.Cmd {
return tea.Cmd(func() tea.Msg {
inst := installer.New(cfg)
result := inst.InstallTool(tools[index])
if index+1 < len(tools) {
return installBatchMsg{
result: result,
tools: tools,
index: index,
config: cfg,
}
}
return installCompleteMsg{results: []installer.InstallResult{result}}
})
}
func sendAIMessage(orch *orchestrator.Orchestrator, input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
if orch == nil {
return aiErrMsg{err: fmt.Errorf("orchestrator not configured")}
}
resp, err := orch.Send(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func startWorkflowCmd(orch *orchestrator.Orchestrator, goal string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.StartWorkflow(goal)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func workflowChatCmd(orch *orchestrator.Orchestrator, input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
wf := orch.Workflow
switch wf.Phase {
case workflow.PhaseGathering:
resp, err := orch.AnswerQuestion(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
case workflow.PhaseReviewing:
approved, feedback := workflow.ParseApproval(input)
resp, err := orch.ReviewPlan(approved, feedback)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
default:
resp, err := orch.Send(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
}
})
}
func generatePlanCmd(orch *orchestrator.Orchestrator) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.GeneratePlan()
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func reviewPlanCmd(orch *orchestrator.Orchestrator, approved bool, feedback string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.ReviewPlan(approved, feedback)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func continueWorkflowCmd(orch *orchestrator.Orchestrator, output string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.ContinueExecution(output)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}

View File

@@ -1,114 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderConfig() string {
colWidth := m.width / 2
if colWidth < 30 {
colWidth = 30
}
var left, right strings.Builder
left.WriteString(renderSectionHeader("PROFILE", "[@]"))
left.WriteString("\n")
if m.config != nil {
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 {
left.WriteString(fmt.Sprintf(" %s %s\n",
labelStyle.Render(f.label+":"),
valueStyle.Render(f.value)))
}
if len(m.config.Profile.Languages) > 0 {
left.WriteString(fmt.Sprintf(" %s %s\n",
labelStyle.Render("Languages:"),
valueStyle.Render(strings.Join(m.config.Profile.Languages, ", "))))
}
}
left.WriteString("\n")
left.WriteString(renderSectionHeader("AI PROVIDERS", "[AI]"))
left.WriteString("\n")
if m.config != nil {
for _, p := range m.config.AI.Providers {
active := ""
if p.Active {
active = lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" >>")
}
keyStatus := itemMissingStyle.Render("no key")
if p.APIKey != "" {
keyStatus = itemOKStyle.Render("configured")
}
nameStyle := lipgloss.NewStyle().Foreground(textBright).Bold(true)
left.WriteString(fmt.Sprintf(" %s %s key=%s%s\n",
nameStyle.Render(p.Name),
lipgloss.NewStyle().Foreground(dimRed).Render("model="+p.Model),
keyStatus, active))
}
}
left.WriteString("\n")
right.WriteString(renderSectionHeader("TERMINAL", "[$]"))
right.WriteString("\n")
if m.config != nil {
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Custom Prompt:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Terminal.CustomPrompt))))
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Prompt Theme:"), valueStyle.Render(m.config.Terminal.PromptTheme)))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Auto Update:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.AutoUpdate))))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Check on Start:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.CheckOnStart))))
}
right.WriteString("\n")
right.WriteString(renderSectionHeader("BMAD METHOD", "[B]"))
right.WriteString("\n")
if m.config != nil {
installed := itemMissingStyle.Render("[--] no")
if m.config.BMAD.Installed {
installed = itemOKStyle.Render("[OK] yes")
}
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Installed:"), installed))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Global:"), valueStyle.Render(fmt.Sprintf("%v", m.config.BMAD.Global))))
if m.config.BMAD.Version != "" {
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Version:"), valueStyle.Render(m.config.BMAD.Version)))
}
}
right.WriteString("\n")
right.WriteString(renderSectionHeader(fmt.Sprintf("SKILLS (%d)", len(m.skillList)), "[!]"))
right.WriteString("\n")
if len(m.skillList) > 0 {
for _, s := range m.skillList {
target := s.Target
if target == "" {
target = "both"
}
right.WriteString(fmt.Sprintf(" %s %s %s\n",
lipgloss.NewStyle().Foreground(textMain).Render(s.Name),
lipgloss.NewStyle().Foreground(cyberRed).Render("["+target+"]"),
lipgloss.NewStyle().Foreground(dimRed).Render(s.Description)))
}
} else {
right.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(" No skills. Run `muyue skills init`."))
right.WriteString("\n")
}
leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
}

View File

@@ -1,176 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderDashboard() string {
colWidth := m.width / 2
if colWidth < 30 {
colWidth = 30
}
var left, right strings.Builder
left.WriteString(renderSectionHeader("SYSTEM", "[*]"))
left.WriteString("\n")
if m.scanResult != nil {
sysInfo := m.scanResult.System.String()
left.WriteString(" ")
left.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(sysInfo))
}
left.WriteString("\n\n")
left.WriteString(renderSectionHeader("INSTALLED TOOLS", "[+]"))
left.WriteString("\n")
if m.scanResult != nil {
installed := 0
total := len(m.scanResult.Tools)
for _, t := range m.scanResult.Tools {
if t.Installed {
installed++
left.WriteString(" ")
left.WriteString(itemOKStyle.Render("[OK] "))
left.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(t.Name))
left.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %s", extractVersion(t.Version))))
left.WriteString("\n")
} else {
left.WriteString(" ")
left.WriteString(itemMissingStyle.Render("[--] "))
left.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(t.Name))
left.WriteString(itemPendingStyle.Render(" (missing)"))
left.WriteString("\n")
}
}
barWidth := 20
pct := 0
if total > 0 {
pct = (installed * barWidth) / total
}
bar := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(strings.Repeat("█", pct)) +
lipgloss.NewStyle().Foreground(dimRed).Render(strings.Repeat("░", barWidth-pct))
left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total))
}
left.WriteString("\n")
if m.installing {
left.WriteString(renderSectionHeader("INSTALLING", "[~]"))
left.WriteString("\n")
progBar := m.progressBar.View()
label := ""
if m.installTool != "" {
label = fmt.Sprintf(" %d/%d - %s", m.installCurrent+1, m.installTotal, m.installTool)
} else {
label = fmt.Sprintf(" %d/%d", m.installCurrent, m.installTotal)
}
left.WriteString(fmt.Sprintf(" %s%s\n", progBar, label))
left.WriteString("\n")
}
if len(m.installLog) > 0 {
left.WriteString(renderSectionHeader("INSTALL LOG", "[#]"))
left.WriteString("\n")
for _, l := range m.installLog {
left.WriteString(l + "\n")
}
left.WriteString("\n")
}
right.WriteString(renderSectionHeader("QUICK ACTIONS", "[!]"))
right.WriteString("\n")
actions := []struct {
key string
desc string
color lipgloss.Color
}{
{"i", "Install missing tools", cyberRed},
{"u", "Check for updates", neonRed},
{"s", "Rescan system", cyberPink},
{"l", "Scan LSP servers", cyberRose},
{"m", "Configure MCP servers", brightRed},
}
for _, a := range actions {
right.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(a.color).Bold(true).Render("["+a.key+"]"),
lipgloss.NewStyle().Foreground(textMain).Render(a.desc)))
}
right.WriteString("\n")
right.WriteString(renderSectionHeader("ACTIVE AGENTS", "[*]"))
right.WriteString("\n")
agents := []struct {
name string
}{
{"Crush"},
{"Claude Code"},
}
for _, a := range agents {
right.WriteString(" ")
right.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(">> "))
right.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(a.name + " "))
right.WriteString(itemPendingStyle.Render("[stopped]"))
right.WriteString("\n")
}
right.WriteString("\n")
if len(m.updateStatus) > 0 {
right.WriteString(renderSectionHeader("UPDATES", "[^]"))
right.WriteString("\n")
for _, s := range m.updateStatus {
if s.NeedsUpdate {
right.WriteString(" ")
right.WriteString(itemWarnStyle.Render("[!!] "))
right.WriteString(fmt.Sprintf("%s: %s -> %s\n", s.Tool, s.Current, s.Latest))
} else if s.Error == "" {
right.WriteString(" ")
right.WriteString(itemOKStyle.Render("[OK] "))
right.WriteString(fmt.Sprintf("%s: up to date\n", s.Tool))
}
}
right.WriteString("\n")
}
if len(m.lspServers) > 0 {
right.WriteString(renderSectionHeader("LSP SERVERS", "[L]"))
right.WriteString("\n")
lspInstalled := 0
for _, s := range m.lspServers {
if s.Installed {
lspInstalled++
right.WriteString(" ")
right.WriteString(itemOKStyle.Render("[OK] "))
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
} else {
right.WriteString(" ")
right.WriteString(itemPendingStyle.Render("[ ] "))
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
}
}
right.WriteString(fmt.Sprintf("\n %d/%d available\n", lspInstalled, len(m.lspServers)))
right.WriteString("\n")
}
mcpStatus := itemPendingStyle.Render("[ ] not configured")
if m.mcpConfigured {
mcpStatus = itemOKStyle.Render("[OK] configured")
}
right.WriteString(fmt.Sprintf(" MCP: %s\n", mcpStatus))
if m.daemon != nil {
daemonStatus := itemPendingStyle.Render("[ ] stopped")
if m.daemon.IsRunning() {
daemonStatus = itemOKStyle.Render("[OK] running")
}
right.WriteString(fmt.Sprintf(" Daemon: %s\n", daemonStatus))
}
leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
}

View File

@@ -1,74 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/version"
)
func (m Model) renderFooter() string {
profile := "unknown"
if m.config != nil && m.config.Profile.Pseudo != "" {
profile = m.config.Profile.Pseudo
}
left := fmt.Sprintf(" %s@%s",
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(profile),
lipgloss.NewStyle().Foreground(dimRed).Render(version.Name))
leftR := statusBarStyle.Render(left)
var helpText string
switch m.activeTab {
case tabDashboard:
helpText = "[i] install [u] update [s] scan [ctrl+t] tabs"
case tabStudio:
helpText = "[enter] send [ctrl+s] sidebar [ctrl+t] tabs"
case tabShell:
helpText = "[enter] run [ctrl+a] AI panel [ctrl+c] kill"
case tabConfig:
helpText = "[up/down] sections [ctrl+t] tabs"
default:
helpText = "[ctrl+t] tabs [ctrl+c] quit"
}
rightR := statusBarStyle.Render(helpText)
updateIndicator := ""
if len(m.updateStatus) > 0 {
needsUpdate := false
for _, s := range m.updateStatus {
if s.NeedsUpdate {
needsUpdate = true
break
}
}
if needsUpdate {
updateIndicator = lipgloss.NewStyle().Foreground(warnAmber).Render(" [UPD] ")
} else {
updateIndicator = lipgloss.NewStyle().Foreground(successGreen).Render(" [OK] ")
}
}
verStr := lipgloss.NewStyle().Foreground(dimRed).Render("v" + version.Version)
midContent := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
updateIndicator + verStr,
)
gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR) - lipgloss.Width(midContent)
if gap < 0 {
gap = 0
}
statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
leftR,
strings.Repeat(" ", gap),
midContent,
rightR,
)
helpLine := lipgloss.NewStyle().Background(bgSurface).Foreground(textMuted).Render(
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys)))
return lipgloss.JoinVertical(lipgloss.Left, statusLine, helpLine)
}

View File

@@ -1,360 +0,0 @@
package tui
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/workflow"
)
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.showingQuit {
return m.handleQuitConfirm(msg)
}
if m.showingTabMenu {
return m.handleTabMenu(msg)
}
if m.activeTab == tabShell {
return m.handleShellKey(msg)
}
switch msg.String() {
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 "ctrl+t":
m.showingTabMenu = true
m.tabMenuCursor = int(m.activeTab)
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+s":
if m.activeTab == tabStudio {
m.studioSidebarOpen = !m.studioSidebarOpen
m.viewport.SetContent(m.renderContent())
}
case "enter":
if m.activeTab == tabStudio && m.chatInput != "" && !m.chatLoading {
return m.handleChatSubmit()
}
case "backspace":
if m.activeTab == tabStudio && len(m.chatInput) > 0 {
m.chatInput = m.chatInput[:len(m.chatInput)-1]
m.viewport.SetContent(m.renderContent())
}
default:
if m.activeTab == tabStudio && len(msg.String()) == 1 && !m.chatLoading {
m.chatInput += msg.String()
m.viewport.SetContent(m.renderContent())
}
}
if m.activeTab == tabDashboard {
return m.handleDashboardKey(msg)
}
if m.activeTab == tabStudio {
return m.handleStudioKey(msg)
}
return m, nil
}
func cleanup(m Model) {
if m.daemon != nil {
m.daemon.Stop()
}
if m.previewSrv != nil {
m.previewSrv.Stop()
}
for _, agentType := range []proxy.AgentType{proxy.AgentCrush, proxy.AgentClaude} {
m.proxyMgr.Stop(agentType)
}
}
func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "y", "Y", "o", "O":
m.showingQuit = false
cleanup(m)
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
cleanup(m)
return m, tea.Quit
}
m.showingQuit = false
m.ctrlCCount = 0
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+c":
m.showingQuit = false
cleanup(m)
return m, tea.Quit
}
return m, nil
}
func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "esc":
m.showingTabMenu = false
m.viewport.SetContent(m.renderContent())
return m, nil
case "up", "k":
if m.tabMenuCursor > 0 {
m.tabMenuCursor--
}
return m, nil
case "down", "j":
if m.tabMenuCursor < int(tabCount)-1 {
m.tabMenuCursor++
}
return m, nil
case "enter":
m.switchTab(tab(m.tabMenuCursor))
m.showingTabMenu = false
return m, nil
default:
for i := 0; i < int(tabCount); i++ {
if msg.String() == fmt.Sprintf("%d", i+1) {
m.switchTab(tab(i))
m.showingTabMenu = false
return m, nil
}
}
}
return m, nil
}
func (m *Model) switchTab(t tab) {
if t == m.activeTab {
return
}
m.prevTab = m.activeTab
m.activeTab = t
m.transition = transitionGlitch
m.transitionTick = 0
m.resizeViewport()
}
func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "i":
if m.installing {
return m, nil
}
var missing []string
if m.scanResult != nil {
for _, t := range m.scanResult.Tools {
if !t.Installed {
missing = append(missing, t.Name)
}
}
}
if len(missing) == 0 {
m.installLog = append(m.installLog, itemOKStyle.Render("[OK] All tools already installed!"))
m.viewport.SetContent(m.renderContent())
return m, nil
}
needsSudo := checkNeedsSudo(m.scanResult)
if needsSudo && !hasSudo() {
m.installLog = append(m.installLog, errMsgStyle.Render("[!!] Some tools require sudo. Run: sudo muyue install"))
m.viewport.SetContent(m.renderContent())
return m, nil
}
m.installing = true
m.installCurrent = 0
m.installTotal = len(missing)
m.installTool = missing[0]
m.progressBar.SetPercent(0)
m.viewport.SetContent(m.renderContent())
return m, startInstallCmd(m.config, missing, 0)
case "u":
return m, tea.Cmd(func() tea.Msg {
result := scanner.ScanSystem()
return updateCheckMsg{statuses: updater.CheckUpdates(result)}
})
case "s":
return m, tea.Cmd(func() tea.Msg {
return scanCompleteMsg{result: scanner.ScanSystem()}
})
case "l":
return m, tea.Cmd(func() tea.Msg {
servers := lsp.ScanServers()
return lspScanMsg{servers: servers}
})
case "m":
return m, tea.Cmd(func() tea.Msg {
err := mcp.ConfigureAll(m.config)
return mcpConfigMsg{err: err}
})
}
return m, nil
}
func (m Model) handleStudioKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if !m.studioSidebarOpen {
return m, nil
}
switch msg.String() {
case "1":
m.studioPanel = panelChat
m.viewport.SetContent(m.renderContent())
case "2":
m.studioPanel = panelAgents
m.viewport.SetContent(m.renderContent())
case "3":
m.studioPanel = panelWorkflows
m.viewport.SetContent(m.renderContent())
}
if m.studioPanel == panelAgents {
return m.handleAgentsKey(msg)
}
if m.studioPanel == panelWorkflows {
return m.handleWorkflowKey(msg)
}
return m, nil
}
func (m Model) handleAgentsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "c":
if m.proxyMgr.IsAvailable(proxy.AgentCrush) {
m.proxyMgr.Start(proxy.AgentCrush)
}
m.viewport.SetContent(m.renderContent())
case "l":
if m.proxyMgr.IsAvailable(proxy.AgentClaude) {
m.proxyMgr.Start(proxy.AgentClaude)
}
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.orch == nil || m.orch.Workflow == nil {
return m, nil
}
wf := m.orch.Workflow
switch msg.String() {
case "a":
if wf.Phase == workflow.PhaseReviewing {
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> [Plan approved]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, reviewPlanCmd(m.orch, true, "")
}
case "r":
if wf.Phase == workflow.PhaseReviewing {
m.chatInput = ""
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Type your rejection feedback below:"))
m.viewport.SetContent(m.renderContent())
}
case "g":
if wf.Phase == workflow.PhaseGathering && len(wf.Plan.Questions) > 0 && len(wf.Plan.Answers) >= len(wf.Plan.Questions) {
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> [Generate plan]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, generatePlanCmd(m.orch)
}
case "n":
if wf.Phase == workflow.PhaseExecuting {
current := wf.CurrentStep()
if current != nil {
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, continueWorkflowCmd(m.orch, "proceeding")
}
}
case "x":
wf.Reset()
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Workflow reset."))
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func checkNeedsSudo(scan *scanner.ScanResult) bool {
if scan == nil {
return false
}
sudoTools := map[string]bool{
"docker": true, "git": true, "gh": true, "node": true, "python3": 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 (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
input := m.chatInput
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> "+input))
m.chatInput = ""
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
if strings.HasPrefix(input, "/plan ") {
goal := strings.TrimPrefix(input, "/plan ")
return m, startWorkflowCmd(m.orch, goal)
}
if m.orch != nil && m.orch.Workflow != nil && m.orch.Workflow.Phase != workflow.PhaseIdle {
return m, workflowChatCmd(m.orch, input)
}
return m, sendAIMessage(m.orch, input)
}

View File

@@ -1,178 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/version"
)
func (m Model) renderHeader() string {
var tabs []string
for i, name := range tabNames {
icon := tabIcons[i]
if tab(i) == m.activeTab {
tabStyle := lipgloss.NewStyle().
Background(cyberRed).
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
} else {
tabStyle := lipgloss.NewStyle().
Background(bgSurface).
Foreground(textDim).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
}
}
tabLine := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...))
timeStr := ""
if !m.currentTime.IsZero() {
timeStr = m.currentTime.Format("15:04:05")
}
dateStr := ""
if !m.currentTime.IsZero() {
dateStr = m.currentTime.Format("02/01/2006")
}
rightInfo := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center,
lipgloss.NewStyle().Foreground(textDim).Render(dateStr+" "),
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(timeStr),
lipgloss.NewStyle().Foreground(textMuted).Render(" "+getAnimFrame(m.animationFrame)),
),
)
statusDots := ""
if m.config != nil {
hasAI := false
for _, p := range m.config.AI.Providers {
if p.Active && p.APIKey != "" {
hasAI = true
break
}
}
if hasAI {
statusDots += lipgloss.NewStyle().Foreground(successGreen).Render("●")
} else {
statusDots += lipgloss.NewStyle().Foreground(errorRed).Render("●")
}
} else {
statusDots += lipgloss.NewStyle().Foreground(warnAmber).Render("●")
}
statusDots += lipgloss.NewStyle().Foreground(textMuted).Render(" ")
if m.mcpConfigured {
statusDots += lipgloss.NewStyle().Foreground(successGreen).Render("●")
} else {
statusDots += lipgloss.NewStyle().Foreground(warnAmber).Render("●")
}
statusInfo := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center,
lipgloss.NewStyle().Foreground(textDim).Render("SYS "),
statusDots,
),
)
badge := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("MUYUE")
versionBadge := lipgloss.NewStyle().Foreground(dimRed).Render("v" + version.Version)
logoLine := lipgloss.NewStyle().Background(bgVoid).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center, badge, " ", versionBadge),
)
topLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
logoLine,
strings.Repeat(" ", max(0, m.width-lipgloss.Width(logoLine)-lipgloss.Width(rightInfo)-lipgloss.Width(statusInfo))),
statusInfo,
rightInfo,
)
return lipgloss.JoinVertical(lipgloss.Left, topLine, tabLine)
}
func (m Model) renderTabMenuOverlay() string {
menuStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Background(bgCard).
Padding(1, 3)
tabItemStyle := lipgloss.NewStyle().
Foreground(textDim).
Padding(0, 2)
tabItemActiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(cyberRed).
Bold(true).
Padding(0, 2)
descs := []string{
"tools, updates & system status",
"chat, agents & workflows",
"terminal + AI assistant",
"profile, API keys & settings",
}
var items []string
for i, name := range tabNames {
num := lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %d.", i+1))
icon := tabIcons[i] + " "
if i == m.tabMenuCursor {
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(cyberRose).Render(descs[i]))
items = append(items, tabItemActiveStyle.Render(">"+item))
} else {
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(textMuted).Render(descs[i]))
items = append(items, tabItemStyle.Render(" "+item))
}
}
header := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("SWITCH TAB")
content := header + "\n\n" +
strings.Join(items, "\n") +
"\n\n" +
lipgloss.NewStyle().Foreground(textMuted).Render("up/down navigate | enter select | esc cancel")
box := menuStyle.Render(content)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
box,
lipgloss.WithWhitespaceBackground(bgVoid),
lipgloss.WithWhitespaceForeground(textMuted),
)
}
func (m Model) renderQuitOverlay() string {
yesStyle := confirmNoStyle
noStyle := confirmYesStyle
if m.confirmCursor == 0 {
yesStyle = confirmYesStyle
noStyle = confirmNoStyle
}
frame := lipgloss.NewStyle().Foreground(cyberRed).Render(getAnimFrame(m.animationFrame))
box := fmt.Sprintf("\n\n %s Quit muyue?\n\n %s %s",
frame,
yesStyle.Render("[ Yes ]"),
noStyle.Render("[ No ]"),
)
content := confirmBoxStyle.Render(box)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
content,
lipgloss.WithWhitespaceBackground(bgVoid),
lipgloss.WithWhitespaceForeground(textMuted),
)
}

View File

@@ -1,19 +0,0 @@
package tui
import (
"regexp"
"github.com/muyue/muyue/internal/workflow"
)
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func extractVersion(s string) string {
return versionRegex.FindString(s)
}
type previewFile = workflow.PreviewFile
func parsePreviewFiles(response string) []previewFile {
return workflow.ParsePreviewFiles(response)
}

View File

@@ -1,395 +0,0 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/daemon"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/preview"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
)
func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
orch, _ := orchestrator.New(cfg)
proxyMgr := proxy.NewManager()
d := daemon.NewDaemon(cfg, 1*time.Hour)
lspServers := lsp.ScanServers()
skillList, _ := skills.List()
mcpConfigured := false
if err := mcp.ConfigureAll(cfg); err == nil {
mcpConfigured = true
}
if cfg.Profile.Preferences.AutoUpdate {
d.Start()
}
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(cyberRed)
prog := progress.New(progress.WithGradient("#FF0033", "#FF1A5E"))
cwd, _ := os.Getwd()
return Model{
config: cfg,
scanResult: scan,
activeTab: tabDashboard,
chatLog: []string{
aiMsgStyle.Render(" >> Welcome to Studio! Chat with your AI assistant here."),
aiMsgStyle.Render(" >> Configure agents and workflows from the sidebar. Type /plan <goal> to start."),
},
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,
showingTabMenu: false,
tabMenuCursor: 0,
termCwd: cwd,
studioPanel: panelChat,
studioSidebarOpen: true,
termAIChat: []string{
aiMsgStyle.Render(" >> I know your system inside out. Ask me anything."),
},
termAIShow: true,
configSection: configProfile,
configField: 0,
animationFrame: 0,
currentTime: time.Now(),
transition: transitionNone,
}
}
func animTick() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return animTickMsg{time: t}
})
}
func clockTick() tea.Cmd {
return tea.Tick(1*time.Second, func(t time.Time) tea.Msg {
return clockTickMsg{time: t}
})
}
func (m Model) Init() tea.Cmd {
return tea.Batch(spinner.Tick, animTick(), clockTick(), 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 animTickMsg:
m.animationFrame++
if m.transition == transitionGlitch {
m.transitionTick++
if m.transitionTick > 5 {
m.transition = transitionScan
m.transitionTick = 0
}
} else if m.transition == transitionScan {
m.transitionTick++
if m.transitionTick > 8 {
m.transition = transitionTypewriter
m.transitionTick = 0
m.typewriterBuf = m.renderContent()
m.typewriterPos = 0
}
} else if m.transition == transitionTypewriter {
m.typewriterPos += 3
if m.typewriterPos >= len(m.typewriterBuf) {
m.transition = transitionNone
}
}
return m, animTick()
case clockTickMsg:
m.currentTime = msg.time
return m, clockTick()
case progress.FrameMsg:
pm, cmd := m.progressBar.Update(msg)
m.progressBar = pm.(progress.Model)
return m, cmd
case termOutputMsg:
m.termLog = append(m.termLog, msg.line)
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
return m, nil
case termExitMsg:
m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render("(process exited)"))
m.termCmd = nil
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
}
return m, nil
case aiResponseMsg:
m.chatLoading = false
m.termAILoading = false
content := msg.content
if m.activeTab == tabShell && m.termAIShow {
m.termAIChat = append(m.termAIChat, aiMsgStyle.Render(" "+content))
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
} else {
m.chatLog = append(m.chatLog, aiMsgStyle.Render(" "+content))
if m.orch != nil && m.orch.Workflow != nil {
previewFiles := parsePreviewFiles(content)
if len(previewFiles) > 0 {
m.handlePreview(previewFiles)
}
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
return m, nil
case aiErrMsg:
m.chatLoading = false
m.termAILoading = false
errText := errMsgStyle.Render(" [ERROR] " + msg.err.Error())
if m.activeTab == tabShell && m.termAIShow {
m.termAIChat = append(m.termAIChat, errText)
} else {
m.chatLog = append(m.chatLog, errText)
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
case scanCompleteMsg:
m.scanResult = msg.result
m.viewport.SetContent(m.renderContent())
return m, nil
case installCompleteMsg:
m.installing = false
for _, r := range msg.results {
status := itemOKStyle.Render("[OK]")
if !r.Success {
status = itemMissingStyle.Render("[--]")
}
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message))
}
m.scanResult = scanner.ScanSystem()
m.progressBar.SetPercent(1)
m.viewport.SetContent(m.renderContent())
return m, nil
case installProgressMsg:
status := itemOKStyle.Render("[OK]")
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool))
m.installCurrent = msg.current
m.installTool = ""
pct := float64(msg.current) / float64(max(msg.total, 1))
m.progressBar.SetPercent(pct)
m.viewport.SetContent(m.renderContent())
return m, nil
case installBatchMsg:
status := itemOKStyle.Render("[OK]")
if !msg.result.Success {
status = itemMissingStyle.Render("[--]")
}
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message))
m.installCurrent = msg.index + 1
m.installTotal = len(msg.tools)
pct := float64(m.installCurrent) / float64(max(m.installTotal, 1))
m.progressBar.SetPercent(pct)
if msg.index+1 < len(msg.tools) {
m.installTool = msg.tools[msg.index+1]
m.viewport.SetContent(m.renderContent())
return m, startInstallCmd(msg.config, msg.tools, msg.index+1)
}
m.installing = false
m.scanResult = scanner.ScanSystem()
m.viewport.SetContent(m.renderContent())
return m, nil
case updateCheckMsg:
m.updateStatus = msg.statuses
m.viewport.SetContent(m.renderContent())
return m, nil
case previewReadyMsg:
m.previewURL = msg.url
m.viewport.SetContent(m.renderContent())
return m, nil
case lspScanMsg:
m.lspServers = msg.servers
m.viewport.SetContent(m.renderContent())
return m, nil
case mcpConfigMsg:
if msg.err == nil {
m.mcpConfigured = true
}
m.viewport.SetContent(m.renderContent())
return m, nil
case daemonLogMsg:
m.viewport.SetContent(m.renderContent())
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.helpModel.Width = msg.Width
headerH := 2
footerH := 2
inputH := 0
if m.activeTab == tabStudio || m.activeTab == tabShell {
inputH = 2
}
contentH := msg.Height - headerH - footerH - inputH
if contentH < 1 {
contentH = 1
}
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
}
return m, nil
}
func (m Model) View() string {
if !m.ready {
return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("Initializing muyue...")
}
if m.showingQuit {
return m.renderQuitOverlay()
}
if m.showingTabMenu {
return m.renderTabMenuOverlay()
}
if m.transition == transitionGlitch {
return renderGlitchEffect(m.width, m.height, m.transitionTick)
}
if m.transition == transitionScan {
return renderScanEffect(m.width, m.height, m.transitionTick)
}
if m.transition == transitionTypewriter {
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(typewriterRender(m.typewriterBuf, m.typewriterPos))
if m.activeTab == tabStudio {
b.WriteString("\n")
b.WriteString(m.renderStudioInput())
}
if m.activeTab == tabShell {
b.WriteString("\n")
b.WriteString(m.renderShellInput())
}
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(m.viewport.View())
if m.activeTab == tabStudio {
b.WriteString("\n")
b.WriteString(m.renderStudioInput())
}
if m.activeTab == tabShell {
b.WriteString("\n")
b.WriteString(m.renderShellInput())
}
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
func (m Model) renderContent() string {
switch m.activeTab {
case tabDashboard:
return m.renderDashboard()
case tabStudio:
return m.renderStudio()
case tabShell:
return m.renderShell()
case tabConfig:
return m.renderConfig()
default:
return ""
}
}
func (m *Model) resizeViewport() {
headerH := 2
footerH := 2
inputH := 0
if m.activeTab == tabStudio || m.activeTab == tabShell {
inputH = 2
}
contentH := m.height - headerH - footerH - inputH
if contentH < 1 {
contentH = 1
}
m.viewport = viewport.New(m.width, contentH)
m.viewport.Width = m.width
m.viewport.Height = contentH
m.viewport.SetContent(m.renderContent())
}
func (m *Model) handlePreview(files []previewFile) {
dir := filepath.Join(os.TempDir(), "muyue-preview")
os.RemoveAll(dir)
os.MkdirAll(dir, 0755)
for _, f := range files {
preview.CreatePreviewFile(dir, f.Filename, f.Content)
}
if m.previewSrv != nil {
m.previewSrv.Stop()
}
m.previewSrv = preview.NewPreviewServer(dir)
if err := m.previewSrv.Start(8765); err != nil {
m.chatLog = append(m.chatLog, errMsgStyle.Render(" preview error: "+err.Error()))
} else {
m.previewURL = "http://127.0.0.1:8765"
m.chatLog = append(m.chatLog, itemOKStyle.Render(" Preview: http://127.0.0.1:8765"))
}
}

View File

@@ -1,253 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/workflow"
)
func (m Model) renderStudio() string {
if m.studioSidebarOpen {
sidebarWidth := 28
chatWidth := m.width - sidebarWidth - 2
if chatWidth < 20 {
chatWidth = 20
sidebarWidth = m.width - chatWidth - 2
}
sidebar := m.renderStudioSidebar(sidebarWidth)
chat := m.renderStudioChat(chatWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, chat)
}
return m.renderStudioChat(m.width)
}
func (m Model) renderStudioSidebar(width int) string {
var b strings.Builder
b.WriteString(renderSectionHeader("STUDIO", "[<>]"))
b.WriteString("\n\n")
panels := []struct {
name string
panel studioPanel
icon string
}{
{"Chat", panelChat, "[#]"},
{"Agents", panelAgents, "[*]"},
{"Workflows", panelWorkflows, "[~]"},
}
for _, p := range panels {
if m.studioPanel == p.panel {
activeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(cyberRed).
Bold(true).
Padding(0, 1)
b.WriteString(activeStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
} else {
inactiveStyle := lipgloss.NewStyle().
Foreground(textDim).
Padding(0, 1)
b.WriteString(inactiveStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", width-4)))
b.WriteString("\n\n")
switch m.studioPanel {
case panelAgents:
m.renderAgentsSidebar(&b, width)
case panelWorkflows:
m.renderWorkflowSidebar(&b, width)
default:
m.renderChatSidebar(&b, width)
}
return sidebarStyle.Width(width).Render(b.String())
}
func (m Model) renderChatSidebar(b *strings.Builder, width int) {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Active Provider"))
b.WriteString("\n")
provider := "none"
if m.config != nil {
provider = m.config.Profile.Preferences.DefaultAI
}
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" " + provider))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Commands"))
b.WriteString("\n")
cmds := []string{"/plan <goal>", "/help"}
for _, c := range cmds {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(" " + c))
b.WriteString("\n")
}
if m.previewURL != "" {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Preview"))
b.WriteString("\n")
b.WriteString(itemOKStyle.Render(" " + m.previewURL))
b.WriteString("\n")
}
}
func (m Model) renderAgentsSidebar(b *strings.Builder, width int) {
agents := []struct {
name string
agentType proxy.AgentType
tool string
}{
{"Crush", proxy.AgentCrush, "GLM"},
{"Claude Code", proxy.AgentClaude, "Anthropic"},
}
for _, a := range agents {
status, _ := m.proxyMgr.Status(a.agentType)
available := m.proxyMgr.IsAvailable(a.agentType)
var statusIcon string
switch status {
case proxy.StatusRunning:
statusIcon = lipgloss.NewStyle().Foreground(neonRed).Render("[>> running]")
case proxy.StatusStopped:
statusIcon = lipgloss.NewStyle().Foreground(textMuted).Render("[|| stopped]")
case proxy.StatusError:
statusIcon = lipgloss.NewStyle().Foreground(errorRed).Render("[!! error]")
default:
if available {
statusIcon = lipgloss.NewStyle().Foreground(successGreen).Render("[OK available]")
} else {
statusIcon = lipgloss.NewStyle().Foreground(textMuted).Render("[-- not installed]")
}
}
b.WriteString(lipgloss.NewStyle().Foreground(textBright).Bold(true).Render(a.name))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", statusIcon))
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %s\n", a.tool)))
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Actions"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" [c]"))
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(" Start Crush"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" [l]"))
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(" Start Claude"))
b.WriteString("\n")
}
func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
if m.orch == nil || m.orch.Workflow == nil {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("No active workflow."))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render("Use /plan <goal> in chat"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render("to start a workflow."))
b.WriteString("\n")
return
}
wf := m.orch.Workflow
phaseColors := map[workflow.Phase]lipgloss.Style{
workflow.PhaseIdle: lipgloss.NewStyle().Foreground(textMuted),
workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warnAmber).Bold(true),
workflow.PhasePlanning: lipgloss.NewStyle().Foreground(cyberPink).Bold(true),
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(cyberRose).Bold(true),
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(cyberRed).Bold(true),
workflow.PhaseDone: lipgloss.NewStyle().Foreground(successGreen).Bold(true),
workflow.PhaseError: lipgloss.NewStyle().Foreground(errorRed).Bold(true),
}
if style, ok := phaseColors[wf.Phase]; ok {
b.WriteString(style.Render(string(wf.Phase)))
}
b.WriteString("\n\n")
if wf.Plan.Goal != "" {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Goal"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(wf.Plan.Goal))
b.WriteString("\n\n")
}
if wf.Phase == workflow.PhaseExecuting {
done, total := wf.Progress()
m.progressBar.SetPercent(float64(done) / float64(max(total, 1)))
b.WriteString(m.progressBar.View())
b.WriteString(fmt.Sprintf(" %d/%d", done, total))
b.WriteString("\n\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Controls"))
b.WriteString("\n")
controls := []struct {
key string
desc string
}{
{"[a]", "Approve plan"},
{"[r]", "Reject plan"},
{"[g]", "Generate plan"},
{"[n]", "Next step"},
{"[x]", "Cancel"},
}
for _, c := range controls {
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" "+c.key))
b.WriteString(lipgloss.NewStyle().Foreground(textDim).Render(" "+c.desc))
b.WriteString("\n")
}
}
func (m Model) renderStudioChat(width int) string {
var b strings.Builder
chatHeader := renderSectionHeader("CHAT", "[#]")
if m.chatLoading {
chatHeader += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warnAmber).Render("thinking...")
}
b.WriteString(chatHeader)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n\n")
for _, msg := range m.chatLog {
b.WriteString(msg)
b.WriteString("\n\n")
}
return b.String()
}
func (m Model) renderStudioInput() string {
if m.chatLoading {
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render(">> ") + m.spinner.View() + lipgloss.NewStyle().Foreground(textMuted).Render(" thinking..."),
)
}
cursor := lipgloss.NewStyle().Foreground(cyberRed).Render("▎")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render(">> ") + m.chatInput + cursor,
)
}
func (m Model) handleStudioPanelSwitch(panel studioPanel) {
m.studioPanel = panel
m.viewport.SetContent(m.renderContent())
}

View File

@@ -1,196 +0,0 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
)
var (
cyberRed = lipgloss.Color("#FF0033")
cyberRedDark = lipgloss.Color("#8B0020")
cyberRedDeep = lipgloss.Color("#5C0015")
cyberPink = lipgloss.Color("#FF1A5E")
cyberRose = lipgloss.Color("#FF4D6D")
neonRed = lipgloss.Color("#FF1744")
brightRed = lipgloss.Color("#FF5252")
dimRed = lipgloss.Color("#6B2033")
mutedRed = lipgloss.Color("#4A1525")
textBright = lipgloss.Color("#EAE0E2")
textMain = lipgloss.Color("#D4C4C8")
textDim = lipgloss.Color("#8A7A7E")
textMuted = lipgloss.Color("#5A4F52")
successGreen = lipgloss.Color("#00E676")
warnAmber = lipgloss.Color("#FFD740")
errorRed = lipgloss.Color("#FF1744")
bgVoid = lipgloss.Color("#0A0A0C")
bgBase = lipgloss.Color("#0F0D10")
bgSurface = lipgloss.Color("#161218")
bgPanel = lipgloss.Color("#1C1719")
bgCard = lipgloss.Color("#221B1E")
bgInput = lipgloss.Color("#2A2225")
borderDim = lipgloss.Color("#2A1F22")
borderRed = lipgloss.Color("#FF003344")
borderRedFull = lipgloss.Color("#FF0033")
)
var (
baseStyle = lipgloss.NewStyle()
titleBlockStyle = lipgloss.NewStyle().
Foreground(cyberRed).
Bold(true)
sectionTitleStyle = lipgloss.NewStyle().
Foreground(cyberRed).
Bold(true)
labelStyle = lipgloss.NewStyle().
Foreground(textDim).
Width(14)
valueStyle = lipgloss.NewStyle().
Foreground(textMain)
cardStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(borderDim).
Padding(0, 1)
cardActiveStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Padding(0, 1)
sidebarStyle = lipgloss.NewStyle().
Background(bgSurface).
Border(lipgloss.Border{Right: "│"}).
BorderForeground(borderDim).
Padding(0, 1)
statusBarStyle = lipgloss.NewStyle().
Background(bgSurface).
Foreground(textDim).
Padding(0, 1)
inputStyle = lipgloss.NewStyle().
Foreground(cyberRed)
userMsgStyle = lipgloss.NewStyle().
Foreground(cyberRose)
aiMsgStyle = lipgloss.NewStyle().
Foreground(textMain)
errMsgStyle = lipgloss.NewStyle().
Foreground(errorRed)
itemOKStyle = lipgloss.NewStyle().Foreground(successGreen)
itemMissingStyle = lipgloss.NewStyle().Foreground(errorRed)
itemWarnStyle = lipgloss.NewStyle().Foreground(warnAmber)
itemPendingStyle = lipgloss.NewStyle().Foreground(textMuted)
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Background(bgCard).
Foreground(textBright).
Padding(1, 3).
Bold(true)
confirmYesStyle = lipgloss.NewStyle().Foreground(successGreen).Bold(true)
confirmNoStyle = lipgloss.NewStyle().Foreground(textMuted)
badgeStyle = lipgloss.NewStyle().
Background(cyberRed).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Bold(true)
tabBarStyle = lipgloss.NewStyle().Background(bgSurface)
stepDoneStyle = lipgloss.NewStyle().Foreground(successGreen)
stepPendingStyle = lipgloss.NewStyle().Foreground(textMuted)
stepCurrentStyle = lipgloss.NewStyle().Foreground(cyberRed).Bold(true)
stepErrorStyle = lipgloss.NewStyle().Foreground(errorRed)
)
var logoLines = []string{
"███╗ ███╗██╗ ██╗ █████╗ ███╗ ██╗███████╗",
"████╗ ████║╚██╗ ██╔╝██╔══██╗████╗ ██║██╔════╝",
"██╔████╔██║ ╚████╔╝ ███████║██╔██╗ ██║███████╗",
"██║╚██╔╝██║ ╚██╔╝ ██╔══██║██║╚██╗██║╚════██║",
"██║ ╚═╝ ██║ ██║ ██║ ██║██║ ╚████║███████║",
"╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝",
}
var scanFrames = []string{
"─────────────────────────── ───",
" ─────────────────────────── ─── ",
"── ──────────────────────────── ",
"─ ─── ────────────────────────────",
"─── ─────────────────────────── ─",
" ──── ─────────────────────────────",
}
func getAnimFrame(frame int) string {
frames := []string{
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
}
return frames[frame%len(frames)]
}
func getScanFrame(frame int) string {
return scanFrames[frame%len(scanFrames)]
}
func renderLogo() string {
styled := make([]string, len(logoLines))
for i, line := range logoLines {
styled[i] = lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(line)
}
return lipgloss.JoinVertical(lipgloss.Left, styled...)
}
func renderBlockTitle(text string) string {
width := len(text) + 6
top := lipgloss.NewStyle().Foreground(dimRed).Render(
"╭" + repeatStr("─", width) + "╮",
)
content := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
"│ ■ " + text + " ■ │",
)
bottom := lipgloss.NewStyle().Foreground(dimRed).Render(
"╰" + repeatStr("─", width) + "╯",
)
return lipgloss.JoinVertical(lipgloss.Left, top, content, bottom)
}
func renderSectionHeader(title string, icon string) string {
return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
"■ "+icon+" "+title+" ■",
)
}
func renderProgressBar(pct float64, width int) string {
filled := int(float64(width) * pct)
empty := width - filled
bar := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
repeatStr("█", filled),
) + lipgloss.NewStyle().Foreground(dimRed).Render(
repeatStr("░", empty),
)
return bar
}
func repeatStr(s string, n int) string {
result := ""
for i := 0; i < n; i++ {
result += s
}
return result
}

View File

@@ -1,216 +0,0 @@
package tui
import (
"os"
"os/exec"
"regexp"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var dangerousPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|/)`),
regexp.MustCompile(`(?i)\bmkfs\b`),
regexp.MustCompile(`(?i)\bdd\s+if=`),
regexp.MustCompile(`(?i)\b(format\s+[A-Za-z]:)\b`),
regexp.MustCompile(`(?i):\(\)\{.*\}`),
regexp.MustCompile(`(?i)>(/dev/|/etc/|/boot/)`),
regexp.MustCompile(`(?i)\bshutdown\b`),
regexp.MustCompile(`(?i)\breboot\b`),
regexp.MustCompile(`(?i)\bhalt\b`),
regexp.MustCompile(`(?i)\bpoweroff\b`),
}
func isDangerousCommand(input string) bool {
for _, pat := range dangerousPatterns {
if pat.MatchString(input) {
return true
}
}
return false
}
func (m Model) renderShell() string {
if m.termAIShow {
aiWidth := 36
termWidth := m.width - aiWidth - 2
if termWidth < 20 {
termWidth = 20
aiWidth = m.width - termWidth - 2
}
termPanel := m.renderTermPanel(termWidth)
aiPanel := m.renderAIPanel(aiWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, termPanel, aiPanel)
}
return m.renderTermPanel(m.width)
}
func (m Model) renderTermPanel(width int) string {
var b strings.Builder
cwdStyle := lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd)
b.WriteString(renderSectionHeader("TERMINAL", "[$]"))
b.WriteString(" ")
b.WriteString(cwdStyle)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n")
for _, line := range m.termLog {
b.WriteString(line + "\n")
}
return b.String()
}
func (m Model) renderAIPanel(width int) string {
var b strings.Builder
b.WriteString(renderSectionHeader("AI ASSISTANT", "[?]"))
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n\n")
for _, msg := range m.termAIChat {
b.WriteString(msg)
b.WriteString("\n\n")
}
if m.termAILoading {
b.WriteString(lipgloss.NewStyle().Foreground(neonRed).Render(" " + getAnimFrame(m.animationFrame) + " thinking..."))
b.WriteString("\n")
}
inputLabel := lipgloss.NewStyle().Foreground(cyberRed).Render(">> ")
b.WriteString(inputLabel)
b.WriteString(m.termAIInput)
return lipgloss.NewStyle().
Background(bgSurface).
Border(lipgloss.Border{Left: "│"}).
BorderForeground(borderDim).
Width(width).
Padding(0, 1).
Render(b.String())
}
func (m Model) renderShellInput() string {
prompt := lipgloss.NewStyle().Foreground(successGreen).Render("> ")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
prompt + m.termInput + lipgloss.NewStyle().Foreground(cyberRed).Render("▎"),
)
}
func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
if m.termCmd != nil && m.termCmd.Process != nil {
m.termCmd.Process.Kill()
m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorRed).Render("^C"))
m.termCmd = nil
m.viewport.SetContent(m.renderContent())
return m, nil
}
now := time.Now()
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
return m, tea.Quit
}
m.ctrlCCount++
m.lastCtrlC = now
m.showingQuit = true
m.confirmCursor = 1
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+t":
m.showingTabMenu = true
m.tabMenuCursor = int(m.activeTab)
return m, nil
case "ctrl+a":
m.termAIShow = !m.termAIShow
m.viewport.SetContent(m.renderContent())
return m, nil
case "enter":
if m.termRunning {
return m, nil
}
input := strings.TrimSpace(m.termInput)
m.termInput = ""
if input == "" {
return m, nil
}
if input == "exit" || input == "quit" {
return m, nil
}
if input == "clear" {
m.termLog = nil
m.viewport.SetContent(m.renderContent())
return m, nil
}
if isDangerousCommand(input) {
m.termLog = append(m.termLog, errMsgStyle.Render(" [BLOCKED] potentially dangerous command"))
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
}
if strings.HasPrefix(input, "cd ") {
dir := strings.TrimPrefix(input, "cd ")
dir = strings.TrimSpace(dir)
if dir == "~" {
home, _ := os.UserHomeDir()
dir = home
}
if err := os.Chdir(dir); err == nil {
m.termCwd, _ = os.Getwd()
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd+" $ ")+input)
} else {
m.termLog = append(m.termLog, errMsgStyle.Render(" cd: "+err.Error()))
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
}
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd+" $ ")+input)
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, m.runTermCommand(input)
case "backspace":
if len(m.termInput) > 0 {
m.termInput = m.termInput[:len(m.termInput)-1]
m.viewport.SetContent(m.renderContent())
}
return m, nil
default:
if len(msg.String()) == 1 {
m.termInput += msg.String()
m.viewport.SetContent(m.renderContent())
}
}
return m, nil
}
func (m Model) runTermCommand(input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
cmd := exec.Command(shell, "-c", input)
cmd.Dir = m.termCwd
out, err := cmd.CombinedOutput()
if err != nil {
return termOutputMsg{line: string(out) + errMsgStyle.Render(err.Error())}
}
return termOutputMsg{line: string(out)}
})
}

View File

@@ -1,207 +0,0 @@
package tui
import (
"os/exec"
"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"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/daemon"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/preview"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/workflow"
)
type tab int
const (
tabDashboard tab = iota
tabStudio
tabShell
tabConfig
tabCount
)
var tabNames = []string{"DASH", "STUDIO", "SHELL", "CONFIG"}
var tabIcons = []string{"[■]", "[<>]", "[>$]", "[//]"}
type aiResponseMsg struct{ content string }
type aiErrMsg struct{ err error }
type scanCompleteMsg struct{ result *scanner.ScanResult }
type installCompleteMsg struct{ results []installer.InstallResult }
type installProgressMsg struct {
tool string
current int
total int
}
type installBatchMsg struct {
result installer.InstallResult
tools []string
index int
config *config.MuyueConfig
}
type updateCheckMsg struct{ statuses []updater.UpdateStatus }
type previewReadyMsg struct{ url string }
type workflowPhaseMsg struct{ phase workflow.Phase }
type daemonLogMsg struct{ logs []string }
type lspScanMsg struct{ servers []lsp.LSPServer }
type mcpConfigMsg struct{ err error }
type skillsListMsg struct{ skills []skills.Skill }
type spinnerTickMsg struct{ time time.Time }
type termOutputMsg struct{ line string }
type termExitMsg struct{}
type animTickMsg struct{ time time.Time }
type clockTickMsg struct{ time time.Time }
type glitchDoneMsg struct{}
type scanDoneMsg struct{}
type studioPanel int
const (
panelChat studioPanel = iota
panelAgents
panelWorkflows
)
type configSection int
const (
configProfile configSection = iota
configProviders
configTerminal
configSkills
)
type transitionState int
const (
transitionNone transitionState = iota
transitionGlitch
transitionScan
transitionTypewriter
)
type Model struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
activeTab tab
prevTab 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
helpModel help.Model
progressBar progress.Model
spinner spinner.Model
showingQuit bool
confirmCursor int
showingTabMenu bool
tabMenuCursor int
ctrlCCount int
lastCtrlC time.Time
installing bool
installCurrent int
installTotal int
installTool string
termCmd *exec.Cmd
termInput string
termLog []string
termRunning bool
termCwd string
studioPanel studioPanel
studioSidebarOpen bool
termAIChat []string
termAIInput string
termAILoading bool
termAIShow bool
configSection configSection
configField int
animationFrame int
transition transitionState
transitionTick int
typewriterBuf string
typewriterPos int
currentTime time.Time
}
type keyMap struct {
Tab key.Binding
Prev key.Binding
Quit key.Binding
TabMenu key.Binding
Enter key.Binding
Backspace key.Binding
}
var keys = keyMap{
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "next"),
),
Prev: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "prev"),
),
Quit: key.NewBinding(
key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"),
),
TabMenu: key.NewBinding(
key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "tabs"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "send"),
),
Backspace: key.NewBinding(
key.WithKeys("backspace"),
key.WithHelp("backspace", "delete"),
),
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.TabMenu, k.Tab, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.TabMenu, k.Tab, k.Prev},
{k.Quit},
}
}

View File

@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
!dist/.gitkeep
.vite/ .vite/

6
web/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed all:dist
var Assets embed.FS

View File

@@ -1,19 +1,13 @@
{ {
"name": "frontend", "name": "muyue-web",
"version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "muyue-web",
"version": "1.0.0",
"license": "ISC",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5"
"xterm": "^5.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
@@ -402,21 +396,6 @@
} }
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -981,13 +960,6 @@
"optional": true "optional": true
} }
} }
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
} }
} }
} }

18
web/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "muyue-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.9"
}
}

31
web/src/api/client.js Normal file
View File

@@ -0,0 +1,31 @@
const API_BASE = '/api'
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
return res.json()
}
const api = {
getInfo: () => request('/info'),
getSystem: () => request('/system'),
getTools: () => request('/tools'),
getConfig: () => request('/config'),
getProviders: () => request('/providers'),
getSkills: () => request('/skills'),
getLSP: () => request('/lsp'),
getMCP: () => request('/mcp'),
getUpdates: () => request('/updates'),
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
}
export default api

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useAPI } from '../hooks/useAPI' import api from '../api/client'
import { getTheme, getThemeNames, applyTheme } from '../themes' import { getTheme, getThemeNames, applyTheme } from '../themes'
import Dashboard from './Dashboard' import Dashboard from './Dashboard'
import Studio from './Studio' import Studio from './Studio'
@@ -21,7 +21,7 @@ export default function App() {
const [tools, setTools] = useState([]) const [tools, setTools] = useState([])
const [transition, setTransition] = useState(false) const [transition, setTransition] = useState(false)
const [currentTheme, setCurrentTheme] = useState('cyberpunk-red') const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
const api = useAPI() // api is imported directly
useEffect(() => { useEffect(() => {
api.getInfo().then(setInfo).catch(() => {}) api.getInfo().then(setInfo).catch(() => {})

View File

@@ -8,8 +8,12 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
}, },
server: { server: {
port: 5173,
proxy: { proxy: {
'/api': 'http://127.0.0.1:0', '/api': {
target: 'http://127.0.0.1:8095',
changeOrigin: true,
},
}, },
}, },
}) })