Compare commits

...

9 Commits

Author SHA1 Message Date
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
CI Bot
6dad84067d chore: update CHANGELOG for v0.2.0 2026-04-20 19:08:34 +00:00
39 changed files with 3840 additions and 444 deletions

View File

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

View File

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

View File

@@ -15,6 +15,11 @@ jobs:
with: with:
go-version: '1.24.3' go-version: '1.24.3'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules - name: Cache Go modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -25,9 +30,23 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-go- ${{ 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 - name: Download dependencies
run: go mod download run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Vet - name: Vet
run: go vet ./... run: go vet ./...

3
.gitignore vendored
View File

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

View File

@@ -4,6 +4,158 @@ 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/). 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
- refactor: redesign TUI with 4 tabs, red/rose theme, split layouts (035e923)
- feat: GitFlow workflow with beta/stable CI pipelines (bbdac6c)
- feat: security hardening, tests, doctor command, CI update, CHANGELOG (3494f6b)
- refactor: modularize TUI, improve error handling, add CI caching and tests (4469122)
- fix: remove tab switching, filter AI thinking from responses (5a33dfc)
- fix: enable text selection, dashboard multi-column layout (82b2816)
- feat: Ctrl+T tab switcher, minimal header, integrated terminal (2d6fc64)
- feat: Ctrl+M tab switcher overlay menu (bb3b303)
- fix: docker version check, uv PATH, install progress bar (e6fdec4)
- feat: smart setup wizard - sort choices by system detection (1be4fc0)
- fix: use Alt+1-5 for tab navigation to free number keys for input (825b429)
- ci: add install instructions for all platforms in release body (ac35ff2)
- ci: add build + release steps with push-only conditions (bcb9aa0)
- ci: restore exact working ci.yml from e58e00d for testing (0a91cef)
- fix: rename workflow back to CI (slash in name breaks Gitea 1.25) (461122a)
- ci: trigger workflow run (ea59c2c)
- fix: remove workflow_dispatch + add push-only conditions on release steps (9cd583f)
- ci: single job - build + vet + release latest in one pass (92275be)
- ci: merge CI and Release into single workflow (f2c0996)
- fix: release workflow - delete old release before creating new one (5eb237f)
- feat: redesign TUI + Ctrl+C quit confirm + version logic + sudo handling (e3cd618)
- feat: add mouse support + install pnpm, uv, docker, gh (e58e00d)
- fix: use GITEATOKEN secret name (no underscores in Gitea 1.25) (8e3f8b8)
- fix: make release delete step resilient + check GITEA_TOKEN (69ca5c6)
- fix: remove redundant newline in profiler.go (go vet) (2d421fe)
- fix: export PATH in every step for Gitea runner compatibility (3f8e01f)
- ci: restore actions/checkout + simplify workflows (4db69e4)
- fix: add missing cmd/muyue/main.go and fix .gitignore (f650988)
- ci: fix Gitea Actions - native checkout + auto-release on push (78c7239)
- ci: migrate workflows to Gitea Actions with self-hosted runner (811a9aa)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/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.0/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.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [0.2.0] - 2026-04-20 ## [0.2.0] - 2026-04-20
### Added ### Added

View File

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

View File

@@ -76,25 +76,49 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill muyue skills delete <name> # Delete a skill
``` ```
## TUI Controls ## TUI — 4 Tabs
| Key | Action | The TUI is organized into 4 tabs with a red/rose theme (`#E8364F``#FF6B8A`):
|-----|--------|
| `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 |
### 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 ## Configuration
@@ -179,7 +203,7 @@ git push -u origin feature/my-feature
```bash ```bash
# 1. Bump the version in internal/version/version.go # 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: # Commit on develop:
git checkout develop git checkout develop
# (edit internal/version/version.go) # (edit internal/version/version.go)
@@ -222,7 +246,7 @@ git push
```go ```go
const ( 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 ```bash
# Beta build (automatic in CI) # Beta build (automatic in CI)
go build -ldflags="-X github.com/muyue/muyue/internal/version.Prerelease=beta.3" ./cmd/muyue/ 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) # Stable build (automatic in CI)
go build -ldflags="-s -w" ./cmd/muyue/ go build -ldflags="-s -w" ./cmd/muyue/
# → muyue v0.2.0 # → muyue v0.2.1
``` ```
### Conventional commits ### Conventional commits

View File

@@ -7,6 +7,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/installer" "github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/mcp"
@@ -53,6 +54,8 @@ func handleCommand(args []string) {
runLSP(args[1:]) runLSP(args[1:])
case "mcp": case "mcp":
runMCP(args[1:]) runMCP(args[1:])
case "desktop":
runDesktop(args[1:])
case "skills": case "skills":
runSkills(args[1:]) runSkills(args[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
@@ -79,6 +82,7 @@ Commands:
setup Run first-time setup wizard setup Run first-time setup wizard
config Show current configuration config Show current configuration
doctor Check that everything is properly configured doctor Check that everything is properly configured
desktop Launch desktop web UI (opens browser)
lsp [scan|install] Scan or install LSP servers lsp [scan|install] Scan or install LSP servers
mcp [config|scan] Configure MCP servers for Crush and Claude Code mcp [config|scan] Configure MCP servers for Crush and Claude Code
skills [list|generate|deploy|init|delete] Manage AI coding skills skills [list|generate|deploy|init|delete] Manage AI coding skills
@@ -118,6 +122,14 @@ func runTUI() {
} }
} }
func runDesktop(args []string) {
cfg := loadOrSetupConfig()
if err := desktop.Run(cfg, args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func loadOrSetupConfig() *config.MuyueConfig { func loadOrSetupConfig() *config.MuyueConfig {
if !config.Exists() { if !config.Exists() {
fmt.Println("First time setup detected!") fmt.Println("First time setup detected!")

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
}

115
internal/tui/animations.go Normal file
View File

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

View File

@@ -2,18 +2,11 @@ package tui
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "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 { func (m Model) renderConfig() string {
colWidth := m.width / 2 colWidth := m.width / 2
if colWidth < 30 { if colWidth < 30 {
@@ -22,7 +15,7 @@ func (m Model) renderConfig() string {
var left, right strings.Builder var left, right strings.Builder
left.WriteString(renderSectionWithIcon("Profile", "👤")) left.WriteString(renderSectionHeader("PROFILE", "[@]"))
left.WriteString("\n") left.WriteString("\n")
if m.config != nil { if m.config != nil {
fields := []struct { fields := []struct {
@@ -50,28 +43,28 @@ func (m Model) renderConfig() string {
} }
left.WriteString("\n") left.WriteString("\n")
left.WriteString(renderSectionWithIcon("AI Providers", "")) left.WriteString(renderSectionHeader("AI PROVIDERS", "[AI]"))
left.WriteString("\n") left.WriteString("\n")
if m.config != nil { if m.config != nil {
for _, p := range m.config.AI.Providers { for _, p := range m.config.AI.Providers {
active := "" active := ""
if p.Active { if p.Active {
active = lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" ") active = lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" >>")
} }
keyStatus := itemMissingStyle.Render("no key") keyStatus := itemMissingStyle.Render("no key")
if p.APIKey != "" { if p.APIKey != "" {
keyStatus = itemOKStyle.Render("configured") keyStatus = itemOKStyle.Render("configured")
} }
nameStyle := lipgloss.NewStyle().Foreground(textColor).Bold(true) nameStyle := lipgloss.NewStyle().Foreground(textBright).Bold(true)
left.WriteString(fmt.Sprintf(" %s %s key=%s%s\n", left.WriteString(fmt.Sprintf(" %s %s key=%s%s\n",
nameStyle.Render(p.Name), nameStyle.Render(p.Name),
lipgloss.NewStyle().Foreground(dimColor).Render("model="+p.Model), lipgloss.NewStyle().Foreground(dimRed).Render("model="+p.Model),
keyStatus, active)) keyStatus, active))
} }
} }
left.WriteString("\n") left.WriteString("\n")
right.WriteString(renderSectionWithIcon("Terminal", "")) right.WriteString(renderSectionHeader("TERMINAL", "[$]"))
right.WriteString("\n") right.WriteString("\n")
if m.config != nil { 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 %v\n", labelStyle.Render("Custom Prompt:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Terminal.CustomPrompt))))
@@ -81,12 +74,12 @@ func (m Model) renderConfig() string {
} }
right.WriteString("\n") right.WriteString("\n")
right.WriteString(renderSectionWithIcon("BMAD Method", "")) right.WriteString(renderSectionHeader("BMAD METHOD", "[B]"))
right.WriteString("\n") right.WriteString("\n")
if m.config != nil { if m.config != nil {
installed := itemMissingStyle.Render("no") installed := itemMissingStyle.Render("[--] no")
if m.config.BMAD.Installed { if m.config.BMAD.Installed {
installed = itemOKStyle.Render("yes") installed = itemOKStyle.Render("[OK] yes")
} }
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Installed:"), installed)) 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)))) right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Global:"), valueStyle.Render(fmt.Sprintf("%v", m.config.BMAD.Global))))
@@ -96,7 +89,7 @@ func (m Model) renderConfig() string {
} }
right.WriteString("\n") right.WriteString("\n")
right.WriteString(renderSectionWithIcon(fmt.Sprintf("Skills (%d)", len(m.skillList)), "")) right.WriteString(renderSectionHeader(fmt.Sprintf("SKILLS (%d)", len(m.skillList)), "[!]"))
right.WriteString("\n") right.WriteString("\n")
if len(m.skillList) > 0 { if len(m.skillList) > 0 {
for _, s := range m.skillList { for _, s := range m.skillList {
@@ -105,12 +98,12 @@ func (m Model) renderConfig() string {
target = "both" target = "both"
} }
right.WriteString(fmt.Sprintf(" %s %s %s\n", right.WriteString(fmt.Sprintf(" %s %s %s\n",
lipgloss.NewStyle().Foreground(textColor).Render(s.Name), lipgloss.NewStyle().Foreground(textMain).Render(s.Name),
lipgloss.NewStyle().Foreground(primaryColor).Render("["+target+"]"), lipgloss.NewStyle().Foreground(cyberRed).Render("["+target+"]"),
lipgloss.NewStyle().Foreground(dimColor).Render(s.Description))) lipgloss.NewStyle().Foreground(dimRed).Render(s.Description)))
} }
} else { } else {
right.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(" No skills. Run `muyue skills init`.")) right.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(" No skills. Run `muyue skills init`."))
right.WriteString("\n") right.WriteString("\n")
} }

View File

@@ -15,16 +15,16 @@ func (m Model) renderDashboard() string {
var left, right strings.Builder var left, right strings.Builder
left.WriteString(renderSectionWithIcon("System", "")) left.WriteString(renderSectionHeader("SYSTEM", "[*]"))
left.WriteString("\n") left.WriteString("\n")
if m.scanResult != nil { if m.scanResult != nil {
sysInfo := m.scanResult.System.String() sysInfo := m.scanResult.System.String()
left.WriteString(" ") left.WriteString(" ")
left.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(sysInfo)) left.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(sysInfo))
} }
left.WriteString("\n\n") left.WriteString("\n\n")
left.WriteString(renderSectionWithIcon("Installed Tools", "")) left.WriteString(renderSectionHeader("INSTALLED TOOLS", "[+]"))
left.WriteString("\n") left.WriteString("\n")
if m.scanResult != nil { if m.scanResult != nil {
installed := 0 installed := 0
@@ -33,14 +33,14 @@ func (m Model) renderDashboard() string {
if t.Installed { if t.Installed {
installed++ installed++
left.WriteString(" ") left.WriteString(" ")
left.WriteString(itemOKStyle.Render(" ")) left.WriteString(itemOKStyle.Render("[OK] "))
left.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(t.Name)) left.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(t.Name))
left.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %s", extractVersion(t.Version)))) left.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %s", extractVersion(t.Version))))
left.WriteString("\n") left.WriteString("\n")
} else { } else {
left.WriteString(" ") left.WriteString(" ")
left.WriteString(itemMissingStyle.Render(" ")) left.WriteString(itemMissingStyle.Render("[--] "))
left.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(t.Name)) left.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(t.Name))
left.WriteString(itemPendingStyle.Render(" (missing)")) left.WriteString(itemPendingStyle.Render(" (missing)"))
left.WriteString("\n") left.WriteString("\n")
} }
@@ -51,14 +51,14 @@ func (m Model) renderDashboard() string {
if total > 0 { if total > 0 {
pct = (installed * barWidth) / total pct = (installed * barWidth) / total
} }
bar := lipgloss.NewStyle().Foreground(primaryColor).Render(strings.Repeat("█", pct)) + bar := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(strings.Repeat("█", pct)) +
lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("░", barWidth-pct)) lipgloss.NewStyle().Foreground(dimRed).Render(strings.Repeat("░", barWidth-pct))
left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total)) left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total))
} }
left.WriteString("\n") left.WriteString("\n")
if m.installing { if m.installing {
left.WriteString(renderSectionWithIcon("Installing", "")) left.WriteString(renderSectionHeader("INSTALLING", "[~]"))
left.WriteString("\n") left.WriteString("\n")
progBar := m.progressBar.View() progBar := m.progressBar.View()
label := "" label := ""
@@ -72,7 +72,7 @@ func (m Model) renderDashboard() string {
} }
if len(m.installLog) > 0 { if len(m.installLog) > 0 {
left.WriteString(renderSectionWithIcon("Install Log", "📋")) left.WriteString(renderSectionHeader("INSTALL LOG", "[#]"))
left.WriteString("\n") left.WriteString("\n")
for _, l := range m.installLog { for _, l := range m.installLog {
left.WriteString(l + "\n") left.WriteString(l + "\n")
@@ -80,27 +80,27 @@ func (m Model) renderDashboard() string {
left.WriteString("\n") left.WriteString("\n")
} }
right.WriteString(renderSectionWithIcon("Quick Actions", "")) right.WriteString(renderSectionHeader("QUICK ACTIONS", "[!]"))
right.WriteString("\n") right.WriteString("\n")
actions := []struct { actions := []struct {
key string key string
desc string desc string
color lipgloss.Color color lipgloss.Color
}{ }{
{"i", "Install missing tools", primaryColor}, {"i", "Install missing tools", cyberRed},
{"u", "Check for updates", warmColor}, {"u", "Check for updates", neonRed},
{"s", "Rescan system", roseColor}, {"s", "Rescan system", cyberPink},
{"l", "Scan LSP servers", accentColor}, {"l", "Scan LSP servers", cyberRose},
{"m", "Configure MCP servers", roseLightColor}, {"m", "Configure MCP servers", brightRed},
} }
for _, a := range actions { for _, a := range actions {
right.WriteString(fmt.Sprintf(" %s %s\n", right.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(a.color).Bold(true).Render("["+a.key+"]"), lipgloss.NewStyle().Foreground(a.color).Bold(true).Render("["+a.key+"]"),
lipgloss.NewStyle().Foreground(textColor).Render(a.desc))) lipgloss.NewStyle().Foreground(textMain).Render(a.desc)))
} }
right.WriteString("\n") right.WriteString("\n")
right.WriteString(renderSectionWithIcon("Active Agents", "")) right.WriteString(renderSectionHeader("ACTIVE AGENTS", "[*]"))
right.WriteString("\n") right.WriteString("\n")
agents := []struct { agents := []struct {
@@ -111,24 +111,24 @@ func (m Model) renderDashboard() string {
} }
for _, a := range agents { for _, a := range agents {
right.WriteString(" ") right.WriteString(" ")
right.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(" ")) right.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(">> "))
right.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render(a.name + " ")) right.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(a.name + " "))
right.WriteString(itemPendingStyle.Render("stopped")) right.WriteString(itemPendingStyle.Render("[stopped]"))
right.WriteString("\n") right.WriteString("\n")
} }
right.WriteString("\n") right.WriteString("\n")
if len(m.updateStatus) > 0 { if len(m.updateStatus) > 0 {
right.WriteString(renderSectionWithIcon("Updates", "")) right.WriteString(renderSectionHeader("UPDATES", "[^]"))
right.WriteString("\n") right.WriteString("\n")
for _, s := range m.updateStatus { for _, s := range m.updateStatus {
if s.NeedsUpdate { if s.NeedsUpdate {
right.WriteString(" ") right.WriteString(" ")
right.WriteString(itemWarnStyle.Render(" ")) right.WriteString(itemWarnStyle.Render("[!!] "))
right.WriteString(fmt.Sprintf("%s: %s %s\n", s.Tool, s.Current, s.Latest)) right.WriteString(fmt.Sprintf("%s: %s -> %s\n", s.Tool, s.Current, s.Latest))
} else if s.Error == "" { } else if s.Error == "" {
right.WriteString(" ") right.WriteString(" ")
right.WriteString(itemOKStyle.Render(" ")) right.WriteString(itemOKStyle.Render("[OK] "))
right.WriteString(fmt.Sprintf("%s: up to date\n", s.Tool)) right.WriteString(fmt.Sprintf("%s: up to date\n", s.Tool))
} }
} }
@@ -136,18 +136,18 @@ func (m Model) renderDashboard() string {
} }
if len(m.lspServers) > 0 { if len(m.lspServers) > 0 {
right.WriteString(renderSectionWithIcon("LSP Servers", "§")) right.WriteString(renderSectionHeader("LSP SERVERS", "[L]"))
right.WriteString("\n") right.WriteString("\n")
lspInstalled := 0 lspInstalled := 0
for _, s := range m.lspServers { for _, s := range m.lspServers {
if s.Installed { if s.Installed {
lspInstalled++ lspInstalled++
right.WriteString(" ") right.WriteString(" ")
right.WriteString(itemOKStyle.Render(" ")) right.WriteString(itemOKStyle.Render("[OK] "))
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language)) right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
} else { } else {
right.WriteString(" ") right.WriteString(" ")
right.WriteString(itemPendingStyle.Render(" ")) right.WriteString(itemPendingStyle.Render("[ ] "))
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language)) right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
} }
} }
@@ -155,16 +155,16 @@ func (m Model) renderDashboard() string {
right.WriteString("\n") right.WriteString("\n")
} }
mcpStatus := itemPendingStyle.Render(" not configured") mcpStatus := itemPendingStyle.Render("[ ] not configured")
if m.mcpConfigured { if m.mcpConfigured {
mcpStatus = itemOKStyle.Render(" configured") mcpStatus = itemOKStyle.Render("[OK] configured")
} }
right.WriteString(fmt.Sprintf(" MCP: %s\n", mcpStatus)) right.WriteString(fmt.Sprintf(" MCP: %s\n", mcpStatus))
if m.daemon != nil { if m.daemon != nil {
daemonStatus := itemPendingStyle.Render(" stopped") daemonStatus := itemPendingStyle.Render("[ ] stopped")
if m.daemon.IsRunning() { if m.daemon.IsRunning() {
daemonStatus = itemOKStyle.Render(" running") daemonStatus = itemOKStyle.Render("[OK] running")
} }
right.WriteString(fmt.Sprintf(" Daemon: %s\n", daemonStatus)) right.WriteString(fmt.Sprintf(" Daemon: %s\n", daemonStatus))
} }
@@ -174,8 +174,3 @@ func (m Model) renderDashboard() string {
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
} }
func renderSectionWithIcon(title string, icon string) string {
return lipgloss.NewStyle().Foreground(primaryColor).Render(icon+" ") +
sectionStyle.Render(title)
}

74
internal/tui/footer.go Normal file
View File

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

View File

@@ -142,16 +142,14 @@ func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case "enter": case "enter":
m.activeTab = tab(m.tabMenuCursor) m.switchTab(tab(m.tabMenuCursor))
m.showingTabMenu = false m.showingTabMenu = false
m.resizeViewport()
return m, nil return m, nil
default: default:
for i := 0; i < int(tabCount); i++ { for i := 0; i < int(tabCount); i++ {
if msg.String() == fmt.Sprintf("%d", i+1) { if msg.String() == fmt.Sprintf("%d", i+1) {
m.activeTab = tab(i) m.switchTab(tab(i))
m.showingTabMenu = false m.showingTabMenu = false
m.resizeViewport()
return m, nil return m, nil
} }
} }
@@ -159,6 +157,17 @@ func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *Model) switchTab(t tab) {
if t == m.activeTab {
return
}
m.prevTab = m.activeTab
m.activeTab = t
m.transition = transitionGlitch
m.transitionTick = 0
m.resizeViewport()
}
func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "i": case "i":
@@ -174,13 +183,13 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
} }
if len(missing) == 0 { if len(missing) == 0 {
m.installLog = append(m.installLog, itemOKStyle.Render(" All tools already installed!")) m.installLog = append(m.installLog, itemOKStyle.Render("[OK] All tools already installed!"))
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
} }
needsSudo := checkNeedsSudo(m.scanResult) needsSudo := checkNeedsSudo(m.scanResult)
if needsSudo && !hasSudo() { if needsSudo && !hasSudo() {
m.installLog = append(m.installLog, errMsgStyle.Render(" Some tools require sudo. Run: sudo muyue install")) m.installLog = append(m.installLog, errMsgStyle.Render("[!!] Some tools require sudo. Run: sudo muyue install"))
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
} }
@@ -267,7 +276,7 @@ func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "a": case "a":
if wf.Phase == workflow.PhaseReviewing { if wf.Phase == workflow.PhaseReviewing {
m.chatLog = append(m.chatLog, userMsgStyle.Render(" [Plan approved]")) m.chatLog = append(m.chatLog, userMsgStyle.Render(">> [Plan approved]"))
m.chatLoading = true m.chatLoading = true
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, reviewPlanCmd(m.orch, true, "") return m, reviewPlanCmd(m.orch, true, "")
@@ -280,7 +289,7 @@ func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case "g": case "g":
if wf.Phase == workflow.PhaseGathering && len(wf.Plan.Questions) > 0 && len(wf.Plan.Answers) >= len(wf.Plan.Questions) { 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.chatLog = append(m.chatLog, userMsgStyle.Render(">> [Generate plan]"))
m.chatLoading = true m.chatLoading = true
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, generatePlanCmd(m.orch) return m, generatePlanCmd(m.orch)
@@ -332,7 +341,7 @@ func hasSudo() bool {
func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) { func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
input := m.chatInput input := m.chatInput
m.chatLog = append(m.chatLog, userMsgStyle.Render(" "+input)) m.chatLog = append(m.chatLog, userMsgStyle.Render(">> "+input))
m.chatInput = "" m.chatInput = ""
m.chatLoading = true m.chatLoading = true
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())

178
internal/tui/header.go Normal file
View File

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

View File

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

View File

@@ -22,7 +22,6 @@ import (
"github.com/muyue/muyue/internal/proxy" "github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills" "github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/version"
) )
func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model { func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
@@ -44,9 +43,9 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
sp := spinner.New() sp := spinner.New()
sp.Spinner = spinner.Dot sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(primaryColor) sp.Style = lipgloss.NewStyle().Foreground(cyberRed)
prog := progress.New(progress.WithGradient("#E8364F", "#FF6B8A")) prog := progress.New(progress.WithGradient("#FF0033", "#FF1A5E"))
cwd, _ := os.Getwd() cwd, _ := os.Getwd()
@@ -55,8 +54,8 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
scanResult: scan, scanResult: scan,
activeTab: tabDashboard, activeTab: tabDashboard,
chatLog: []string{ chatLog: []string{
aiMsgStyle.Render(" Welcome to Studio! Chat with your AI assistant here."), 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."), aiMsgStyle.Render(" >> Configure agents and workflows from the sidebar. Type /plan <goal> to start."),
}, },
orch: orch, orch: orch,
proxyMgr: proxyMgr, proxyMgr: proxyMgr,
@@ -77,12 +76,14 @@ func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
studioPanel: panelChat, studioPanel: panelChat,
studioSidebarOpen: true, studioSidebarOpen: true,
termAIChat: []string{ termAIChat: []string{
aiMsgStyle.Render(" I know your system inside out. Ask me anything."), aiMsgStyle.Render(" >> I know your system inside out. Ask me anything."),
}, },
termAIShow: true, termAIShow: true,
configSection: configProfile, configSection: configProfile,
configField: 0, configField: 0,
animationFrame: 0, animationFrame: 0,
currentTime: time.Now(),
transition: transitionNone,
} }
} }
@@ -92,8 +93,14 @@ func animTick() tea.Cmd {
}) })
} }
func clockTick() tea.Cmd {
return tea.Tick(1*time.Second, func(t time.Time) tea.Msg {
return clockTickMsg{time: t}
})
}
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch(spinner.Tick, animTick(), tea.EnterAltScreen) return tea.Batch(spinner.Tick, animTick(), clockTick(), tea.EnterAltScreen)
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -106,7 +113,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
case animTickMsg: case animTickMsg:
m.animationFrame++ m.animationFrame++
if m.transition == transitionGlitch {
m.transitionTick++
if m.transitionTick > 5 {
m.transition = transitionScan
m.transitionTick = 0
}
} else if m.transition == transitionScan {
m.transitionTick++
if m.transitionTick > 8 {
m.transition = transitionTypewriter
m.transitionTick = 0
m.typewriterBuf = m.renderContent()
m.typewriterPos = 0
}
} else if m.transition == transitionTypewriter {
m.typewriterPos += 3
if m.typewriterPos >= len(m.typewriterBuf) {
m.transition = transitionNone
}
}
return m, animTick() return m, animTick()
case clockTickMsg:
m.currentTime = msg.time
return m, clockTick()
case progress.FrameMsg: case progress.FrameMsg:
pm, cmd := m.progressBar.Update(msg) pm, cmd := m.progressBar.Update(msg)
m.progressBar = pm.(progress.Model) m.progressBar = pm.(progress.Model)
@@ -120,7 +152,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case termExitMsg: case termExitMsg:
m.termRunning = false m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render("(process exited)")) m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render("(process exited)"))
m.termCmd = nil m.termCmd = nil
if m.activeTab == tabShell { if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
@@ -152,7 +184,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case aiErrMsg: case aiErrMsg:
m.chatLoading = false m.chatLoading = false
m.termAILoading = false m.termAILoading = false
errText := errMsgStyle.Render(" error: " + msg.err.Error()) errText := errMsgStyle.Render(" [ERROR] " + msg.err.Error())
if m.activeTab == tabShell && m.termAIShow { if m.activeTab == tabShell && m.termAIShow {
m.termAIChat = append(m.termAIChat, errText) m.termAIChat = append(m.termAIChat, errText)
} else { } else {
@@ -168,9 +200,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case installCompleteMsg: case installCompleteMsg:
m.installing = false m.installing = false
for _, r := range msg.results { for _, r := range msg.results {
status := itemOKStyle.Render("") status := itemOKStyle.Render("[OK]")
if !r.Success { if !r.Success {
status = itemMissingStyle.Render("") status = itemMissingStyle.Render("[--]")
} }
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message)) m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message))
} }
@@ -179,7 +211,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
case installProgressMsg: case installProgressMsg:
status := itemOKStyle.Render("") status := itemOKStyle.Render("[OK]")
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool)) m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool))
m.installCurrent = msg.current m.installCurrent = msg.current
m.installTool = "" m.installTool = ""
@@ -188,9 +220,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
case installBatchMsg: case installBatchMsg:
status := itemOKStyle.Render("") status := itemOKStyle.Render("[OK]")
if !msg.result.Success { if !msg.result.Success {
status = itemMissingStyle.Render("") status = itemMissingStyle.Render("[--]")
} }
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message)) m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message))
m.installCurrent = msg.index + 1 m.installCurrent = msg.index + 1
@@ -254,7 +286,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) View() string { func (m Model) View() string {
if !m.ready { if !m.ready {
return lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("Loading muyue...") return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("Initializing muyue...")
} }
if m.showingQuit { if m.showingQuit {
@@ -265,6 +297,32 @@ func (m Model) View() string {
return m.renderTabMenuOverlay() return m.renderTabMenuOverlay()
} }
if m.transition == transitionGlitch {
return renderGlitchEffect(m.width, m.height, m.transitionTick)
}
if m.transition == transitionScan {
return renderScanEffect(m.width, m.height, m.transitionTick)
}
if m.transition == transitionTypewriter {
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(typewriterRender(m.typewriterBuf, m.typewriterPos))
if m.activeTab == tabStudio {
b.WriteString("\n")
b.WriteString(m.renderStudioInput())
}
if m.activeTab == tabShell {
b.WriteString("\n")
b.WriteString(m.renderShellInput())
}
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
var b strings.Builder var b strings.Builder
b.WriteString(m.renderHeader()) b.WriteString(m.renderHeader())
b.WriteString("\n") b.WriteString("\n")
@@ -283,45 +341,6 @@ func (m Model) View() string {
return b.String() 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 { func (m Model) renderContent() string {
switch m.activeTab { switch m.activeTab {
case tabDashboard: case tabDashboard:
@@ -354,127 +373,6 @@ func (m *Model) resizeViewport() {
m.viewport.SetContent(m.renderContent()) 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) { func (m *Model) handlePreview(files []previewFile) {
dir := filepath.Join(os.TempDir(), "muyue-preview") dir := filepath.Join(os.TempDir(), "muyue-preview")
os.RemoveAll(dir) os.RemoveAll(dir)
@@ -495,22 +393,3 @@ func (m *Model) handlePreview(files []previewFile) {
m.chatLog = append(m.chatLog, itemOKStyle.Render(" Preview: 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

@@ -30,7 +30,7 @@ func (m Model) renderStudio() string {
func (m Model) renderStudioSidebar(width int) string { func (m Model) renderStudioSidebar(width int) string {
var b strings.Builder var b strings.Builder
b.WriteString(renderSectionWithIcon("Studio", "")) b.WriteString(renderSectionHeader("STUDIO", "[<>]"))
b.WriteString("\n\n") b.WriteString("\n\n")
panels := []struct { panels := []struct {
@@ -38,23 +38,23 @@ func (m Model) renderStudioSidebar(width int) string {
panel studioPanel panel studioPanel
icon string icon string
}{ }{
{"Chat", panelChat, "💬"}, {"Chat", panelChat, "[#]"},
{"Agents", panelAgents, ""}, {"Agents", panelAgents, "[*]"},
{"Workflows", panelWorkflows, ""}, {"Workflows", panelWorkflows, "[~]"},
} }
for _, p := range panels { for _, p := range panels {
if m.studioPanel == p.panel { if m.studioPanel == p.panel {
activeStyle := lipgloss.NewStyle(). activeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")). Foreground(lipgloss.Color("#FFFFFF")).
Background(primaryColor). Background(cyberRed).
Bold(true). Bold(true).
Padding(0, 1) Padding(0, 1)
b.WriteString(activeStyle.Render(p.icon + " " + p.name)) b.WriteString(activeStyle.Render(p.icon + " " + p.name))
b.WriteString("\n") b.WriteString("\n")
} else { } else {
inactiveStyle := lipgloss.NewStyle(). inactiveStyle := lipgloss.NewStyle().
Foreground(textDimColor). Foreground(textDim).
Padding(0, 1) Padding(0, 1)
b.WriteString(inactiveStyle.Render(p.icon + " " + p.name)) b.WriteString(inactiveStyle.Render(p.icon + " " + p.name))
b.WriteString("\n") b.WriteString("\n")
@@ -62,7 +62,7 @@ func (m Model) renderStudioSidebar(width int) string {
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", width-4))) b.WriteString(lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", width-4)))
b.WriteString("\n\n") b.WriteString("\n\n")
switch m.studioPanel { switch m.studioPanel {
@@ -78,26 +78,26 @@ func (m Model) renderStudioSidebar(width int) string {
} }
func (m Model) renderChatSidebar(b *strings.Builder, width int) { func (m Model) renderChatSidebar(b *strings.Builder, width int) {
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Active Provider")) b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Active Provider"))
b.WriteString("\n") b.WriteString("\n")
provider := "none" provider := "none"
if m.config != nil { if m.config != nil {
provider = m.config.Profile.Preferences.DefaultAI provider = m.config.Profile.Preferences.DefaultAI
} }
b.WriteString(lipgloss.NewStyle().Foreground(roseColor).Bold(true).Render(" " + provider)) b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" " + provider))
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Commands")) b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Commands"))
b.WriteString("\n") b.WriteString("\n")
cmds := []string{"/plan <goal>", "/help"} cmds := []string{"/plan <goal>", "/help"}
for _, c := range cmds { for _, c := range cmds {
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(" " + c)) b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(" " + c))
b.WriteString("\n") b.WriteString("\n")
} }
if m.previewURL != "" { if m.previewURL != "" {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Preview")) b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Preview"))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(itemOKStyle.Render(" " + m.previewURL)) b.WriteString(itemOKStyle.Render(" " + m.previewURL))
b.WriteString("\n") b.WriteString("\n")
@@ -121,43 +121,43 @@ func (m Model) renderAgentsSidebar(b *strings.Builder, width int) {
var statusIcon string var statusIcon string
switch status { switch status {
case proxy.StatusRunning: case proxy.StatusRunning:
statusIcon = lipgloss.NewStyle().Foreground(warmColor).Render(" running") statusIcon = lipgloss.NewStyle().Foreground(neonRed).Render("[>> running]")
case proxy.StatusStopped: case proxy.StatusStopped:
statusIcon = lipgloss.NewStyle().Foreground(mutedColor).Render(" stopped") statusIcon = lipgloss.NewStyle().Foreground(textMuted).Render("[|| stopped]")
case proxy.StatusError: case proxy.StatusError:
statusIcon = lipgloss.NewStyle().Foreground(errorColor).Render(" error") statusIcon = lipgloss.NewStyle().Foreground(errorRed).Render("[!! error]")
default: default:
if available { if available {
statusIcon = lipgloss.NewStyle().Foreground(successColor).Render(" available") statusIcon = lipgloss.NewStyle().Foreground(successGreen).Render("[OK available]")
} else { } else {
statusIcon = lipgloss.NewStyle().Foreground(dimColor).Render(" not installed") statusIcon = lipgloss.NewStyle().Foreground(textMuted).Render("[-- not installed]")
} }
} }
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Bold(true).Render(a.name)) b.WriteString(lipgloss.NewStyle().Foreground(textBright).Bold(true).Render(a.name))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", statusIcon)) b.WriteString(fmt.Sprintf(" %s\n", statusIcon))
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render(fmt.Sprintf(" %s\n", a.tool))) b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %s\n", a.tool)))
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Actions")) b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Actions"))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" [c]")) b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" [c]"))
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(" Start Crush")) b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(" Start Crush"))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" [l]")) b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" [l]"))
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(" Start Claude")) b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(" Start Claude"))
b.WriteString("\n") b.WriteString("\n")
} }
func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) { func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
if m.orch == nil || m.orch.Workflow == nil { if m.orch == nil || m.orch.Workflow == nil {
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("No active workflow.")) b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("No active workflow."))
b.WriteString("\n\n") b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("Use /plan <goal> in chat")) b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render("Use /plan <goal> in chat"))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimColor).Render("to start a workflow.")) b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render("to start a workflow."))
b.WriteString("\n") b.WriteString("\n")
return return
} }
@@ -165,13 +165,13 @@ func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
wf := m.orch.Workflow wf := m.orch.Workflow
phaseColors := map[workflow.Phase]lipgloss.Style{ phaseColors := map[workflow.Phase]lipgloss.Style{
workflow.PhaseIdle: lipgloss.NewStyle().Foreground(mutedColor), workflow.PhaseIdle: lipgloss.NewStyle().Foreground(textMuted),
workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warningColor).Bold(true), workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warnAmber).Bold(true),
workflow.PhasePlanning: lipgloss.NewStyle().Foreground(roseColor).Bold(true), workflow.PhasePlanning: lipgloss.NewStyle().Foreground(cyberPink).Bold(true),
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(accentColor).Bold(true), workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(cyberRose).Bold(true),
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(primaryColor).Bold(true), workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(cyberRed).Bold(true),
workflow.PhaseDone: lipgloss.NewStyle().Foreground(successColor).Bold(true), workflow.PhaseDone: lipgloss.NewStyle().Foreground(successGreen).Bold(true),
workflow.PhaseError: lipgloss.NewStyle().Foreground(errorColor).Bold(true), workflow.PhaseError: lipgloss.NewStyle().Foreground(errorRed).Bold(true),
} }
if style, ok := phaseColors[wf.Phase]; ok { if style, ok := phaseColors[wf.Phase]; ok {
@@ -180,9 +180,9 @@ func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
b.WriteString("\n\n") b.WriteString("\n\n")
if wf.Plan.Goal != "" { if wf.Plan.Goal != "" {
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Goal")) b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Goal"))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textColor).Render(wf.Plan.Goal)) b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(wf.Plan.Goal))
b.WriteString("\n\n") b.WriteString("\n\n")
} }
@@ -194,7 +194,7 @@ func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
b.WriteString("\n\n") b.WriteString("\n\n")
} }
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Controls")) b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Controls"))
b.WriteString("\n") b.WriteString("\n")
controls := []struct { controls := []struct {
key string key string
@@ -207,8 +207,8 @@ func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
{"[x]", "Cancel"}, {"[x]", "Cancel"},
} }
for _, c := range controls { for _, c := range controls {
b.WriteString(lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render(" " + c.key)) b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" "+c.key))
b.WriteString(lipgloss.NewStyle().Foreground(textDimColor).Render(" " + c.desc)) b.WriteString(lipgloss.NewStyle().Foreground(textDim).Render(" "+c.desc))
b.WriteString("\n") b.WriteString("\n")
} }
} }
@@ -216,14 +216,14 @@ func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
func (m Model) renderStudioChat(width int) string { func (m Model) renderStudioChat(width int) string {
var b strings.Builder var b strings.Builder
chatHeader := renderSectionWithIcon("Chat", "💬") chatHeader := renderSectionHeader("CHAT", "[#]")
if m.chatLoading { if m.chatLoading {
chatHeader += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warningColor).Render("thinking...") chatHeader += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warnAmber).Render("thinking...")
} }
b.WriteString(chatHeader) b.WriteString(chatHeader)
b.WriteString("\n\n") b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10))) sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep) b.WriteString(" " + sep)
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -235,6 +235,18 @@ func (m Model) renderStudioChat(width int) string {
return b.String() return b.String()
} }
func (m Model) renderStudioInput() string {
if m.chatLoading {
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render(">> ") + m.spinner.View() + lipgloss.NewStyle().Foreground(textMuted).Render(" thinking..."),
)
}
cursor := lipgloss.NewStyle().Foreground(cyberRed).Render("▎")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render(">> ") + m.chatInput + cursor,
)
}
func (m Model) handleStudioPanelSwitch(panel studioPanel) { func (m Model) handleStudioPanelSwitch(panel studioPanel) {
m.studioPanel = panel m.studioPanel = panel
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())

View File

@@ -5,127 +5,192 @@ import (
) )
var ( var (
primaryColor = lipgloss.Color("#E8364F") cyberRed = lipgloss.Color("#FF0033")
roseColor = lipgloss.Color("#FF6B8A") cyberRedDark = lipgloss.Color("#8B0020")
roseLightColor = lipgloss.Color("#FFB3C6") cyberRedDeep = lipgloss.Color("#5C0015")
accentColor = lipgloss.Color("#FF8FA3") cyberPink = lipgloss.Color("#FF1A5E")
warmColor = lipgloss.Color("#FF4D6D") cyberRose = lipgloss.Color("#FF4D6D")
successColor = lipgloss.Color("#4ADE80") neonRed = lipgloss.Color("#FF1744")
warningColor = lipgloss.Color("#FBBF24") brightRed = lipgloss.Color("#FF5252")
errorColor = lipgloss.Color("#FF4D4D") dimRed = lipgloss.Color("#6B2033")
mutedColor = lipgloss.Color("#8B7E8E") mutedRed = lipgloss.Color("#4A1525")
dimColor = lipgloss.Color("#5A4F5E")
textColor = lipgloss.Color("#F0E6E8")
textDimColor = lipgloss.Color("#B8A9AD")
bgDark = lipgloss.Color("#0D0A0B") textBright = lipgloss.Color("#EAE0E2")
bgPanel = lipgloss.Color("#1A1215") textMain = lipgloss.Color("#D4C4C8")
bgCard = lipgloss.Color("#231A1D") textDim = lipgloss.Color("#8A7A7E")
bgInput = lipgloss.Color("#2A2023") textMuted = lipgloss.Color("#5A4F52")
bgHover = lipgloss.Color("#332528")
borderColor = lipgloss.Color("#3D2E32") successGreen = lipgloss.Color("#00E676")
borderAccent = lipgloss.Color("#E8364F") warnAmber = lipgloss.Color("#FFD740")
errorRed = lipgloss.Color("#FF1744")
tabActiveBg = lipgloss.Color("#E8364F") bgVoid = lipgloss.Color("#0A0A0C")
tabInactiveBg = lipgloss.Color("#1A1215") bgBase = lipgloss.Color("#0F0D10")
bgSurface = lipgloss.Color("#161218")
bgPanel = lipgloss.Color("#1C1719")
bgCard = lipgloss.Color("#221B1E")
bgInput = lipgloss.Color("#2A2225")
sectionStyle = lipgloss.NewStyle(). borderDim = lipgloss.Color("#2A1F22")
Foreground(roseColor). borderRed = lipgloss.Color("#FF003344")
borderRedFull = lipgloss.Color("#FF0033")
)
var (
baseStyle = lipgloss.NewStyle()
titleBlockStyle = lipgloss.NewStyle().
Foreground(cyberRed).
Bold(true) Bold(true)
sectionIconStyle = lipgloss.NewStyle(). sectionTitleStyle = lipgloss.NewStyle().
Foreground(primaryColor). Foreground(cyberRed).
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) Bold(true)
confirmYesStyle = lipgloss.NewStyle(). labelStyle = lipgloss.NewStyle().
Foreground(successColor). Foreground(textDim).
Bold(true) Width(14)
confirmNoStyle = lipgloss.NewStyle(). valueStyle = lipgloss.NewStyle().
Foreground(mutedColor) Foreground(textMain)
cardStyle = lipgloss.NewStyle(). cardStyle = lipgloss.NewStyle().
Background(bgCard). Background(bgCard).
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(borderColor). BorderForeground(borderDim).
Padding(0, 1)
cardActiveStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Padding(0, 1) Padding(0, 1)
sidebarStyle = lipgloss.NewStyle(). sidebarStyle = lipgloss.NewStyle().
Background(bgPanel). Background(bgSurface).
Border(lipgloss.Border{Right: "│"}). Border(lipgloss.Border{Right: "│"}).
BorderForeground(borderColor). BorderForeground(borderDim).
Padding(0, 1) Padding(0, 1)
statusBarStyle = lipgloss.NewStyle().
Background(bgSurface).
Foreground(textDim).
Padding(0, 1)
inputStyle = lipgloss.NewStyle().
Foreground(cyberRed)
userMsgStyle = lipgloss.NewStyle().
Foreground(cyberRose)
aiMsgStyle = lipgloss.NewStyle().
Foreground(textMain)
errMsgStyle = lipgloss.NewStyle().
Foreground(errorRed)
itemOKStyle = lipgloss.NewStyle().Foreground(successGreen)
itemMissingStyle = lipgloss.NewStyle().Foreground(errorRed)
itemWarnStyle = lipgloss.NewStyle().Foreground(warnAmber)
itemPendingStyle = lipgloss.NewStyle().Foreground(textMuted)
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Background(bgCard).
Foreground(textBright).
Padding(1, 3).
Bold(true)
confirmYesStyle = lipgloss.NewStyle().Foreground(successGreen).Bold(true)
confirmNoStyle = lipgloss.NewStyle().Foreground(textMuted)
badgeStyle = lipgloss.NewStyle(). badgeStyle = lipgloss.NewStyle().
Background(primaryColor). Background(cyberRed).
Foreground(lipgloss.Color("#FFFFFF")). Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1). Padding(0, 1).
Bold(true) Bold(true)
labelStyle = lipgloss.NewStyle(). tabBarStyle = lipgloss.NewStyle().Background(bgSurface)
Foreground(mutedColor).
Width(14)
valueStyle = lipgloss.NewStyle(). stepDoneStyle = lipgloss.NewStyle().Foreground(successGreen)
Foreground(textColor) stepPendingStyle = lipgloss.NewStyle().Foreground(textMuted)
stepCurrentStyle = lipgloss.NewStyle().Foreground(cyberRed).Bold(true)
tabBarStyle = lipgloss.NewStyle(). stepErrorStyle = lipgloss.NewStyle().Foreground(errorRed)
Background(bgPanel)
pulseFrames = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}
) )
func getAnimFrame(frame int) string { var logoLines = []string{
return pulseFrames[frame%len(pulseFrames)] "███╗ ███╗██╗ ██╗ █████╗ ███╗ ██╗███████╗",
"████╗ ████║╚██╗ ██╔╝██╔══██╗████╗ ██║██╔════╝",
"██╔████╔██║ ╚████╔╝ ███████║██╔██╗ ██║███████╗",
"██║╚██╔╝██║ ╚██╔╝ ██╔══██║██║╚██╗██║╚════██║",
"██║ ╚═╝ ██║ ██║ ██║ ██║██║ ╚████║███████║",
"╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝",
}
var scanFrames = []string{
"─────────────────────────── ───",
" ─────────────────────────── ─── ",
"── ──────────────────────────── ",
"─ ─── ────────────────────────────",
"─── ─────────────────────────── ─",
" ──── ─────────────────────────────",
}
func getAnimFrame(frame int) string {
frames := []string{
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
}
return frames[frame%len(frames)]
}
func getScanFrame(frame int) string {
return scanFrames[frame%len(scanFrames)]
}
func renderLogo() string {
styled := make([]string, len(logoLines))
for i, line := range logoLines {
styled[i] = lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(line)
}
return lipgloss.JoinVertical(lipgloss.Left, styled...)
}
func renderBlockTitle(text string) string {
width := len(text) + 6
top := lipgloss.NewStyle().Foreground(dimRed).Render(
"╭" + repeatStr("─", width) + "╮",
)
content := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
"│ ■ " + text + " ■ │",
)
bottom := lipgloss.NewStyle().Foreground(dimRed).Render(
"╰" + repeatStr("─", width) + "╯",
)
return lipgloss.JoinVertical(lipgloss.Left, top, content, bottom)
}
func renderSectionHeader(title string, icon string) string {
return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
"■ "+icon+" "+title+" ■",
)
}
func renderProgressBar(pct float64, width int) string {
filled := int(float64(width) * pct)
empty := width - filled
bar := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
repeatStr("█", filled),
) + lipgloss.NewStyle().Foreground(dimRed).Render(
repeatStr("░", empty),
)
return bar
}
func repeatStr(s string, n int) string {
result := ""
for i := 0; i < n; i++ {
result += s
}
return result
} }

View File

@@ -54,13 +54,13 @@ func (m Model) renderShell() string {
func (m Model) renderTermPanel(width int) string { func (m Model) renderTermPanel(width int) string {
var b strings.Builder var b strings.Builder
cwdStyle := lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd) cwdStyle := lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd)
b.WriteString(renderSectionWithIcon("Terminal", "")) b.WriteString(renderSectionHeader("TERMINAL", "[$]"))
b.WriteString(" ") b.WriteString(" ")
b.WriteString(cwdStyle) b.WriteString(cwdStyle)
b.WriteString("\n\n") b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10))) sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep) b.WriteString(" " + sep)
b.WriteString("\n") b.WriteString("\n")
@@ -74,10 +74,10 @@ func (m Model) renderTermPanel(width int) string {
func (m Model) renderAIPanel(width int) string { func (m Model) renderAIPanel(width int) string {
var b strings.Builder var b strings.Builder
b.WriteString(renderSectionWithIcon("AI Assistant", "")) b.WriteString(renderSectionHeader("AI ASSISTANT", "[?]"))
b.WriteString("\n\n") b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderColor).Render(strings.Repeat("─", max(width-4, 10))) sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep) b.WriteString(" " + sep)
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -87,30 +87,37 @@ func (m Model) renderAIPanel(width int) string {
} }
if m.termAILoading { if m.termAILoading {
b.WriteString(lipgloss.NewStyle().Foreground(warmColor).Render(" " + getAnimFrame(m.animationFrame) + " thinking...")) b.WriteString(lipgloss.NewStyle().Foreground(neonRed).Render(" " + getAnimFrame(m.animationFrame) + " thinking..."))
b.WriteString("\n") b.WriteString("\n")
} }
inputLabel := lipgloss.NewStyle().Foreground(roseColor).Render(" ") inputLabel := lipgloss.NewStyle().Foreground(cyberRed).Render(">> ")
b.WriteString(inputLabel) b.WriteString(inputLabel)
b.WriteString(m.termAIInput) b.WriteString(m.termAIInput)
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Background(bgPanel). Background(bgSurface).
Border(lipgloss.Border{Left: "│"}). Border(lipgloss.Border{Left: "│"}).
BorderForeground(borderColor). BorderForeground(borderDim).
Width(width). Width(width).
Padding(0, 1). Padding(0, 1).
Render(b.String()) Render(b.String())
} }
func (m Model) renderShellInput() string {
prompt := lipgloss.NewStyle().Foreground(successGreen).Render("> ")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
prompt + m.termInput + lipgloss.NewStyle().Foreground(cyberRed).Render("▎"),
)
}
func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
if m.termCmd != nil && m.termCmd.Process != nil { if m.termCmd != nil && m.termCmd.Process != nil {
m.termCmd.Process.Kill() m.termCmd.Process.Kill()
m.termRunning = false m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorColor).Render("^C")) m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorRed).Render("^C"))
m.termCmd = nil m.termCmd = nil
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, nil return m, nil
@@ -151,7 +158,7 @@ func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if isDangerousCommand(input) { if isDangerousCommand(input) {
m.termLog = append(m.termLog, errMsgStyle.Render(" blocked: potentially dangerous command")) m.termLog = append(m.termLog, errMsgStyle.Render(" [BLOCKED] potentially dangerous command"))
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom() m.viewport.GotoBottom()
return m, nil return m, nil
@@ -165,7 +172,7 @@ func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
if err := os.Chdir(dir); err == nil { if err := os.Chdir(dir); err == nil {
m.termCwd, _ = os.Getwd() m.termCwd, _ = os.Getwd()
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ "+input)) m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd+" $ ")+input)
} else { } else {
m.termLog = append(m.termLog, errMsgStyle.Render(" cd: "+err.Error())) m.termLog = append(m.termLog, errMsgStyle.Render(" cd: "+err.Error()))
} }
@@ -173,7 +180,7 @@ func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.viewport.GotoBottom() m.viewport.GotoBottom()
return m, nil return m, nil
} }
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimColor).Render(m.termCwd+" $ ")+input) m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd+" $ ")+input)
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom() m.viewport.GotoBottom()
return m, m.runTermCommand(input) return m, m.runTermCommand(input)

View File

@@ -32,8 +32,8 @@ const (
tabCount tabCount
) )
var tabNames = []string{"Dashboard", "Studio", "Shell", "Config"} var tabNames = []string{"DASH", "STUDIO", "SHELL", "CONFIG"}
var tabIcons = []string{"", "", "", ""} var tabIcons = []string{"[■]", "[<>]", "[>$]", "[//]"}
type aiResponseMsg struct{ content string } type aiResponseMsg struct{ content string }
type aiErrMsg struct{ err error } type aiErrMsg struct{ err error }
@@ -61,6 +61,9 @@ type spinnerTickMsg struct{ time time.Time }
type termOutputMsg struct{ line string } type termOutputMsg struct{ line string }
type termExitMsg struct{} type termExitMsg struct{}
type animTickMsg struct{ time time.Time } type animTickMsg struct{ time time.Time }
type clockTickMsg struct{ time time.Time }
type glitchDoneMsg struct{}
type scanDoneMsg struct{}
type studioPanel int type studioPanel int
@@ -79,10 +82,20 @@ const (
configSkills configSkills
) )
type transitionState int
const (
transitionNone transitionState = iota
transitionGlitch
transitionScan
transitionTypewriter
)
type Model struct { type Model struct {
config *config.MuyueConfig config *config.MuyueConfig
scanResult *scanner.ScanResult scanResult *scanner.ScanResult
activeTab tab activeTab tab
prevTab tab
width int width int
height int height int
viewport viewport.Model viewport viewport.Model
@@ -126,7 +139,7 @@ type Model struct {
termRunning bool termRunning bool
termCwd string termCwd string
studioPanel studioPanel studioPanel studioPanel
studioSidebarOpen bool studioSidebarOpen bool
termAIChat []string termAIChat []string
@@ -138,6 +151,12 @@ type Model struct {
configField int configField int
animationFrame int animationFrame int
transition transitionState
transitionTick int
typewriterBuf string
typewriterPos int
currentTime time.Time
} }
type keyMap struct { type keyMap struct {

View File

@@ -2,7 +2,7 @@ package version
const ( const (
Name = "muyue" Name = "muyue"
Version = "0.2.0" Version = "0.2.1"
Author = "La Légion de Muyue" Author = "La Légion de Muyue"
License = "MIT" 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,
},
},
},
})