Compare commits

...

21 Commits

Author SHA1 Message Date
Augustin
0830e64ae6 fix(shell,config): terminal font size, AI tools, provider keys
All checks were successful
Beta Release / beta (push) Successful in 56s
- Fix terminal default fontSize from 6px to 14px across all references
- Add terminal tool to shell AI via ChatEngine with tool_call streaming
- Fix provider key detection (apiKey → api_key, baseURL → base_url)
- Add mimo provider migration and validation endpoint
- Bump version to 0.4.0

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 22:03:35 +02:00
Augustin
9a218b1904 fix(shell): set default terminal fontSize to 6px
All checks were successful
Beta Release / beta (push) Successful in 49s
All fallbacks were still using 12px. User confirmed 6px is the
correct baseline on their display.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:41:47 +02:00
Augustin
399b845e14 fix(shell): default fontSize 10px and init new tabs immediately
All checks were successful
Beta Release / beta (push) Successful in 48s
- Base font size reduced from 12px to 10px
- New tabs now initialize directly when added (was waiting for
  tab switch because the MutationObserver only fired on visibility
  changes, not on tab additions)
- Zoom level applied to newly created terminals

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:33:49 +02:00
Augustin
436d5c6149 feat(shell): add Ctrl+/- zoom and display all shortcuts in footer
All checks were successful
Beta Release / beta (push) Successful in 48s
- Ctrl+/Ctrl-/Ctrl+0 to zoom in/out/reset terminal font size
- Zoom badge indicator in tab bar
- All shell shortcuts now shown in statusbar footer
- Added i18n labels for search, zoom, switch tab, next tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:28:15 +02:00
Augustin
5a9edc076e fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility
All checks were successful
Beta Release / beta (push) Successful in 49s
The addon-web-links registerApcHandler API requires xterm >= 6.1.0.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:19:12 +02:00
Augustin
5bdc7a6429 fix(shell): enable allowProposedApi for Unicode11 addon
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:16:23 +02:00
Augustin
5a0480bae0 fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution
All checks were successful
Beta Release / beta (push) Successful in 49s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:12:05 +02:00
Augustin
80de4dd523 feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
Some checks failed
Beta Release / beta (push) Failing after 20s
Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM
fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and
inline image protocol. All existing functionality preserved.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:10:15 +02:00
Augustin
de52f4ebd6 fix(shell): restore all missing imports, constants, and utility functions
All checks were successful
Beta Release / beta (push) Successful in 49s
- Restore xterm imports (Terminal, FitAddon, WebLinksAddon)
- Restore all lucide-react icons (Globe, X, Plus, ChevronDown, etc.)
- Restore module-level constants (AI_TAB_ID, MAX_TABS, SHELL_MAX_TOKENS,
  TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY)
- Restore renderContent() and formatText() utility functions
- Add @xterm/xterm CSS import
- Remove duplicate constants from inside Shell component

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:02:36 +02:00
Augustin
98ff0dd578 fix(shell): add missing Monitor import from lucide-react
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:56:32 +02:00
Augustin
9a1ff6e8dc fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:53:38 +02:00
Augustin
034b9ee0e4 fix(shell): add missing useI18n import
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:51:54 +02:00
Augustin
c1b1fc653f fix(shell): remove stray 'impo' typo causing ReferenceError
All checks were successful
Beta Release / beta (push) Successful in 44s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:50:12 +02:00
Augustin
50ca75180c fix(terminal): improve dimensions handling and add system theme for xterm
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 21:43:10 +02:00
Augustin
b8aa935bec fix(shell): resolve savedTabs undefined ReferenceError in activeTab init
All checks were successful
Beta Release / beta (push) Successful in 50s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:36:25 +02:00
Augustin
5627ddd2ce fix(terminal): improve dimension calculation and tab init reliability
All checks were successful
Beta Release / beta (push) Successful in 48s
- Guarantee minimum 24x80 dimensions on WebSocket open
- Force reflow before init attempts
- Multiple fit attempts with increasing delays (0/50/100/200/400ms)
- Validate saved tabs structure from localStorage
- Resize active tab after closing another tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:30:07 +02:00
Augustin
d27872572a fix(dashboard): show MiMo quota instead of ZAI on dashboard
All checks were successful
Beta Release / beta (push) Successful in 47s
Replace Z.AI quota display with MiMo provider in the API Quota card.
ZAI is now a hidden fallback and should not appear in the dashboard.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:28:22 +02:00
Augustin
7d0f807fb0 feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback
All checks were successful
Beta Release / beta (push) Successful in 57s
Add MiMo-V2.5-Pro from Xiaomi Token Plan as a new AI provider with
base URL https://token-plan-ams.xiaomimimo.com/v1. The /model change
command now switches between MiniMax and MiMo only. ZAI is always
placed last in the fallback chain as the provider of ultimate resort.
Config panel shows MiniMax and MiMo cards.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:22:34 +02:00
Augustin
cbf623b98b fix(terminal): use absolute positioning for content panels
All checks were successful
Beta Release / beta (push) Successful in 50s
height:100% on .content>div fails because .content uses flex:1
without explicit height. Switch to position:absolute;inset:0 which
correctly fills the content area and gives xterm proper container
dimensions for fitAddon.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:13:20 +02:00
Augustin
b85ebb8e54 feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts
All checks were successful
Beta Release / beta (push) Successful in 48s
xterm captures all keyboard input which prevents standard clipboard
operations. Add custom key handler to intercept Ctrl+Shift+C for
copy (selection) and Ctrl+Shift+V for paste, without interfering
with Ctrl+C (SIGINT) or browser devtools shortcut.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:10:24 +02:00
Augustin
7cc206dc20 fix(shell): prevent Enter in AI chat from leaking to terminal
All checks were successful
Beta Release / beta (push) Successful in 48s
Stop propagation of Enter keydown in AI input and defer terminal
focus to next event loop tick to prevent xterm from capturing the
same key event.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:07:36 +02:00
19 changed files with 811 additions and 128 deletions

View File

@@ -187,6 +187,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
switch body.Name {
case "minimax":
baseURL = "https://api.minimax.io/v1"
case "mimo":
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
case "openai":
baseURL = "https://api.openai.com/v1"
case "anthropic":

View File

@@ -530,6 +530,11 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
}
}
}
case "mimo":
q.Healthy = p.APIKey != ""
if p.APIKey == "" {
q.Error = "no API key"
}
case "claude", "anthropic":
// Claude Code n'a pas d'API externe, vérifier l'installation
claudePath := "/usr/bin/claude"

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -51,26 +52,31 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return
}
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
orb.SetTools(s.shellAgentToolsJSON)
if req.Stream {
s.handleShellChatStreamV2(w, orb)
s.handleShellChatStream(w, orb)
} else {
s.handleShellChatNonStreamV2(w, orb)
s.handleShellChatNonStream(w, orb)
}
}
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
func (s *Server) buildShellSystemPrompt(_ ShellChatRequest) string {
var sb strings.Builder
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
RÈGLES STRICTES:
- Tu ne peux JAMAIS exécuter de commande ou de code
- Tu ne peux que analyser, expliquer, et proposer des solutions
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
OUTILS DISPONIBLES:
- terminal: Exécute des commandes shell sur le système local et retourne le résultat
RÈGLES:
- Utilise l'outil terminal pour exécuter des commandes quand c'est nécessaire
- Analyse les résultats et explique-les clairement
- Formate tes réponses en markdown avec des blocs de code quand approprié
- Sois concis et technique
- Quand tu proposes des commandes alternatives, utilise des blocs de code markdown
`)
@@ -89,43 +95,42 @@ RÈGLES STRICTES:
return sb.String()
}
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
// Rebuild history into orchestrator
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] { // all except last user msg
if m.Role == "system" {
continue
ctx := context.Background()
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
// Pre-load orchestrator history
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
lastUserMsg := history[len(history)-1].Content
var finalContent string
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
finalContent = chunk
sseWriter.Write(map[string]interface{}{"content": chunk})
sseWriter.Write(data)
if canFlush {
flusher.Flush()
}
})
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
content := result
if content == "" {
content = finalContent
storeContent := finalContent
if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{
"content": storeContent,
"tool_calls": allToolCalls,
"tool_results": allToolResults,
}
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
}
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
s.shellConvStore.Add("assistant", storeContent)
sseWriter.Write(map[string]interface{}{
"done": "true",
@@ -133,30 +138,62 @@ func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrato
})
}
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] {
if m.Role == "system" {
continue
}
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
ctx := context.Background()
messages := s.buildShellContextMessages()
lastUserMsg := history[len(history)-1].Content
result, err := orb.Send(lastUserMsg)
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
s.shellConvStore.Add("assistant", finalContent)
writeJSON(w, map[string]interface{}{
"content": result,
"content": finalContent,
"tokens": s.shellConvStore.ApproxTokens(),
})
}
func (s *Server) buildShellContextMessages() []orchestrator.Message {
history := s.shellConvStore.Get()
start := 0
const shellContextWindow = 20
if len(history) > shellContextWindow {
start = len(history) - shellContextWindow
}
messages := make([]orchestrator.Message, 0, len(history[start:]))
for _, m := range history[start:] {
content := m.Content
if m.Role == "assistant" {
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
content = parsed.Content
}
}
role := m.Role
if role == "system" {
continue
}
messages = append(messages, orchestrator.Message{
Role: role,
Content: content,
})
}
return messages
}
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)

View File

@@ -13,14 +13,16 @@ import (
)
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
shellConvStore *ShellConvStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
shellConvStore *ShellConvStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -52,6 +54,14 @@ func NewServer(cfg *config.MuyueConfig) *Server {
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
s.shellAgentRegistry = agent.NewRegistry()
terminalTool, _ := agent.NewTerminalTool()
s.shellAgentRegistry.Register(terminalTool)
shellTools := s.shellAgentRegistry.OpenAITools()
shellToolsJSON, _ := json.Marshal(shellTools)
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
s.routes()
return s

View File

@@ -128,6 +128,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
},
}
func migrateProviders(cfg *MuyueConfig) {
defaults := Default().AI.Providers
for _, dp := range defaults {
found := false
for _, p := range cfg.AI.Providers {
if p.Name == dp.Name {
found = true
break
}
}
if !found {
cfg.AI.Providers = append(cfg.AI.Providers, dp)
}
}
}
func GetTerminalTheme(name string) TerminalTheme {
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
return theme
@@ -206,6 +222,8 @@ func Load() (*MuyueConfig, error) {
}
}
migrateProviders(&cfg)
return &cfg, nil
}
@@ -269,6 +287,12 @@ func Default() *MuyueConfig {
BaseURL: "https://api.minimax.io/v1",
Active: true,
},
{
Name: "mimo",
Model: "MiMo-V2.5-Pro",
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
Active: false,
},
{
Name: "zai",
Model: "glm",
@@ -297,6 +321,7 @@ func Default() *MuyueConfig {
cfg.Terminal.CustomPrompt = true
cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14
return cfg
}

View File

@@ -476,6 +476,8 @@ func getProviderBaseURL(name string) string {
return "https://api.openai.com/v1"
case "zai":
return "https://api.z.ai/v1"
case "mimo":
return "https://token-plan-ams.xiaomimimo.com/v1"
default:
return ""
}
@@ -503,11 +505,19 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
if o.provider != nil {
providerOrder = append(providerOrder, o.provider)
}
var zaiProvider *config.AIProvider
for _, p := range providers {
if o.provider == nil || p.Name != o.provider.Name {
providerOrder = append(providerOrder, p)
if p.Name == "zai" {
zaiProvider = p
} else {
providerOrder = append(providerOrder, p)
}
}
}
if zaiProvider != nil {
providerOrder = append(providerOrder, zaiProvider)
}
var lastErr error
var triedProviders []string

View File

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

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

48
web/package-lock.json generated
View File

@@ -7,8 +7,12 @@
"name": "muyue-web",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.10.0-beta.203",
"@xterm/addon-search": "^0.17.0-beta.203",
"@xterm/addon-unicode11": "^0.10.0-beta.203",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.1.0-beta.203",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
@@ -406,16 +410,52 @@
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.203",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.203.tgz",
"integrity": "sha512-1hRy7/jYCYvUhc6GYu177EdsW44QQQHsq71Odvo6cEhHKEEoqFsrOnLpe9WuNWZXgqpCwy2Cnp6FepHm960Eiw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.203"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.17.0-beta.203",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.203.tgz",
"integrity": "sha512-agxzh30h4L82kjGlTwWEsaXnXzOuMIAm80+zcNElFL/hHuT/nLvcwRng+s7RzOWNNLG3pB4jbTHqbBaM+nW8mg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.203"
}
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.10.0-beta.203",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.203.tgz",
"integrity": "sha512-KqMOqqpeEPQw5TQLb8jNHPESjZSwenFzhBPNA1g2zcPY5JtZ15pFzzoFxXdzS5LYmdYxexpd8s2ianf8WmQKyg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.203"
}
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.202",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.202.tgz",
"integrity": "sha512-GCh0QlUv77XX8cJt8/7AVdDUNFpa1f6MGX/skhciu5ZRK88hR1m8T+8MZ3FYfddLV6phY0ksmiO9ErC0R+7G/A==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.203"
}
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"version": "6.1.0-beta.203",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.203.tgz",
"integrity": "sha512-Ctqf05M6fPWZkfKxC4hy2+PP5P2BlVnJLbIsXZMpkCz/MjJvcf5OwwsGkq+nzhFDuojSX+rc2RxIetLONUBGqw==",
"license": "MIT",
"workspaces": [
"addons/*"

View File

@@ -9,8 +9,12 @@
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.10.0-beta.203",
"@xterm/addon-search": "^0.17.0-beta.203",
"@xterm/addon-unicode11": "^0.10.0-beta.203",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.1.0-beta.203",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"

View File

@@ -141,7 +141,11 @@ const api = {
if (data.error) { reject(new Error(data.error)); return }
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
if (data.content) {
full = data.content
full += data.content
if (onChunk) onChunk(full, data)
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data)
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data)
}
} catch {}

View File

@@ -92,6 +92,12 @@ export default function App() {
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
],
shell: [
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
{ keys: `${layout.keys.ctrl}+F`, desc: t('statusbar.search') },
{ keys: `${layout.keys.ctrl}+/Ctrl`, desc: t('statusbar.zoom') },
{ keys: `Alt+1-7`, desc: t('statusbar.switchTab') },
{ keys: `${layout.keys.shift}+Tab`, desc: t('statusbar.nextTab') },
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
],

View File

@@ -101,9 +101,9 @@ export default function Config({ api }) {
...prev,
[p.name]: {
name: p.name,
api_key: p.apiKey || '',
api_key: p.api_key || '',
model: p.model || '',
base_url: p.baseURL || '',
base_url: p.base_url || '',
},
}))
setEditProvider(p.name)
@@ -314,7 +314,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
const validateKey = async (p) => {
setValidating(p.name)
try {
await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' })
await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
} catch (err) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
@@ -324,9 +324,9 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
useEffect(() => {
providers.forEach(p => {
if (p.apiKey && !keyStatus[p.name]) {
if (p.api_key && !keyStatus[p.name]) {
validateKey(p)
} else if (!p.apiKey) {
} else if (!p.api_key) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
}
})
@@ -343,7 +343,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null)
}
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'mimo')
return (
<div className="config-providers-list">
@@ -370,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<input
className="config-form-input"
type="password"
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)

View File

@@ -91,7 +91,7 @@ export default function Dashboard({ api, refreshRef }) {
}, [loadData, refreshRef])
const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai')
const mimo = (quota || []).find(p => p.name === 'mimo')
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
@@ -186,22 +186,22 @@ export default function Dashboard({ api, refreshRef }) {
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
</div>
)}
{zai && zai.data?.models?.map((m, i) => (
{mimo && mimo.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model)}</span>
<span className="dash-quota-name">{String(m.model).replace('MiMo-', '')}</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>
))}
{zai && !zai.data?.models?.length && (
{mimo && !mimo.data?.models?.length && (
<div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{zai.error || 'no data'}</span>
<span className="dash-quota-name">MiMo</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{mimo.error || (mimo.healthy ? '✓ configured' : 'no key')}</span>
</div>
)}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
{!minimax && !mimo && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div>
</div>

View File

@@ -1,11 +1,16 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye } from 'lucide-react'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { ImageAddon } from '@xterm/addon-image'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react'
import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n'
const AI_TAB_ID = 0
const MAX_TABS = 7
const SHELL_MAX_TOKENS = 100000
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
@@ -67,7 +72,70 @@ function formatText(text) {
return html
}
// === Style thème système pour xterm ===
function getCSSVariable(varName) {
if (typeof document === 'undefined') return null;
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || null;
}
function parseHexColor(hex) {
if (!hex || hex.startsWith('var(')) return null;
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
if (hex.length !== 6) return null;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
function toRgbString(hex) {
const c = parseHexColor(hex);
if (!c) return '#000000';
return `#${c.r.toString(16).padStart(2, '0')}${c.g.toString(16).padStart(2, '0')}${c.b.toString(16).padStart(2, '0')}`;
}
function buildSystemTheme() {
const bg = getCSSVariable('--bg-base') || '#0F0D10';
const fg = getCSSVariable('--text-primary') || '#EAE0E2';
const accent = getCSSVariable('--accent-light') || '#FF1A5E';
const accentDim = getCSSVariable('--accent-dim') || '#6B2033';
const success = '#00E676';
const warning = '#FFD740';
const error = getCSSVariable('--accent-bright') || '#FF1744';
const bgSurface = getCSSVariable('--bg-surface') || bg;
const bgElevated = getCSSVariable('--bg-elevated') || bgSurface;
const textSecondary = getCSSVariable('--text-secondary') || fg;
const textTertiary = getCSSVariable('--text-tertiary') || textSecondary;
return {
background: toRgbString(bg),
foreground: toRgbString(fg),
cursor: toRgbString(accent),
cursorAccent: toRgbString(bg),
selectionBackground: `${toRgbString(accentDim)}44`,
selectionForeground: '#FFFFFF',
black: toRgbString(bgElevated),
red: toRgbString(error),
green: toRgbString(success),
yellow: toRgbString(warning),
blue: toRgbString(getCSSVariable('--accent') || '#448AFF'),
magenta: toRgbString(accent),
cyan: '#00BCD4',
white: toRgbString(fg),
brightBlack: toRgbString(bgSurface),
brightRed: toRgbString(accent),
brightGreen: toRgbString(success),
brightYellow: toRgbString(warning),
brightBlue: toRgbString(getCSSVariable('--accent-muted') || '#82B1FF'),
brightMagenta: toRgbString(getCSSVariable('--accent-soft') || '#FF80AB'),
brightCyan: '#84FFFF',
brightWhite: '#FFFFFF',
};
}
const THEMES = {
system: buildSystemTheme(),
default: {
background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033',
cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff',
@@ -125,14 +193,18 @@ const THEMES = {
}
function getTheme(themeName) {
return THEMES[themeName] || THEMES.default
if (themeName === 'system' || themeName === 'default') {
return buildSystemTheme()
}
return THEMES[themeName] || buildSystemTheme()
}
function createTerminal(container, settings = {}) {
const theme = getTheme(settings.theme || 'default')
const theme = getTheme(settings.theme || 'system')
const term = new XTerm({
cursorBlink: true,
fontSize: settings.fontSize || 12,
allowProposedApi: true,
fontSize: settings.fontSize || 14,
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme,
allowTransparency: false,
@@ -141,12 +213,76 @@ function createTerminal(container, settings = {}) {
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()
const imageAddon = new ImageAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.loadAddon(searchAddon)
term.loadAddon(unicode11Addon)
term.loadAddon(imageAddon)
term.unicode.activeVersion = '11'
try {
const webglAddon = new WebglAddon()
webglAddon.onContextLoss(() => { webglAddon.dispose() })
term.loadAddon(webglAddon)
} catch (e) {
console.warn('[Shell] WebGL renderer not available, using DOM fallback:', e)
}
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
if (ctrl && shift && e.key === 'C') {
e.preventDefault()
e.stopPropagation()
const selection = term.getSelection()
if (selection) navigator.clipboard.writeText(selection)
return false
}
if (ctrl && shift && e.key === 'V') {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.readText().then(text => {
if (text) term.paste(text)
}).catch(() => {})
return false
}
if (ctrl && (e.key === '=' || e.key === '+')) {
e.preventDefault()
e.stopPropagation()
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: 1 }))
return false
}
if (ctrl && e.key === '-') {
e.preventDefault()
e.stopPropagation()
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: -1 }))
return false
}
if (ctrl && e.key === '0') {
e.preventDefault()
e.stopPropagation()
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: 0 }))
return false
}
return true
})
term.open(container)
fitAddon.fit()
return { term, fitAddon }
return { term, fitAddon, searchAddon }
}
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
@@ -156,9 +292,20 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes
ws.addEventListener('open', () => {
ws.send(JSON.stringify(initPayload))
const dims = fitAddon.proposeDimensions()
if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
}
// Envoyer resize avec dimensions minimales garanties (24x80)
const rows = dims?.rows || 24
const cols = dims?.cols || 80
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
// Forcer un fit après l'ouverture
setTimeout(() => {
try {
fitAddon.fit()
const newDims = fitAddon.proposeDimensions()
if (newDims && newDims.rows > 0 && newDims.cols > 0) {
ws.send(JSON.stringify({ type: 'resize', rows: newDims.rows, cols: newDims.cols }))
}
} catch (e) { console.warn('[Shell] fit failed:', e) }
}, 50)
if (onStateChange) onStateChange(true)
})
@@ -203,29 +350,44 @@ export default function Shell({ api }) {
const { t } = useI18n()
const tabsRef = useRef({})
const nextIdRef = useRef(1)
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
const settingsRef = useRef({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' })
const pendingCommandsRef = useRef({})
const savedTabs = (() => {
const [tabs, setTabs] = useState(() => {
try {
const raw = localStorage.getItem(TABS_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map(t => ({ ...t, connected: false }))
if (Array.isArray(parsed) && parsed.length > 0 && parsed.length <= MAX_TABS) {
return parsed.map((t, i) => ({
id: t.id || i + 1,
name: t.name || `Tab ${i + 1}`,
type: t.type || 'local',
shell: t.shell || '',
host: t.host,
port: t.port,
user: t.user,
key_path: t.key_path,
connected: false
}))
}
}
} catch {}
return null
})()
const [tabs, setTabs] = useState(savedTabs || [
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
])
const [activeTab, setActiveTab] = useState(() => {
if (savedTabs) {
return savedTabs[0]?.id || 1
} catch (e) {
console.warn('[Shell] Failed to parse saved tabs:', e)
localStorage.removeItem(TABS_STORAGE_KEY)
}
return [
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
]
})
const [activeTab, setActiveTab] = useState(() => {
try {
const raw = localStorage.getItem(TABS_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) return parsed[0]?.id || 1
}
} catch {}
return 1
})
const activeTabRef = useRef(activeTab)
@@ -237,13 +399,45 @@ export default function Shell({ api }) {
const [editingTab, setEditingTab] = useState(null)
const [editName, setEditName] = useState('')
const [terminalSettings, setTerminalSettings] = useState({
fontSize: 12,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: 'default',
theme: 'system',
})
const [showSearch, setShowSearch] = useState(false)
const [searchText, setSearchText] = useState('')
const searchInputRef = useRef(null)
const searchDecorationsRef = useRef(null)
const [zoomLevel, setZoomLevel] = useState(0)
const baseFontSizeRef = useRef(12)
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
useEffect(() => {
baseFontSizeRef.current = terminalSettings.fontSize || 14
}, [terminalSettings.fontSize])
useEffect(() => {
const handler = (e) => {
const direction = e.detail
setZoomLevel(prev => {
let next
if (direction === 0) next = 0
else next = Math.max(-8, Math.min(10, prev + direction))
const newSize = baseFontSizeRef.current + next * 2
for (const entry of Object.values(tabsRef.current)) {
if (entry.term && !entry.term._disposed) {
entry.term.options.fontSize = newSize
try { entry.fitAddon.fit() } catch {}
}
}
return next
})
}
window.addEventListener('shell-zoom', handler)
return () => window.removeEventListener('shell-zoom', handler)
}, [])
const [sshForm, setSshForm] = useState({
name: '', host: '', port: 22, user: '', key_path: '',
})
@@ -302,9 +496,9 @@ export default function Shell({ api }) {
api.getConfig().then(d => {
if (d.terminal) {
setTerminalSettings({
fontSize: d.terminal.font_size || 12,
fontSize: d.terminal.font_size || 14,
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: d.terminal.theme || 'default',
theme: d.terminal.theme || 'system',
})
}
}).catch(() => {})
@@ -317,8 +511,9 @@ export default function Shell({ api }) {
if (!container) return
const s = settingsRef.current
const { term, fitAddon } = createTerminal(container, {
fontSize: s.fontSize,
const effectiveFontSize = s.fontSize + zoomLevel * 2
const { term, fitAddon, searchAddon } = createTerminal(container, {
fontSize: effectiveFontSize,
fontFamily: s.fontFamily,
theme: s.theme,
})
@@ -409,7 +604,7 @@ export default function Shell({ api }) {
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
tabsRef.current[tabId] = { term, fitAddon, searchAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
@@ -456,33 +651,63 @@ export default function Shell({ api }) {
let cancelled = false
const pending = []
// Forcer le layout à se calculer
const forceLayout = () => {
const el = document.querySelector('.shell-terminal-col')
if (el) {
el.style.height = ''
el.style.minHeight = ''
// Forcer reflow
void el.offsetHeight
}
}
const tryInitTab = (tab, attempt) => {
if (cancelled) return
const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 200))
if (attempt > 20) {
console.warn(`[Shell] max attempts reached for tab ${tab.id}`)
return
}
forceLayout()
const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150))
return
}
const container = document.getElementById(`terminal-${tab.id}`)
if (!container) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
return
}
if (container.offsetHeight === 0) {
const rect = container.getBoundingClientRect()
if (rect.height < 10 || rect.width < 10) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
return
}
if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab)
}
requestAnimationFrame(() => {
if (cancelled) return
const entry = tabsRef.current[tab.id]
if (entry) {
entry.fitAddon.fit()
setTimeout(() => { if (!cancelled) entry.fitAddon.fit() }, 100)
}
// Multiple fit attempts avec délais croissants
const fitAttempts = [0, 50, 100, 200, 400]
fitAttempts.forEach(delay => {
setTimeout(() => {
if (cancelled) return
const entry = tabsRef.current[tab.id]
if (entry && entry.fitAddon) {
try {
entry.fitAddon.fit()
} catch (e) { console.warn(`[Shell] fit attempt ${delay}ms failed:`, e) }
}
}, delay)
})
}
for (const tab of tabs) {
if (!tabsRef.current[tab.id]) {
tryInitTab(tab, 0)
}
@@ -545,6 +770,15 @@ export default function Shell({ api }) {
useEffect(() => {
const onKey = (e) => {
const ctrl = e.ctrlKey || e.metaKey
if (ctrl && e.shiftKey && e.key === 'F') {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
setShowSearch(prev => !prev)
return
}
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
@@ -568,6 +802,49 @@ export default function Shell({ api }) {
return () => window.removeEventListener('keydown', onKey)
}, [tabs])
useEffect(() => {
if (showSearch && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [showSearch])
const handleSearchChange = useCallback((value) => {
setSearchText(value)
const entry = tabsRef.current[activeTabRef.current]
if (!entry?.searchAddon) return
if (!value) {
entry.searchAddon.clearDecorations()
entry.searchAddon.clearActiveDecoration()
return
}
try {
searchDecorationsRef.current = entry.searchAddon.findNext(value)
} catch {}
}, [])
const handleSearchNext = useCallback(() => {
const entry = tabsRef.current[activeTabRef.current]
if (!entry?.searchAddon || !searchText) return
try { entry.searchAddon.findNext(searchText) } catch {}
}, [searchText])
const handleSearchPrev = useCallback(() => {
const entry = tabsRef.current[activeTabRef.current]
if (!entry?.searchAddon || !searchText) return
try { entry.searchAddon.findPrevious(searchText) } catch {}
}, [searchText])
const handleCloseSearch = useCallback(() => {
setShowSearch(false)
setSearchText('')
const entry = tabsRef.current[activeTabRef.current]
if (entry?.searchAddon) {
entry.searchAddon.clearDecorations()
entry.searchAddon.clearActiveDecoration()
}
if (entry?.term) entry.term.focus()
}, [])
const addLocalTab = (shell, name) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
@@ -624,6 +901,19 @@ export default function Shell({ api }) {
}
return next
})
// Redimensionner le nouveau tab actif
setTimeout(() => {
const newActiveTabId = next.length > 0 ? next[next.length - 1].id : null
if (newActiveTabId) {
const entry = tabsRef.current[newActiveTabId]
if (entry && entry.fitAddon) {
try {
entry.fitAddon.fit()
} catch (e) { console.warn('[Shell] fit after close failed:', e) }
}
}
}, 100)
}
const startRename = (tabId, e) => {
@@ -693,7 +983,7 @@ export default function Shell({ api }) {
if (!fromEvent) {
setAiInput('')
focusAiTerminal()
setTimeout(() => focusAiTerminal(), 0)
}
if (trimmed === '/clear') {
@@ -710,7 +1000,7 @@ export default function Shell({ api }) {
if (trimmed === '/help') {
setAiMessages(prev => [...prev,
{ role: 'user', content: trimmed },
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe peux exécuter des commandes via l\'outil terminal. Les blocs de code proposés peuvent aussi être copiés ou envoyés directement au terminal actif.' }
])
aiLoadingRef.current = false
return
@@ -723,17 +1013,56 @@ export default function Shell({ api }) {
try {
let accumulated = ''
await api.sendShellChat(trimmed, {}, true, (partial) => {
let toolCalls = []
const controller = new AbortController()
await api.sendShellChat(trimmed, {}, true, (partial, event) => {
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
})
return
}
if (event && event.tool_result) {
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
if (idx >= 0) {
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
})
}
return
}
if (event && (event.thinking !== undefined || event.thinking_end)) {
return
}
accumulated = partial
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }]
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }]
})
})
}, controller.signal)
const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab }
if (toolCalls.length > 0) {
finalMsg._toolCalls = toolCalls
finalMsg.content = JSON.stringify({
content: accumulated,
tool_calls: toolCalls.map(tc => tc.call),
tool_results: toolCalls.map(tc => ({
tool_call_id: tc.call?.tool_call_id,
result: tc.result?.content || '',
is_error: tc.result?.is_error || false,
})),
})
}
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
return [...filtered, finalMsg]
})
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
@@ -824,6 +1153,11 @@ export default function Shell({ api }) {
</div>
<div className="shell-tab-actions">
{zoomLevel !== 0 && (
<span className="shell-zoom-badge">
{zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px
</span>
)}
{tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
@@ -884,6 +1218,26 @@ export default function Shell({ api }) {
</div>
<div className="shell-xterm-wrapper">
{showSearch && (
<div className="shell-search-bar">
<Search size={14} className="shell-search-icon" />
<input
ref={searchInputRef}
className="shell-search-input"
value={searchText}
onChange={e => handleSearchChange(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() }
if (e.key === 'Escape') handleCloseSearch()
e.stopPropagation()
}}
placeholder="Rechercher..."
/>
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)"></button>
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)"></button>
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button>
</div>
)}
{tabs.map(tab => (
<div
key={tab.id}
@@ -937,7 +1291,7 @@ export default function Shell({ api }) {
<input
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend() } }}
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
disabled={aiAtLimit && aiInput !== '/clear'}
/>
@@ -1021,6 +1375,40 @@ export default function Shell({ api }) {
)
}
function ShellToolBlock({ call, result }) {
const icon = '⌨'
const label = call.name === 'terminal' ? 'Terminal' : call.name
const isErr = result && result.is_error
let argsPreview = ''
try {
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
if (args.command) argsPreview = args.command
else argsPreview = JSON.stringify(args).slice(0, 80)
} catch {
argsPreview = String(call.args).slice(0, 80)
}
const truncatedResult = result ? (result.content || '').slice(0, 1500) : null
return (
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
<div className="studio-tool-header">
<span className="studio-tool-icon">{icon}</span>
<span className="studio-tool-name">{label}</span>
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
</div>
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
{truncatedResult && (
<div className="studio-tool-result">
<pre>{truncatedResult}</pre>
</div>
)}
</div>
)
}
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const content = msg.content || ''
@@ -1033,10 +1421,38 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
return <div className={`ai-message system`}>{content}</div>
}
const parts = renderContent(content)
let parsedToolCalls = null
let parsedToolResults = null
let displayContent = content
let streamingToolCalls = msg._toolCalls || null
if (!streamingToolCalls) {
try {
const parsed = JSON.parse(content)
if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
}
} catch {}
}
const parts = renderContent(displayContent)
return (
<div className={`ai-message assistant`}>
{streamingToolCalls && streamingToolCalls.map((tc, i) => (
<ShellToolBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))}
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
: null
const result = resultData
? { content: resultData.result, is_error: resultData.is_error }
: null
return <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
})}
{parts.map((part, i) => {
if (part.type === 'code') {
return (

View File

@@ -452,15 +452,15 @@ export default function Studio({ api }) {
api.getProviders().then(data => {
const providers = data.providers || []
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
const zai = providers.find(p => p.name.toUpperCase() === 'ZAI')
if (!minimax || !zai) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
const mimo = providers.find(p => p.name.toUpperCase() === 'MIMO')
if (!minimax || !mimo) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et MiMo doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
return
}
const active = providers.find(p => p.active)
const activeName = active ? active.name.toUpperCase() : ''
const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX'
const target = switchTo === 'MINIMAX' ? minimax : zai
const switchTo = activeName === 'MINIMAX' ? 'MIMO' : 'MINIMAX'
const target = switchTo === 'MINIMAX' ? minimax : mimo
api.saveProvider({ name: target.name, active: true }).then(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
}).catch(() => {

View File

@@ -16,6 +16,12 @@ const en = {
switchWindow: 'Switch window',
sendMessage: 'Send message',
newLine: 'New line',
copy: 'Copy',
paste: 'Paste',
search: 'Search',
zoom: 'Zoom +/',
switchTab: 'Switch tab',
nextTab: 'Next tab',
runCommand: 'Run command',
commandHistory: 'Command history',
},

View File

@@ -16,6 +16,12 @@ const fr = {
switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne',
copy: 'Copier',
paste: 'Coller',
search: 'Rechercher',
zoom: 'Zoom +/\u2212',
switchTab: 'Changer d\u2019onglet',
nextTab: 'Onglet suivant',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},

View File

@@ -155,7 +155,7 @@ input::placeholder { color: var(--text-disabled); }
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
.content { flex: 1; overflow: hidden; position: relative; }
.content > div { height: 100%; }
.content > div { position: absolute; inset: 0; overflow: hidden; }
.tab-hidden { display: none; }
.statusbar {
@@ -329,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.shell-zoom-badge {
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
color: var(--accent); background: var(--accent-bg);
padding: 2px 6px; border-radius: 3px;
border: 1px solid var(--accent-dim);
white-space: nowrap;
}
.shell-new-tab-wrapper { position: relative; }
.shell-new-tab-btn {
display: flex; align-items: center; gap: 2px;
@@ -383,6 +391,36 @@ input::placeholder { color: var(--text-disabled); }
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
.shell-search-bar {
position: absolute; top: 8px; right: 12px; z-index: 20;
display: flex; align-items: center; gap: 4px;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius); padding: 4px 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
.shell-search-input {
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
font-family: var(--font-mono); outline: none;
}
.shell-search-input:focus { border-color: var(--accent); }
.shell-search-nav {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 4px;
background: transparent; border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
padding: 0; transition: all 0.1s;
}
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
.shell-search-close {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 4px;
background: transparent; border: none;
color: var(--text-disabled); cursor: pointer; padding: 0;
}
.shell-search-close:hover { color: var(--accent); }
.shell-xterm-instance {
position: absolute;
inset: 0;
@@ -1058,3 +1096,76 @@ input::placeholder { color: var(--text-disabled); }
word-break: break-word;
background: var(--bg);
}
/* === XTerm Custom Styling === */
/* Styles for xterm.js integrated with Muyue theme */
.shell-xterm-instance .xterm {
padding: 4px 8px;
}
.shell-xterm-instance .xterm-viewport {
background-color: var(--bg-base) !important;
}
.shell-xterm-instance .xterm-screen {
background-color: var(--bg-base);
}
/* Scrollbar styling for xterm */
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar {
width: 8px;
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track {
background: var(--bg-surface);
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb {
background: var(--accent-dim);
border-radius: 4px;
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: var(--accent-dark);
}
/* Selection styling */
.shell-xterm-instance .xterm-selection {
background: var(--accent-dim) !important;
}
/* Focus ring styling */
.shell-xterm-instance .xterm:focus .xterm-helper-text-container {
box-shadow: none;
}
/* Ensure consistent font rendering */
.shell-xterm-instance .xterm .xterm-char-measure-element {
font-family: var(--font-mono) !important;
}
/* Bell animation styling */
.shell-xterm-instance .xterm-bell {
animation: xterm-bell-flash 0.3s ease-out;
}
@keyframes xterm-bell-flash {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 0; }
}
/* Cursor styling */
.shell-xterm-instance .xterm-cursor {
outline: none !important;
}
/* Link styling for web links addon */
.shell-xterm-instance .xterm-link {
color: var(--accent-light) !important;
text-decoration: underline;
}
.shell-xterm-instance .xterm-link:hover {
color: var(--accent-muted) !important;
}