Compare commits

..

32 Commits

Author SHA1 Message Date
CI Bot
c39203cc4b chore: update CHANGELOG for v0.3.4 2026-04-23 21:14:19 +00:00
Augustin
869bf154cc Merge branch 'develop'
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-23 23:13:04 +02:00
Augustin
52a785ec9a Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 34s
2026-04-23 23:08:10 +02:00
Augustin
0b6d5281df Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 32s
2026-04-23 22:13:03 +02:00
Augustin
745e03d00a Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 31s
2026-04-23 22:10:55 +02:00
Augustin
f88c7a4f3f Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 22:08:41 +02:00
Augustin
028fb364ba Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 22:06:22 +02:00
Augustin
85edea9ed9 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Has been cancelled
2026-04-23 22:04:38 +02:00
Augustin
0232bd7afe Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:57:01 +02:00
Augustin
49a0f5c8c3 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 31s
2026-04-23 21:50:06 +02:00
Augustin
d3755028fb Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:35:00 +02:00
Augustin
41cbee8928 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:30:02 +02:00
Augustin
1d521cbf90 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Has been cancelled
2026-04-23 21:29:08 +02:00
Augustin
d9d1ec5cb7 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:20:31 +02:00
Augustin
45884ee75c Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 32s
2026-04-23 21:14:56 +02:00
Augustin
6f7f588e51 merge develop: resolve conflicts, accept develop versions
Some checks failed
Stable Release / stable (push) Failing after 31s
💘 Generated with Crush

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

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
c81ebb4e46 feat(dashboard): add quota monitoring, process list, and command history
- New API endpoints: /providers/quota, /recent-commands, /running-processes
- New grid-based dashboard layout with cards for tools, quota, processes, commands
- Improved OnboardingWizard with required API key validation and scanning feedback
- Auto-initialize config on first run

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
b0865bc598 refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection
- Add ChatEngine for deduplicated chat logic (handlers_chat/shell_chat)
- Add SendWithToolsStream for real-time streaming responses
- Add /help, /plan, /export, /model commands in Studio
- Fix XSS: sanitize HTML after markdown rendering
- Add ConversationStoreMulti for multi-conversation support
- Add Anthropic headers (x-api-key, anthropic-version)
- Add fallback logging when provider switch occurs
- Add API handler tests (handlers_test.go)
- Polish Studio: max-height 200px, word-break on tool args
- Update CLI version to show full info (version, go, platform)

🤖 Generated with Crush

Assisted-by: MiniMax-M2.5 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
0d8e1b1e1a fix(studio): improve chat context, thinking tags, streaming, and tool results
- Fix cleanThinkingTags to use proper regex instead of naive ReplaceAll
- Send conversation history (last 20 messages + summary) to AI instead of single message
- Store tool results alongside tool calls so history shows complete execution info
- Stream words instead of characters for smoother SSE rendering
- Add stop button to cancel in-progress AI requests (AbortController)
- Fix markdown rendering: add h2 support, use div for bullets
- Add i18n keys for cancel/stop (EN + FR)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
485e085bb0 feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
Major changes:
- Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version)
- Add LSP registry with health checks, auto-install, and editor config generation
- Add MCP registry with editor detection, status tracking, and per-editor configuration
- Add workflow engine with planner and step execution for automated task chains
- Add conversation search, export (Markdown/JSON), and detailed token counting
- Add streaming shell chat handler with tool call/result events
- Add skill validation, dry-run testing, and export endpoints
- Enrich dashboard with Tools/Activity/Status tabs and tool cards grid
- Add PRD documentation
- Complete i18n for both EN and FR

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
61da8039bc feat(agent): refactor AI chat with streaming, agent registry, and tool execution
- Replace old tool-call regex with proper agent registry
- Add streaming chat via SSE (handleStreamChat / handleNonStreamChat)
- Add internal/agent package with tool definitions and execution
- Add orchestrator with system prompt and tool scaffolding
- Add internal/agent/ directory
- Studio.jsx: streaming chat with thinking indicator and tool result rendering
- global.css: chat bubble styles, streaming animation, thinking dots
- handlers_chat.go: full rewrite using new agent/orchestrator architecture

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
65df15498b feat(onboarding): add minimax api key step and AI-powered editor scan
- Add apikey step in onboarding wizard (optional, with validation)
- Add ScanEditors() in scanner package detecting vim/nvim/code/emacs/nano/helix/subl/zed
- Add GET /api/editors endpoint
- Editor step now has scan button to detect installed editors via backend
- MiniMax API key is saved to provider config if provided

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
b6147ddb12 fix(onboarding): require fields before advancing steps
- Validate each step before allowing goNext
- Show required error message on name step if empty
- Clear error on input change

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
275a9a4cc7 fix: register missing /api/config/reset and /api/starship/apply-theme routes
- Add resetConfig and applyStarshipTheme to frontend api client
- Register handleResetConfig and handleApplyStarshipTheme in server mux

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
e92a2f00f5 fix(config): per-provider form state to avoid field cross-talk
- providerForm is now keyed by provider name
- Each provider (minimax/glm/claude) has isolated form data
- Validation and save target the specific provider being edited

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
1f12b8a4fb fix(onboarding): auto-save on done step, keyboard nav, error feedback
- Trigger save automatically when reaching done step
- Add Escape to go back, Enter to advance (works in text fields)
- Add back button visible between step 1 and last step
- Fix accent encoding in done message
- Show saving state and error with retry button

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
9188231a05 feat(config): add system panel with reset and starship theme, add onboarding wizard
- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default)
- Add OnboardingWizard that activates when profile is empty on first run
- Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering)
- Add /api/config/reset and /api/starship/apply-theme endpoints
- Wire wizard trigger in App.jsx based on profile completeness

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
CI Bot
28e5113733 chore: update CHANGELOG for v0.3.2 2026-04-22 18:31:39 +00:00
Augustin
51a599fc83 chore: update CHANGELOG for v0.3.2-beta.1
All checks were successful
Stable Release / stable (push) Successful in 47s
💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:29:54 +02:00
Augustin
d8384cad00 merge develop into main for v0.3.2-beta.1 2026-04-22 20:29:46 +02:00
CI Bot
5b4a70e690 chore: update CHANGELOG for v0.3.1 2026-04-22 18:21:00 +00:00
11 changed files with 513 additions and 386 deletions

View File

@@ -4,6 +4,196 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.3.4
### Changes since v0.3.3
- fix(ci): replace jq with python3 in release step, add debug output (7ae4017)
- feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects (8c540eb)
- feat(studio): Tab focuses textarea, autocomplete commands (1074b01)
- fix(studio): convert newlines to <br/> in AI message rendering (2da0cf9)
- fix(config): replace hardcoded model list with free text input (9987a58)
- feat(config): providers panel shows only MINIMAX/ZAI with model selector (2827acf)
- feat(dashboard): show top 5 most used commands as clickable chips (afb6e77)
- fix: tab containers height, dashboard 2-row grid, studio scroll buttons (84be226)
- feat(shell): dedicated System Analyst AI, no code execution, analyze system (f9c4cf1)
- fix: keep all tabs mounted, switch via CSS display instead of unmount (eda7293)
- refactor(config): locale panel with edit/save flow like profile (b55feae)
- feat(config): split profile into Personal Info + Preferences sections, centered (54621bd)
- feat(studio): improve context compression UI and provider display (6bad294)
- fix(config): locale panel show active language/keyboard, add save button (92eb783)
- feat(config): dynamic profile panel, generic save, tabs margin fix (8005e97)
- fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota (6e76e7d)
- feat(chat): add auto-summarization with token tracking UI (e8f6dc4)
- feat(dashboard): add background graphs to cards and improve layout (bb03c9f)
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (79d0821)
- feat(dashboard): add quota monitoring, process list, and command history (7682717)
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (3948a4c)
- fix(studio): improve chat context, thinking tags, streaming, and tool results (65804aa)
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (2e50366)
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (66b773f)
- feat(onboarding): add minimax api key step and AI-powered editor scan (bc5c295)
- fix(onboarding): require fields before advancing steps (e19122d)
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (8b6a7e8)
- fix(config): per-provider form state to avoid field cross-talk (58f8cb0)
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (b52fecc)
- feat(config): add system panel with reset and starship theme, add onboarding wizard (5bbac49)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.2
### Changes since v0.3.1
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
- chore: bump version to 3.2 (0fe82f6)
- refactor(config): remove Terminal sub-tab from Configuration page (3b6cc38)
- fix(terminal): init payload never sent due to ws.onopen being overwritten (93a22d4)
- fix(terminal): improve shell resolution with better error handling and ws proxy support (e0e1e73)
- feat(studio): parse AI thinking and tool launch messages in terminal panel (0496ca7)
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (b407ab8)
- feat(studio): add tool execution and hide AI thinking tags (12df184)
- fix(terminal): ignore invalid shell config from race condition (8af6d25)
- feat(shell): restore AI assistant panel (4fd599a)
- fix(terminal): restore terminal input and cursor visibility (bcba593)
- refactor(api): split monolithic handlers.go into focused modules (04b0fff)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.2-beta.1 (Beta)
### Commits since v0.3.1
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
> This is a **beta** release. Use at your own risk.
## v0.3.1
### Changes since v0.3.0
- refactor(config): remove Terminal sub-tab from Configuration page (95bd824)
- fix(terminal): init payload never sent due to ws.onopen being overwritten (252f178)
- fix(terminal): improve shell resolution with better error handling and ws proxy support (7dcf505)
- feat(studio): parse AI thinking and tool launch messages in terminal panel (8fb93fa)
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (5ec373c)
- feat(studio): add tool execution and hide AI thinking tags (1eb5a6d)
- fix(terminal): ignore invalid shell config from race condition (cd5ebe0)
- feat(shell): restore AI assistant panel (2004c15)
- fix(terminal): restore terminal input and cursor visibility (9306152)
- refactor(api): split monolithic handlers.go into focused modules (e15a034)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.0
### Changes since v0.2.1

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
@@ -75,8 +76,12 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"content": content})
words := strings.Fields(content)
for _, w := range words {
chunk := w
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"content": chunk})
}
}
finalContent = content
}

View File

@@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -496,34 +495,22 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
if json.Unmarshal(body, &data) == nil {
if d, ok := data["data"].(map[string]interface{}); ok {
if limits, ok := d["limits"].([]interface{}); ok {
models := make([]map[string]interface{}, 0)
timeLimit := map[string]interface{}{}
for _, l := range limits {
if lm, ok := l.(map[string]interface{}); ok {
name := "Z.AI"
if model, ok := lm["model"].(string); ok && model != "" {
name = model
} else if t, ok := lm["type"].(string); ok && t != "TIME_LIMIT" {
name = t
}
if lm, ok := l.(map[string]interface{}); ok && lm["type"] == "TIME_LIMIT" {
usage, _ := lm["usage"].(float64)
remaining, _ := lm["remaining"].(float64)
limitVal, hasLimit := lm["limit"].(float64)
total := usage + remaining
if hasLimit && limitVal > 0 {
total = limitVal
}
if total > 0 {
models = append(models, map[string]interface{}{
"model": name,
"used": usage,
"total": total,
"remaining": remaining,
})
timeLimit = map[string]interface{}{
"model": "Z.AI",
"used": usage,
"total": total,
"remaining": remaining,
}
}
}
if len(models) > 0 {
q.Data = map[string]interface{}{"models": models}
if len(timeLimit) > 0 {
q.Data = map[string]interface{}{"models": []map[string]interface{}{timeLimit}}
q.Healthy = true
}
}
@@ -566,11 +553,10 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
shell = "zsh"
}
lines := strings.Split(string(data), "\n")
start := len(lines) - 50
start := len(lines) - 25
if start < 0 {
start = 0
}
for i := len(lines) - 1; i >= start; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") {
@@ -587,15 +573,6 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
if line == "" {
continue
}
base := strings.Fields(line)[0]
if len(base) < 2 {
continue
}
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
continue
}
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
}
}

View File

@@ -146,6 +146,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("terminal: pty started successfully")
defer func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
}()
var once sync.Once
cleanup := func() {
@@ -157,7 +164,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
})
}
defer cleanup()
go func() {
buf := make([]byte, 4096)
@@ -165,6 +171,8 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
n, err := ptmx.Read(buf)
if err != nil {
cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
}
if err := conn.WriteJSON(wsMessage{
@@ -222,11 +230,12 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
return
}
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
KeyPath string `json:"key_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)

View File

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

View File

@@ -105,7 +105,7 @@ const api = {
}).catch(reject)
})
},
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
sendShellChat: (message, context = {}, stream = true, onChunk) => {
const payload = {
message,
cwd: context.cwd || '',
@@ -120,7 +120,6 @@ const api = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))

View File

@@ -1,11 +1,13 @@
import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
import { useI18n } from '../i18n'
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench },
{ id: 'system', icon: Monitor },
]
@@ -27,6 +29,8 @@ export default function Config({ api }) {
const [toast, setToast] = useState(null)
const layouts = getLayoutList()
const loadData = useCallback(() => {
api.getConfig().then(d => {
setConfig(d)
@@ -164,6 +168,13 @@ export default function Config({ api }) {
t={t}
/>
)}
{activePanel === 'locale' && (
<PanelLocale
language={language} keyboard={keyboard} layouts={layouts}
api={api}
t={t}
/>
)}
{activePanel === 'skills' && (
<PanelSkills skillList={skillList} t={t} />
)}
@@ -309,36 +320,21 @@ function getFieldLabel(key, t) {
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null)
const [keyStatus, setKeyStatus] = useState({})
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 || '' })
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' } }))
}
setValidating(null)
}
useEffect(() => {
providers.forEach(p => {
if (p.apiKey && !keyStatus[p.name]) {
validateKey(p)
} else if (!p.apiKey) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
}
})
}, [providers])
const [validationStatus, setValidationStatus] = useState(null)
const handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name)
setValidationStatus(null)
try {
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
setValidationStatus({ provider: name, valid: true })
} catch (err) {
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
const msg = err.message || ''
if (msg.includes('invalid_api_key')) {
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
} else {
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
}
}
setValidating(null)
}
@@ -349,8 +345,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<div className="config-providers-list">
{displayed.map((p, i) => {
const isEditing = editProvider === p.name
const isValidationTarget = validationStatus?.provider === p.name
const currentModel = providerForm[p.name]?.model || p.model
const status = keyStatus[p.name]
return (
<div key={i} className="config-card provider-card-v2">
@@ -358,8 +354,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<div className="provider-card-identity">
<span className="provider-card-name">{p.name.toUpperCase()}</span>
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
{status?.checked && status?.valid && <span className="badge ok"> {t('config.keyValid')}</span>}
{status?.checked && !status?.valid && <span className="badge error"> {status.error || t('config.keyInvalid')}</span>}
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
</div>
</div>
@@ -406,14 +402,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
)
}
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
const handleInstallTool = (tool) => {
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
}
const missingTools = tools.filter(tool => !tool.installed)
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
return (
<>
<div className="config-card">
@@ -436,30 +425,6 @@ function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, in
</div>
</div>
{missingTools.length > 0 && (
<>
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
<div className="config-update-list">
{missingTools.map((tool, i) => (
<div key={`miss-${i}`} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{tool.name}</span>
<span className="config-update-versions">
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
</span>
</div>
<button
className="sm primary"
onClick={() => handleInstallTool(tool.name)}
>
{t('config.install') || 'Installer'}
</button>
</div>
))}
</div>
</>
)}
{updates.length === 0 ? (
<div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div>
@@ -495,7 +460,98 @@ function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, in
)
}
function PanelLocale({ language, keyboard, layouts, api, t }) {
const { setLanguage, setKeyboard } = useI18n()
const [editLocale, setEditLocale] = useState(false)
const [draftLang, setDraftLang] = useState(language)
const [draftKbd, setDraftKbd] = useState(keyboard)
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState(null)
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2500)
}
const handleSave = async () => {
setSaving(true)
try {
await api.savePreferences({ language: draftLang, keyboard_layout: draftKbd })
setLanguage(draftLang)
setKeyboard(draftKbd)
setEditLocale(false)
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setSaving(false)
}
const currentLang = LANGUAGES.find(l => l.id === language)
const currentKbd = layouts.find(l => l.id === keyboard)
return (
<div className="config-profile-center">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-card">
<div className="config-card-row">
<span className="config-card-label">{t('config.language')}</span>
<span className="config-card-value">{currentLang?.name || language}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.keyboardLayout')}</span>
<span className="config-card-value">{currentKbd?.name || keyboard}</span>
</div>
</div>
{editLocale && (
<div className="config-card">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.language')}</span>
<div className="chip-row">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${draftLang === lang.id ? 'active' : ''}`}
onClick={() => setDraftLang(lang.id)}
>
{lang.name}
</div>
))}
</div>
</div>
<div className="config-card-group">
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
<div className="chip-row">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${draftKbd === l.id ? 'active' : ''}`}
onClick={() => setDraftKbd(l.id)}
>
{l.name}
</div>
))}
</div>
</div>
</div>
)}
<div className="config-card">
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
{editLocale ? (
<>
<button className="primary sm" onClick={handleSave} disabled={saving}>
{saving ? t('config.saving') : t('config.save')}
</button>
<button className="ghost sm" onClick={() => setEditLocale(false)}>{t('config.cancel')}</button>
</>
) : (
<button className="primary sm" onClick={() => { setDraftLang(language); setDraftKbd(keyboard); setEditLocale(true) }}>{t('config.editProfile')}</button>
)}
</div>
</div>
</div>
)
}
function PanelSkills({ skillList, t }) {
const [selected, setSelected] = useState(null)
@@ -578,7 +634,7 @@ function PanelSkills({ skillList, t }) {
}
function PanelSystem({ api, t }) {
const [showResetModal, setShowResetModal] = useState(false)
const [resetConfirm, setResetConfirm] = useState(false)
const [toast, setToast] = useState(null)
const showToast = (msg) => {
@@ -589,7 +645,7 @@ function PanelSystem({ api, t }) {
const handleReset = async () => {
try {
await api.resetConfig()
setShowResetModal(false)
setResetConfirm(false)
showToast(t('config.resetDone'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
@@ -597,66 +653,49 @@ 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 handleApplyStarship = async () => {
try {
await api.applyStarshipTheme('charm')
showToast(t('config.starshipApplied'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
return (
<>
{toast && <div className="config-toast">{toast}</div>}
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
<div className="config-card">
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
Vérifie l'installation de starship et configure le thème charm via l'IA.
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
{t('config.starshipApplied')}
</div>
<button className="sm primary" onClick={handleApplyStarship}>
{t('config.applyStarship')}
</button>
</div>
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
Zone Rouge
</div>
<div className="config-card" style={{ borderColor: 'var(--danger)', borderWidth: 1, borderStyle: 'solid' }}>
<div className="config-card" style={{ marginTop: 12 }}>
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
Cette action supprimera toute votre configuration et relancera l'application.
</div>
<button className="sm ghost danger" onClick={() => setShowResetModal(true)}>
{t('config.resetConfig')}
</button>
</div>
{showResetModal && (
<div className="shell-modal-overlay" onClick={() => setShowResetModal(false)}>
<div className="shell-modal" onClick={e => e.stopPropagation()}>
<div className="shell-modal-header" style={{ color: 'var(--danger)' }}>
<AlertTriangle size={16} style={{ verticalAlign: 'middle', marginRight: 8 }} />
{t('config.resetConfig')}
{resetConfirm ? (
<div>
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
{t('config.resetConfirm')}
</div>
<div className="shell-modal-body">
<p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
{t('config.resetConfirm')}
</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>
Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée.
</p>
</div>
<div className="shell-modal-footer">
<button className="ghost" onClick={() => setShowResetModal(false)}>{t('config.cancel')}</button>
<button className="danger" onClick={handleReset}>{t('config.resetConfig')}</button>
<div style={{ display: 'flex', gap: 8 }}>
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
</div>
</div>
</div>
)}
) : (
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
{t('config.resetConfig')}
</button>
)}
</div>
</>
)
}

View File

@@ -93,14 +93,13 @@ export default function Dashboard({ api, refreshRef }) {
const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai')
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']
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history']
const topCmds = (() => {
const counts = {}
for (const c of recentCmds) {
const base = c.cmd.split(/\s+/)[0]
if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
if (!/^[a-zA-Z@.\/]/.test(base)) continue
if (EXCLUDE_CMDS.includes(base) || !base) continue
counts[base] = (counts[base] || 0) + 1
}
return Object.entries(counts)

View File

@@ -1,15 +1,15 @@
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 { 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'
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
function renderContent(text) {
const parts = []
@@ -28,16 +28,7 @@ function renderContent(text) {
lastIndex = match.index + full.length
}
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex)
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
if (openBlock) {
if (openBlock.index > 0) {
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
}
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
} else {
parts.push({ type: 'text', content: remaining })
}
parts.push({ type: 'text', content: text.slice(lastIndex) })
}
return parts
}
@@ -132,7 +123,7 @@ function createTerminal(container, settings = {}) {
const theme = getTheme(settings.theme || 'default')
const term = new XTerm({
cursorBlink: true,
fontSize: settings.fontSize || 12,
fontSize: settings.fontSize || 14,
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme,
allowTransparency: false,
@@ -149,7 +140,7 @@ function createTerminal(container, settings = {}) {
return { term, fitAddon }
}
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
function connectWebSocket(term, fitAddon, initPayload) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
@@ -159,15 +150,9 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes
if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
}
if (onStateChange) onStateChange(true)
})
let firstMessage = true
ws.addEventListener('message', (event) => {
if (firstMessage) {
firstMessage = false
if (onFirstMessage) onFirstMessage()
}
try {
const msg = JSON.parse(event.data)
if (msg.type === 'output') {
@@ -182,12 +167,16 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes
ws.addEventListener('close', () => {
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
if (onStateChange) onStateChange(false)
})
ws.addEventListener('error', () => {
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
if (onStateChange) onStateChange(false)
})
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => {
@@ -203,11 +192,7 @@ 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 activeTabRef = useRef(null)
const pendingCommandsRef = useRef({})
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
const settingsRef = useRef({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
const savedTabs = (() => {
try {
@@ -223,13 +208,15 @@ export default function Shell({ api }) {
})()
const [tabs, setTabs] = useState(savedTabs || [
{ id: AI_TAB_ID, name: 'AI Terminal', type: 'ai', shell: '', connected: false, ai: true },
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
])
const [activeTab, setActiveTab] = useState(() => {
if (savedTabs) {
return savedTabs[0]?.id || 1
const aiTab = savedTabs.find(t => t.ai)
return aiTab ? aiTab.id : savedTabs[0].id
}
return 1
return AI_TAB_ID
})
const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([])
@@ -238,7 +225,7 @@ 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',
})
@@ -257,14 +244,20 @@ export default function Shell({ api }) {
const [analyzing, setAnalyzing] = useState(false)
const [showAnalysis, setShowAnalysis] = useState(false)
const [analysisContent, setAnalysisContent] = useState('')
const [renderTick, setRenderTick] = useState(0)
const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false)
const aiLoadingRef = useRef(false)
useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
useEffect(() => {
const ms = aiLoading ? 1000 : 5000
const iv = setInterval(() => setRenderTick(t => t + 1), ms)
return () => clearInterval(iv)
}, [aiLoading])
useEffect(() => {
api.getShellAnalysis?.().then(d => {
if (d?.analysis) setAnalysisContent(d.analysis)
@@ -303,7 +296,7 @@ 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',
})
@@ -315,7 +308,7 @@ export default function Shell({ api }) {
if (tabsRef.current[tabId]) return
const container = document.getElementById(`terminal-${tabId}`)
if (!container) return
if (!container || container.offsetHeight === 0) return
const s = settingsRef.current
const { term, fitAddon } = createTerminal(container, {
@@ -342,63 +335,20 @@ export default function Shell({ api }) {
}
}
let disposed = false
const ws = connectWebSocket(term, fitAddon, initPayload)
const saveBuffer = () => {
try {
const buf = term.buffer.active
const lines = []
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i)
if (line) lines.push(line.translateToString(true))
}
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
savedBuffers[tabId] = lines.join('\n')
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch (e) { console.warn('[Shell] Buffer save failed:', e) }
ws.onopen = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
}
const onWsState = (connected) => {
if (disposed) return
if (!connected) saveBuffer()
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
ws.onclose = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
const restoreBuffer = () => {
try {
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
if (savedBuffers[tabId]) {
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
term.write(savedBuffers[tabId])
}
} catch (e) { console.warn('[Shell] Buffer restore failed:', e) }
ws.onerror = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer)
const clearBufferOnClear = () => {
try {
const buf = term.buffer.active
const lineY = buf.length - 1
const line = buf.getLine(lineY)
if (line) {
const text = line.translateToString(true).trim().toLowerCase()
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) {
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
delete savedBuffers[tabId]
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
}
}
} catch (e) { console.warn('[Shell] Clear detection failed:', e) }
}
term.onData((data) => {
if (data === '\r') clearBufferOnClear()
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
const onResize = () => {
const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) {
@@ -410,59 +360,30 @@ export default function Shell({ api }) {
resizeObserver.observe(container)
window.addEventListener('resize', onResize)
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]._markDisposed = () => { disposed = true }
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
const pending = pendingCommandsRef.current[tabId]
if (pending && pending.length > 0) {
console.log(`[Shell] Flushing ${pending.length} pending commands for tab ${tabId}`)
for (const cmd of pending) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' }))
}
}
delete pendingCommandsRef.current[tabId]
}
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
}, [])
useEffect(() => {
const tab = tabs.find(t => t.id === activeTab)
if (!tab) return
let cancelled = false
const pending = []
const tryInit = (attempt) => {
if (cancelled || attempt > 20) return
const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) {
pending.push(setTimeout(() => tryInit(attempt + 1), 150))
return
}
if (attempt > 10) return
const container = document.getElementById(`terminal-${tab.id}`)
if (!container || container.offsetHeight === 0) {
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
setTimeout(() => tryInit(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()
})
}
tryInit(0)
return () => {
cancelled = true
pending.forEach(clearTimeout)
}
}, [activeTab, tabs, initTerminal])
useEffect(() => {
@@ -480,34 +401,10 @@ export default function Shell({ api }) {
return () => clearInterval(iv)
}, [tabs])
useEffect(() => {
return () => {
for (const [tabId, entry] of Object.entries(tabsRef.current)) {
entry._markDisposed?.()
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
window.removeEventListener('resize', entry.onResize)
entry.resizeObserver?.disconnect()
entry.ws?.close()
entry.term?.dispose()
}
tabsRef.current = {}
}
}, [])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
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)
return
}
if (!e.altKey) return
const num = parseInt(e.key)
if (num >= 1 && num <= tabs.length) {
@@ -523,7 +420,7 @@ export default function Shell({ api }) {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false }
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, ai: t.ai || false, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
setActiveTab(id)
setShowMenu(false)
}
@@ -541,34 +438,26 @@ export default function Shell({ api }) {
key_path: conn.key_path || '',
connected: false,
}
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, ai: t.ai || false, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
setActiveTab(id)
setShowMenu(false)
}
const closeTab = (tabId, e) => {
if (e) e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
if (!tab || tab.ai || tabs.length <= 1) return
const entry = tabsRef.current[tabId]
if (entry) {
entry._markDisposed?.()
entry.saveBuffer?.()
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
window.removeEventListener('resize', entry.onResize)
entry.resizeObserver.disconnect()
entry.ws.close()
entry.term.dispose()
if (tabsRef.current[tabId]) {
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
window.removeEventListener('resize', onResize)
resizeObserver.disconnect()
ws.close()
term.dispose()
delete tabsRef.current[tabId]
}
try {
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
delete savedBuffers[tabId]
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) }
setTabs(prev => {
if (prev.length <= 1) return prev
const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id)
@@ -613,105 +502,133 @@ export default function Shell({ api }) {
}
}
const sendToTerminal = useCallback((code, tabId) => {
const targetId = tabId || activeTabRef.current
const entry = tabsRef.current[targetId]
if (!entry) {
console.warn(`[Shell] sendToTerminal: tab ${targetId} not ready. Queueing. tabsRef:`, Object.keys(tabsRef.current), 'activeTab:', activeTabRef.current, 'requested:', tabId)
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
pendingCommandsRef.current[targetId].push(code)
return
const sendToTerminal = useCallback((code) => {
const aiEntry = tabsRef.current[AI_TAB_ID]
if (aiEntry?.ws && aiEntry.ws.readyState === WebSocket.OPEN) {
aiEntry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
}
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
console.warn(`[Shell] sendToTerminal: WS not open for tab ${targetId} (state=${entry.ws?.readyState}). Queueing.`)
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
pendingCommandsRef.current[targetId].push(code)
return
}
console.log(`[Shell] sendToTerminal: tab ${targetId}${code.length} chars`)
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
}, [])
const focusAiTerminal = useCallback(() => {
const entry = tabsRef.current[activeTabRef.current]
if (entry) entry.term.focus()
setActiveTab(AI_TAB_ID)
setTimeout(() => {
const entry = tabsRef.current[AI_TAB_ID]
if (entry) entry.term.focus()
}, 150)
}, [])
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
const trimmed = text.trim()
aiLoadingRef.current = true
const handleAiSend = async () => {
if (!aiInput.trim() || aiLoading || aiAtLimit) return
const text = aiInput.trim()
setAiInput('')
focusAiTerminal()
if (!fromEvent) {
setAiInput('')
focusAiTerminal()
}
if (trimmed === '/clear') {
if (text === '/clear') {
try {
await api.clearShellChat()
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
setAiTokens(0)
setAiAtLimit(false)
} catch {}
aiLoadingRef.current = false
return
}
if (trimmed === '/help') {
if (text === '/help') {
setAiMessages(prev => [...prev,
{ role: 'user', content: trimmed },
{ role: 'user', content: text },
{ 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.' }
])
aiLoadingRef.current = false
return
}
const currentTab = activeTabRef.current
console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`)
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }])
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiLoading(true)
try {
let accumulated = ''
await api.sendShellChat(trimmed, {}, true, (partial) => {
await api.sendShellChat(text, {}, true, (partial) => {
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 }]
})
})
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
return [...filtered, { role: 'assistant', content: accumulated }]
})
// Refresh token count
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {})
} catch (err) {
if (err.message?.includes('context limit')) {
if (err.message.includes('context limit')) {
setAiAtLimit(true)
}
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
}
setAiLoading(false)
aiLoadingRef.current = false
}, [api, t, aiAtLimit, focusAiTerminal])
const handleAiSend = () => _sendAiMessage(aiInput, false)
}
useEffect(() => {
const handler = (e) => {
const msg = e.detail?.message
if (!msg) return
setAiInput(msg)
setTimeout(() => _sendAiMessage(msg, true), 100)
setActiveTab(AI_TAB_ID)
setTimeout(() => {
handleAiSendDirect(msg)
}, 100)
}
window.addEventListener('ask-ai-terminal', handler)
return () => window.removeEventListener('ask-ai-terminal', handler)
}, [_sendAiMessage])
}, [])
const handleAiSendDirect = async (text) => {
if (!text || aiLoading || aiAtLimit) return
setAiInput('')
if (text === '/clear') {
try {
await api.clearShellChat()
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
setAiTokens(0)
setAiAtLimit(false)
} catch {}
return
}
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiLoading(true)
try {
let accumulated = ''
await api.sendShellChat(text, {}, true, (partial) => {
accumulated = partial
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
})
})
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated }]
})
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {})
} catch (err) {
if (err.message.includes('context limit')) {
setAiAtLimit(true)
}
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
}
setAiLoading(false)
}
const handleAnalyze = async () => {
setAnalyzing(true)
@@ -740,13 +657,14 @@ export default function Shell({ api }) {
{tabs.map((tab, i) => (
<div
key={tab.id}
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
className={`shell-tab ${activeTab === tab.id ? 'active' : ''} ${tab.ai ? 'ai-tab' : ''}`}
onClick={() => setActiveTab(tab.id)}
onDoubleClick={(e) => startRename(tab.id, e)}
onDoubleClick={(e) => !tab.ai && startRename(tab.id, e)}
>
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
{tab.type === 'ssh' && <Globe size={12} />}
{tab.type === 'local' && <Monitor size={12} />}
{tab.ai && <Bot size={12} />}
{!tab.ai && tab.type === 'ssh' && <Globe size={12} />}
{!tab.ai && tab.type === 'local' && <Monitor size={12} />}
{editingTab === tab.id ? (
<input
className="shell-tab-rename"
@@ -761,7 +679,7 @@ export default function Shell({ api }) {
<span className="shell-tab-name">{tab.name}</span>
)}
<span className="shell-tab-index">{i + 1}</span>
{tabs.length > 1 && (
{!tab.ai && tabs.length > 1 && (
<button
className="shell-tab-close"
onClick={(e) => closeTab(tab.id, e)}
@@ -881,7 +799,7 @@ export default function Shell({ api }) {
</div>
<div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => (
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
<ShellAIMessage key={`${i}-${renderTick}`} msg={msg} sendToTerminal={sendToTerminal} renderTick={renderTick} />
))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div>
@@ -973,7 +891,7 @@ export default function Shell({ api }) {
)
}
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
function ShellAIMessage({ msg, sendToTerminal, renderTick }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const content = msg.content || ''
@@ -992,21 +910,21 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
{parts.map((part, i) => {
if (part.type === 'code') {
return (
<div key={i} className="shell-code-block">
<div key={`${i}-${renderTick}`} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
<div className="shell-code-actions">
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
<Copy size={12} /> Copier
</button>
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
<button onClick={() => sendToTerminal(part.content)} title="Envoyer au terminal">
<Send size={12} /> Terminal
</button>
</div>
</div>
)
}
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
return <span key={`${i}-${renderTick}`} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
})}
</div>
)

View File

@@ -47,16 +47,7 @@ function renderContent(text) {
lastIndex = match.index + full.length
}
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex)
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
if (openBlock) {
if (openBlock.index > 0) {
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
}
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
} else {
parts.push({ type: 'text', content: remaining })
}
parts.push({ type: 'text', content: text.slice(lastIndex) })
}
return parts
}
@@ -197,7 +188,7 @@ function FeedItem({ msg }) {
)
}
let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return (
<div className={`feed-item ${msg.role}`}>
@@ -309,6 +300,7 @@ export default function Studio({ api }) {
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const [renderTick, setRenderTick] = useState(0)
const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null)
@@ -341,6 +333,12 @@ export default function Studio({ api }) {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
const ms = loading ? 1000 : 5000
const iv = setInterval(() => setRenderTick(t => t + 1), ms)
return () => clearInterval(iv)
}, [loading])
useEffect(() => {
const onTab = (e) => {
if (e.key !== 'Tab') return
@@ -532,8 +530,6 @@ export default function Studio({ api }) {
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls])
accumulated = ''
setStreaming('')
return
}
if (event && event.tool_result) {
@@ -560,11 +556,6 @@ export default function Studio({ api }) {
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,
})),
})
}
setMessages(prev => [...prev, aiMsg])
@@ -648,7 +639,7 @@ export default function Studio({ api }) {
return (
<>
{messages.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} />
<FeedItem key={`${msg.id}-${renderTick}`} msg={msg} />
))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -661,7 +652,7 @@ export default function Studio({ api }) {
)
}
return messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
<FeedItem key={`${msg.id}-${renderTick}`} msg={msg} />
))
}

View File

@@ -626,7 +626,7 @@ input::placeholder { color: var(--text-disabled); }
position: relative;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px 16px;
display: flex; flex-direction: column; justify-content: center; gap: 8px;
display: flex; flex-direction: column; gap: 8px;
overflow: hidden;
}