Compare commits

...

12 Commits

Author SHA1 Message Date
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
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
37 changed files with 2451 additions and 1717 deletions

View File

@@ -15,7 +15,7 @@ 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

View File

@@ -15,7 +15,7 @@ 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

View File

@@ -13,7 +13,7 @@ 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

View File

@@ -6,8 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [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.

View File

@@ -3,103 +3,15 @@ package main
import (
"fmt"
"os"
"os/exec"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/profiler"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
)
func main() {
if len(os.Args) > 1 {
if isCommand(os.Args[1]) {
handleCommand(os.Args[1:])
return
}
}
runDesktop(os.Args[1:])
}
func isCommand(arg string) bool {
switch arg {
case "version", "-v", "--version",
"scan", "install", "update", "setup",
"config", "doctor", "lsp", "mcp", "skills",
"help", "-h", "--help":
return true
}
return false
}
func handleCommand(args []string) {
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()
}
}
func printHelp() {
fmt.Printf(`%s - AI-powered development environment assistant
Usage:
muyue Launch desktop app (opens browser)
muyue <command> Run a specific command
Options:
--port=PORT Specify port (default: auto)
--no-open Don't open browser automatically
Commands:
version Show version
scan Scan your system for tools and runtimes
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
Note:
Some tools (docker, gh, etc.) require elevated privileges.
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
`, version.FullVersion())
}
func runDesktop(args []string) {
cfg := loadOrSetupConfig()
if err := desktop.Run(cfg, args); err != nil {
if err := desktop.Run(cfg, os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
@@ -140,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")
}
}

4
go.mod
View File

@@ -1,6 +1,8 @@
module github.com/muyue/muyue
go 1.24.3
go 1.24.2
toolchain go1.24.3
require (
github.com/charmbracelet/huh v1.0.0

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

@@ -1,466 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"os/exec"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
)
func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, msg string, code int) {
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"name": version.Name,
"version": version.Version,
"author": version.Author,
})
}
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
writeJSON(w, map[string]interface{}{
"system": s.scanResult.System,
})
}
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
type toolInfo struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Version string `json:"version"`
Path string `json:"path"`
}
tools := make([]toolInfo, len(s.scanResult.Tools))
for i, t := range s.scanResult.Tools {
tools[i] = toolInfo{
Name: t.Name,
Installed: t.Installed,
Version: t.Version,
Path: t.Path,
}
}
writeJSON(w, map[string]interface{}{
"tools": tools,
"total": len(tools),
})
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"profile": s.config.Profile,
"terminal": s.config.Terminal,
"bmad": s.config.BMAD,
})
}
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"providers": s.config.AI.Providers,
})
}
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
list, err := skills.List()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"skills": list,
"count": len(list),
})
}
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
servers := lsp.ScanServers()
writeJSON(w, map[string]interface{}{
"servers": servers,
})
}
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
servers := mcp.ScanServers()
writeJSON(w, map[string]interface{}{
"servers": servers,
"configured": true,
})
}
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
if err := mcp.ConfigureAll(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
type updateInfo struct {
Tool string `json:"tool"`
Current string `json:"current"`
Latest string `json:"latest"`
NeedsUpdate bool `json:"needsUpdate"`
Error string `json:"error,omitempty"`
}
updates := make([]updateInfo, len(statuses))
for i, u := range statuses {
updates[i] = updateInfo{
Tool: u.Tool,
Current: u.Current,
Latest: u.Latest,
NeedsUpdate: u.NeedsUpdate,
Error: u.Error,
}
}
writeJSON(w, map[string]interface{}{
"updates": updates,
})
}
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Tools []string `json:"tools"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if len(body.Tools) == 0 {
writeError(w, "no tools specified", http.StatusBadRequest)
return
}
writeJSON(w, map[string]string{"status": "installing"})
}
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
s.scanResult = scanner.ScanSystem()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) 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) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Command string `json:"command"`
Cwd string `json:"cwd"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Command == "" {
writeError(w, "no command", http.StatusBadRequest)
return
}
shell := "/bin/sh"
if s, err := exec.LookPath("bash"); err == nil {
shell = s
}
cmd := exec.Command(shell, "-c", body.Command)
if body.Cwd != "" {
cmd.Dir = body.Cwd
}
out, err := cmd.CombinedOutput()
type termResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
result := termResult{Output: string(out)}
if err != nil {
result.Error = err.Error()
}
writeJSON(w, result)
}
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.StatusBadRequest)
return
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(`You are Muyue Studio's AI orchestrator. You help the user with software development tasks. You can:
- Create and manage development plans with step-by-step workflows
- Propose agents (tools like Crush, Claude Code, etc.) to execute specific tasks
- Track progress across multi-step tasks
- Suggest file modifications, code reviews, and architecture decisions
Be concise, actionable, and structured. When proposing a plan, use clear numbered steps. When referencing files, use relative paths. You are embedded in the Muyue desktop app.`)
if body.Stream {
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)
chunkSize := 8
result, err := orb.Send(body.Message)
if err != nil {
data, _ := json.Marshal(map[string]string{"error": err.Error()})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
runes := []rune(result)
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
chunk := string(runes[i:end])
data, _ := json.Marshal(map[string]string{"content": chunk})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
}
data, _ := json.Marshal(map[string]string{"done": "true"})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
result, err := orb.Send(body.Message)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"content": result})
}
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) 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"`
}
json.NewDecoder(r.Body).Decode(&body)
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,236 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os/exec"
"regexp"
"strings"
"github.com/muyue/muyue/internal/orchestrator"
)
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
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.StatusBadRequest)
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(`Tu es l'assistant IA de Muyue Studio. Tu as accès à un outil "crush" pour exécuter des tâches complexes sur l'ordinateur de l'utilisateur.
RÈGLES ABSOLUES:
1. Tu as DEUX possibilités ONLY:
- Répondre directement à l'utilisateur avec tes connaissances
- Demander l'exécution d'une tâche via crush en utilisant ce format EXACT:
[TOOL_CALL:{"tool":"crush","task":"description de la tâche"}]
2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat.
Tu peux ensuite répondre à l'utilisateur avec ce résultat.
3. SOIS CONCIS - pas de blabla, vais droit au but.
4. L'utilisateur ne voit PAS tes pensées entre <think> tags.
5. EXEMPLES d'utilisation de tool:
- "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}]
- "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool
- "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}]
6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`)
if body.Stream {
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)
result, err := orb.SendStream(body.Message, func(chunk string) {
if strings.HasPrefix(chunk, "<think") {
data, _ := json.Marshal(map[string]string{"thinking": strings.TrimPrefix(chunk, "<think")})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
if chunk == "</think>" {
data, _ := json.Marshal(map[string]string{"thinking_end": "true"})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
data, _ := json.Marshal(map[string]string{"content": chunk})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
})
if err != nil {
data, _ := json.Marshal(map[string]string{"error": err.Error()})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
// Process tool calls if any
cleanResult := processToolCalls(result)
s.convStore.Add("assistant", cleanResult)
data, _ := json.Marshal(map[string]string{"done": "true"})
w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush {
flusher.Flush()
}
return
}
result, err := orb.Send(body.Message)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
cleanResult := processToolCalls(result)
s.convStore.Add("assistant", cleanResult)
writeJSON(w, map[string]string{"content": cleanResult})
}
func processToolCalls(content string) string {
matches := toolCallRegex.FindAllString(content, -1)
if len(matches) == 0 {
return cleanThinkingTags(content)
}
var result strings.Builder
clean := content
for _, match := range matches {
// Extract tool and task from [TOOL_CALL:{...}]
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
inner = strings.TrimSuffix(inner, "]}") + "}"
var call struct {
Tool string `json:"tool"`
Task string `json:"task"`
}
if err := json.Unmarshal([]byte(inner), &call); err != nil {
continue
}
if call.Tool == "crush" && call.Task != "" {
result.WriteString(fmt.Sprintf("> %s\n\n", call.Task))
output := executeCrush(call.Task)
result.WriteString(output)
result.WriteString("\n\n---\n\n")
}
clean = strings.Replace(clean, match, "", 1)
}
clean = cleanThinkingTags(clean)
if result.Len() > 0 {
clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String())
}
return clean
}
func cleanThinkingTags(content string) string {
re := regexp.MustCompile(`(?s)<think[^>]*>.*?</think>`)
return re.ReplaceAllString(content, "")
}
func executeCrush(task string) string {
cmd := exec.Command("crush", "run", task)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("Erreur: %v\n%s", err, string(output))
}
return string(output)
}
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,283 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"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})
}

View File

@@ -0,0 +1,119 @@
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"})
}

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()
}

View File

@@ -12,6 +12,7 @@ type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -20,6 +21,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
mux: http.NewServeMux(),
}
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.routes()
return s
}
@@ -41,10 +43,16 @@ func (s *Server) routes() {
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/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) {

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
@@ -18,7 +19,23 @@ import (
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
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 {
@@ -82,14 +99,33 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
shell := initMsg.Data
if shell == "" {
shell = detectShell()
} else {
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
}
if strings.Contains(shell, "wsl") {
cmd = exec.Command("wsl", "--shell-type", "login")
} else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
// Ignore invalid shell paths (e.g., single characters from race condition)
if len(shell) <= 1 {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid shell config"})
return
}
if _, err := os.Stat(shell); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)})
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")
} else {
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
}
}
@@ -223,6 +259,10 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
}
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)
@@ -250,8 +290,8 @@ func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Req
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if _, err := exec.LookPath(s); err == nil {
return s
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"

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"
)
@@ -66,9 +68,73 @@ type MuyueConfig struct {
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 {
@@ -79,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)
}
}
}
@@ -179,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",

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

@@ -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 -",
@@ -291,15 +292,15 @@ func (i *Installer) installGit() InstallResult {
}
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")
}
}
@@ -340,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

@@ -101,5 +101,3 @@ func InstallForLanguages(languages []string) []LSPServer {
return results
}

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"
@@ -34,6 +35,9 @@ type ChatResponse struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage struct {
TotalTokens int `json:"total_tokens"`
@@ -161,6 +165,104 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
return content, nil
}
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: messages,
Stream: true,
}
o.histMu.Unlock()
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
baseURL := o.provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(o.provider.Name)
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
resp, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
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 err := scanner.Err(); err != nil {
return fullContent.String(), fmt.Errorf("read stream: %w", err)
}
content := cleanAIResponse(fullContent.String())
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
Content: content,
})
o.histMu.Unlock()
return content, nil
}
func cleanAIResponse(content string) string {
content = thinkRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n")

View File

@@ -1,6 +1,9 @@
package orchestrator
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -14,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",
},
@@ -24,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",
},
@@ -49,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",
},
@@ -146,3 +149,128 @@ func TestNewNoAPIKey(t *testing.T) {
t.Error("Should fail with no API key")
}
}
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
}
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)
}
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

@@ -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

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
@@ -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
}

View File

@@ -2,7 +2,7 @@ package version
const (
Name = "muyue"
Version = "0.3.0"
Version = "0.3.1"
Author = "La Légion de Muyue"
)

View File

@@ -2,8 +2,8 @@ const API_BASE = '/api'
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
@@ -28,12 +28,17 @@ const api = {
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) }),
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' }),
sendChat: (message, stream = true) => {
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 }) })
}
@@ -61,7 +66,12 @@ const api = {
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 (data.content) {
full += data.content
if (onChunk) onChunk(full, data)
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data)
}
} catch {}
}
}

View File

@@ -14,6 +14,7 @@ export default function App() {
const [clock, setClock] = useState(new Date())
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null)
const { t, layout } = useI18n()
const TABS = useMemo(() => [
@@ -27,7 +28,13 @@ export default function App() {
api.getInfo().then(setInfo).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
applyTheme(getTheme('cyberpunk-red'))
api.getConfig().then(d => {
setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
applyTheme(getTheme(theme))
}).catch(() => {
applyTheme(getTheme('cyberpunk-red'))
})
}, [])
useEffect(() => {
@@ -57,7 +64,7 @@ export default function App() {
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(t => t.installed).length
const installed = tools.filter(tool => tool.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [
@@ -80,10 +87,10 @@ export default function App() {
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} />
case 'dash': return <Dashboard api={api} />
case 'studio': return <Studio api={api} />
case 'shell': return <Shell api={api} />
case 'config': return <Config api={api} onThemeChange={() => {}} />
case 'config': return <Config api={api} />
default: return null
}
}

View File

@@ -1,9 +1,20 @@
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: 'terminal', icon: Monitor },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench },
]
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([])
@@ -16,6 +27,9 @@ export default function Config({ api }) {
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({})
const [toast, setToast] = useState(null)
const [terminalThemes, setTerminalThemes] = useState([])
const [terminalSettings, setTerminalSettings] = useState({ font_size: 14, font_family: '', theme: 'default' })
const [savingTerminal, setSavingTerminal] = useState(false)
const layouts = getLayoutList()
@@ -29,11 +43,19 @@ export default function Config({ api }) {
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
if (d.terminal) {
setTerminalSettings({
font_size: d.terminal.font_size || 14,
font_family: d.terminal.font_family || '',
theme: d.terminal.theme || 'default',
})
}
}).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.getTerminalThemes().then(d => setTerminalThemes(d.themes || [])).catch(() => {})
}, [api])
useEffect(() => { loadData() }, [loadData])
@@ -104,6 +126,18 @@ export default function Config({ api }) {
}
}
const handleSaveTerminalSettings = async () => {
setSavingTerminal(true)
try {
await api.saveTerminalSettings(terminalSettings)
showToast(t('config.saved'))
window.location.reload()
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setSavingTerminal(false)
}
const openProviderEdit = (p) => {
setProviderForm({
name: p.name,
@@ -115,136 +149,276 @@ export default function Config({ api }) {
}
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
const installedCount = tools.filter(t => t.installed).length
const missingCount = tools.filter(t => !t.installed).length
const installedCount = tools.filter(tool => tool.installed).length
const missingCount = tools.filter(tool => !tool.installed).length
return (
<div className="config-layout">
<div className="config-window">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-section">
<div className="config-section-title">{t('config.systemUpdates')}</div>
<div className="config-actions-row">
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? 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 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 === 'terminal' && (
<PanelTerminal
settings={terminalSettings} setSettings={setTerminalSettings}
themes={terminalThemes} saving={savingTerminal}
onSave={handleSaveTerminalSettings} t={t}
/>
)}
</div>
<div className="config-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>
{updates.length === 0 ? (
<div className="empty-state">{t('config.noUpdates')}</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>
)}
</div>
</div>
)
}
<div className="config-section">
<div className="config-section-title">
{t('config.profile')}
<button className="ghost sm" onClick={() => setEditProfile(!editProfile)}>
{editProfile ? t('config.cancel') : t('config.editProfile')}
</button>
</div>
{config?.profile && !editProfile ? (
<div>
<FieldRow label={t('config.name')} value={config.profile.name} />
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
<FieldRow label={t('config.email')} value={config.profile.email} />
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
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>
) : editProfile ? (
<div className="config-form">
<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 }))} />
<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-form-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
<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>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.aiProviders')}</div>
{providers.map((p, i) => (
<div key={i} className="provider-card">
<div className="provider-info">
<div className="provider-name">
{p.name}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div>
{editProvider !== p.name ? (
<div className="provider-meta">
<span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
{!p.active && (
<button className="sm" onClick={async () => {
await api.saveProvider({ name: p.name, active: true })
loadData()
}}>{t('config.activate')}</button>
<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.api_key : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)
setProviderForm(f => ({ ...f, api_key: e.target.value }))
}}
/>
</div>
<div className="provider-setup-token-actions">
<button
className="sm primary"
disabled={validating === p.name || !providerForm.api_key}
onClick={() => handleValidate(p.name, providerForm.api_key, providerForm.model, providerForm.base_url)}
>
{validating === p.name ? t('config.validating') : t('config.validateKey')}
</button>
{isValidationTarget && validationStatus?.valid && (
<button className="sm" onClick={handleSaveProvider}>{t('config.save')}</button>
)}
</div>
) : (
<div className="config-form" style={{ marginTop: 8 }}>
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
<div className="config-form-actions">
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
</div>
</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>
<div className="config-section">
<div className="config-section-title">{t('config.language')}</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
@@ -257,9 +431,8 @@ export default function Config({ api }) {
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="config-card-group">
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
<div className="chip-row">
{layouts.map(l => (
<div
@@ -272,43 +445,133 @@ export default function Config({ api }) {
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
{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>
</div>
)
}
function FieldRow({ label, value }) {
function PanelSkills({ skillList, t }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || '—'}</span>
<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 PanelTerminal({ settings, setSettings, themes, saving, onSave, t }) {
const previewTheme = {
background: settings.theme === 'default' ? '#0A0A0C' :
settings.theme === 'monokai' ? '#272822' :
settings.theme === 'gruvbox' ? '#282828' :
settings.theme === 'nord' ? '#2E3440' :
settings.theme === 'solarized-dark' ? '#002B36' :
settings.theme === 'dracula' ? '#282A36' : '#0A0A0C',
foreground: settings.theme === 'default' ? '#EAE0E2' :
settings.theme === 'monokai' ? '#F8F8F2' :
settings.theme === 'gruvbox' ? '#EBDBB2' :
settings.theme === 'nord' ? '#D8DEE9' :
settings.theme === 'solarized-dark' ? '#839496' :
settings.theme === 'dracula' ? '#F8F8F2' : '#EAE0E2',
}
return (
<div className="config-card">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.terminalTheme')}</span>
<div className="chip-row">
{themes.map(th => (
<div
key={th.id}
className={`chip ${settings.theme === th.id ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, theme: th.id }))}
>
{th.name}
</div>
))}
</div>
</div>
<div className="config-card-group">
<span className="config-card-group-label">{t('config.fontSize')}</span>
<div className="chip-row">
{[12, 14, 16, 18, 20, 24].map(size => (
<div
key={size}
className={`chip ${settings.font_size === size ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, font_size: size }))}
>
{size}px
</div>
))}
</div>
</div>
<div className="config-card-group">
<span className="config-card-group-label">{t('config.fontFamily')}</span>
<select
className="config-form-input"
value={settings.font_family}
onChange={e => setSettings(s => ({ ...s, font_family: e.target.value }))}
style={{ maxWidth: 300 }}
>
<option value="">Default (JetBrains Mono)</option>
<option value="'Fira Code', monospace">Fira Code</option>
<option value="'Cascadia Code', 'SF Mono', monospace">Cascadia Code</option>
<option value="'SF Mono', 'Menlo', monospace">SF Mono</option>
<option value="'Source Code Pro', monospace">Source Code Pro</option>
<option value="monospace">System Monospace</option>
</select>
</div>
<div className="config-card-group">
<span className="config-card-group-label">{t('config.preview')}</span>
<div style={{
background: previewTheme.background,
color: previewTheme.foreground,
padding: '16px 20px',
borderRadius: 'var(--radius)',
fontFamily: settings.font_family || "'JetBrains Mono', monospace",
fontSize: settings.font_size || 14,
border: '1px solid var(--border)',
}}>
<span style={{ color: '#00E676' }}></span> <span>~/projects</span>
<span style={{ color: '#448AFF' }}> git status</span>
<br />
<span>On branch </span>
<span style={{ color: '#FFD740' }}>main</span>
<br />
<span style={{ opacity: 0.6 }}>Type a command...</span>
<span style={{ animation: 'blink 1s step-end infinite' }}> </span>
</div>
</div>
<div className="config-card-actions" style={{ marginTop: 16 }}>
<button className="primary sm" onClick={onSave} disabled={saving}>
{saving ? t('config.saving') : t('config.save')}
</button>
</div>
</div>
)
}
function FormInput({ label, value, onChange, type = 'text' }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<div className="config-form-field">
<label className="config-form-label">{label}</label>
<input
className="config-input"
className="config-form-input"
type={type}
value={value}
onChange={e => onChange(e.target.value)}

View File

@@ -1,50 +1,14 @@
import { useState } from 'react'
import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) {
const { t, layout } = useI18n()
export default function Dashboard({ api }) {
const { t } = useI18n()
const [notifications, setNotifications] = useState([])
const installed = tools.filter(tool => tool.installed).length
const total = tools.length
const addNotif = (text, type) => {
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
}
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('dashboard.systemOverview')}</div>
{total > 0 && (
<span className="badge info">{installed}/{total}</span>
)}
</div>
{tools.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
<div className="tools-compact">
{tools.map((tool, i) => {
const name = tool.name || tool.Name
const ver = extractVersion(tool.Version || tool.version)
return (
<div key={i} className="tool-compact-row">
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
{tool.installed ? '\u2713' : '\u2717'}
</span>
<span className="tool-compact-name">{name}</span>
{ver && <span className="tool-compact-ver">{ver}</span>}
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
</div>
)
})}
</div>
)}
</div>
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('studio.workflows')}</div>
@@ -79,7 +43,7 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
{notifications.map(n => (
<div key={n.id} className={`notif-row notif-${n.type}`}>
<span className="notif-time">
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
{n.time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="notif-text">{n.text}</span>
</div>
@@ -92,9 +56,3 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
</div>
)
}
function extractVersion(s) {
if (!s) return ''
const m = s.match(/\d+\.\d+\.\d+/)
return m ? m[0] : s.slice(0, 12)
}

View File

@@ -8,37 +8,74 @@ import { useI18n } from '../i18n'
const MAX_TABS = 7
const XTERM_THEME = {
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',
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 createTerminal(container) {
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: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: XTERM_THEME,
fontSize: settings.fontSize || 14,
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme,
allowTransparency: false,
scrollback: 5000,
})
@@ -116,6 +153,11 @@ export default function Shell({ api }) {
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: '',
@@ -137,6 +179,15 @@ export default function Shell({ api }) {
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) => {
@@ -145,7 +196,11 @@ export default function Shell({ api }) {
const container = document.getElementById(`terminal-${tabId}`)
if (!container) return
const { term, fitAddon } = createTerminal(container)
const { term, fitAddon } = createTerminal(container, {
fontSize: terminalSettings.fontSize,
fontFamily: terminalSettings.fontFamily,
theme: terminalSettings.theme,
})
let initPayload
if (tab.type === 'ssh') {
@@ -195,15 +250,25 @@ export default function Shell({ api }) {
useEffect(() => {
const tab = tabs.find(t => t.id === activeTab)
if (tab && !tabsRef.current[tab.id]) {
const timer = setTimeout(() => initTerminal(tab.id, tab), 50)
return () => clearTimeout(timer)
} else if (tab && tabsRef.current[tab.id]) {
if (!tab) return
const container = document.getElementById(`terminal-${tab.id}`)
if (!container) return
if (!tabsRef.current[tab.id]) {
const timer = setTimeout(() => {
const { fitAddon } = tabsRef.current[tab.id]
fitAddon.fit()
}, 50)
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])
@@ -264,7 +329,7 @@ export default function Shell({ api }) {
setTabs(prev => {
const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId) {
if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id)
}
return next
@@ -315,13 +380,61 @@ export default function Shell({ api }) {
setAiLoading(true)
try {
const res = await api.runCommand(`echo "AI: ${text}"`, '')
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
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">
@@ -442,6 +555,7 @@ export default function Shell({ api }) {
{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>}

View File

@@ -1,54 +1,37 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useI18n } from '../i18n'
const MSG_ID = () => Math.random().toString(36).slice(2, 10)
function parsePlanBlocks(text) {
const plans = []
const regex = /(?:^|\n)(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN[^\]]*\]?:?\s*(.*?)(?=\n(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN|\n## |\n### |\n$)/gis
const matches = text.matchAll(regex)
for (const m of matches) {
plans.push({ id: MSG_ID(), title: m[1].trim(), content: m[0].trim() })
}
if (plans.length === 0 && /plan|workflow/i.test(text)) {
const lines = text.split('\n').filter(l => /^\s*[-*]\s|^\s*\d+\.\s/.test(l))
if (lines.length > 0) {
plans.push({ id: MSG_ID(), title: text.split('\n')[0].slice(0, 80), content: text.trim() })
}
}
return plans
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 parseAgentMentions(text) {
const agents = new Set()
const names = ['crush', 'claude', 'claude code', 'ollama', 'copilot', 'cursor', 'agent']
for (const name of names) {
if (new RegExp('\\b' + name + '\\b', 'i').test(text)) {
agents.add(name)
}
}
return [...agents]
function getRank(role) {
if (role === 'user') return RANKS.commandant
if (role === 'system') return null
return RANKS.general
}
function parseSteps(text) {
const steps = []
const lines = text.split('\n')
for (const line of lines) {
const match = line.match(/^\s*(\d+)[.)]\s+(.+)/)
if (match) {
steps.push({ num: match[1], text: match[2].trim() })
}
const bulletMatch = line.match(/^\s*[-*]\s+(.+)/)
if (bulletMatch) {
steps.push({ num: String(steps.length + 1), text: bulletMatch[1].trim() })
}
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 steps
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 = []
let i = 0
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
@@ -70,7 +53,7 @@ function renderContent(text) {
}
function formatText(text) {
let html = 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>')
@@ -78,188 +61,108 @@ function formatText(text) {
.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>')
return html
}
function MessageBubble({ msg }) {
const { t } = useI18n()
const [expanded, setExpanded] = useState(null)
const plans = msg.role === 'ai' ? parsePlanBlocks(msg.content) : []
const steps = msg.role === 'ai' ? parseSteps(msg.content) : []
const agents = msg.role === 'ai' ? parseAgentMentions(msg.content) : []
function ThinkingBlock({ content, done }) {
return (
<div className={`studio-msg ${msg.role}`}>
{msg.role === 'ai' && (
<div className="studio-msg-avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</div>
)}
<div className="studio-msg-body">
<div className="studio-msg-content">
{renderContent(msg.content).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>
{msg.role === 'ai' && (plans.length > 0 || agents.length > 0) && (
<div className="studio-msg-meta">
{plans.map(plan => (
<div key={plan.id} className="studio-plan-chip" onClick={() => setExpanded(expanded === plan.id ? null : plan.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{plan.title.slice(0, 60)}
<span className="studio-expand-icon">{expanded === plan.id ? '\u25B2' : '\u25BC'}</span>
</div>
))}
{agents.map(agent => (
<span key={agent} className="studio-agent-tag">
{agent}
</span>
))}
</div>
)}
{expanded && plans.find(p => p.id === expanded) && (
<div className="studio-plan-detail">
<div className="studio-plan-detail-header">{t('studio.planDetail')}</div>
{steps.length > 0 && (
<div className="studio-steps">
{steps.map(step => (
<div key={step.num} className="studio-step">
<span className="studio-step-num">{step.num}</span>
<span className="studio-step-text">{step.text}</span>
</div>
))}
</div>
)}
<div className="studio-plan-raw">
<pre>{plans.find(p => p.id === expanded).content}</pre>
</div>
</div>
)}
<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>
)
}
function StreamingMessage({ content }) {
return (
<div className="studio-msg ai">
<div className="studio-msg-avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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 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' }) : ''
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>
<div className="studio-msg-body">
<div className="studio-msg-content">
{renderContent(content).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>
<span className="studio-cursor" />
</div>
</div>
)
}
function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
const { t } = useI18n()
const [tab, setTab] = useState('plans')
const allPlans = []
const allAgents = new Set()
const activities = []
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role === 'ai') {
const plans = parsePlanBlocks(msg.content)
for (const plan of plans) {
if (!allPlans.find(p => p.title === plan.title)) {
allPlans.push({ ...plan, msgIndex: i })
}
}
parseAgentMentions(msg.content).forEach(a => allAgents.add(a))
}
activities.push({ role: msg.role, content: msg.content.slice(0, 100), time: msg.time })
)
}
const tabs = [
{ id: 'plans', label: t('studio.plans'), count: allPlans.length },
{ id: 'agents', label: t('studio.agents'), count: allAgents.size },
{ id: 'activity', label: t('studio.activity'), count: activities.length },
]
const cleanContent = msg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return (
<div className="studio-context">
<div className="studio-context-tabs">
{tabs.map(t2 => (
<div
key={t2.id}
className={`studio-context-tab ${tab === t2.id ? 'active' : ''}`}
onClick={() => setTab(t2.id)}
>
{t2.label}
{t2.count > 0 && <span className="studio-tab-count">{t2.count}</span>}
</div>
))}
<div className={`feed-item ${msg.role}`}>
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
<RankIcon rank={rank} />
</div>
<div className="studio-context-body">
{tab === 'plans' && (
allPlans.length > 0 ? (
<div className="studio-plan-list">
{allPlans.map(plan => (
<div
key={plan.id}
className={`studio-plan-item ${selectedPlan === plan.id ? 'active' : ''}`}
onClick={() => onSelectPlan(selectedPlan === plan.id ? null : plan.id)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<div className="studio-plan-item-text">{plan.title}</div>
<span className="studio-plan-item-badge">{parseSteps(plan.content).length} {t('studio.steps')}</span>
</div>
))}
</div>
) : (
<div className="studio-empty">{t('studio.noPlansYet')}</div>
)
)}
{tab === 'agents' && (
allAgents.size > 0 ? (
<div className="studio-agent-list">
{[...allAgents].map(agent => (
<div key={agent} className="studio-agent-item">
<div className="studio-agent-dot" />
<span className="studio-agent-name">{agent}</span>
<span className="badge info">{t('studio.mentioned')}</span>
</div>
))}
</div>
) : (
<div className="studio-empty">{t('studio.noAgentsYet')}</div>
)
)}
{tab === 'activity' && (
<div className="studio-activity-list">
{activities.map((act, i) => (
<div key={i} className="studio-activity-item">
<div className={`studio-activity-dot ${act.role}`} />
<div className="studio-activity-text">
{act.role === 'user' ? t('studio.you') + ': ' : 'AI: '}
{act.content}{act.content.length >= 100 ? '...' : ''}
</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 />}
<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 }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
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} />}
{!thinking && !cleanContent && (
<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>
@@ -269,20 +172,36 @@ function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
export default function Studio({ api }) {
const { t } = useI18n()
const [messages, setMessages] = useState([
{ id: MSG_ID(), role: 'ai', content: t('studio.welcomeNew'), time: new Date() },
])
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('')
const [selectedPlan, setSelectedPlan] = useState(null)
const [showContext, setShowContext] = useState(true)
const [streamThinking, setStreamThinking] = 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])
}, [messages, streaming, streamThinking])
useEffect(() => {
if (textareaRef.current) {
@@ -291,30 +210,69 @@ export default function Studio({ api }) {
}
}, [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('')
const userMsg = { id: MSG_ID(), role: 'user', content: text, time: new Date() }
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('')
try {
let accumulated = ''
await api.sendChat(text, true).then(full => {
accumulated = full
}).catch(() => {})
let thinking = ''
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
}
accumulated = partial
setStreaming(partial)
})
const finalContent = accumulated || t('studio.noResponse')
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: finalContent, time: new Date() }])
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}
if (thinking) aiMsg.thinking = thinking
setMessages(prev => [...prev, aiMsg])
} catch (err) {
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: `${t('studio.error')}: ${err.message}`, time: new Date() }])
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('')
}
}, [input, loading, api, t])
}, [input, loading, api, t, handleClear])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -323,70 +281,54 @@ export default function Studio({ api }) {
}
}
return (
<div className="studio-layout">
<div className="studio-chat-area">
<div className="studio-messages">
{messages.map(msg => (
<MessageBubble key={msg.id} msg={msg} />
))}
{streaming && <StreamingMessage content={streaming} />}
{loading && !streaming && (
<div className="studio-msg ai">
<div className="studio-msg-avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
</div>
<div className="studio-msg-body">
<div className="studio-thinking">
<span /><span /><span />
</div>
</div>
</div>
)}
<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')}
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>
)
}
<div className={`studio-sidebar ${showContext ? 'open' : ''}`}>
<div className="studio-sidebar-header">
<span>{t('studio.context')}</span>
<button className="ghost sm studio-sidebar-toggle" onClick={() => setShowContext(!showContext)}>
{showContext ? '\u203A' : '\u2039'}
return (
<div className="studio-feed-layout">
<div className="studio-feed">
{messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
{(streaming || streamThinking || loading) && (
<StreamingItem content={streaming} thinking={streamThinking} />
)}
<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>
{showContext && (
<ContextPanel
messages={messages}
selectedPlan={selectedPlan}
onSelectPlan={setSelectedPlan}
/>
)}
<div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear
</div>
</div>
</div>
)

View File

@@ -76,14 +76,11 @@ const en = {
steps: 'steps',
you: 'You',
mentioned: 'mentioned',
cleared: 'Conversation cleared.',
},
shell: {
terminal: 'Terminal',
hideAi: 'Hide AI',
aiAssistant: 'AI Assistant',
aiWelcome: 'I know your system inside out. Ask me anything.',
askAi: 'Ask AI...',
send: 'Send',
noResponse: 'No response',
error: 'Error',
@@ -110,9 +107,21 @@ const en = {
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',
},
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
@@ -155,9 +164,22 @@ const en = {
version: 'Version',
installed: 'Installed',
missing: 'Missing',
editProfile: 'Edit profile',
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...',
},
}

View File

@@ -76,14 +76,11 @@ const fr = {
steps: '\u00e9tapes',
you: 'Vous',
mentioned: 'mentionn\u00e9',
cleared: 'Conversation effac\u00e9e.',
},
shell: {
terminal: 'Terminal',
hideAi: 'Masquer IA',
aiAssistant: 'Assistant IA',
aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.',
askAi: 'Demander \u00e0 l\u2019IA...',
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
@@ -110,9 +107,21 @@ const fr = {
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\u00e9tences',
},
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
@@ -155,9 +164,22 @@ const fr = {
version: 'Version',
installed: 'Install\u00e9',
missing: 'Manquant',
editProfile: 'Modifier le profil',
editProfile: 'Modifier',
editProvider: 'Configurer',
validateKey: 'Valider',
validating: 'Vérification...',
keyValid: 'Clé valide',
keyInvalid: 'Clé invalide',
connectionFailed: 'Connexion échouée',
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...',
},
}

View File

@@ -269,7 +269,7 @@ input::placeholder { color: var(--text-disabled); }
.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; }
.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);
@@ -377,14 +377,27 @@ input::placeholder { color: var(--text-disabled); }
.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; }
.shell-ai-col { width: 340px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
.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;
@@ -407,34 +420,86 @@ input::placeholder { color: var(--text-disabled); }
display: flex; justify-content: flex-end; gap: 8px;
}
.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; position: relative; }
.config-section { margin-bottom: 28px; }
.config-section-title {
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
.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;
}
.field-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; }
.field-row:last-child { border-bottom: none; }
.field-label { width: 140px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
.field-value { color: var(--text-primary); font-size: 14px; flex: 1; }
.field-value.empty { color: var(--text-disabled); font-style: italic; }
.config-input { flex: 1; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; color: var(--text-primary); font-size: 13px; outline: none; font-family: var(--font-mono); }
.config-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
.config-form-actions { display: flex; gap: 8px; padding: 12px 0 0 152px; }
.config-actions-row { display: flex; gap: 8px; margin-bottom: 12px; }
.config-stats { display: flex; gap: 8px; margin-bottom: 12px; }
.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: 8px 12px; border-radius: var(--radius); }
.config-update-row:hover { background: var(--bg-card); }
.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: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.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);
@@ -442,15 +507,8 @@ input::placeholder { color: var(--text-disabled); }
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
}
.provider-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between;
transition: border-color 0.2s;
}
.provider-card:hover { border-color: var(--accent-dim); }
.provider-info { display: flex; flex-direction: column; gap: 4px; }
.provider-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); }
.spin-icon { animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
.mono { font-family: var(--font-mono); }
@@ -465,14 +523,6 @@ input::placeholder { color: var(--text-disabled); }
.agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; }
.agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
.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-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; }
.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%; }
@@ -480,11 +530,8 @@ input::placeholder { color: var(--text-disabled); }
.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;
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; }
@@ -498,19 +545,6 @@ input::placeholder { color: var(--text-disabled); }
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
.dashboard-tools { padding: 0; }
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
.tool-compact-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; border-radius: var(--radius);
font-size: 13px; transition: background 0.1s;
}
.tool-compact-row:hover { background: var(--bg-card); }
.badge.sm { padding: 1px 5px; font-size: 10px; }
.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; }
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
.dashboard-notifications { padding: 0; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
@@ -543,21 +577,65 @@ input::placeholder { color: var(--text-disabled); }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.fade-in { animation: fadeIn 0.2s ease-out; }
/* ── Studio ── */
.studio-layout { display: flex; height: 100%; overflow: hidden; }
.studio-chat-area { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.studio-messages { flex: 1; overflow-y: auto; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
.studio-msg { display: flex; gap: 10px; max-width: 85%; animation: fadeIn 0.2s ease-out; }
.studio-msg.user { align-self: flex-end; flex-direction: row-reverse; }
.studio-msg.ai { align-self: flex-start; }
.studio-msg-avatar {
width: 28px; height: 28px; border-radius: 50%; background: var(--accent-bg); color: var(--accent);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
/* ── 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-msg-body { display: flex; flex-direction: column; gap: 0; }
.studio-msg-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
.studio-code-block {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
@@ -569,50 +647,19 @@ input::placeholder { color: var(--text-disabled); }
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-msg-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.studio-plan-chip {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; 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;
}
.studio-plan-chip:hover { border-color: var(--accent-dark); background: var(--bg-hover); color: var(--text-primary); }
.studio-expand-icon { font-size: 9px; color: var(--text-tertiary); margin-left: 4px; }
.studio-agent-tag {
display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 99px;
background: rgba(68,138,255,0.12); color: var(--info); font-size: 11px; font-weight: 600;
}
.studio-plan-detail {
margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-surface); overflow: hidden;
}
.studio-plan-detail-header { padding: 10px 14px; font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
.studio-steps { display: flex; flex-direction: column; gap: 2px; padding: 8px 0; }
.studio-step { display: flex; gap: 10px; align-items: baseline; padding: 4px 14px; }
.studio-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 24px; }
.studio-step-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
.studio-plan-raw { padding: 8px 14px 12px; border-top: 1px solid var(--border); }
.studio-plan-raw pre { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); white-space: pre-wrap; word-break: break-word; margin: 0; line-height: 1.5; }
.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 {
@@ -623,7 +670,6 @@ input::placeholder { color: var(--text-disabled); }
}
.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);
@@ -631,54 +677,4 @@ input::placeholder { color: var(--text-disabled); }
}
.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 Sidebar ── */
.studio-sidebar {
width: 0; border-left: 1px solid var(--border); background: var(--bg-surface);
overflow: hidden; transition: width 0.25s ease; flex-shrink: 0; display: flex; flex-direction: column;
}
.studio-sidebar.open { width: 300px; }
.studio-sidebar-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.studio-sidebar-header span { font-size: 13px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
.studio-sidebar-toggle { font-size: 18px; padding: 0 6px; line-height: 1; }
.studio-context { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.studio-context-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.studio-context-tab {
flex: 1; padding: 9px 8px; font-size: 12px; font-weight: 600; color: var(--text-tertiary);
cursor: pointer; text-align: center; transition: all 0.15s; border-bottom: 2px solid transparent; user-select: none;
}
.studio-context-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.studio-context-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.studio-tab-count { font-size: 10px; padding: 1px 5px; border-radius: 99px; background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono); margin-left: 4px; }
.studio-context-body { flex: 1; overflow-y: auto; padding: 12px; }
.studio-plan-list { display: flex; flex-direction: column; gap: 4px; }
.studio-plan-item {
display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius);
cursor: pointer; transition: all 0.15s; font-size: 13px; color: var(--text-secondary);
}
.studio-plan-item:hover { background: var(--bg-card); color: var(--text-primary); }
.studio-plan-item.active { background: var(--accent-bg); border-left: 2px solid var(--accent); }
.studio-plan-item svg { flex-shrink: 0; color: var(--text-tertiary); }
.studio-plan-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.studio-plan-item-badge { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); flex-shrink: 0; }
.studio-agent-list { display: flex; flex-direction: column; gap: 4px; }
.studio-agent-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius); }
.studio-agent-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--info); flex-shrink: 0; }
.studio-agent-name { font-size: 13px; color: var(--text-secondary); flex: 1; }
.studio-empty { display: flex; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-disabled); font-size: 12px; text-align: center; line-height: 1.6; }
.studio-activity-list { display: flex; flex-direction: column; gap: 2px; }
.studio-activity-item { display: flex; gap: 8px; padding: 6px 10px; border-radius: var(--radius); font-size: 12px; }
.studio-activity-item:hover { background: var(--bg-card); }
.studio-activity-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
.studio-activity-dot.user { background: var(--accent-muted); }
.studio-activity-dot.ai { background: var(--info); }
.studio-activity-text { color: var(--text-tertiary); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }