Compare commits

..

3 Commits

Author SHA1 Message Date
Augustin
c8903efa5e fix: guard against empty tabs array in closeTab
All checks were successful
Beta Release / beta (push) Successful in 37s
💾 Generated with Crush

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

💾 Generated with Crush

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

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:35:49 +02:00
16 changed files with 1937 additions and 630 deletions

View File

@@ -0,0 +1,157 @@
package api
import (
"encoding/json"
"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")
}

View File

@@ -15,6 +15,8 @@ import (
"github.com/muyue/muyue/internal/version"
)
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)
}
@@ -264,18 +266,24 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
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(`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
orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
- Créer et gérer des plans de développement étape par étape
- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
- Suivre la progression de tâches multi-étapes
- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
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.`)
Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
if body.Stream {
w.Header().Set("Content-Type", "text/event-stream")
@@ -285,7 +293,6 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
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()})
@@ -296,6 +303,9 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
return
}
s.convStore.Add("assistant", result)
chunkSize := 8
runes := []rune(result)
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
@@ -323,5 +333,199 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.convStore.Add("assistant", result)
writeJSON(w, map[string]string{"content": result})
}
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"})
}
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

@@ -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
}
@@ -39,7 +41,15 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
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

@@ -2,15 +2,19 @@ package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"
"github.com/creack/pty/v2"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/config"
)
var upgrader = websocket.Upgrader{
@@ -32,12 +36,63 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
defer conn.Close()
shell := "/bin/sh"
if s, err := exec.LookPath("bash"); err == nil {
shell = s
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
if err := json.Unmarshal(raw, &initMsg); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
var cmd *exec.Cmd
if initMsg.Type == "ssh" && initMsg.Data != "" {
var sshConf struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
}
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
return
}
if sshConf.Port == 0 {
sshConf.Port = 22
}
sshArgs := []string{
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
}
if sshConf.KeyPath != "" {
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
}
if sshConf.Port != 22 {
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
}
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
cmd = exec.Command("ssh", sshArgs...)
} else {
shell := initMsg.Data
if shell == "" {
shell = detectShell()
}
if strings.Contains(shell, "wsl") {
cmd = exec.Command("wsl", "--shell-type", "login")
} else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") {
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
} else {
cmd = exec.Command(shell, "--login")
}
}
cmd := exec.Command(shell)
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.Start(cmd)
@@ -65,7 +120,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
})
}
// PTY -> WebSocket
go func() {
buf := make([]byte, 4096)
for {
@@ -86,8 +140,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
}()
// WebSocket -> PTY
conn.SetReadLimit(1 << 20) // 1MB
conn.SetReadLimit(1 << 20)
conn.SetReadDeadline(time.Time{})
for {
@@ -118,3 +171,131 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
}
}
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
writeJSON(w, map[string]interface{}{
"ssh": s.config.Terminal.SSH,
"system": detectSystemTerminals(),
})
return
}
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
KeyPath string `json:"key_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" || body.Host == "" {
writeError(w, "name and host required", http.StatusBadRequest)
return
}
if body.Port == 0 {
body.Port = 22
}
conn := config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
}
if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{}
}
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i, c := range s.config.Terminal.SSH {
if c.Name == name {
s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
found = true
break
}
}
if !found {
writeError(w, "not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if _, err := exec.LookPath(s); err == nil {
return s
}
}
return "/bin/sh"
}
func detectSystemTerminals() []map[string]string {
var terminals []map[string]string
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Default Shell",
"shell": detectShell(),
})
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL",
"shell": "wsl",
})
}
if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell",
"shell": "powershell",
})
}
if _, err := exec.LookPath("pwsh"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell Core",
"shell": "pwsh",
})
}
if _, err := exec.LookPath("cmd"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Command Prompt",
"shell": "cmd",
})
}
}
return terminals
}

View File

@@ -41,6 +41,15 @@ type ToolConfig struct {
AutoUpdate bool `yaml:"auto_update"`
}
type SSHConnection struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty"`
}
type MuyueConfig struct {
Version string `yaml:"version"`
Profile Profile `yaml:"profile"`
@@ -54,8 +63,9 @@ type MuyueConfig struct {
Global bool `yaml:"global"`
} `yaml:"bmad"`
Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh"`
} `yaml:"terminal"`
}

10
web/package-lock.json generated
View File

@@ -9,6 +9,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
@@ -736,6 +737,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",

View File

@@ -11,6 +11,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},

View File

@@ -26,7 +26,15 @@ const api = {
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
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' }),
getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }),
sendChat: (message, stream = true) => {
if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
import { getTheme, getThemeNames, applyTheme } from '../themes'
import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard'
import Studio from './Studio'
@@ -16,10 +17,10 @@ export default function App() {
const { t, layout } = useI18n()
const TABS = useMemo(() => [
{ id: 'dash', label: t('tabs.dashboard'), icon: '\u25A0' },
{ id: 'studio', label: t('tabs.studio'), icon: '\u27E8\u27E9' },
{ id: 'shell', label: t('tabs.shell'), icon: '$' },
{ id: 'config', label: t('tabs.config'), icon: '\u2699' },
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
], [t])
useEffect(() => {

View File

@@ -1,58 +1,367 @@
import { useState, useEffect } from 'react'
import { getThemeNames, applyTheme, getTheme } from '../themes'
import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench },
]
export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard, layout } = useI18n()
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [activePanel, setActivePanel] = useState('profile')
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(null)
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({})
const [toast, setToast] = useState(null)
useEffect(() => {
api.getConfig().then(d => setConfig(d)).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
}, [])
const themes = getThemeNames()
const layouts = getLayoutList()
const handleThemeChange = (themeId) => {
applyTheme(getTheme(themeId))
setCurrentTheme(themeId)
const loadData = useCallback(() => {
api.getConfig().then(d => {
setConfig(d)
setProfileForm({
name: d.profile?.name || '',
pseudo: d.profile?.pseudo || '',
email: d.profile?.email || '',
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
}, [api])
useEffect(() => { loadData() }, [loadData])
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2500)
}
const themeColors = {
'cyberpunk-red': '#FF0033',
'cyberpunk-pink': '#FF1A8C',
'midnight-blue': '#0088FF',
'matrix-green': '#00FF41',
const handleCheckUpdates = async () => {
setChecking(true)
try {
await api.runScan()
const d = await api.getUpdates()
setUpdates(d.updates || [])
const td = await api.getTools()
setTools(td.tools || [])
showToast(t('config.upToDate'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setChecking(false)
}
const handleUpdateTool = async (tool) => {
setUpdating(tool)
try {
await api.runUpdate(tool)
await handleCheckUpdates()
showToast(`${tool}`)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = async () => {
setUpdating('__all__')
try {
await api.runUpdate('')
await handleCheckUpdates()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleSaveProfile = async () => {
try {
await api.saveProfile(profileForm)
setEditProfile(false)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleSaveProvider = async () => {
try {
await api.saveProvider(providerForm)
setEditProvider(null)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const openProviderEdit = (p) => {
setProviderForm({
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
})
setEditProvider(p.name)
}
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
const installedCount = tools.filter(t => t.installed).length
const missingCount = tools.filter(t => !t.installed).length
return (
<div className="config-layout">
<div className="config-section">
<div className="config-section-title">{t('config.profile')}</div>
{config?.profile ? (
<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.defaultAi')} value={config.profile.preferences?.defaultAI} />
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
</div>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
<div className="config-window">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-sidebar">
{PANELS.map(p => {
const Icon = p.icon
return (
<div
key={p.id}
className={`config-sidebar-item ${activePanel === p.id ? 'active' : ''}`}
onClick={() => setActivePanel(p.id)}
>
<Icon size={16} />
<span>{t(`config.panels.${p.id}`)}</span>
</div>
)
})}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.language')}</div>
<div className="actions-stack">
<div className="config-panel-area">
<div className="config-panel-header">
<h2 className="config-panel-title">{t(`config.panels.${activePanel}`)}</h2>
</div>
<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} />
)}
</div>
</div>
</div>
)
}
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
return (
<div className="config-card">
{config?.profile && !editProfile ? (
<>
<div className="config-card-row">
<span className="config-card-label">{t('config.name')}</span>
<span className="config-card-value">{config.profile.name || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.pseudo')}</span>
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.email')}</span>
<span className="config-card-value">{config.profile.email || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.editor')}</span>
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.shell')}</span>
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.languages')}</span>
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
</div>
<div className="config-card-actions">
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
</div>
</>
) : editProfile ? (
<>
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
<div className="config-card-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
)
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
return (
<div className="config-providers-list">
{providers.map((p, i) => (
<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.active && <span className="badge accent">{t('config.active')}</span>}
</div>
<div className="provider-card-actions">
{editProvider !== p.name && (
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
)}
{!p.active && editProvider !== p.name && (
<button className="sm" onClick={async () => {
await api.saveProvider({ name: p.name, active: true })
loadData()
}}>{t('config.activate')}</button>
)}
</div>
</div>
{editProvider !== p.name ? (
<div className="provider-card-meta">
<span className="mono">{p.model || '—'}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
</div>
) : (
<div className="provider-card-form">
<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-card-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>
)
}
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
return (
<>
<div className="config-card">
<div className="config-update-controls">
<div className="config-update-stats">
<span className="badge ok">{installedCount} {t('config.installed')}</span>
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
</div>
<div className="config-update-buttons">
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
</button>
{needsUpdateCount > 0 && (
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
</button>
)}
</div>
</div>
</div>
{updates.length === 0 ? (
<div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div>
</div>
) : (
<div className="config-update-list">
{updates.map((u, i) => (
<div key={i} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{u.tool}</span>
<span className="config-update-versions">
{u.needsUpdate ? (
<>{u.current} <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
) : (
<span style={{ color: 'var(--success)' }}>{u.current}</span>
)}
</span>
</div>
{u.needsUpdate && (
<button
className="sm"
onClick={() => handleUpdateTool(u.tool)}
disabled={updating === u.tool}
>
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
</button>
)}
</div>
))}
</div>
)}
</>
)
}
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
return (
<div className="config-card">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.language')}</span>
<div className="chip-row">
{LANGUAGES.map(lang => (
<div
key={lang.id}
@@ -64,10 +373,9 @@ export default function Config({ api }) {
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="actions-stack">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
<div className="chip-row">
{layouts.map(l => (
<div
key={l.id}
@@ -79,68 +387,41 @@ export default function Config({ api }) {
))}
</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>
<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>
</div>
</div>
</div>
))}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.theme')}</div>
<div className="theme-picker">
{themes.map(th => (
<div
key={th.id}
className={`theme-swatch ${currentTheme === th.id ? 'active' : ''}`}
style={{ background: themeColors[th.id] || '#FF0033' }}
onClick={() => handleThemeChange(th.id)}
title={th.name}
/>
))}
</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="tool-row">
<span className="tool-name">{s.name}</span>
<span className="badge neutral">{s.target || 'both'}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{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 FormInput({ label, value, onChange, type = 'text' }) {
return (
<div className="config-form-field">
<label className="config-form-label">{label}</label>
<input
className="config-form-input"
type={type}
value={value}
onChange={e => onChange(e.target.value)}
/>
</div>
)
}

View File

@@ -1,110 +1,62 @@
import { useState } from 'react'
import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) {
export default function Dashboard({ api, onRescan }) {
const { t, layout } = useI18n()
const [activeSection, setActiveSection] = useState('tools')
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])
}
const sections = [
{ id: 'tools', label: t('dashboard.systemOverview') },
{ id: 'notifications', label: t('dashboard.activityLog') },
{ id: 'workflows', label: t('studio.workflows') },
]
return (
<div className="dashboard-layout">
<div className="dashboard-tabs">
{sections.map(s => (
<div
key={s.id}
className={`dashboard-tab ${activeSection === s.id ? 'active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
{s.label}
{s.id === 'tools' && total > 0 && (
<span className="tab-count">{installed}/{total}</span>
)}
{s.id === 'notifications' && notifications.length > 0 && (
<span className="tab-count warn">{notifications.length}</span>
)}
</div>
))}
</div>
<div className="dashboard-content">
{activeSection === 'tools' && (
<div className="dashboard-tools">
{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 className="dashboard-grid">
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('studio.workflows')}</div>
</div>
<div className="dashboard-workflows-inline">
<div className="workflow-section">
<div className="section-label">{t('studio.workflows')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
)}
<div className="workflow-section">
<div className="section-label">{t('studio.activeAgents')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
</div>
</div>
)}
{activeSection === 'notifications' && (
<div className="dashboard-notifications">
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
{notifications.length > 0 && (
<span className="badge warn">{notifications.length}</span>
)}
</div>
{notifications.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
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' })}
</span>
<span className="notif-text">{n.text}</span>
</div>
))
<div className="dashboard-notifications-inline">
{notifications.map(n => (
<div key={n.id} className={`notif-row notif-${n.type}`}>
<span className="notif-time">
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="notif-text">{n.text}</span>
</div>
))}
</div>
)}
</div>
)}
{activeSection === 'workflows' && (
<div className="dashboard-workflows">
<div className="workflow-section">
<div className="section-label">{t('studio.workflows')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
<div className="workflow-section">
<div className="section-label">{t('studio.activeAgents')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}
function extractVersion(s) {
if (!s) return ''
const m = s.match(/\d+\.\d+\.\d+/)
return m ? m[0] : s.slice(0, 12)
}

View File

@@ -1,142 +1,311 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Terminal } from '@xterm/xterm'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react'
import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n'
const MAX_TABS = 7
const 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',
}
function createTerminal(container) {
const term = new XTerm({
cursorBlink: true,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: XTERM_THEME,
allowTransparency: false,
scrollback: 5000,
})
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.open(container)
fitAddon.fit()
return { term, fitAddon }
}
function connectWebSocket(term, fitAddon, initPayload) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
ws.onopen = () => {
ws.send(JSON.stringify(initPayload))
const dims = fitAddon.proposeDimensions()
if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
}
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'output') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
}
} catch {
term.write(event.data)
}
}
ws.onclose = () => {
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
}
ws.onerror = () => {
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
}
})
return ws
}
export default function Shell({ api }) {
const { t } = useI18n()
const termRef = useRef(null)
const fitAddonRef = useRef(null)
const wsRef = useRef(null)
const containerRef = useRef(null)
const tabsRef = useRef({})
const nextIdRef = useRef(1)
const [tabs, setTabs] = useState([
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
])
const [activeTab, setActiveTab] = useState(1)
const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false)
const [showSshModal, setShowSshModal] = useState(false)
const [editingTab, setEditingTab] = useState(null)
const [editName, setEditName] = useState('')
const [sshForm, setSshForm] = useState({
name: '', host: '', port: 22, user: '', key_path: '',
})
const [aiMessages, setAiMessages] = useState([
{ role: 'ai', content: t('shell.aiWelcome') }
])
const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [connected, setConnected] = useState(false)
const aiMessagesRef = useRef(null)
useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
const getWsUrl = useCallback(() => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}/api/ws/terminal`
useEffect(() => {
api.getTerminalSessions().then(d => {
setSshConnections(d.ssh || [])
setSystemTerminals(d.system || [])
}).catch(() => {})
}, [])
useEffect(() => {
if (!containerRef.current) return
const initTerminal = useCallback((tabId, tab) => {
if (tabsRef.current[tabId]) return
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
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',
},
allowTransparency: false,
scrollback: 5000,
})
const container = document.getElementById(`terminal-${tabId}`)
if (!container) return
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.open(containerRef.current)
fitAddon.fit()
const { term, fitAddon } = createTerminal(container)
termRef.current = term
fitAddonRef.current = fitAddon
const ws = new WebSocket(getWsUrl())
wsRef.current = ws
ws.onopen = () => {
setConnected(true)
const dims = fitAddon.proposeDimensions()
if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
let initPayload
if (tab.type === 'ssh') {
initPayload = {
type: 'ssh',
data: JSON.stringify({
host: tab.host,
port: tab.port || 22,
user: tab.user || 'root',
key_path: tab.key_path || '',
}),
}
} else {
initPayload = {
type: 'shell',
data: tab.shell || '',
}
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'output') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
}
} catch {
term.write(event.data)
}
const ws = connectWebSocket(term, fitAddon, initPayload)
ws.onopen = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
}
ws.onclose = () => {
setConnected(false)
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
ws.onerror = () => {
setConnected(false)
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
}
})
const onResize = () => {
if (containerRef.current?.offsetParent !== null) {
const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) {
fitAddon.fit()
}
}
const resizeObserver = new ResizeObserver(onResize)
resizeObserver.observe(containerRef.current)
resizeObserver.observe(container)
window.addEventListener('resize', onResize)
return () => {
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
}, [])
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]) {
const timer = setTimeout(() => {
const { fitAddon } = tabsRef.current[tab.id]
fitAddon.fit()
}, 50)
return () => clearTimeout(timer)
}
}, [activeTab, tabs, initTerminal])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey) return
const num = parseInt(e.key)
if (num >= 1 && num <= tabs.length) {
e.preventDefault()
setActiveTab(tabs[num - 1].id)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [tabs])
const addLocalTab = (shell, name) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
setTabs(prev => [...prev, newTab])
setActiveTab(id)
setShowMenu(false)
}
const addSSHTab = (conn) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = {
id,
name: conn.name || `${conn.user}@${conn.host}`,
type: 'ssh',
host: conn.host,
port: conn.port || 22,
user: conn.user || 'root',
key_path: conn.key_path || '',
connected: false,
}
setTabs(prev => [...prev, newTab])
setActiveTab(id)
setShowMenu(false)
}
const closeTab = (tabId, e) => {
if (e) e.stopPropagation()
if (tabs.length <= 1) return
if (tabsRef.current[tabId]) {
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
window.removeEventListener('resize', onResize)
resizeObserver.disconnect()
ws.close()
term.dispose()
delete tabsRef.current[tabId]
}
}, [getWsUrl])
setTabs(prev => {
const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id)
}
return next
})
}
const startRename = (tabId, e) => {
if (e) e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
setEditingTab(tabId)
setEditName(tab.name)
}
const finishRename = () => {
if (editName.trim() && editingTab) {
setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t))
}
setEditingTab(null)
setEditName('')
}
const saveSSHConnection = async () => {
if (!sshForm.name.trim() || !sshForm.host.trim()) return
try {
await api.addSSHConnection(sshForm)
setSshConnections(prev => [...prev, { ...sshForm }])
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
setShowSshModal(false)
} catch (err) {
console.error(err)
}
}
const deleteSSHConnection = async (name) => {
try {
await api.deleteSSHConnection(name)
setSshConnections(prev => prev.filter(c => c.name !== name))
} catch (err) {
console.error(err)
}
}
const handleAiSend = async () => {
if (!aiInput.trim() || aiLoading) return
@@ -144,7 +313,6 @@ export default function Shell({ api }) {
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiInput('')
setAiLoading(true)
try {
const res = await api.runCommand(`echo "AI: ${text}"`, '')
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
@@ -157,13 +325,115 @@ export default function Shell({ api }) {
return (
<div className="shell-layout">
<div className="shell-terminal-col">
<div className="panel-header">
<span className="panel-title">
{t('shell.terminal')}
<span className={`connection-dot ${connected ? 'on' : 'off'}`} />
</span>
<div className="shell-tabs-bar">
<div className="shell-tabs">
{tabs.map((tab, i) => (
<div
key={tab.id}
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
onDoubleClick={(e) => startRename(tab.id, e)}
>
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
{tab.type === 'ssh' && <Globe size={12} />}
{tab.type === 'local' && <Monitor size={12} />}
{editingTab === tab.id ? (
<input
className="shell-tab-rename"
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={finishRename}
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
autoFocus
onClick={e => e.stopPropagation()}
/>
) : (
<span className="shell-tab-name">{tab.name}</span>
)}
<span className="shell-tab-index">{i + 1}</span>
{tabs.length > 1 && (
<button
className="shell-tab-close"
onClick={(e) => closeTab(tab.id, e)}
title={t('shell.closeTab')}
>
<X size={12} />
</button>
)}
</div>
))}
</div>
<div className="shell-tab-actions">
{tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
<Plus size={16} />
<ChevronDown size={12} />
</button>
{showMenu && (
<>
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
<div className="shell-new-tab-menu">
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
{systemTerminals.map(st => (
<button
key={st.name}
className="shell-menu-item"
onClick={() => addLocalTab(st.shell, st.name)}
>
<Monitor size={14} />
<span>{st.name}</span>
<span className="shell-menu-item-sub">{st.shell}</span>
</button>
))}
<div className="shell-menu-divider" />
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
{sshConnections.length === 0 && (
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
)}
{sshConnections.map(conn => (
<div key={conn.name} className="shell-menu-item-row">
<button
className="shell-menu-item"
onClick={() => addSSHTab(conn)}
>
<Globe size={14} />
<span>{conn.name}</span>
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
</button>
<button
className="shell-menu-item-icon"
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
title={t('shell.deleteConnection')}
>
<Trash2 size={12} />
</button>
</div>
))}
<div className="shell-menu-divider" />
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
<Plus size={14} />
<span>{t('shell.addConnection')}</span>
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
<div className="shell-xterm-wrapper">
{tabs.map(tab => (
<div
key={tab.id}
id={`terminal-${tab.id}`}
className="shell-xterm-instance"
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
/>
))}
</div>
<div className="shell-xterm-wrapper" ref={containerRef} />
</div>
<div className="shell-ai-col">
@@ -186,6 +456,56 @@ export default function Shell({ api }) {
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
</div>
</div>
{showSshModal && (
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
<div className="shell-modal" onClick={e => e.stopPropagation()}>
<div className="shell-modal-header">{t('shell.addConnection')}</div>
<div className="shell-modal-body">
<label className="shell-modal-label">{t('shell.connectionName')}</label>
<input
value={sshForm.name}
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
placeholder="prod-server"
/>
<label className="shell-modal-label">{t('shell.host')}</label>
<input
value={sshForm.host}
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
placeholder="192.168.1.100"
/>
<div className="shell-modal-row">
<div className="shell-modal-field">
<label className="shell-modal-label">{t('shell.port')}</label>
<input
type="number"
value={sshForm.port}
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
/>
</div>
<div className="shell-modal-field">
<label className="shell-modal-label">{t('shell.user')}</label>
<input
value={sshForm.user}
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
placeholder="root"
/>
</div>
</div>
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
<input
value={sshForm.key_path}
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
placeholder="~/.ssh/id_rsa"
/>
</div>
<div className="shell-modal-footer">
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,54 +1,8 @@
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
}
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 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() })
}
}
return steps
}
function renderContent(text) {
const parts = []
let i = 0
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
@@ -70,7 +24,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,25 +32,41 @@ 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 FeedItem({ msg }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const roleLabel = isUser ? null : isSystem ? null : (
<div className="feed-avatar">
<svg width="14" height="14" 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>
)
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>
)
}
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 className={`feed-item ${msg.role}`}>
{roleLabel}
<div className="feed-body">
<div className="feed-header">
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
)}
<div className="studio-msg-body">
<div className="studio-msg-content">
<div className="feed-content">
{renderContent(msg.content).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
@@ -108,53 +78,24 @@ function MessageBubble({ msg }) {
)
)}
</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>
</div>
)
}
function StreamingMessage({ content }) {
function StreamingItem({ 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>
<div className="feed-item assistant">
<div className="feed-avatar">
<svg width="14" height="14" 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">
<div className="feed-body">
<div className="feed-header">
<span className="feed-role">IA</span>
</div>
<div className="feed-content">
{renderContent(content).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
@@ -165,103 +106,8 @@ function StreamingMessage({ content }) {
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" />
</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 },
]
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>
<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>
))}
</div>
)}
</div>
</div>
)
@@ -269,17 +115,32 @@ 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 [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])
@@ -291,11 +152,26 @@ 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('')
@@ -307,14 +183,24 @@ export default function Studio({ api }) {
}).catch(() => {})
const finalContent = accumulated || t('studio.noResponse')
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: finalContent, time: new Date() }])
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}])
} 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('')
}
}, [input, loading, api, t])
}, [input, loading, api, t, handleClear])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -323,70 +209,66 @@ 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 && <StreamingItem content={streaming} />}
{loading && !streaming && (
<div className="feed-item assistant">
<div className="feed-avatar">
<svg width="14" height="14" 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="feed-body">
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</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>
{showContext && (
<ContextPanel
messages={messages}
selectedPlan={selectedPlan}
onSelectPlan={setSelectedPlan}
/>
)}
<div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear
</div>
</div>
</div>
)

View File

@@ -87,9 +87,39 @@ const en = {
send: 'Send',
noResponse: 'No response',
error: 'Error',
newTab: 'New tab',
closeTab: 'Close tab',
maxTabsReached: 'Maximum 7 terminals reached',
renameTab: 'Rename',
local: 'Local',
ssh: 'SSH',
connections: 'Connections',
addConnection: 'Add SSH connection',
editConnection: 'Edit connection',
deleteConnection: 'Delete',
connectionName: 'Name',
host: 'Host',
port: 'Port',
user: 'User',
keyPath: 'SSH key path',
connect: 'Connect',
save: 'Save',
cancel: 'Cancel',
savedConnections: 'Saved connections',
noConnections: 'No saved SSH connections.',
systemTerminals: 'System terminals',
switchTerminal: 'Switch terminal',
localShell: 'Local Shell',
},
config: {
panels: {
profile: 'Profile',
providers: 'AI Providers',
updates: 'Updates',
locale: 'Language & Keyboard',
skills: 'Skills',
},
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
@@ -102,15 +132,39 @@ const en = {
notSet: 'Not set',
aiProviders: 'AI Providers',
active: 'Active',
activate: 'Activate',
keyConfigured: 'Key configured',
noKey: 'No key',
theme: 'Theme',
apiKey: 'API Key',
model: 'Model',
baseUrl: 'Base URL',
save: 'Save',
saved: 'Saved!',
error: 'Error',
skills: 'Skills',
noSkills: 'No skills installed.',
runSkillsInit: 'Run muyue skills init',
language: 'Language',
keyboardLayout: 'Keyboard Layout',
target: 'Target',
updates: 'Updates',
systemUpdates: 'System Updates',
checkUpdates: 'Check for updates',
updateAll: 'Update all',
updateTool: 'Update',
checking: 'Checking...',
updating: 'Updating...',
upToDate: 'Up to date',
needsUpdate: 'Update available',
current: 'Current',
latest: 'Latest',
noUpdates: 'All tools are up to date.',
version: 'Version',
installed: 'Installed',
missing: 'Missing',
editProfile: 'Edit',
cancel: 'Cancel',
editProvider: 'Configure',
},
}

View File

@@ -87,9 +87,39 @@ const fr = {
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
newTab: 'Nouvel onglet',
closeTab: 'Fermer l\u2019onglet',
maxTabsReached: 'Maximum 7 terminaux atteint',
renameTab: 'Renommer',
local: 'Local',
ssh: 'SSH',
connections: 'Connexions',
addConnection: 'Ajouter une connexion SSH',
editConnection: 'Modifier la connexion',
deleteConnection: 'Supprimer',
connectionName: 'Nom',
host: 'H\u00f4te',
port: 'Port',
user: 'Utilisateur',
keyPath: 'Chemin cl\u00e9 SSH',
connect: 'Se connecter',
save: 'Enregistrer',
cancel: 'Annuler',
savedConnections: 'Connexions enregistr\u00e9es',
noConnections: 'Aucune connexion SSH enregistr\u00e9e.',
systemTerminals: 'Terminaux syst\u00e8me',
switchTerminal: 'Changer de terminal',
localShell: 'Shell local',
},
config: {
panels: {
profile: 'Profil',
providers: 'Fournisseurs IA',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Comp\u00e9tences',
},
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
@@ -102,15 +132,39 @@ const fr = {
notSet: 'Non d\u00e9fini',
aiProviders: 'Fournisseurs IA',
active: 'Actif',
activate: 'Activer',
keyConfigured: 'Cl\u00e9 configur\u00e9e',
noKey: 'Pas de cl\u00e9',
theme: 'Th\u00e8me',
apiKey: 'Cl\u00e9 API',
model: 'Mod\u00e8le',
baseUrl: 'URL de base',
save: 'Enregistrer',
saved: 'Enregistr\u00e9 !',
error: 'Erreur',
skills: 'Comp\u00e9tences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
keyboardLayout: 'Disposition du clavier',
target: 'Cible',
updates: 'Mises \u00e0 jour',
systemUpdates: 'Mises \u00e0 jour syst\u00e8me',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
updateAll: 'Tout mettre \u00e0 jour',
updateTool: 'Mettre \u00e0 jour',
checking: 'V\u00e9rification...',
updating: 'Mise \u00e0 jour...',
upToDate: '\u00c0 jour',
needsUpdate: 'Mise \u00e0 jour disponible',
current: 'Actuel',
latest: 'Dernier',
noUpdates: 'Tous les outils sont \u00e0 jour.',
version: 'Version',
installed: 'Install\u00e9',
missing: 'Manquant',
editProfile: 'Modifier',
editProvider: 'Configurer',
cancel: 'Annuler',
},
}

View File

@@ -141,6 +141,7 @@ input::placeholder { color: var(--text-disabled); }
}
.nav-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.nav-tab.active { color: #fff; background: var(--accent); }
.tab-icon { display: flex; align-items: center; }
.header-spacer { flex: 1; }
@@ -269,46 +270,241 @@ input::placeholder { color: var(--text-disabled); }
.shell-layout { display: flex; height: 100%; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.shell-xterm-wrapper { flex: 1; padding: 8px; background: var(--bg); overflow: hidden; }
.shell-xterm-wrapper .xterm { height: 100%; padding: 4px; }
.shell-tabs-bar {
display: flex; align-items: center; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
height: 36px; padding: 0 8px; gap: 4px;
}
.shell-tabs {
display: flex; align-items: center; gap: 2px; flex: 1; overflow-x: auto;
scrollbar-width: none;
}
.shell-tabs::-webkit-scrollbar { display: none; }
.shell-tab {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: var(--radius) var(--radius) 0 0;
font-size: 12px; font-weight: 500; color: var(--text-tertiary);
cursor: pointer; transition: all 0.15s; user-select: none;
border: 1px solid transparent; border-bottom: none;
white-space: nowrap; max-width: 180px; position: relative;
background: transparent;
}
.shell-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.shell-tab.active {
color: var(--text-primary); background: var(--bg);
border-color: var(--border); border-bottom-color: var(--bg);
margin-bottom: -1px;
}
.shell-tab-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 120px; font-size: 12px;
}
.shell-tab-index {
font-size: 9px; color: var(--text-disabled); font-family: var(--font-mono);
padding: 0 3px; background: var(--bg-input); border-radius: 3px; line-height: 1.4;
}
.shell-tab-close {
display: flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 3px; border: none;
background: transparent; color: var(--text-disabled); cursor: pointer;
padding: 0; transition: all 0.1s; flex-shrink: 0;
}
.shell-tab-close:hover { background: var(--accent-bg); color: var(--accent); }
.shell-tab-rename {
width: 80px; font-size: 12px; padding: 1px 4px; border-radius: 3px;
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--accent);
outline: none; font-family: var(--font-sans);
}
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.shell-new-tab-wrapper { position: relative; }
.shell-new-tab-btn {
display: flex; align-items: center; gap: 2px;
padding: 4px 8px; border-radius: var(--radius);
background: transparent; border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
font-size: 12px;
}
.shell-new-tab-btn:hover { color: var(--text-primary); background: var(--bg-card); border-color: var(--accent-dark); }
.shell-menu-overlay {
position: fixed; inset: 0; z-index: 998;
}
.shell-new-tab-menu {
position: absolute; top: 100%; right: 0; z-index: 999;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 6px;
min-width: 260px; max-height: 400px; overflow-y: auto;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.shell-menu-label {
font-size: 10px; font-weight: 700; color: var(--text-disabled);
text-transform: uppercase; letter-spacing: 0.5px;
padding: 6px 10px 4px;
}
.shell-menu-item {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 7px 10px; border-radius: var(--radius);
background: transparent; border: none; color: var(--text-secondary);
cursor: pointer; transition: all 0.1s; font-size: 12px;
text-align: left; font-family: var(--font-sans);
}
.shell-menu-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.shell-menu-item.accent { color: var(--accent); }
.shell-menu-item.accent:hover { background: var(--accent-bg); }
.shell-menu-item-sub {
font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono);
margin-left: auto;
}
.shell-menu-item-row { display: flex; align-items: center; }
.shell-menu-item-icon {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius);
background: transparent; border: none; color: var(--text-disabled);
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
}
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
.shell-menu-empty {
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
font-style: italic;
}
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance {
position: absolute; inset: 0; padding: 4px;
}
.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; margin-left: 8px; }
.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); }
.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; }
.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);
.shell-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.shell-modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); min-width: 380px; max-width: 480px;
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
}
.shell-modal-header {
padding: 16px 20px; font-size: 14px; font-weight: 700;
color: var(--text-primary); border-bottom: 1px solid var(--border);
}
.shell-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
.shell-modal-label { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 2px; }
.shell-modal-row { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; }
.shell-modal-field { display: flex; flex-direction: column; }
.shell-modal-footer {
padding: 12px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 8px;
}
.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; }
.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); }
.config-window { display: flex; height: 100%; overflow: hidden; }
.theme-picker { display: flex; gap: 8px; flex-wrap: wrap; }
.theme-swatch {
width: 48px; height: 48px; border-radius: var(--radius); border: 2px solid var(--border);
cursor: pointer; transition: all 0.15s; position: relative;
.config-sidebar {
width: 180px; background: var(--bg-surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 12px 8px; gap: 2px; flex-shrink: 0;
overflow-y: auto;
}
.theme-swatch:hover { transform: scale(1.1); border-color: var(--accent-dim); }
.theme-swatch.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.theme-swatch.active::after {
content: '\2713'; position: absolute; inset: 0; display: flex; align-items: center;
justify-content: center; color: #fff; font-size: 18px; font-weight: 700; text-shadow: 0 1px 3px rgba(0,0,0,0.5);
.config-sidebar-item {
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
border-radius: var(--radius); font-size: 13px; font-weight: 500;
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
user-select: none;
}
.config-sidebar-item:hover { background: var(--bg-card); color: var(--text-primary); }
.config-sidebar-item.active { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
.config-sidebar-item svg { flex-shrink: 0; }
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.config-panel-header {
padding: 20px 28px 0; flex-shrink: 0;
}
.config-panel-title {
font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 0;
}
.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); }
.config-update-controls {
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.config-update-stats { display: flex; gap: 8px; }
.config-update-buttons { display: flex; gap: 8px; }
.config-update-list { display: flex; flex-direction: column; gap: 2px; }
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-radius: var(--radius); background: var(--bg-card); border: 1px solid var(--border); margin-bottom: 6px; }
.config-update-row:hover { border-color: var(--accent-dim); }
.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.config-skill-row:last-child { border-bottom: none; }
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.config-toast {
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
font-size: 13px; font-weight: 600; z-index: 100; animation: fadeIn 0.2s ease-out;
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
}
.spin-icon { animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
.mono { font-family: var(--font-mono); }
.section-title { font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
.actions-stack { display: flex; flex-direction: column; gap: 6px; }
@@ -332,40 +528,26 @@ input::placeholder { color: var(--text-disabled); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.dashboard-tab {
padding: 10px 24px; font-size: 13px; font-weight: 600;
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 8px; border-bottom: 2px solid transparent;
user-select: none;
}
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.dashboard-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-count {
font-size: 10px; padding: 1px 6px; border-radius: 99px;
background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono);
}
.tab-count.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
.dashboard-content { flex: 1; overflow-y: auto; }
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
.dashboard-tools { padding: 16px 24px; }
.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;
.dashboard-section {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
}
.dashboard-section:hover { border-color: var(--accent-dim); }
.dashboard-section.full-width { grid-column: 1 / -1; }
.dashboard-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.dashboard-section-title {
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 0.5px;
}
.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: 16px 24px; }
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
.dashboard-notifications { padding: 0; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
@@ -378,7 +560,7 @@ input::placeholder { color: var(--text-disabled); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
.dashboard-workflows { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; }
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;