diff --git a/internal/agent/definitions.go b/internal/agent/definitions.go
index d830440..037ae56 100644
--- a/internal/agent/definitions.go
+++ b/internal/agent/definitions.go
@@ -6,11 +6,18 @@ import (
"os"
"os/exec"
"path/filepath"
+ "regexp"
"strings"
"sync"
"time"
)
+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
+
+func stripANSI(s string) string {
+ return ansiRegex.ReplaceAllString(s, "")
+}
+
var (
sudoCache bool
sudoCacheSet bool
@@ -103,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
output, err := cmd.CombinedOutput()
result := string(output)
+ result = stripANSI(result)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
@@ -116,7 +124,8 @@ func NewTerminalTool() (*ToolDefinition, error) {
}
type CrushRunParams struct {
- Task string `json:"task" description:"The task description for Crush to execute"`
+ Task string `json:"task" description:"The task description for Crush to execute"`
+ Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 600, max 900)"`
}
func NewCrushRunTool() (*ToolDefinition, error) {
@@ -127,7 +136,14 @@ func NewCrushRunTool() (*ToolDefinition, error) {
return TextErrorResponse("task is required"), nil
}
- ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
+ timeout := time.Duration(p.Timeout) * time.Second
+ if timeout == 0 {
+ timeout = 600 * time.Second
+ }
+ if timeout > 900*time.Second {
+ timeout = 900 * time.Second
+ }
+ ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
@@ -139,7 +155,14 @@ func NewCrushRunTool() (*ToolDefinition, error) {
}
if err != nil {
- return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
+ errMsg := fmt.Sprintf("Crush error: %v", err)
+ if ctx.Err() == context.DeadlineExceeded {
+ errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
+ }
+ if result != "" {
+ errMsg += "\n\n" + result
+ }
+ return TextErrorResponse(errMsg), nil
}
return TextResponse(result), nil
diff --git a/internal/api/chat_engine.go b/internal/api/chat_engine.go
index cc463df..e4146e9 100644
--- a/internal/api/chat_engine.go
+++ b/internal/api/chat_engine.go
@@ -13,6 +13,9 @@ const (
MaxToolIterations = 15
)
+// ToolLimiter checks if a tool call is allowed and returns a release function.
+type ToolLimiter func(toolName string) (release func(), err error)
+
// ChatEngine handles chat interactions with tool execution.
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
type ChatEngine struct {
@@ -21,6 +24,7 @@ type ChatEngine struct {
tools json.RawMessage
onChunk func(map[string]interface{})
stream bool
+ limiter ToolLimiter
TotalTokens int
}
@@ -44,6 +48,11 @@ func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
ce.onChunk = fn
}
+// SetLimiter sets the tool call limiter for agent concurrency control.
+func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
+ ce.limiter = l
+}
+
// RunWithTools executes the chat loop with tool calls.
// Returns final content, tool calls, tool results, and error.
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
@@ -77,7 +86,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
}
choice := resp.Choices[0]
- content := cleanThinkingTags(choice.Message.Content)
+ content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
if ce.onChunk != nil {
@@ -115,6 +124,35 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments),
}
+ if ce.limiter != nil {
+ release, limitErr := ce.limiter(tc.Function.Name)
+ if limitErr != nil {
+ limResultData := map[string]interface{}{
+ "tool_call_id": tc.ID,
+ "content": limitErr.Error(),
+ "is_error": true,
+ }
+ allToolResults = append(allToolResults, map[string]interface{}{
+ "tool_call_id": tc.ID,
+ "name": tc.Function.Name,
+ "args": tc.Function.Arguments,
+ "result": limitErr.Error(),
+ "is_error": true,
+ })
+ if ce.onChunk != nil {
+ ce.onChunk(map[string]interface{}{"tool_result": limResultData})
+ }
+ messages = append(messages, orchestrator.Message{
+ Role: "tool",
+ Content: orchestrator.TextContent(limitErr.Error()),
+ ToolCallID: tc.ID,
+ Name: tc.Function.Name,
+ })
+ continue
+ }
+ defer release()
+ }
+
result, execErr := ce.registry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
@@ -179,7 +217,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
}
choice := resp.Choices[0]
- content := cleanThinkingTags(choice.Message.Content)
+ content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
finalContent = content
@@ -203,6 +241,20 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments),
}
+ if ce.limiter != nil {
+ release, limitErr := ce.limiter(tc.Function.Name)
+ if limitErr != nil {
+ messages = append(messages, orchestrator.Message{
+ Role: "tool",
+ Content: orchestrator.TextContent(limitErr.Error()),
+ ToolCallID: tc.ID,
+ Name: tc.Function.Name,
+ })
+ continue
+ }
+ defer release()
+ }
+
result, execErr := ce.registry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
diff --git a/internal/api/conversation.go b/internal/api/conversation.go
index b658b66..de5454f 100644
--- a/internal/api/conversation.go
+++ b/internal/api/conversation.go
@@ -17,12 +17,53 @@ const contextWindowTokens = 150000
const summarizeRatio = 0.80
const charsPerToken = 4
+func extractDisplayContent(role, content string) string {
+ if role != "assistant" {
+ return content
+ }
+ var parsed struct {
+ Content string `json:"content"`
+ ToolCalls []struct {
+ Name string `json:"name"`
+ Args string `json:"args"`
+ } `json:"tool_calls"`
+ ToolResults []struct {
+ Name string `json:"name"`
+ Result string `json:"result"`
+ } `json:"tool_results"`
+ }
+ if err := json.Unmarshal([]byte(content), &parsed); err != nil {
+ return content
+ }
+ var sb strings.Builder
+ if parsed.Content != "" {
+ sb.WriteString(parsed.Content)
+ }
+ for _, tc := range parsed.ToolCalls {
+ sb.WriteString("\n[")
+ sb.WriteString(tc.Name)
+ sb.WriteString("] ")
+ sb.WriteString(tc.Args)
+ }
+ for _, tr := range parsed.ToolResults {
+ sb.WriteString("\n[result")
+ if tr.Name != "" {
+ sb.WriteString(":")
+ sb.WriteString(tr.Name)
+ }
+ sb.WriteString("] ")
+ sb.WriteString(tr.Result)
+ }
+ return sb.String()
+}
+
type FeedMessage struct {
- ID string `json:"id"`
- Role string `json:"role"`
- Content string `json:"content"`
- Time string `json:"time"`
- Images []string `json:"images,omitempty"`
+ ID string `json:"id"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ Time string `json:"time"`
+ Images []string `json:"images,omitempty"`
+ Summarized bool `json:"summarized,omitempty"`
}
type Conversation struct {
@@ -168,13 +209,15 @@ func (cs *ConversationStore) SetSummary(summary string) {
cs.save()
}
-func (cs *ConversationStore) TrimOld(keepCount int) {
+func (cs *ConversationStore) MarkSummarized(upToIndex int) {
cs.mu.Lock()
defer cs.mu.Unlock()
- if len(cs.conv.Messages) <= keepCount {
+ if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
return
}
- cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
+ for i := 0; i < upToIndex; i++ {
+ cs.conv.Messages[i].Summarized = true
+ }
cs.save()
}
@@ -191,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
}
for _, m := range cs.conv.Messages {
- count := utf8.RuneCountInString(m.Content) / charsPerToken
+ if m.Role == "system" || m.Summarized {
+ continue
+ }
+ count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
result.byMessage += count
result.byRole[m.Role] += count
}
diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go
index c632c2c..8e2d920 100644
--- a/internal/api/handlers_chat.go
+++ b/internal/api/handlers_chat.go
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"io"
- "log"
"net/http"
"os"
"path/filepath"
@@ -64,15 +63,13 @@ func (s *Server) describeImages(images []ImageAttachment) []string {
}
}
if apiKey == "" {
- log.Printf("[vlm] no API key found for image description")
return nil
}
descriptions := make([]string, 0, len(images))
- for i, img := range images {
+ for _, img := range images {
desc, err := s.callVLM(apiKey, img)
if err != nil {
- log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err)
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
} else {
descriptions = append(descriptions, desc)
@@ -163,7 +160,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
if err != nil {
- log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err)
+ _ = err
} else {
imageIDs = append(imageIDs, id)
}
@@ -227,6 +224,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
+ engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
@@ -265,6 +263,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
+ engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -299,7 +298,11 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
- msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
+ if history[i].Summarized {
+ break
+ }
+ displayContent := extractDisplayContent(history[i].Role, history[i].Content)
+ msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
@@ -315,14 +318,21 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
start = 0
}
+ hasSummarized := false
+ for i := 0; i < start; i++ {
+ if history[i].Summarized {
+ hasSummarized = true
+ break
+ }
+ }
if start > 0 {
- log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start)
+ _ = start
}
messages := make([]orchestrator.Message, 0, included+2)
summary := s.convStore.GetSummary()
- if summary != "" && start > 0 {
+ if summary != "" && (start > 0 || hasSummarized) {
messages = append(messages, orchestrator.Message{
Role: "system",
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
@@ -330,27 +340,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
}
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" {
+ if m.Role == "system" {
continue
}
+ displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
- Role: role,
- Content: orchestrator.TextContent(content),
+ Role: m.Role,
+ Content: orchestrator.TextContent(displayContent),
})
}
@@ -391,8 +387,7 @@ func (s *Server) autoSummarize() {
}
s.convStore.SetSummary(result)
- s.convStore.TrimOld(len(messages) - half)
- s.convStore.Add("system", "[Conversation résumée automatiquement]")
+ s.convStore.MarkSummarized(half)
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go
index b2e7896..044037a 100644
--- a/internal/api/handlers_config.go
+++ b/internal/api/handlers_config.go
@@ -335,30 +335,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
body.Theme = s.config.Terminal.PromptTheme
}
- cfgDir, err := config.ConfigDir()
- if err != nil {
- writeError(w, err.Error(), http.StatusInternalServerError)
- return
- }
+ themeFile := ApplyStarshipTheme(body.Theme)
+
+ s.config.Terminal.PromptTheme = body.Theme
+ config.Save(s.config)
+
+ writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
+}
+
+func ApplyStarshipTheme(theme string) string {
+ cfgDir, _ := config.ConfigDir()
starshipDir := filepath.Join(cfgDir, "starship")
- if err := os.MkdirAll(starshipDir, 0755); err != nil {
- writeError(w, err.Error(), http.StatusInternalServerError)
- return
- }
+ os.MkdirAll(starshipDir, 0755)
themeFile := filepath.Join(starshipDir, "starship.toml")
- themeContent := getStarshipThemeConfig(body.Theme)
- if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
- writeError(w, err.Error(), http.StatusInternalServerError)
- return
- }
+ themeContent := getStarshipThemeConfig(theme)
+ os.WriteFile(themeFile, []byte(themeContent), 0644)
home, _ := os.UserHomeDir()
- shellRCs := []string{
- filepath.Join(home, ".bashrc"),
- filepath.Join(home, ".zshrc"),
- }
- for _, rc := range shellRCs {
+ for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
if _, err := os.Stat(rc); err != nil {
continue
}
@@ -375,10 +370,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
f.Close()
}
- s.config.Terminal.PromptTheme = body.Theme
- config.Save(s.config)
-
- writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
+ return themeFile
}
func getStarshipThemeConfig(theme string) string {
diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go
index cf99f6b..ae027b7 100644
--- a/internal/api/handlers_info.go
+++ b/internal/api/handlers_info.go
@@ -91,6 +91,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
+ for i := range list {
+ list[i].Deployed = skills.IsDeployed(list[i].Name)
+ }
writeJSON(w, map[string]interface{}{
"skills": list,
"count": len(list),
diff --git a/internal/api/handlers_missing.go b/internal/api/handlers_missing.go
index ff6007b..727c01f 100644
--- a/internal/api/handlers_missing.go
+++ b/internal/api/handlers_missing.go
@@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "all deployed"})
}
+func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ writeError(w, "POST only", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if body.Name == "" {
+ writeError(w, "name is required", http.StatusBadRequest)
+ return
+ }
+ if err := skills.Undeploy(body.Name); err != nil {
+ writeError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
+}
+
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go
index c4961ad..753c1c2 100644
--- a/internal/api/handlers_shell_chat.go
+++ b/internal/api/handlers_shell_chat.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
- "log"
"net/http"
"os"
"os/exec"
@@ -108,6 +107,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
+ engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
@@ -149,6 +149,7 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
+ engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -185,7 +186,8 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
- msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
+ displayContent := extractDisplayContent(history[i].Role, history[i].Content)
+ msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
@@ -202,33 +204,19 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
}
if start > 0 {
- log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start)
+ _ = start
}
messages := make([]orchestrator.Message, 0, included)
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" {
+ if m.Role == "system" {
continue
}
+ displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
- Role: role,
- Content: orchestrator.TextContent(content),
+ Role: m.Role,
+ Content: orchestrator.TextContent(displayContent),
})
}
diff --git a/internal/api/image_cache.go b/internal/api/image_cache.go
index f5dd286..bcbfe86 100644
--- a/internal/api/image_cache.go
+++ b/internal/api/image_cache.go
@@ -3,7 +3,6 @@ package api
import (
"encoding/base64"
"fmt"
- "log"
"net/http"
"os"
"path/filepath"
@@ -64,7 +63,7 @@ func cleanupImages(ids []string) {
for _, id := range ids {
p := imagePath(id)
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
- log.Printf("[images] failed to delete %s: %v", id, err)
+ _ = err
}
}
}
diff --git a/internal/api/server.go b/internal/api/server.go
index 4ef7088..229b278 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -2,12 +2,15 @@ package api
import (
"encoding/json"
- "log"
+ "fmt"
"net/http"
+ "os/exec"
"strings"
+ "sync/atomic"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
+ "github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/workflow"
)
@@ -24,6 +27,8 @@ type Server struct {
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
+ activeCrushAgents atomic.Int32
+ activeClaudeAgents atomic.Int32
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -43,7 +48,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
}
// 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)
+ _ = err
}
cfg = defaultCfg
}
@@ -65,6 +70,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
+ s.initStarship()
s.routes()
return s
}
@@ -120,6 +126,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
+ s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
@@ -156,3 +163,37 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
s.mux.ServeHTTP(w, r)
}
+
+const maxCrushAgents = 2
+const maxClaudeAgents = 2
+
+func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
+ var counter *atomic.Int32
+ var max int32
+ switch toolName {
+ case "crush_run":
+ counter = &s.activeCrushAgents
+ max = maxCrushAgents
+ case "claude_run":
+ counter = &s.activeClaudeAgents
+ max = maxClaudeAgents
+ default:
+ return func() {}, nil
+ }
+ current := counter.Add(1)
+ if current > max {
+ counter.Add(-1)
+ return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
+ }
+ return func() { counter.Add(-1) }, nil
+}
+
+func (s *Server) initStarship() {
+ if _, err := exec.LookPath("starship"); err != nil {
+ inst := installer.New(s.config)
+ if result := inst.InstallTool("starship"); !result.Success {
+ return
+ }
+ }
+ ApplyStarshipTheme(s.config.Terminal.PromptTheme)
+}
diff --git a/internal/api/shell_conversation.go b/internal/api/shell_conversation.go
index 4840ea6..a362a24 100644
--- a/internal/api/shell_conversation.go
+++ b/internal/api/shell_conversation.go
@@ -147,7 +147,10 @@ func (s *ShellConvStore) ApproxTokens() int {
defer s.mu.RUnlock()
total := 0
for _, m := range s.msgs {
- total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
+ if m.Role == "system" {
+ continue
+ }
+ total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
}
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
diff --git a/internal/api/terminal.go b/internal/api/terminal.go
index 67a4dc5..f2eed64 100644
--- a/internal/api/terminal.go
+++ b/internal/api/terminal.go
@@ -3,7 +3,6 @@ package api
import (
"encoding/json"
"fmt"
- "log"
"net/http"
"os"
"os/exec"
@@ -48,7 +47,6 @@ type wsMessage struct {
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
- log.Printf("ws upgrade: %v", err)
return
}
defer conn.Close()
@@ -56,17 +54,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
- log.Printf("terminal: read init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
- log.Printf("terminal: init message received: %s", string(raw))
if err := json.Unmarshal(raw, &initMsg); err != nil {
- log.Printf("terminal: unmarshal init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
- log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
var cmd *exec.Cmd
@@ -111,24 +105,19 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
} else {
shell := strings.TrimSpace(initMsg.Data)
- log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
if shell == "" {
shell = detectShell()
- log.Printf("terminal: auto-detected shell=%q", shell)
}
if shell == "" {
- log.Printf("terminal: no shell detected, falling back to /bin/sh")
shell = "/bin/sh"
}
if path, err := exec.LookPath(shell); err == nil {
shell = path
- log.Printf("terminal: resolved shell path=%q", shell)
}
if _, err := os.Stat(shell); err != nil {
- log.Printf("terminal: shell stat failed: %v for %q", err, shell)
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
@@ -148,14 +137,11 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
- log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
ptmx, err := pty.Start(cmd)
if err != nil {
- log.Printf("terminal: pty start failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
- log.Printf("terminal: pty started successfully")
var once sync.Once
cleanup := func() {
diff --git a/internal/config/config.go b/internal/config/config.go
index b2941d9..6b9c7e1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -2,7 +2,6 @@ package config
import (
"fmt"
- "log"
"os"
"path/filepath"
@@ -162,7 +161,7 @@ func ConfigDir() (string, error) {
if _, err := os.Stat(legacyDir); err == nil {
if _, err := os.Stat(dir); err != nil {
if err := os.Rename(legacyDir, dir); err != nil {
- log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
+ _ = err
}
}
}
diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go
index 57e95ae..4725011 100644
--- a/internal/orchestrator/orchestrator.go
+++ b/internal/orchestrator/orchestrator.go
@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"io"
- "log"
"net/http"
"regexp"
"strings"
@@ -17,6 +16,14 @@ import (
)
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?[Tt]hink>`)
+var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
+var providerTagRegex = regexp.MustCompile(`(?s)?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
+var xmlToolTagRegex = regexp.MustCompile(`(?s)?(invoke|parameter|tool_call|tool_result)[^>]*>`)
+var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
+
+var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
+var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
+var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
const maxHistorySize = 100
@@ -197,7 +204,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
return "", err
}
- content := cleanAIResponse(chatResp.Choices[0].Message.Content)
+ content := CleanAIResponse(chatResp.Choices[0].Message.Content)
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
@@ -297,7 +304,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
return fullContent.String(), fmt.Errorf("read stream: %w", err)
}
- content := cleanAIResponse(fullContent.String())
+ content := CleanAIResponse(fullContent.String())
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
@@ -388,6 +395,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
var fullContent strings.Builder
var accumulatedToolCalls []ToolCallMsg
var totalTokens int
+ var insideToolBlock bool
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
@@ -411,7 +419,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
chunk := chatResp.Choices[0].Delta.Content
if chunk != "" {
fullContent.WriteString(chunk)
- onChunk(chunk, nil)
+ cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
+ if cleanedChunk != "" {
+ onChunk(cleanedChunk, nil)
+ }
}
// Handle delta tool calls
@@ -463,15 +474,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
}{},
}
- finalContent := cleanAIResponse(fullContent.String())
+ finalContent := CleanAIResponse(fullContent.String())
finalResp.Choices[0].Message.Content = finalContent
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
return finalResp, nil
}
-func cleanAIResponse(content string) string {
+func CleanAIResponse(content string) string {
content = thinkRegex.ReplaceAllString(content, "")
+ content = providerToolBlockRegex.ReplaceAllString(content, "")
+ content = providerTagRegex.ReplaceAllString(content, "")
+ content = xmlToolTagRegex.ReplaceAllString(content, "")
+ content = bracketToolCallRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n")
var clean []string
inBlock := false
@@ -494,6 +509,35 @@ func cleanAIResponse(content string) string {
return result
}
+// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
+// It tracks state via a bool pointer to suppress content inside tool-call blocks.
+func CleanStreamChunk(chunk string, insideBlock *bool) string {
+ if *insideBlock {
+ // Check for closing tag
+ if strings.Contains(chunk, ":tool_call>") {
+ *insideBlock = false
+ }
+ return ""
+ }
+
+ // Check for opening tool_call block
+ if streamBlockStartRegex.MatchString(chunk) {
+ *insideBlock = true
+ // If closing tag also in same chunk, emit nothing
+ if strings.Contains(chunk, ":tool_call>") {
+ *insideBlock = false
+ }
+ return ""
+ }
+
+ // Clean individual tags and bracket calls
+ cleaned := providerTagRegex.ReplaceAllString(chunk, "")
+ cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
+ cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
+
+ return cleaned
+}
+
func getProviderBaseURL(name string) string {
switch name {
case "minimax":
@@ -616,6 +660,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
return &chatResp, prov.Name, nil
}
- log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
return nil, "", lastErr
}
diff --git a/internal/skills/skills.go b/internal/skills/skills.go
index 13270f6..609f2ef 100644
--- a/internal/skills/skills.go
+++ b/internal/skills/skills.go
@@ -33,6 +33,7 @@ type Skill struct {
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"`
+ Deployed bool `yaml:"-" json:"deployed,omitempty"`
}
type ValidationError struct {
@@ -155,6 +156,27 @@ func Delete(name string) error {
return nil
}
+func IsDeployed(name string) bool {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return false
+ }
+ crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
+ claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
+ _, crushErr := os.Stat(crushPath)
+ _, claudeErr := os.Stat(claudePath)
+ return crushErr == nil || claudeErr == nil
+}
+
+func Undeploy(name string) error {
+ skill, err := Get(name)
+ if err != nil {
+ return err
+ }
+ undeployFromTargets(skill.Name)
+ return nil
+}
+
func Update(skill *Skill) error {
if errs := Validate(skill); len(errs) > 0 {
return fmt.Errorf("validation failed: %v", errs)
diff --git a/web/src/api/client.js b/web/src/api/client.js
index ec513ca..73823b4 100644
--- a/web/src/api/client.js
+++ b/web/src/api/client.js
@@ -36,6 +36,8 @@ const api = {
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
+ deploySkill: (name) => request('/skills/deploy', { method: 'POST', body: JSON.stringify({ name }) }),
+ undeploySkill: (name) => request('/skills/undeploy', { method: 'POST', body: JSON.stringify({ name }) }),
getDashboardStatus: () => request('/dashboard/status'),
getProvidersQuota: () => request('/providers/quota'),
getProvidersConsumption: () => request('/providers/consumption'),
diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx
index 2ce14ba..299ee65 100644
--- a/web/src/components/App.jsx
+++ b/web/src/components/App.jsx
@@ -16,8 +16,6 @@ export default function App() {
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)
const [showOnboarding, setShowOnboarding] = useState(false)
const { t, layout } = useI18n()
@@ -31,8 +29,6 @@ export default function App() {
useEffect(() => {
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 => {
setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
@@ -82,9 +78,6 @@ export default function App() {
return () => window.removeEventListener('navigate-to-shell', handler)
}, [])
- const hasUpdates = updates.some(u => u.needsUpdate)
- const installed = tools.filter(tool => tool.installed).length
-
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [],
studio: [
@@ -127,17 +120,6 @@ export default function App() {
-
- 0 ? 'ok' : 'off'}`}
- title={t('header.toolsInstalled', { count: installed })}
- />
-
-
-
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx
index 9ec4208..3f91804 100644
--- a/web/src/components/Config.jsx
+++ b/web/src/components/Config.jsx
@@ -1,11 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
-import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
+import { User, Brain, Wrench, Monitor, AlertTriangle, Bot, Sparkles, Zap, GitBranch, Container, Circle, Hexagon, Code, Rocket, Download } from 'lucide-react'
import { useI18n } from '../i18n'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
- { id: 'updates', icon: RefreshCw },
{ id: 'skills', icon: Wrench },
{ id: 'system', icon: Monitor },
]
@@ -16,10 +15,7 @@ export default function Config({ api }) {
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
- const [updates, setUpdates] = useState([])
- const [tools, setTools] = useState([])
- const [checking, setChecking] = useState(false)
- const [updating, setUpdating] = useState(null)
+
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
@@ -34,8 +30,6 @@ export default function Config({ api }) {
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
- api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
- api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
}, [api])
@@ -46,83 +40,6 @@ export default function Config({ api }) {
setTimeout(() => setToast(null), 2500)
}
- const handleCheckUpdates = async () => {
- setChecking(true)
- try {
- const d = await api.aiTask('check_tools')
- const result = d.result
- if (result && result.tools) {
- const aiTools = result.tools
- const newUpdates = aiTools.filter(t => t.installed).map(t => ({
- tool: t.name,
- current: t.version || '',
- latest: t.latest || '',
- needsUpdate: t.needs_update || false,
- error: t.error || '',
- }))
- const newTools = aiTools.map(t => ({
- name: t.name,
- installed: t.installed,
- version: t.version || '',
- category: t.category || '',
- }))
- setUpdates(newUpdates)
- setTools(newTools)
- showToast(t('config.upToDate'))
- } else {
- showToast(t('config.error'))
- }
- } catch (err) {
- showToast(`${t('config.error')}: ${err.message}`)
- }
- setChecking(false)
- }
-
- const handleUpdateTool = async (tool) => {
- setUpdating(tool)
- try {
- const d = await api.aiTask('update_tool', tool)
- if (d.result && d.result.updated) {
- showToast(`${tool} ${t('config.updated') || 'mis à jour'}`)
- } else {
- showToast(d.result?.error || d.result?.message || t('config.error'))
- }
- handleCheckUpdates()
- } catch (err) {
- showToast(`${t('config.error')}: ${err.message}`)
- }
- setUpdating(null)
- }
-
- const handleInstallTool = async (tool) => {
- setUpdating(`install-${tool}`)
- try {
- const d = await api.aiTask('install_tool', tool)
- if (d.result && d.result.installed) {
- showToast(`${tool} ${t('config.installed') || 'installé'}`)
- } else {
- showToast(d.result?.error || d.result?.message || t('config.error'))
- }
- handleCheckUpdates()
- } catch (err) {
- showToast(`${t('config.error')}: ${err.message}`)
- }
- setUpdating(null)
- }
-
- const handleUpdateAll = async () => {
- const toUpdate = updates.filter(u => u.needsUpdate)
- setUpdating('__all__')
- for (const u of toUpdate) {
- try {
- await api.aiTask('update_tool', u.tool)
- } catch (err) {
- console.error(`Failed to update ${u.tool}:`, err)
- }
- }
- setUpdating(null)
- handleCheckUpdates()
- }
const handleSaveProfile = async () => {
try {
@@ -161,9 +78,7 @@ export default function Config({ api }) {
setEditProvider(p.name)
}
- const needsUpdateCount = updates.filter(u => u.needsUpdate).length
- const installedCount = tools.filter(tool => tool.installed).length
- const missingCount = tools.filter(tool => !tool.installed).length
+
return (
@@ -204,21 +119,8 @@ export default function Config({ api }) {
t={t}
/>
)}
- {activePanel === 'updates' && (
-
- )}
{activePanel === 'skills' && (
-
+
)}
{activePanel === 'system' && (
@@ -459,176 +361,80 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
)
}
-function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, handleUpdateAll, t }) {
+function PanelSkills({ skillList, api, loadData, t }) {
+ const [deploying, setDeploying] = useState(null)
- const missingTools = tools.filter(tool => !tool.installed)
+ const handleDeploy = async (name) => {
+ setDeploying(name + '-deploy')
+ try {
+ await api.deploySkill(name)
+ loadData()
+ } catch (err) {
+ console.error('deploy skill:', err)
+ }
+ setDeploying(null)
+ }
- return (
- <>
-
-
-
- {installedCount} {t('config.installed')}
- {missingCount > 0 && {missingCount} {t('config.missing')} }
- {needsUpdateCount > 0 && {needsUpdateCount} {t('config.needsUpdate')} }
-
-
-
- {checking ? <> {t('config.checking')}> : t('config.checkUpdates')}
-
- {needsUpdateCount > 0 && (
-
- {updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
-
- )}
-
-
-
-
- {missingTools.length > 0 && (
- <>
-
{t('config.missing') || 'Modules manquants'}
-
- {missingTools.map((tool, i) => (
-
-
- {tool.name}
-
- {t('config.notInstalled') || 'Non installé'}
-
-
-
handleInstallTool(tool.name)}
- >
- {t('config.install') || 'Installer'}
-
-
- ))}
-
- >
- )}
-
- {updates.length === 0 ? (
-
-
{t('config.noUpdates')}
-
- ) : (
-
- {updates.map((u, i) => (
-
-
- {u.tool}
-
- {u.needsUpdate ? (
- <>{u.current} → {u.latest} >
- ) : (
- {u.current}
- )}
-
-
- {u.needsUpdate && (
-
handleUpdateTool(u.tool)}
- disabled={updating === u.tool}
- >
- {updating === u.tool ? t('config.updating') : t('config.updateTool')}
-
- )}
-
- ))}
-
- )}
- >
- )
-}
-
-
-
-function PanelSkills({ skillList, t }) {
- const [selected, setSelected] = useState(null)
+ const handleUndeploy = async (name) => {
+ setDeploying(name + '-undeploy')
+ try {
+ await api.undeploySkill(name)
+ loadData()
+ } catch (err) {
+ console.error('undeploy skill:', err)
+ }
+ setDeploying(null)
+ }
if (skillList.length === 0) {
return
{t('config.noSkills')}
}
return (
- <>
-
- {skillList.map((s, i) => (
-
setSelected(s)}>
-
{s.name}
-
{s.description}
-
- {s.target &&
{s.target} }
- {s.version &&
{s.version} }
- {s.category &&
{s.category} }
+
+ {skillList.map((s, i) => (
+
+
+
+ {s.name}
+ {s.deployed ? (
+ {t('config.installed')}
+ ) : (
+ {t('config.notInstalled')}
+ )}
+
{s.description}
- ))}
-
- {selected && (
-
setSelected(null)}>
-
e.stopPropagation()}>
-
- {selected.name}
- setSelected(null)}>✕
-
-
-
-
Description
-
{selected.description}
-
-
-
Métadonnées
-
- {selected.target && {selected.target} }
- {selected.version && {selected.version} }
- {selected.category && {selected.category} }
- {selected.author && {selected.author} }
- {selected.languages && selected.languages.map(l => {l} )}
-
-
- {selected.tags && selected.tags.length > 0 && (
-
-
Tags
-
- {selected.tags.map(tag => {tag} )}
-
-
- )}
- {selected.content && (
-
-
Contenu
-
{selected.content}
-
- )}
- {selected.dependencies && selected.dependencies.length > 0 && (
-
-
Dépendances
-
- {selected.dependencies.map((d, i) => (
-
- {d.type}
- {d.name}
- {d.required === false && optionnel }
-
- ))}
-
-
- )}
-
+
+ handleDeploy(s.name)}
+ >
+ {deploying === s.name + '-deploy' ? '...' : t('config.apply')}
+
+ handleUndeploy(s.name)}
+ >
+ {deploying === s.name + '-undeploy' ? '...' : t('config.remove')}
+
- )}
- >
+ ))}
+
)
}
function PanelSystem({ api, t }) {
const [showResetModal, setShowResetModal] = useState(false)
const [toast, setToast] = useState(null)
+ const [isSudo, setIsSudo] = useState(false)
+
+ useEffect(() => {
+ api.getInfo().then(d => setIsSudo(!!d.sudo)).catch(() => {})
+ }, [api])
const showToast = (msg) => {
setToast(msg)
@@ -646,26 +452,123 @@ function PanelSystem({ api, t }) {
}
}
- const handleApplyStarship = () => {
- window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
- window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
+ const handleSystemUpdate = () => {
+ window.dispatchEvent(new CustomEvent('navigate-to-shell'))
+ if (isSudo) {
+ window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Mets à jour le système et tous les outils utilisés par l'application Muyue. Exécute les commandes suivantes dans l'ordre :\n1. Met à jour les paquets système : sudo apt update && sudo apt upgrade -y\n2. Installe les dépendances utiles si manquantes : sudo apt install -y sshpass git curl wget\n3. Mets à jour les outils installés : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n4. Pour chaque outil, vérifie la version actuelle, mets à jour si possible, puis vérifie la nouvelle version\n5. Donne un récapitulatif final de tout ce qui a été mis à jour ou installé` } }))
+ } else {
+ window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Je n'ai pas les droits sudo sur ce système. Donne-moi les commandes nécessaires pour mettre à jour le système et les outils suivants. Pour chaque outil, indique la commande exacte à exécuter :\n1. Paquets système (apt update && apt upgrade)\n2. Outils à mettre à jour : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n3. Dépendances utiles à installer : sshpass, git, curl, wget\n4. Présente les commandes dans un tableau markdown avec le nom de l'outil, la commande, et si sudo est requis` } }))
+ }
}
+ const configureTool = (tool) => {
+ window.dispatchEvent(new CustomEvent('navigate-to-shell'))
+ window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: tool.prompt } }))
+ }
+
+ const AI_TOOLS = [
+ {
+ id: 'crush',
+ name: 'Crush',
+ icon: 'Zap',
+ description: t('config.toolCrushDesc'),
+ prompt: `Configure l'outil Crush sur ce système. Vérifie d'abord s'il est installé avec "crush --version". S'il n'est pas installé, installe-le avec la méthode appropriée (npm install -g @anthropic/crush ou via le script officiel). S'il est déjà installé, vérifie sa configuration dans ~/.config/crush/ et affiche son état. Demande-moi les informations nécessaires si besoin (clés API, préférences, etc.).`,
+ },
+ {
+ id: 'claude',
+ name: 'Claude Code',
+ icon: 'Bot',
+ description: t('config.toolClaudeDesc'),
+ prompt: `Configure l'outil Claude Code (claude) sur ce système. Vérifie d'abord s'il est installé avec "claude --version". S'il n'est pas installé, installe-le avec npm install -g @anthropic-ai/claude-code. S'il est installé, vérifie sa configuration et son authentification. Demande-moi les informations nécessaires si besoin (clé API Anthropic, etc.).`,
+ },
+ {
+ id: 'gh',
+ name: 'GitHub CLI',
+ icon: 'GitBranch',
+ description: t('config.toolGhDesc'),
+ prompt: `Configure l'outil GitHub CLI (gh) sur ce système. Vérifie d'abord s'il est installé avec "gh --version". S'il n'est pas installé, installe-le avec la méthode appropriée pour ce système. S'il est installé, vérifie son authentification avec "gh auth status". Si non authentifié, guide-moi pour le configurer avec "gh auth login". Demande-moi le token si nécessaire.`,
+ },
+ {
+ id: 'docker',
+ name: 'Docker',
+ icon: 'Container',
+ description: t('config.toolDockerDesc'),
+ prompt: `Configure Docker sur ce système. Vérifie d'abord s'il est installé avec "docker --version". Vérifie aussi si le daemon tourne avec "docker info". S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que l'utilisateur est dans le groupe docker. Si des problèmes de permissions existent, explique comment les résoudre.`,
+ },
+ {
+ id: 'go',
+ name: 'Go',
+ icon: 'Circle',
+ description: t('config.toolGoDesc'),
+ prompt: `Configure l'environnement Go sur ce système. Vérifie s'il est installé avec "go version". Vérifie le GOPATH, GOROOT et les variables d'environnement. S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que les binaires Go sont dans le PATH.`,
+ },
+ {
+ id: 'node',
+ name: 'Node.js',
+ icon: 'Hexagon',
+ description: t('config.toolNodeDesc'),
+ prompt: `Configure l'environnement Node.js sur ce système. Vérifie s'il est installé avec "node --version" et "npm --version". Vérifie aussi pnpm et npx. S'il n'est pas installé, installe-le avec la méthode recommandée (nvm, fnm ou le gestionnaire de paquets). Vérifie la version LTS vs Current.`,
+ },
+ {
+ id: 'python',
+ name: 'Python',
+ icon: 'Code',
+ description: t('config.toolPythonDesc'),
+ prompt: `Configure l'environnement Python sur ce système. Vérifie python3 --version, pip3 --version, et uv --version. S'ils ne sont pas installés, installe-les avec la méthode appropriée. Vérifie les paquets essentiels (venv, pip). Configure uv si nécessaire.`,
+ },
+ {
+ id: 'starship',
+ name: 'Starship',
+ icon: 'Rocket',
+ description: t('config.toolStarshipDesc'),
+ prompt: `Configure Starship (prompt shell) sur ce système. Vérifie s'il est installé avec "starship --version". S'il n'est pas installé, installe-le. Ensuite, configure le thème "charm" dans ~/.config/starship.toml. Assure-toi que starship est initialisé dans le shell de l'utilisateur (.bashrc, .zshrc ou config fish).`,
+ },
+ ]
+
+ const ICON_MAP = { Zap, Bot, GitBranch, Container, Circle, Hexagon, Code, Rocket }
+
return (
<>
{toast &&
{toast}
}
-
Configuration Système
-
-
-
{t('config.applyStarship')}
+
{t('config.systemConfig')}
+
+
+
+ {t('config.aiToolsConfig')}
+
+
+ {AI_TOOLS.map(tool => {
+ const Icon = ICON_MAP[tool.icon] || Bot
+ return (
+
+
+
+ {tool.name}
+
+
{tool.description}
+
configureTool(tool)} style={{ marginTop: 'auto' }}>
+
+ {t('config.configureViaAI')}
+
+
+ )
+ })}
+
+
+
+
+
+
{t('config.systemUpdate')}
+
+ {isSudo ? t('config.systemUpdateDescSudo') : t('config.systemUpdateDescNoSudo')}
+
+
+
+
+ {t('config.updateBtn')}
+
-
- Vérifie l'installation de starship et configure le thème charm via l'IA.
-
-
- {t('config.applyStarship')}
-
diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx
index 6d857e0..3c3fb79 100644
--- a/web/src/components/Shell.jsx
+++ b/web/src/components/Shell.jsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
+import { useState, useRef, useEffect, useCallback, useMemo, memo, Fragment } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
@@ -409,6 +409,7 @@ export default function Shell({ api, isSudo }) {
})
const activeTabRef = useRef(activeTab)
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
+ const tabIdsKey = useMemo(() => tabs.map(t => t.id).join(','), [tabs])
const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false)
@@ -474,8 +475,22 @@ export default function Shell({ api, isSudo }) {
const aiLoadedRef = useRef(false)
const aiLoadingRef = useRef(false)
const analysisSavingRef = useRef(false)
+ const _streamRafRef = useRef(null)
+ const _streamPendingRef = useRef(null)
+
+ const _flushStreamUpdate = useCallback(() => {
+ _streamRafRef.current = null
+ const pending = _streamPendingRef.current
+ if (!pending) return
+ _streamPendingRef.current = null
+ setAiMessages(pending)
+ }, [])
useEffect(() => {
+ if (_streamRafRef.current) {
+ cancelAnimationFrame(_streamRafRef.current)
+ _streamRafRef.current = null
+ }
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
@@ -760,7 +775,7 @@ export default function Shell({ api, isSudo }) {
pending.forEach(clearTimeout)
observer?.disconnect()
}
- }, [tabs, initTerminal, initPendingTabs, configLoaded])
+ }, [tabIdsKey, initTerminal, initPendingTabs, configLoaded])
useEffect(() => {
const entry = tabsRef.current[activeTab]
@@ -778,12 +793,18 @@ export default function Shell({ api, isSudo }) {
const wrapper = document.querySelector('.shell-layout')?.parentElement
if (wrapper && wrapper.classList.contains('tab-hidden')) return
const entry = tabsRef.current[activeTabRef.current]
- if (entry) {
- entry.fitAddon.fit()
+ if (entry && entry.fitAddon && entry.term) {
+ const container = document.getElementById(`terminal-${activeTabRef.current}`)
+ if (!container) return
+ const rect = container.getBoundingClientRect()
+ const dims = entry.fitAddon.proposeDimensions()
+ if (dims && entry.term.cols !== dims.cols || entry.term.rows !== dims.rows) {
+ entry.fitAddon.fit()
+ }
}
}, 2000)
return () => clearInterval(iv)
- }, [tabs])
+ }, [tabIdsKey])
useEffect(() => {
return () => {
@@ -813,25 +834,26 @@ export default function Shell({ api, isSudo }) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
+ const currentTabs = tabsRef.current._tabList || []
if (e.key === 'Tab' && e.shiftKey) {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
- const idx = tabs.findIndex(t => t.id === activeTab)
- const next = (idx + 1) % tabs.length
- setActiveTab(tabs[next].id)
+ const idx = currentTabs.findIndex(t => t.id === activeTabRef.current)
+ const next = (idx + 1) % currentTabs.length
+ setActiveTab(currentTabs[next].id)
return
}
const num = parseInt(e.key)
- if (num >= 1 && num <= tabs.length) {
+ if (num >= 1 && num <= currentTabs.length) {
e.preventDefault()
- setActiveTab(tabs[num - 1].id)
+ setActiveTab(currentTabs[num - 1].id)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
- }, [tabs])
+ }, [])
useEffect(() => {
if (showSearch && searchInputRef.current) {
@@ -1103,16 +1125,31 @@ export default function Shell({ api, isSudo }) {
setAiLoading(true)
try {
- let accumulated = ''
- let toolCalls = []
+ let segments = []
+ let textStartIdx = 0
const controller = new AbortController()
+ const _updateLastText = (text) => {
+ if (!text) return
+ const last = segments.length > 0 ? segments[segments.length - 1] : null
+ if (last && last.type === 'text') {
+ last.content = text
+ } else {
+ segments.push({ type: 'text', content: text })
+ }
+ }
+
await api.sendShellChat(trimmed, {}, true, (partial, event) => {
if (event && event.tool_call) {
- toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
+ _updateLastText(partial.slice(textStartIdx))
+ textStartIdx = partial.length
+ segments.push({ type: 'tool', call: event.tool_call, result: null })
+ const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
+ if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
+ _streamPendingRef.current = null
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
- return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
+ return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
})
return
}
@@ -1120,12 +1157,15 @@ export default function Shell({ api, isSudo }) {
if (event.tool_result.sudo_blocked) {
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
}
- 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 }
+ const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
+ if (segIdx >= 0) {
+ segments[segIdx].result = event.tool_result
+ const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
+ if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
+ _streamPendingRef.current = null
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
- return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
+ return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
})
}
return
@@ -1133,23 +1173,37 @@ export default function Shell({ api, isSudo }) {
if (event && (event.thinking !== undefined || event.thinking_end)) {
return
}
- accumulated = partial
- setAiMessages(prev => {
+ _updateLastText(partial.slice(textStartIdx))
+ const nextMsgs = prev => {
const filtered = prev.filter(m => !m._streaming)
- return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }]
- })
+ const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
+ return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
+ }
+ _streamPendingRef.current = nextMsgs
+ if (!_streamRafRef.current) {
+ _streamRafRef.current = requestAnimationFrame(_flushStreamUpdate)
+ }
}, controller.signal)
- const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab }
- if (toolCalls.length > 0) {
- finalMsg._toolCalls = toolCalls
+ if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
+ _streamPendingRef.current = null
+
+ const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
+ const toolSegs = segments.filter(s => s.type === 'tool')
+
+ const finalMsg = { role: 'assistant', content: allText, _tabId: currentTab }
+ if (toolSegs.length > 0 || segments.length > 1) {
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,
+ segments: segments.map(s => s.type === 'text'
+ ? { type: 'text', content: s.content }
+ : { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } }
+ ),
+ content: allText,
+ tool_calls: toolSegs.map(s => s.call),
+ tool_results: toolSegs.map(s => ({
+ tool_call_id: s.call?.tool_call_id,
+ result: s.result?.content || '',
+ is_error: s.result?.is_error || false,
})),
})
}
@@ -1159,10 +1213,10 @@ export default function Shell({ api, isSudo }) {
return [...filtered, finalMsg]
})
- if (analysisSavingRef.current && accumulated) {
+ if (analysisSavingRef.current && allText) {
analysisSavingRef.current = false
- setAnalysisContent(accumulated)
- try { localStorage.setItem('shell_analysis', accumulated) } catch {}
+ setAnalysisContent(allText)
+ try { localStorage.setItem('shell_analysis', allText) } catch {}
setAnalyzing(false)
}
@@ -1182,7 +1236,7 @@ export default function Shell({ api, isSudo }) {
}
setAiLoading(false)
aiLoadingRef.current = false
- }, [api, t, aiAtLimit, focusAiTerminal])
+ }, [api, t, aiAtLimit, focusAiTerminal, _flushStreamUpdate])
const handleAiSend = () => _sendAiMessage(aiInput, false)
@@ -1190,7 +1244,7 @@ export default function Shell({ api, isSudo }) {
const handler = (e) => {
const msg = e.detail?.message
if (!msg) return
- setAiInput(msg)
+ setAiInput('')
setTimeout(() => _sendAiMessage(msg, true), 100)
}
window.addEventListener('ask-ai-terminal', handler)
@@ -1378,11 +1432,9 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
-
-
Analyste Système
+
Analyste Système
+
-
-
setShowAnalysis(true)}
@@ -1627,7 +1679,39 @@ function MermaidBlock({ code }) {
return
}
-function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
+const _renderParts = (parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId) => parts.map((part, i) => {
+ if (part.type === 'code' && part.lang === 'mermaid') {
+ return (
+
+ )
+ }
+ if (part.type === 'code') {
+ return (
+
+ {part.lang &&
{part.lang}
}
+
{part.content}
+
+ {
+ navigator.clipboard.writeText(part.content)
+ setCopiedIdx(i)
+ setTimeout(() => setCopiedIdx(null), 1500)
+ }} title="Copier">
+ {copiedIdx === i ? 'Copié !' : 'Copier'}
+
+ sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
+ Terminal
+
+
+
+ )
+ }
+ return
+})
+
+const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const content = msg.content || ''
const [copiedIdx, setCopiedIdx] = useState(null)
@@ -1640,18 +1724,51 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
return {content}
}
+ // Ordered segments (streaming or final with segments)
+ let segments = msg._segments || null
+ if (!segments) {
+ try {
+ const parsed = JSON.parse(content)
+ if (parsed && Array.isArray(parsed.segments)) {
+ segments = parsed.segments
+ }
+ } catch {}
+ }
+
+ if (segments && segments.length > 0) {
+ const hasTools = segments.some(s => s.type === 'tool')
+ if (hasTools) {
+ return (
+
+ {segments.map((seg, i) => {
+ if (seg.type === 'text') {
+ if (!seg.content) return null
+ return {_renderParts(renderContent(seg.content), copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}
+ }
+ if (seg.type === 'tool') {
+ const r = seg.result
+ const result = r && (r.content !== undefined || r.is_error !== undefined)
+ ? { content: r.content, is_error: r.is_error }
+ : null
+ return
+ }
+ return null
+ })}
+
+ )
+ }
+ }
+
+ // Fallback: old format (all tools then all text)
let parsedToolCalls = null
let parsedToolResults = null
let displayContent = content
- let streamingToolCalls = msg._toolCalls || null
try {
const parsed = JSON.parse(content)
if (parsed && Array.isArray(parsed.tool_calls)) {
- if (!streamingToolCalls) {
- parsedToolCalls = parsed.tool_calls
- parsedToolResults = parsed.tool_results || null
- }
+ parsedToolCalls = parsed.tool_calls
+ parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
}
} catch {}
@@ -1660,9 +1777,6 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
return (
- {streamingToolCalls && streamingToolCalls.map((tc, i) => (
-
- ))}
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
@@ -1672,37 +1786,7 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
: null
return
})}
- {parts.map((part, i) => {
- if (part.type === 'code' && part.lang === 'mermaid') {
- return (
-
- )
- }
- if (part.type === 'code') {
- return (
-
- {part.lang &&
{part.lang}
}
-
{part.content}
-
- {
- navigator.clipboard.writeText(part.content)
- setCopiedIdx(i)
- setTimeout(() => setCopiedIdx(null), 1500)
- }} title="Copier">
- {copiedIdx === i ? 'Copié !' : 'Copier'}
-
- sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
- Terminal
-
-
-
- )
- }
- return
- })}
+ {_renderParts(parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}
)
-}
+})
diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx
index ddb7f5a..004297d 100644
--- a/web/src/components/Studio.jsx
+++ b/web/src/components/Studio.jsx
@@ -142,10 +142,17 @@ const TOOL_LABELS = {
web_fetch: 'Web Fetch',
}
-function ToolCallBlock({ call, result }) {
+function ToolCallBlock({ call, result, activeAgents, onModeChange }) {
const icon = TOOL_ICONS[call.name] || '🔧'
const label = TOOL_LABELS[call.name] || call.name
const isErr = result && result.is_error
+ const isCrush = call.name === 'crush_run'
+ const isClaude = call.name === 'claude_run'
+ const isAgent = isCrush || isClaude
+ const agentType = isCrush ? 'crush' : isClaude ? 'claude' : null
+ const maxAgents = isCrush ? 2 : isClaude ? 2 : 0
+ const currentCount = agentType && activeAgents ? (activeAgents[agentType] || 0) : 0
+ const [mode, setMode] = useState('sync')
let argsPreview = ''
try {
@@ -163,15 +170,39 @@ function ToolCallBlock({ call, result }) {
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
+ const handleModeChange = (newMode) => {
+ setMode(newMode)
+ if (onModeChange) onModeChange(call.tool_call_id, newMode)
+ }
+
return (
{icon}
{label}
+ {isAgent && !result && (
+ {currentCount}/{maxAgents}
+ )}
{!result && }
{result && {isErr ? '✗' : '✓'} }
{argsPreview}
+ {isAgent && !result && (
+
+ handleModeChange('sync')}
+ >
+ Exécuter et attendre
+
+ handleModeChange('async')}
+ >
+ Exécuter en arrière-plan
+
+
+ )}
{truncatedResult && (
{truncatedResult}
@@ -249,10 +280,16 @@ function FeedItem({ msg }) {
let parsedToolCalls = null
let parsedToolResults = null
+ let parsedSegments = null
let displayContent = msg.content
try {
const parsed = JSON.parse(msg.content)
- if (parsed && Array.isArray(parsed.tool_calls)) {
+ if (parsed && Array.isArray(parsed.segments)) {
+ parsedSegments = parsed.segments
+ parsedToolCalls = parsed.tool_calls || null
+ parsedToolResults = parsed.tool_results || null
+ displayContent = parsed.content || ''
+ } else if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
@@ -292,32 +329,63 @@ function FeedItem({ msg }) {
))}
)}
- {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
- })}
- {cleanContent && (
-
- {renderContent(cleanContent).map((part, i) =>
- part.type === 'code' ? (
-
- ) : (
-
+ {parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
+ parsedSegments.map((seg, i) => {
+ if (seg.type === 'text') {
+ if (!seg.content) return null
+ const c = seg.content.replace(/
]*>[\s\S]*?<\/think>/gi, '')
+ if (!c) return null
+ return (
+
+ {renderContent(c).map((part, j) =>
+ part.type === 'code' ? (
+
+ ) : (
+
+ )
+ )}
+
)
+ }
+ if (seg.type === 'tool') {
+ const r = seg.result
+ const result = r && (r.content !== undefined || r.is_error !== undefined)
+ ? { content: r.content, is_error: r.is_error }
+ : null
+ return
+ }
+ return null
+ })
+ ) : (
+ <>
+ {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
+ })}
+ {cleanContent && (
+
+ {renderContent(cleanContent).map((part, i) =>
+ part.type === 'code' ? (
+
+ ) : (
+
+ )
+ )}
+
)}
-
+ >
)}
)
}
-function StreamingItem({ content, thinking, toolCalls }) {
+function StreamingItem({ content, thinking, toolCalls, segments }) {
const rank = RANKS.general
const cleanContent = content.replace(/
]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
@@ -333,6 +401,8 @@ function StreamingItem({ content, thinking, toolCalls }) {
return formatText(thinking)
}, [thinking])
+ const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
+
return (
@@ -346,25 +416,54 @@ function StreamingItem({ content, thinking, toolCalls }) {
{rank.label}
{thinking &&
}
- {hasToolCalls && toolCalls.map((tc, i) => (
-
- ))}
- {!thinking && !cleanContent && !hasToolCalls && (
+ {hasOrderedSegments ? (
+ segments.map((seg, i) => {
+ if (seg.type === 'text') {
+ if (!seg.content) return null
+ const parts = renderContent(seg.content)
+ return (
+
+ {parts.map((part, j) =>
+ part.type === 'code' ? (
+
+ ) : (
+
+ )
+ )}
+
+ )
+ }
+ if (seg.type === 'tool') {
+ return
+ }
+ return null
+ })
+ ) : (
+ <>
+ {hasToolCalls && toolCalls.map((tc, i) => (
+
+ ))}
+ {cleanContent && (
+
+ {renderedContent.map((part, i) =>
+ part.type === 'code' ? (
+
+ ) : (
+
+ )
+ )}
+
+
+ )}
+ >
+ )}
+ {!thinking && !cleanContent && !hasToolCalls && !hasOrderedSegments && (
)}
- {cleanContent && (
-
- {renderedContent.map((part, i) =>
- part.type === 'code' ? (
-
- ) : (
-
- )
- )}
-
-
+ {!hasOrderedSegments && cleanContent && (
+
)}
@@ -379,12 +478,17 @@ export default function Studio({ api }) {
const [streaming, setStreaming] = useState('')
const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
+ const [streamSegments, setStreamSegments] = useState(null)
const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const [sudoModal, setSudoModal] = useState(null)
const [attachedImages, setAttachedImages] = useState([])
+ const [activeAgents, setActiveAgents] = useState({ crush: 0, claude: 0 })
+ const [toolModes, setToolModes] = useState({})
+ const MAX_CRUSH_AGENTS = 2
+ const MAX_CLAUDE_AGENTS = 2
const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null)
@@ -584,9 +688,19 @@ export default function Studio({ api }) {
abortRef.current = controller
try {
- let accumulated = ''
+ let segments = []
+ let textStartIdx = 0
let thinking = ''
- let toolCalls = []
+
+ const _updateLastText = (text) => {
+ if (!text) return
+ const last = segments.length > 0 ? segments[segments.length - 1] : null
+ if (last && last.type === 'text') {
+ last.content = text
+ } else {
+ segments.push({ type: 'text', content: text })
+ }
+ }
await api.sendChat(text, true, (partial, event) => {
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
@@ -597,28 +711,47 @@ export default function Studio({ api }) {
return
}
if (event && event.tool_call) {
- toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
- setStreamToolCalls([...toolCalls])
- accumulated = ''
- setStreaming('')
+ _updateLastText(partial.slice(textStartIdx))
+ textStartIdx = partial.length
+ segments.push({ type: 'tool', call: event.tool_call, result: null })
+ const toolName = event.tool_call.name
+ if (toolName === 'crush_run' || toolName === 'claude_run') {
+ const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
+ setActiveAgents(prev => ({ ...prev, [agentType]: prev[agentType] + 1 }))
+ }
+ const snap = segments.map(s => ({ ...s }))
+ setStreamToolCalls(snap.filter(s => s.type === 'tool'))
+ setStreamSegments(snap)
+ setStreaming(partial)
return
}
if (event && event.tool_result) {
if (event.tool_result.sudo_blocked) {
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
}
- const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
- if (idx >= 0) {
- toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
- setStreamToolCalls([...toolCalls])
+ const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
+ if (segIdx >= 0) {
+ segments[segIdx].result = event.tool_result
+ const toolName = segments[segIdx].call?.name
+ if (toolName === 'crush_run' || toolName === 'claude_run') {
+ const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
+ setActiveAgents(prev => ({ ...prev, [agentType]: Math.max(0, prev[agentType] - 1) }))
+ }
+ const snap = segments.map(s => ({ ...s }))
+ setStreamToolCalls(snap.filter(s => s.type === 'tool'))
+ setStreamSegments(snap)
}
return
}
- accumulated = partial
+ _updateLastText(partial.slice(textStartIdx))
setStreaming(partial)
+ const snap = segments.map(s => ({ ...s }))
+ setStreamSegments(snap)
}, controller.signal, images)
- const finalContent = accumulated || t('studio.noResponse')
+ const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
+ const toolSegs = segments.filter(s => s.type === 'tool')
+ const finalContent = allText || t('studio.noResponse')
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
@@ -626,14 +759,18 @@ export default function Studio({ api }) {
time: new Date().toISOString(),
}
if (thinking) aiMsg.thinking = thinking
- if (toolCalls.length > 0) {
+ if (toolSegs.length > 0 || segments.length > 1) {
aiMsg.content = JSON.stringify({
- content: finalContent,
- 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,
+ segments: segments.map(s => s.type === 'text'
+ ? { type: 'text', content: s.content }
+ : { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } }
+ ),
+ content: allText,
+ tool_calls: toolSegs.map(s => s.call),
+ tool_results: toolSegs.map(s => ({
+ tool_call_id: s.call?.tool_call_id,
+ result: s.result?.content || '',
+ is_error: s.result?.is_error || false,
})),
})
}
@@ -661,6 +798,9 @@ export default function Studio({ api }) {
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
+ setStreamSegments(null)
+ setActiveAgents({ crush: 0, claude: 0 })
+ setToolModes({})
abortRef.current = null
refreshTokens()
}
@@ -672,6 +812,10 @@ export default function Studio({ api }) {
}
}, [])
+ const handleToolModeChange = useCallback((toolCallId, mode) => {
+ setToolModes(prev => ({ ...prev, [toolCallId]: mode }))
+ }, [])
+
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
const handleKeyDown = (e) => {
@@ -695,29 +839,61 @@ export default function Studio({ api }) {
if (afterSlash) {
const partial = afterSlash[0]
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
- if (matches.length === 1) {
- const completed = matches[0] + ' '
- const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
- setInput(newText)
- requestAnimationFrame(() => {
- ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
- })
+ if (matches.length >= 1) {
+ let completed = matches[0]
+ for (const m of matches) {
+ while (!m.startsWith(completed)) completed = completed.slice(0, -1)
+ }
+ if (completed === partial && matches.length === 1) completed = matches[0]
+ if (completed.length > partial.length) {
+ const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '')
+ completed += suffix
+ const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
+ setInput(newText)
+ requestAnimationFrame(() => {
+ ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
+ })
+ }
}
}
}
}
+ const [summarizedExpanded, setSummarizedExpanded] = useState(false)
+
const handleToggleCollapsed = useCallback(() => {
setMessagesCollapsed(prev => !prev)
}, [])
const renderMessages = () => {
- if (messagesCollapsed && messages.length > 4) {
+ const summarizedMsgs = messages.filter(m => m.summarized)
+ const activeMsgs = messages.filter(m => !m.summarized)
+
+ const renderSummaryBlock = () => summarizedMsgs.length > 0 && (
+
+
setSummarizedExpanded(prev => !prev)}>
+
+
+
+
+
+
+
Résumé · {summarizedMsgs.length} messages
+
{summarizedExpanded ? 'masquer' : 'voir'}
+
+ {summarizedExpanded && summarizedMsgs.map(msg => (
+
+ ))}
+
+ )
+
+ if (messagesCollapsed && activeMsgs.length > 4) {
const visibleCount = 4
- const hiddenCount = messages.length - visibleCount
+ const hiddenCount = activeMsgs.length - visibleCount
return (
<>
- {messages.slice(0, visibleCount).map(msg => (
+ {renderSummaryBlock()}
+ {activeMsgs.slice(0, visibleCount).map(msg => (
))}
@@ -730,9 +906,15 @@ export default function Studio({ api }) {
>
)
}
- return messages.map(msg => (
-
- ))
+
+ return (
+ <>
+ {renderSummaryBlock()}
+ {activeMsgs.map(msg => (
+
+ ))}
+ >
+ )
}
if (!loaded) {
@@ -753,7 +935,7 @@ export default function Studio({ api }) {
{renderMessages()}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
-
+
)}
diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js
index 5a4d0b3..0cc4767 100644
--- a/web/src/i18n/en.js
+++ b/web/src/i18n/en.js
@@ -211,8 +211,27 @@ const en = {
resetConfirm: 'Are you sure? All preferences will be erased.',
resetDone: 'Settings reset.',
applyStarship: 'Apply starship',
+ apply: 'Apply',
+ remove: 'Remove',
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
starshipError: 'Failed to apply starship theme.',
+ systemConfig: 'System Configuration',
+ aiToolsConfig: 'Tools & Environments',
+ configureViaAI: 'Configure',
+ toolCrushDesc: 'Autonomous AI agent for code writing and refactoring.',
+ toolClaudeDesc: 'AI coding assistant by Anthropic.',
+ toolGhDesc: 'Command-line interface for GitHub.',
+ toolDockerDesc: 'Application containerization platform.',
+ toolGoDesc: 'Programming language and runtime environment.',
+ toolNodeDesc: 'JavaScript runtime and package manager.',
+ toolPythonDesc: 'Programming language, pip and uv manager.',
+ toolStarshipDesc: 'Modern and customizable shell prompt.',
+ systemUpdate: 'System Update',
+ systemUpdateDescSudo: 'Updates the system and all tools (sshpass, crush, claude, gh, etc.).',
+ systemUpdateDescNoSudo: 'Shows update commands to run manually.',
+ updateBtn: 'Update',
+ notInstalled: 'Not installed',
+ install: 'Install',
},
}
diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js
index a811078..b006f36 100644
--- a/web/src/i18n/fr.js
+++ b/web/src/i18n/fr.js
@@ -211,8 +211,27 @@ const fr = {
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
applyStarship: 'Appliquer starship',
+ apply: 'Appliquer',
+ remove: 'Retirer',
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
+ systemConfig: 'Configuration Syst\u00e8me',
+ aiToolsConfig: 'Outils & Environnements',
+ configureViaAI: 'Configurer',
+ toolCrushDesc: 'Agent IA autonome pour l\u2019\u00e9criture et le refactoring de code.',
+ toolClaudeDesc: 'Assistant de codage IA par Anthropic.',
+ toolGhDesc: 'Interface en ligne de commande pour GitHub.',
+ toolDockerDesc: 'Plateforme de conteneurisation d\u2019applications.',
+ toolGoDesc: 'Langage de programmation et environnement d\u2019ex\u00e9cution.',
+ toolNodeDesc: 'Environnement d\u2019ex\u00e9cution JavaScript et gestionnaire de paquets.',
+ toolPythonDesc: 'Langage de programmation, pip et gestionnaire uv.',
+ toolStarshipDesc: 'Prompt shell moderne et personnalisable.',
+ systemUpdate: 'Mise à jour système',
+ systemUpdateDescSudo: 'Met à jour le système et tous les outils (sshpass, crush, claude, gh, etc.).',
+ systemUpdateDescNoSudo: 'Affiche les commandes de mise à jour à exécuter manuellement.',
+ updateBtn: 'Mettre à jour',
+ notInstalled: 'Non installé',
+ install: 'Installer',
},
}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css
index a0dc8b5..a09cb17 100644
--- a/web/src/styles/global.css
+++ b/web/src/styles/global.css
@@ -379,11 +379,11 @@ input::placeholder { color: var(--text-disabled); }
.shell-menu-item-row { display: flex; align-items: center; }
.shell-menu-item-icon {
display: flex; align-items: center; justify-content: center;
- width: 24px; height: 24px; border-radius: var(--radius);
- background: transparent; border: none; color: var(--text-disabled);
+ width: 26px; height: 26px; border-radius: var(--radius);
+ background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary);
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
}
-.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
+.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); }
.shell-menu-empty {
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
font-style: italic;
@@ -459,7 +459,7 @@ input::placeholder { color: var(--text-disabled); }
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.shell-ai-token-fill.warn { background: var(--warning); }
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
-.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
+.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
.ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
@@ -511,7 +511,7 @@ input::placeholder { color: var(--text-disabled); }
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
-.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; }
+.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.ai-message thead, .ai-message tbody { display: table-row-group; }
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
@@ -1024,8 +1024,10 @@ input::placeholder { color: var(--text-disabled); }
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
.feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
-.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
-.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
+.msg-h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; display: block; }
+.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
+.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
+.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
@@ -1133,6 +1135,22 @@ input::placeholder { color: var(--text-disabled); }
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
+.feed-summary-block { margin: 4px 0; }
+.feed-summary-header {
+ display: flex; align-items: center; gap: 10px;
+ padding: 8px 16px;
+ background: var(--bg-surface); border: 1px solid var(--border);
+ border-radius: var(--radius); cursor: pointer;
+ transition: all 0.2s ease;
+}
+.feed-summary-header:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
+.feed-summary-header svg { color: var(--accent); flex-shrink: 0; }
+.feed-summary-text { font-size: 11px; color: var(--text-tertiary); flex: 1; font-weight: 600; }
+.feed-summary-toggle { font-size: 10px; color: var(--accent); font-family: var(--font-mono); }
+
+.skill-list-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
+.skills-list { display: flex; flex-direction: column; gap: 2px; }
+
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);
@@ -1294,3 +1312,51 @@ input::placeholder { color: var(--text-disabled); }
.shell-xterm-instance .xterm-link:hover {
color: var(--accent-muted) !important;
}
+
+.config-ai-tools-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.config-ai-tool-card {
+ display: flex;
+ flex-direction: column;
+ padding: 14px;
+ border-radius: var(--radius);
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ transition: border-color 0.15s;
+ min-height: 120px;
+}
+
+.config-ai-tool-card:hover {
+ border-color: var(--accent-dim);
+}
+
+.config-ai-tool-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.config-ai-tool-icon {
+ font-size: 18px;
+ line-height: 1;
+}
+
+.config-ai-tool-name {
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--text-primary);
+}
+
+.config-ai-tool-desc {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ line-height: 1.4;
+ margin-bottom: 10px;
+ flex: 1;
+}