Compare commits

..

7 Commits

Author SHA1 Message Date
Augustin
92eb783df0 fix(config): locale panel show active language/keyboard, add save button
All checks were successful
Beta Release / beta (push) Successful in 41s
- Fix language prop passing keyboard value instead of language
- Add save button that persists language + keyboard_layout via API
- Add local toast for save confirmation

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:20:30 +02:00
Augustin
8005e978f0 feat(config): dynamic profile panel, generic save, tabs margin fix
All checks were successful
Beta Release / beta (push) Successful in 44s
- Config tabs now have bottom padding for visual spacing
- Profile panel dynamically renders all config fields (strings, bools,
  arrays, nested objects) — new struct fields appear automatically
- handleSaveProfile uses generic JSON merge via deepMerge, so any
  new Profile field works without handler changes
- RenderFields recursively renders config sections with edit/view modes

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:14:47 +02:00
Augustin
6e76e7dca6 fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota
All checks were successful
Beta Release / beta (push) Successful in 40s
Remove BgGraph background SVGs that were misaligned with foreground graphs.
Add max-height: 270px with overflow-y scroll to quota/processes/commands lists.
Change API quota display from remaining/total to used/total.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:02:53 +02:00
Augustin
e8f6dc4b4d feat(chat): add auto-summarization with token tracking UI
All checks were successful
Beta Release / beta (push) Successful in 47s
Add /summarize command, token usage bar, and summary endpoint.
Add JSON tags to config/platform/scanner structs for API serialization.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 20:42:43 +02:00
Augustin
bb03c9fe2d feat(dashboard): add background graphs to cards and improve layout
All checks were successful
Beta Release / beta (push) Successful in 39s
- Add BgGraph component for subtle background SVG graphs
- Add gradient fills to MiniGraph components
- Track process count over time
- Calculate total API quota usage
- Improve card styling with overlay content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:55:10 +02:00
Augustin
79d082180c feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator
All checks were successful
Beta Release / beta (push) Successful in 46s
- Rewrite dashboard from 4 tabs to single grid view with 5s auto-refresh
- Add live CPU/RAM/Network SVG graphs with rolling 30-point history
- Add backend /api/system/metrics reading /proc/stat, /proc/meminfo, /proc/net/dev
- Add backend /api/providers/quota for MiniMax and Z.AI quota monitoring
- Add backend /api/recent-commands reading bash/zsh history
- Add backend /api/running-processes filtering editors/IDEs/languages
- Add sudo/root indicator ( ROOT) in footer when running as root
- Remove duplicate Ctrl+1-4 shortcut from page-specific footer (keep only right side)
- Add Ctrl+R shortcut on dashboard for metrics-only refresh
- Make API key mandatory in onboarding, auto-scan editors via AI chat
- Remove manual editor input, only show AI-detected editors
- Bump version to 0.3.3

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:46:16 +02:00
Augustin
7682717093 feat(dashboard): add quota monitoring, process list, and command history
All checks were successful
Beta Release / beta (push) Successful in 44s
- New API endpoints: /providers/quota, /recent-commands, /running-processes
- New grid-based dashboard layout with cards for tools, quota, processes, commands
- Improved OnboardingWizard with required API key validation and scanning feedback
- Auto-initialize config on first run

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:24:23 +02:00
15 changed files with 1081 additions and 708 deletions

View File

@@ -206,8 +206,11 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
"max_tokens": maxTokensApprox,
"summarize_at": summarizeThreshold,
"summary": s.convStore.GetSummary(),
})
}
@@ -219,3 +222,16 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.autoSummarize()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": s.convStore.ApproxTokenCount(),
"summary": s.convStore.GetSummary(),
})
}

View File

@@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
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"`
currentJSON, err := json.Marshal(s.config.Profile)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
var currentMap map[string]interface{}
json.Unmarshal(currentJSON, &currentMap)
var updates map[string]interface{}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &updates); 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
}
deepMerge(currentMap, updates)
mergedJSON, _ := json.Marshal(currentMap)
json.Unmarshal(mergedJSON, &s.config.Profile)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
@@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
func deepMerge(dst, src map[string]interface{}) {
for k, sv := range src {
if dv, ok := dst[k]; ok {
dstMap, dOk := dv.(map[string]interface{})
srcMap, sOk := sv.(map[string]interface{})
if dOk && sOk {
deepMerge(dstMap, srcMap)
continue
}
}
dst[k] = sv
}
}
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)

View File

@@ -2,8 +2,14 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
@@ -17,6 +23,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
"name": version.Name,
"version": version.Version,
"author": version.Author,
"sudo": os.Geteuid() == 0,
})
}
@@ -415,3 +422,299 @@ func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
editors := scanner.ScanEditors()
writeJSON(w, map[string]interface{}{"editors": editors})
}
func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
type providerQuota struct {
Name string `json:"name"`
Active bool `json:"active"`
Healthy bool `json:"healthy"`
Data map[string]interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
var results []providerQuota
client := &http.Client{Timeout: 8 * time.Second}
for _, p := range s.config.AI.Providers {
q := providerQuota{Name: p.Name, Active: p.Active}
switch p.Name {
case "minimax":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if models, ok := data["model_remains"].([]interface{}); ok {
filtered := make([]map[string]interface{}, 0)
for _, m := range models {
if mm, ok := m.(map[string]interface{}); ok {
usage, _ := mm["current_interval_usage_count"].(float64)
total, _ := mm["current_interval_total_count"].(float64)
if total > 0 {
filtered = append(filtered, map[string]interface{}{
"model": mm["model_name"],
"used": usage,
"total": total,
"remaining": total - usage,
"weekly_used": mm["current_weekly_usage_count"],
"weekly_total": mm["current_weekly_total_count"],
})
}
}
}
q.Data = map[string]interface{}{"models": filtered}
q.Healthy = true
}
}
}
case "zai":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
q.Data = data
q.Healthy = true
}
}
default:
q.Error = "quota not supported"
}
results = append(results, q)
}
writeJSON(w, map[string]interface{}{"providers": results})
}
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir()
type cmdEntry struct {
Cmd string `json:"cmd"`
Shell string `json:"shell"`
}
var entries []cmdEntry
for _, histFile := range []string{".bash_history", ".zsh_history"} {
path := filepath.Join(home, histFile)
data, err := os.ReadFile(path)
if err != nil {
continue
}
shell := "bash"
if strings.Contains(histFile, "zsh") {
shell = "zsh"
}
lines := strings.Split(string(data), "\n")
start := len(lines) - 25
if start < 0 {
start = 0
}
for i := len(lines) - 1; i >= start; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, ": ") {
parts := strings.SplitN(line, ";", 2)
if len(parts) == 2 {
line = strings.TrimSpace(parts[1])
} else {
continue
}
}
if line == "" {
continue
}
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
}
}
max := 20
if len(entries) > max {
entries = entries[:max]
}
writeJSON(w, map[string]interface{}{"commands": entries})
}
func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) {
type proc struct {
PID int `json:"pid"`
Name string `json:"name"`
Command string `json:"command"`
CPU string `json:"cpu"`
Mem string `json:"mem"`
}
var procs []proc
editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"}
langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"}
interesting := append(editors, langs...)
interesting = append(interesting, "muyue")
cmd := exec.Command("ps", "aux")
out, err := cmd.Output()
if err != nil {
writeJSON(w, map[string]interface{}{"processes": procs})
return
}
lines := strings.Split(string(out), "\n")
for _, line := range lines[1:] {
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
fullCmd := strings.Join(fields[10:], " ")
name := filepath.Base(fields[10])
matched := false
for _, pattern := range interesting {
if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) {
matched = true
break
}
}
if !matched {
continue
}
var pid int
fmt.Sscanf(fields[1], "%d", &pid)
procs = append(procs, proc{
PID: pid,
Name: name,
Command: fullCmd,
CPU: fields[2],
Mem: fields[3],
})
}
writeJSON(w, map[string]interface{}{"processes": procs})
}
type sysMetrics struct {
CPUPercent float64 `json:"cpu_percent"`
MemPercent float64 `json:"mem_percent"`
MemUsedMB float64 `json:"mem_used_mb"`
MemTotalMB float64 `json:"mem_total_mb"`
NetRxKBs float64 `json:"net_rx_kbs"`
NetTxKBs float64 `json:"net_tx_kbs"`
}
var (
lastCPU [2]float64
lastNet [2]float64
lastNetTs time.Time
lastCPUSet bool
)
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
m := sysMetrics{}
// CPU from /proc/stat
if data, err := os.ReadFile("/proc/stat"); err == nil {
line := strings.Split(string(data), "\n")[0]
fields := strings.Fields(line)
if len(fields) >= 5 {
var idle, total float64
for i := 1; i < len(fields) && i <= 4; i++ {
var v float64
fmt.Sscanf(fields[i], "%f", &v)
total += v
if i == 4 {
idle = v
}
}
if lastCPUSet {
dIdle := idle - lastCPU[0]
dTotal := total - lastCPU[1]
if dTotal > 0 {
m.CPUPercent = (1 - dIdle/dTotal) * 100
}
}
lastCPU = [2]float64{idle, total}
lastCPUSet = true
}
}
// Memory from /proc/meminfo
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
var memTotal, memAvailable float64
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
var v float64
fmt.Sscanf(fields[1], "%f", &v)
switch fields[0] {
case "MemTotal:":
memTotal = v
case "MemAvailable:":
memAvailable = v
}
}
if memTotal > 0 {
m.MemTotalMB = memTotal / 1024
m.MemUsedMB = (memTotal - memAvailable) / 1024
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
}
}
// Network from /proc/net/dev
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
var rxBytes, txBytes float64
for _, line := range strings.Split(string(data), "\n")[2:] {
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
iface := strings.TrimSuffix(fields[0], ":")
if iface == "lo" {
continue
}
var rx, tx float64
fmt.Sscanf(fields[1], "%f", &rx)
fmt.Sscanf(fields[9], "%f", &tx)
rxBytes += rx
txBytes += tx
}
now := time.Now()
if !lastNetTs.IsZero() {
elapsed := now.Sub(lastNetTs).Seconds()
if elapsed > 0 {
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
if m.NetRxKBs < 0 {
m.NetRxKBs = 0
}
if m.NetTxKBs < 0 {
m.NetTxKBs = 0
}
}
}
lastNet = [2]float64{rxBytes, txBytes}
lastNetTs = now
}
writeJSON(w, m)
}

View File

@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"log"
"net/http"
"strings"
@@ -23,9 +24,26 @@ type Server struct {
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
mux: http.NewServeMux(),
}
// Auto-initialize config if nil or if no config file exists on disk
if cfg == nil || !config.Exists() {
defaultCfg := config.Default()
if cfg != nil {
// Preserve any user-provided settings from cfg
defaultCfg.Profile = cfg.Profile
defaultCfg.AI = cfg.AI
defaultCfg.Tools = cfg.Tools
defaultCfg.BMAD = cfg.BMAD
defaultCfg.Terminal = cfg.Terminal
}
// Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil {
log.Printf("config: initial save failed: %v", err)
}
cfg = defaultCfg
}
s.config = cfg
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.agentRegistry = agent.DefaultRegistry()
@@ -67,6 +85,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
@@ -95,6 +114,10 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {

View File

@@ -12,66 +12,66 @@ import (
)
type Profile struct {
Name string `yaml:"name"`
Pseudo string `yaml:"pseudo"`
Email string `yaml:"email"`
Languages []string `yaml:"languages"`
Name string `yaml:"name" json:"name"`
Pseudo string `yaml:"pseudo" json:"pseudo"`
Email string `yaml:"email" json:"email"`
Languages []string `yaml:"languages" json:"languages"`
Preferences struct {
Editor string `yaml:"editor"`
Shell string `yaml:"shell"`
Theme string `yaml:"theme"`
DefaultAI string `yaml:"default_ai"`
AutoUpdate bool `yaml:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"`
Language string `yaml:"language"`
KeyboardLayout string `yaml:"keyboard_layout"`
} `yaml:"preferences"`
Editor string `yaml:"editor" json:"editor"`
Shell string `yaml:"shell" json:"shell"`
Theme string `yaml:"theme" json:"theme"`
DefaultAI string `yaml:"default_ai" json:"default_ai"`
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
Language string `yaml:"language" json:"language"`
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
} `yaml:"preferences" json:"preferences"`
}
type AIProvider struct {
Name string `yaml:"name"`
APIKey string `yaml:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty"`
Model string `yaml:"model"`
Active bool `yaml:"active"`
Name string `yaml:"name" json:"name"`
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
Model string `yaml:"model" json:"model"`
Active bool `yaml:"active" json:"active"`
}
type ToolConfig struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
AutoUpdate bool `yaml:"auto_update"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
AutoUpdate bool `yaml:"auto_update" json:"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"`
Name string `yaml:"name" json:"name"`
Host string `yaml:"host" json:"host"`
Port int `yaml:"port" json:"port"`
User string `yaml:"user" json:"user"`
Password string `yaml:"password,omitempty" json:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
}
type MuyueConfig struct {
Version string `yaml:"version"`
Profile Profile `yaml:"profile"`
Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile" json:"profile"`
AI struct {
Providers []AIProvider `yaml:"providers"`
} `yaml:"ai"`
Tools []ToolConfig `yaml:"tools"`
Providers []AIProvider `yaml:"providers" json:"providers"`
} `yaml:"ai" json:"ai"`
Tools []ToolConfig `yaml:"tools" json:"tools"`
BMAD struct {
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Global bool `yaml:"global"`
} `yaml:"bmad"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
Global bool `yaml:"global" json:"global"`
} `yaml:"bmad" json:"bmad"`
Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh"`
FontSize int `yaml:"font_size"`
FontFamily string `yaml:"font_family"`
Theme string `yaml:"theme"`
} `yaml:"terminal"`
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
FontSize int `yaml:"font_size" json:"font_size"`
FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal" json:"terminal"`
}
type TerminalTheme struct {

View File

@@ -24,12 +24,12 @@ const (
)
type SystemInfo struct {
OS OS
Arch Arch
IsWSL bool
Shell string
Terminal string
PackageManager string
OS OS `json:"os"`
Arch Arch `json:"arch"`
IsWSL bool `json:"is_wsl"`
Shell string `json:"shell"`
Terminal string `json:"terminal"`
PackageManager string `json:"package_manager"`
}
func Detect() SystemInfo {

View File

@@ -14,27 +14,27 @@ import (
)
type ToolStatus struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Path string `yaml:"path"`
Latest string `yaml:"latest"`
NeedsUpdate bool `yaml:"needs_update"`
Category string `yaml:"category"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
Path string `yaml:"path" json:"path"`
Latest string `yaml:"latest" json:"latest"`
NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
Category string `yaml:"category" json:"category"`
}
type RuntimeStatus struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
}
type ScanResult struct {
System platform.SystemInfo `yaml:"system"`
Tools []ToolStatus `yaml:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"`
ShellSetup bool `yaml:"shell_setup"`
GitConfigured bool `yaml:"git_configured"`
System platform.SystemInfo `yaml:"system" json:"system"`
Tools []ToolStatus `yaml:"tools" json:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
GitConfigured bool `yaml:"git_configured" json:"git_configured"`
}
var (

View File

@@ -7,7 +7,7 @@ import (
const (
Name = "muyue"
Version = "0.3.2"
Version = "0.3.3"
Author = "La Légion de Muyue"
)

View File

@@ -37,6 +37,10 @@ const api = {
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
getDashboardStatus: () => request('/dashboard/status'),
getProvidersQuota: () => request('/providers/quota'),
getRecentCommands: () => request('/recent-commands'),
getRunningProcesses: () => request('/running-processes'),
getSystemMetrics: () => request('/system/metrics'),
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) }),
@@ -52,6 +56,7 @@ const api = {
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }),
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
sendChat: (message, stream = true, onChunk, signal) => {
if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
import { getTheme, applyTheme } from '../themes'
@@ -13,6 +13,9 @@ export default function App() {
const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date())
const [isSudo, setIsSudo] = useState(false)
const [dashRefreshKey, setDashRefreshKey] = useState(0)
const dashRefreshRef = useRef(null)
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null)
@@ -27,7 +30,7 @@ export default function App() {
], [t])
useEffect(() => {
api.getInfo().then(setInfo).catch(() => {})
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getConfig().then(d => {
@@ -60,6 +63,11 @@ export default function App() {
if (map[e.code]) {
e.preventDefault()
setActiveTab(map[e.code])
return
}
if (e.ctrlKey && e.code === 'KeyR') {
e.preventDefault()
if (dashRefreshRef.current) dashRefreshRef.current()
}
}
window.addEventListener('keydown', onKey)
@@ -72,27 +80,21 @@ export default function App() {
const installed = tools.filter(tool => tool.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
dash: [],
studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
shell: [
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
config: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
config: [],
}), [layout, t])
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard api={api} />
case 'dash': return <Dashboard api={api} refreshRef={dashRefreshRef} />
case 'studio': return <Studio api={api} />
case 'shell': return <Shell api={api} />
case 'config': return <Config api={api} />
@@ -147,6 +149,12 @@ export default function App() {
<footer className="statusbar">
<div className="statusbar-left">
{isSudo && <span className="statusbar-sudo"> ROOT</span>}
{activeTab === 'dash' && (
<span className="statusbar-shortcut">
<kbd>{layout.keys.ctrl}+R</kbd> refresh
</span>
)}
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div>
<div className="statusbar-right">

View File

@@ -34,14 +34,7 @@ export default function Config({ api }) {
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 || '',
})
setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
@@ -190,8 +183,8 @@ export default function Config({ api }) {
)}
{activePanel === 'locale' && (
<PanelLocale
language={keyboard} layouts={layouts}
setLanguage={setLanguage} setKeyboard={setKeyboard}
language={language} keyboard={keyboard} layouts={layouts}
api={api}
t={t}
/>
)}
@@ -209,57 +202,125 @@ export default function Config({ api }) {
}
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
const updateField = (path, value) => {
setProfileForm(prev => {
const next = JSON.parse(JSON.stringify(prev))
const keys = path.split('.')
let target = next
for (let i = 0; i < keys.length - 1; i++) {
if (target[keys[i]] == null) target[keys[i]] = {}
target = target[keys[i]]
}
target[keys[keys.length - 1]] = value
return next
})
}
const profile = editProfile ? profileForm : config?.profile
if (!profile) {
return (
<div className="config-card">
<div className="empty-state">{t('config.loadingProfile')}</div>
</div>
)
}
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">
<RenderFields obj={profile} path="" editing={editProfile} onChange={updateField} t={t} />
<div className="config-card-actions">
{editProfile ? (
<>
<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>
)}
</>
) : (
<button className="primary sm" onClick={() => {
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
setEditProfile(true)
}}>{t('config.editProfile')}</button>
)}
</div>
</div>
)
}
function RenderFields({ obj, path, editing, onChange, t }) {
if (!obj || typeof obj !== 'object') return null
return Object.entries(obj).map(([key, value]) => {
const fieldPath = path ? `${path}.${key}` : key
const label = getFieldLabel(key, t)
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return (
<div key={key} className="config-card-group">
<span className="config-card-group-label">{label}</span>
<RenderFields obj={value} path={fieldPath} editing={editing} onChange={onChange} t={t} />
</div>
)
}
if (editing) {
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
</label>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
</div>
)
}
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
</div>
)
}
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
</div>
)
}
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
</div>
)
})
}
function getFieldLabel(key, t) {
const translated = t(`config.${key}`)
if (translated !== `config.${key}`) return translated
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null)
@@ -331,7 +392,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
</div>
</div>
<div className="provider-card-meta" style={{ marginTop: 8 }}>
<span className="mono">{p.model || '—'}</span>
{p.active && <span className="badge ok" style={{ marginRight: 6 }}>active</span>}
{p.model && p.model !== p.name && <span className="mono">{p.model}</span>}
</div>
</div>
</div>
@@ -399,9 +461,29 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
)
}
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
function PanelLocale({ language, keyboard, layouts, api, t }) {
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState(null)
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2500)
}
const handleSave = async () => {
setSaving(true)
try {
await api.savePreferences({ language, keyboard_layout: keyboard })
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setSaving(false)
}
return (
<div className="config-card">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-card-group">
<span className="config-card-group-label">{t('config.language')}</span>
<div className="chip-row">
@@ -430,6 +512,11 @@ function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t
))}
</div>
</div>
<div className="config-card-actions">
<button className="primary sm" onClick={handleSave} disabled={saving}>
{saving ? t('config.saving') : t('config.save')}
</button>
</div>
</div>
)
}

View File

@@ -1,438 +1,176 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useI18n } from '../i18n'
const TOOL_ICONS = {
crush: '⚡',
claude: '🤖',
go: '🔷',
node: '🟢',
python: '🐍',
docker: '🐳',
git: '📚',
ssh: '🌐',
starship: '🚀',
rust: '🦀',
}
function ToolCard({ tool, onInstall, installing }) {
const { t } = useI18n()
const [showInstall, setShowInstall] = useState(false)
const icon = TOOL_ICONS[tool.name?.toLowerCase()] || '🔧'
const isInstalled = tool.installed || tool.status === 'installed'
const version = tool.version || ''
const hasUpdate = tool.hasUpdate || tool.updateAvailable
const MAX_POINTS = 30
function MiniGraph({ data, max, color, label, unit }) {
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
const m = max || Math.max(...data, 1)
const w = 100
const h = 32
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = h - (v / m) * h
return `${x},${y}`
}).join(' ')
const last = data[data.length - 1]
return (
<div className={`tool-card ${isInstalled ? 'installed' : 'missing'}`}>
<div className="tool-card-icon">{icon}</div>
<div className="tool-card-info">
<div className="tool-card-name">{tool.name || 'Unknown'}</div>
<div className="tool-card-version">
{isInstalled ? (
<span className="status-ok">{t('dashboard.installed')}</span>
) : (
<span className="status-missing">{t('dashboard.missing')}</span>
)}
{version && <span className="tool-version-text">{version}</span>}
</div>
</div>
<div className="tool-card-actions">
{isInstalled && hasUpdate && (
<span className="tool-update-badge" title={`Update to ${tool.latestVersion || 'latest'}`}>
{tool.latestVersion || 'new'}
</span>
)}
{!isInstalled && (
<button
className="sm primary"
onClick={() => onInstall(tool.name)}
disabled={installing}
>
{installing ? '...' : t('dashboard.install')}
</button>
)}
<div className="dash-graph-wrap">
<div className="dash-graph-header">
<span className="dash-graph-label">{label}</span>
<span className="dash-graph-value" style={{ color }}>{last.toFixed(1)}{unit}</span>
</div>
<svg viewBox={`0 0 ${w} ${h}`} className="dash-graph-svg" preserveAspectRatio="none">
<defs>
<linearGradient id={`fg-${color.replace('#','').replace('var(','').replace(')','')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<polygon fill={`url(#fg-${color.replace('#','').replace('var(','').replace(')','')})`} points={`${points} ${w},${h} 0,${h}`} />
<polyline fill="none" stroke={color} strokeWidth="1.5" points={points} vectorEffect="non-scaling-stroke" />
</svg>
</div>
)
}
function ActivityItem({ entry }) {
const time = entry.time
? new Date(entry.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: ''
const type = entry.type || entry.level || 'info'
const text = entry.message || entry.text || entry.content || ''
const typeClass = {
ok: 'notif-ok',
success: 'notif-ok',
install: 'notif-ok',
update: 'notif-info',
info: 'notif-info',
warn: 'notif-warn',
warning: 'notif-warn',
error: 'notif-error',
fail: 'notif-error',
}[type] || 'notif-info'
const icon = {
ok: '✓', success: '✓', install: '✓', update: '→',
info: '', warn: '⚠', warning: '⚠', error: '✗', fail: '✗',
}[type] || '•'
return (
<div className={`notif-row ${typeClass}`}>
<span className="notif-time">{time}</span>
<span className="notif-icon">{icon}</span>
<span className="notif-text">{text}</span>
</div>
)
}
function QuickActionButton({ icon, label, onClick, loading, disabled }) {
return (
<button
className="quick-action-btn"
onClick={onClick}
disabled={disabled || loading}
>
{loading ? <span className="spinner" style={{ width: 14, height: 14 }} /> : <span className="quick-action-icon">{icon}</span>}
<span className="quick-action-label">{label}</span>
</button>
)
}
export default function Dashboard({ api }) {
export default function Dashboard({ api, refreshRef }) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState('tools')
const [tools, setTools] = useState([])
const [updates, setUpdates] = useState([])
const [systemInfo, setSystemInfo] = useState(null)
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(false)
const [installing, setInstalling] = useState(false)
const [scanLoading, setScanLoading] = useState(false)
const [mcpLoading, setMcpLoading] = useState(false)
const [dashboardStatus, setDashboardStatus] = useState(null)
const [quota, setQuota] = useState(null)
const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null)
const cpuRef = useRef([])
const memRef = useRef([])
const netRxRef = useRef([])
const netTxRef = useRef([])
const loadData = useCallback(async () => {
try {
const [toolsData, updatesData, systemData] = await Promise.all([
api.getTools().catch(() => ({ tools: [] })),
api.getUpdates().catch(() => ({ updates: [] })),
api.getSystem().catch(() => null),
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
api.getProvidersQuota().catch(() => null),
api.getRecentCommands().catch(() => ({ commands: [] })),
api.getRunningProcesses().catch(() => ({ processes: [] })),
api.getSystemMetrics().catch(() => null),
])
setTools(toolsData.tools || toolsData || [])
setUpdates(updatesData.updates || updatesData || [])
setSystemInfo(systemData)
api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {})
setQuota(quotaData?.providers || [])
setRecentCmds(cmdData.commands || [])
setProcesses(procData.processes || [])
if (metricsData) {
setMetrics(metricsData)
cpuRef.current = [...cpuRef.current, metricsData.cpu_percent].slice(-MAX_POINTS)
memRef.current = [...memRef.current, metricsData.mem_percent].slice(-MAX_POINTS)
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
}
} catch (err) {
console.error('Failed to load dashboard data:', err)
console.error('Dashboard load error:', err)
}
}, [api])
useEffect(() => {
loadData()
}, [loadData])
if (refreshRef) refreshRef.current = loadData
const iv = setInterval(loadData, 5000)
return () => clearInterval(iv)
}, [loadData, refreshRef])
const addNotification = (message, type = 'info') => {
const entry = { id: Date.now(), time: new Date().toISOString(), message, type }
setNotifications(prev => [entry, ...prev].slice(0, 100))
}
const handleRescan = async () => {
setScanLoading(true)
addNotification(t('dashboard.rescanning'), 'info')
try {
await api.runScan()
await loadData()
addNotification(t('dashboard.scanComplete'), 'ok')
} catch (err) {
addNotification(`${t('dashboard.scanFailed')}: ${err.message}`, 'error')
} finally {
setScanLoading(false)
}
}
const handleInstallMissing = async () => {
const missing = tools.filter(t => !t.installed && t.status !== 'installed')
if (missing.length === 0) return
setInstalling(true)
addNotification(t('dashboard.installing', { count: missing.length }), 'info')
try {
await api.installTools(missing.map(t => t.name))
addNotification(t('dashboard.installStarted'), 'ok')
setTimeout(() => handleRescan(), 2000)
} catch (err) {
addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error')
} finally {
setInstalling(false)
}
}
const handleCheckUpdates = async () => {
setLoading(true)
addNotification(t('config.checking'), 'info')
try {
const data = await api.getUpdates()
setUpdates(data.updates || data || [])
const count = (data.updates || data || []).length
if (count > 0) {
addNotification(t('dashboard.updatesCount', { count }), 'warn')
} else {
addNotification(t('dashboard.allUpToDate'), 'ok')
}
} catch (err) {
addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
const handleConfigureMCP = async () => {
setMcpLoading(true)
addNotification(t('dashboard.configuringMCP'), 'info')
try {
await api.configureMCP()
addNotification(t('dashboard.mcpConfigured'), 'ok')
} catch (err) {
addNotification(`${t('dashboard.mcpConfigFailed')}: ${err.message}`, 'error')
} finally {
setMcpLoading(false)
}
}
const handleInstallTool = async (name) => {
setInstalling(true)
addNotification(`${t('dashboard.installing')} ${name}...`, 'info')
try {
await api.installTools([name])
addNotification(`${name} ${t('dashboard.installed')}`, 'ok')
setTimeout(() => loadData(), 2000)
} catch (err) {
addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error')
} finally {
setInstalling(false)
}
}
const installedCount = tools.filter(t => t.installed || t.status === 'installed').length
const missingCount = tools.length - installedCount
const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai')
return (
<div className="dashboard-layout">
<div className="dashboard-tabs">
<button
className={`dashboard-tab ${activeTab === 'tools' ? 'active' : ''}`}
onClick={() => setActiveTab('tools')}
>
<span className="tab-icon">🔧</span>
{t('dashboard.tools')}
<span className="tab-count">{installedCount}</span>
</button>
<button
className={`dashboard-tab ${activeTab === 'activity' ? 'active' : ''}`}
onClick={() => setActiveTab('activity')}
>
<span className="tab-icon">📋</span>
{t('dashboard.activity')}
{notifications.length > 0 && <span className="tab-count warn">{notifications.length}</span>}
</button>
<button
className={`dashboard-tab ${activeTab === 'actions' ? 'active' : ''}`}
onClick={() => setActiveTab('actions')}
>
<span className="tab-icon"></span>
{t('dashboard.quickActions')}
</button>
<button
className={`dashboard-tab ${activeTab === 'status' ? 'active' : ''}`}
onClick={() => setActiveTab('status')}
>
<span className="tab-icon">📡</span>
{t('dashboard.status') || 'Status'}
</button>
<div className="dash-grid">
{/* CPU */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">CPU</span>
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
</div>
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
</div>
<div className="dashboard-content">
{activeTab === 'tools' && (
<div className="dashboard-tools-panel">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
<div className="dashboard-tools-stats">
<span className="stat-ok">{installedCount} {t('dashboard.installed')}</span>
{missingCount > 0 && <span className="stat-missing">{missingCount} {t('dashboard.missing')}</span>}
{/* RAM */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">RAM</span>
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
</div>
{/* Network */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Network</span>
<span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
</div>
{/* API Quota */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">API Quota</span>
</div>
<div className="dash-quota-list">
{minimax && minimax.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div>
{systemInfo && (
<div className="dashboard-system-info">
<span className="sys-info-item">{systemInfo.os || systemInfo.platform || 'Unknown'}</span>
<span className="sys-info-sep">·</span>
<span className="sys-info-item">{systemInfo.arch || 'Unknown'}</span>
{systemInfo.shell && <><span className="sys-info-sep">·</span><span className="sys-info-item">{systemInfo.shell}</span></>}
</div>
)}
<div className="tools-grid">
{tools.length === 0 && (
<div className="empty-state">{t('dashboard.noTools')}</div>
)}
{tools.map((tool, i) => (
<ToolCard
key={tool.name || i}
tool={tool}
onInstall={handleInstallTool}
installing={installing}
/>
))}
))}
{minimax && minimax.data?.models?.length === 0 && (
<div className="dash-quota-row">
<span className="dash-quota-name">MiniMax</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
</div>
</div>
)}
{activeTab === 'activity' && (
<div className="dashboard-activity-panel">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
<button className="sm ghost" onClick={() => setNotifications([])} disabled={notifications.length === 0}>
{t('dashboard.clearLog')}
</button>
)}
{zai && (
<div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
</div>
{notifications.length === 0 ? (
<div className="empty-state">{t('dashboard.noActivity')}</div>
) : (
<div className="activity-log">
{notifications.map(entry => (
<ActivityItem key={entry.id} entry={entry} />
))}
</div>
)}
</div>
)}
)}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div>
</div>
{activeTab === 'actions' && (
<div className="dashboard-actions-panel">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.quickActions')}</div>
{/* Running Processes */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Processes</span>
<span className="dash-count">{processes.length}</span>
</div>
<div className="dash-proc-list">
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
{processes.map((p, i) => (
<div key={i} className="dash-proc-row">
<span className="dash-proc-name">{p.name}</span>
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
</div>
<div className="quick-actions-grid">
<QuickActionButton
icon="🔍"
label={t('dashboard.rescanSystem')}
onClick={handleRescan}
loading={scanLoading}
/>
<QuickActionButton
icon="📦"
label={t('dashboard.installMissing')}
onClick={handleInstallMissing}
loading={installing}
disabled={missingCount === 0}
/>
<QuickActionButton
icon="🔄"
label={t('dashboard.checkUpdates')}
onClick={handleCheckUpdates}
loading={loading}
/>
<QuickActionButton
icon="⚙"
label={t('dashboard.configureMCP')}
onClick={handleConfigureMCP}
loading={mcpLoading}
/>
))}
</div>
</div>
{/* Recent Commands */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Recent Commands</span>
</div>
<div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}>
<span className="dash-cmd-shell">{c.shell}</span>
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
</div>
{updates.length > 0 && (
<div className="dashboard-updates-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.updates')}</div>
<span className="badge warn">{updates.length}</span>
</div>
<div className="updates-list">
{updates.map((update, i) => (
<div key={update.name || i} className="update-row">
<div className="update-info">
<span className="update-name">{update.name || 'Unknown'}</span>
<span className="update-versions">
{update.current || update.version || '?'} {update.latest || update.target || '?'}
</span>
</div>
<button
className="sm"
onClick={() => api.runUpdate(update.name)}
disabled={loading}
>
{t('dashboard.update')}
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'status' && (
<div className="dashboard-status-panel">
{dashboardStatus ? (
<>
<div className="dashboard-section-header">
<div className="dashboard-section-title">MCP Servers</div>
<span className="badge">{dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy</span>
</div>
<div className="tools-grid" style={{ marginBottom: 16 }}>
{(dashboardStatus.mcp?.servers || []).map((s, i) => (
<div key={i} className={`tool-card ${s.healthy ? 'installed' : s.installed ? '' : 'missing'}`}>
<div className="tool-card-info">
<div className="tool-card-name">{s.name}</div>
<div className="tool-card-version">
{s.healthy ? <span className="status-ok">healthy</span> :
s.installed ? <span className="status-missing">installed</span> :
<span className="status-missing">not found</span>}
</div>
</div>
</div>
))}
</div>
<div className="dashboard-section-header">
<div className="dashboard-section-title">LSP Servers</div>
<span className="badge">{dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed</span>
</div>
<div className="tools-grid" style={{ marginBottom: 16 }}>
{(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => (
<div key={i} className="tool-card installed">
<div className="tool-card-info">
<div className="tool-card-name">{s.name}</div>
<div className="tool-card-version">
<span className="status-ok">{s.language}</span>
</div>
</div>
</div>
))}
</div>
<div className="dashboard-section-header">
<div className="dashboard-section-title">Skills</div>
<span className="badge">{dashboardStatus.skills?.total || 0} deployed</span>
{(dashboardStatus.skills?.issues || []).length > 0 && (
<span className="badge warn">{(dashboardStatus.skills.issues || []).length} issues</span>
)}
</div>
{(dashboardStatus.skills?.issues || []).length > 0 && (
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 8 }}>
{(dashboardStatus.skills.issues || []).map((issue, i) => (
<div key={i}>{issue}</div>
))}
</div>
)}
</>
) : (
<div className="empty-state">Loading status...</div>
)}
</div>
)}
))}
</div>
</div>
</div>
)
}
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
@@ -32,6 +32,8 @@ export default function OnboardingWizard({ api, onComplete }) {
const [validating, setValidating] = useState(false)
const [keyValid, setKeyValid] = useState(false)
const [scanning, setScanning] = useState(false)
const [scanMessage, setScanMessage] = useState('')
const scanAbortRef = useRef(null)
const current = STEPS[step]
const layouts = getLayoutList()
@@ -50,7 +52,7 @@ export default function OnboardingWizard({ api, onComplete }) {
case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard
case 'apikey': return true
case 'apikey': return keyValid && !scanning
case 'editor': return true
case 'done': return true
default: return true
@@ -61,14 +63,82 @@ export default function OnboardingWizard({ api, onComplete }) {
if (step > 0) setStep(step - 1)
}
const cycleOption = (key, list, dir) => {
const idx = list.findIndex(item => item.id === answers[key])
const next = (idx + dir + list.length) % list.length
setAnswers(a => ({ ...a, [key]: list[next].id }))
}
const cycleOptionEditor = (dir) => {
const idx = editorList.findIndex(ed => ed === answers.editor)
const next = (idx + dir + editorList.length) % editorList.length
setAnswers(a => ({ ...a, editor: editorList[next] }))
}
const handleScanViaChat = async (apikey) => {
setScanning(true)
setScanMessage('Recherche des éditeurs sur votre système...')
setError(null)
try {
const detected = []
const fallback = async () => {
setScanMessage('Utilisation du scan local...')
const data = await api.getEditors()
return (data.editors || []).map(e => e.name)
}
const prompt = 'Liste tous les éditeurs de texte et IDE installés sur ce système. Exécute les commandes nécessaires pour les détecter (which, command -v, etc.). Réponds UNIQUEMENT avec les noms séparés par des virgules, sans aucune autre explication. Exemples: vim, nvim, code, emacs, nano, helix, subl, zed'
const ctrl = new AbortController()
scanAbortRef.current = ctrl
const full = await api.sendChat(prompt, true, (text, data) => {
if (data.tool_call) setScanMessage('Exécution: ' + (data.tool_call.name || '...'))
else if (data.tool_result) setScanMessage('Analyse des résultats...')
else if (data.content) setScanMessage('Réception: ' + text.slice(0, 60) + (text.length > 60 ? '...' : ''))
}, ctrl.signal)
const names = full.split(/[,\n]/).map(s => s.replace(/[^a-zA-Z0-9._-]/g, '')).filter(Boolean)
if (names.length > 0) {
detected.push(...names)
} else {
detected.push(...(await fallback()))
}
setEditorList([...new Set(detected.map(n => n.toLowerCase()))])
setScanMessage('')
} catch (err) {
try {
setScanMessage('Fallback: scan local...')
const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name)
setEditorList([...new Set(detected)])
} catch {}
setScanMessage('')
}
setScanning(false)
}
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { goPrev(); return }
if (current.key === 'language') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('language', LANGUAGES, 1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('language', LANGUAGES, -1); return }
}
if (current.key === 'keyboard') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('keyboard', layouts, 1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('keyboard', layouts, -1); return }
}
if (current.key === 'editor') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOptionEditor(1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOptionEditor(-1); return }
}
if (e.key === 'Tab') { e.preventDefault(); const input = document.querySelector('.onboarding-input'); if (input) input.focus(); return }
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [step, current])
}, [step, current, answers, editorList])
useEffect(() => {
return () => { if (scanAbortRef.current) scanAbortRef.current.abort() }
}, [])
useEffect(() => {
if (current.key === 'done' && !saving) {
@@ -88,6 +158,14 @@ export default function OnboardingWizard({ api, onComplete }) {
base_url: 'https://api.minimax.io/v1',
})
setKeyValid(true)
await api.saveProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
active: true,
})
handleScanViaChat(answers.apikey)
} catch (err) {
setError(err.message || 'Clé invalide')
setKeyValid(false)
@@ -95,22 +173,7 @@ export default function OnboardingWizard({ api, onComplete }) {
setValidating(false)
}
const handleScanEditors = async () => {
setScanning(true)
setError(null)
try {
const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name)
const merged = [...new Set([...detected, ...BASE_EDITORS])]
setEditorList(merged)
if (detected.length === 0) {
setError('Aucun éditeur détecté')
}
} catch (err) {
setError(err.message || 'Erreur lors du scan')
}
setScanning(false)
}
const handleSave = async () => {
setSaving(true)
@@ -154,9 +217,10 @@ export default function OnboardingWizard({ api, onComplete }) {
</div>
<div className="onboarding-progress">
{STEPS.map((_, i) => (
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
))}
{STEPS.filter(s => s.key !== 'done').map(s => {
const i = STEPS.indexOf(s)
return <div key={s.key} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
})}
</div>
<div className="onboarding-body">
@@ -221,7 +285,7 @@ export default function OnboardingWizard({ api, onComplete }) {
<div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div>
<div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
</div>
<input
className="onboarding-input"
@@ -232,7 +296,14 @@ export default function OnboardingWizard({ api, onComplete }) {
autoFocus
/>
{error && !keyValid && <div className="onboarding-required">{error}</div>}
{keyValid && <div className="onboarding-valid">Clé valide ✓</div>}
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
{scanning && (
<div className="onboarding-scanning">
<Loader size={14} className="spin-icon" />
<span>{scanMessage}</span>
</div>
)}
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
className="sm primary"
@@ -241,16 +312,9 @@ export default function OnboardingWizard({ api, onComplete }) {
>
{validating ? 'Validation...' : 'Valider la clé'}
</button>
<button
className="sm ghost"
onClick={goNext}
disabled={!answers.apikey.trim()}
>
Passer
</button>
</div>
{answers.apikey.trim() && !keyValid && !error && (
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
{!keyValid && !error && answers.apikey.trim() && (
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
)}
</div>
)}
@@ -258,37 +322,20 @@ export default function OnboardingWizard({ api, onComplete }) {
{current.key === 'editor' && (
<div className="onboarding-step">
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="onboarding-chips" style={{ flex: 1 }}>
{editorList.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
</div>
<button
className="sm ghost"
onClick={handleScanEditors}
disabled={scanning}
title="Détecter les éditeurs installés"
style={{ marginLeft: 8, flexShrink: 0 }}
>
{scanning ? <Loader size={14} className="spin-icon" /> : <Search size={14} />}
</button>
<div className="onboarding-desc">
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
</div>
<div className="onboarding-chips">
{editorList.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
</div>
<input
className="onboarding-input"
style={{ marginTop: 12 }}
placeholder="Autre éditeur..."
value={answers.editor}
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus
/>
{error && <div className="onboarding-required">{error}</div>}
</div>
)}
@@ -394,6 +441,10 @@ export default function OnboardingWizard({ api, onComplete }) {
.onboarding-hint {
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
}
.onboarding-scanning {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: var(--accent); margin-top: 4px;
}
.spin-icon {
animation: spin 1s linear infinite;
}

View File

@@ -284,6 +284,7 @@ export default function Studio({ api }) {
const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const messagesEnd = useRef(null)
const textareaRef = useRef(null)
const abortRef = useRef(null)
@@ -297,6 +298,11 @@ export default function Studio({ api }) {
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
}
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
setLoaded(true)
}).catch(() => {
setMessages([
@@ -317,6 +323,28 @@ export default function Studio({ api }) {
}
}, [input])
const refreshTokens = useCallback(async () => {
try {
const data = await api.getChatHistory()
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
} catch {}
}, [api])
const handleSummarize = useCallback(async () => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
try {
const data = await api.summarizeChat()
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString() }])
} catch (err) {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
}
}, [api])
const handleClear = useCallback(async () => {
try {
await api.clearChat()
@@ -341,6 +369,7 @@ export default function Studio({ api }) {
'## Commandes Studio',
'',
'- `/clear` - Effacer la conversation',
'- `/summarize` - Résumer la conversation précédente',
'- `/help` - Afficher cette aide',
'- `/plan <objectif>` - Demander un plan structuré',
'- `/export` - Exporter la conversation en Markdown',
@@ -359,6 +388,11 @@ export default function Studio({ api }) {
return
}
if (text === '/summarize') {
handleSummarize()
return
}
if (text === '/model') {
api.getProviders().then(data => {
const active = data.providers?.find(p => p.active)
@@ -474,8 +508,9 @@ export default function Studio({ api }) {
setStreamThinking('')
setStreamToolCalls([])
abortRef.current = null
refreshTokens()
}
}, [input, loading, api, t, handleClear, streaming])
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
const handleStop = useCallback(() => {
if (abortRef.current) {
@@ -515,6 +550,18 @@ export default function Studio({ api }) {
</div>
<div className="studio-input-area">
<div className="studio-token-bar">
<div className="studio-token-track">
<div
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/>
</div>
<span className="studio-token-text">
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
{tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'}
</span>
</div>
<div className="studio-input-row">
<textarea
ref={textareaRef}
@@ -543,7 +590,7 @@ export default function Studio({ api }) {
)}
</div>
<div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear /help /plan /export /model
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model
</div>
</div>
</div>

View File

@@ -169,6 +169,12 @@ input::placeholder { color: var(--text-disabled); }
color: var(--text-disabled);
}
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
.statusbar-sudo {
font-size: 10px; font-weight: 700; font-family: var(--font-mono);
padding: 1px 6px; border-radius: 3px;
background: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3);
text-transform: uppercase; letter-spacing: 0.5px;
}
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
.statusbar-shortcut kbd {
display: inline-block; padding: 1px 5px; border-radius: 3px;
@@ -423,7 +429,7 @@ input::placeholder { color: var(--text-disabled); }
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.config-tabs-bar {
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
display: flex; gap: 4px; padding: 12px 20px; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
@@ -525,10 +531,132 @@ 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 Grid ── */
.dash-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 16px;
height: 100%;
overflow: hidden;
}
.dash-card {
position: relative;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px;
overflow: hidden;
}
.dash-span-2 { grid-column: span 2; }
.dash-card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 4px;
}
.dash-label {
font-size: 11px; font-weight: 700; color: var(--accent);
text-transform: uppercase; letter-spacing: 0.5px;
}
.dash-count {
font-size: 10px; font-family: var(--font-mono);
background: var(--bg-input); padding: 1px 6px; border-radius: 10px;
}
.dash-count.warn { background: var(--accent-bg); color: var(--accent); }
/* Tools row */
.dash-tools-row {
display: flex; flex-wrap: wrap; gap: 6px;
}
.dash-tool-tag {
font-size: 11px; font-family: var(--font-mono);
padding: 3px 8px; border-radius: var(--radius);
background: var(--bg-surface);
}
.dash-tool-tag.ok { color: var(--success); }
.dash-tool-tag.missing { color: var(--error); }
/* Quota */
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; max-height: 270px; overflow-y: auto; }
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
.dash-quota-name {
font-size: 11px; font-weight: 600; color: var(--text-primary);
min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dash-bar {
flex: 1; height: 4px; background: var(--bg-input); border-radius: 2px; overflow: hidden;
}
.dash-bar-fill {
height: 100%; background: var(--accent); border-radius: 2px;
transition: width 0.3s;
}
.dash-quota-val {
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
white-space: nowrap;
}
/* Processes */
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
.dash-proc-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0;
}
.dash-proc-name {
font-size: 11px; font-weight: 600; color: var(--text-primary);
font-family: var(--font-mono);
}
.dash-proc-res {
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
}
/* Commands */
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
.dash-cmd-row {
display: flex; align-items: center; gap: 6px;
padding: 3px 0; overflow: hidden;
}
.dash-cmd-shell {
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
text-transform: uppercase; flex-shrink: 0;
}
.dash-cmd-text {
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; }
.dash-svc-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0;
}
.dash-svc-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.dash-svc-val { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); }
.dash-svc-issues { margin-top: 4px; }
.dash-svc-issue { font-size: 10px; color: var(--warning); padding: 2px 0; }
/* Updates */
.dash-updates-list { display: flex; flex-direction: column; gap: 4px; }
.dash-update-row {
display: flex; justify-content: space-between; align-items: center;
}
.dash-update-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.dash-update-ver { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); }
.dash-empty { font-size: 11px; color: var(--text-disabled); }
/* Graph */
.dash-graph-wrap { display: flex; flex-direction: column; gap: 2px; }
.dash-graph-header { display: flex; justify-content: space-between; align-items: center; }
.dash-graph-label { font-size: 9px; color: var(--text-disabled); text-transform: uppercase; }
.dash-graph-value { font-size: 10px; font-family: var(--font-mono); font-weight: 600; }
.dash-graph-svg { width: 100%; height: 32px; }
.dash-graph-empty { font-size: 10px; color: var(--text-disabled); text-align: center; padding: 8px 0; }
/* Legacy dashboard kept for reference */
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-content { flex: 1; overflow-y: auto; }
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
.dashboard-section {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
@@ -540,11 +668,8 @@ input::placeholder { color: var(--text-disabled); }
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 0.5px;
}
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
.dashboard-notifications { padding: 0; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
@@ -557,7 +682,6 @@ input::placeholder { color: var(--text-disabled); }
.notif-ok .notif-text { color: var(--success); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
@@ -565,81 +689,6 @@ input::placeholder { color: var(--text-disabled); }
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
/* ── Dashboard Tabs ── */
.dashboard-tabs {
display: flex; gap: 4px; padding: 12px 20px 0;
border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0;
}
.dashboard-tab {
padding: 8px 16px; border-radius: var(--radius) var(--radius) 0 0;
border: 1px solid transparent; border-bottom: none; background: transparent;
color: var(--text-tertiary); font-size: 12px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; gap: 6px; transition: all 0.15s;
}
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
.dashboard-tab.active { background: var(--bg-card); color: var(--accent); border-color: var(--border); }
.dashboard-tab .tab-icon { font-size: 14px; }
.dashboard-tab .tab-count {
background: var(--bg-input); padding: 1px 6px; border-radius: 10px; font-size: 10px; font-family: var(--font-mono);
}
.dashboard-tab .tab-count.warn { background: var(--accent-bg); color: var(--accent); }
.dashboard-tools-panel { padding: 20px 24px; }
.dashboard-tools-stats { display: flex; gap: 12px; font-size: 12px; }
.stat-ok { color: var(--success); font-family: var(--font-mono); }
.stat-missing { color: var(--error); font-family: var(--font-mono); }
.dashboard-system-info { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 12px; color: var(--text-tertiary); }
.sys-info-item { font-family: var(--font-mono); }
.sys-info-sep { color: var(--text-disabled); }
.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; }
.tool-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s;
}
.tool-card:hover { border-color: var(--accent-dim); }
.tool-card.installed { border-left: 3px solid var(--success); }
.tool-card.missing { border-left: 3px solid var(--error); }
.tool-card-icon { font-size: 20px; flex-shrink: 0; }
.tool-card-info { flex: 1; min-width: 0; }
.tool-card-name { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; }
.tool-card-version { font-size: 11px; color: var(--text-tertiary); display: flex; align-items: center; gap: 6px; }
.tool-version-text { font-family: var(--font-mono); font-size: 10px; color: var(--text-disabled); }
.status-ok { color: var(--success); }
.status-missing { color: var(--error); }
.tool-card-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
.tool-update-badge { background: var(--accent-bg); color: var(--accent); font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 4px; cursor: pointer; }
.tool-update-badge:hover { background: var(--accent-dim); }
.dashboard-activity-panel { padding: 20px 24px; }
.activity-log { display: flex; flex-direction: column; gap: 2px; }
.notif-icon { font-size: 12px; width: 16px; text-align: center; }
.dashboard-actions-panel { padding: 20px 24px; }
.quick-actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 24px; }
.quick-action-btn {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 16px 20px; display: flex; align-items: center; gap: 12px; cursor: pointer;
transition: all 0.2s; font-size: 13px; color: var(--text-secondary);
}
.quick-action-btn:hover:not(:disabled) { border-color: var(--accent-dim); background: var(--bg-hover); color: var(--text-primary); }
.quick-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.quick-action-icon { font-size: 18px; }
.quick-action-label { font-weight: 600; }
.dashboard-updates-section { margin-top: 16px; }
.updates-list { display: flex; flex-direction: column; gap: 6px; }
.update-row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-radius: var(--radius); background: var(--bg-card);
border: 1px solid var(--border);
}
.update-row:hover { border-color: var(--accent-dim); }
.update-info { display: flex; align-items: center; gap: 16px; }
.update-name { font-weight: 600; color: var(--text-primary); font-size: 13px; min-width: 100px; }
.update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.panel-header {
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
border-bottom: 1px solid var(--border); background: var(--bg-surface);
@@ -679,6 +728,18 @@ input::placeholder { color: var(--text-disabled); }
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
.feed-compressed-indicator {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; margin: 4px 0;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-compressed-indicator:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
.feed-compressed-indicator svg { color: var(--accent); flex-shrink: 0; }
.feed-compressed-text { font-size: 12px; color: var(--text-tertiary); flex: 1; }
.feed-compressed-count { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-thinking-block {
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
@@ -738,6 +799,16 @@ input::placeholder { color: var(--text-disabled); }
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
.studio-token-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.studio-token-fill.warn { background: var(--warning); }
.studio-token-fill.compressed { height: 2px; }
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.studio-token-text.compressed { font-size: 9px; }
.studio-token-track.compressed { height: 2px; }
.studio-token-bar.compressed { margin-bottom: 4px; }
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
@@ -762,6 +833,21 @@ input::placeholder { color: var(--text-disabled); }
.studio-stop-btn:hover { opacity: 0.8; }
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
/* ── Collapsed Messages ── */
.feed-collapsed-messages {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px; margin: 4px 0;
background: linear-gradient(135deg, var(--bg-surface), var(--bg-elevated));
border: 1px dashed var(--border-accent);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-collapsed-messages:hover { background: var(--bg-hover); border-color: var(--accent); }
.feed-collapsed-messages svg { color: var(--accent); flex-shrink: 0; }
.feed-collapsed-text { font-size: 11px; color: var(--text-tertiary); flex: 1; }
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);