Compare commits

...

47 Commits

Author SHA1 Message Date
Augustin
66b773ff86 feat(agent): refactor AI chat with streaming, agent registry, and tool execution
All checks were successful
Beta Release / beta (push) Successful in 47s
- Replace old tool-call regex with proper agent registry
- Add streaming chat via SSE (handleStreamChat / handleNonStreamChat)
- Add internal/agent package with tool definitions and execution
- Add orchestrator with system prompt and tool scaffolding
- Add internal/agent/ directory
- Studio.jsx: streaming chat with thinking indicator and tool result rendering
- global.css: chat bubble styles, streaming animation, thinking dots
- handlers_chat.go: full rewrite using new agent/orchestrator architecture

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 21:19:36 +02:00
Augustin
bc5c2956b4 feat(onboarding): add minimax api key step and AI-powered editor scan
Some checks failed
Beta Release / beta (push) Failing after 22s
- Add apikey step in onboarding wizard (optional, with validation)
- Add ScanEditors() in scanner package detecting vim/nvim/code/emacs/nano/helix/subl/zed
- Add GET /api/editors endpoint
- Editor step now has scan button to detect installed editors via backend
- MiniMax API key is saved to provider config if provided

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 21:04:27 +02:00
Augustin
e19122dad9 fix(onboarding): require fields before advancing steps
All checks were successful
Beta Release / beta (push) Successful in 39s
- Validate each step before allowing goNext
- Show required error message on name step if empty
- Clear error on input change

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:58:36 +02:00
Augustin
8b6a7e8bc3 fix: register missing /api/config/reset and /api/starship/apply-theme routes
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add resetConfig and applyStarshipTheme to frontend api client
- Register handleResetConfig and handleApplyStarshipTheme in server mux

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:57:25 +02:00
Augustin
58f8cb0bd3 fix(config): per-provider form state to avoid field cross-talk
All checks were successful
Beta Release / beta (push) Successful in 38s
- providerForm is now keyed by provider name
- Each provider (minimax/glm/claude) has isolated form data
- Validation and save target the specific provider being edited

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:56:04 +02:00
Augustin
b52feccc17 fix(onboarding): auto-save on done step, keyboard nav, error feedback
All checks were successful
Beta Release / beta (push) Successful in 40s
- Trigger save automatically when reaching done step
- Add Escape to go back, Enter to advance (works in text fields)
- Add back button visible between step 1 and last step
- Fix accent encoding in done message
- Show saving state and error with retry button

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:53:12 +02:00
Augustin
5bbac499a7 feat(config): add system panel with reset and starship theme, add onboarding wizard
All checks were successful
Beta Release / beta (push) Successful in 41s
- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default)
- Add OnboardingWizard that activates when profile is empty on first run
- Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering)
- Add /api/config/reset and /api/starship/apply-theme endpoints
- Wire wizard trigger in App.jsx based on profile completeness

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:36:36 +02:00
Augustin
83d7a573c7 fix: correct version from 3.2 to 0.3.2
All checks were successful
Beta Release / beta (push) Successful in 41s
The version was incorrectly bumped to 3.2 instead of 0.3.2.
This follows the existing semver pattern (v0.2.0, v0.2.1, v0.3.1).

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:29:09 +02:00
Augustin
0fe82f67df chore: bump version to 3.2
All checks were successful
Beta Release / beta (push) Successful in 45s
2026-04-22 20:19:47 +02:00
Augustin
4b9f2c377d Merge branch 'main' into develop 2026-04-22 20:19:35 +02:00
Augustin
95bd824259 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-22 20:19:29 +02:00
Augustin
252f178bbd fix(terminal): init payload never sent due to ws.onopen being overwritten
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:19:29 +02:00
Augustin
7dcf505360 fix(terminal): improve shell resolution with better error handling and ws proxy support
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:19:29 +02:00
Augustin
8fb93fa47e feat(studio): parse AI thinking and tool launch messages in terminal panel
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
5ec373cd6a fix(studio): forward AI thinking chunks to frontend instead of dropping them
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
1eb5a6d00f feat(studio): add tool execution and hide AI thinking tags
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
cd5ebe083c fix(terminal): ignore invalid shell config from race condition
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
2004c15dd7 feat(shell): restore AI assistant panel
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
9306152736 fix(terminal): restore terminal input and cursor visibility
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
e15a034de5 refactor(api): split monolithic handlers.go into focused modules
Break down the 627-line handlers.go into specialized modules:
- handlers_chat.go: chat and streaming endpoints
- handlers_config.go: configuration endpoints
- handlers_common.go: shared utilities
- handlers_info.go: info and status endpoints
- handlers_terminal.go: terminal/shell endpoints
- handlers_tools.go: tool-related endpoints

Also includes config improvements, orchestrator enhancements, and
web component updates.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
3b6cc38ea0 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Beta Release / beta (push) Successful in 41s
2026-04-22 20:13:17 +02:00
Augustin
93a22d4075 fix(terminal): init payload never sent due to ws.onopen being overwritten
All checks were successful
Beta Release / beta (push) Successful in 40s
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:05:10 +02:00
Augustin
e0e1e73bca fix(terminal): improve shell resolution with better error handling and ws proxy support
All checks were successful
Beta Release / beta (push) Successful in 40s
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:02:55 +02:00
Augustin
0496ca789b feat(studio): parse AI thinking and tool launch messages in terminal panel
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:41:42 +02:00
Augustin
b407ab879b fix(studio): forward AI thinking chunks to frontend instead of dropping them
All checks were successful
Beta Release / beta (push) Successful in 40s
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 19:12:32 +02:00
Augustin
12df184e11 feat(studio): add tool execution and hide AI thinking tags
All checks were successful
Beta Release / beta (push) Successful in 40s
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:04:59 +02:00
Augustin
8af6d25e28 fix(terminal): ignore invalid shell config from race condition
All checks were successful
Beta Release / beta (push) Successful in 40s
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:56:33 +02:00
Augustin
4fd599adec feat(shell): restore AI assistant panel
All checks were successful
Beta Release / beta (push) Successful in 38s
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:51:33 +02:00
Augustin
bcba5932d5 fix(terminal): restore terminal input and cursor visibility
All checks were successful
Beta Release / beta (push) Successful in 38s
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:46:29 +02:00
Augustin
04b0fff791 refactor(api): split monolithic handlers.go into focused modules
All checks were successful
Beta Release / beta (push) Successful in 44s
Break down the 627-line handlers.go into specialized modules:
- handlers_chat.go: chat and streaming endpoints
- handlers_config.go: configuration endpoints
- handlers_common.go: shared utilities
- handlers_info.go: info and status endpoints
- handlers_terminal.go: terminal/shell endpoints
- handlers_tools.go: tool-related endpoints

Also includes config improvements, orchestrator enhancements, and
web component updates.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:34:14 +02:00
CI Bot
80c11cab3f chore: update CHANGELOG for v0.3.0 2026-04-21 21:08:06 +00:00
Augustin
0b221094f2 fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS
All checks were successful
Beta Release / beta (push) Successful in 37s
Stable Release / stable (push) Successful in 37s
- Fix detectShell() to return full paths via LookPath (was returning bare
  names causing exec error on some systems)
- Add shell path validation before pty.Start to prevent crashes
- Simplify CLI: remove all subcommands, keep only desktop launch with --port
- Restore missing Studio shared CSS (code blocks, input area, animations)
- Replace Config vertical sidebar with horizontal nav-tabs matching main layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 23:06:39 +02:00
Augustin
7f674730c7 feat: add API key validation flow for AI provider config
All checks were successful
Beta Release / beta (push) Successful in 37s
- Add POST /api/providers/validate backend endpoint that sends a test
  request to the provider's chat/completions API to verify the key
- Add validateProvider to frontend API client
- Redesign PanelProviders: show token input inline with Validate button,
  display valid/invalid badge after validation, Save only appears after
  successful validation
- Add i18n keys (EN/FR) for validation flow

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:53:52 +02:00
Augustin
040e482c75 feat(studio): replace sidebar layout with unified execution feed styles
All checks were successful
Beta Release / beta (push) Successful in 36s
Replace old Studio sidebar/chat bubble CSS with new feed-based layout.
Add studio.cleared i18n key for /clear command feedback.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:46:36 +02:00
Augustin
c8903efa5e fix: guard against empty tabs array in closeTab
All checks were successful
Beta Release / beta (push) Successful in 37s
💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:43:38 +02:00
Augustin
f3cb306053 refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard
All checks were successful
Beta Release / beta (push) Successful in 38s
- Config: sidebar navigation with 5 panels (Profile, AI Providers, Updates, Locale, Skills)
- Dashboard: remove duplicated system overview section, keep workflows and activity log
- New CSS for config window layout, cards, provider cards, update rows
- Add i18n panel keys (FR/EN)

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:41:25 +02:00
Augustin
3cdcb22068 feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign
All checks were successful
Beta Release / beta (push) Successful in 39s
- Terminal: multi-tab sessions, SSH connections, shell detection (zsh/bash/fish/wsl/powershell)
- Config: inline profile & provider editing, system update management
- Dashboard: grid layout with inline tools/notifications/workflows sections
- Add lucide-react icons, i18n keys (FR/EN), and new CSS components

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:35:49 +02:00
Augustin
ee18bbeb53 feat(studio): add i18n keys and CSS for redesigned AI chat interface
All checks were successful
Beta Release / beta (push) Successful in 2m10s
Add missing English translations and full cyberpunk-themed CSS for the
new Studio tab: central AI chat with context panels (plans, agents,
activity), streaming cursor, plan detail expansion, and collapsible
sidebar.

Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:18:31 +02:00
Augustin
b0b0e1d308 chore: bump version to 0.3.0
Some checks failed
Beta Release / beta (push) Has been cancelled
feat(shell): real terminal with xterm.js + PTY over WebSocket

Replace fake shell input with a full PTY-backed terminal using xterm.js.
Apps like btop, vim, htop now work. AI chat panel is always visible.

Backend:
- Add WebSocket handler /api/ws/terminal with creack/pty
- Allocate real pseudo-terminal with TERM=xterm-256color
- Bidirectional I/O + dynamic resize via pty.Setsize
- Skip JSON headers on /api/ws/* paths for WebSocket upgrade

Frontend:
- Integrate xterm.js with FitAddon and WebLinksAddon
- Cyberpunk color theme matching app design
- ResizeObserver for automatic terminal resizing
- AI assistant panel always visible (340px, no toggle)
- Connection status indicator (green/red dot)

Dependencies:
- Go: github.com/gorilla/websocket, github.com/creack/pty/v2
- npm: @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:17:24 +02:00
Augustin
fc7981037f chore: remove dead code (packages, functions, types, constants)
All checks were successful
Beta Release / beta (push) Successful in 34s
Remove 5 unused packages (daemon, preview, proxy, workflow) and dead
symbols across 7 files: orchestrator workflow engine, skills Target type
and Update(), LSP config generation, installer SetupPrompt(), unexported
desktop options, and version License/Prerelease. Total: -1453 lines.
2026-04-21 22:09:42 +02:00
Augustin
f7222b0f6c docs: rewrite README and CHANGELOG for desktop app mode
All checks were successful
Beta Release / beta (push) Successful in 32s
Update README to reflect TUI removal and new React desktop UI with
API backend, i18n, themes, and keyboard layout support. Fix duplicate
v0.2.1 entries in CHANGELOG and add [Unreleased] section for recent
desktop/i18n/theme changes.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:02:09 +02:00
Augustin
11417d3ea7 feat(web): add i18n support with FR/EN locales and keyboard layout awareness
All checks were successful
Beta Release / beta (push) Successful in 36s
Add full internationalization system with React context, French/English
translations, and AZERTY/QWERTY keyboard layout support. Dashboard now
uses a tabbed layout (Tools, Notifications, Workflows). Config page exposes
language and keyboard preferences persisted via new /api/preferences endpoint.

💕 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 21:48:36 +02:00
Augustin
3dc24ae22c refactor(web): redesign frontend for native web UX
All checks were successful
Beta Release / beta (push) Successful in 33s
- Replace all TUI artifacts: [OK], [FAIL], >>, [■], [$] with proper
  web components (badges, cards, chips, avatars)
- Rename CSS variables from TUI names (cyberRed, dimRed, bgVoid)
  to semantic names (accent, accent-dim, bg)
- Add proper interactive elements: hover states, cursor pointer,
  click feedback (scale), focus rings, spinner animation
- Fix user-select: was none globally, now allows text selection
- Redesign navigation: proper tabs with role="tab" and aria attributes
- Add keyboard shortcuts only when not in input/textarea (1-4 for tabs)
- Replace footer TUI shortcuts with clean statusbar
- Dashboard: card-based layout, badge status, progress bar, activity log
- Studio: message bubbles (aligned left/right), agent cards with avatars
- Shell: command history (ArrowUp/Down), toggleable AI panel button,
  panel header with current directory
- Config: provider cards, color swatches for theme picker,
  clean field rows with empty states
- CSS imported via main.jsx (not HTML link) for proper Vite hashing
- Remove glitch/scanline/typewriter TUI animations
- Add favicon

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:25:55 +02:00
Augustin
aa0ff199c6 refactor: remove TUI, desktop web UI is now the default and only mode
All checks were successful
Beta Release / beta (push) Successful in 2m17s
- Remove internal/tui/ entirely (2600+ lines of Bubble Tea code)
- Remove bubbletea, bubbles, lipgloss direct dependencies
- `muyue` now launches desktop web UI (opens browser)
- CLI subcommands preserved (scan, install, setup, etc.)
- Unknown args passed as desktop flags (--port, --no-open)
- Update help text to reflect new default behavior

💘 Generated with Crush

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

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:04:47 +02:00
Augustin
097cf40ccd fix(ci): add frontend build step before Go vet/test/build
All checks were successful
Beta Release / beta (push) Successful in 1m16s
All three CI workflows now build the React frontend (npm ci && npm run
build) before any Go steps, so the go:embed directive in
cmd/muyue-desktop/main.go finds the dist/ directory.

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

💘 Generated with Crush

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

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

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

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

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

Generated with Crush

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

Co-authored-by: Augustin <muyue@legion-muyue.fr>
Reviewed-on: #2
2026-04-20 20:46:12 +00:00
77 changed files with 8657 additions and 4486 deletions

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

@@ -4,53 +4,121 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.2.1
## v0.3.0
### Changes since v0.2.1
- fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS (0b22109)
- feat: add API key validation flow for AI provider config (7f67473)
- feat(studio): replace sidebar layout with unified execution feed styles (040e482)
- fix: guard against empty tabs array in closeTab (c8903ef)
- refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard (f3cb306)
- feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign (3cdcb22)
- feat(studio): add i18n keys and CSS for redesigned AI chat interface (ee18bbe)
- chore: bump version to 0.3.0 (b0b0e1d)
- chore: remove dead code (packages, functions, types, constants) (fc79810)
- docs: rewrite README and CHANGELOG for desktop app mode (f7222b0)
- feat(web): add i18n support with FR/EN locales and keyboard layout awareness (11417d3)
- refactor(web): redesign frontend for native web UX (3dc24ae)
- refactor: remove TUI, desktop web UI is now the default and only mode (aa0ff19)
- refactor: unify into single `muyue` binary with embedded desktop mode (3463605)
- fix(ci): add frontend build step before Go vet/test/build (097cf40)
- feat: add desktop app with React frontend, API backend, theme system (#2) (88d2a03)
- chore: update CHANGELOG for v0.2.1 (1830c18)
- 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) |
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### 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
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.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.1/muyue-darwin-arm64.tar.gz | tar xz
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.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.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [Unreleased]
### Security
- **Command injection**: Removed non-functional AI sidebar from Shell.jsx that interpolated user input directly into a shell command (`echo "AI: ${text}"`). The panel was a stub with no real AI integration.
- **WebSocket origin validation**: Terminal WebSocket handler now validates the `Origin` header matches the server's own host.
- **DELETE method guard**: Terminal sessions DELETE endpoint now rejects non-DELETE methods.
### Fixed
- **Message ID collisions**: `generateMsgID()` now appends nanosecond suffix to prevent collisions under rapid creation.
- **Legacy dir migration**: Config migration from `~/.muyue` to XDG path now logs errors instead of silently failing.
- **MCP JSON parsing**: `json.Unmarshal` errors in MCP config loading are now handled instead of ignored.
- **API header merging**: `client.js` `request()` now correctly merges caller headers with defaults (was overwriting `Content-Type`).
- **Variable shadowing**: `t` translation function shadowed by `.filter(t => ...)` in Config.jsx and App.jsx — renamed to `tool`.
### Changed
- **Real SSE streaming**: Chat endpoint now streams AI responses via SSE (`data: {"content":"..."}` chunks) instead of fake 8-rune chunking. Frontend renders responses progressively as they arrive.
- **Progressive rendering**: Studio.jsx now uses `StreamingItem` component to display partial AI output during streaming, with cursor animation.
- **Theme from config**: App.jsx loads theme from user profile preferences on startup (was hardcoded to `cyberpunk-red`).
- **Handlers split**: Monolithic `handlers.go` split into 6 focused files: `handlers_common.go`, `handlers_info.go`, `handlers_tools.go`, `handlers_config.go`, `handlers_chat.go`, `handlers_terminal.go`.
- **Dynamic version**: Config `Version` field now uses `version.Version` constant instead of hardcoded `"0.1.0"`.
- **Path construction**: `filepath.Join` used consistently in installer, MCP, scanner, and profiler for cross-platform safety.
- **CI Go version**: All 3 CI workflows updated from `go-version: '1.24.3'` to `'1.24'` to match `go.mod`.
- **Dead code removed**: Unused `addNotif` function in Dashboard.jsx, unused `layout` destructuring, dead `tools`/`updates`/`onRescan` props, dead AI sidebar in Shell.jsx, associated CSS and i18n keys.
### Added
- **SendStream tests**: 3 new tests for the SSE streaming method (chunk parsing, history accumulation, API error handling) using `httptest` server.
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
- **Themes**: 4 built-in themes (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green) with 30+ CSS custom properties applied at runtime.
- **Desktop flags**: `--port=PORT` to specify port, `--no-open` to skip browser auto-open.
- **SPA routing**: Frontend handles client-side routing via catch-all fallback.
- **CI**: Frontend build step (`npm ci && npm run build`) added to all 3 CI pipelines.
### Changed
- **Default mode**: `muyue` now launches the desktop web app instead of the TUI. The TUI has been removed entirely.
- **Single binary**: `cmd/muyue-desktop` merged into `cmd/muyue`. Only one binary needed.
- **Frontend**: Moved from `cmd/muyue-desktop/frontend/` to `web/` and embedded via `web/embed.go`.
- **Go module**: Dependencies cleaned up — removed indirect TUI-related packages.
- **Makefile**: `build` target now runs `frontend` (npm build) automatically. Added `dev-desktop` target for Vite dev server.
### Removed
- **TUI**: All `internal/tui/` code removed (model, views, handlers, animations, terminal, styles).
- **`cmd/muyue-desktop/`**: Separate desktop binary removed; merged into main binary.
## v0.2.1
### Changes since v0.2.0
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Downloads
| Platform | File |
@@ -62,6 +130,11 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
| 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) |
### Changes since v0.2.0
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Install
**Linux (x86_64)**
@@ -85,9 +158,19 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.0
### 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) |
### Changes since start
- refactor: redesign TUI with 4 tabs, red/rose theme, split layouts (035e923)
@@ -121,17 +204,6 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
- 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)**
@@ -155,7 +227,6 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [0.2.0] - 2026-04-20
### Added

View File

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

174
README.md
View File

@@ -4,25 +4,30 @@ AI-powered development environment assistant by **La Légion de Muyue**.
## What it does
`muyue` is a single binary that transforms your entire development environment:
`muyue` is a single binary (frontend embedded) that transforms your entire development environment:
- **Desktop app** — React web UI served locally, auto-opens in your browser
- **Scans** your system for tools, runtimes, and configs
- **Installs** missing tools automatically (Crush, Claude Code, BMAD, Starship, runtimes...)
- **Updates** everything in the background
- **Profiles** you on first run to personalize the experience
- **Unifies** control of Crush and Claude Code from one TUI
- **Unifies** control of Crush and Claude Code from one interface
- **Orchestrates** AI agents via MiniMax M2.7
- **Customizes** your terminal prompt (branch, commits, language, etc.)
- **Configures** MCP servers, LSPs, and skills automatically
- **Previews** HTML/visual outputs in your browser
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
## Tech Stack
- **Go** — single binary, no dependencies
- **Charm** — Bubble Tea, Lip Gloss, Huh (TUI, styling, forms)
- **Starship** — terminal prompt customization
- **MiniMax M2.7** — AI orchestration
- **BMAD-METHOD** — structured development workflows
| Layer | Technology |
|-------|-----------|
| **Backend** | Go 1.24 — single binary, no runtime dependencies |
| **Frontend** | React 19, Vite 8 — embedded via `go:embed` |
| **Styling** | CSS custom properties, 4 built-in themes |
| **i18n** | Custom FR/EN system with keyboard layout awareness |
| **CLI** | Charm (Bubble Tea, Huh) — for setup wizard, profiler, and CLI commands |
| **AI** | MiniMax M2.7 — orchestration |
| **CI/CD** | Gitea Actions — Go + Node build, multi-platform releases |
## Install
@@ -37,10 +42,14 @@ make build
make install-local
```
The frontend is built automatically during `make build` (runs `npm ci && npm run build` in `web/`).
## Usage
```bash
muyue # Start interactive TUI
muyue # Launch desktop app (opens browser)
muyue --port=8080 # Launch on a specific port
muyue --no-open # Launch without opening the browser
muyue scan # Scan system
muyue install # Install missing tools
muyue update # Check and apply updates
@@ -76,55 +85,116 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill
```
## TUI — 4 Tabs
## Desktop App — 4 Tabs
The TUI is organized into 4 tabs with a red/rose theme (`#E8364F``#FF6B8A`):
The web UI is organized into 4 tabs with a cyberpunk dark theme. Navigate with `Ctrl+1` through `Ctrl+4`.
### Dashboard
### Dashboard
System overview: installed tools with status, active agents, updates, LSP/MCP/daemon status, and quick actions (install, update, scan).
System overview with sub-tabs:
- **Tools** — installed/missing tools with status badges and version info
- **Notifications** — activity log with colored severity
- **Workflows** — quick actions (install missing, check updates, rescan, configure MCP)
### Studio
### ⟨⟩ Studio
Central AI chat with a collapsible sidebar (`Ctrl+S`) containing 3 panels:
AI chat interface with a sidebar 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) |
| Panel | Description |
|-------|-------------|
| **Chat** | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | Status of Crush and Claude Code agents |
| **Workflows** | Plan→Execute workflow controls |
### Shell
### $ 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.
Split-view: terminal emulator on the left (sends commands to the Go backend), collapsible AI assistant panel on the right. Full command history with `↑`/`↓` navigation.
### ⚙ Config
Profile, API providers, terminal/starship settings, BMAD, and skills — displayed in a two-column layout.
Two-column profile settings:
- **Profile** — name, pseudo, email, editor, shell, default AI, languages
- **AI Providers** — active provider, API key status, model info
- **Theme** — 4 swatches (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green)
- **Language** — FR/EN with keyboard layout selection (AZERTY, QWERTY, QWERTZ)
- **Skills** — installed skills list
### 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 |
| `Ctrl+1` | Global | Dashboard tab |
| `Ctrl+2` | Global | Studio tab |
| `Ctrl+3` | Global | Shell tab |
| `Ctrl+4` | Global | Config tab |
| `Enter` | Studio | Send message |
| `Shift+Enter` | Studio | New line |
| `Enter` | Shell | Run command |
| ``/`↓` | Shell | Command history |
## API Endpoints
The Go backend serves 15 REST endpoints under `/api/`:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/info` | GET | App name, version, author |
| `/api/system` | GET | OS, arch, platform info |
| `/api/tools` | GET | Tool scan results |
| `/api/config` | GET | Profile, terminal, BMAD config |
| `/api/providers` | GET | AI provider list |
| `/api/skills` | GET | Installed skills |
| `/api/lsp` | GET | LSP server scan |
| `/api/mcp` | GET | MCP server scan |
| `/api/updates` | GET | Update check results |
| `/api/scan` | POST | Trigger system rescan |
| `/api/install` | POST | Install tools `{"tools": [...]}` |
| `/api/terminal` | POST | Execute command `{"command": "...", "cwd": "..."}` |
| `/api/mcp/configure` | POST | Configure MCP servers |
| `/api/preferences` | PUT | Save language/keyboard preferences |
## Project Structure
```
.
├── cmd/muyue/main.go # CLI entry point + command routing
├── internal/
│ ├── api/ # HTTP server + handlers (15 endpoints)
│ ├── config/ # YAML config + XDG paths
│ ├── daemon/ # Background daemon
│ ├── desktop/ # Desktop mode (HTTP server + SPA)
│ ├── installer/ # Tool installation logic
│ ├── lsp/ # LSP server scan + install
│ ├── mcp/ # MCP server configuration
│ ├── orchestrator/ # AI agent orchestration
│ ├── platform/ # Cross-platform abstractions
│ ├── preview/ # HTML preview server
│ ├── profiler/ # First-run setup wizard
│ ├── proxy/ # AI proxy agents
│ ├── scanner/ # System tool/runtime scanner
│ ├── secret/ # AES-256-GCM key encryption
│ ├── skills/ # Skills management (CRUD, deploy, AI-generate)
│ ├── updater/ # Tool auto-updater
│ ├── version/ # Version constants
│ └── workflow/ # Plan→Execute workflow engine
├── web/ # Frontend (React 19 + Vite)
│ ├── embed.go # go:embed dist/
│ ├── src/
│ │ ├── api/client.js # API client
│ │ ├── components/ # App, Dashboard, Studio, Shell, Config
│ │ ├── i18n/ # FR/EN translations + keyboard layouts
│ │ ├── styles/global.css # Full CSS theme system
│ │ └── themes/index.js # 4 themes with CSS variable injection
│ └── vite.config.js # Vite + dev proxy to :8095
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
└── Makefile # build, test, lint, cross-compile
```
## Configuration
Config stored at `$XDG_CONFIG_HOME/muyue/config.yaml` (defaults to `~/.config/muyue/config.yaml`).
API keys are encrypted at rest using AES-GCM with a machine-local key stored in `~/.muyue_key`.
API keys are encrypted at rest using AES-256-GCM with a machine-local key stored in `~/.muyue_key`.
First run launches an interactive profiling wizard that:
1. Asks your name, pseudo, email
@@ -133,17 +203,39 @@ First run launches an interactive profiling wizard that:
4. Scans your system
5. Installs missing tools
## Themes
4 built-in themes, selectable from the Config tab:
| Theme | Accent Color |
|-------|-------------|
| Cyberpunk Red | `#FF0033` |
| Cyberpunk Pink | `#FF1A8C` |
| Midnight Blue | `#0088FF` |
| Matrix Green | `#00FF41` |
Themes are applied via CSS custom properties injected at runtime. All colors (30+ variables) adapt automatically.
## i18n & Keyboard Layouts
- **Languages**: Français, English
- **Keyboard layouts**: AZERTY (fr-FR), QWERTY (en-US), QWERTZ (de-DE)
- Keyboard layout affects displayed shortcuts in the status bar (e.g., `Ctrl+&-é-"-'` on AZERTY vs `Ctrl+1-4` on QWERTY)
- Preferences saved to backend and synced across sessions
## Security
- API keys are encrypted at rest (AES-256-GCM) with a per-machine key
- API keys encrypted at rest (AES-256-GCM) with a per-machine key
- Config files use restrictive permissions (0600)
- MCP config files use restrictive permissions (0600)
- Integrated terminal blocks dangerous commands (rm -rf /, mkfs, fork bombs, etc.)
- Terminal API executes commands via shell — only accessible on localhost
## Cross-Platform
Built for Linux (primary), macOS, and Windows. WSL supported.
Single binary includes both CLI and embedded web frontend.
## Contributing — GitFlow Workflow
This project uses a **lightweight GitFlow** with 2 permanent branches and conventional commits.
@@ -179,6 +271,8 @@ hotfix/xxx ──PR (squash)──▶ main (+ backport develop)
| `ci-develop.yml` | Push to `develop` | vet + test + build all platforms + create beta release |
| `ci-main.yml` | Push to `main` | vet + test + build all platforms + update CHANGELOG.md + create stable release |
All CI pipelines build the frontend (`npm ci && npm run build`) before Go vet/test/build.
### Step-by-step: contribute a feature
```bash

View File

@@ -3,116 +3,15 @@ package main
import (
"fmt"
"os"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/profiler"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/tui"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
)
func main() {
if len(os.Args) > 1 {
handleCommand(os.Args[1:])
return
}
runTUI()
}
func handleCommand(args []string) {
if len(args) == 0 {
runTUI()
return
}
switch args[0] {
case "version", "-v", "--version":
fmt.Println(version.FullVersion())
case "scan":
runScan()
case "install":
runInstall(args[1:])
case "update":
runUpdate()
case "setup":
runSetup()
case "config":
showConfig()
case "doctor":
runDoctor()
case "lsp":
runLSP(args[1:])
case "mcp":
runMCP(args[1:])
case "skills":
runSkills(args[1:])
case "help", "-h", "--help":
printHelp()
default:
fmt.Printf("Unknown command: %s\n", args[0])
printHelp()
os.Exit(1)
}
}
func printHelp() {
fmt.Printf(`%s - AI-powered development environment assistant
Usage:
muyue Start the interactive TUI
muyue <command> Run a specific command
Commands:
version Show version
scan Scan your system for tools and runtimes
install [tools] Install missing tools (needs sudo for some tools)
update Check and apply updates for all tools
setup Run first-time setup wizard
config Show current configuration
doctor Check that everything is properly configured
lsp [scan|install] Scan or install LSP servers
mcp [config|scan] Configure MCP servers for Crush and Claude Code
skills [list|generate|deploy|init|delete] Manage AI coding skills
help Show this help
TUI Controls:
Ctrl+T Open tab switcher (navigate with arrows, select with enter)
Tab / Shift+Tab Cycle tabs
Ctrl+C Show quit confirmation (press twice quickly to force quit)
Chat Commands:
/plan <goal> Start a structured Plan→Execute workflow
Workflow Controls:
[a] Approve plan
[r] Reject plan (type feedback)
[g] Generate plan (after answering questions)
[n] Execute next step
[x] Cancel/reset workflow
Note:
Some tools (docker, gh, etc.) require elevated privileges.
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
`, version.FullVersion())
}
func runTUI() {
cfg := loadOrSetupConfig()
result := scanner.ScanSystem()
model := tui.NewModel(cfg, result)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
if err := desktop.Run(cfg, os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
@@ -153,453 +52,3 @@ func loadOrSetupConfig() *config.MuyueConfig {
return cfg
}
func runScan() {
fmt.Println("Scanning system...")
result := scanner.ScanSystem()
fmt.Println(result.Summary())
}
func runInstall(tools []string) {
cfg := loadOrSetupConfig()
inst := installer.New(cfg)
if len(tools) == 0 {
result := scanner.ScanSystem()
var missing []string
for _, t := range result.Tools {
if !t.Installed {
missing = append(missing, t.Name)
}
}
if len(missing) == 0 {
fmt.Println("All tools are installed!")
return
}
fmt.Printf("Missing tools: %v\nInstalling...\n", missing)
tools = missing
}
if needsSudo(tools) && os.Geteuid() != 0 {
fmt.Println("Some tools require elevated privileges.")
if path, err := exec.LookPath("sudo"); err == nil {
fmt.Printf("Re-running with sudo...\n")
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "sudo install failed: %v\n", err)
os.Exit(1)
}
config.Save(cfg)
return
}
if path, err := exec.LookPath("pkexec"); err == nil {
fmt.Printf("Re-running with pkexec...\n")
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "pkexec install failed: %v\n", err)
os.Exit(1)
}
config.Save(cfg)
return
}
fmt.Println("Neither sudo nor pkexec found. Some installs may fail.")
fmt.Println("Try running: sudo muyue install")
}
results := inst.InstallAll(tools)
for _, r := range results {
status := "[OK]"
if !r.Success {
status = "[FAIL]"
}
fmt.Printf(" %s %s: %s\n", status, r.Tool, r.Message)
}
config.Save(cfg)
}
func needsSudo(tools []string) bool {
sudoTools := map[string]bool{
"docker": true, "git": true, "gh": true, "node": true, "python": true,
}
for _, t := range tools {
if sudoTools[t] {
return true
}
}
return false
}
func runUpdate() {
fmt.Println("Checking for updates...")
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
needsUpdate := false
for _, s := range statuses {
if s.NeedsUpdate {
fmt.Printf(" [!] %s: %s -> %s\n", s.Tool, s.Current, s.Latest)
needsUpdate = true
} else if s.Error == "" {
fmt.Printf(" [v] %s: up to date (%s)\n", s.Tool, s.Current)
} else {
fmt.Printf(" [?] %s: %s\n", s.Tool, s.Error)
}
}
if needsUpdate {
fmt.Println("\nApplying updates...")
results := updater.RunAutoUpdate(statuses)
for _, r := range results {
fmt.Printf(" %s: %s\n", r.Tool, r.Message)
}
}
}
func runSetup() {
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
os.Exit(1)
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Println("Setup complete!")
}
func showConfig() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config not found. Run `muyue setup` first.\n")
os.Exit(1)
}
fmt.Printf("Profile: %s (%s)\n", cfg.Profile.Name, cfg.Profile.Pseudo)
fmt.Printf("Email: %s\n", cfg.Profile.Email)
fmt.Printf("Editor: %s\n", cfg.Profile.Preferences.Editor)
fmt.Printf("Default AI: %s\n", cfg.Profile.Preferences.DefaultAI)
fmt.Printf("Languages: %v\n", cfg.Profile.Languages)
for _, p := range cfg.AI.Providers {
active := ""
if p.Active {
active = " (active)"
}
keyStatus := "no key"
if p.APIKey != "" {
keyStatus = "configured"
}
fmt.Printf(" %s: model=%s, key=%s%s\n", p.Name, p.Model, keyStatus, active)
}
fmt.Printf("BMAD: installed=%v, global=%v\n", cfg.BMAD.Installed, cfg.BMAD.Global)
fmt.Printf("Custom Prompt: %v\n", cfg.Terminal.CustomPrompt)
}
func runDoctor() {
ok := true
fmt.Println("Running diagnostics...")
fmt.Println()
fmt.Println("Configuration:")
if !config.Exists() {
fmt.Println(" [FAIL] Config file not found. Run 'muyue setup' first.")
ok = false
} else {
cfg, err := config.Load()
if err != nil {
fmt.Printf(" [FAIL] Config load error: %v\n", err)
ok = false
} else {
fmt.Println(" [OK] Config file present")
hasKey := false
for _, p := range cfg.AI.Providers {
if p.Active && p.APIKey != "" {
hasKey = true
}
}
if hasKey {
fmt.Println(" [OK] API key configured")
} else {
fmt.Println(" [FAIL] No API key set for active provider")
ok = false
}
}
}
fmt.Println("\nTools:")
result := scanner.ScanSystem()
installed := 0
for _, t := range result.Tools {
if t.Installed {
installed++
fmt.Printf(" [OK] %s\n", t.Name)
} else {
fmt.Printf(" [FAIL] %s (not installed)\n", t.Name)
}
}
fmt.Printf(" Installed: %d/%d\n", installed, len(result.Tools))
fmt.Println("\nLSP Servers:")
servers := lsp.ScanServers()
lspOK := 0
for _, s := range servers {
if s.Installed {
lspOK++
fmt.Printf(" [OK] %s (%s)\n", s.Name, s.Language)
}
}
fmt.Printf(" Available: %d/%d\n", lspOK, len(servers))
fmt.Println("\nMCP Servers:")
mcpServers := mcp.ScanServers()
mcpOK := 0
for _, s := range mcpServers {
if s.Installed {
mcpOK++
}
}
fmt.Printf(" Available: %d/%d\n", mcpOK, len(mcpServers))
fmt.Println("\nSkills:")
skillList, err := skills.List()
if err != nil || len(skillList) == 0 {
fmt.Println(" [FAIL] No skills. Run 'muyue skills init'.")
ok = false
} else {
fmt.Printf(" [OK] %d skills installed\n", len(skillList))
}
fmt.Println()
if ok {
fmt.Println("All checks passed!")
} else {
fmt.Println("Some checks failed. Review the output above.")
os.Exit(1)
}
}
func runLSP(args []string) {
if len(args) == 0 {
args = []string{"scan"}
}
switch args[0] {
case "scan":
fmt.Println("Scanning LSP servers...")
servers := lsp.ScanServers()
installed := 0
for _, s := range servers {
if s.Installed {
installed++
fmt.Printf(" [v] %-35s (%s)\n", s.Name, s.Language)
} else {
fmt.Printf(" [ ] %-35s (%s)\n", s.Name, s.Language)
}
}
fmt.Printf("\nInstalled: %d/%d\n", installed, len(servers))
case "install":
if len(args) < 2 {
cfg := loadOrSetupConfig()
fmt.Printf("Installing LSP servers for: %v\n", cfg.Profile.Languages)
results := lsp.InstallForLanguages(cfg.Profile.Languages)
for _, r := range results {
if r.Installed {
fmt.Printf(" [OK] %s (%s)\n", r.Name, r.Language)
} else {
fmt.Printf(" [FAIL] %s (%s)\n", r.Name, r.Language)
}
}
} else {
for _, name := range args[1:] {
fmt.Printf("Installing %s...\n", name)
if err := lsp.InstallServer(name); err != nil {
fmt.Printf(" [FAIL] %s: %s\n", name, err)
} else {
fmt.Printf(" [OK] %s\n", name)
}
}
}
default:
fmt.Printf("Unknown lsp subcommand: %s (scan, install)\n", args[0])
}
}
func runMCP(args []string) {
if len(args) == 0 {
args = []string{"config"}
}
switch args[0] {
case "config":
cfg := loadOrSetupConfig()
fmt.Println("Configuring MCP servers for Crush and Claude Code...")
if err := mcp.ConfigureAll(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Done! MCP servers configured.")
case "scan":
fmt.Println("Scanning MCP servers...")
servers := mcp.ScanServers()
available := 0
for _, s := range servers {
if s.Installed {
available++
fmt.Printf(" [v] %-30s (%s)\n", s.Name, s.Category)
} else {
fmt.Printf(" [ ] %-30s (%s)\n", s.Name, s.Category)
}
}
fmt.Printf("\nAvailable: %d/%d\n", available, len(servers))
default:
fmt.Printf("Unknown mcp subcommand: %s (config, scan)\n", args[0])
}
}
func runSkills(args []string) {
if len(args) == 0 {
args = []string{"list"}
}
switch args[0] {
case "list", "ls":
skillsList, err := skills.List()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(skillsList) == 0 {
fmt.Println("No skills found. Run `muyue skills init` to install built-in skills.")
return
}
fmt.Printf("Skills (%d):\n", len(skillsList))
for _, s := range skillsList {
target := s.Target
if target == "" {
target = "both"
}
fmt.Printf(" %-20s %-8s %s\n", s.Name, target, s.Description)
}
case "init":
fmt.Println("Installing built-in skills...")
if err := skills.InstallBuiltinSkills(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Deploying to Crush and Claude Code...")
if err := skills.DeployAll(); err != nil {
fmt.Fprintf(os.Stderr, "Deploy error: %v\n", err)
}
fmt.Println("Done! Built-in skills installed and deployed.")
case "show":
if len(args) < 2 {
fmt.Println("Usage: muyue skills show <name>")
return
}
skill, err := skills.Get(args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Name: %s\n", skill.Name)
fmt.Printf("Description: %s\n", skill.Description)
fmt.Printf("Author: %s\n", skill.Author)
fmt.Printf("Version: %s\n", skill.Version)
fmt.Printf("Target: %s\n", skill.Target)
fmt.Printf("Tags: %v\n", skill.Tags)
fmt.Printf("Path: %s\n", skill.FilePath)
fmt.Printf("\n--- Content ---\n%s\n", skill.Content)
case "generate":
if len(args) < 3 {
fmt.Println("Usage: muyue skills generate <name> <description> [crush|claude|both]")
fmt.Println("Example: muyue skills generate docker-setup \"Set up Docker for a project\" both")
return
}
name := args[1]
description := args[2]
target := "both"
if len(args) > 3 {
target = args[3]
}
cfg := loadOrSetupConfig()
orch, err := orchestrator.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "AI not configured: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generating skill '%s'...\n", name)
prompt := skills.BuildAIGeneratePrompt(name, description, target)
resp, err := orch.Send(prompt)
if err != nil {
fmt.Fprintf(os.Stderr, "Generation error: %v\n", err)
os.Exit(1)
}
skill := &skills.Skill{
Name: name,
Description: description,
Content: resp,
Author: "muyue-generated",
Version: "0.1.0",
Target: target,
Tags: []string{"generated"},
}
if err := skills.Create(skill); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Skill '%s' created and deployed!\n", name)
case "deploy":
fmt.Println("Deploying all skills to Crush and Claude Code...")
if err := skills.DeployAll(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Done!")
case "delete":
if len(args) < 2 {
fmt.Println("Usage: muyue skills delete <name>")
return
}
if err := skills.Delete(args[1]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Skill '%s' deleted.\n", args[1])
default:
fmt.Printf("Unknown skills subcommand: %s\n", args[0])
fmt.Println("Available: list, show, generate, deploy, init, delete")
}
}

13
go.mod
View File

@@ -1,12 +1,13 @@
module github.com/muyue/muyue
go 1.24.3
go 1.24.2
toolchain go1.24.3
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty/v2 v2.0.1
github.com/gorilla/websocket v1.5.3
gopkg.in/yaml.v3 v3.0.1
)
@@ -14,8 +15,10 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect

6
go.sum
View File

@@ -14,8 +14,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@@ -46,10 +44,14 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -0,0 +1,311 @@
package agent
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
)
type TerminalParams struct {
Command string `json:"command" description:"The shell command to execute"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
}
func NewTerminalTool() (*ToolDefinition, error) {
return NewTool("terminal",
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
func(ctx context.Context, p TerminalParams) (ToolResponse, error) {
if p.Command == "" {
return TextErrorResponse("command is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 60 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
shell := detectShell()
cmd := exec.CommandContext(ctx, shell, "-c", p.Command)
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Error: %v\n\n%s", err, result)), nil
}
return TextResponse(result), nil
})
}
type CrushRunParams struct {
Task string `json:"task" description:"The task description for Crush to execute"`
}
func NewCrushRunTool() (*ToolDefinition, error) {
return NewTool("crush_run",
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Returns the agent's final output.",
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 15000 {
result = result[:15000] + "\n... [truncated]"
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
}
return TextResponse(result), nil
})
}
type ReadFileParams struct {
Path string `json:"path" description:"Absolute or relative path to the file to read"`
Offset int `json:"offset,omitempty" description:"Line number to start reading from (0-based, default 0)"`
Limit int `json:"limit,omitempty" description:"Maximum number of lines to read (default 200, max 2000)"`
}
func NewReadFileTool() (*ToolDefinition, error) {
return NewTool("read_file",
"Read file contents from the local filesystem. Returns the file content with line numbers. Supports offset/limit for reading specific sections of large files.",
func(ctx context.Context, p ReadFileParams) (ToolResponse, error) {
if p.Path == "" {
return TextErrorResponse("path is required"), nil
}
expanded := expandHome(p.Path)
data, err := readFileLimited(expanded, p.Offset, p.Limit)
if err != nil {
return TextErrorResponse(fmt.Sprintf("read error: %v", err)), nil
}
return TextResponse(data), nil
})
}
type ListFilesParams struct {
Path string `json:"path,omitempty" description:"Directory path to list (default: user home)"`
Depth int `json:"depth,omitempty" description:"Maximum depth to traverse (default 1, max 3)"`
}
func NewListFilesTool() (*ToolDefinition, error) {
return NewTool("list_files",
"List files and directories at a given path. Shows directory tree structure with file names. Useful for exploring project structure or finding specific files.",
func(ctx context.Context, p ListFilesParams) (ToolResponse, error) {
dir := expandHome(p.Path)
if dir == "" {
dir, _ = osUserHomeDir()
}
if p.Depth <= 0 {
p.Depth = 1
}
if p.Depth > 3 {
p.Depth = 3
}
result, err := listDirTree(dir, p.Depth, 0)
if err != nil {
return TextErrorResponse(fmt.Sprintf("list error: %v", err)), nil
}
return TextResponse(result), nil
})
}
type SearchFilesParams struct {
Pattern string `json:"pattern" description:"Search pattern (supports * and ? glob wildcards)"`
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
}
func NewSearchFilesTool() (*ToolDefinition, error) {
return NewTool("search_files",
"Search for files by name pattern using glob syntax. Use * for any characters, ** for recursive matching. Returns matching file paths sorted by name.",
func(ctx context.Context, p SearchFilesParams) (ToolResponse, error) {
if p.Pattern == "" {
return TextErrorResponse("pattern is required"), nil
}
dir := expandHome(p.Path)
if dir == "" {
dir = "."
}
matches, err := filepath.Glob(filepath.Join(dir, p.Pattern))
if err != nil {
return TextErrorResponse(fmt.Sprintf("glob error: %v", err)), nil
}
if len(matches) == 0 {
return TextResponse("No files found matching pattern."), nil
}
if len(matches) > 100 {
matches = matches[:100]
}
var result strings.Builder
for _, m := range matches {
result.WriteString(m)
result.WriteString("\n")
}
return TextResponse(result.String()), nil
})
}
type GrepContentParams struct {
Pattern string `json:"pattern" description:"Text pattern to search for in file contents"`
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
Include string `json:"include,omitempty" description:"File extension filter, e.g. '*.go' or '*.{js,ts}'"`
}
func NewGrepContentTool() (*ToolDefinition, error) {
return NewTool("grep_content",
"Search for text patterns inside file contents. Returns matching lines with file paths and line numbers. Use include to filter by file extension.",
func(ctx context.Context, p GrepContentParams) (ToolResponse, error) {
if p.Pattern == "" {
return TextErrorResponse("pattern is required"), nil
}
dir := expandHome(p.Path)
if dir == "" {
dir = "."
}
result, err := grepFiles(dir, p.Pattern, p.Include)
if err != nil {
return TextErrorResponse(fmt.Sprintf("grep error: %v", err)), nil
}
if result == "" {
return TextResponse("No matches found."), nil
}
return TextResponse(result), nil
})
}
type GetConfigParams struct {
Section string `json:"section,omitempty" description:"Config section to retrieve: 'providers', 'profile', 'tools', 'terminal', 'all' (default: 'all')"`
}
func NewGetConfigTool() (*ToolDefinition, error) {
return NewTool("get_config",
"Read the Muyue configuration. Returns provider settings, profile info, installed tools, terminal config, etc. Use section parameter to get a specific part, or 'all' for the full config.",
func(ctx context.Context, p GetConfigParams) (ToolResponse, error) {
return getConfigSection(p.Section), nil
})
}
type SetProviderParams struct {
Name string `json:"name" description:"Provider name (e.g. 'openai', 'anthropic', 'ollama')"`
APIKey string `json:"api_key,omitempty" description:"API key for the provider"`
BaseURL string `json:"base_url,omitempty" description:"Custom base URL for the provider API"`
Model string `json:"model,omitempty" description:"Model identifier to use"`
Active *bool `json:"active,omitempty" description:"Set to true to make this the active provider"`
}
func NewSetProviderTool() (*ToolDefinition, error) {
return NewTool("set_provider",
"Configure an AI provider in Muyue settings. Can create, update, or activate a provider. API keys are automatically encrypted. Set active=true to switch to this provider.",
func(ctx context.Context, p SetProviderParams) (ToolResponse, error) {
if p.Name == "" {
return TextErrorResponse("name is required"), nil
}
return setProviderConfig(p), nil
})
}
type ManageSSHParams struct {
Action string `json:"action" description:"Action to perform: 'list', 'add', 'remove'"`
Name string `json:"name,omitempty" description:"Connection name (required for add/remove)"`
Host string `json:"host,omitempty" description:"SSH host (required for add)"`
Port int `json:"port,omitempty" description:"SSH port (default: 22)"`
User string `json:"user,omitempty" description:"SSH username (required for add)"`
KeyPath string `json:"key_path,omitempty" description:"Path to SSH private key"`
}
func NewManageSSHTool() (*ToolDefinition, error) {
return NewTool("manage_ssh",
"Manage SSH connections configured in Muyue. List existing connections, add new ones, or remove connections. SSH configs are persisted to the Muyue config file.",
func(ctx context.Context, p ManageSSHParams) (ToolResponse, error) {
if p.Action == "" {
return TextErrorResponse("action is required (list, add, remove)"), nil
}
return manageSSHAction(p), nil
})
}
type WebFetchParams struct {
URL string `json:"url" description:"The URL to fetch content from"`
}
func NewWebFetchTool() (*ToolDefinition, error) {
return NewTool("web_fetch",
"Fetch content from a URL and return the text. Useful for reading documentation, APIs, or web resources. Only HTTP/HTTPS URLs are supported.",
func(ctx context.Context, p WebFetchParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required"), nil
}
return fetchURL(p.URL), nil
})
}
func DefaultRegistry() *Registry {
r := NewRegistry()
tools := []*ToolDefinition{
must(NewTerminalTool()),
must(NewCrushRunTool()),
must(NewReadFileTool()),
must(NewListFilesTool()),
must(NewSearchFilesTool()),
must(NewGrepContentTool()),
must(NewGetConfigTool()),
must(NewSetProviderTool()),
must(NewManageSSHTool()),
must(NewWebFetchTool()),
}
for _, t := range tools {
if err := r.Register(t); err != nil {
panic(err)
}
}
return r
}
func must(t *ToolDefinition, err error) *ToolDefinition {
if err != nil {
panic(err)
}
return t
}

579
internal/agent/impl.go Normal file
View File

@@ -0,0 +1,579 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
)
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"
}
func expandHome(path string) string {
if path == "" {
return ""
}
if path == "~" {
home, _ := os.UserHomeDir()
return home
}
if strings.HasPrefix(path, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:])
}
return path
}
func osUserHomeDir() (string, error) {
return os.UserHomeDir()
}
func readFileLimited(path string, offset, limit int) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
lines := strings.Split(string(data), "\n")
if offset < 0 {
offset = 0
}
if offset > len(lines) {
offset = len(lines)
}
end := offset + limit
if limit <= 0 || limit > 2000 {
limit = 2000
}
if end > len(lines) {
end = len(lines)
}
if end-offset > limit {
end = offset + limit
}
selected := lines[offset:end]
var buf strings.Builder
for i, line := range selected {
fmt.Fprintf(&buf, "%6d\t%s\n", offset+i+1, line)
}
return buf.String(), nil
}
func listDirTree(dir string, maxDepth, currentDepth int) (string, error) {
info, err := os.Stat(dir)
if err != nil {
return "", err
}
if !info.IsDir() {
return dir + "\n", nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var buf strings.Builder
indent := strings.Repeat(" ", currentDepth)
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") && name != "." && name != ".." {
continue
}
if entry.IsDir() {
fmt.Fprintf(&buf, "%s%s/\n", indent, name)
if currentDepth < maxDepth {
sub, err := listDirTree(filepath.Join(dir, name), maxDepth, currentDepth+1)
if err == nil {
buf.WriteString(sub)
}
}
} else {
fmt.Fprintf(&buf, "%s%s\n", indent, name)
}
}
return buf.String(), nil
}
func grepFiles(dir, pattern, include string) (string, error) {
if include != "" {
matches, err := filepath.Glob(filepath.Join(dir, include))
if err != nil {
return "", err
}
if len(matches) == 0 {
return "", nil
}
var buf strings.Builder
for _, match := range matches {
result, err := grepInFile(match, pattern)
if err != nil {
continue
}
buf.WriteString(result)
}
return buf.String(), nil
}
return grepInDir(dir, pattern, 0)
}
func grepInDir(dir, pattern string, depth int) (string, error) {
if depth > 10 {
return "", nil
}
var buf strings.Builder
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
path := filepath.Join(dir, name)
if entry.IsDir() {
sub, err := grepInDir(path, pattern, depth+1)
if err == nil {
buf.WriteString(sub)
}
continue
}
result, err := grepInFile(path, pattern)
if err != nil {
continue
}
buf.WriteString(result)
}
return buf.String(), nil
}
func grepInFile(path, pattern string) (string, error) {
re, err := regexp.Compile(pattern)
if err != nil {
re, err = regexp.Compile(regexp.QuoteMeta(pattern))
if err != nil {
return "", err
}
}
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
var buf strings.Builder
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNum := 0
matchCount := 0
for scanner.Scan() {
lineNum++
if re.MatchString(scanner.Text()) {
fmt.Fprintf(&buf, "%s:%d: %s\n", path, lineNum, scanner.Text())
matchCount++
if matchCount >= 50 {
buf.WriteString("... [truncated, more matches exist]\n")
break
}
}
}
return buf.String(), nil
}
func getConfigSection(section string) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
switch section {
case "providers", "profile", "tools", "terminal":
sectionData := extractYAMLSection(data, section)
if sectionData == "" {
return TextResponse(fmt.Sprintf("Section '%s' not found in config.", section))
}
return TextResponse(sectionData)
default:
content := string(data)
if len(content) > 8000 {
content = content[:8000] + "\n... [truncated]"
}
return TextResponse(content)
}
}
func extractYAMLSection(data []byte, section string) string {
lines := strings.Split(string(data), "\n")
inSection := false
indentLevel := 0
var buf strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
if inSection {
buf.WriteString("\n")
}
continue
}
if !inSection {
if strings.HasPrefix(trimmed, section+":") || strings.HasPrefix(trimmed, section+" ") {
inSection = true
indentLevel = len(line) - len(strings.TrimLeft(line, " "))
buf.WriteString(line)
buf.WriteString("\n")
}
continue
}
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
if currentIndent <= indentLevel && trimmed != "" {
break
}
buf.WriteString(line)
buf.WriteString("\n")
}
return strings.TrimSpace(buf.String())
}
func setProviderConfig(p SetProviderParams) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
lines := strings.Split(string(data), "\n")
inProviders := false
providerIndent := 0
foundProvider := false
insertIdx := -1
lastProviderEnd := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !inProviders {
if strings.HasPrefix(trimmed, "providers:") {
inProviders = true
providerIndent = len(line) - len(strings.TrimLeft(line, " "))
}
continue
}
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
if currentIndent <= providerIndent && trimmed != "" && !strings.HasPrefix(trimmed, "#") {
lastProviderEnd = i
break
}
if currentIndent == providerIndent+2 && strings.HasPrefix(trimmed, "- name:") {
nameMatch := strings.TrimPrefix(trimmed, "- name:")
nameMatch = strings.TrimSpace(nameMatch)
if nameMatch == p.Name {
foundProvider = true
insertIdx = i
}
if insertIdx == -1 || insertIdx < i {
insertIdx = i
}
}
}
if lastProviderEnd == -1 {
lastProviderEnd = len(lines)
}
entryIndent := strings.Repeat(" ", providerIndent+4)
var newEntry strings.Builder
newEntry.WriteString(fmt.Sprintf(" - name: %s\n", p.Name))
if p.Model != "" {
newEntry.WriteString(fmt.Sprintf("%smodel: %s\n", entryIndent, p.Model))
}
if p.BaseURL != "" {
newEntry.WriteString(fmt.Sprintf("%sbase_url: %s\n", entryIndent, p.BaseURL))
}
if p.APIKey != "" {
newEntry.WriteString(fmt.Sprintf("%sapi_key: %s\n", entryIndent, p.APIKey))
}
if p.Active != nil {
newEntry.WriteString(fmt.Sprintf("%sactive: %v\n", entryIndent, *p.Active))
}
if foundProvider && insertIdx >= 0 {
var endIdx int
for endIdx = insertIdx + 1; endIdx < len(lines); endIdx++ {
li := len(lines[endIdx]) - len(strings.TrimLeft(lines[endIdx], " "))
if li <= providerIndent+2 || lines[endIdx] == "" {
if endIdx > insertIdx+1 && strings.TrimSpace(lines[endIdx]) == "" {
continue
}
break
}
}
newLines := make([]string, 0, len(lines))
newLines = append(newLines, lines[:insertIdx]...)
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
newLines = append(newLines, lines[endIdx:]...)
lines = newLines
} else {
insertAt := lastProviderEnd
newLines := make([]string, 0, len(lines)+10)
newLines = append(newLines, lines[:insertAt]...)
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
newLines = append(newLines, lines[insertAt:]...)
lines = newLines
}
content := strings.Join(lines, "\n")
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("Provider '%s' configured successfully.", p.Name))
}
func manageSSHAction(p ManageSSHParams) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
switch p.Action {
case "list":
sshSection := extractYAMLSection(data, "ssh")
if sshSection == "" {
return TextResponse("No SSH connections configured.")
}
return TextResponse(sshSection)
case "add":
if p.Name == "" || p.Host == "" || p.User == "" {
return TextErrorResponse("name, host, and user are required for add action")
}
if p.Port == 0 {
p.Port = 22
}
lines := strings.Split(string(data), "\n")
sshIdx := -1
sshIndent := 0
lastSSHEnd := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if sshIdx == -1 && strings.HasPrefix(trimmed, "ssh:") {
sshIdx = i
sshIndent = len(line) - len(strings.TrimLeft(line, " "))
continue
}
if sshIdx != -1 {
li := len(line) - len(strings.TrimLeft(line, " "))
if li <= sshIndent && trimmed != "" {
lastSSHEnd = i
break
}
}
}
if lastSSHEnd == -1 {
lastSSHEnd = len(lines)
}
entry := fmt.Sprintf(" - name: %s\n host: %s\n port: %d\n user: %s", p.Name, p.Host, p.Port, p.User)
if p.KeyPath != "" {
entry += fmt.Sprintf("\n key_path: %s", p.KeyPath)
}
newLines := make([]string, 0, len(lines)+10)
newLines = append(newLines, lines[:lastSSHEnd]...)
newLines = append(newLines, entry)
newLines = append(newLines, lines[lastSSHEnd:]...)
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("SSH connection '%s' (%s@%s:%d) added.", p.Name, p.User, p.Host, p.Port))
case "remove":
if p.Name == "" {
return TextErrorResponse("name is required for remove action")
}
lines := strings.Split(string(data), "\n")
newLines := make([]string, 0, len(lines))
skipping := false
removed := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "name: "+p.Name) && strings.HasPrefix(trimmed, "-") {
skipping = true
removed = true
continue
}
if skipping {
li := len(line) - len(strings.TrimLeft(line, " "))
if li > 6 && i < len(lines)-1 && strings.TrimSpace(lines[i+1]) != "" {
continue
}
skipping = false
continue
}
newLines = append(newLines, line)
}
if !removed {
return TextErrorResponse(fmt.Sprintf("SSH connection '%s' not found.", p.Name))
}
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("SSH connection '%s' removed.", p.Name))
default:
return TextErrorResponse("unknown action. Use 'list', 'add', or 'remove'")
}
}
func fetchURL(url string) ToolResponse {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return TextErrorResponse("only http/https URLs are supported")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return TextErrorResponse(fmt.Sprintf("create request: %v", err))
}
req.Header.Set("User-Agent", "MuyueStudio/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return TextErrorResponse(fmt.Sprintf("fetch error: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 50000))
if err != nil {
return TextErrorResponse(fmt.Sprintf("read error: %v", err))
}
if resp.StatusCode != http.StatusOK {
return TextErrorResponse(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 2000)))
}
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "text/html") {
text := stripHTML(string(body))
if len(text) > 8000 {
text = text[:8000] + "\n... [truncated]"
}
return TextResponse(text)
}
result := string(body)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
return TextResponse(result)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
func stripHTML(html string) string {
tagRe := regexp.MustCompile(`<[^>]*>`)
text := tagRe.ReplaceAllString(html, " ")
entityRe := regexp.MustCompile(`&[a-zA-Z]+;`)
text = entityRe.ReplaceAllStringFunc(text, func(s string) string {
switch s {
case "&amp;":
return "&"
case "&lt;":
return "<"
case "&gt;":
return ">"
case "&quot;":
return "\""
case "&#39;":
return "'"
case "&nbsp;":
return " "
default:
return " "
}
})
multiSpace := regexp.MustCompile(`\s+`)
text = multiSpace.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}
var _ = runtime.GOOS
var _ = json.Marshal

10
internal/agent/prompt.go Normal file
View File

@@ -0,0 +1,10 @@
package agent
import _ "embed"
//go:embed prompts/studio_system.md
var studioSystemPrompt string
func StudioSystemPrompt() string {
return studioSystemPrompt
}

View File

@@ -0,0 +1,44 @@
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur.
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev.
## Environnement
Muyue gère :
- **Fournisseurs IA** (OpenAI, Anthropic, Ollama, MiniMax, etc.)
- **Outils de développement** (Crush, Claude Code, etc.)
- **Terminaux locaux et SSH**
- **Configuration et préférences**
- **Serveurs MCP et LSP**
## Outils disponibles
Tu as accès à des outils. Utilise-les concrètement, ne décris pas ce que tu ferais — fais-le.
- **terminal** : Exécuter des commandes shell (builds, tests, git, etc.)
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug)
- **read_file** : Lire le contenu d'un fichier
- **list_files** : Lister les fichiers d'un répertoire
- **search_files** : Chercher des fichiers par motif (glob)
- **grep_content** : Chercher du texte dans le contenu des fichiers
- **get_config** : Lire la configuration Muyue
- **set_provider** : Configurer un fournisseur IA
- **manage_ssh** : Gérer les connexions SSH
- **web_fetch** : Récupérer le contenu d'une URL
## Règles
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils pour le faire. Ne dis pas "je pourrais faire X" — fais-le.
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe.
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire.
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur.
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.)
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses.
7. **Langue** — Réponds dans la même langue que l'utilisateur.
## Format des réponses
- Code : utilise des blocs markdown
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes
- Erreurs : explique clairement et propose une solution
- Succès : confirme brièvement ce qui a été fait

218
internal/agent/tools.go Normal file
View File

@@ -0,0 +1,218 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
)
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type ToolResponse struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
Meta map[string]string `json:"meta,omitempty"`
}
func TextResponse(content string) ToolResponse {
return ToolResponse{Content: content}
}
func TextErrorResponse(msg string) ToolResponse {
return ToolResponse{Content: msg, IsError: true}
}
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Params json.RawMessage `json:"parameters"`
Handler func(ctx context.Context, args json.RawMessage) (ToolResponse, error)
}
func (td *ToolDefinition) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
resp, err := td.Handler(ctx, call.Arguments)
if err != nil {
return ToolResponse{Content: err.Error(), IsError: true}, nil
}
return resp, nil
}
func (td *ToolDefinition) ToOpenAITool() map[string]interface{} {
return map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": td.Name,
"description": td.Description,
"parameters": td.Params,
},
}
}
func NewTool[P any](name, description string, handler func(ctx context.Context, params P) (ToolResponse, error)) (*ToolDefinition, error) {
var zero P
paramsSchema, err := generateSchema(zero)
if err != nil {
return nil, fmt.Errorf("generate schema for %s: %w", name, err)
}
wrappedHandler := func(ctx context.Context, raw json.RawMessage) (ToolResponse, error) {
var params P
if err := json.Unmarshal(raw, &params); err != nil {
return TextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil
}
return handler(ctx, params)
}
return &ToolDefinition{
Name: name,
Description: description,
Params: paramsSchema,
Handler: wrappedHandler,
}, nil
}
type Registry struct {
tools map[string]*ToolDefinition
}
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]*ToolDefinition),
}
}
func (r *Registry) Register(tool *ToolDefinition) error {
if _, exists := r.tools[tool.Name]; exists {
return fmt.Errorf("tool %q already registered", tool.Name)
}
r.tools[tool.Name] = tool
return nil
}
func (r *Registry) Get(name string) (*ToolDefinition, bool) {
t, ok := r.tools[name]
return t, ok
}
func (r *Registry) All() []*ToolDefinition {
out := make([]*ToolDefinition, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t)
}
return out
}
func (r *Registry) OpenAITools() []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t.ToOpenAITool())
}
return out
}
func (r *Registry) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
tool, ok := r.tools[call.Name]
if !ok {
return TextErrorResponse(fmt.Sprintf("unknown tool: %s", call.Name)), nil
}
return tool.Execute(ctx, call)
}
func generateSchema(v interface{}) (json.RawMessage, error) {
t := reflect.TypeOf(v)
if t == nil {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
props := make(map[string]interface{})
required := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := field.Name
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
jsonName = parts[0]
}
omitempty := false
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
desc := field.Tag.Get("description")
prop := map[string]interface{}{
"type": goTypeToJSON(field.Type),
}
if desc != "" {
prop["description"] = desc
}
props[jsonName] = prop
if !omitempty {
required = append(required, jsonName)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
data, err := json.Marshal(schema)
if err != nil {
return nil, err
}
return json.RawMessage(data), nil
}
func goTypeToJSON(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.Map:
return "object"
default:
return "string"
}
}

View File

@@ -0,0 +1,158 @@
package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const maxTokensApprox = 100000
const summarizeThreshold = 80000
const charsPerToken = 4
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
}
func NewConversationStore() *ConversationStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "conversation.json")
cs := &ConversationStore{path: path}
cs.load()
return cs
}
func (cs *ConversationStore) load() {
data, err := os.ReadFile(cs.path)
if err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
if conv.Messages == nil {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
}
func (cs *ConversationStore) save() error {
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(cs.path)
os.MkdirAll(dir, 0755)
return os.WriteFile(cs.path, data, 0600)
}
func (cs *ConversationStore) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
out := make([]FeedMessage, len(cs.conv.Messages))
copy(out, cs.conv.Messages)
return out
}
func (cs *ConversationStore) GetSummary() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.conv.Summary
}
func (cs *ConversationStore) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save()
}
func (cs *ConversationStore) SetSummary(summary string) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Summary = summary
cs.save()
}
func (cs *ConversationStore) TrimOld(keepCount int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if len(cs.conv.Messages) <= keepCount {
return
}
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
cs.save()
}
func (cs *ConversationStore) ApproxTokenCount() int {
cs.mu.RLock()
defer cs.mu.RUnlock()
total := utf8.RuneCountInString(cs.conv.Summary)
for _, m := range cs.conv.Messages {
total += utf8.RuneCountInString(m.Content)
}
return total / charsPerToken
}
func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > summarizeThreshold
}
func generateMsgID() string {
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
}

View File

@@ -0,0 +1,281 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
const maxToolIterations = 15
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Message string `json:"message"`
Stream bool `json:"stream"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Message == "" {
writeError(w, "no message", http.StatusMethodNotAllowed)
return
}
s.convStore.Add("user", body.Message)
if s.convStore.NeedsSummarization() {
s.autoSummarize()
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(agent.StudioSystemPrompt())
orb.SetTools(s.agentToolsJSON)
if body.Stream {
s.handleStreamChat(w, orb, body.Message)
} else {
s.handleNonStreamChat(w, orb, body.Message)
}
}
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
writeSSE := func(data map[string]interface{}) {
b, _ := json.Marshal(data)
w.Write([]byte("data: " + string(b) + "\n\n"))
if canFlush {
flusher.Flush()
}
}
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: userMessage},
}
var finalContent string
var allToolCalls []map[string]interface{}
for i := 0; i < maxToolIterations; i++ {
resp, err := orb.SendWithTools(messages)
if err != nil {
writeSSE(map[string]interface{}{"error": err.Error()})
return
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
for _, ch := range strings.Split(content, "") {
writeSSE(map[string]interface{}{"content": ch})
}
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
toolCallData := map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
}
allToolCalls = append(allToolCalls, toolCallData)
writeSSE(map[string]interface{}{"tool_call": toolCallData})
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
result, execErr := s.agentRegistry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
resultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": result.Content,
"is_error": result.IsError,
}
writeSSE(map[string]interface{}{"tool_result": resultData})
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: result.Content,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
storeContent := finalContent
if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{"content": storeContent, "tool_calls": allToolCalls}
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
}
s.convStore.Add("assistant", storeContent)
writeSSE(map[string]interface{}{"done": "true"})
}
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: userMessage},
}
var finalContent string
for i := 0; i < maxToolIterations; i++ {
resp, err := orb.SendWithTools(messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
result, execErr := s.agentRegistry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: result.Content,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
if finalContent == "" {
finalContent = "(tool calls completed, no text response)"
}
s.convStore.Add("assistant", finalContent)
writeJSON(w, map[string]string{"content": finalContent})
}
func cleanThinkingTags(content string) string {
return strings.ReplaceAll(content, "<think", "")
}
func (s *Server) autoSummarize() {
messages := s.convStore.Get()
if len(messages) < 10 {
return
}
half := len(messages) / 2
var oldText string
for _, m := range messages[:half] {
oldText += m.Role + ": " + m.Content + "\n\n"
}
summary := s.convStore.GetSummary()
if summary != "" {
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
}
orb, err := orchestrator.New(s.config)
if err != nil {
return
}
orb.SetSystemPrompt(summarizePrompt)
result, err := orb.Send(oldText)
if err != nil {
return
}
s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
})
}
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}

View File

@@ -0,0 +1,17 @@
package api
import (
"encoding/json"
"net/http"
)
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
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})
}

View File

@@ -0,0 +1,486 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
)
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Language string `json:"language"`
KeyboardLayout string `json:"keyboard_layout"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Language != "" {
s.config.Profile.Preferences.Language = body.Language
}
if body.KeyboardLayout != "" {
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Name string `json:"name"`
Pseudo string `json:"pseudo"`
Email string `json:"email"`
Editor string `json:"editor"`
Shell string `json:"shell"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name != "" {
s.config.Profile.Name = body.Name
}
if body.Pseudo != "" {
s.config.Profile.Pseudo = body.Pseudo
}
if body.Email != "" {
s.config.Profile.Email = body.Email
}
if body.Editor != "" {
s.config.Profile.Preferences.Editor = body.Editor
}
if body.Shell != "" {
s.config.Profile.Preferences.Shell = body.Shell
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
Active *bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Name == body.Name {
if body.APIKey != "" {
s.config.AI.Providers[i].APIKey = body.APIKey
}
if body.Model != "" {
s.config.AI.Providers[i].Model = body.Model
}
if body.BaseURL != "" {
s.config.AI.Providers[i].BaseURL = body.BaseURL
}
if body.Active != nil {
if *body.Active {
for j := range s.config.AI.Providers {
s.config.AI.Providers[j].Active = false
}
}
s.config.AI.Providers[i].Active = *body.Active
}
found = true
break
}
}
if !found {
writeError(w, "provider not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.APIKey == "" {
writeError(w, "api_key required", http.StatusBadRequest)
return
}
baseURL := body.BaseURL
if baseURL == "" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
baseURL = p.BaseURL
break
}
}
}
if baseURL == "" {
switch body.Name {
case "minimax":
baseURL = "https://api.minimax.io/v1"
case "openai":
baseURL = "https://api.openai.com/v1"
case "anthropic":
baseURL = "https://api.anthropic.com/v1"
default:
baseURL = "https://api.minimax.io/v1"
}
}
model := body.Model
if model == "" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
model = p.Model
break
}
}
}
if model == "" {
model = "MiniMax-M2.7"
}
reqBody, _ := json.Marshal(map[string]interface{}{
"model": model,
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
"max_tokens": 5,
"stream": false,
})
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+body.APIKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
writeError(w, "invalid_api_key", http.StatusUnauthorized)
return
}
if resp.StatusCode != http.StatusOK {
writeError(w, "api_error: "+string(respBody), http.StatusBadGateway)
return
}
writeJSON(w, map[string]interface{}{"status": "valid"})
}
func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
FontSize int `json:"font_size"`
FontFamily string `json:"font_family"`
Theme string `json:"theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.FontSize > 0 {
s.config.Terminal.FontSize = body.FontSize
}
if body.FontFamily != "" {
s.config.Terminal.FontFamily = body.FontFamily
}
if body.Theme != "" {
s.config.Terminal.Theme = body.Theme
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"theme": config.GetTerminalTheme(s.config.Terminal.Theme),
})
}
func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request) {
themes := make([]map[string]string, 0, len(config.DEFAULT_TERMINAL_THEMES))
for id, theme := range config.DEFAULT_TERMINAL_THEMES {
themes = append(themes, map[string]string{
"id": id,
"name": theme.Name,
})
}
writeJSON(w, map[string]interface{}{"themes": themes})
}
func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
dir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
path := filepath.Join(dir, "config.yaml")
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.config = config.Default()
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Theme string `json:"theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Theme == "" {
body.Theme = s.config.Terminal.PromptTheme
}
cfgDir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
starshipDir := filepath.Join(cfgDir, "starship")
if err := os.MkdirAll(starshipDir, 0755); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(body.Theme)
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
home, _ := os.UserHomeDir()
shellRCs := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".zshrc"),
}
for _, rc := range shellRCs {
if _, err := os.Stat(rc); err != nil {
continue
}
content, _ := os.ReadFile(rc)
if strings.Contains(string(content), "STARSHIP_CONFIG") {
continue
}
exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile)
f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
continue
}
f.WriteString(exportLine)
f.Close()
}
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
}
func getStarshipThemeConfig(theme string) string {
switch theme {
case "charm":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[➜](bold #00E676)"
error_symbol = "[✗](bold #FF0033)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #00BCD4"
[username]
show_on_left = false
style_user = "bold #FF0033"
style_root = "bold #FF0033"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #FFD740"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #FF1A5E"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #75715E"
`
case "zerotwo":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[](bold #3B82F6)"
error_symbol = "[](bold #EF4444)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #8B5CF6"
[username]
show_on_left = false
style_user = "bold #EC4899"
style_root = "bold #EF4444"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #F472B6"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #EF4444"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #6B7280"
`
default:
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$line_break$character"""
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold cyan"
[username]
show_on_left = false
style_user = "bold red"
style_root = "bold red"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold yellow"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold bright-black"
`
}
}

View File

@@ -0,0 +1,124 @@
package api
import (
"net/http"
"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/version"
)
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) handleScan(w http.ResponseWriter, r *http.Request) {
s.scanResult = scanner.ScanSystem()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
editors := scanner.ScanEditors()
writeJSON(w, map[string]interface{}{"editors": editors})
}

View File

@@ -0,0 +1,44 @@
package api
import (
"encoding/json"
"net/http"
"os/exec"
)
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 := detectShell()
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)
}

View File

@@ -0,0 +1,94 @@
package api
import (
"encoding/json"
"net/http"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
)
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) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Tool string `json:"tool"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
if body.Tool != "" {
for _, u := range statuses {
if u.Tool == body.Tool && u.NeedsUpdate {
updater.RunAutoUpdate([]updater.UpdateStatus{u})
}
}
writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
return
}
needsUpdate := make([]updater.UpdateStatus, 0)
for _, u := range statuses {
if u.NeedsUpdate {
needsUpdate = append(needsUpdate, u)
}
}
if len(needsUpdate) > 0 {
updater.RunAutoUpdate(needsUpdate)
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"updated": len(needsUpdate),
})
}

View File

@@ -0,0 +1,80 @@
package api
import (
"encoding/json"
"net/http"
"os/exec"
"strings"
)
type toolCallRequest struct {
Tool string `json:"tool"`
Task string `json:"task"`
}
type toolResult struct {
Success bool `json:"success"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req toolCallRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if req.Tool != "crush" {
writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest)
return
}
if req.Task == "" {
writeError(w, "task is required", http.StatusBadRequest)
return
}
result := executeTool(req.Tool, req.Task)
writeJSON(w, result)
}
func executeTool(tool, task string) toolResult {
var cmd *exec.Cmd
switch tool {
case "crush":
cmd = exec.Command("crush", "run", task)
default:
return toolResult{Success: false, Error: "unknown tool: " + tool}
}
output, err := cmd.CombinedOutput()
if err != nil {
return toolResult{
Success: false,
Output: string(output),
Error: err.Error(),
}
}
return toolResult{
Success: true,
Output: string(output),
}
}
func buildToolMessage(tool, task string, history []string) string {
var b strings.Builder
b.WriteString("TASK: " + task + "\n\n")
b.WriteString("CONVERSATION HISTORY:\n")
for _, msg := range history {
b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n")
}
return b.String()
}

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

@@ -0,0 +1,83 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
)
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
}
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
}
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
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/editors", s.handleEditors)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
s.mux.HandleFunc("/api/terminal/themes", s.handleGetTerminalThemes)
s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
s.mux.HandleFunc("/api/config/reset", s.handleResetConfig)
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
s.mux.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
s.mux.ServeHTTP(w, r)
}

350
internal/api/terminal.go Normal file
View File

@@ -0,0 +1,350 @@
package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/creack/pty/v2"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/config"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
switch {
case strings.HasPrefix(origin, "http://127.0.0.1"),
strings.HasPrefix(origin, "http://localhost"),
strings.HasPrefix(origin, "http://[::1]"),
strings.HasPrefix(origin, "https://127.0.0.1"),
strings.HasPrefix(origin, "https://localhost"),
strings.HasPrefix(origin, "https://[::1]"):
return true
default:
return false
}
},
}
type wsMessage struct {
Type string `json:"type"`
Data string `json:"data"`
Rows uint16 `json:"rows,omitempty"`
Cols uint16 `json:"cols,omitempty"`
}
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade: %v", err)
return
}
defer conn.Close()
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
log.Printf("terminal: read init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
log.Printf("terminal: init message received: %s", string(raw))
if err := json.Unmarshal(raw, &initMsg); err != nil {
log.Printf("terminal: unmarshal init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
var cmd *exec.Cmd
if initMsg.Type == "ssh" && initMsg.Data != "" {
var sshConf struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
}
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
return
}
if sshConf.Port == 0 {
sshConf.Port = 22
}
sshArgs := []string{
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
}
if sshConf.KeyPath != "" {
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
}
if sshConf.Port != 22 {
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
}
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
cmd = exec.Command("ssh", sshArgs...)
} else {
shell := strings.TrimSpace(initMsg.Data)
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
if shell == "" {
shell = detectShell()
log.Printf("terminal: auto-detected shell=%q", shell)
}
if shell == "" {
log.Printf("terminal: no shell detected, falling back to /bin/sh")
shell = "/bin/sh"
}
if path, err := exec.LookPath(shell); err == nil {
shell = path
log.Printf("terminal: resolved shell path=%q", shell)
}
if _, err := os.Stat(shell); err != nil {
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
}
}
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
ptmx, err := pty.Start(cmd)
if err != nil {
log.Printf("terminal: pty start failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
log.Printf("terminal: pty started successfully")
defer func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
}()
var once sync.Once
cleanup := func() {
once.Do(func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
})
}
go func() {
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if err != nil {
cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
}
if err := conn.WriteJSON(wsMessage{
Type: "output",
Data: string(buf[:n]),
}); err != nil {
cleanup()
return
}
}
}()
conn.SetReadLimit(1 << 20)
conn.SetReadDeadline(time.Time{})
for {
_, raw, err := conn.ReadMessage()
if err != nil {
cleanup()
return
}
var msg wsMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "input":
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
cleanup()
return
}
case "resize":
if msg.Rows > 0 && msg.Cols > 0 {
pty.Setsize(ptmx, &pty.Winsize{
Rows: msg.Rows,
Cols: msg.Cols,
})
}
}
}
}
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
writeJSON(w, map[string]interface{}{
"ssh": s.config.Terminal.SSH,
"system": detectSystemTerminals(),
})
return
}
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
KeyPath string `json:"key_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" || body.Host == "" {
writeError(w, "name and host required", http.StatusBadRequest)
return
}
if body.Port == 0 {
body.Port = 22
}
conn := config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
}
if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{}
}
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i, c := range s.config.Terminal.SSH {
if c.Name == name {
s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
found = true
break
}
}
if !found {
writeError(w, "not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"
}
func detectSystemTerminals() []map[string]string {
var terminals []map[string]string
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Default Shell",
"shell": detectShell(),
})
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL",
"shell": "wsl",
})
}
if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell",
"shell": "powershell",
})
}
if _, err := exec.LookPath("pwsh"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell Core",
"shell": "pwsh",
})
}
if _, err := exec.LookPath("cmd"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Command Prompt",
"shell": "cmd",
})
}
}
return terminals
}

View File

@@ -2,10 +2,12 @@ package config
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/muyue/muyue/internal/secret"
"github.com/muyue/muyue/internal/version"
"gopkg.in/yaml.v3"
)
@@ -15,12 +17,14 @@ type Profile struct {
Email string `yaml:"email"`
Languages []string `yaml:"languages"`
Preferences struct {
Editor string `yaml:"editor"`
Shell string `yaml:"shell"`
Theme string `yaml:"theme"`
DefaultAI string `yaml:"default_ai"`
AutoUpdate bool `yaml:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"`
Editor string `yaml:"editor"`
Shell string `yaml:"shell"`
Theme string `yaml:"theme"`
DefaultAI string `yaml:"default_ai"`
AutoUpdate bool `yaml:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"`
Language string `yaml:"language"`
KeyboardLayout string `yaml:"keyboard_layout"`
} `yaml:"preferences"`
}
@@ -39,6 +43,15 @@ type ToolConfig struct {
AutoUpdate bool `yaml:"auto_update"`
}
type SSHConnection struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty"`
}
type MuyueConfig struct {
Version string `yaml:"version"`
Profile Profile `yaml:"profile"`
@@ -52,11 +65,76 @@ type MuyueConfig struct {
Global bool `yaml:"global"`
} `yaml:"bmad"`
Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh"`
FontSize int `yaml:"font_size"`
FontFamily string `yaml:"font_family"`
Theme string `yaml:"theme"`
} `yaml:"terminal"`
}
type TerminalTheme struct {
Name string `yaml:"name"`
Background string `yaml:"background"`
Foreground string `yaml:"foreground"`
Cursor string `yaml:"cursor"`
Black string `yaml:"black"`
Red string `yaml:"red"`
Green string `yaml:"green"`
Yellow string `yaml:"yellow"`
Blue string `yaml:"blue"`
Magenta string `yaml:"magenta"`
Cyan string `yaml:"cyan"`
White string `yaml:"white"`
}
var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
"default": {
Name: "Default", Background: "#0A0A0C", Foreground: "#EAE0E2",
Cursor: "#FF0033", Black: "#0A0A0C", Red: "#FF0033",
Green: "#00E676", Yellow: "#FFD740", Blue: "#448AFF",
Magenta: "#FF1A5E", Cyan: "#00BCD4", White: "#EAE0E2",
},
"monokai": {
Name: "Monokai", Background: "#272822", Foreground: "#F8F8F2",
Cursor: "#F8F8F0", Black: "#272822", Red: "#F92672",
Green: "#A6E22E", Yellow: "#E6DB74", Blue: "#66D9EF",
Magenta: "#AE81FF", Cyan: "#A1EFE4", White: "#F8F8F2",
},
"gruvbox": {
Name: "Gruvbox", Background: "#282828", Foreground: "#EBDBB2",
Cursor: "#FB4934", Black: "#282828", Red: "#CC241D",
Green: "#98971A", Yellow: "#D79921", Blue: "#458588",
Magenta: "#B16286", Cyan: "#689D6A", White: "#EBDBB2",
},
"nord": {
Name: "Nord", Background: "#2E3440", Foreground: "#D8DEE9",
Cursor: "#D8DEE9", Black: "#2E3440", Red: "#BF616A",
Green: "#A3BE8C", Yellow: "#EBCB8B", Blue: "#81A1C1",
Magenta: "#B48EAD", Cyan: "#88C0D0", White: "#D8DEE9",
},
"solarized-dark": {
Name: "Solarized Dark", Background: "#002B36", Foreground: "#839496",
Cursor: "#D33682", Black: "#002B36", Red: "#DC322F",
Green: "#859900", Yellow: "#B58900", Blue: "#268BD2",
Magenta: "#D33682", Cyan: "#2AA198", White: "#FDF6E3",
},
"dracula": {
Name: "Dracula", Background: "#282A36", Foreground: "#F8F8F2",
Cursor: "#F8F8F2", Black: "#282A36", Red: "#FF5555",
Green: "#50FA7B", Yellow: "#F1FA8C", Blue: "#BD93F9",
Magenta: "#FF79C6", Cyan: "#8BE9FD", White: "#F8F8F2",
},
}
func GetTerminalTheme(name string) TerminalTheme {
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
return theme
}
return DEFAULT_TERMINAL_THEMES["default"]
}
func ConfigDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
@@ -67,7 +145,9 @@ func ConfigDir() (string, error) {
legacyDir := filepath.Join(homeDir(), ".muyue")
if _, err := os.Stat(legacyDir); err == nil {
if _, err := os.Stat(dir); err != nil {
os.Rename(legacyDir, dir)
if err := os.Rename(legacyDir, dir); err != nil {
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
}
}
}
@@ -167,7 +247,7 @@ func Save(cfg *MuyueConfig) error {
func Default() *MuyueConfig {
cfg := &MuyueConfig{
Version: "0.1.0",
Version: version.Version,
Profile: Profile{
Name: "",
Pseudo: "muyue",
@@ -179,6 +259,8 @@ func Default() *MuyueConfig {
cfg.Profile.Preferences.AutoUpdate = true
cfg.Profile.Preferences.CheckOnStart = true
cfg.Profile.Preferences.Theme = "charm"
cfg.Profile.Preferences.Language = "fr"
cfg.Profile.Preferences.KeyboardLayout = "azerty"
cfg.AI.Providers = []AIProvider{
{

View File

@@ -4,12 +4,14 @@ import (
"os"
"path/filepath"
"testing"
"github.com/muyue/muyue/internal/version"
)
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.Version != "0.1.0" {
t.Errorf("Expected version 0.1.0, got %s", cfg.Version)
if cfg.Version != version.Version {
t.Errorf("Expected version %s, got %s", version.Version, cfg.Version)
}
if cfg.Profile.Pseudo != "muyue" {
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)

View File

@@ -1,173 +0,0 @@
package daemon
import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
)
type Daemon struct {
config *config.MuyueConfig
interval time.Duration
stopCh chan struct{}
mu sync.RWMutex
running bool
lastCheck time.Time
lastStatus []updater.UpdateStatus
logs []string
onUpdate func([]updater.UpdateStatus)
}
func NewDaemon(cfg *config.MuyueConfig, interval time.Duration) *Daemon {
if interval == 0 {
interval = 1 * time.Hour
}
return &Daemon{
config: cfg,
interval: interval,
stopCh: make(chan struct{}, 1),
logs: []string{},
}
}
func (d *Daemon) OnUpdate(fn func([]updater.UpdateStatus)) {
d.onUpdate = fn
}
func (d *Daemon) Start() error {
d.mu.Lock()
if d.running {
d.mu.Unlock()
return fmt.Errorf("daemon already running")
}
d.running = true
d.mu.Unlock()
d.log("daemon started (interval: %s)", d.interval)
go d.run()
return nil
}
func (d *Daemon) Stop() {
d.mu.Lock()
defer d.mu.Unlock()
if !d.running {
return
}
d.running = false
d.stopCh <- struct{}{}
d.log("daemon stopped")
}
func (d *Daemon) IsRunning() bool {
d.mu.RLock()
defer d.mu.RUnlock()
return d.running
}
func (d *Daemon) LastCheck() time.Time {
d.mu.RLock()
defer d.mu.RUnlock()
return d.lastCheck
}
func (d *Daemon) LastStatus() []updater.UpdateStatus {
d.mu.RLock()
defer d.mu.RUnlock()
return d.lastStatus
}
func (d *Daemon) Logs() []string {
d.mu.RLock()
defer d.mu.RUnlock()
return d.logs
}
func (d *Daemon) TriggerCheck() []updater.UpdateStatus {
return d.checkUpdates()
}
func (d *Daemon) run() {
d.checkUpdates()
ticker := time.NewTicker(d.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
d.checkUpdates()
case <-d.stopCh:
return
}
}
}
func (d *Daemon) checkUpdates() []updater.UpdateStatus {
d.log("checking for updates...")
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
needsUpdate := false
for _, s := range statuses {
if s.NeedsUpdate {
needsUpdate = true
d.log("update available: %s %s -> %s", s.Tool, s.Current, s.Latest)
}
}
if !needsUpdate {
d.log("all tools up to date")
}
d.mu.Lock()
d.lastCheck = time.Now()
d.lastStatus = statuses
d.mu.Unlock()
if d.config.Profile.Preferences.AutoUpdate && needsUpdate {
d.log("auto-updating...")
results := updater.RunAutoUpdate(statuses)
for _, r := range results {
if r.Message != "" {
d.log(" %s: %s", r.Tool, r.Message)
}
}
}
if d.onUpdate != nil {
d.onUpdate(statuses)
}
return statuses
}
func (d *Daemon) log(format string, args ...interface{}) {
msg := fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), fmt.Sprintf(format, args...))
d.mu.Lock()
d.logs = append(d.logs, msg)
if len(d.logs) > 500 {
d.logs = d.logs[250:]
}
d.mu.Unlock()
}
func RunStandalone(cfg *config.MuyueConfig) {
d := NewDaemon(cfg, 1*time.Hour)
d.Start()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
d.Stop()
}

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

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

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@@ -123,7 +124,7 @@ func (i *Installer) installBMAD() InstallResult {
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
}
bmadDir := configDir + "/bmad"
bmadDir := filepath.Join(configDir, "bmad")
os.MkdirAll(bmadDir, 0755)
cmd := exec.Command("npx", "bmad-method@latest", "install",
@@ -175,7 +176,7 @@ func (i *Installer) installGo() InstallResult {
}
home, _ := os.UserHomeDir()
goDir := home + "/.local/go"
goDir := filepath.Join(home, ".local", "go")
cmd := exec.Command("bash", "-c", fmt.Sprintf(
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
@@ -290,56 +291,16 @@ func (i *Installer) installGit() InstallResult {
return InstallResult{Tool: "git", Success: true, Message: "installed and configured"}
}
func (i *Installer) SetupPrompt() error {
starshipPath, err := exec.LookPath("starship")
if err != nil {
return fmt.Errorf("starship not found")
}
rcFile := i.getRCFile()
line := fmt.Sprintf("eval \"$(" + starshipPath + " init %s)\"", i.system.Shell)
appendLine(rcFile, line)
configDir, _ := config.ConfigDir()
starshipConfig := `format = """
$directory\
$git_branch\
$git_status\
$git_metrics\
$nodejs\
$python\
$golang\
$rust\
$cmd_duration\
$line_break\
$character"""
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
[git_branch]
format = "[$symbol$branch]($style) "
[git_status]
format = '([$all_status$ahead_behind]($style) )'
`
configPath := configDir + "/starship.toml"
os.MkdirAll(configDir, 0755)
os.WriteFile(configPath, []byte(starshipConfig), 0644)
return nil
}
func (i *Installer) getRCFile() string {
func (i *Installer) getRCFile() string {
home, _ := os.UserHomeDir()
switch i.system.Shell {
case "zsh":
return home + "/.zshrc"
return filepath.Join(home, ".zshrc")
case "fish":
return home + "/.config/fish/config.fish"
return filepath.Join(home, ".config", "fish", "config.fish")
default:
return home + "/.bashrc"
return filepath.Join(home, ".bashrc")
}
}
@@ -380,7 +341,7 @@ func (i *Installer) installUv() InstallResult {
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
}
rcFile := i.getRCFile()
appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH")
appendLine(rcFile, "export PATH="+filepath.Join(home, ".local", "bin")+":$PATH")
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
case platform.Windows:
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")

View File

@@ -1,13 +1,9 @@
package lsp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/muyue/muyue/internal/config"
)
type LSPServer struct {
@@ -15,14 +11,9 @@ type LSPServer struct {
Language string `json:"language"`
Command string `json:"command"`
InstallCmd string `json:"install_cmd"`
ConfigFile string `json:"config_file"`
Installed bool `json:"installed"`
}
type LSPConfig struct {
Servers []LSPServer `json:"servers"`
}
var knownServers = []LSPServer{
{Name: "gopls", Language: "go", Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest"},
{Name: "pyright", Language: "python", Command: "pyright", InstallCmd: "npm install -g pyright"},
@@ -110,86 +101,3 @@ func InstallForLanguages(languages []string) []LSPServer {
return results
}
func GenerateCrushConfig(cfg *config.MuyueConfig) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
configDir, err := config.ConfigDir()
if err != nil {
return err
}
type lspEntry struct {
Command []string `json:"command"`
}
lspConfig := map[string]lspEntry{}
for _, lang := range cfg.Profile.Languages {
switch lang {
case "go":
lspConfig["go"] = lspEntry{Command: []string{"gopls"}}
case "python":
lspConfig["python"] = lspEntry{Command: []string{"pyright-langserver", "--stdio"}}
case "typescript", "javascript":
lspConfig["typescript"] = lspEntry{Command: []string{"typescript-language-server", "--stdio"}}
case "rust":
lspConfig["rust"] = lspEntry{Command: []string{"rust-analyzer"}}
case "c", "cpp":
lspConfig["c"] = lspEntry{Command: []string{"clangd"}}
case "lua":
lspConfig["lua"] = lspEntry{Command: []string{"lua-language-server"}}
}
}
if len(lspConfig) == 0 {
return nil
}
data, err := json.MarshalIndent(lspConfig, "", " ")
if err != nil {
return err
}
lspPath := filepath.Join(configDir, "crush.json")
existing, err := os.ReadFile(lspPath)
if err == nil {
var existingConfig map[string]interface{}
if unmarshalErr := json.Unmarshal(existing, &existingConfig); unmarshalErr == nil {
var newConfig map[string]interface{}
if unmarshalErr2 := json.Unmarshal(data, &newConfig); unmarshalErr2 == nil {
for k, v := range newConfig {
existingConfig[k] = v
}
data, _ = json.MarshalIndent(existingConfig, "", " ")
}
}
}
return os.WriteFile(lspPath, data, 0644)
}
func EnsureCrushConfig(cfg *config.MuyueConfig) error {
configDir, _ := config.ConfigDir()
crusherPath := filepath.Join(configDir, "crush.json")
if _, err := os.Stat(crusherPath); err != nil {
home, _ := os.UserHomeDir()
homeCrush := filepath.Join(home, ".config", "crush", "crush.json")
if _, err := os.Stat(homeCrush); err == nil {
return nil
}
defaultConfig := map[string]interface{}{
"version": "1",
}
data, _ := json.MarshalIndent(defaultConfig, "", " ")
os.MkdirAll(filepath.Dir(crusherPath), 0755)
return os.WriteFile(crusherPath, data, 0644)
}
return nil
}

View File

@@ -53,7 +53,7 @@ func ScanServers() []MCPServer {
func getCoreEntries(homeDir string) []mcpEntry {
return []mcpEntry{
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil},
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil},
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
}
@@ -86,7 +86,9 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
existing := map[string]interface{}{}
data, err := os.ReadFile(configPath)
if err == nil {
json.Unmarshal(data, &existing)
if err := json.Unmarshal(data, &existing); err != nil {
return fmt.Errorf("parse existing config: %w", err)
}
}
mcpMap := map[string]interface{}{}

View File

@@ -1,6 +1,7 @@
package orchestrator
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
@@ -12,7 +13,6 @@ import (
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/workflow"
)
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
@@ -20,21 +20,42 @@ var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
const maxHistorySize = 100
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type ToolCallMsg struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFuncMsg `json:"function"`
}
type ToolCallFuncMsg struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Tools json.RawMessage `json:"tools,omitempty"`
}
type ChatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
TotalTokens int `json:"total_tokens"`
@@ -42,12 +63,13 @@ type ChatResponse struct {
}
type Orchestrator struct {
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
Workflow *workflow.Workflow
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
systemPrompt string
tools json.RawMessage
}
var sharedHTTPClient = &http.Client{
@@ -72,14 +94,45 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
}
return &Orchestrator{
config: cfg,
config: cfg,
provider: provider,
client: sharedHTTPClient,
history: []Message{},
Workflow: workflow.New(),
client: sharedHTTPClient,
history: []Message{},
}, nil
}
func (o *Orchestrator) SetSystemPrompt(prompt string) {
o.systemPrompt = prompt
}
func (o *Orchestrator) SetTools(tools json.RawMessage) {
o.tools = tools
}
func (o *Orchestrator) ProviderName() string {
if o.provider == nil {
return ""
}
return o.provider.Name
}
func (o *Orchestrator) AppendHistory(msg Message) {
o.histMu.Lock()
defer o.histMu.Unlock()
o.history = append(o.history, msg)
if len(o.history) > maxHistorySize {
o.history = o.history[len(o.history)-maxHistorySize:]
}
}
func (o *Orchestrator) GetHistory() []Message {
o.histMu.Lock()
defer o.histMu.Unlock()
out := make([]Message, len(o.history))
copy(out, o.history)
return out
}
func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
@@ -91,10 +144,17 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
o.history = o.history[len(o.history)-maxHistorySize:]
}
messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
}
messages = append(messages, o.history...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: o.history,
Messages: messages,
Stream: false,
Tools: o.tools,
}
o.histMu.Unlock()
@@ -153,19 +213,30 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
return content, nil
}
func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
o.Workflow.Start(goal)
prompt := fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal)
o.history = []Message{
{Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)},
{Role: "user", Content: prompt},
func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "user",
Content: userMessage,
})
if len(o.history) > maxHistorySize {
o.history = o.history[len(o.history)-maxHistorySize:]
}
messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
}
messages = append(messages, o.history...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: o.history,
Stream: false,
Messages: messages,
Stream: true,
Tools: o.tools,
}
o.histMu.Unlock()
body, err := json.Marshal(reqBody)
if err != nil {
@@ -193,114 +264,113 @@ func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
var fullContent strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chatResp ChatResponse
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
continue
}
if len(chatResp.Choices) > 0 {
chunk := chatResp.Choices[0].Delta.Content
if chunk != "" {
fullContent.WriteString(chunk)
onChunk(chunk)
}
}
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no response from AI")
if err := scanner.Err(); err != nil {
return fullContent.String(), fmt.Errorf("read stream: %w", err)
}
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
content := cleanAIResponse(fullContent.String())
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
Content: content,
})
o.histMu.Unlock()
return content, nil
}
func (o *Orchestrator) AnswerQuestion(answer string) (string, error) {
o.Workflow.AddAnswer(answer)
return o.Send(answer)
}
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
}
fullMessages = append(fullMessages, messages...)
func (o *Orchestrator) GeneratePlan() (string, error) {
o.Workflow.Phase = workflow.PhasePlanning
o.history = append(o.history, Message{
Role: "system",
Content: workflow.BuildSystemPrompt(workflow.PhasePlanning, o.Workflow.Plan),
})
prompt := "All questions have been answered. Now create a detailed step-by-step execution plan as a JSON array. Each step should have: id, title, description, agent (crush/claude/muyue)."
if len(o.Workflow.Plan.PreviewFiles) > 0 {
prompt += "\nInclude visual previews where helpful using the PREVIEW_JSON format."
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: fullMessages,
Stream: false,
Tools: o.tools,
}
resp, err := o.Send(prompt)
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
return nil, fmt.Errorf("marshal request: %w", err)
}
steps, parseErr := workflow.ParsePlanResponse(resp)
if parseErr == nil {
o.Workflow.SetPlan("")
o.Workflow.Plan.Steps = steps
o.Workflow.Phase = workflow.PhaseReviewing
baseURL := o.provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(o.provider.Name)
}
previewFiles := workflow.ParsePreviewFiles(resp)
if len(previewFiles) > 0 {
o.Workflow.SetPreviewFiles(previewFiles)
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
return resp, nil
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
func (o *Orchestrator) ReviewPlan(approved bool, feedback string) (string, error) {
if approved {
o.Workflow.Approve()
return o.executeNextStep()
resp, err := o.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
o.Workflow.Reject(feedback)
return o.Send(fmt.Sprintf("The plan was rejected. Reason: %s. Please revise the plan.", feedback))
}
defer resp.Body.Close()
func (o *Orchestrator) executeNextStep() (string, error) {
step := o.Workflow.CurrentStep()
if step == nil {
return "All steps completed!", nil
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
o.history = append(o.history, Message{
Role: "system",
Content: workflow.BuildSystemPrompt(workflow.PhaseExecuting, o.Workflow.Plan),
})
return o.Send(fmt.Sprintf("Execute step %s: %s\n%s", step.ID, step.Title, step.Description))
}
func (o *Orchestrator) ContinueExecution(output string) (string, error) {
o.Workflow.AdvanceStep(output)
if o.Workflow.Phase == workflow.PhaseDone {
return "Workflow completed! All steps have been executed.", nil
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
return o.executeNextStep()
}
func (o *Orchestrator) History() []Message {
o.histMu.Lock()
defer o.histMu.Unlock()
cp := make([]Message, len(o.history))
copy(cp, o.history)
return cp
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
func (o *Orchestrator) ClearHistory() {
o.histMu.Lock()
o.history = []Message{}
o.histMu.Unlock()
o.Workflow.Reset()
if len(chatResp.Choices) == 0 {
return nil, fmt.Errorf("no response from AI")
}
return &chatResp, nil
}
func cleanAIResponse(content string) string {

View File

@@ -1,19 +1,15 @@
package orchestrator
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/muyue/muyue/internal/config"
)
func testConfig() *config.MuyueConfig {
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-api-key-12345"
return cfg
}
func TestCleanAIResponse(t *testing.T) {
tests := []struct {
name string
@@ -21,7 +17,7 @@ func TestCleanAIResponse(t *testing.T) {
expected string
}{
{
"removes standard think tags",
"malformed think tags pass through",
"<think internal reasoning</think Hello world",
"<think internal reasoning</think Hello world",
},
@@ -31,7 +27,7 @@ func TestCleanAIResponse(t *testing.T) {
"response",
},
{
"removes think with attrs",
"think with attrs, no closing bracket",
"<think type=re>reasoning</think result",
"<think type=re>reasoning</think result",
},
@@ -56,12 +52,12 @@ func TestCleanAIResponse(t *testing.T) {
"",
},
{
"removes valid think block",
"malformed think block no closing bracket",
"<think some reasoning here</think rest",
"<think some reasoning here</think rest",
},
{
"removes simple think",
"malformed simple think no closing bracket",
"before<think reasoning</think after",
"before<think reasoning</think after",
},
@@ -154,57 +150,127 @@ func TestNewNoAPIKey(t *testing.T) {
}
}
func TestHistoryManagement(t *testing.T) {
cfg := testConfig()
orch, err := New(cfg)
if err != nil {
t.Fatalf("New failed: %v", err)
}
h := orch.History()
if len(h) != 0 {
t.Errorf("Expected empty history, got %d", len(h))
}
orch.ClearHistory()
h = orch.History()
if len(h) != 0 {
t.Errorf("Expected 0 after clear, got %d", len(h))
}
}
func TestHistoryCopy(t *testing.T) {
cfg := testConfig()
orch, _ := New(cfg)
orch.history = []Message{
{Role: "user", Content: "hello"},
}
h := orch.History()
h[0].Content = "modified"
orig := orch.History()
if orig[0].Content == "modified" {
t.Error("History should return a copy")
}
}
func TestMaxHistorySize(t *testing.T) {
cfg := testConfig()
orch, _ := New(cfg)
for i := 0; i < maxHistorySize+10; i++ {
orch.histMu.Lock()
orch.history = append(orch.history, Message{Role: "user", Content: "msg"})
if len(orch.history) > maxHistorySize {
orch.history = orch.history[len(orch.history)-maxHistorySize:]
func TestSendStreamChunks(t *testing.T) {
sseBody := `data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" world"}}]}
data: {"choices":[{"delta":{"content":"!"}}]}
data: [DONE]
`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
orch.histMu.Unlock()
var reqBody ChatRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !reqBody.Stream {
http.Error(w, "stream must be true", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte(sseBody))
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
h := orch.History()
if len(h) > maxHistorySize {
t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h))
var chunks []string
result, err := orb.SendStream("hi", func(chunk string) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("SendStream: %v", err)
}
if result != "Hello world!" {
t.Errorf("SendStream result = %q, want %q", result, "Hello world!")
}
if len(chunks) != 3 {
t.Fatalf("expected 3 chunks, got %d: %v", len(chunks), chunks)
}
if strings.Join(chunks, "") != "Hello world!" {
t.Errorf("chunks joined = %q, want %q", strings.Join(chunks, ""), "Hello world!")
}
}
func TestSendStreamHistory(t *testing.T) {
callCount := 0
sseBody := `data: {"choices":[{"delta":{"content":"reply"}}]}
data: [DONE]
`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
var reqBody ChatRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if callCount == 1 {
if len(reqBody.Messages) != 2 {
t.Errorf("first call: expected 2 messages (system + 1 user), got %d", len(reqBody.Messages))
}
} else {
if len(reqBody.Messages) != 4 {
t.Errorf("second call: expected 4 messages (system + 3 history), got %d", len(reqBody.Messages))
}
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte(sseBody))
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
orb.SetSystemPrompt("you are helpful")
_, _ = orb.SendStream("first", func(string) {})
_, _ = orb.SendStream("second", func(string) {})
orb.histMu.Lock()
if len(orb.history) != 4 {
t.Errorf("expected 4 history entries (2 user + 2 assistant), got %d", len(orb.history))
}
orb.histMu.Unlock()
}
func TestSendStreamAPIError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = orb.SendStream("hi", func(string) {})
if err == nil {
t.Error("expected error for non-200 response")
}
if !strings.Contains(err.Error(), "429") {
t.Errorf("error should mention status code, got: %v", err)
}
}

View File

@@ -1,6 +1,7 @@
package platform
import (
"strings"
"testing"
)
@@ -43,16 +44,9 @@ func TestString(t *testing.T) {
if s == "" {
t.Error("String should not be empty")
}
if !contains(s, "linux") {
if !strings.Contains(s, "linux") {
t.Error("Should contain OS")
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -1,79 +0,0 @@
package preview
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
type PreviewServer struct {
dir string
server *http.Server
}
func NewPreviewServer(dir string) *PreviewServer {
return &PreviewServer{dir: dir}
}
func (p *PreviewServer) Start(port int) error {
fs := http.FileServer(http.Dir(p.dir))
mux := http.NewServeMux()
mux.Handle("/", fs)
p.server = &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", port),
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
go func() {
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Preview server error: %s\n", err)
}
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
fmt.Printf("Preview server running at %s\n", url)
return openBrowser(url)
}
func (p *PreviewServer) Stop() error {
if p.server != nil {
return p.server.Close()
}
return nil
}
func CreatePreviewFile(dir, filename, content string) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644)
}
func openBrowser(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd = "xdg-open"
args = []string{url}
case "darwin":
cmd = "open"
args = []string{url}
case "windows":
cmd = "cmd"
args = []string{"/c", "start", url}
default:
return fmt.Errorf("unsupported platform")
}
return exec.Command(cmd, args...).Start()
}

View File

@@ -279,7 +279,7 @@ func AskAPIKey(providerName string) (string, error) {
field := huh.NewInput().
Title(fmt.Sprintf("Enter your %s API key:", providerName)).
Description("The key will be stored locally in ~/.muyue/config.yaml").
Description("The key will be stored locally in ~/.config/muyue/config.yaml").
EchoMode(huh.EchoModePassword).
Value(&apiKey)

View File

@@ -1,250 +0,0 @@
package proxy
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
)
type AgentType string
const (
AgentCrush AgentType = "crush"
AgentClaude AgentType = "claude"
)
type AgentStatus string
const (
StatusIdle AgentStatus = "idle"
StatusRunning AgentStatus = "running"
StatusStopped AgentStatus = "stopped"
StatusError AgentStatus = "error"
)
type LogEntry struct {
Timestamp time.Time
Agent AgentType
Level string
Message string
}
type Agent struct {
Type AgentType
Status AgentStatus
cmd *exec.Cmd
stdout io.Reader
stderr io.Reader
cancel context.CancelFunc
mu sync.Mutex
logs []LogEntry
}
type Manager struct {
agents map[AgentType]*Agent
mu sync.RWMutex
}
func NewManager() *Manager {
return &Manager{
agents: make(map[AgentType]*Agent),
}
}
func (m *Manager) Start(agentType AgentType, args ...string) error {
m.mu.Lock()
defer m.mu.Unlock()
if a, exists := m.agents[agentType]; exists && a.Status == StatusRunning {
return fmt.Errorf("%s already running", agentType)
}
ctx, cancel := context.WithCancel(context.Background())
var cmdName string
switch agentType {
case AgentCrush:
cmdName = "crush"
case AgentClaude:
cmdName = "claude"
default:
cancel()
return fmt.Errorf("unknown agent type: %s", agentType)
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Env = os.Environ()
stdout, pipeErr := cmd.StdoutPipe()
if pipeErr != nil {
cancel()
return fmt.Errorf("stdout pipe: %w", pipeErr)
}
stderr, pipeErr := cmd.StderrPipe()
if pipeErr != nil {
cancel()
return fmt.Errorf("stderr pipe: %w", pipeErr)
}
agent := &Agent{
Type: agentType,
Status: StatusRunning,
cmd: cmd,
stdout: stdout,
stderr: stderr,
cancel: cancel,
}
m.agents[agentType] = agent
go agent.captureOutput(stdout, "info")
go agent.captureOutput(stderr, "error")
if err := cmd.Start(); err != nil {
agent.Status = StatusError
cancel()
return fmt.Errorf("start %s: %w", agentType, err)
}
go func() {
err := cmd.Wait()
m.mu.Lock()
defer m.mu.Unlock()
if err != nil && ctx.Err() == nil {
agent.Status = StatusError
agent.log("error", fmt.Sprintf("exited with error: %s", err))
} else {
agent.Status = StatusStopped
agent.log("info", "stopped")
}
}()
return nil
}
func (m *Manager) Stop(agentType AgentType) error {
m.mu.Lock()
defer m.mu.Unlock()
agent, exists := m.agents[agentType]
if !exists {
return fmt.Errorf("%s not found", agentType)
}
if agent.Status != StatusRunning {
return fmt.Errorf("%s is not running", agentType)
}
agent.cancel()
agent.Status = StatusStopped
return nil
}
func (m *Manager) Status(agentType AgentType) (AgentStatus, []LogEntry) {
m.mu.RLock()
defer m.mu.RUnlock()
agent, exists := m.agents[agentType]
if !exists {
return StatusIdle, nil
}
agent.mu.Lock()
defer agent.mu.Unlock()
return agent.Status, agent.logs
}
func (m *Manager) AllStatus() map[AgentType]AgentStatus {
m.mu.RLock()
defer m.mu.RUnlock()
statuses := make(map[AgentType]AgentStatus)
for t, a := range m.agents {
statuses[t] = a.Status
}
return statuses
}
func (a *Agent) captureOutput(reader io.Reader, level string) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
a.mu.Lock()
a.logs = append(a.logs, LogEntry{
Timestamp: time.Now(),
Agent: a.Type,
Level: level,
Message: line,
})
if len(a.logs) > 1000 {
a.logs = a.logs[500:]
}
a.mu.Unlock()
}
}
func (a *Agent) log(level, msg string) {
a.mu.Lock()
defer a.mu.Unlock()
a.logs = append(a.logs, LogEntry{
Timestamp: time.Now(),
Agent: a.Type,
Level: level,
Message: msg,
})
}
func (m *Manager) IsAvailable(agentType AgentType) bool {
var cmdName string
switch agentType {
case AgentCrush:
cmdName = "crush"
case AgentClaude:
cmdName = "claude"
default:
return false
}
path, err := exec.LookPath(cmdName)
return err == nil && path != ""
}
func (m *Manager) GetLogs(agentType AgentType, lastN int) []LogEntry {
m.mu.RLock()
agent, exists := m.agents[agentType]
m.mu.RUnlock()
if !exists {
return nil
}
agent.mu.Lock()
defer agent.mu.Unlock()
logs := agent.logs
if lastN > 0 && len(logs) > lastN {
logs = logs[len(logs)-lastN:]
}
return logs
}
func FormatLogs(logs []LogEntry) string {
var b strings.Builder
for _, l := range logs {
b.WriteString(fmt.Sprintf("[%s] %s %s: %s\n",
l.Timestamp.Format("15:04:05"),
l.Agent,
l.Level,
l.Message,
))
}
return b.String()
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
@@ -13,13 +14,13 @@ import (
)
type ToolStatus struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Path string `yaml:"path"`
Latest string `yaml:"latest"`
NeedsUpdate bool `yaml:"needs_update"`
Category string `yaml:"category"`
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Path string `yaml:"path"`
Latest string `yaml:"latest"`
NeedsUpdate bool `yaml:"needs_update"`
Category string `yaml:"category"`
}
type RuntimeStatus struct {
@@ -29,15 +30,15 @@ type RuntimeStatus struct {
}
type ScanResult struct {
System platform.SystemInfo `yaml:"system"`
Tools []ToolStatus `yaml:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"`
ShellSetup bool `yaml:"shell_setup"`
GitConfigured bool `yaml:"git_configured"`
System platform.SystemInfo `yaml:"system"`
Tools []ToolStatus `yaml:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"`
ShellSetup bool `yaml:"shell_setup"`
GitConfigured bool `yaml:"git_configured"`
}
var (
cacheMu sync.RWMutex
cacheMu sync.RWMutex
cacheResult *ScanResult
cacheTime time.Time
cacheTTL = 5 * time.Minute
@@ -169,7 +170,7 @@ func checkShellSetup() bool {
home, _ := os.UserHomeDir()
rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"}
for _, f := range rcFiles {
data, err := os.ReadFile(home + "/" + f)
data, err := os.ReadFile(filepath.Join(home, f))
if err != nil {
continue
}
@@ -192,6 +193,43 @@ func checkGitConfig() bool {
return true
}
var editorsList = []struct {
name string
cmd []string
version []string
}{
{"vim", []string{"vim"}, []string{"--version"}},
{"nvim", []string{"nvim"}, []string{"--version"}},
{"code", []string{"code"}, []string{"--version"}},
{"emacs", []string{"emacs"}, []string{"--version"}},
{"nano", []string{"nano"}, []string{"--version"}},
{"helix", []string{"hx"}, []string{"--version"}},
{"subl", []string{"subl"}, []string{"--version"}},
{"zed", []string{"zed"}, []string{"--version"}},
}
func ScanEditors() []ToolStatus {
var results []ToolStatus
for _, e := range editorsList {
status := ToolStatus{Name: e.name}
path, err := exec.LookPath(e.name)
if err != nil {
continue
}
status.Installed = true
status.Path = path
if len(e.version) > 0 {
cmd := exec.Command(e.cmd[0], e.version...)
out, err := cmd.Output()
if err == nil {
status.Version = strings.TrimSpace(string(out))
}
}
results = append(results, status)
}
return results
}
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func (s *ScanResult) Summary() string {

View File

@@ -24,14 +24,6 @@ type Skill struct {
FilePath string `yaml:"-" json:"-"`
}
type Target string
const (
TargetCrush Target = "crush"
TargetClaude Target = "claude"
TargetBoth Target = "both"
)
func SkillsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
@@ -122,27 +114,6 @@ func Create(skill *Skill) error {
return Deploy(skill)
}
func Update(skill *Skill) error {
dir, err := SkillsDir()
if err != nil {
return err
}
skillDir := filepath.Join(dir, skill.Name)
skillPath := filepath.Join(skillDir, "SKILL.md")
if _, err := os.Stat(skillPath); err != nil {
return fmt.Errorf("skill '%s' not found", skill.Name)
}
skill.UpdatedAt = time.Now()
content := renderSkill(skill)
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
return err
}
return Deploy(skill)
}
func Delete(name string) error {
dir, err := SkillsDir()
if err != nil {
@@ -164,7 +135,7 @@ func Deploy(skill *Skill) error {
return fmt.Errorf("get home dir: %w", err)
}
if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) {
if skill.Target == "crush" || skill.Target == "both" {
crushSkillsDir := filepath.Join(home, ".config", "crush", "skills")
if err := os.MkdirAll(crushSkillsDir, 0755); err != nil {
return fmt.Errorf("create crush skills dir: %w", err)
@@ -179,7 +150,7 @@ func Deploy(skill *Skill) error {
}
}
if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) {
if skill.Target == "claude" || skill.Target == "both" {
claudeSkillsDir := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeSkillsDir, 0755); err != nil {
return fmt.Errorf("create claude skills dir: %w", err)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,17 +2,10 @@ package version
const (
Name = "muyue"
Version = "0.2.1"
Version = "0.3.2"
Author = "La Légion de Muyue"
License = "MIT"
)
var Prerelease string
func FullVersion() string {
v := Name + " v" + Version
if Prerelease != "" {
v += "-" + Prerelease
}
return v
return Name + " v" + Version
}

View File

@@ -15,28 +15,6 @@ func TestFullVersion(t *testing.T) {
}
}
func TestFullVersionWithPrerelease(t *testing.T) {
original := Prerelease
Prerelease = "beta.1"
defer func() { Prerelease = original }()
v := FullVersion()
if !strings.Contains(v, "beta.1") {
t.Errorf("FullVersion should contain prerelease suffix, got %s", v)
}
}
func TestFullVersionWithoutPrerelease(t *testing.T) {
original := Prerelease
Prerelease = ""
defer func() { Prerelease = original }()
v := FullVersion()
if strings.Contains(v, "-") {
t.Errorf("FullVersion should not contain prerelease suffix, got %s", v)
}
}
func TestConstants(t *testing.T) {
if Name == "" {
t.Error("Name should not be empty")
@@ -47,7 +25,4 @@ func TestConstants(t *testing.T) {
if Author == "" {
t.Error("Author should not be empty")
}
if License == "" {
t.Error("License should not be empty")
}
}

View File

@@ -1,280 +0,0 @@
package workflow
import (
"encoding/json"
"fmt"
"strings"
)
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseGathering Phase = "gathering"
PhasePlanning Phase = "planning"
PhaseReviewing Phase = "reviewing"
PhaseExecuting Phase = "executing"
PhaseDone Phase = "done"
PhaseError Phase = "error"
)
type Step struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Agent string `json:"agent"`
Output string `json:"output,omitempty"`
}
type Plan struct {
Goal string `json:"goal"`
Context string `json:"context"`
Questions []string `json:"questions"`
Answers []string `json:"answers"`
Steps []Step `json:"steps"`
StepIndex int `json:"current_step"`
PreviewFiles []PreviewFile `json:"preview_files,omitempty"`
}
type PreviewFile struct {
Filename string `json:"filename"`
Content string `json:"content"`
Type string `json:"type"`
}
type Workflow struct {
Phase Phase
Plan *Plan
History []string
}
func New() *Workflow {
return &Workflow{
Phase: PhaseIdle,
Plan: &Plan{},
History: []string{},
}
}
func (w *Workflow) Start(goal string) {
w.Phase = PhaseGathering
w.Plan = &Plan{
Goal: goal,
Steps: []Step{},
Answers: []string{},
}
w.History = append(w.History, fmt.Sprintf("[started] %s", goal))
}
func (w *Workflow) AddAnswer(answer string) {
w.Plan.Answers = append(w.Plan.Answers, answer)
if len(w.Plan.Answers) >= len(w.Plan.Questions) {
w.Phase = PhasePlanning
w.History = append(w.History, "[gathering complete, moving to planning]")
}
}
func (w *Workflow) SetPlan(planJSON string) error {
var steps []Step
if err := json.Unmarshal([]byte(planJSON), &steps); err != nil {
if err2 := json.Unmarshal([]byte("["+planJSON+"]"), &steps); err2 != nil {
return fmt.Errorf("parse plan: %w", err)
}
}
w.Plan.Steps = steps
w.Phase = PhaseReviewing
w.History = append(w.History, fmt.Sprintf("[plan created] %d steps", len(steps)))
return nil
}
func (w *Workflow) SetPreviewFiles(files []PreviewFile) {
w.Plan.PreviewFiles = files
}
func (w *Workflow) Approve() {
w.Phase = PhaseExecuting
w.Plan.StepIndex = 0
w.History = append(w.History, "[plan approved, starting execution]")
}
func (w *Workflow) Reject(feedback string) {
w.Phase = PhasePlanning
w.History = append(w.History, fmt.Sprintf("[plan rejected: %s]", feedback))
}
func (w *Workflow) AdvanceStep(output string) {
if w.Plan.StepIndex < len(w.Plan.Steps) {
w.Plan.Steps[w.Plan.StepIndex].Status = "done"
w.Plan.Steps[w.Plan.StepIndex].Output = output
w.Plan.StepIndex++
w.History = append(w.History, fmt.Sprintf("[step %d done]", w.Plan.StepIndex))
if w.Plan.StepIndex >= len(w.Plan.Steps) {
w.Phase = PhaseDone
w.History = append(w.History, "[all steps complete]")
}
}
}
func (w *Workflow) FailStep(errMsg string) {
if w.Plan.StepIndex < len(w.Plan.Steps) {
w.Plan.Steps[w.Plan.StepIndex].Status = "error"
w.Plan.Steps[w.Plan.StepIndex].Output = errMsg
w.Phase = PhaseError
w.History = append(w.History, fmt.Sprintf("[step %d failed: %s]", w.Plan.StepIndex+1, errMsg))
}
}
func (w *Workflow) Reset() {
w.Phase = PhaseIdle
w.Plan = &Plan{}
}
func (w *Workflow) CurrentStep() *Step {
if w.Plan.StepIndex < len(w.Plan.Steps) {
return &w.Plan.Steps[w.Plan.StepIndex]
}
return nil
}
func (w *Workflow) Progress() (done, total int) {
for _, s := range w.Plan.Steps {
if s.Status == "done" {
done++
}
total++
}
return
}
func BuildSystemPrompt(phase Phase, plan *Plan) string {
base := `You are muyue, an AI-powered development environment assistant.
You follow a structured workflow: GATHER requirements → PLAN → REVIEW → EXECUTE.
RULES:
- Always respond in the same language the user writes in.
- When in GATHERING phase, ask clarifying questions ONE AT A TIME to understand the requirement fully.
- When in PLANNING phase, create a detailed step-by-step plan as a JSON array of objects.
- When in REVIEWING phase, present the plan clearly and wait for approval.
- When in EXECUTING phase, execute one step at a time and report results.
- If the user wants a visual preview, generate 1-2 HTML files wrapped in a PREVIEW_JSON block.`
switch phase {
case PhaseGathering:
base += fmt.Sprintf(`
CURRENT PHASE: GATHERING
Goal: %s
Questions to ask: %v
Answers received: %v
Remaining questions: %d
Ask the NEXT question that hasn't been answered yet. If all questions are answered, say "GATHERING_COMPLETE".`,
plan.Goal, plan.Questions, plan.Answers,
len(plan.Questions)-len(plan.Answers))
case PhasePlanning:
qa := ""
for i, q := range plan.Questions {
a := ""
if i < len(plan.Answers) {
a = plan.Answers[i]
}
qa += fmt.Sprintf("\nQ: %s\nA: %s", q, a)
}
base += fmt.Sprintf(`
CURRENT PHASE: PLANNING
Goal: %s
%s
Create a step-by-step plan. Output ONLY a JSON array of steps:
[
{"id": "1", "title": "...", "description": "...", "agent": "crush|claude|muyue", "status": "pending"},
...
]
If the user needs a visual preview, wrap HTML in:
<<<PREVIEW_JSON>>>
[{"filename":"preview.html","content":"<html>...</html>","type":"html"}]
<<<END_PREVIEW>>>`,
plan.Goal, qa)
case PhaseReviewing:
steps, _ := json.MarshalIndent(plan.Steps, "", " ")
base += fmt.Sprintf(`
CURRENT PHASE: REVIEWING
Present the plan below clearly and ask for approval:
%s
Say "PLAN_APPROVED" if the user approves, or "PLAN_REJECTED: <reason>" if not.`,
string(steps))
case PhaseExecuting:
if plan.StepIndex < len(plan.Steps) {
step := plan.Steps[plan.StepIndex]
base += fmt.Sprintf(`
CURRENT PHASE: EXECUTING
Current step: %s — %s (agent: %s)
Execute this step and report the result.`,
step.Title, step.Description, step.Agent)
}
}
return base
}
func ParsePlanResponse(response string) ([]Step, error) {
response = strings.TrimSpace(response)
start := strings.Index(response, "[")
end := strings.LastIndex(response, "]")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("no JSON array found in response")
}
jsonStr := response[start : end+1]
var steps []Step
if err := json.Unmarshal([]byte(jsonStr), &steps); err != nil {
return nil, fmt.Errorf("parse steps: %w", err)
}
for i := range steps {
steps[i].Status = "pending"
}
return steps, nil
}
func ParsePreviewFiles(response string) []PreviewFile {
startMarker := "<<<PREVIEW_JSON>>>"
endMarker := "<<<END_PREVIEW>>>"
start := strings.Index(response, startMarker)
end := strings.Index(response, endMarker)
if start == -1 || end == -1 {
return nil
}
jsonStr := strings.TrimSpace(response[start+len(startMarker) : end])
var files []PreviewFile
if err := json.Unmarshal([]byte(jsonStr), &files); err != nil {
return nil
}
return files
}
func ParseApproval(response string) (approved bool, feedback string) {
lower := strings.ToLower(strings.TrimSpace(response))
if strings.Contains(lower, "plan_approved") || strings.Contains(lower, "approved") || strings.Contains(lower, "yes") || strings.Contains(lower, "go ahead") || strings.Contains(lower, "oui") || strings.Contains(lower, "ok") {
return true, ""
}
if strings.Contains(lower, "plan_rejected:") {
parts := strings.SplitN(lower, "plan_rejected:", 2)
if len(parts) > 1 {
return false, strings.TrimSpace(parts[1])
}
}
return false, response
}

View File

@@ -1,255 +0,0 @@
package workflow
import (
"testing"
)
func TestNew(t *testing.T) {
wf := New()
if wf.Phase != PhaseIdle {
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
}
if wf.Plan == nil {
t.Error("Plan should not be nil")
}
}
func TestStart(t *testing.T) {
wf := New()
wf.Start("Build a REST API")
if wf.Phase != PhaseGathering {
t.Errorf("Expected PhaseGathering, got %s", wf.Phase)
}
if wf.Plan.Goal != "Build a REST API" {
t.Errorf("Expected goal 'Build a REST API', got %s", wf.Plan.Goal)
}
}
func TestAddAnswer(t *testing.T) {
wf := New()
wf.Start("test goal")
wf.Plan.Questions = []string{"Q1?", "Q2?"}
wf.AddAnswer("A1")
if wf.Phase != PhaseGathering {
t.Errorf("Should still be gathering, got %s", wf.Phase)
}
wf.AddAnswer("A2")
if wf.Phase != PhasePlanning {
t.Errorf("Should move to planning, got %s", wf.Phase)
}
}
func TestSetPlan(t *testing.T) {
wf := New()
planJSON := `[{"id":"1","title":"Step 1","description":"Do something","agent":"crush","status":"pending"}]`
err := wf.SetPlan(planJSON)
if err != nil {
t.Fatalf("SetPlan failed: %v", err)
}
if len(wf.Plan.Steps) != 1 {
t.Errorf("Expected 1 step, got %d", len(wf.Plan.Steps))
}
if wf.Phase != PhaseReviewing {
t.Errorf("Expected PhaseReviewing, got %s", wf.Phase)
}
}
func TestApprove(t *testing.T) {
wf := New()
wf.Start("test")
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1", Status: "pending"}}
wf.Phase = PhaseReviewing
wf.Approve()
if wf.Phase != PhaseExecuting {
t.Errorf("Expected PhaseExecuting, got %s", wf.Phase)
}
if wf.Plan.StepIndex != 0 {
t.Errorf("Expected step index 0, got %d", wf.Plan.StepIndex)
}
}
func TestReject(t *testing.T) {
wf := New()
wf.Phase = PhaseReviewing
wf.Reject("too complex")
if wf.Phase != PhasePlanning {
t.Errorf("Expected PhasePlanning, got %s", wf.Phase)
}
}
func TestAdvanceStep(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{
{ID: "1", Title: "Step 1", Status: "pending"},
{ID: "2", Title: "Step 2", Status: "pending"},
}
wf.Phase = PhaseExecuting
wf.AdvanceStep("output1")
if wf.Plan.Steps[0].Status != "done" {
t.Error("First step should be done")
}
if wf.Plan.StepIndex != 1 {
t.Errorf("Expected step index 1, got %d", wf.Plan.StepIndex)
}
if wf.Phase != PhaseExecuting {
t.Errorf("Should still be executing, got %s", wf.Phase)
}
wf.AdvanceStep("output2")
if wf.Phase != PhaseDone {
t.Errorf("Expected PhaseDone, got %s", wf.Phase)
}
}
func TestFailStep(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1"}}
wf.Phase = PhaseExecuting
wf.FailStep("something broke")
if wf.Phase != PhaseError {
t.Errorf("Expected PhaseError, got %s", wf.Phase)
}
if wf.Plan.Steps[0].Status != "error" {
t.Error("Step should have error status")
}
}
func TestReset(t *testing.T) {
wf := New()
wf.Start("test")
wf.Phase = PhaseExecuting
wf.Reset()
if wf.Phase != PhaseIdle {
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
}
}
func TestCurrentStep(t *testing.T) {
wf := New()
if wf.CurrentStep() != nil {
t.Error("Should be nil with no steps")
}
wf.Plan.Steps = []Step{{ID: "1"}, {ID: "2"}}
wf.Plan.StepIndex = 0
step := wf.CurrentStep()
if step == nil || step.ID != "1" {
t.Error("Should return first step")
}
wf.Plan.StepIndex = 2
if wf.CurrentStep() != nil {
t.Error("Should be nil when past all steps")
}
}
func TestProgress(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{
{ID: "1", Status: "done"},
{ID: "2", Status: "pending"},
{ID: "3", Status: "done"},
}
done, total := wf.Progress()
if done != 2 || total != 3 {
t.Errorf("Expected 2/3, got %d/%d", done, total)
}
}
func TestParsePlanResponse(t *testing.T) {
resp := `Here is the plan:
[
{"id": "1", "title": "Setup", "description": "Init project", "agent": "crush"},
{"id": "2", "title": "Build", "description": "Write code", "agent": "claude"}
]`
steps, err := ParsePlanResponse(resp)
if err != nil {
t.Fatalf("ParsePlanResponse failed: %v", err)
}
if len(steps) != 2 {
t.Errorf("Expected 2 steps, got %d", len(steps))
}
if steps[0].ID != "1" {
t.Errorf("Expected step ID 1, got %s", steps[0].ID)
}
for _, s := range steps {
if s.Status != "pending" {
t.Errorf("Steps should be pending, got %s", s.Status)
}
}
}
func TestParsePlanResponseInvalid(t *testing.T) {
_, err := ParsePlanResponse("no json here")
if err == nil {
t.Error("Should fail with no JSON")
}
}
func TestParseApproval(t *testing.T) {
tests := []struct {
input string
approved bool
}{
{"plan_approved", true},
{"approved", true},
{"yes", true},
{"ok", true},
{"oui", true},
{"go ahead", true},
{"no", false},
{"plan_rejected: too complex", false},
{"I don't like it", false},
}
for _, tt := range tests {
approved, feedback := ParseApproval(tt.input)
if approved != tt.approved {
t.Errorf("ParseApproval(%q) = %v, want %v", tt.input, approved, tt.approved)
}
if !approved && tt.input == "plan_rejected: too complex" {
if feedback != "too complex" {
t.Errorf("Expected feedback 'too complex', got %s", feedback)
}
}
}
}
func TestParsePreviewFiles(t *testing.T) {
resp := `Some text
<<<PREVIEW_JSON>>>
[{"filename":"test.html","content":"<h1>Hello</h1>","type":"html"}]
<<<END_PREVIEW>>>`
files := ParsePreviewFiles(resp)
if len(files) != 1 {
t.Fatalf("Expected 1 file, got %d", len(files))
}
if files[0].Filename != "test.html" {
t.Errorf("Expected test.html, got %s", files[0].Filename)
}
}
func TestParsePreviewFilesNone(t *testing.T) {
files := ParsePreviewFiles("no preview here")
if files != nil {
t.Error("Should return nil")
}
}
func TestBuildSystemPrompt(t *testing.T) {
prompt := BuildSystemPrompt(PhaseIdle, &Plan{})
if prompt == "" {
t.Error("Prompt should not be empty")
}
if len(prompt) < 100 {
t.Error("Prompt seems too short")
}
prompt = BuildSystemPrompt(PhaseGathering, &Plan{Goal: "test"})
if prompt == "" {
t.Error("Gathering prompt should not be empty")
}
}

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

14
web/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0A0A0C" />
<title>muyue</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

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

@@ -0,0 +1,999 @@
{
"name": "muyue-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "muyue-web",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"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/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/detect-libc": {
"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/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"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
}
}
}
}
}

22
web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "muyue-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.9"
}
}

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

@@ -0,0 +1,89 @@
const API_BASE = '/api'
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
})
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'),
getEditors: () => request('/editors'),
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
resetConfig: () => request('/config/reset', { method: 'POST' }),
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
getTerminalSessions: () => request('/terminal/sessions'),
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
getTerminalThemes: () => request('/terminal/themes'),
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }),
sendChat: (message, stream = true, onChunk) => {
if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
}
return new Promise((resolve, reject) => {
fetch(`${API_BASE}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: true }),
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
reject(new Error(err.error || res.statusText))
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let full = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.error) { reject(new Error(data.error)); return }
if (data.done) { resolve(full); return }
if (data.content) {
full += data.content
if (onChunk) onChunk(full, data)
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data)
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data)
}
} catch {}
}
}
resolve(full)
}).catch(reject)
})
},
}
export default api

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

@@ -0,0 +1,170 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
import OnboardingWizard from './OnboardingWizard'
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 [config, setConfig] = useState(null)
const [showOnboarding, setShowOnboarding] = useState(false)
const { t, layout } = useI18n()
const TABS = useMemo(() => [
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
], [t])
useEffect(() => {
api.getInfo().then(setInfo).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getConfig().then(d => {
setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
applyTheme(getTheme(theme))
const hasProfile = d.profile?.name || d.profile?.pseudo
if (!hasProfile) setShowOnboarding(true)
}).catch(() => {
applyTheme(getTheme('cyberpunk-red'))
setShowOnboarding(true)
})
}, [])
useEffect(() => {
const id = setInterval(() => setClock(new Date()), 1000)
return () => clearInterval(id)
}, [])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.ctrlKey && !e.metaKey) return
const map = {
Digit1: 'dash',
Digit2: 'studio',
Digit3: 'shell',
Digit4: 'config',
}
if (map[e.code]) {
e.preventDefault()
setActiveTab(map[e.code])
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(tool => tool.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
shell: [
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
config: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
}), [layout, t])
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard api={api} />
case 'studio': return <Studio api={api} />
case 'shell': return <Shell api={api} />
case 'config': return <Config api={api} />
default: return null
}
}
return (
<div className="app-layout">
<header className="header">
<div className="header-brand">
<span className="header-logo">MUYUE</span>
<span className="header-version">v{info.version || '...'}</span>
</div>
<nav className="header-nav">
{TABS.map(tab => (
<div
key={tab.id}
className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => switchTab(tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
>
<span className="tab-icon">{tab.icon}</span>
{tab.label}
</div>
))}
</nav>
<div className="header-spacer" />
<div className="header-indicators">
<span
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
title={t('header.toolsInstalled', { count: installed })}
/>
<span
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
/>
</div>
<span className="header-clock">
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
</span>
</header>
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
{renderContent()}
</main>
<footer className="statusbar">
<div className="statusbar-left">
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div>
<div className="statusbar-right">
<span style={{ fontFamily: 'var(--font-mono)' }}>
{layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')}
</span>
</div>
</footer>
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
</div>
)
}
function FooterShortcuts({ shortcuts }) {
return shortcuts.map((s, i) => (
<span key={i} className="statusbar-shortcut">
<kbd>{s.keys}</kbd> {s.desc}
</span>
))
}

View File

@@ -0,0 +1,537 @@
import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench },
{ id: 'system', icon: Monitor },
]
export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [activePanel, setActivePanel] = useState('profile')
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(null)
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({}) // keyed by provider name
const [toast, setToast] = useState(null)
const layouts = getLayoutList()
const loadData = useCallback(() => {
api.getConfig().then(d => {
setConfig(d)
setProfileForm({
name: d.profile?.name || '',
pseudo: d.profile?.pseudo || '',
email: d.profile?.email || '',
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
}, [api])
useEffect(() => { loadData() }, [loadData])
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2500)
}
const handleCheckUpdates = async () => {
setChecking(true)
try {
await api.runScan()
const d = await api.getUpdates()
setUpdates(d.updates || [])
const td = await api.getTools()
setTools(td.tools || [])
showToast(t('config.upToDate'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setChecking(false)
}
const handleUpdateTool = async (tool) => {
setUpdating(tool)
try {
await api.runUpdate(tool)
await handleCheckUpdates()
showToast(`${tool}`)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = async () => {
setUpdating('__all__')
try {
await api.runUpdate('')
await handleCheckUpdates()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleSaveProfile = async () => {
try {
await api.saveProfile(profileForm)
setEditProfile(false)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleSaveProvider = async (name) => {
const form = providerForm[name]
if (!form) return
try {
await api.saveProvider({ name, ...form })
setEditProvider(null)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const openProviderEdit = (p) => {
setProviderForm(prev => ({
...prev,
[p.name]: {
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
},
}))
setEditProvider(p.name)
}
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
const installedCount = tools.filter(tool => tool.installed).length
const missingCount = tools.filter(tool => !tool.installed).length
return (
<div className="config-window">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-tabs-bar">
{PANELS.map(p => {
const Icon = p.icon
return (
<div
key={p.id}
className={`nav-tab ${activePanel === p.id ? 'active' : ''}`}
onClick={() => setActivePanel(p.id)}
>
<span className="tab-icon"><Icon size={15} /></span>
{t(`config.panels.${p.id}`)}
</div>
)
})}
</div>
<div className="config-panel-area">
<div className="config-panel-body">
{activePanel === 'profile' && (
<PanelProfile
config={config} editProfile={editProfile}
profileForm={profileForm} setProfileForm={setProfileForm}
setEditProfile={setEditProfile} handleSaveProfile={handleSaveProfile}
t={t}
/>
)}
{activePanel === 'providers' && (
<PanelProviders
providers={providers} editProvider={editProvider}
providerForm={providerForm} setProviderForm={setProviderForm}
setEditProvider={setEditProvider} openProviderEdit={openProviderEdit}
handleSaveProvider={handleSaveProvider} api={api} loadData={loadData}
t={t}
/>
)}
{activePanel === 'updates' && (
<PanelUpdates
updates={updates} tools={tools}
checking={checking} updating={updating}
needsUpdateCount={needsUpdateCount}
installedCount={installedCount} missingCount={missingCount}
handleCheckUpdates={handleCheckUpdates}
handleUpdateTool={handleUpdateTool}
handleUpdateAll={handleUpdateAll}
t={t}
/>
)}
{activePanel === 'locale' && (
<PanelLocale
language={keyboard} layouts={layouts}
setLanguage={setLanguage} setKeyboard={setKeyboard}
t={t}
/>
)}
{activePanel === 'skills' && (
<PanelSkills skillList={skillList} t={t} />
)}
{activePanel === 'system' && (
<PanelSystem api={api} t={t} />
)}
</div>
</div>
</div>
)
}
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
return (
<div className="config-card">
{config?.profile && !editProfile ? (
<>
<div className="config-card-row">
<span className="config-card-label">{t('config.name')}</span>
<span className="config-card-value">{config.profile.name || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.pseudo')}</span>
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.email')}</span>
<span className="config-card-value">{config.profile.email || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.editor')}</span>
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.shell')}</span>
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.languages')}</span>
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
</div>
<div className="config-card-actions">
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
</div>
</>
) : editProfile ? (
<>
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
<div className="config-card-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
)
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null)
const handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name)
setValidationStatus(null)
try {
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
setValidationStatus({ provider: name, valid: true })
} catch (err) {
const msg = err.message || ''
if (msg.includes('invalid_api_key')) {
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
} else {
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
}
}
setValidating(null)
}
return (
<div className="config-providers-list">
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
{providers.map((p, i) => {
const isEditing = editProvider === p.name
const isValidationTarget = validationStatus?.provider === p.name
return (
<div key={i} className="config-card provider-card-v2">
<div className="provider-card-top">
<div className="provider-card-identity">
<span className="provider-card-name">{p.name}</span>
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
</div>
</div>
<div className="provider-card-form">
<div className="provider-setup-token-row">
<div className="provider-setup-token-input">
<label className="config-form-label">{t('config.apiKey')}</label>
<input
className="config-form-input"
type="password"
placeholder={t('config.tokenPlaceholder')}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)
setProviderForm(prev => ({
...prev,
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
}))
}}
/>
</div>
<div className="provider-setup-token-actions">
<button
className="sm primary"
disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
>
{validating === p.name ? t('config.validating') : t('config.validateKey')}
</button>
{isValidationTarget && validationStatus?.valid && (
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)}
</div>
</div>
<div className="provider-card-meta" style={{ marginTop: 8 }}>
<span className="mono">{p.model || '—'}</span>
</div>
</div>
</div>
)
})}
</div>
)
}
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
return (
<>
<div className="config-card">
<div className="config-update-controls">
<div className="config-update-stats">
<span className="badge ok">{installedCount} {t('config.installed')}</span>
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
</div>
<div className="config-update-buttons">
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
</button>
{needsUpdateCount > 0 && (
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
</button>
)}
</div>
</div>
</div>
{updates.length === 0 ? (
<div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div>
</div>
) : (
<div className="config-update-list">
{updates.map((u, i) => (
<div key={i} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{u.tool}</span>
<span className="config-update-versions">
{u.needsUpdate ? (
<>{u.current} <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
) : (
<span style={{ color: 'var(--success)' }}>{u.current}</span>
)}
</span>
</div>
{u.needsUpdate && (
<button
className="sm"
onClick={() => handleUpdateTool(u.tool)}
disabled={updating === u.tool}
>
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
</button>
)}
</div>
))}
</div>
)}
</>
)
}
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
return (
<div className="config-card">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.language')}</span>
<div className="chip-row">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${language === lang.id ? 'active' : ''}`}
onClick={() => setLanguage(lang.id)}
>
{lang.name}
</div>
))}
</div>
</div>
<div className="config-card-group">
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
<div className="chip-row">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${keyboard === l.id ? 'active' : ''}`}
onClick={() => setKeyboard(l.id)}
>
{l.name}
</div>
))}
</div>
</div>
</div>
)
}
function PanelSkills({ skillList, t }) {
return (
<div className="config-card">
{skillList.length === 0 ? (
<div className="empty-state">
{t('config.noSkills')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
</div>
) : (
skillList.map((s, i) => (
<div key={i} className="config-skill-row">
<span className="config-skill-name">{s.name}</span>
<span className="badge neutral">{s.target || 'both'}</span>
<span className="config-skill-desc">{s.description}</span>
</div>
))
)}
</div>
)
}
function PanelSystem({ api, t }) {
const [resetConfirm, setResetConfirm] = useState(false)
const [toast, setToast] = useState(null)
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const handleReset = async () => {
try {
await api.resetConfig()
setResetConfirm(false)
showToast(t('config.resetDone'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleApplyStarship = async () => {
try {
await api.applyStarshipTheme('charm')
showToast(t('config.starshipApplied'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
return (
<>
{toast && <div className="config-toast">{toast}</div>}
<div className="config-card">
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
{t('config.starshipApplied')}
</div>
<button className="sm primary" onClick={handleApplyStarship}>
{t('config.applyStarship')}
</button>
</div>
<div className="config-card" style={{ marginTop: 12 }}>
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
</div>
{resetConfirm ? (
<div>
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
{t('config.resetConfirm')}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
</div>
</div>
) : (
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
{t('config.resetConfig')}
</button>
)}
</div>
</>
)
}
function FormInput({ label, value, onChange, type = 'text' }) {
return (
<div className="config-form-field">
<label className="config-form-label">{label}</label>
<input
className="config-form-input"
type={type}
value={value}
onChange={e => onChange(e.target.value)}
/>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react'
import { useI18n } from '../i18n'
export default function Dashboard({ api }) {
const { t } = useI18n()
const [notifications, setNotifications] = useState([])
return (
<div className="dashboard-layout">
<div className="dashboard-content">
<div className="dashboard-grid">
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('studio.workflows')}</div>
</div>
<div className="dashboard-workflows-inline">
<div className="workflow-section">
<div className="section-label">{t('studio.workflows')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
<div className="workflow-section">
<div className="section-label">{t('studio.activeAgents')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
</div>
</div>
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
{notifications.length > 0 && (
<span className="badge warn">{notifications.length}</span>
)}
</div>
{notifications.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
<div className="dashboard-notifications-inline">
{notifications.map(n => (
<div key={n.id} className={`notif-row notif-${n.type}`}>
<span className="notif-time">
{n.time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="notif-text">{n.text}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,407 @@
import { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const STEPS = [
{ key: 'welcome', title: 'welcome' },
{ key: 'name', title: 'name' },
{ key: 'language', title: 'language' },
{ key: 'keyboard', title: 'keyboard' },
{ key: 'apikey', title: 'apikey' },
{ key: 'editor', title: 'editor' },
{ key: 'done', title: 'done' },
]
const BASE_EDITORS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
export default function OnboardingWizard({ api, onComplete }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [step, setStep] = useState(0)
const [answers, setAnswers] = useState({
name: '',
language: 'fr',
keyboard: 'azerty',
apikey: '',
editor: '',
})
const [editorList, setEditorList] = useState(BASE_EDITORS)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [requiredError, setRequiredError] = useState(false)
const [validating, setValidating] = useState(false)
const [keyValid, setKeyValid] = useState(false)
const [scanning, setScanning] = useState(false)
const current = STEPS[step]
const layouts = getLayoutList()
const goNext = () => {
if (step < STEPS.length - 1) {
if (!canProceed) { setRequiredError(true); return }
setRequiredError(false)
setStep(step + 1)
}
}
const canProceed = (() => {
switch (current.key) {
case 'welcome': return true
case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard
case 'apikey': return true
case 'editor': return true
case 'done': return true
default: return true
}
})()
const goPrev = () => {
if (step > 0) setStep(step - 1)
}
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { goPrev(); return }
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [step, current])
useEffect(() => {
if (current.key === 'done' && !saving) {
handleSave()
}
}, [step])
const handleValidateKey = async () => {
if (!answers.apikey.trim()) return
setValidating(true)
setError(null)
try {
await api.validateProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
})
setKeyValid(true)
} catch (err) {
setError(err.message || 'Clé invalide')
setKeyValid(false)
}
setValidating(false)
}
const handleScanEditors = async () => {
setScanning(true)
setError(null)
try {
const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name)
const merged = [...new Set([...detected, ...BASE_EDITORS])]
setEditorList(merged)
if (detected.length === 0) {
setError('Aucun éditeur détecté')
}
} catch (err) {
setError(err.message || 'Erreur lors du scan')
}
setScanning(false)
}
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const profile = {
name: answers.name,
pseudo: answers.name.split(' ')[0] || 'user',
editor: answers.editor,
}
if (answers.apikey.trim()) {
profile.apikey = answers.apikey
}
await api.saveProfile(profile)
await api.savePreferences({
language: answers.language,
keyboard_layout: answers.keyboard,
})
if (answers.apikey.trim()) {
await api.saveProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
active: true,
})
}
onComplete()
} catch (err) {
setError(err.message || 'Erreur lors de la sauvegarde')
setSaving(false)
}
}
return (
<div className="onboarding-overlay">
<div className="onboarding-card">
<div className="onboarding-header">
<Sparkles size={20} style={{ color: 'var(--accent)' }} />
<span> Muyue Setup</span>
</div>
<div className="onboarding-progress">
{STEPS.map((_, i) => (
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
))}
</div>
<div className="onboarding-body">
{current.key === 'welcome' && (
<div className="onboarding-step">
<div className="onboarding-title">Bienvenue ! 👋</div>
<div className="onboarding-desc">
Je suis votre assistant de configuration. Quelques questions rapides pour personnaliser votre expérience.
</div>
</div>
)}
{current.key === 'name' && (
<div className="onboarding-step">
<div className="onboarding-title">Comment vous appelez-vous ?</div>
<input
className="onboarding-input"
placeholder="Votre nom..."
value={answers.name}
onChange={e => { setAnswers(a => ({ ...a, name: e.target.value })); setRequiredError(false) }}
autoFocus
/>
{requiredError && <div className="onboarding-required">Veuillez entrer votre nom</div>}
</div>
)}
{current.key === 'language' && (
<div className="onboarding-step">
<div className="onboarding-title">Quelle langue préférez-vous ?</div>
<div className="onboarding-chips">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${answers.language === lang.id ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, language: lang.id }))}
>
{lang.name}
</div>
))}
</div>
</div>
)}
{current.key === 'keyboard' && (
<div className="onboarding-step">
<div className="onboarding-title">Disposition du clavier ?</div>
<div className="onboarding-chips">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${answers.keyboard === l.id ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, keyboard: l.id }))}
>
{l.name}
</div>
))}
</div>
</div>
)}
{current.key === 'apikey' && (
<div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div>
<div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
</div>
<input
className="onboarding-input"
placeholder="sk-xxxxxxxxxxxxxxxx"
type="password"
value={answers.apikey}
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
autoFocus
/>
{error && !keyValid && <div className="onboarding-required">{error}</div>}
{keyValid && <div className="onboarding-valid">Clé valide ✓</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
className="sm primary"
onClick={handleValidateKey}
disabled={validating || !answers.apikey.trim()}
>
{validating ? 'Validation...' : 'Valider la clé'}
</button>
<button
className="sm ghost"
onClick={goNext}
disabled={!answers.apikey.trim()}
>
Passer
</button>
</div>
{answers.apikey.trim() && !keyValid && !error && (
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
)}
</div>
)}
{current.key === 'editor' && (
<div className="onboarding-step">
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="onboarding-chips" style={{ flex: 1 }}>
{editorList.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
</div>
<button
className="sm ghost"
onClick={handleScanEditors}
disabled={scanning}
title="Détecter les éditeurs installés"
style={{ marginLeft: 8, flexShrink: 0 }}
>
{scanning ? <Loader size={14} className="spin-icon" /> : <Search size={14} />}
</button>
</div>
<input
className="onboarding-input"
style={{ marginTop: 12 }}
placeholder="Autre éditeur..."
value={answers.editor}
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus
/>
{error && <div className="onboarding-required">{error}</div>}
</div>
)}
{current.key === 'done' && (
<div className="onboarding-step">
{saving ? (
<>
<div className="onboarding-title">Configuration en cours...</div>
<div className="onboarding-desc">Sauvegarde de vos préférences.</div>
</>
) : error ? (
<>
<div className="onboarding-title" style={{ color: 'var(--error)' }}>Erreur</div>
<div className="onboarding-desc" style={{ color: 'var(--error)' }}>{error}</div>
<button className="primary" style={{ alignSelf: 'flex-start', marginTop: 8 }} onClick={() => handleSave()}>Réessayer</button>
</>
) : (
<>
<div className="onboarding-title">C'est parti ! 🚀</div>
<div className="onboarding-desc">
Votre profil est configuré. Vous pouvez toujours ajuster les paramètres dans l'onglet Configuration.
</div>
</>
)}
</div>
)}
</div>
<div className="onboarding-footer">
{step > 0 && step < STEPS.length - 1 && (
<button className="ghost" onClick={goPrev}>
<ArrowLeft size={14} /> Précédent
</button>
)}
<div style={{ flex: 1 }} />
{step < STEPS.length - 1 && (
<button className="primary" onClick={goNext}>
Suivant <ArrowRight size={14} />
</button>
)}
{step === STEPS.length - 1 && !saving && !error && (
<button className="primary" onClick={handleSave}>
Commencer
</button>
)}
</div>
</div>
<style>{`
.onboarding-overlay {
position: fixed; inset: 0; z-index: 500;
background: rgba(10,10,12,0.85);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(8px);
}
.onboarding-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 480px; max-width: 90vw;
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
overflow: hidden;
}
.onboarding-header {
display: flex; align-items: center; gap: 8px;
padding: 16px 20px; font-size: 14px; font-weight: 700;
color: var(--accent); border-bottom: 1px solid var(--border);
background: var(--bg-surface);
}
.onboarding-progress {
display: flex; gap: 6px; padding: 14px 20px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
.onboarding-dot {
width: 32px; height: 4px; border-radius: 2px;
background: var(--bg-input); transition: all 0.3s;
}
.onboarding-dot.active { background: var(--accent); }
.onboarding-dot.done { background: var(--accent-dim); }
.onboarding-body { padding: 28px 24px; min-height: 200px; }
.onboarding-step { display: flex; flex-direction: column; gap: 16px; }
.onboarding-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.onboarding-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; }
.onboarding-input {
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary);
font-size: 14px; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
}
.onboarding-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.onboarding-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.onboarding-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid var(--border);
background: var(--bg-surface);
}
.onboarding-required {
font-size: 12px; color: var(--error); margin-top: 4px;
}
.onboarding-valid {
font-size: 12px; color: var(--success); margin-top: 4px;
}
.onboarding-hint {
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
}
.spin-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,625 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react'
import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n'
const MAX_TABS = 7
const THEMES = {
default: {
background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033',
cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff',
black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740',
blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2',
brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE',
brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB',
brightCyan: '#84FFFF', brightWhite: '#FFFFFF',
},
monokai: {
background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0',
cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74',
blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2',
brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E',
brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF',
brightCyan: '#A1EFE4', brightWhite: '#F8F8F2',
},
gruvbox: {
background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934',
cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff',
black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921',
blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2',
brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26',
brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B',
brightCyan: '#8EC07C', brightWhite: '#EBDBB2',
},
nord: {
background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9',
cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff',
black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B',
blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9',
brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C',
brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD',
brightCyan: '#8FBCBB', brightWhite: '#ECEFF4',
},
'solarized-dark': {
background: '#002B36', foreground: '#839496', cursor: '#D33682',
cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff',
black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900',
blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3',
brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75',
brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4',
brightCyan: '#93A1A1', brightWhite: '#FDF6E3',
},
dracula: {
background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2',
cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C',
blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2',
brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94',
brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF',
brightCyan: '#A4FFFF', brightWhite: '#FFFFFF',
},
}
function getTheme(themeName) {
return THEMES[themeName] || THEMES.default
}
function createTerminal(container, settings = {}) {
const theme = getTheme(settings.theme || 'default')
const term = new XTerm({
cursorBlink: true,
fontSize: settings.fontSize || 14,
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme,
allowTransparency: false,
scrollback: 5000,
})
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.open(container)
fitAddon.fit()
return { term, fitAddon }
}
function connectWebSocket(term, fitAddon, initPayload) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
ws.addEventListener('open', () => {
ws.send(JSON.stringify(initPayload))
const dims = fitAddon.proposeDimensions()
if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
}
})
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'output') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
}
} catch {
term.write(event.data)
}
})
ws.addEventListener('close', () => {
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
})
ws.addEventListener('error', () => {
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
})
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
}
})
return ws
}
export default function Shell({ api }) {
const { t } = useI18n()
const tabsRef = useRef({})
const nextIdRef = useRef(1)
const [tabs, setTabs] = useState([
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
])
const [activeTab, setActiveTab] = useState(1)
const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false)
const [showSshModal, setShowSshModal] = useState(false)
const [editingTab, setEditingTab] = useState(null)
const [editName, setEditName] = useState('')
const [terminalSettings, setTerminalSettings] = useState({
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: 'default',
})
const [sshForm, setSshForm] = useState({
name: '', host: '', port: 22, user: '', key_path: '',
})
const [aiMessages, setAiMessages] = useState([
{ role: 'ai', content: t('shell.aiWelcome') }
])
const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const aiMessagesRef = useRef(null)
useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
useEffect(() => {
api.getTerminalSessions().then(d => {
setSshConnections(d.ssh || [])
setSystemTerminals(d.system || [])
}).catch(() => {})
api.getConfig().then(d => {
if (d.terminal) {
setTerminalSettings({
fontSize: d.terminal.font_size || 14,
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: d.terminal.theme || 'default',
})
}
}).catch(() => {})
}, [])
const initTerminal = useCallback((tabId, tab) => {
if (tabsRef.current[tabId]) return
const container = document.getElementById(`terminal-${tabId}`)
if (!container) return
const { term, fitAddon } = createTerminal(container, {
fontSize: terminalSettings.fontSize,
fontFamily: terminalSettings.fontFamily,
theme: terminalSettings.theme,
})
let initPayload
if (tab.type === 'ssh') {
initPayload = {
type: 'ssh',
data: JSON.stringify({
host: tab.host,
port: tab.port || 22,
user: tab.user || 'root',
key_path: tab.key_path || '',
}),
}
} else {
initPayload = {
type: 'shell',
data: tab.shell || '',
}
}
const ws = connectWebSocket(term, fitAddon, initPayload)
ws.onopen = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
}
ws.onclose = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
ws.onerror = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
const onResize = () => {
const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) {
fitAddon.fit()
}
}
const resizeObserver = new ResizeObserver(onResize)
resizeObserver.observe(container)
window.addEventListener('resize', onResize)
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
}, [])
useEffect(() => {
const tab = tabs.find(t => t.id === activeTab)
if (!tab) return
const container = document.getElementById(`terminal-${tab.id}`)
if (!container) return
if (!tabsRef.current[tab.id]) {
const timer = setTimeout(() => {
initTerminal(tab.id, tab)
requestAnimationFrame(() => {
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
})
}, 100)
return () => clearTimeout(timer)
} else {
requestAnimationFrame(() => {
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
})
}
}, [activeTab, tabs, initTerminal])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey) return
const num = parseInt(e.key)
if (num >= 1 && num <= tabs.length) {
e.preventDefault()
setActiveTab(tabs[num - 1].id)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [tabs])
const addLocalTab = (shell, name) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
setTabs(prev => [...prev, newTab])
setActiveTab(id)
setShowMenu(false)
}
const addSSHTab = (conn) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = {
id,
name: conn.name || `${conn.user}@${conn.host}`,
type: 'ssh',
host: conn.host,
port: conn.port || 22,
user: conn.user || 'root',
key_path: conn.key_path || '',
connected: false,
}
setTabs(prev => [...prev, newTab])
setActiveTab(id)
setShowMenu(false)
}
const closeTab = (tabId, e) => {
if (e) e.stopPropagation()
if (tabs.length <= 1) return
if (tabsRef.current[tabId]) {
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
window.removeEventListener('resize', onResize)
resizeObserver.disconnect()
ws.close()
term.dispose()
delete tabsRef.current[tabId]
}
setTabs(prev => {
const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id)
}
return next
})
}
const startRename = (tabId, e) => {
if (e) e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
setEditingTab(tabId)
setEditName(tab.name)
}
const finishRename = () => {
if (editName.trim() && editingTab) {
setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t))
}
setEditingTab(null)
setEditName('')
}
const saveSSHConnection = async () => {
if (!sshForm.name.trim() || !sshForm.host.trim()) return
try {
await api.addSSHConnection(sshForm)
setSshConnections(prev => [...prev, { ...sshForm }])
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
setShowSshModal(false)
} catch (err) {
console.error(err)
}
}
const deleteSSHConnection = async (name) => {
try {
await api.deleteSSHConnection(name)
setSshConnections(prev => prev.filter(c => c.name !== name))
} catch (err) {
console.error(err)
}
}
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}"`, '')
const output = res.output || t('shell.noResponse')
parseAndAddAiMessages(output)
} catch (err) {
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
}
setAiLoading(false)
}
const parseAndAddAiMessages = (text) => {
const lines = text.split('\n')
let buffer = ''
let inBlock = false
const flushBuffer = () => {
if (buffer.trim()) {
setAiMessages(prev => [...prev, { role: 'ai', content: buffer.trim() }])
}
buffer = ''
}
for (const line of lines) {
const toolMatch = line.match(/^\[TOOL_CALL:\{.*\}\]$/)
if (toolMatch) {
flushBuffer()
try {
const toolData = JSON.parse(toolMatch[0].slice(10, -1))
setAiMessages(prev => [...prev, {
role: 'tool',
content: `${t('shell.toolLaunched')}: ${toolData.tool || 'tool'}`,
args: toolData.task || toolData.args || '',
}])
} catch {
setAiMessages(prev => [...prev, { role: 'tool', content: line, args: '' }])
}
} else if (line.match(/^(Reflexion|Thought|thinking):/i) || line.startsWith('>')) {
if (buffer.trim() && !inBlock) {
flushBuffer()
}
inBlock = true
const cleaned = line.replace(/^(Reflexion|Thought|thinking):\s*/i, '').replace(/^>\s*/, '')
if (buffer) buffer += ' '
buffer += cleaned
} else {
if (inBlock && buffer.trim()) {
setAiMessages(prev => [...prev, { role: 'thinking', content: buffer.trim() }])
buffer = ''
}
inBlock = false
if (buffer) buffer += '\n'
buffer += line
}
}
flushBuffer()
}
return (
<div className="shell-layout">
<div className="shell-terminal-col">
<div className="shell-tabs-bar">
<div className="shell-tabs">
{tabs.map((tab, i) => (
<div
key={tab.id}
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
onDoubleClick={(e) => startRename(tab.id, e)}
>
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
{tab.type === 'ssh' && <Globe size={12} />}
{tab.type === 'local' && <Monitor size={12} />}
{editingTab === tab.id ? (
<input
className="shell-tab-rename"
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={finishRename}
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
autoFocus
onClick={e => e.stopPropagation()}
/>
) : (
<span className="shell-tab-name">{tab.name}</span>
)}
<span className="shell-tab-index">{i + 1}</span>
{tabs.length > 1 && (
<button
className="shell-tab-close"
onClick={(e) => closeTab(tab.id, e)}
title={t('shell.closeTab')}
>
<X size={12} />
</button>
)}
</div>
))}
</div>
<div className="shell-tab-actions">
{tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
<Plus size={16} />
<ChevronDown size={12} />
</button>
{showMenu && (
<>
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
<div className="shell-new-tab-menu">
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
{systemTerminals.map(st => (
<button
key={st.name}
className="shell-menu-item"
onClick={() => addLocalTab(st.shell, st.name)}
>
<Monitor size={14} />
<span>{st.name}</span>
<span className="shell-menu-item-sub">{st.shell}</span>
</button>
))}
<div className="shell-menu-divider" />
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
{sshConnections.length === 0 && (
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
)}
{sshConnections.map(conn => (
<div key={conn.name} className="shell-menu-item-row">
<button
className="shell-menu-item"
onClick={() => addSSHTab(conn)}
>
<Globe size={14} />
<span>{conn.name}</span>
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
</button>
<button
className="shell-menu-item-icon"
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
title={t('shell.deleteConnection')}
>
<Trash2 size={12} />
</button>
</div>
))}
<div className="shell-menu-divider" />
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
<Plus size={14} />
<span>{t('shell.addConnection')}</span>
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
<div className="shell-xterm-wrapper">
{tabs.map(tab => (
<div
key={tab.id}
id={`terminal-${tab.id}`}
className="shell-xterm-instance"
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
/>
))}
</div>
</div>
<div className="shell-ai-col">
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}>
{msg.content}
{msg.args && <div className="tool-args">{msg.args}</div>}
</div>
))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div>
<div className="ai-panel-input">
<input
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder={t('shell.askAi')}
/>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
</div>
</div>
{showSshModal && (
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
<div className="shell-modal" onClick={e => e.stopPropagation()}>
<div className="shell-modal-header">{t('shell.addConnection')}</div>
<div className="shell-modal-body">
<label className="shell-modal-label">{t('shell.connectionName')}</label>
<input
value={sshForm.name}
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
placeholder="prod-server"
/>
<label className="shell-modal-label">{t('shell.host')}</label>
<input
value={sshForm.host}
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
placeholder="192.168.1.100"
/>
<div className="shell-modal-row">
<div className="shell-modal-field">
<label className="shell-modal-label">{t('shell.port')}</label>
<input
type="number"
value={sshForm.port}
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
/>
</div>
<div className="shell-modal-field">
<label className="shell-modal-label">{t('shell.user')}</label>
<input
value={sshForm.user}
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
placeholder="root"
/>
</div>
</div>
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
<input
value={sshForm.key_path}
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
placeholder="~/.ssh/id_rsa"
/>
</div>
<div className="shell-modal-footer">
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,442 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useI18n } from '../i18n'
const RANKS = {
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
general: { label: 'General', short: 'GEN', color: '#FF9100' },
colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' },
lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' },
soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' },
}
function getRank(role) {
if (role === 'user') return RANKS.commandant
if (role === 'system') return null
return RANKS.general
}
function RankIcon({ rank }) {
if (rank === RANKS.commandant) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
</svg>
)
}
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
)
}
function renderContent(text) {
const parts = []
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
}
const full = match[1]
const firstNewline = full.indexOf('\n')
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
parts.push({ type: 'code', lang, content: code })
lastIndex = match.index + full.length
}
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) })
}
return parts
}
function formatText(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
}
function ThinkingBlock({ content, done }) {
return (
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
<div className="feed-thinking-header">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
<span>Reflexion</span>
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
</div>
<div className="feed-thinking-content">{content}</div>
</div>
)
}
const TOOL_ICONS = {
terminal: '⌨',
crush_run: '⚡',
read_file: '📄',
list_files: '📁',
search_files: '🔍',
grep_content: '🔎',
get_config: '⚙',
set_provider: '🔑',
manage_ssh: '🌐',
web_fetch: '🌐',
}
const TOOL_LABELS = {
terminal: 'Terminal',
crush_run: 'Crush Agent',
read_file: 'Read File',
list_files: 'List Files',
search_files: 'Search Files',
grep_content: 'Grep',
get_config: 'Config',
set_provider: 'Set Provider',
manage_ssh: 'SSH',
web_fetch: 'Web Fetch',
}
function ToolCallBlock({ call, result }) {
const icon = TOOL_ICONS[call.name] || '🔧'
const label = TOOL_LABELS[call.name] || call.name
const isErr = result && result.is_error
let argsPreview = ''
try {
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
if (args.command) argsPreview = args.command
else if (args.task) argsPreview = args.task
else if (args.path) argsPreview = args.path
else if (args.pattern) argsPreview = args.pattern
else if (args.url) argsPreview = args.url
else if (args.action) argsPreview = args.action
else argsPreview = JSON.stringify(args).slice(0, 80)
} catch {
argsPreview = String(call.args).slice(0, 80)
}
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
return (
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
<div className="studio-tool-header">
<span className="studio-tool-icon">{icon}</span>
<span className="studio-tool-name">{label}</span>
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
</div>
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
{truncatedResult && (
<div className="studio-tool-result">
<pre>{truncatedResult}</pre>
</div>
)}
</div>
)
}
function FeedItem({ msg }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const rank = getRank(msg.role)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
let parsedToolCalls = null
let displayContent = msg.content
try {
const parsed = JSON.parse(msg.content)
if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
displayContent = parsed.content || ''
}
} catch {}
if (isSystem) {
return (
<div className="feed-item system">
<div className="feed-system-badge" />
<div className="feed-system-text">{msg.content}</div>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
)
}
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return (
<div className={`feed-item ${msg.role}`}>
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
<RankIcon rank={rank} />
</div>
<div className="feed-body">
<div className="feed-header">
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
{rank.short}
</span>
<span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
{parsedToolCalls && parsedToolCalls.map((tc, i) => (
<ToolCallBlock key={tc.tool_call_id || i} call={tc} result={null} />
))}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
)}
</div>
</div>
)
}
function StreamingItem({ content, thinking, toolCalls }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
return (
<div className="feed-item assistant">
<div className="feed-avatar ai-rank">
<RankIcon rank={rank} />
</div>
<div className="feed-body">
<div className="feed-header">
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
{rank.short}
</span>
<span className="feed-role">{rank.label}</span>
</div>
{thinking && <ThinkingBlock content={thinking} done={false} />}
{hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))}
{!thinking && !cleanContent && !hasToolCalls && (
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
)}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" />
</div>
)}
</div>
</div>
)
}
export default function Studio({ api }) {
const { t } = useI18n()
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('')
const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false)
const messagesEnd = useRef(null)
const textareaRef = useRef(null)
useEffect(() => {
api.getChatHistory().then(data => {
if (data.messages && data.messages.length > 0) {
setMessages(data.messages)
} else {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
}
setLoaded(true)
}).catch(() => {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
setLoaded(true)
})
}, [])
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
}
}, [input])
const handleClear = useCallback(async () => {
try {
await api.clearChat()
setMessages([
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
])
} catch {}
}, [api, t])
const handleSend = useCallback(async () => {
if (!input.trim() || loading) return
const text = input.trim()
setInput('')
if (text === '/clear') {
handleClear()
return
}
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
setMessages(prev => [...prev, userMsg])
setLoading(true)
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
try {
let accumulated = ''
let thinking = ''
let toolCalls = []
await api.sendChat(text, true, (partial, event) => {
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
if (event.thinking !== undefined) {
thinking += event.thinking
setStreamThinking(thinking)
}
return
}
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls])
return
}
if (event && event.tool_result) {
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
if (idx >= 0) {
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
setStreamToolCalls([...toolCalls])
}
return
}
accumulated = partial
setStreaming(partial)
})
const finalContent = accumulated || t('studio.noResponse')
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}
if (thinking) aiMsg.thinking = thinking
if (toolCalls.length > 0) {
aiMsg.content = JSON.stringify({
content: finalContent,
tool_calls: toolCalls.map(tc => tc.call),
})
}
setMessages(prev => [...prev, aiMsg])
} catch (err) {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: `${t('studio.error')}: ${err.message}`,
time: new Date().toISOString(),
}])
} finally {
setLoading(false)
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
}
}, [input, loading, api, t, handleClear])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
if (!loaded) {
return (
<div className="studio-feed-layout">
<div className="studio-feed">
<div className="feed-loading">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
</div>
</div>
)
}
return (
<div className="studio-feed-layout">
<div className="studio-feed">
{messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
)}
<div ref={messagesEnd} />
</div>
<div className="studio-input-area">
<div className="studio-input-row">
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('studio.placeholderNew')}
disabled={loading}
rows={1}
/>
<button
className="studio-send-btn"
onClick={handleSend}
disabled={loading || !input.trim()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear
</div>
</div>
</div>
)
}

193
web/src/i18n/en.js Normal file
View File

@@ -0,0 +1,193 @@
const en = {
tabs: {
dashboard: 'Dashboard',
studio: 'Studio',
shell: 'Shell',
config: 'Config',
},
header: {
toolsInstalled: '{count} tools installed',
updatesAvailable: 'Updates available',
upToDate: 'Up to date',
},
statusbar: {
switchWindow: 'Switch window',
sendMessage: 'Send message',
newLine: 'New line',
runCommand: 'Run command',
commandHistory: 'Command history',
},
dashboard: {
systemOverview: 'System Overview',
tools: 'tools',
installed: 'Installed',
missing: 'Missing',
quickActions: 'Quick Actions',
installMissing: 'Install missing',
checkUpdates: 'Check for updates',
rescanSystem: 'Rescan system',
configureMCP: 'Configure MCP',
updates: 'Updates',
update: 'Update',
latest: 'Latest',
activityLog: 'Activity Log',
noUpdateData: 'No update data yet.',
installing: 'Installing {count} tools...',
installStarted: 'Install started. Rescanning...',
done: 'Done.',
scanComplete: 'Scan complete.',
updatesCount: '{count} updates available.',
allUpToDate: 'All tools up to date.',
mcpConfigured: 'MCP configured.',
},
studio: {
welcome: 'Welcome to Studio! Chat with your AI assistant here.',
welcomeNew: 'Welcome to Muyue Studio. I am your AI orchestrator. Describe your project and I will create a plan, propose agents, and track each step.',
configureHint: 'Configure agents and workflows from the sidebar.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Type a message... (Enter to send)',
placeholderNew: 'Describe your project or ask a question...',
send: 'Send',
commands: 'Commands',
planGoal: '/plan <goal>',
help: '/help',
activeAgents: 'Active Agents',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Stopped',
inactive: 'Inactive',
noWorkflow: 'No active workflow.',
usePlan: 'Use /plan <goal> in chat to start.',
noResponse: 'No response',
error: 'Error',
inputHint: 'Enter to send, Shift+Enter for new line',
context: 'Context',
plans: 'Plans',
activity: 'Activity',
noPlansYet: 'No plans detected. Ask the AI to create a plan.',
noAgentsYet: 'No agents mentioned.',
planDetail: 'Plan detail',
steps: 'steps',
you: 'You',
mentioned: 'mentioned',
cleared: 'Conversation cleared.',
},
shell: {
terminal: 'Terminal',
send: 'Send',
noResponse: 'No response',
error: 'Error',
newTab: 'New tab',
closeTab: 'Close tab',
maxTabsReached: 'Maximum 7 terminals reached',
renameTab: 'Rename',
local: 'Local',
ssh: 'SSH',
connections: 'Connections',
addConnection: 'Add SSH connection',
editConnection: 'Edit connection',
deleteConnection: 'Delete',
connectionName: 'Name',
host: 'Host',
port: 'Port',
user: 'User',
keyPath: 'SSH key path',
connect: 'Connect',
save: 'Save',
cancel: 'Cancel',
savedConnections: 'Saved connections',
noConnections: 'No saved SSH connections.',
systemTerminals: 'System terminals',
switchTerminal: 'Switch terminal',
localShell: 'Local Shell',
aiAssistant: 'AI Assistant',
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
askAi: 'Ask AI assistant...',
toolLaunched: 'Tool launched',
},
config: {
panels: {
profile: 'Profile',
providers: 'AI Providers',
terminal: 'Terminal',
updates: 'Updates',
locale: 'Language & Keyboard',
skills: 'Skills',
system: 'System',
},
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
email: 'Email',
editor: 'Editor',
shell: 'Shell',
defaultAi: 'Default AI',
languages: 'Languages',
loadingProfile: 'Loading profile...',
notSet: 'Not set',
aiProviders: 'AI Providers',
active: 'Active',
activate: 'Activate',
keyConfigured: 'Key configured',
noKey: 'No key',
apiKey: 'API Key',
model: 'Model',
baseUrl: 'Base URL',
save: 'Save',
saved: 'Saved!',
error: 'Error',
skills: 'Skills',
noSkills: 'No skills installed.',
runSkillsInit: 'Run muyue skills init',
language: 'Language',
keyboardLayout: 'Keyboard Layout',
target: 'Target',
updates: 'Updates',
systemUpdates: 'System Updates',
checkUpdates: 'Check for updates',
updateAll: 'Update all',
updateTool: 'Update',
checking: 'Checking...',
updating: 'Updating...',
upToDate: 'Up to date',
needsUpdate: 'Update available',
current: 'Current',
latest: 'Latest',
noUpdates: 'All tools are up to date.',
version: 'Version',
installed: 'Installed',
missing: 'Missing',
editProfile: 'Edit',
cancel: 'Cancel',
editProvider: 'Configure',
validateKey: 'Validate',
validating: 'Validating...',
keyValid: 'Valid key',
keyInvalid: 'Invalid key',
connectionFailed: 'Connection failed',
enterToken: 'Enter your API token for {provider}',
tokenPlaceholder: 'sk-...',
setupDescription: 'Configure your AI provider token to use the assistant.',
terminalTheme: 'Terminal Theme',
fontSize: 'Font Size',
fontFamily: 'Font Family',
preview: 'Preview',
saving: 'Saving...',
resetConfig: 'Reset all',
resetConfirm: 'Are you sure? All preferences will be erased.',
resetDone: 'Settings reset.',
applyStarship: 'Apply starship',
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
starshipError: 'Failed to apply starship theme.',
},
}
export default en

193
web/src/i18n/fr.js Normal file
View File

@@ -0,0 +1,193 @@
const fr = {
tabs: {
dashboard: 'Tableau de bord',
studio: 'Studio',
shell: 'Terminal',
config: 'Configuration',
},
header: {
toolsInstalled: '{count} outils install\u00e9s',
updatesAvailable: 'Mises \u00e0 jour disponibles',
upToDate: '\u00c0 jour',
},
statusbar: {
switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},
dashboard: {
systemOverview: 'Vue d\u2019ensemble du syst\u00e8me',
tools: 'outils',
installed: 'Install\u00e9',
missing: 'Manquant',
quickActions: 'Actions rapides',
installMissing: 'Installer les manquants',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
rescanSystem: 'Rescanner le syst\u00e8me',
configureMCP: 'Configurer MCP',
updates: 'Mises \u00e0 jour',
update: 'Mise \u00e0 jour',
latest: '\u00c0 jour',
activityLog: 'Journal d\u2019activit\u00e9',
noUpdateData: 'Aucune donn\u00e9e de mise \u00e0 jour.',
installing: 'Installation de {count} outils...',
installStarted: 'Installation lanc\u00e9e. Rescan en cours...',
done: 'Termin\u00e9.',
scanComplete: 'Scan termin\u00e9.',
updatesCount: '{count} mises \u00e0 jour disponibles.',
allUpToDate: 'Tous les outils sont \u00e0 jour.',
mcpConfigured: 'MCP configur\u00e9.',
},
studio: {
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
welcomeNew: 'Bienvenue dans Muyue Studio. Je suis votre orchestrateur IA. D\u00e9crivez votre projet et je cr\u00e9erai un plan, proposerai des agents, et suivrai chaque \u00e9tape.',
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
placeholderNew: 'D\u00e9crivez votre projet ou posez une question...',
send: 'Envoyer',
commands: 'Commandes',
planGoal: '/plan <objectif>',
help: '/help',
activeAgents: 'Agents actifs',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Arr\u00eat\u00e9',
inactive: 'Inactif',
noWorkflow: 'Aucun workflow actif.',
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
inputHint: 'Entr\u00e9e pour envoyer, Shift+Entr\u00e9e pour un retour \u00e0 la ligne',
context: 'Contexte',
plans: 'Plans',
activity: 'Activit\u00e9',
noPlansYet: 'Aucun plan d\u00e9tect\u00e9. Demandez \u00e0 l\u2019IA de cr\u00e9er un plan.',
noAgentsYet: 'Aucun agent mentionn\u00e9.',
planDetail: 'D\u00e9tail du plan',
steps: '\u00e9tapes',
you: 'Vous',
mentioned: 'mentionn\u00e9',
cleared: 'Conversation effac\u00e9e.',
},
shell: {
terminal: 'Terminal',
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
newTab: 'Nouvel onglet',
closeTab: 'Fermer l\u2019onglet',
maxTabsReached: 'Maximum 7 terminaux atteint',
renameTab: 'Renommer',
local: 'Local',
ssh: 'SSH',
connections: 'Connexions',
addConnection: 'Ajouter une connexion SSH',
editConnection: 'Modifier la connexion',
deleteConnection: 'Supprimer',
connectionName: 'Nom',
host: 'H\u00f4te',
port: 'Port',
user: 'Utilisateur',
keyPath: 'Chemin cl\u00e9 SSH',
connect: 'Se connecter',
save: 'Enregistrer',
cancel: 'Annuler',
savedConnections: 'Connexions enregistr\u00e9es',
noConnections: 'Aucune connexion SSH enregistr\u00e9e.',
systemTerminals: 'Terminaux syst\u00e8me',
switchTerminal: 'Changer de terminal',
localShell: 'Shell local',
aiAssistant: 'Assistant IA',
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
askAi: 'Interroger l\'assistant IA...',
toolLaunched: 'Outil lanc\u00e9',
},
config: {
panels: {
profile: 'Profil',
providers: 'Fournisseurs IA',
terminal: 'Terminal',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Comp\u00e9ENCES',
system: 'Syst\u00e8me',
},
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
email: 'Email',
editor: '\u00c9diteur',
shell: 'Shell',
defaultAi: 'IA par d\u00e9faut',
languages: 'Langages',
loadingProfile: 'Chargement du profil...',
notSet: 'Non d\u00e9fini',
aiProviders: 'Fournisseurs IA',
active: 'Actif',
activate: 'Activer',
keyConfigured: 'Cl\u00e9 configur\u00e9e',
noKey: 'Pas de cl\u00e9',
apiKey: 'Cl\u00e9 API',
model: 'Mod\u00e8le',
baseUrl: 'URL de base',
save: 'Enregistrer',
saved: 'Enregistr\u00e9 !',
error: 'Erreur',
skills: 'Comp\u00e9ENCES',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
keyboardLayout: 'Disposition du clavier',
target: 'Cible',
updates: 'Mises \u00e0 jour',
systemUpdates: 'Mises \u00e0 jour syst\u00e8me',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
updateAll: 'Tout mettre \u00e0 jour',
updateTool: 'Mettre \u00e0 jour',
checking: 'V\u00e9rification...',
updating: 'Mise \u00e0 jour...',
upToDate: '\u00c0 jour',
needsUpdate: 'Mise \u00e0 jour disponible',
current: 'Actuel',
latest: 'Dernier',
noUpdates: 'Tous les outils sont \u00e0 jour.',
version: 'Version',
installed: 'Install\u00e9',
missing: 'Manquant',
editProfile: 'Modifier',
editProvider: 'Configurer',
validateKey: 'Valider',
validating: 'V\u00e9rification...',
keyValid: 'Cl\u00e9 valide',
keyInvalid: 'Cl\u00e9 invalide',
connectionFailed: 'Connexion \u00e9chou\u00e9e',
enterToken: 'Entrez votre token API pour {provider}',
tokenPlaceholder: 'sk-...',
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
cancel: 'Annuler',
terminalTheme: 'Th\u00e8me du terminal',
fontSize: 'Taille de police',
fontFamily: 'Police',
preview: 'Aper\u00e7u',
saving: 'Enregistrement...',
resetConfig: 'R\u00e9initialiser',
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
applyStarship: 'Appliquer starship',
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
},
}
export default fr

101
web/src/i18n/index.jsx Normal file
View File

@@ -0,0 +1,101 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
import en from './en'
import fr from './fr'
import { getLayout, getLayoutList } from './keyboards'
import api from '../api/client'
const translations = { en, fr }
const STORAGE_KEY_LANG = 'muyue-language'
const STORAGE_KEY_KBD = 'muyue-keyboard'
const I18nContext = createContext(null)
function resolveLocale(layout) {
const l = getLayout(layout)
return l.locale
}
export function I18nProvider({ children }) {
const [language, setLanguageState] = useState(() => localStorage.getItem(STORAGE_KEY_LANG) || 'fr')
const [keyboard, setKeyboardState] = useState(() => localStorage.getItem(STORAGE_KEY_KBD) || 'azerty')
const [loaded, setLoaded] = useState(false)
const pendingSave = useRef(null)
useEffect(() => {
api.getConfig()
.then(d => {
const prefs = d.profile?.preferences
if (prefs?.language) setLanguageState(prefs.language)
if (prefs?.keyboard_layout) setKeyboardState(prefs.keyboard_layout)
})
.catch(() => {})
.finally(() => setLoaded(true))
}, [])
useEffect(() => {
if (!loaded) return
if (pendingSave.current) clearTimeout(pendingSave.current)
pendingSave.current = setTimeout(() => {
api.savePreferences({ language, keyboard_layout: keyboard }).catch(() => {})
}, 500)
return () => { if (pendingSave.current) clearTimeout(pendingSave.current) }
}, [language, keyboard, loaded])
const setLanguage = useCallback((lang) => {
setLanguageState(lang)
localStorage.setItem(STORAGE_KEY_LANG, lang)
}, [])
const setKeyboard = useCallback((kbd) => {
setKeyboardState(kbd)
localStorage.setItem(STORAGE_KEY_KBD, kbd)
}, [])
const layout = useMemo(() => getLayout(keyboard), [keyboard])
const t = useCallback((key, params) => {
const dict = translations[language] || translations.fr
const keys = key.split('.')
let value = dict
for (const k of keys) {
if (value == null) return key
value = value[k]
}
if (typeof value !== 'string') return key
if (params) {
return Object.entries(params).reduce((str, [k, v]) => str.replace(`{${k}}`, v), value)
}
return value
}, [language])
const clockLocale = useMemo(() => resolveLocale(keyboard), [keyboard])
const contextValue = useMemo(() => ({
language,
keyboard,
layout,
setLanguage,
setKeyboard,
t,
clockLocale,
layouts: getLayoutList(),
}), [language, keyboard, layout, t, clockLocale])
return (
<I18nContext.Provider value={contextValue}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
export const LANGUAGES = [
{ id: 'fr', name: 'Fran\u00e7ais' },
{ id: 'en', name: 'English' },
]

61
web/src/i18n/keyboards.js Normal file
View File

@@ -0,0 +1,61 @@
export const LAYOUTS = {
qwerty: {
id: 'qwerty',
name: 'QWERTY',
locale: 'en-US',
keys: {
tab1: '1',
tab2: '2',
tab3: '3',
tab4: '4',
ctrl: 'Ctrl',
enter: 'Enter',
shift: 'Shift',
up: '\u2191',
down: '\u2193',
range: '1-4',
},
},
azerty: {
id: 'azerty',
name: 'AZERTY',
locale: 'fr-FR',
keys: {
tab1: '&',
tab2: '\u00e9',
tab3: '"',
tab4: "'",
ctrl: 'Ctrl',
enter: 'Entr\u00e9e',
shift: 'Maj',
up: '\u2191',
down: '\u2193',
range: '&-\u00e9-"-\'',
},
},
qwertz: {
id: 'qwertz',
name: 'QWERTZ',
locale: 'de-DE',
keys: {
tab1: '1',
tab2: '2',
tab3: '3',
tab4: '4',
ctrl: 'Strg',
enter: 'Enter',
shift: 'Umschalt',
up: '\u2191',
down: '\u2193',
range: '1-4',
},
},
}
export function getLayout(id) {
return LAYOUTS[id] || LAYOUTS.azerty
}
export function getLayoutList() {
return Object.values(LAYOUTS)
}

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

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { I18nProvider } from './i18n'
import './styles/global.css'
import App from './components/App'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<I18nProvider>
<App />
</I18nProvider>
</React.StrictMode>
)

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

@@ -0,0 +1,768 @@
:root {
--bg: #0A0A0C;
--bg-base: #0F0D10;
--bg-surface: #161218;
--bg-elevated: #1C1719;
--bg-card: #221B1E;
--bg-input: #2A2225;
--bg-hover: #332528;
--accent: #FF0033;
--accent-dark: #8B0020;
--accent-deep: #5C0015;
--accent-light: #FF1A5E;
--accent-muted: #FF4D6D;
--accent-bright: #FF1744;
--accent-soft: #FF5252;
--accent-dim: #6B2033;
--accent-bg: #4A1525;
--text-primary: #EAE0E2;
--text-secondary: #D4C4C8;
--text-tertiary: #8A7A7E;
--text-disabled: #5A4F52;
--success: #00E676;
--warning: #FFD740;
--error: #FF1744;
--info: #448AFF;
--border: #2A1F22;
--border-accent: #FF003344;
--border-accent-full: #FF0033;
--radius-sm: 6px;
--radius: 8px;
--radius-lg: 12px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace;
--header-h: 52px;
--sidebar-w: 280px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { height: 100%; width: 100%; overflow: hidden; }
body {
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection { background: var(--accent); color: #fff; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--accent-dim); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }
a { color: var(--accent); text-decoration: none; cursor: pointer; }
a:hover { color: var(--accent-light); }
button {
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
padding: 8px 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
button:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--accent-dark); color: var(--text-primary); }
button:active:not(:disabled) { transform: scale(0.97); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
button.ghost { background: transparent; border-color: transparent; color: var(--text-tertiary); }
button.ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
button.sm { font-size: 12px; padding: 4px 10px; }
input, textarea {
font-family: var(--font-sans);
font-size: 14px;
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
width: 100%;
}
input:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
input::placeholder { color: var(--text-disabled); }
.app-layout { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.header {
height: var(--header-h);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
flex-shrink: 0;
}
.header-brand { display: flex; align-items: center; gap: 8px; }
.header-logo { font-family: var(--font-mono); font-weight: 900; font-size: 18px; color: var(--accent); letter-spacing: 3px; user-select: none; }
.header-version { font-size: 11px; color: var(--accent-dim); font-family: var(--font-mono); }
.header-nav { display: flex; gap: 4px; margin-left: 32px; }
.nav-tab {
padding: 8px 18px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 600;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.15s ease;
background: transparent;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.nav-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.nav-tab.active { color: #fff; background: var(--accent); }
.tab-icon { display: flex; align-items: center; }
.header-spacer { flex: 1; }
.header-indicators { display: flex; align-items: center; gap: 12px; }
.indicator { width: 8px; height: 8px; border-radius: 50%; transition: background 0.3s; }
.indicator.ok { background: var(--success); }
.indicator.warn { background: var(--warning); }
.indicator.error { background: var(--error); }
.indicator.off { background: var(--text-disabled); }
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
.content { flex: 1; overflow: hidden; }
.statusbar {
height: 28px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
flex-shrink: 0;
font-size: 11px;
color: var(--text-disabled);
}
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
.statusbar-shortcut kbd {
display: inline-block; padding: 1px 5px; border-radius: 3px;
background: var(--bg-card); border: 1px solid var(--border);
font-family: var(--font-mono); font-size: 10px; color: var(--text-tertiary);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
transition: border-color 0.2s;
}
.card:hover { border-color: var(--accent-dim); }
.card-header {
font-size: 12px;
font-weight: 700;
color: var(--accent);
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
}
.badge.ok { background: rgba(0,230,118,0.15); color: var(--success); }
.badge.error { background: rgba(255,23,68,0.15); color: var(--error); }
.badge.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
.badge.info { background: rgba(68,138,255,0.15); color: var(--info); }
.badge.neutral { background: var(--bg-hover); color: var(--text-tertiary); }
.badge.accent { background: var(--accent-bg); color: var(--accent); }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.chip:hover { border-color: var(--accent-dark); background: var(--bg-hover); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.progress { height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 3px; transition: width 0.4s ease; }
.tool-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.tool-row:last-child { border-bottom: none; }
.tool-name { flex: 1; color: var(--text-primary); font-weight: 500; }
.tool-version { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; height: 100%; padding: 20px; overflow: auto; }
.split-horizontal { display: flex; height: 100%; }
.split-right { width: var(--sidebar-w); border-left: 1px solid var(--border); background: var(--bg-surface); overflow: auto; padding: 16px; }
.chat-layout { display: flex; flex-direction: column; height: 100%; }
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.message {
max-width: 80%;
padding: 12px 16px;
border-radius: var(--radius-lg);
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
.message.user { align-self: flex-end; background: var(--accent-bg); color: var(--text-primary); border-bottom-right-radius: 4px; }
.message.ai { align-self: flex-start; background: var(--bg-card); color: var(--text-primary); border-bottom-left-radius: 4px; border-left: 3px solid var(--accent); }
.chat-input-bar { display: flex; gap: 8px; padding: 16px 20px; border-top: 1px solid var(--border); background: var(--bg-surface); }
.chat-input-bar input { flex: 1; }
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; margin-bottom: 20px; }
.sidebar-tab {
display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: var(--radius);
font-size: 13px; color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
}
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
.shell-layout { display: flex; height: 100%; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
.shell-tabs-bar {
display: flex; align-items: center; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
height: 36px; padding: 0 8px; gap: 4px;
}
.shell-tabs {
display: flex; align-items: center; gap: 2px; flex: 1; overflow-x: auto;
scrollbar-width: none;
}
.shell-tabs::-webkit-scrollbar { display: none; }
.shell-tab {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: var(--radius) var(--radius) 0 0;
font-size: 12px; font-weight: 500; color: var(--text-tertiary);
cursor: pointer; transition: all 0.15s; user-select: none;
border: 1px solid transparent; border-bottom: none;
white-space: nowrap; max-width: 180px; position: relative;
background: transparent;
}
.shell-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.shell-tab.active {
color: var(--text-primary); background: var(--bg);
border-color: var(--border); border-bottom-color: var(--bg);
margin-bottom: -1px;
}
.shell-tab-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 120px; font-size: 12px;
}
.shell-tab-index {
font-size: 9px; color: var(--text-disabled); font-family: var(--font-mono);
padding: 0 3px; background: var(--bg-input); border-radius: 3px; line-height: 1.4;
}
.shell-tab-close {
display: flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 3px; border: none;
background: transparent; color: var(--text-disabled); cursor: pointer;
padding: 0; transition: all 0.1s; flex-shrink: 0;
}
.shell-tab-close:hover { background: var(--accent-bg); color: var(--accent); }
.shell-tab-rename {
width: 80px; font-size: 12px; padding: 1px 4px; border-radius: 3px;
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--accent);
outline: none; font-family: var(--font-sans);
}
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.shell-new-tab-wrapper { position: relative; }
.shell-new-tab-btn {
display: flex; align-items: center; gap: 2px;
padding: 4px 8px; border-radius: var(--radius);
background: transparent; border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
font-size: 12px;
}
.shell-new-tab-btn:hover { color: var(--text-primary); background: var(--bg-card); border-color: var(--accent-dark); }
.shell-menu-overlay {
position: fixed; inset: 0; z-index: 998;
}
.shell-new-tab-menu {
position: absolute; top: 100%; right: 0; z-index: 999;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 6px;
min-width: 260px; max-height: 400px; overflow-y: auto;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.shell-menu-label {
font-size: 10px; font-weight: 700; color: var(--text-disabled);
text-transform: uppercase; letter-spacing: 0.5px;
padding: 6px 10px 4px;
}
.shell-menu-item {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 7px 10px; border-radius: var(--radius);
background: transparent; border: none; color: var(--text-secondary);
cursor: pointer; transition: all 0.1s; font-size: 12px;
text-align: left; font-family: var(--font-sans);
}
.shell-menu-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.shell-menu-item.accent { color: var(--accent); }
.shell-menu-item.accent:hover { background: var(--accent-bg); }
.shell-menu-item-sub {
font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono);
margin-left: auto;
}
.shell-menu-item-row { display: flex; align-items: center; }
.shell-menu-item-icon {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius);
background: transparent; border: none; color: var(--text-disabled);
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
}
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
.shell-menu-empty {
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
font-style: italic;
}
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance {
position: absolute; inset: 0; padding: 4px;
display: block !important;
}
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
.connection-dot.off { background: var(--error); }
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
.shell-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.shell-modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); min-width: 380px; max-width: 480px;
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
}
.shell-modal-header {
padding: 16px 20px; font-size: 14px; font-weight: 700;
color: var(--text-primary); border-bottom: 1px solid var(--border);
}
.shell-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
.shell-modal-label { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 2px; }
.shell-modal-row { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; }
.shell-modal-field { display: flex; flex-direction: column; }
.shell-modal-footer {
padding: 12px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 8px;
}
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.config-tabs-bar {
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
.config-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px 24px; margin-bottom: 16px;
}
.config-card-row {
display: flex; align-items: center; padding: 10px 0;
border-bottom: 1px solid var(--border); gap: 16px;
}
.config-card-row:last-of-type { border-bottom: none; }
.config-card-label { width: 130px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
.config-card-value { color: var(--text-primary); font-size: 14px; flex: 1; }
.config-card-value.mono { font-family: var(--font-mono); }
.config-card-value:not(.mono)[style*="—"] { color: var(--text-disabled); font-style: italic; }
.config-card-actions { display: flex; gap: 8px; padding-top: 16px; }
.config-form-field { margin-bottom: 14px; }
.config-form-label { display: block; font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.3px; }
.config-form-input {
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 12px; color: var(--text-primary);
font-size: 13px; font-family: var(--font-mono); outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.config-form-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
.config-card-group { margin-bottom: 20px; }
.config-card-group:last-child { margin-bottom: 0; }
.config-card-group-label { display: block; font-size: 11px; font-weight: 700; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.config-providers-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card-v2 {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 16px 20px; transition: border-color 0.2s;
}
.provider-card-v2:hover { border-color: var(--accent-dim); }
.provider-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.provider-card-identity { display: flex; align-items: center; gap: 10px; }
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.provider-setup-hint {
font-size: 13px; color: var(--text-tertiary); margin-bottom: 16px;
padding: 10px 14px; border-radius: var(--radius); background: var(--bg-surface);
border-left: 3px solid var(--accent-dim);
}
.provider-setup-token-row { display: flex; gap: 12px; align-items: flex-end; }
.provider-setup-token-input { flex: 1; }
.provider-setup-token-actions { display: flex; gap: 8px; flex-shrink: 0; padding-bottom: 1px; }
.config-update-controls {
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.config-update-stats { display: flex; gap: 8px; }
.config-update-buttons { display: flex; gap: 8px; }
.config-update-list { display: flex; flex-direction: column; gap: 2px; }
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-radius: var(--radius); background: var(--bg-card); border: 1px solid var(--border); margin-bottom: 6px; }
.config-update-row:hover { border-color: var(--accent-dim); }
.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.config-skill-row:last-child { border-bottom: none; }
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.config-toast {
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
font-size: 13px; font-weight: 600; z-index: 100; animation: fadeIn 0.2s ease-out;
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
}
.spin-icon { animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
.mono { font-family: var(--font-mono); }
.section-title { font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
.actions-stack { display: flex; flex-direction: column; gap: 6px; }
.agent-card { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--radius); background: var(--bg-card); margin-bottom: 6px; }
.agent-avatar {
width: 32px; height: 32px; border-radius: 50%; background: var(--accent-bg); color: var(--accent);
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0;
}
.agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; }
.agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-content { flex: 1; overflow-y: auto; }
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
.dashboard-section {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
}
.dashboard-section:hover { border-color: var(--accent-dim); }
.dashboard-section.full-width { grid-column: 1 / -1; }
.dashboard-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.dashboard-section-title {
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 0.5px;
}
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
.dashboard-notifications { padding: 0; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
}
.notif-row:hover { background: var(--bg-card); }
.notif-time { color: var(--text-disabled); font-size: 11px; font-family: var(--font-mono); flex-shrink: 0; padding-top: 1px; }
.notif-text { font-size: 13px; color: var(--text-secondary); }
.notif-info .notif-text { color: var(--info); }
.notif-ok .notif-text { color: var(--success); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
.panel-header {
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
border-bottom: 1px solid var(--border); background: var(--bg-surface);
}
.panel-title { font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
.panel-subtitle { font-weight: 400; font-size: 12px; color: var(--text-tertiary); margin-left: 8px; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; vertical-align: middle; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.fade-in { animation: fadeIn 0.2s ease-out; }
/* ── Studio Feed ── */
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
.feed-item:hover { background: var(--bg-card); }
.feed-item.user { background: var(--bg-card); border-left: 3px solid #FFD740; }
.feed-item.assistant { border-left: 3px solid transparent; }
.feed-item.assistant:hover { border-left-color: var(--accent-dark); }
.feed-item.system { align-items: center; gap: 8px; padding: 6px 12px; }
.feed-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; font-size: 14px; }
.feed-avatar.user-rank { background: rgba(255, 215, 64, 0.15); }
.feed-avatar.ai-rank { background: var(--accent-bg); }
.feed-rank-icon { display: flex; align-items: center; justify-content: center; }
.feed-body { flex: 1; min-width: 0; }
.feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
.feed-rank-badge {
font-size: 9px; font-weight: 800; font-family: var(--font-mono);
padding: 1px 6px; border-radius: 3px; border: 1px solid;
letter-spacing: 0.5px; text-transform: uppercase;
background: rgba(255, 215, 64, 0.08);
}
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
.feed-thinking-block {
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden;
transition: all 0.3s ease;
}
.feed-thinking-block.active {
border-left-color: var(--warning);
}
.feed-thinking-block.done {
border-left-color: var(--text-disabled);
opacity: 0.7;
}
.feed-thinking-block.done .feed-thinking-content {
max-height: 80px;
overflow-y: auto;
}
.feed-thinking-header {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px; font-size: 10px; font-weight: 700;
color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px;
background: var(--bg-card); border-bottom: 1px solid var(--border);
}
.feed-thinking-header svg { color: var(--warning); }
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
.feed-thinking-content {
padding: 8px 10px; font-size: 12px; color: var(--text-tertiary);
font-style: italic; line-height: 1.5; max-height: 120px; overflow-y: auto;
}
.studio-code-block {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; margin: 8px 0;
}
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
.studio-code-lang {
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
}
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
font-size: 14px; line-height: 1.5; border-radius: var(--radius);
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
font-family: var(--font-sans); outline: none; transition: border-color 0.2s, box-shadow 0.2s;
}
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
.studio-send-btn {
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
border-radius: var(--radius); background: var(--accent); color: #fff; border: 1px solid var(--accent);
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
}
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 3px solid var(--accent-dim);
border-radius: var(--radius);
margin: 6px 0;
overflow: hidden;
transition: all 0.3s ease;
}
.studio-tool-block.running {
border-left-color: var(--warning);
}
.studio-tool-block.error {
border-left-color: var(--error);
background: rgba(255, 23, 68, 0.05);
}
.studio-tool-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.studio-tool-icon {
font-size: 14px;
flex-shrink: 0;
}
.studio-tool-name {
color: var(--text-tertiary);
font-weight: 600;
font-family: var(--font-mono);
font-size: 12px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.studio-tool-spinner {
display: inline-flex;
gap: 2px;
margin-left: 4px;
}
.studio-tool-spinner span {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--warning);
animation: bounce 1.2s ease-in-out infinite;
}
.studio-tool-spinner span:nth-child(2) { animation-delay: 0.15s; }
.studio-tool-spinner span:nth-child(3) { animation-delay: 0.3s; }
.studio-tool-status {
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.studio-tool-status.ok { color: var(--success); }
.studio-tool-status.error { color: var(--error); }
.studio-tool-args {
padding: 6px 10px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
}
.studio-tool-result {
max-height: 200px;
overflow-y: auto;
}
.studio-tool-result pre {
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
background: var(--bg);
}

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

@@ -0,0 +1,129 @@
const defaultTheme = {
name: 'Cyberpunk Red',
colors: {
bg: '#0A0A0C',
bgBase: '#0F0D10',
bgSurface: '#161218',
bgElevated: '#1C1719',
bgCard: '#221B1E',
bgInput: '#2A2225',
bgHover: '#332528',
accent: '#FF0033',
accentDark: '#8B0020',
accentDeep: '#5C0015',
accentLight: '#FF1A5E',
accentMuted: '#FF4D6D',
accentBright: '#FF1744',
accentSoft: '#FF5252',
accentDim: '#6B2033',
accentBg: '#4A1525',
textPrimary: '#EAE0E2',
textSecondary: '#D4C4C8',
textTertiary: '#8A7A7E',
textDisabled: '#5A4F52',
success: '#00E676',
warning: '#FFD740',
error: '#FF1744',
info: '#448AFF',
border: '#2A1F22',
borderAccent: '#FF003344',
borderAccentFull: '#FF0033',
},
}
const themes = {
'cyberpunk-red': defaultTheme,
'cyberpunk-pink': {
...defaultTheme,
name: 'Cyberpunk Pink',
colors: {
...defaultTheme.colors,
accent: '#FF1A8C',
accentDark: '#8B1050',
accentDeep: '#5C0A35',
accentLight: '#FF4DAE',
accentMuted: '#FF6DC2',
accentBright: '#FF1A8C',
accentSoft: '#FF6DC2',
accentDim: '#6B2050',
accentBg: '#4A1535',
},
},
'midnight-blue': {
...defaultTheme,
name: 'Midnight Blue',
colors: {
...defaultTheme.colors,
accent: '#0088FF',
accentDark: '#004488',
accentDeep: '#002255',
accentLight: '#00AAFF',
accentMuted: '#44CCFF',
accentBright: '#0088FF',
accentSoft: '#44CCFF',
accentDim: '#203366',
accentBg: '#152244',
},
},
'matrix-green': {
...defaultTheme,
name: 'Matrix Green',
colors: {
...defaultTheme.colors,
accent: '#00FF41',
accentDark: '#008822',
accentDeep: '#005515',
accentLight: '#33FF66',
accentMuted: '#66FF99',
accentBright: '#00FF41',
accentSoft: '#66FF99',
accentDim: '#206630',
accentBg: '#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': c.bg,
'--bg-base': c.bgBase,
'--bg-surface': c.bgSurface,
'--bg-elevated': c.bgElevated,
'--bg-card': c.bgCard,
'--bg-input': c.bgInput,
'--bg-hover': c.bgHover,
'--accent': c.accent,
'--accent-dark': c.accentDark,
'--accent-deep': c.accentDeep,
'--accent-light': c.accentLight,
'--accent-muted': c.accentMuted,
'--accent-bright': c.accentBright,
'--accent-soft': c.accentSoft,
'--accent-dim': c.accentDim,
'--accent-bg': c.accentBg,
'--text-primary': c.textPrimary,
'--text-secondary': c.textSecondary,
'--text-tertiary': c.textTertiary,
'--text-disabled': c.textDisabled,
'--success': c.success,
'--warning': c.warning,
'--error': c.error,
'--info': c.info,
'--border': c.border,
'--border-accent': c.borderAccent,
'--border-accent-full': c.borderAccentFull,
}
Object.entries(map).forEach(([k, v]) => root.style.setProperty(k, v))
}
export default defaultTheme

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

@@ -0,0 +1,20 @@
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,
ws: true,
},
},
},
})