Compare commits

...

9 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
Augustin
097cf40ccd fix(ci): add frontend build step before Go vet/test/build
All checks were successful
Beta Release / beta (push) Successful in 1m16s
All three CI workflows now build the React frontend (npm ci && npm run
build) before any Go steps, so the go:embed directive in
cmd/muyue-desktop/main.go finds the dist/ directory.

- ci-develop.yml: already rewritten, included in this commit
- ci-main.yml: add Node 22 setup, cache, frontend build, desktop binary
  builds for all platforms, updated changelog download table
- ci-pr.yml: add Node 22 setup, cache, frontend build, desktop binary
  build check

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 22:54:50 +02:00
Augustin ROUX
88d2a03808 feat: add desktop app with React frontend, API backend, theme system (#2)
Some checks failed
Beta Release / beta (push) Failing after 12s
New desktop application that launches a local HTTP server with embedded
React frontend. Opens in the user's browser automatically.

Architecture:
- internal/api/: REST API exposing all internal/ packages to frontend
- cmd/muyue-desktop/: entry point, serves embedded frontend + API
- cmd/muyue-desktop/frontend/: React + Vite SPA

Frontend features:
- 4 tabs: Dashboard, Studio, Shell, Config
- Cyberpunk red theme with CSS custom properties
- Theme system: 4 built-in themes (Cyberpunk Red, Pink, Midnight Blue, Matrix Green)
- Terminal with command execution via API
- Chat interface with sidebar (agents, workflows, commands)
- Live clock, status indicators, update badges
- Glitch/scanline/fade animations between tabs
- xterm.js included for future full terminal integration

Backend API endpoints:
- GET /api/info, /api/system, /api/tools, /api/config
- GET /api/providers, /api/skills, /api/lsp, /api/mcp, /api/updates
- POST /api/scan, /api/install, /api/terminal, /api/mcp/configure

Build: cd cmd/muyue-desktop/frontend && npm run build && go build ./cmd/muyue-desktop/
Binary: ~11MB single binary with embedded frontend

Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>

Co-authored-by: Augustin <muyue@legion-muyue.fr>
Reviewed-on: #2
2026-04-20 20:46:12 +00:00
CI Bot
1830c18c7a chore: update CHANGELOG for v0.2.1
All checks were successful
Beta Release / beta (push) Successful in 28s
2026-04-20 20:17:33 +00:00
Augustin ROUX
cb8e3d0d26 feat: complete TUI redesign with cyberpunk theme (#1)
Some checks failed
Stable Release / stable (push) Failing after 22s
- Dark theme with red accents (cyberpunk aesthetic)
- Epuré cyberpunk style: clean dark backgrounds, sharp red highlights
- Full cyberpunk animations: glitch effect, scan line, typewriter
- Mixed Unicode + ASCII icons
- Rounded borders (╭ ╮ ╯ ╰) on cards and panels
- ASCII art block titles (■) with red styling
- Header: MUYUE branding, status indicators, live clock
- Footer: shortcuts, version, update indicator
- Tab transitions: glitch → scan → typewriter sequence
- Extracted header.go, footer.go, animations.go as new files

Controls unchanged: ctrl+t tabs, ctrl+s sidebar, ctrl+a AI panel

file changes:
- styles.go: new color palette (cyberRed, bgVoid, dimRed), block titles
- types.go: added transition state, clock tick, glitch/scan/done messages
- animations.go: new file with glitch, scan, typewriter, hex stream effects
- header.go: new file with logo, tabs, status dots, live clock
- footer.go: new file with shortcuts, version, update indicator
- model.go: integrated transition state machine, clock updates
- dashboard.go, studio.go, terminal.go, config_tab.go: updated icons/styles

Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>

Co-authored-by: Augustin <muyue@legion-muyue.fr>
Reviewed-on: #1
2026-04-20 20:17:06 +00:00
CI Bot
8ea7418684 chore: update CHANGELOG for v0.2.1 2026-04-20 19:22:23 +00:00
Augustin
ec33ff4e4d Merge branch 'main' of https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace
All checks were successful
Stable Release / stable (push) Successful in 28s
Beta Release / beta (push) Successful in 26s
2026-04-20 21:21:32 +02:00
Augustin
22fb2823ce chore: bump version to 0.2.1, update README for TUI redesign
All checks were successful
Beta Release / beta (push) Successful in 32s
- Document 4-tab layout (Dashboard, Studio, Shell, Config)
- Add keyboard shortcuts table for new tabs
- Update version references from 0.2.0 to 0.2.1

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 21:21:03 +02:00
39 changed files with 3014 additions and 2127 deletions

View File

@@ -17,6 +17,11 @@ jobs:
with:
go-version: '1.24.3'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
with:
@@ -27,9 +32,23 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
- name: Cache Node modules
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Download Go dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Vet
run: go vet ./...
@@ -49,7 +68,7 @@ jobs:
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
echo "Building beta release: ${VERSION}"
- name: Build all platforms
- name: Build (all platforms)
run: |
mkdir -p dist
VERSION=${{ steps.version.outputs.version }}

View File

@@ -17,6 +17,11 @@ jobs:
with:
go-version: '1.24.3'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
with:
@@ -27,9 +32,23 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Download dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Vet
run: go vet ./...
@@ -45,7 +64,7 @@ jobs:
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
echo "Building stable release: ${VERSION}"
- name: Build all platforms
- name: Build (all platforms)
run: |
mkdir -p dist
LDFLAGS="-s -w"
@@ -100,6 +119,9 @@ jobs:
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 ""
echo "The binary includes both CLI and Desktop modes."
echo "Run \`muyue\` for TUI, \`muyue desktop\` for web UI."
echo ""
echo "### Install"
echo ""
echo "**Linux (x86_64)**"

View File

@@ -15,6 +15,11 @@ jobs:
with:
go-version: '1.24.3'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
with:
@@ -25,9 +30,23 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Download dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Vet
run: go vet ./...

3
.gitignore vendored
View File

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

View File

@@ -4,6 +4,88 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.2.1
### Changes since v0.2.1
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.1
### Changes since v0.2.0
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.0
### Changes since start

View File

@@ -3,10 +3,16 @@ GOBIN ?= $(GOPATH)/bin
BINARY = muyue
BUILD_DIR = .
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/
install: build
@@ -18,6 +24,8 @@ install-local: build
clean:
rm -f $(BUILD_DIR)/$(BINARY)
rm -rf $(WEB_DIR)/dist
rm -rf $(WEB_DIR)/node_modules
test:
$(GO) test ./... -v -count=1
@@ -31,6 +39,12 @@ vet:
run: build
./$(BINARY)
desktop: build
./$(BINARY) desktop
dev-desktop:
cd $(WEB_DIR) && $(NPM) run dev
scan: build
./$(BINARY) scan
@@ -41,7 +55,7 @@ fmt:
lint:
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=arm64 $(GO) build -o dist/$(BINARY)-linux-arm64 ./cmd/muyue/
GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY)-darwin-amd64 ./cmd/muyue/

View File

@@ -76,25 +76,49 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill
```
## TUI Controls
## TUI — 4 Tabs
| Key | Action |
|-----|--------|
| `Ctrl+T` | Open tab switcher |
| `Tab` / `Shift+Tab` | Cycle tabs |
| `Ctrl+C` | Quit confirmation |
| `i` (Dashboard) | Install missing tools |
| `u` (Dashboard) | Check for updates |
| `s` (Dashboard) | Rescan system |
| `a` (Workflow) | Approve plan |
| `r` (Workflow) | Reject plan |
| `g` (Workflow) | Generate plan |
| `n` (Workflow) | Next step |
| `x` (Workflow) | Cancel workflow |
The TUI is organized into 4 tabs with a red/rose theme (`#E8364F``#FF6B8A`):
### Chat Commands
### ◉ Dashboard
- `/plan <goal>` — Start a structured Plan→Execute workflow
System overview: installed tools with status, active agents, updates, LSP/MCP/daemon status, and quick actions (install, update, scan).
### ◈ Studio
Central AI chat with a collapsible sidebar (`Ctrl+S`) containing 3 panels:
| Panel | Shortcut | Description |
|-------|----------|-------------|
| **Chat** | `1` | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | `2` | Start/stop Crush and Claude Code agents |
| **Workflows** | `3` | Plan→Execute workflow controls (approve, reject, next step) |
### ▶ Shell
Split-view terminal with an AI assistant panel (`Ctrl+A` to toggle). The AI knows your system and suggests commands you can easily copy into the terminal.
### ⚙ Config
Profile, API providers, terminal/starship settings, BMAD, and skills — displayed in a two-column layout.
### Keyboard Shortcuts
| Key | Context | Action |
|-----|---------|--------|
| `Ctrl+T` | Global | Open tab switcher |
| `Ctrl+S` | Studio | Toggle sidebar |
| `Ctrl+A` | Shell | Toggle AI assistant panel |
| `Ctrl+C` | Global | Quit confirmation |
| `i` | Dashboard | Install missing tools |
| `u` | Dashboard | Check for updates |
| `s` | Dashboard | Rescan system |
| `1` `2` `3` | Studio sidebar | Switch panels (Chat/Agents/Workflows) |
| `a` | Workflow | Approve plan |
| `r` | Workflow | Reject plan |
| `g` | Workflow | Generate plan |
| `n` | Workflow | Next step |
| `x` | Workflow | Cancel workflow |
## Configuration
@@ -179,7 +203,7 @@ git push -u origin feature/my-feature
```bash
# 1. Bump the version in internal/version/version.go
# Change: Version = "0.2.0" → Version = "0.3.0"
# Change: Version = "0.2.1" → Version = "0.3.0"
# Commit on develop:
git checkout develop
# (edit internal/version/version.go)
@@ -222,7 +246,7 @@ git push
```go
const (
Version = "0.2.0" // ← bump this before a release
Version = "0.2.1" // ← bump this before a release
)
```
@@ -236,11 +260,11 @@ Binary version is injected at build time via `-ldflags`:
```bash
# Beta build (automatic in CI)
go build -ldflags="-X github.com/muyue/muyue/internal/version.Prerelease=beta.3" ./cmd/muyue/
# → muyue v0.2.0-beta.3
# → muyue v0.2.1-beta.3
# Stable build (automatic in CI)
go build -ldflags="-s -w" ./cmd/muyue/
# → muyue v0.2.0
# → muyue v0.2.1
```
### Conventional commits

View File

@@ -5,8 +5,8 @@ import (
"os"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
@@ -14,26 +14,33 @@ import (
"github.com/muyue/muyue/internal/profiler"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/tui"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
)
func main() {
if len(os.Args) > 1 {
handleCommand(os.Args[1:])
return
if isCommand(os.Args[1]) {
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) {
if len(args) == 0 {
runTUI()
return
}
switch args[0] {
case "version", "-v", "--version":
fmt.Println(version.FullVersion())
@@ -57,10 +64,6 @@ func handleCommand(args []string) {
runSkills(args[1:])
case "help", "-h", "--help":
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
Usage:
muyue Start the interactive TUI
muyue Launch desktop app (opens browser)
muyue <command> Run a specific command
Options:
--port=PORT Specify port (default: auto)
--no-open Don't open browser automatically
Commands:
version Show version
scan Scan your system for tools and runtimes
@@ -84,35 +91,15 @@ Commands:
skills [list|generate|deploy|init|delete] Manage AI coding skills
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:
Some tools (docker, gh, etc.) require elevated privileges.
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
`, version.FullVersion())
}
func runTUI() {
func runDesktop(args []string) {
cfg := loadOrSetupConfig()
result := scanner.ScanSystem()
model := tui.NewModel(cfg, result)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
if err := desktop.Run(cfg, args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

7
go.mod
View File

@@ -3,10 +3,7 @@ module github.com/muyue/muyue
go 1.24.3
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/lipgloss v1.1.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -14,8 +11,10 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/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/cellbuf v0.0.15 // 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/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=

215
internal/api/handlers.go Normal file
View File

@@ -0,0 +1,215 @@
package api
import (
"encoding/json"
"net/http"
"os/exec"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
)
func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, msg string, code int) {
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"name": version.Name,
"version": version.Version,
"author": version.Author,
})
}
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
writeJSON(w, map[string]interface{}{
"system": s.scanResult.System,
})
}
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
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 {
tools[i] = toolInfo{
Name: t.Name,
Installed: t.Installed,
Version: t.Version,
Path: t.Path,
}
}
writeJSON(w, map[string]interface{}{
"tools": tools,
"total": len(tools),
})
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"profile": s.config.Profile,
"terminal": s.config.Terminal,
"bmad": s.config.BMAD,
})
}
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"providers": s.config.AI.Providers,
})
}
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
list, err := skills.List()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"skills": list,
"count": len(list),
})
}
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
servers := lsp.ScanServers()
writeJSON(w, map[string]interface{}{
"servers": servers,
})
}
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
servers := mcp.ScanServers()
writeJSON(w, map[string]interface{}{
"servers": servers,
"configured": true,
})
}
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
if err := mcp.ConfigureAll(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
type updateInfo struct {
Tool string `json:"tool"`
Current string `json:"current"`
Latest string `json:"latest"`
NeedsUpdate bool `json:"needsUpdate"`
Error string `json:"error,omitempty"`
}
updates := make([]updateInfo, len(statuses))
for i, u := range statuses {
updates[i] = updateInfo{
Tool: u.Tool,
Current: u.Current,
Latest: u.Latest,
NeedsUpdate: u.NeedsUpdate,
Error: u.Error,
}
}
writeJSON(w, map[string]interface{}{
"updates": updates,
})
}
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Tools []string `json:"tools"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if len(body.Tools) == 0 {
writeError(w, "no tools specified", http.StatusBadRequest)
return
}
writeJSON(w, map[string]string{"status": "installing"})
}
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
s.scanResult = scanner.ScanSystem()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Command string `json:"command"`
Cwd string `json:"cwd"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Command == "" {
writeError(w, "no command", http.StatusBadRequest)
return
}
shell := "/bin/sh"
if s, err := exec.LookPath("bash"); err == nil {
shell = s
}
cmd := exec.Command(shell, "-c", body.Command)
if body.Cwd != "" {
cmd.Dir = body.Cwd
}
out, err := cmd.CombinedOutput()
type termResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
result := termResult{Output: string(out)}
if err != nil {
result.Error = err.Error()
}
writeJSON(w, result)
}

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,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,121 +0,0 @@
package tui
import (
"fmt"
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
)
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func extractVersion(s string) string {
return versionRegex.FindString(s)
}
func (m Model) renderConfig() string {
colWidth := m.width / 2
if colWidth < 30 {
colWidth = 30
}
var left, right strings.Builder
left.WriteString(renderSectionWithIcon("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(renderSectionWithIcon("AI Providers", "◆"))
left.WriteString("\n")
if m.config != nil {
for _, p := range m.config.AI.Providers {
active := ""
if p.Active {
active = lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" ●")
}
keyStatus := itemMissingStyle.Render("no key")
if p.APIKey != "" {
keyStatus = itemOKStyle.Render("configured")
}
nameStyle := lipgloss.NewStyle().Foreground(textColor).Bold(true)
left.WriteString(fmt.Sprintf(" %s %s key=%s%s\n",
nameStyle.Render(p.Name),
lipgloss.NewStyle().Foreground(dimColor).Render("model="+p.Model),
keyStatus, active))
}
}
left.WriteString("\n")
right.WriteString(renderSectionWithIcon("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(renderSectionWithIcon("BMAD Method", "◈"))
right.WriteString("\n")
if m.config != nil {
installed := itemMissingStyle.Render("no")
if m.config.BMAD.Installed {
installed = itemOKStyle.Render("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(renderSectionWithIcon(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(textColor).Render(s.Name),
lipgloss.NewStyle().Foreground(primaryColor).Render("["+target+"]"),
lipgloss.NewStyle().Foreground(dimColor).Render(s.Description)))
}
} else {
right.WriteString(lipgloss.NewStyle().Foreground(mutedColor).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,181 +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(renderSectionWithIcon("System", "◉"))
left.WriteString("\n")
if m.scanResult != nil {
sysInfo := m.scanResult.System.String()
left.WriteString(" ")
left.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(sysInfo))
}
left.WriteString("\n\n")
left.WriteString(renderSectionWithIcon("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("✓ "))
left.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(t.Name))
left.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %s", extractVersion(t.Version))))
left.WriteString("\n")
} else {
left.WriteString(" ")
left.WriteString(itemMissingStyle.Render("✗ "))
left.WriteString(lipgloss.NewStyle().Foreground(mutedColor).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(primaryColor).Render(strings.Repeat("█", pct)) +
lipgloss.NewStyle().Foreground(dimColor).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(renderSectionWithIcon("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(renderSectionWithIcon("Install Log", "📋"))
left.WriteString("\n")
for _, l := range m.installLog {
left.WriteString(l + "\n")
}
left.WriteString("\n")
}
right.WriteString(renderSectionWithIcon("Quick Actions", "⚡"))
right.WriteString("\n")
actions := []struct {
key string
desc string
color lipgloss.Color
}{
{"i", "Install missing tools", primaryColor},
{"u", "Check for updates", warmColor},
{"s", "Rescan system", roseColor},
{"l", "Scan LSP servers", accentColor},
{"m", "Configure MCP servers", roseLightColor},
}
for _, a := range actions {
right.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(a.color).Bold(true).Render("["+a.key+"]"),
lipgloss.NewStyle().Foreground(textColor).Render(a.desc)))
}
right.WriteString("\n")
right.WriteString(renderSectionWithIcon("Active Agents", "◉"))
right.WriteString("\n")
agents := []struct {
name string
}{
{"Crush"},
{"Claude Code"},
}
for _, a := range agents {
right.WriteString(" ")
right.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("● "))
right.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(a.name + " "))
right.WriteString(itemPendingStyle.Render("stopped"))
right.WriteString("\n")
}
right.WriteString("\n")
if len(m.updateStatus) > 0 {
right.WriteString(renderSectionWithIcon("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("✓ "))
right.WriteString(fmt.Sprintf("%s: up to date\n", s.Tool))
}
}
right.WriteString("\n")
}
if len(m.lspServers) > 0 {
right.WriteString(renderSectionWithIcon("LSP Servers", "§"))
right.WriteString("\n")
lspInstalled := 0
for _, s := range m.lspServers {
if s.Installed {
lspInstalled++
right.WriteString(" ")
right.WriteString(itemOKStyle.Render("✓ "))
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("✓ configured")
}
right.WriteString(fmt.Sprintf(" MCP: %s\n", mcpStatus))
if m.daemon != nil {
daemonStatus := itemPendingStyle.Render("○ stopped")
if m.daemon.IsRunning() {
daemonStatus = itemOKStyle.Render("✓ 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)
}
func renderSectionWithIcon(title string, icon string) string {
return lipgloss.NewStyle().Foreground(primaryColor).Render(icon+" ") +
sectionStyle.Render(title)
}

View File

@@ -1,351 +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.activeTab = tab(m.tabMenuCursor)
m.showingTabMenu = false
m.resizeViewport()
return m, nil
default:
for i := 0; i < int(tabCount); i++ {
if msg.String() == fmt.Sprintf("%d", i+1) {
m.activeTab = tab(i)
m.showingTabMenu = false
m.resizeViewport()
return m, nil
}
}
}
return m, nil
}
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("✓ 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,11 +0,0 @@
package tui
import (
"github.com/muyue/muyue/internal/workflow"
)
type previewFile = workflow.PreviewFile
func parsePreviewFiles(response string) []previewFile {
return workflow.ParsePreviewFiles(response)
}

View File

@@ -1,516 +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"
"github.com/muyue/muyue/internal/version"
)
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(primaryColor)
prog := progress.New(progress.WithGradient("#E8364F", "#FF6B8A"))
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,
}
}
func animTick() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return animTickMsg{time: t}
})
}
func (m Model) Init() tea.Cmd {
return tea.Batch(spinner.Tick, animTick(), 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++
return m, animTick()
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(dimColor).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("✓")
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("✓")
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("✓")
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(primaryColor).Bold(true).Render("Loading muyue...")
}
if m.showingQuit {
return m.renderQuitOverlay()
}
if m.showingTabMenu {
return m.renderTabMenuOverlay()
}
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) renderHeader() string {
var tabs []string
for i, name := range tabNames {
icon := tabIcons[i]
if tab(i) == m.activeTab {
tabStyle := lipgloss.NewStyle().
Background(primaryColor).
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
} else {
tabStyle := lipgloss.NewStyle().
Background(bgPanel).
Foreground(textDimColor).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
}
}
tabLine := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...))
badge := lipgloss.NewStyle().
Foreground(roseColor).
Bold(true).
Render("muyue")
versionBadge := lipgloss.NewStyle().
Foreground(dimColor).
Render("v" + version.Version)
anim := lipgloss.NewStyle().Foreground(warmColor).Render(getAnimFrame(m.animationFrame))
logoLine := lipgloss.NewStyle().Background(bgDark).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center, badge, " ", versionBadge, " ", anim),
)
return lipgloss.JoinVertical(lipgloss.Left, logoLine, tabLine)
}
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) 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(roseColor).Bold(true).Render(profile),
lipgloss.NewStyle().Foreground(dimColor).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 = "[↑↓] sections [ctrl+t] tabs"
default:
helpText = "[ctrl+t] tabs [ctrl+c] quit"
}
rightR := statusBarStyle.Render(helpText)
gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR)
if gap < 0 {
gap = 0
}
statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
leftR,
strings.Repeat(" ", gap),
rightR,
)
return lipgloss.JoinVertical(lipgloss.Left, statusLine,
lipgloss.NewStyle().Background(bgPanel).Foreground(dimColor).Render(
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys))))
}
func (m Model) renderQuitOverlay() string {
yesStyle := confirmNoStyle
noStyle := confirmYesStyle
if m.confirmCursor == 0 {
yesStyle = confirmYesStyle
noStyle = confirmNoStyle
}
frame := lipgloss.NewStyle().Foreground(primaryColor).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(bgDark),
lipgloss.WithWhitespaceForeground(dimColor),
)
}
func (m Model) renderTabMenuOverlay() string {
menuStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(primaryColor).
Background(bgCard).
Padding(1, 3)
tabItemStyle := lipgloss.NewStyle().
Foreground(textDimColor).
Padding(0, 2)
tabItemActiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(primaryColor).
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(dimColor).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(roseLightColor).Render(descs[i]))
items = append(items, tabItemActiveStyle.Render("▸"+item))
} else {
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(dimColor).Render(descs[i]))
items = append(items, tabItemStyle.Render(" "+item))
}
}
header := lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Switch Tab")
content := header + "\n\n" +
strings.Join(items, "\n") +
"\n\n" +
lipgloss.NewStyle().Foreground(dimColor).Render("↑↓ navigate · enter select · esc cancel")
box := menuStyle.Render(content)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
box,
lipgloss.WithWhitespaceBackground(bgDark),
lipgloss.WithWhitespaceForeground(dimColor),
)
}
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"))
}
}
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(mutedColor).Render(" thinking..."),
)
}
cursor := lipgloss.NewStyle().Foreground(primaryColor).Render("▎")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render("⟩ ") + m.chatInput + cursor,
)
}
func (m Model) renderShellInput() string {
prompt := lipgloss.NewStyle().Foreground(successColor).Render(" ")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
prompt + m.termInput + lipgloss.NewStyle().Foreground(primaryColor).Render("▎"),
)
}

View File

@@ -1,241 +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(renderSectionWithIcon("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(primaryColor).
Bold(true).
Padding(0, 1)
b.WriteString(activeStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
} else {
inactiveStyle := lipgloss.NewStyle().
Foreground(textDimColor).
Padding(0, 1)
b.WriteString(inactiveStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(borderColor).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(mutedColor).Render("Active Provider"))
b.WriteString("\n")
provider := "none"
if m.config != nil {
provider = m.config.Profile.Preferences.DefaultAI
}
b.WriteString(lipgloss.NewStyle().Foreground(roseColor).Bold(true).Render(" " + provider))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Commands"))
b.WriteString("\n")
cmds := []string{"/plan <goal>", "/help"}
for _, c := range cmds {
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(" " + c))
b.WriteString("\n")
}
if m.previewURL != "" {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).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(warmColor).Render("● running")
case proxy.StatusStopped:
statusIcon = lipgloss.NewStyle().Foreground(mutedColor).Render("○ stopped")
case proxy.StatusError:
statusIcon = lipgloss.NewStyle().Foreground(errorColor).Render("✗ error")
default:
if available {
statusIcon = lipgloss.NewStyle().Foreground(successColor).Render("✓ available")
} else {
statusIcon = lipgloss.NewStyle().Foreground(dimColor).Render("✗ not installed")
}
}
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Bold(true).Render(a.name))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", statusIcon))
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %s\n", a.tool)))
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Actions"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" [c]"))
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(" Start Crush"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" [l]"))
b.WriteString(lipgloss.NewStyle().Foreground(textColor).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(mutedColor).Render("No active workflow."))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("Use /plan <goal> in chat"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("to start a workflow."))
b.WriteString("\n")
return
}
wf := m.orch.Workflow
phaseColors := map[workflow.Phase]lipgloss.Style{
workflow.PhaseIdle: lipgloss.NewStyle().Foreground(mutedColor),
workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warningColor).Bold(true),
workflow.PhasePlanning: lipgloss.NewStyle().Foreground(roseColor).Bold(true),
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(accentColor).Bold(true),
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(primaryColor).Bold(true),
workflow.PhaseDone: lipgloss.NewStyle().Foreground(successColor).Bold(true),
workflow.PhaseError: lipgloss.NewStyle().Foreground(errorColor).Bold(true),
}
if style, ok := phaseColors[wf.Phase]; ok {
b.WriteString(style.Render(string(wf.Phase)))
}
b.WriteString("\n\n")
if wf.Plan.Goal != "" {
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Goal"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textColor).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(mutedColor).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(primaryColor).Bold(true).Render(" " + c.key))
b.WriteString(lipgloss.NewStyle().Foreground(textDimColor).Render(" " + c.desc))
b.WriteString("\n")
}
}
func (m Model) renderStudioChat(width int) string {
var b strings.Builder
chatHeader := renderSectionWithIcon("Chat", "💬")
if m.chatLoading {
chatHeader += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warningColor).Render("thinking...")
}
b.WriteString(chatHeader)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).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) handleStudioPanelSwitch(panel studioPanel) {
m.studioPanel = panel
m.viewport.SetContent(m.renderContent())
}

View File

@@ -1,131 +0,0 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
)
var (
primaryColor = lipgloss.Color("#E8364F")
roseColor = lipgloss.Color("#FF6B8A")
roseLightColor = lipgloss.Color("#FFB3C6")
accentColor = lipgloss.Color("#FF8FA3")
warmColor = lipgloss.Color("#FF4D6D")
successColor = lipgloss.Color("#4ADE80")
warningColor = lipgloss.Color("#FBBF24")
errorColor = lipgloss.Color("#FF4D4D")
mutedColor = lipgloss.Color("#8B7E8E")
dimColor = lipgloss.Color("#5A4F5E")
textColor = lipgloss.Color("#F0E6E8")
textDimColor = lipgloss.Color("#B8A9AD")
bgDark = lipgloss.Color("#0D0A0B")
bgPanel = lipgloss.Color("#1A1215")
bgCard = lipgloss.Color("#231A1D")
bgInput = lipgloss.Color("#2A2023")
bgHover = lipgloss.Color("#332528")
borderColor = lipgloss.Color("#3D2E32")
borderAccent = lipgloss.Color("#E8364F")
tabActiveBg = lipgloss.Color("#E8364F")
tabInactiveBg = lipgloss.Color("#1A1215")
sectionStyle = lipgloss.NewStyle().
Foreground(roseColor).
Bold(true)
sectionIconStyle = lipgloss.NewStyle().
Foreground(primaryColor).
Bold(true)
itemOKStyle = lipgloss.NewStyle().
Foreground(successColor)
itemMissingStyle = lipgloss.NewStyle().
Foreground(errorColor)
itemWarnStyle = lipgloss.NewStyle().
Foreground(warningColor)
itemPendingStyle = lipgloss.NewStyle().
Foreground(mutedColor)
userMsgStyle = lipgloss.NewStyle().
Foreground(roseLightColor)
aiMsgStyle = lipgloss.NewStyle().
Foreground(textColor)
errMsgStyle = lipgloss.NewStyle().
Foreground(errorColor)
inputStyle = lipgloss.NewStyle().
Foreground(roseColor)
stepDoneStyle = lipgloss.NewStyle().
Foreground(successColor)
stepPendingStyle = lipgloss.NewStyle().
Foreground(mutedColor)
stepCurrentStyle = lipgloss.NewStyle().
Foreground(primaryColor).
Bold(true)
stepErrorStyle = lipgloss.NewStyle().
Foreground(errorColor)
statusBarStyle = lipgloss.NewStyle().
Background(bgPanel).
Foreground(textDimColor).
Padding(0, 1)
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(primaryColor).
Background(bgCard).
Foreground(textColor).
Padding(1, 3).
Bold(true)
confirmYesStyle = lipgloss.NewStyle().
Foreground(successColor).
Bold(true)
confirmNoStyle = lipgloss.NewStyle().
Foreground(mutedColor)
cardStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(borderColor).
Padding(0, 1)
sidebarStyle = lipgloss.NewStyle().
Background(bgPanel).
Border(lipgloss.Border{Right: "│"}).
BorderForeground(borderColor).
Padding(0, 1)
badgeStyle = lipgloss.NewStyle().
Background(primaryColor).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Bold(true)
labelStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Width(14)
valueStyle = lipgloss.NewStyle().
Foreground(textColor)
tabBarStyle = lipgloss.NewStyle().
Background(bgPanel)
pulseFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}
)
func getAnimFrame(frame int) string {
return pulseFrames[frame%len(pulseFrames)]
}

View File

@@ -1,209 +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(dimColor).Render(m.termCwd)
b.WriteString(renderSectionWithIcon("Terminal", "▶"))
b.WriteString(" ")
b.WriteString(cwdStyle)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).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(renderSectionWithIcon("AI Assistant", "◈"))
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).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(warmColor).Render(" " + getAnimFrame(m.animationFrame) + " thinking..."))
b.WriteString("\n")
}
inputLabel := lipgloss.NewStyle().Foreground(roseColor).Render("⟩ ")
b.WriteString(inputLabel)
b.WriteString(m.termAIInput)
return lipgloss.NewStyle().
Background(bgPanel).
Border(lipgloss.Border{Left: "│"}).
BorderForeground(borderColor).
Width(width).
Padding(0, 1).
Render(b.String())
}
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(errorColor).Render("^C"))
m.termCmd = nil
m.viewport.SetContent(m.renderContent())
return m, nil
}
now := time.Now()
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
return m, tea.Quit
}
m.ctrlCCount++
m.lastCtrlC = now
m.showingQuit = true
m.confirmCursor = 1
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+t":
m.showingTabMenu = true
m.tabMenuCursor = int(m.activeTab)
return m, nil
case "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(dimColor).Render(m.termCwd+" $ "+input))
} else {
m.termLog = append(m.termLog, errMsgStyle.Render(" cd: "+err.Error()))
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
}
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ ")+input)
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, m.runTermCommand(input)
case "backspace":
if len(m.termInput) > 0 {
m.termInput = m.termInput[:len(m.termInput)-1]
m.viewport.SetContent(m.renderContent())
}
return m, nil
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,188 +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{"Dashboard", "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 studioPanel int
const (
panelChat studioPanel = iota
panelAgents
panelWorkflows
)
type configSection int
const (
configProfile configSection = iota
configProviders
configTerminal
configSkills
)
type Model struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
activeTab tab
width int
height int
viewport viewport.Model
ready bool
chatInput string
chatLog []string
chatLoading bool
orch *orchestrator.Orchestrator
proxyMgr *proxy.Manager
updateStatus []updater.UpdateStatus
installLog []string
previewURL string
previewSrv *preview.PreviewServer
daemon *daemon.Daemon
lspServers []lsp.LSPServer
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
}
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

@@ -2,7 +2,7 @@ package version
const (
Name = "muyue"
Version = "0.2.0"
Version = "0.2.1"
Author = "La Légion de Muyue"
License = "MIT"
)

4
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
!dist/.gitkeep
.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

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>muyue</title>
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

965
web/package-lock.json generated Normal file
View File

@@ -0,0 +1,965 @@
{
"name": "muyue-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "muyue-web",
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.9"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.7"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.5"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"dev": true,
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/vite": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

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

105
web/src/components/App.jsx Normal file
View File

@@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react'
import api from '../api/client'
import { getTheme, getThemeNames, applyTheme } from '../themes'
import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
const TABS = [
{ id: 'dash', label: 'DASH', icon: '[■]' },
{ id: 'studio', label: 'STUDIO', icon: '[<>]' },
{ id: 'shell', label: 'SHELL', icon: '[$]' },
{ id: 'config', label: 'CONFIG', icon: '[//]' },
]
export default function App() {
const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date())
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [transition, setTransition] = useState(false)
const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
// api is imported directly
useEffect(() => {
api.getInfo().then(setInfo).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
const theme = getTheme(currentTheme)
applyTheme(theme)
}, [])
useEffect(() => {
const timer = setInterval(() => setClock(new Date()), 1000)
return () => clearInterval(timer)
}, [])
const switchTab = useCallback((tabId) => {
if (tabId === activeTab) return
setTransition(true)
setTimeout(() => {
setActiveTab(tabId)
setTimeout(() => setTransition(false), 150)
}, 100)
}, [activeTab])
const hasUpdates = updates.some(u => u.needsUpdate)
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={(t) => setTools(t)} />
case 'studio': return <Studio api={api} />
case 'shell': return <Shell api={api} />
case 'config': return <Config api={api} theme={currentTheme} onThemeChange={setCurrentTheme} />
default: return null
}
}
return (
<div className="app-layout">
<header className="header">
<span className="header-logo">MUYUE</span>
<span className="header-version">v{info.version || '...'}</span>
<div className="header-tabs">
{TABS.map(tab => (
<div
key={tab.id}
className={`header-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => switchTab(tab.id)}
>
{tab.icon} {tab.label}
</div>
))}
</div>
<div className="header-spacer" />
<div className="header-status">
<span className={`status-dot ${tools.length > 0 ? 'ok' : 'off'}`} title="System" />
<span className={`status-dot ${hasUpdates ? 'warn' : 'ok'}`} title="Updates" />
</div>
<span className="header-date">{clock.toLocaleDateString('fr-FR')}</span>
<span className="header-clock">{clock.toLocaleTimeString('fr-FR')}</span>
</header>
<div className={`content ${transition ? 'glitch-text' : 'fade-in tab-transition'}`}>
{renderContent()}
</div>
<footer className="footer">
<span className="footer-shortcuts">
<kbd>1-4</kbd> tabs · <kbd>Ctrl+T</kbd> switcher · <kbd>Ctrl+C</kbd> quit
</span>
<span className={`footer-update ${hasUpdates ? 'available' : 'uptodate'}`}>
{hasUpdates ? '[UPD] Updates available' : '[OK] Up to date'}
</span>
<span className="footer-version">v{info.version || '...'}</span>
</footer>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useState, useEffect } from 'react'
import { getThemeNames, applyTheme, getTheme } from '../themes'
export default function Config({ api, theme, onThemeChange }) {
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
useEffect(() => {
api.getConfig().then(d => setConfig(d)).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
}, [])
const themes = getThemeNames()
const handleThemeChange = (themeId) => {
const t = getTheme(themeId)
applyTheme(t)
onThemeChange(themeId)
}
return (
<div className="config-container">
<div className="config-section">
<div className="section-header">Profile</div>
{config?.profile && (
<div>
<Field label="Name" value={config.profile.name} />
<Field label="Pseudo" value={config.profile.pseudo} />
<Field label="Email" value={config.profile.email} />
<Field label="Editor" value={config.profile.preferences?.editor} />
<Field label="Shell" value={config.profile.preferences?.shell} />
<Field label="Default AI" value={config.profile.preferences?.defaultAI} />
<Field label="Languages" value={config.profile.languages?.join(', ')} />
</div>
)}
</div>
<div className="config-section">
<div className="section-header">AI Providers</div>
{providers.map((p, i) => (
<div key={i} className="config-field" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: 'var(--text-bright)', fontWeight: 700 }}>{p.name}</span>
{p.active && <span style={{ color: 'var(--cyber-red)', fontSize: 11, fontWeight: 700 }}>{'>>'}</span>}
</div>
<div style={{ display: 'flex', gap: 16, fontSize: 12 }}>
<span style={{ color: 'var(--dim-red)' }}>model={p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
key={p.apiKey ? 'configured' : 'no key'}
</span>
</div>
</div>
))}
</div>
<div className="config-section">
<div className="section-header">Theme</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{themes.map(t => (
<button
key={t.id}
className={theme === t.id ? 'primary' : ''}
onClick={() => handleThemeChange(t.id)}
>
{t.name}
</button>
))}
</div>
</div>
<div className="config-section">
<div className="section-header">Skills ({skillList.length})</div>
{skillList.length === 0 ? (
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>No skills. Run `muyue skills init`.</span>
) : (
skillList.map((s, i) => (
<div key={i} className="tool-item">
<span className="tool-name">{s.name}</span>
<span style={{ color: 'var(--cyber-red)', fontSize: 11 }}>[{s.target || 'both'}]</span>
<span style={{ color: 'var(--dim-red)', fontSize: 11 }}>{s.description}</span>
</div>
))
)}
</div>
</div>
)
}
function Field({ label, value }) {
return (
<div className="config-field">
<span className="config-label">{label}:</span>
<span className="config-value">{value || '-'}</span>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useState, useEffect } from 'react'
export default function Dashboard({ tools, updates, api, onRescan }) {
const [installing, setInstalling] = useState(false)
const [installLog, setInstallLog] = useState([])
const installed = tools.filter(t => t.installed).length
const total = tools.length
const pct = total > 0 ? (installed / total) * 100 : 0
const missing = tools.filter(t => !t.installed).map(t => t.Name || t.name)
const handleInstall = async () => {
if (missing.length === 0) return
setInstalling(true)
setInstallLog(prev => [...prev, { text: `Installing ${missing.length} tools...`, type: 'info' }])
try {
await api.installTools(missing)
setInstallLog(prev => [...prev, { text: 'Install started. Rescan to see changes.', type: 'ok' }])
const data = await api.runScan()
const toolData = await api.getTools()
onRescan(toolData.tools || [])
} catch (err) {
setInstallLog(prev => [...prev, { text: err.message, type: 'error' }])
}
setInstalling(false)
}
const handleScan = async () => {
await api.runScan()
const data = await api.getTools()
onRescan(data.tools || [])
}
return (
<div className="grid-2">
<div style={{ overflow: 'auto', padding: '4px' }}>
<div className="section-header">System</div>
<div style={{ marginBottom: 16 }}>
<span style={{ color: 'var(--text-main)' }}>{installed}/{total} tools installed</span>
</div>
<div className="section-header">Installed Tools</div>
<div style={{ marginBottom: 8 }}>
{tools.map((t, i) => (
<div key={i} className="tool-item">
<span className={`tool-status ${t.installed ? 'ok' : 'missing'}`}>
{t.installed ? '[OK]' : '[--]'}
</span>
<span className="tool-name">{t.Name || t.name}</span>
{(t.Version || t.version) && (
<span className="tool-version">{extractVersion(t.Version || t.version)}</span>
)}
</div>
))}
</div>
<div className="progress-bar" style={{ marginBottom: 16 }}>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
{installing && (
<div style={{ marginBottom: 16 }}>
<span className="loading-spinner"> Installing...</span>
</div>
)}
{installLog.length > 0 && (
<div>
<div className="section-header">Install Log</div>
{installLog.map((log, i) => (
<div key={i} style={{
color: log.type === 'error' ? 'var(--error)' :
log.type === 'ok' ? 'var(--success)' : 'var(--text-dim)',
fontSize: 12, padding: '2px 0'
}}>
{log.text}
</div>
))}
</div>
)}
</div>
<div style={{ overflow: 'auto', padding: '4px' }}>
<div className="section-header">Quick Actions</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 }}>
<button onClick={handleInstall} disabled={installing || missing.length === 0}>
[i] Install missing ({missing.length})
</button>
<button onClick={() => api.getUpdates().then(d => {})}> [u] Check updates</button>
<button onClick={handleScan}>[s] Rescan system</button>
<button onClick={() => api.configureMCP()}>[m] Configure MCP</button>
</div>
<div className="section-header">Updates</div>
<div style={{ marginBottom: 16 }}>
{updates.length === 0 ? (
<span style={{ color: 'var(--text-muted)' }}>No update data yet</span>
) : updates.map((u, i) => (
<div key={i} className="tool-item">
<span className={`tool-status ${u.needsUpdate ? 'missing' : 'ok'}`}>
{u.needsUpdate ? '[!!]' : '[OK]'}
</span>
<span className="tool-name">{u.tool}</span>
{u.needsUpdate && (
<span style={{ color: 'var(--warning)', fontSize: 11 }}>
{u.current} {u.latest}
</span>
)}
</div>
))}
</div>
</div>
</div>
)
}
function extractVersion(s) {
if (!s) return ''
const m = s.match(/\d+\.\d+\.\d+/)
return m ? m[0] : s.slice(0, 12)
}

View File

@@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react'
export default function Shell({ api }) {
const [history, setHistory] = useState([])
const [input, setInput] = useState('')
const [cwd, setCwd] = useState('~')
const [aiPanel, setAiPanel] = useState(true)
const [aiMessages, setAiMessages] = useState([
{ role: 'ai', content: '>> I know your system inside out. Ask me anything.' }
])
const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const outputRef = useRef(null)
useEffect(() => {
outputRef.current?.scrollTo(0, outputRef.current.scrollHeight)
}, [history])
const handleCommand = async (cmd) => {
if (!cmd.trim()) return
if (cmd === 'clear') {
setHistory([])
return
}
if (cmd === 'exit' || cmd === 'quit') return
setHistory(prev => [...prev, { type: 'input', text: `${cwd} $ ${cmd}` }])
try {
const res = await api.runCommand(cmd, cwd === '~' ? '' : cwd)
if (res.output) {
setHistory(prev => [...prev, { type: 'output', text: res.output }])
}
if (res.error) {
setHistory(prev => [...prev, { type: 'error', text: res.error }])
}
if (cmd.startsWith('cd ')) {
const dir = cmd.slice(3).trim()
setCwd(dir === '~' ? '~' : dir)
}
} catch (err) {
setHistory(prev => [...prev, { type: 'error', text: err.message }])
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleCommand(input)
setInput('')
}
}
const handleAiSend = async () => {
if (!aiInput.trim() || aiLoading) return
const text = aiInput.trim()
setAiMessages(prev => [...prev, { role: 'user', content: '>> ' + text }])
setAiInput('')
setAiLoading(true)
try {
const res = await api.runCommand(`echo "AI: ${text}"`, '')
setAiMessages(prev => [...prev, { role: 'ai', content: '>> ' + (res.output || 'No response') }])
} catch (err) {
setAiMessages(prev => [...prev, { role: 'ai', content: '[ERROR] ' + err.message }])
}
setAiLoading(false)
}
return (
<div className="split-horizontal" style={{ height: '100%' }}>
<div className="terminal-container" style={{ flex: 1 }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border-dim)' }}>
<div className="section-header" style={{ margin: 0 }}>
Terminal
<span style={{ color: 'var(--dim-red)', fontWeight: 400, marginLeft: 12, fontSize: 11 }}>{cwd}</span>
</div>
</div>
<div className="terminal-output" ref={outputRef}>
{history.map((line, i) => (
<div key={i} style={{
color: line.type === 'input' ? 'var(--dim-red)' :
line.type === 'error' ? 'var(--error)' : 'var(--text-main)',
whiteSpace: 'pre-wrap',
fontFamily: 'var(--font-mono)',
fontSize: 13,
lineHeight: 1.4,
}}>
{line.text}
</div>
))}
</div>
<div className="terminal-input-row">
<span className="terminal-prompt">{'>'}</span>
<input
className="terminal-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
</div>
</div>
{aiPanel && (
<div style={{
width: 320,
borderLeft: '1px solid var(--border-dim)',
background: 'var(--bg-surface)',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border-dim)' }}>
<div className="section-header" style={{ margin: 0 }}>AI Assistant</div>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
{aiMessages.map((msg, i) => (
<div key={i} style={{
marginBottom: 8,
padding: '6px 8px',
borderRadius: 'var(--radius)',
background: msg.role === 'ai' ? 'var(--bg-card)' : 'var(--muted-red)',
borderLeft: `3px solid ${msg.role === 'ai' ? 'var(--cyber-red)' : 'var(--cyber-rose)'}`,
fontSize: 12,
lineHeight: 1.4,
}}>
{msg.content}
</div>
))}
{aiLoading && <span className="loading-spinner"> thinking...</span>}
</div>
<div style={{ padding: '8px 12px', borderTop: '1px solid var(--border-dim)', display: 'flex', gap: 6 }}>
<input
style={{ flex: 1, padding: '4px 8px', fontSize: 12 }}
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder="Ask AI..."
/>
<button style={{ padding: '4px 8px' }} onClick={handleAiSend}>Send</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { useState, useRef, useEffect } from 'react'
export default function Studio({ api }) {
const [messages, setMessages] = useState([
{ role: 'ai', content: '>> Welcome to Studio! Chat with your AI assistant here.' },
{ role: 'ai', content: '>> Configure agents and workflows from the sidebar.' },
])
const [input, setInput] = useState('')
const [sidebarPanel, setSidebarPanel] = useState('chat')
const [loading, setLoading] = useState(false)
const messagesEnd = useRef(null)
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSend = () => {
if (!input.trim() || loading) return
const text = input.trim()
setMessages(prev => [...prev, { role: 'user', content: '>> ' + text }])
setInput('')
setLoading(true)
api.runCommand(`echo "AI response simulation for: ${text}"`, '')
.then(res => {
setMessages(prev => [...prev, { role: 'ai', content: '>> ' + (res.output || res.error || 'No response') }])
})
.catch(err => {
setMessages(prev => [...prev, { role: 'ai', content: '[ERROR] ' + err.message }])
})
.finally(() => setLoading(false))
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="split-horizontal">
<div className="chat-container" style={{ flex: 1, borderRight: '1px solid var(--border-dim)' }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-dim)' }}>
<div className="section-header" style={{ margin: 0 }}>
Chat
{loading && <span className="loading-spinner" style={{ marginLeft: 8 }}> thinking...</span>}
</div>
</div>
<div className="chat-messages">
{messages.map((msg, i) => (
<div key={i} className={`chat-message ${msg.role}`}>
{msg.content}
</div>
))}
<div ref={messagesEnd} />
</div>
<div className="chat-input-container">
<input
className="chat-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (/plan <goal> for workflows)"
disabled={loading}
/>
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
Send
</button>
</div>
</div>
<div className="split-right">
<div className="sidebar-section">
<div className="section-header">Studio</div>
{['chat', 'agents', 'workflows'].map(panel => (
<div
key={panel}
className={`sidebar-item ${sidebarPanel === panel ? 'active' : ''}`}
onClick={() => setSidebarPanel(panel)}
>
[{panel === 'chat' ? '#' : panel === 'agents' ? '*' : '~'}] {panel.charAt(0).toUpperCase() + panel.slice(1)}
</div>
))}
</div>
<div style={{ borderTop: '1px solid var(--border-dim)', paddingTop: 12 }}>
{sidebarPanel === 'chat' && (
<div>
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginBottom: 8 }}>Commands</div>
<div style={{ color: 'var(--dim-red)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
/plan {'<goal>'}<br/>
/help
</div>
</div>
)}
{sidebarPanel === 'agents' && (
<div>
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginBottom: 8 }}>Active Agents</div>
<div style={{ padding: '4px 0' }}>
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>Crush</span>
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: 11 }}>[|| stopped]</span>
</div>
<div style={{ padding: '4px 0' }}>
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>Claude Code</span>
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: 11 }}>[|| stopped]</span>
</div>
</div>
)}
{sidebarPanel === 'workflows' && (
<div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No active workflow.</div>
<div style={{ color: 'var(--dim-red)', fontSize: 12, marginTop: 8 }}>
Use /plan {'<goal>'} in chat to start.
</div>
</div>
)}
</div>
</div>
</div>
)
}

9
web/src/main.jsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './components/App'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

576
web/src/styles/global.css Normal file
View File

@@ -0,0 +1,576 @@
:root {
--bg-void: #0A0A0C;
--bg-base: #0F0D10;
--bg-surface: #161218;
--bg-panel: #1C1719;
--bg-card: #221B1E;
--bg-input: #2A2225;
--bg-hover: #332528;
--cyber-red: #FF0033;
--cyber-red-dark: #8B0020;
--cyber-red-deep: #5C0015;
--cyber-pink: #FF1A5E;
--cyber-rose: #FF4D6D;
--neon-red: #FF1744;
--bright-red: #FF5252;
--dim-red: #6B2033;
--muted-red: #4A1525;
--text-bright: #EAE0E2;
--text-main: #D4C4C8;
--text-dim: #8A7A7E;
--text-muted: #5A4F52;
--success: #00E676;
--warning: #FFD740;
--error: #FF1744;
--border-dim: #2A1F22;
--border-red: #FF003344;
--border-red-full: #FF0033;
--radius: 8px;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--header-h: 48px;
--footer-h: 32px;
--tab-h: 40px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
background: var(--bg-void);
color: var(--text-main);
font-family: var(--font-ui);
font-size: 13px;
-webkit-font-smoothing: antialiased;
user-select: none;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--dim-red);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--cyber-red-dark);
}
::selection {
background: var(--cyber-red);
color: #fff;
}
a {
color: var(--cyber-red);
text-decoration: none;
}
a:hover {
color: var(--cyber-pink);
}
input, textarea {
font-family: var(--font-mono);
font-size: 13px;
background: var(--bg-input);
color: var(--text-main);
border: 1px solid var(--border-dim);
border-radius: var(--radius);
padding: 8px 12px;
outline: none;
transition: border-color 0.2s;
}
input:focus, textarea:focus {
border-color: var(--cyber-red);
box-shadow: 0 0 0 2px var(--border-red);
}
button {
font-family: var(--font-ui);
font-size: 12px;
font-weight: 600;
padding: 6px 14px;
border-radius: var(--radius);
border: 1px solid var(--border-dim);
background: var(--bg-card);
color: var(--text-main);
cursor: pointer;
transition: all 0.15s;
}
button:hover {
background: var(--bg-hover);
border-color: var(--cyber-red-dark);
}
button.primary {
background: var(--cyber-red);
color: #fff;
border-color: var(--cyber-red);
}
button.primary:hover {
background: var(--neon-red);
}
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.header {
height: var(--header-h);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-dim);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
flex-shrink: 0;
}
.header-logo {
font-family: var(--font-mono);
font-weight: 900;
font-size: 16px;
color: var(--cyber-red);
letter-spacing: 2px;
}
.header-version {
font-size: 11px;
color: var(--dim-red);
}
.header-tabs {
display: flex;
gap: 2px;
margin-left: 24px;
}
.header-tab {
padding: 6px 16px;
border-radius: var(--radius) var(--radius) 0 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 1px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
border-bottom: none;
text-transform: uppercase;
background: transparent;
}
.header-tab:hover {
color: var(--text-main);
background: var(--bg-card);
}
.header-tab.active {
color: #fff;
background: var(--cyber-red);
border-color: var(--cyber-red);
}
.header-spacer {
flex: 1;
}
.header-status {
display: flex;
gap: 8px;
align-items: center;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.ok { background: var(--success); }
.status-dot.warn { background: var(--warning); }
.status-dot.error { background: var(--error); }
.status-dot.off { background: var(--text-muted); }
.header-clock {
font-family: var(--font-mono);
font-size: 12px;
color: var(--cyber-red);
font-weight: 700;
}
.header-date {
font-size: 11px;
color: var(--text-muted);
}
.content {
flex: 1;
overflow: hidden;
}
.footer {
height: var(--footer-h);
background: var(--bg-surface);
border-top: 1px solid var(--border-dim);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
flex-shrink: 0;
}
.footer-shortcuts {
font-size: 11px;
color: var(--text-muted);
}
.footer-shortcuts kbd {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: 3px;
padding: 1px 5px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
}
.footer-version {
font-size: 11px;
color: var(--dim-red);
}
.footer-update {
font-size: 11px;
font-weight: 600;
}
.footer-update.available {
color: var(--warning);
}
.footer-update.uptodate {
color: var(--success);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius);
padding: 16px;
transition: border-color 0.2s;
}
.card:hover {
border-color: var(--dim-red);
}
.card-title {
font-family: var(--font-mono);
font-weight: 700;
font-size: 13px;
color: var(--cyber-red);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.section-header {
font-family: var(--font-mono);
font-weight: 700;
font-size: 13px;
color: var(--cyber-red);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.section-header::before {
content: '■';
font-size: 10px;
}
.tool-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
}
.tool-status {
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
width: 36px;
}
.tool-status.ok { color: var(--success); }
.tool-status.missing { color: var(--error); }
.tool-name {
color: var(--text-main);
}
.tool-version {
color: var(--dim-red);
font-size: 11px;
}
.progress-bar {
height: 6px;
background: var(--bg-input);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--cyber-red), var(--cyber-pink));
border-radius: 3px;
transition: width 0.3s;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 100%;
padding: 16px;
}
.split-horizontal {
display: flex;
height: 100%;
}
.split-left {
flex: 1;
overflow: auto;
}
.split-right {
width: 320px;
border-left: 1px solid var(--border-dim);
background: var(--bg-surface);
overflow: auto;
padding: 16px;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding-bottom: 8px;
}
.chat-message {
margin-bottom: 12px;
padding: 8px 12px;
border-radius: var(--radius);
font-size: 13px;
line-height: 1.5;
}
.chat-message.ai {
background: var(--bg-card);
border-left: 3px solid var(--cyber-red);
}
.chat-message.user {
background: var(--muted-red);
border-left: 3px solid var(--cyber-rose);
margin-left: 24px;
}
.chat-input-container {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-dim);
}
.chat-input {
flex: 1;
font-family: var(--font-mono);
}
.sidebar-section {
margin-bottom: 16px;
}
.sidebar-item {
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: var(--text-dim);
transition: all 0.15s;
}
.sidebar-item:hover {
background: var(--bg-card);
color: var(--text-main);
}
.sidebar-item.active {
background: var(--cyber-red);
color: #fff;
font-weight: 600;
}
.terminal-container {
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
}
.terminal-output {
flex: 1;
padding: 12px;
font-family: var(--font-mono);
font-size: 13px;
overflow-y: auto;
white-space: pre-wrap;
background: var(--bg-void);
}
.terminal-input-row {
display: flex;
align-items: center;
padding: 8px 12px;
background: var(--bg-input);
border-top: 1px solid var(--border-dim);
}
.terminal-prompt {
color: var(--success);
font-family: var(--font-mono);
font-weight: 700;
margin-right: 8px;
}
.terminal-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-main);
font-family: var(--font-mono);
font-size: 13px;
}
.config-container {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.config-section {
margin-bottom: 24px;
}
.config-field {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border-dim);
}
.config-label {
width: 140px;
color: var(--text-dim);
font-size: 12px;
}
.config-value {
color: var(--text-main);
font-size: 13px;
}
@keyframes glitch {
0% { transform: translate(0); }
20% { transform: translate(-2px, 2px); }
40% { transform: translate(-2px, -2px); }
60% { transform: translate(2px, 2px); }
80% { transform: translate(2px, -2px); }
100% { transform: translate(0); }
}
@keyframes scanline {
0% { top: -10%; }
100% { top: 110%; }
}
@keyframes typewriter {
from { width: 0; }
to { width: 100%; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
.tab-transition {
animation: fadeIn 0.2s ease-out;
}
.glitch-text {
animation: glitch 0.3s ease-in-out;
}
.loading-spinner {
display: inline-block;
animation: pulse 1s infinite;
color: var(--cyber-red);
}

138
web/src/themes/index.js Normal file
View File

@@ -0,0 +1,138 @@
const defaultTheme = {
name: 'Cyberpunk Red',
colors: {
bgVoid: '#0A0A0C',
bgBase: '#0F0D10',
bgSurface: '#161218',
bgPanel: '#1C1719',
bgCard: '#221B1E',
bgInput: '#2A2225',
bgHover: '#332528',
cyberRed: '#FF0033',
cyberRedDark: '#8B0020',
cyberRedDeep: '#5C0015',
cyberPink: '#FF1A5E',
cyberRose: '#FF4D6D',
neonRed: '#FF1744',
brightRed: '#FF5252',
dimRed: '#6B2033',
mutedRed: '#4A1525',
textBright: '#EAE0E2',
textMain: '#D4C4C8',
textDim: '#8A7A7E',
textMuted: '#5A4F52',
success: '#00E676',
warning: '#FFD740',
error: '#FF1744',
borderDim: '#2A1F22',
borderRed: '#FF003344',
borderRedFull: '#FF0033',
},
fonts: {
mono: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
},
borderRadius: '8px',
animations: {
glitch: true,
scanline: true,
typewriter: true,
pulse: true,
},
}
const themes = {
'cyberpunk-red': defaultTheme,
'cyberpunk-pink': {
...defaultTheme,
name: 'Cyberpunk Pink',
colors: {
...defaultTheme.colors,
cyberRed: '#FF1A8C',
cyberRedDark: '#8B1050',
cyberRedDeep: '#5C0A35',
cyberPink: '#FF4DAE',
cyberRose: '#FF6DC2',
neonRed: '#FF1A8C',
brightRed: '#FF6DC2',
dimRed: '#6B2050',
mutedRed: '#4A1535',
},
},
'midnight-blue': {
...defaultTheme,
name: 'Midnight Blue',
colors: {
...defaultTheme.colors,
cyberRed: '#0088FF',
cyberRedDark: '#004488',
cyberRedDeep: '#002255',
cyberPink: '#00AAFF',
cyberRose: '#44CCFF',
neonRed: '#0088FF',
brightRed: '#44CCFF',
dimRed: '#203366',
mutedRed: '#152244',
},
},
'matrix-green': {
...defaultTheme,
name: 'Matrix Green',
colors: {
...defaultTheme.colors,
cyberRed: '#00FF41',
cyberRedDark: '#008822',
cyberRedDeep: '#005515',
cyberPink: '#33FF66',
cyberRose: '#66FF99',
neonRed: '#00FF41',
brightRed: '#66FF99',
dimRed: '#206630',
mutedRed: '#154420',
},
},
}
export function getTheme(name) {
return themes[name] || defaultTheme
}
export function getThemeNames() {
return Object.keys(themes).map(k => ({ id: k, name: themes[k].name }))
}
export function applyTheme(theme) {
const root = document.documentElement
const c = theme.colors
const map = {
'--bg-void': c.bgVoid,
'--bg-base': c.bgBase,
'--bg-surface': c.bgSurface,
'--bg-panel': c.bgPanel,
'--bg-card': c.bgCard,
'--bg-input': c.bgInput,
'--bg-hover': c.bgHover,
'--cyber-red': c.cyberRed,
'--cyber-red-dark': c.cyberRedDark,
'--cyber-red-deep': c.cyberRedDeep,
'--cyber-pink': c.cyberPink,
'--cyber-rose': c.cyberRose,
'--neon-red': c.neonRed,
'--bright-red': c.brightRed,
'--dim-red': c.dimRed,
'--muted-red': c.mutedRed,
'--text-bright': c.textBright,
'--text-main': c.textMain,
'--text-dim': c.textDim,
'--text-muted': c.textMuted,
'--success': c.success,
'--warning': c.warning,
'--error': c.error,
'--border-dim': c.borderDim,
'--border-red': c.borderRed,
'--border-red-full': c.borderRedFull,
}
Object.entries(map).forEach(([k, v]) => root.style.setProperty(k, v))
}
export default defaultTheme

19
web/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8095',
changeOrigin: true,
},
},
},
})