Compare commits

...

23 Commits

Author SHA1 Message Date
Augustin
3a09e0e0c2 fix(ui): adjust global CSS styles
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:38:21 +02:00
Augustin
47fa2e01bb fix(terminal): use display:none instead of visibility for tab hiding
All checks were successful
Beta Release / beta (push) Successful in 49s
Replace visibility-based hiding with display property for reliable tab
detection. Use offsetParent and offsetHeight checks instead of style
properties to properly detect hidden terminals.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:23:54 +02:00
Augustin
401292ec5b feat(ui): refactor copy state to Set and add helper functions
All checks were successful
Beta Release / beta (push) Successful in 46s
- Change copiedIdx (number) to copiedSet (Set) for tracking multiple copied items
- Add copyCmd function to handle clipboard and timeout cleanup
- Add relativeTime function for displaying relative timestamps

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:04:38 +02:00
Augustin
199a7e409a feat(ui): add recentUnique to deduplicate recent commands in Dashboard
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:01:08 +02:00
Augustin
c91931f42f feat(ui): redesign recent commands display and fix terminal visibility
All checks were successful
Beta Release / beta (push) Successful in 44s
- Dashboard: add frequency bars for top commands, click-to-copy, time display
- Shell: switch from display:none to visibility:hidden for terminal containers
- CSS: restyle command list with improved hover states and copy indicators

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:53:59 +02:00
Augustin
cbbb224725 fix(shell): initialize activeTabRef with activeTab and move useEffect
All checks were successful
Beta Release / beta (push) Successful in 45s
Reorder code to follow React hooks rules - initialize ref with value
instead of null, then update via useEffect.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:44:02 +02:00
Augustin
8d10d2182e fix(config): remove unused import, reorder hooks, and improve variable naming
All checks were successful
Beta Release / beta (push) Successful in 42s
Reorder validateKey function and useEffect to avoid referencing before definition.
Rename loop variable from 't' to 'tool' for clarity.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:33:09 +02:00
Augustin
e9696ef82b fix(studio): add tool results serialization and improve message handling
All checks were successful
Beta Release / beta (push) Successful in 43s
- Add tool_results array to AI message content with tool_call_id, result, and is_error
- Convert cleanContent to let for potential reuse
- Reset accumulated and streaming state on tool_call events

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:22:54 +02:00
Augustin
1edd4f053a fix(shell): improve tab reference stability and command queueing
All checks were successful
Beta Release / beta (push) Successful in 47s
Add refs to track activeTab and pending commands outside render cycle.
Flush queued commands after terminal initialization completes.
Fix sendToTerminal to use stable refs instead of stale state.
Enhance debug logging for tab operations.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:10:54 +02:00
Augustin
92f943c3e6 fix(shell): add debug logging for tab tracking and WebSocket state
All checks were successful
Beta Release / beta (push) Successful in 46s
Track which tab messages belong to via _tabId field to ensure AI
responses are sent to the correct terminal tab. Add console.log in
initTerminal, sendToTerminal for troubleshooting tab lifecycle issues.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:53:13 +02:00
Augustin
1704b196cf fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
All checks were successful
Beta Release / beta (push) Successful in 52s
- Add proper disposal tracking to prevent memory leaks
- Move terminal buffer from localStorage to sessionStorage
- Restore buffer immediately after first WS message
- Fix clear detection logic and error handling
- Add signal parameter support for abortable fetch requests

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:41:01 +02:00
Augustin
40ec493bae fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal
All checks were successful
Beta Release / beta (push) Successful in 49s
- Move defer cleanup after async goroutine setup to prevent premature closure
- Remove unused Password field from terminal sessions struct
- Fix line calculation in clear detection using viewportY instead of baseY
- Add onStateChange callback to connectWebSocket for connection state
- Add tabId parameter to sendToTerminal for targeted tab control
- Simplify ShellAIMessage to use specific tab for command sending

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:20:48 +02:00
Augustin
233368c954 fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks
All checks were successful
Beta Release / beta (push) Successful in 50s
- Delay buffer restoration by 300ms to avoid race condition with WebSocket init
- Read current line from terminal buffer on Enter (reliable) instead of keystroke tracking
- Fix streaming to emit full content instead of word-by-word chunks
- Fix WebSocket readyState check in sendToTerminal
- Extract and deduplicate AI message sending logic
- Fix localStorage cleanup on tab close

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 14:15:14 +02:00
Augustin
00118f0803 refactor: remove locale panel, improve provider validation and terminal buffer persistence
All checks were successful
Beta Release / beta (push) Successful in 47s
- Remove locale panel from config (language/keyboard already handled elsewhere)
- Add per-provider key validation status with auto-check on load
- Add missing tools section with AI-powered installation
- Improve reset confirmation with modal
- Persist terminal buffer to localStorage with auto-save
- Detect clear command to wipe saved buffer
- Remove AI tab concept (commands routed to active tab instead)
- Remove renderTick hacks, use proper message keys

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:49:12 +02:00
Augustin
167ab82978 bump: v0.3.5
All checks were successful
Beta Release / beta (push) Successful in 56s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:16:08 +02:00
Augustin
a23c0c5b94 fix: display all quota models, center card content vertically
Some checks failed
Beta Release / beta (push) Has been cancelled
- Handle all quota types in providersQuota, not just TIME_LIMIT
- Extract model name from model field or type field
- Use explicit limit value when available
- Add vertical center alignment to quota card content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:15:51 +02:00
Augustin
24b31b0b47 fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering
All checks were successful
Beta Release / beta (push) Successful in 45s
- Fix AI terminal not initializing (wait for shell col visibility, remove offsetHeight guard)
- Add Shift+Tab to cycle between shell terminals
- Handle unclosed code blocks in renderContent (Shell + Studio)
- Filter irrelevant commands from history (short/non-alpha backend + expanded frontend exclude list)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:24:43 +02:00
Augustin
7ae4017672 fix(ci): replace jq with python3 in release step, add debug output
All checks were successful
Beta Release / beta (push) Successful in 40s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:13:03 +02:00
Augustin
8c540eba93 feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects
All checks were successful
Beta Release / beta (push) Successful in 49s
- Add dedicated AI Terminal tab (non-deletable) shared between user and AI
- Add Z.AI quota display on dashboard via /api/monitor/usage/quota/limit
- Add /model change command in Studio to toggle MiniMax/ZAI
- Apply Studio formatting (formatText, renderContent) to Shell AI messages
- Add render tick refresh for Shell (1s streaming, 5s idle)
- Add analysis viewer modal (Eye button) in Shell panel
- Fix multi-shell tab creation with retry init and settings ref
- Persist shell tabs to localStorage
- Fix line spacing in Studio (line-height 1.7→1.5, cleanup stray <br/>)
- Redirect Config updates to AI terminal via custom events
- Fix CI: delete existing release before recreating
- Bump version to 0.3.4

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:07:54 +02:00
Augustin
1074b019d3 feat(studio): Tab focuses textarea, autocomplete commands
All checks were successful
Beta Release / beta (push) Successful in 41s
- Tab outside textarea focuses it
- Tab inside textarea autocompletes / commands (/clear, /summarize, etc.)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:13:02 +02:00
Augustin
2da0cf9421 fix(studio): convert newlines to <br/> in AI message rendering
All checks were successful
Beta Release / beta (push) Successful in 39s
formatText now replaces \n with <br/> so AI responses display
with proper line breaks instead of a single unbroken block.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:10:54 +02:00
Augustin
9987a586e2 fix(config): replace hardcoded model list with free text input
All checks were successful
Beta Release / beta (push) Successful in 41s
Removed PROVIDER_MODELS hardcoded map. Model is now a simple text
input pre-filled with the current model value.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:08:41 +02:00
Augustin
2827acfe96 feat(config): providers panel shows only MINIMAX/ZAI with model selector
All checks were successful
Beta Release / beta (push) Successful in 42s
- Only MINIMAX and ZAI displayed (names in uppercase)
- Each provider shows selectable model chips (MiniMax-M2.7, glm-4, etc.)
- Save button always visible when editing, not just after validation
- Removed setup hint text

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:06:21 +02:00
14 changed files with 863 additions and 373 deletions

View File

@@ -170,7 +170,7 @@ jobs:
- name: Commit changelog - name: Commit changelog
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
git config user.name "CI Bot" git config user.name "CI Bot"
git config user.email "ci@legion-muyue.fr" git config user.email "ci@legion-muyue.fr"
@@ -181,30 +181,45 @@ jobs:
- name: Create release - name: Create release
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
set -ex
if [ -z "$GITEA_TOKEN" ]; then if [ -z "$GITEA_TOKEN" ]; then
echo "Warning: GITEATOKEN not set, skipping release" echo "Error: GITEA_TOKEN secret is not set"
exit 0 exit 1
fi fi
VERSION=${{ steps.version.outputs.version }} VERSION=${{ steps.version.outputs.version }}
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
BODY=$(cat /tmp/stable_changelog.md) echo "Creating release ${VERSION} at ${API}"
RESPONSE=$(curl -s -X POST "${API}" \
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
if [ -n "$EXISTING" ]; then
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
if [ -n "$EXISTING_ID" ]; then
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
fi
fi
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "{
\"tag_name\":\"${VERSION}\", \"tag_name\":\"${VERSION}\",
\"target_commitish\":\"main\", \"target_commitish\":\"main\",
\"name\":\"muyue ${VERSION}\", \"name\":\"muyue ${VERSION}\",
\"body\":$(echo "$BODY" | jq -Rs .), \"body\":${BODY},
\"draft\":false, \"draft\":false,
\"prerelease\":false \"prerelease\":false
}") }")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') HTTP_CODE=$(echo "$RESPONSE" | tail -1)
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
echo "HTTP Status: ${HTTP_CODE}"
echo "Response: ${RESPONSE_BODY}"
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:" echo "Failed to create release"
echo "$RESPONSE"
exit 1 exit 1
fi fi
echo "Release ID: ${RELEASE_ID}" echo "Release ID: ${RELEASE_ID}"
@@ -212,8 +227,12 @@ jobs:
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
filename=$(basename "$file") filename=$(basename "$file")
echo "Uploading ${filename}..." echo "Uploading ${filename}..."
curl -s -X POST "${UPLOAD_URL}" \ UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file};filename=${filename}" > /dev/null -F "attachment=@${file};filename=${filename}")
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
if [ "$UPLOAD_CODE" != "201" ]; then
echo "Upload failed with status ${UPLOAD_CODE}"
fi
done done
echo "Stable release ${VERSION} published!" echo "Stable release ${VERSION} published!"

View File

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

View File

@@ -8,6 +8,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
@@ -477,9 +478,58 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
} }
} }
case "zai": case "zai":
// Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe if p.APIKey == "" {
q.Healthy = true q.Error = "no API key"
q.Data = map[string]interface{}{"note": "crush-only"} results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if d, ok := data["data"].(map[string]interface{}); ok {
if limits, ok := d["limits"].([]interface{}); ok {
models := make([]map[string]interface{}, 0)
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
}
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,
})
}
}
}
if len(models) > 0 {
q.Data = map[string]interface{}{"models": models}
q.Healthy = true
}
}
}
}
}
case "claude", "anthropic": case "claude", "anthropic":
// Claude Code n'a pas d'API externe, vérifier l'installation // Claude Code n'a pas d'API externe, vérifier l'installation
claudePath := "/usr/bin/claude" claudePath := "/usr/bin/claude"
@@ -516,10 +566,11 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
shell = "zsh" shell = "zsh"
} }
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
start := len(lines) - 25 start := len(lines) - 50
if start < 0 { if start < 0 {
start = 0 start = 0
} }
for i := len(lines) - 1; i >= start; i-- { for i := len(lines) - 1; i >= start; i-- {
line := strings.TrimSpace(lines[i]) line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@@ -536,6 +587,15 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
if line == "" { if line == "" {
continue 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}) entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
} }
} }

View File

@@ -277,3 +277,16 @@ Sois concret et technique. Le rapport sera utilisé comme contexte pour un assis
"analysis": result, "analysis": result,
}) })
} }
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
analysis := LoadSystemAnalysis()
if analysis == "" {
writeJSON(w, map[string]interface{}{"analysis": nil})
return
}
writeJSON(w, map[string]interface{}{"analysis": analysis})
}

View File

@@ -94,6 +94,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory) s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear) s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze) s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate) s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList) s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet) s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)

View File

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

View File

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

View File

@@ -60,6 +60,7 @@ const api = {
getShellChatHistory: () => request('/shell/chat/history'), getShellChatHistory: () => request('/shell/chat/history'),
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }), clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }), analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
getShellAnalysis: () => request('/shell/analysis'),
sendChat: (message, stream = true, onChunk, signal) => { sendChat: (message, stream = true, onChunk, signal) => {
if (!stream) { if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
@@ -104,7 +105,7 @@ const api = {
}).catch(reject) }).catch(reject)
}) })
}, },
sendShellChat: (message, context = {}, stream = true, onChunk) => { sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
const payload = { const payload = {
message, message,
cwd: context.cwd || '', cwd: context.cwd || '',
@@ -119,6 +120,7 @@ const api = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal,
}).then(async (res) => { }).then(async (res) => {
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })) const err = await res.json().catch(() => ({ error: res.statusText }))

View File

@@ -76,6 +76,12 @@ export default function App() {
const switchTab = useCallback((tabId) => setActiveTab(tabId), []) const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
useEffect(() => {
const handler = () => setActiveTab('shell')
window.addEventListener('navigate-to-shell', handler)
return () => window.removeEventListener('navigate-to-shell', handler)
}, [])
const hasUpdates = updates.some(u => u.needsUpdate) const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(tool => tool.installed).length const installed = tools.filter(tool => tool.installed).length

View File

@@ -1,13 +1,11 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react' import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n' import { useI18n } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const PANELS = [ const PANELS = [
{ id: 'profile', icon: User }, { id: 'profile', icon: User },
{ id: 'providers', icon: Brain }, { id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw }, { id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench }, { id: 'skills', icon: Wrench },
{ id: 'system', icon: Monitor }, { id: 'system', icon: Monitor },
] ]
@@ -29,8 +27,6 @@ export default function Config({ api }) {
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const layouts = getLayoutList()
const loadData = useCallback(() => { const loadData = useCallback(() => {
api.getConfig().then(d => { api.getConfig().then(d => {
setConfig(d) setConfig(d)
@@ -65,28 +61,15 @@ export default function Config({ api }) {
setChecking(false) setChecking(false)
} }
const handleUpdateTool = async (tool) => { const handleUpdateTool = (tool) => {
setUpdating(tool) window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
try { window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
await api.runUpdate(tool)
await handleCheckUpdates()
showToast(`${tool}`)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
} }
const handleUpdateAll = async () => { const handleUpdateAll = () => {
setUpdating('__all__') const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
try { window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
await api.runUpdate('') window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
await handleCheckUpdates()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
} }
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
@@ -181,13 +164,6 @@ export default function Config({ api }) {
t={t} t={t}
/> />
)} )}
{activePanel === 'locale' && (
<PanelLocale
language={language} keyboard={keyboard} layouts={layouts}
api={api}
t={t}
/>
)}
{activePanel === 'skills' && ( {activePanel === 'skills' && (
<PanelSkills skillList={skillList} t={t} /> <PanelSkills skillList={skillList} t={t} />
)} )}
@@ -333,39 +309,57 @@ function getFieldLabel(key, t) {
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) { function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null) const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null) const [keyStatus, setKeyStatus] = useState({})
const handleValidate = async (name, apiKey, model, baseUrl) => { const validateKey = async (p) => {
setValidating(name) setValidating(p.name)
setValidationStatus(null)
try { try {
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl }) await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' })
setValidationStatus({ provider: name, valid: true }) setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
} catch (err) { } catch (err) {
const msg = err.message || '' setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
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) 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 handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name)
try {
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
} catch (err) {
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
}
setValidating(null)
}
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
return ( return (
<div className="config-providers-list"> <div className="config-providers-list">
<div className="provider-setup-hint">{t('config.setupDescription')}</div> {displayed.map((p, i) => {
{providers.map((p, i) => {
const isEditing = editProvider === p.name 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 ( return (
<div key={i} className="config-card provider-card-v2"> <div key={i} className="config-card provider-card-v2">
<div className="provider-card-top"> <div className="provider-card-top">
<div className="provider-card-identity"> <div className="provider-card-identity">
<span className="provider-card-name">{p.name}</span> <span className="provider-card-name">{p.name.toUpperCase()}</span>
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>} {p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>} {status?.checked && status?.valid && <span className="badge ok"> {t('config.keyValid')}</span>}
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>} {status?.checked && !status?.valid && <span className="badge error"> {status.error || t('config.keyInvalid')}</span>}
</div> </div>
</div> </div>
@@ -376,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<input <input
className="config-form-input" className="config-form-input"
type="password" type="password"
placeholder={t('config.tokenPlaceholder')} placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''} value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => { onChange={e => {
if (!isEditing) openProviderEdit(p) if (!isEditing) openProviderEdit(p)
@@ -391,18 +385,18 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<button <button
className="sm primary" className="sm primary"
disabled={validating === p.name || !providerForm[p.name]?.api_key} disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)} onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
> >
{validating === p.name ? t('config.validating') : t('config.validateKey')} {validating === p.name ? t('config.validating') : t('config.validateKey')}
</button> </button>
{isValidationTarget && validationStatus?.valid && ( {isEditing && (
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button> <button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)} )}
</div> </div>
</div> </div>
<div className="provider-card-meta" style={{ marginTop: 8 }}> <div className="provider-card-model">
{p.active && <span className="badge ok" style={{ marginRight: 6 }}>active</span>} <span className="provider-card-model-label">{t('config.model')}</span>
{p.model && p.model !== p.name && <span className="mono">{p.model}</span>} <span className="provider-card-model-value">{p.model || '—'}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -412,7 +406,14 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
) )
} }
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) { 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)
return ( return (
<> <>
<div className="config-card"> <div className="config-card">
@@ -435,6 +436,30 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
</div> </div>
</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 ? ( {updates.length === 0 ? (
<div className="config-card"> <div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div> <div className="empty-state">{t('config.noUpdates')}</div>
@@ -470,98 +495,7 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
) )
} }
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 }) { function PanelSkills({ skillList, t }) {
const [selected, setSelected] = useState(null) const [selected, setSelected] = useState(null)
@@ -644,7 +578,7 @@ function PanelSkills({ skillList, t }) {
} }
function PanelSystem({ api, t }) { function PanelSystem({ api, t }) {
const [resetConfirm, setResetConfirm] = useState(false) const [showResetModal, setShowResetModal] = useState(false)
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const showToast = (msg) => { const showToast = (msg) => {
@@ -655,7 +589,7 @@ function PanelSystem({ api, t }) {
const handleReset = async () => { const handleReset = async () => {
try { try {
await api.resetConfig() await api.resetConfig()
setResetConfirm(false) setShowResetModal(false)
showToast(t('config.resetDone')) showToast(t('config.resetDone'))
setTimeout(() => window.location.reload(), 1500) setTimeout(() => window.location.reload(), 1500)
} catch (err) { } catch (err) {
@@ -663,49 +597,66 @@ function PanelSystem({ api, t }) {
} }
} }
const handleApplyStarship = async () => { const handleApplyStarship = () => {
try { window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
await api.applyStarshipTheme('charm') 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.` } }))
showToast(t('config.starshipApplied'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
} }
return ( return (
<> <>
{toast && <div className="config-toast">{toast}</div>} {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">
<div className="config-card-row" style={{ marginBottom: 16 }}> <div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span> <span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
</div> </div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{t('config.starshipApplied')} Vérifie l'installation de starship et configure le thème charm via l'IA.
</div> </div>
<button className="sm primary" onClick={handleApplyStarship}> <button className="sm primary" onClick={handleApplyStarship}>
{t('config.applyStarship')} {t('config.applyStarship')}
</button> </button>
</div> </div>
<div className="config-card" style={{ marginTop: 12 }}>
<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-row" style={{ marginBottom: 16 }}> <div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span> <span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
</div> </div>
{resetConfirm ? ( <div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
<div> Cette action supprimera toute votre configuration et relancera l'application.
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}> </div>
{t('config.resetConfirm')} <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')}
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div className="shell-modal-body">
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button> <p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button> {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> </div>
</div> </div>
) : ( </div>
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}> )}
{t('config.resetConfig')}
</button>
)}
</div>
</> </>
) )
} }

View File

@@ -43,6 +43,7 @@ export default function Dashboard({ api, refreshRef }) {
const [recentCmds, setRecentCmds] = useState([]) const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([]) const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null) const [metrics, setMetrics] = useState(null)
const [copiedSet, setCopiedSet] = useState(new Set())
const cpuRef = useRef([]) const cpuRef = useRef([])
const memRef = useRef([]) const memRef = useRef([])
const netRxRef = useRef([]) const netRxRef = useRef([])
@@ -92,13 +93,14 @@ export default function Dashboard({ api, refreshRef }) {
const minimax = (quota || []).find(p => p.name === 'minimax') const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai') const zai = (quota || []).find(p => p.name === 'zai')
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history'] 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 topCmds = (() => { const topCmds = (() => {
const counts = {} const counts = {}
for (const c of recentCmds) { for (const c of recentCmds) {
const base = c.cmd.split(/\s+/)[0] const base = c.cmd.split(/\s+/)[0]
if (EXCLUDE_CMDS.includes(base) || !base) continue if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
if (!/^[a-zA-Z@.\/]/.test(base)) continue
counts[base] = (counts[base] || 0) + 1 counts[base] = (counts[base] || 0) + 1
} }
return Object.entries(counts) return Object.entries(counts)
@@ -107,6 +109,32 @@ export default function Dashboard({ api, refreshRef }) {
.map(([cmd, count]) => ({ cmd, count })) .map(([cmd, count]) => ({ cmd, count }))
})() })()
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
const copyCmd = (cmd, key) => {
navigator.clipboard.writeText(cmd)
setCopiedSet(prev => new Set(prev).add(key))
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
}
const relativeTime = (ts) => {
if (!ts) return ''
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
if (diff < 60) return `${diff}s`
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
}
const recentUnique = (() => {
const seen = new Set()
return recentCmds.filter(c => {
if (seen.has(c.cmd)) return false
seen.add(c.cmd)
return true
})
})()
return ( return (
<div className="dash-grid"> <div className="dash-grid">
{/* CPU */} {/* CPU */}
@@ -158,10 +186,19 @@ export default function Dashboard({ api, refreshRef }) {
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span> <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
</div> </div>
)} )}
{zai && ( {zai && zai.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model)}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div>
))}
{zai && !zai.data?.models?.length && (
<div className="dash-quota-row"> <div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span> <span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || ''}</span> <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{zai.error || 'no data'}</span>
</div> </div>
)} )}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>} {!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
@@ -186,26 +223,34 @@ export default function Dashboard({ api, refreshRef }) {
</div> </div>
{/* Recent Commands */} {/* Recent Commands */}
<div className="dash-card"> <div className="dash-card dash-cmd-card">
<div className="dash-card-head"> <div className="dash-card-head">
<span className="dash-label">Recent Commands</span> <span className="dash-label">Recent Commands</span>
<span className="dash-count">{recentUnique.length}</span>
</div> </div>
{topCmds.length > 0 && ( {topCmds.length > 0 && (
<div className="dash-cmd-top"> <div className="dash-cmd-freq">
<span className="dash-cmd-freq-title">Most used</span>
{topCmds.map((c, i) => ( {topCmds.map((c, i) => (
<div key={i} className="dash-cmd-chip" onClick={() => navigator.clipboard.writeText(c.cmd)} title="Copier"> <div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
<span className="dash-cmd-chip-name">{c.cmd}</span> <span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
<span className="dash-cmd-chip-count">{c.count}×</span> <div className="dash-cmd-freq-bar-wrap">
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
</div>
<span className="dash-cmd-freq-count">{c.count}×</span>
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="dash-cmd-list"> <div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>} {recentUnique.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.map((c, i) => ( {recentUnique.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}> <div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
<span className="dash-cmd-shell">{c.shell}</span> <div className="dash-cmd-left">
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span> <span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
</div>
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -2,12 +2,70 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import { Terminal as XTerm } from '@xterm/xterm' import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links' import { WebLinksAddon } from '@xterm/addon-web-links'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send } from 'lucide-react' import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye } from 'lucide-react'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
const MAX_TABS = 7 const MAX_TABS = 7
const SHELL_MAX_TOKENS = 100000 const SHELL_MAX_TOKENS = 100000
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
function renderContent(text) {
const parts = []
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
}
const full = match[1]
const firstNewline = full.indexOf('\n')
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
parts.push({ type: 'code', lang, content: code })
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 })
}
}
return parts
}
function formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>')
html = html
.replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
return html
}
const THEMES = { const THEMES = {
default: { default: {
@@ -74,7 +132,7 @@ function createTerminal(container, settings = {}) {
const theme = getTheme(settings.theme || 'default') const theme = getTheme(settings.theme || 'default')
const term = new XTerm({ const term = new XTerm({
cursorBlink: true, cursorBlink: true,
fontSize: settings.fontSize || 14, fontSize: settings.fontSize || 12,
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme, theme,
allowTransparency: false, allowTransparency: false,
@@ -91,7 +149,7 @@ function createTerminal(container, settings = {}) {
return { term, fitAddon } return { term, fitAddon }
} }
function connectWebSocket(term, fitAddon, initPayload) { function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
@@ -101,9 +159,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
if (dims) { if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols })) ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
} }
if (onStateChange) onStateChange(true)
}) })
let firstMessage = true
ws.addEventListener('message', (event) => { ws.addEventListener('message', (event) => {
if (firstMessage) {
firstMessage = false
if (onFirstMessage) onFirstMessage()
}
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === 'output') { if (msg.type === 'output') {
@@ -118,16 +182,12 @@ function connectWebSocket(term, fitAddon, initPayload) {
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n') term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
if (onStateChange) onStateChange(false)
}) })
ws.addEventListener('error', () => { ws.addEventListener('error', () => {
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n') 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 }) => { term.onResize(({ rows, cols }) => {
@@ -143,11 +203,33 @@ export default function Shell({ api }) {
const { t } = useI18n() const { t } = useI18n()
const tabsRef = useRef({}) const tabsRef = useRef({})
const nextIdRef = useRef(1) const nextIdRef = useRef(1)
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
const pendingCommandsRef = useRef({})
const [tabs, setTabs] = useState([ const savedTabs = (() => {
try {
const raw = localStorage.getItem(TABS_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map(t => ({ ...t, connected: false }))
}
}
} catch {}
return null
})()
const [tabs, setTabs] = useState(savedTabs || [
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false }, { id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
]) ])
const [activeTab, setActiveTab] = useState(1) const [activeTab, setActiveTab] = useState(() => {
if (savedTabs) {
return savedTabs[0]?.id || 1
}
return 1
})
const activeTabRef = useRef(activeTab)
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
const [sshConnections, setSshConnections] = useState([]) const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([]) const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
@@ -155,11 +237,13 @@ export default function Shell({ api }) {
const [editingTab, setEditingTab] = useState(null) const [editingTab, setEditingTab] = useState(null)
const [editName, setEditName] = useState('') const [editName, setEditName] = useState('')
const [terminalSettings, setTerminalSettings] = useState({ const [terminalSettings, setTerminalSettings] = useState({
fontSize: 14, fontSize: 12,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: 'default', theme: 'default',
}) })
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
const [sshForm, setSshForm] = useState({ const [sshForm, setSshForm] = useState({
name: '', host: '', port: 22, user: '', key_path: '', name: '', host: '', port: 22, user: '', key_path: '',
}) })
@@ -170,13 +254,25 @@ export default function Shell({ api }) {
const [aiTokens, setAiTokens] = useState(0) const [aiTokens, setAiTokens] = useState(0)
const [aiAtLimit, setAiAtLimit] = useState(false) const [aiAtLimit, setAiAtLimit] = useState(false)
const [analyzing, setAnalyzing] = useState(false) const [analyzing, setAnalyzing] = useState(false)
const [showAnalysis, setShowAnalysis] = useState(false)
const [analysisContent, setAnalysisContent] = useState('')
const aiMessagesRef = useRef(null) const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false) const aiLoadedRef = useRef(false)
const aiLoadingRef = useRef(false)
useEffect(() => { useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages]) }, [aiMessages])
useEffect(() => {
api.getShellAnalysis?.().then(d => {
if (d?.analysis) setAnalysisContent(d.analysis)
}).catch(() => {
const stored = localStorage.getItem('shell_analysis')
if (stored) setAnalysisContent(stored)
})
}, [])
useEffect(() => { useEffect(() => {
if (aiLoadedRef.current) return if (aiLoadedRef.current) return
aiLoadedRef.current = true aiLoadedRef.current = true
@@ -193,6 +289,11 @@ export default function Shell({ api }) {
}) })
}, []) }, [])
useEffect(() => {
const maxId = tabs.reduce((max, t) => Math.max(max, t.id), 0)
nextIdRef.current = maxId + 1
}, [])
useEffect(() => { useEffect(() => {
api.getTerminalSessions().then(d => { api.getTerminalSessions().then(d => {
setSshConnections(d.ssh || []) setSshConnections(d.ssh || [])
@@ -201,7 +302,7 @@ export default function Shell({ api }) {
api.getConfig().then(d => { api.getConfig().then(d => {
if (d.terminal) { if (d.terminal) {
setTerminalSettings({ setTerminalSettings({
fontSize: d.terminal.font_size || 14, fontSize: d.terminal.font_size || 12,
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: d.terminal.theme || 'default', theme: d.terminal.theme || 'default',
}) })
@@ -215,10 +316,11 @@ export default function Shell({ api }) {
const container = document.getElementById(`terminal-${tabId}`) const container = document.getElementById(`terminal-${tabId}`)
if (!container) return if (!container) return
const s = settingsRef.current
const { term, fitAddon } = createTerminal(container, { const { term, fitAddon } = createTerminal(container, {
fontSize: terminalSettings.fontSize, fontSize: s.fontSize,
fontFamily: terminalSettings.fontFamily, fontFamily: s.fontFamily,
theme: terminalSettings.theme, theme: s.theme,
}) })
let initPayload let initPayload
@@ -239,23 +341,66 @@ export default function Shell({ api }) {
} }
} }
const ws = connectWebSocket(term, fitAddon, initPayload) let disposed = false
ws.onopen = () => { const saveBuffer = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t)) 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.onclose = () => { const onWsState = (connected) => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t)) if (disposed) return
if (!connected) saveBuffer()
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
} }
ws.onerror = () => { const restoreBuffer = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t)) 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) }
} }
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 onResize = () => {
const el = document.getElementById(`terminal-${tabId}`) const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) { if (el && el.style.display !== 'none') {
fitAddon.fit() fitAddon.fit()
} }
} }
@@ -264,37 +409,104 @@ export default function Shell({ api }) {
resizeObserver.observe(container) resizeObserver.observe(container)
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, 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]
}
}, []) }, [])
useEffect(() => { useEffect(() => {
const tab = tabs.find(t => t.id === activeTab) const tab = tabs.find(t => t.id === activeTab)
if (!tab) return if (!tab) return
const container = document.getElementById(`terminal-${tab.id}`) let cancelled = false
if (!container) return const pending = []
if (!tabsRef.current[tab.id]) { const tryInit = (attempt) => {
const timer = setTimeout(() => { 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
}
const container = document.getElementById(`terminal-${tab.id}`)
if (!container || container.offsetHeight === 0) {
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
return
}
if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab) initTerminal(tab.id, tab)
requestAnimationFrame(() => { }
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
})
}, 100)
return () => clearTimeout(timer)
} else {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (cancelled) return
const entry = tabsRef.current[tab.id] const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit() if (entry) entry.fitAddon.fit()
}) })
} }
tryInit(0)
return () => {
cancelled = true
pending.forEach(clearTimeout)
}
}, [activeTab, tabs, initTerminal]) }, [activeTab, tabs, initTerminal])
useEffect(() => {
const iv = setInterval(() => {
for (const tab of tabs) {
const entry = tabsRef.current[tab.id]
if (entry) {
const el = document.getElementById(`terminal-${tab.id}`)
if (el && el.style.display !== 'none') {
entry.fitAddon.fit()
}
}
}
}, 2000)
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(() => { useEffect(() => {
const onKey = (e) => { const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey) 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
}
const num = parseInt(e.key) const num = parseInt(e.key)
if (num >= 1 && num <= tabs.length) { if (num >= 1 && num <= tabs.length) {
@@ -309,8 +521,8 @@ export default function Shell({ api }) {
const addLocalTab = (shell, name) => { const addLocalTab = (shell, name) => {
if (tabs.length >= MAX_TABS) return if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++ const id = nextIdRef.current++
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false } const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false }
setTabs(prev => [...prev, newTab]) 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 })
setActiveTab(id) setActiveTab(id)
setShowMenu(false) setShowMenu(false)
} }
@@ -328,25 +540,34 @@ export default function Shell({ api }) {
key_path: conn.key_path || '', key_path: conn.key_path || '',
connected: false, connected: false,
} }
setTabs(prev => [...prev, newTab]) 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 })
setActiveTab(id) setActiveTab(id)
setShowMenu(false) setShowMenu(false)
} }
const closeTab = (tabId, e) => { const closeTab = (tabId, e) => {
if (e) e.stopPropagation() if (e) e.stopPropagation()
if (tabs.length <= 1) return
if (tabsRef.current[tabId]) { const entry = tabsRef.current[tabId]
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId] if (entry) {
window.removeEventListener('resize', onResize) entry._markDisposed?.()
resizeObserver.disconnect() entry.saveBuffer?.()
ws.close() if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
term.dispose() window.removeEventListener('resize', entry.onResize)
entry.resizeObserver.disconnect()
entry.ws.close()
entry.term.dispose()
delete tabsRef.current[tabId] 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 => { setTabs(prev => {
if (prev.length <= 1) return prev
const next = prev.filter(t => t.id !== tabId) const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) { if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id) setActiveTab(next[next.length - 1].id)
@@ -391,73 +612,115 @@ export default function Shell({ api }) {
} }
} }
const sendToTerminal = useCallback((code) => { const sendToTerminal = useCallback((code, tabId) => {
const tab = tabs.find(t => t.id === activeTab) const targetId = tabId || activeTabRef.current
if (!tab) return const entry = tabsRef.current[targetId]
const entry = tabsRef.current[tab.id] if (!entry) {
if (!entry?.ws || entry.ws.readyState !== WebSocket.OPEN) return 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
}
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' })) entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
}, [tabs, activeTab]) }, [])
const handleAiSend = async () => { const focusAiTerminal = useCallback(() => {
if (!aiInput.trim() || aiLoading || aiAtLimit) return const entry = tabsRef.current[activeTabRef.current]
const text = aiInput.trim() if (entry) entry.term.focus()
setAiInput('') }, [])
if (text === '/clear') { const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
const trimmed = text.trim()
aiLoadingRef.current = true
if (!fromEvent) {
setAiInput('')
focusAiTerminal()
}
if (trimmed === '/clear') {
try { try {
await api.clearShellChat() await api.clearShellChat()
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }]) setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
setAiTokens(0) setAiTokens(0)
setAiAtLimit(false) setAiAtLimit(false)
} catch {} } catch {}
aiLoadingRef.current = false
return return
} }
if (text === '/help') { if (trimmed === '/help') {
setAiMessages(prev => [...prev, setAiMessages(prev => [...prev,
{ role: 'user', content: text }, { role: 'user', content: trimmed },
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' } { role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe 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 return
} }
setAiMessages(prev => [...prev, { role: 'user', content: text }]) 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 }])
setAiLoading(true) setAiLoading(true)
try { try {
let accumulated = '' let accumulated = ''
await api.sendShellChat(text, {}, true, (partial) => { await api.sendShellChat(trimmed, {}, true, (partial) => {
accumulated = partial accumulated = partial
setAiMessages(prev => { setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming) const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: partial, _streaming: true }] return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }]
}) })
}) })
setAiMessages(prev => { setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming) const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated }] return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
}) })
// Refresh token count
api.getShellChatHistory().then(d => { api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0) setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false) setAiAtLimit(d.at_limit || false)
}).catch(() => {}) }).catch(() => {})
} catch (err) { } catch (err) {
if (err.message.includes('context limit')) { if (err.message?.includes('context limit')) {
setAiAtLimit(true) setAiAtLimit(true)
} }
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
} }
setAiLoading(false) 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)
}
window.addEventListener('ask-ai-terminal', handler)
return () => window.removeEventListener('ask-ai-terminal', handler)
}, [_sendAiMessage])
const handleAnalyze = async () => { const handleAnalyze = async () => {
setAnalyzing(true) setAnalyzing(true)
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }]) setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
try { try {
const d = await api.analyzeSystem() const d = await api.analyzeSystem()
if (d.analysis) {
setAnalysisContent(d.analysis)
localStorage.setItem('shell_analysis', d.analysis)
}
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), { setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
role: 'system', role: 'system',
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.' content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
@@ -585,15 +848,26 @@ export default function Shell({ api }) {
<div className="shell-ai-col"> <div className="shell-ai-col">
<div className="ai-panel-header"> <div className="ai-panel-header">
<span>Analyste Système</span> <span>Analyste Système</span>
<button <div style={{ display: 'flex', gap: 6 }}>
className="shell-analyze-btn" <button
onClick={handleAnalyze} className="shell-analyze-btn"
disabled={analyzing} onClick={() => setShowAnalysis(true)}
title="Analyser le système" disabled={!analysisContent}
> title="Voir l'analyse"
<Search size={13} /> >
{analyzing ? '...' : 'Analyser'} <Eye size={13} />
</button> Analyse
</button>
<button
className="shell-analyze-btn"
onClick={handleAnalyze}
disabled={analyzing}
title="Analyser le système"
>
<Search size={13} />
{analyzing ? '...' : 'Analyser'}
</button>
</div>
</div> </div>
<div className="shell-ai-token-bar"> <div className="shell-ai-token-bar">
<div className="shell-ai-token-track"> <div className="shell-ai-token-track">
@@ -606,7 +880,7 @@ export default function Shell({ api }) {
</div> </div>
<div className="ai-panel-messages" ref={aiMessagesRef}> <div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => ( {aiMessages.map((msg, i) => (
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} /> <ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
))} ))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>} {aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div> </div>
@@ -622,6 +896,29 @@ export default function Shell({ api }) {
</div> </div>
</div> </div>
{showAnalysis && analysisContent && (
<div className="shell-modal-overlay" onClick={() => setShowAnalysis(false)}>
<div className="shell-analysis-modal" onClick={e => e.stopPropagation()}>
<div className="shell-analysis-modal-header">
<span>Analyse Système</span>
<button className="shell-tab-close" onClick={() => setShowAnalysis(false)}><X size={16} /></button>
</div>
<div className="shell-analysis-modal-body">
{renderContent(analysisContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
</div>
</div>
)}
{showSshModal && ( {showSshModal && (
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}> <div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
<div className="shell-modal" onClick={e => e.stopPropagation()}> <div className="shell-modal" onClick={e => e.stopPropagation()}>
@@ -675,49 +972,41 @@ export default function Shell({ api }) {
) )
} }
function ShellAIMessage({ msg, sendToTerminal }) { function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant' const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const parts = parseMarkdown(msg.content || '') const content = msg.content || ''
if (role === 'user') {
return <div className={`ai-message user`}>{content}</div>
}
if (role === 'system') {
return <div className={`ai-message system`}>{content}</div>
}
const parts = renderContent(content)
return ( return (
<div className={`ai-message ${role}`}> <div className={`ai-message assistant`}>
{parts.map((part, i) => { {parts.map((part, i) => {
if (part.type === 'code') { if (part.type === 'code') {
return ( return (
<div key={i} className="shell-code-block"> <div key={i} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>} {part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.code}</code></pre> <pre><code>{part.content}</code></pre>
<div className="shell-code-actions"> <div className="shell-code-actions">
<button onClick={() => navigator.clipboard.writeText(part.code)} title="Copier"> <button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
<Copy size={12} /> Copier <Copy size={12} /> Copier
</button> </button>
<button onClick={() => sendToTerminal(part.code)} title="Envoyer au terminal"> <button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
<Send size={12} /> Terminal <Send size={12} /> Terminal
</button> </button>
</div> </div>
</div> </div>
) )
} }
return <span key={i}>{part.text}</span> return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
})} })}
</div> </div>
) )
} }
function parseMarkdown(text) {
const parts = []
const regex = /```(\w*)\n([\s\S]*?)```/g
let last = 0
let match
while ((match = regex.exec(text)) !== null) {
if (match.index > last) {
parts.push({ type: 'text', text: text.slice(last, match.index) })
}
parts.push({ type: 'code', lang: match[1] || '', code: match[2].replace(/\n$/, '') })
last = match.index + match[0].length
}
if (last < text.length) {
parts.push({ type: 'text', text: text.slice(last) })
}
return parts.length > 0 ? parts : [{ type: 'text', text }]
}

View File

@@ -47,17 +47,24 @@ function renderContent(text) {
lastIndex = match.index + full.length lastIndex = match.index + full.length
} }
if (lastIndex < text.length) { if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) }) 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 })
}
} }
return parts return parts
} }
function formatText(text) { function formatText(text) {
// First escape HTML entities
let html = text let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Apply markdown transformations (now with escaped brackets)
html = html html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>') .replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
@@ -66,10 +73,13 @@ function formatText(text) {
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>') .replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>') .replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>') .replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>')
// Sanitize: remove event handlers and dangerous protocols
html = html html = html
.replace(/\s+on\w+=["'][^"']*["']/gi, '') // Remove on* event handlers .replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '') .replace(/javascript:/gi, '')
.replace(/data:/gi, '') .replace(/data:/gi, '')
@@ -187,7 +197,7 @@ function FeedItem({ msg }) {
) )
} }
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '') let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return ( return (
<div className={`feed-item ${msg.role}`}> <div className={`feed-item ${msg.role}`}>
@@ -331,6 +341,20 @@ export default function Studio({ api }) {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls]) }, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
const onTab = (e) => {
if (e.key !== 'Tab') return
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
const feed = document.querySelector('.studio-feed-layout')
if (!feed?.closest('.tab-hidden')) {
e.preventDefault()
textareaRef.current?.focus()
}
}
window.addEventListener('keydown', onTab)
return () => window.removeEventListener('keydown', onTab)
}, [])
useEffect(() => { useEffect(() => {
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto' textareaRef.current.style.height = 'auto'
@@ -380,6 +404,14 @@ export default function Studio({ api }) {
const text = input.trim() const text = input.trim()
setInput('') setInput('')
const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t)
if (text.startsWith('/') && !isSlashCommand(text)) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.', time: new Date().toISOString() }])
return
}
if (text === '/clear') { if (text === '/clear') {
handleClear() handleClear()
return return
@@ -395,6 +427,7 @@ export default function Studio({ api }) {
'- `/plan <objectif>` - Demander un plan structuré', '- `/plan <objectif>` - Demander un plan structuré',
'- `/export` - Exporter la conversation en Markdown', '- `/export` - Exporter la conversation en Markdown',
'- `/model` - Afficher le provider et modèle actifs', '- `/model` - Afficher le provider et modèle actifs',
'- `/model change` - Basculer entre MiniMax et ZAI',
'', '',
'## Tools disponibles', '## Tools disponibles',
'- Terminal - Exécuter des commandes', '- Terminal - Exécuter des commandes',
@@ -414,14 +447,37 @@ export default function Studio({ api }) {
return return
} }
if (text === '/model') { if (text === '/model' || text === '/model change') {
api.getProviders().then(data => { if (text === '/model change') {
const active = data.providers?.find(p => p.active) api.getProviders().then(data => {
const modelMsg = active ? active.name : 'Aucun provider actif configuré' const providers = data.providers || []
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }]) const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
}).catch(() => { const zai = providers.find(p => p.name.toUpperCase() === 'ZAI')
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }]) if (!minimax || !zai) {
}) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
return
}
const active = providers.find(p => p.active)
const activeName = active ? active.name.toUpperCase() : ''
const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX'
const target = switchTo === 'MINIMAX' ? minimax : zai
api.saveProvider({ name: target.name, active: true }).then(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur lors du changement de provider.', time: new Date().toISOString() }])
})
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
})
} else {
api.getProviders().then(data => {
const active = data.providers?.find(p => p.active)
const modelMsg = active ? `**${active.name}** — ${active.model}` : 'Aucun provider actif configuré'
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
})
}
return return
} }
@@ -476,6 +532,8 @@ export default function Studio({ api }) {
if (event && event.tool_call) { if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }] toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls]) setStreamToolCalls([...toolCalls])
accumulated = ''
setStreaming('')
return return
} }
if (event && event.tool_result) { if (event && event.tool_result) {
@@ -502,6 +560,11 @@ export default function Studio({ api }) {
aiMsg.content = JSON.stringify({ aiMsg.content = JSON.stringify({
content: finalContent, content: finalContent,
tool_calls: toolCalls.map(tc => tc.call), 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]) setMessages(prev => [...prev, aiMsg])
@@ -539,10 +602,38 @@ export default function Studio({ api }) {
} }
}, []) }, [])
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change']
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
handleSend() handleSend()
return
}
if (e.key === 'Tab') {
e.preventDefault()
const ta = textareaRef.current
if (!ta) return
if (document.activeElement !== ta) {
ta.focus()
return
}
const val = ta.value
const pos = ta.selectionStart
const before = val.slice(0, pos)
const afterSlash = before.match(/\/[\w ]*$/)
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
})
}
}
} }
} }
@@ -653,7 +744,7 @@ export default function Studio({ api }) {
)} )}
</div> </div>
<div className="studio-input-hint"> <div className="studio-input-hint">
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model {t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change
</div> </div>
</div> </div>
</div> </div>

View File

@@ -384,8 +384,8 @@ input::placeholder { color: var(--text-disabled); }
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; } .shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance { .shell-xterm-instance {
position: absolute; inset: 0; padding: 4px; height: 100%;
display: block !important; padding: 4px;
} }
.shell-xterm-instance .xterm { height: 100%; padding: 4px; } .shell-xterm-instance .xterm { height: 100%; padding: 4px; }
@@ -393,6 +393,9 @@ input::placeholder { color: var(--text-disabled); }
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); } .connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
.connection-dot.off { background: var(--error); } .connection-dot.off { background: var(--error); }
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
.shell-tab.ai-tab { border-bottom-color: var(--accent); }
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; } .shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; } .ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
.shell-analyze-btn { .shell-analyze-btn {
@@ -446,6 +449,21 @@ input::placeholder { color: var(--text-disabled); }
.shell-code-actions button:last-child { border-right: none; } .shell-code-actions button:last-child { border-right: none; }
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); } .shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
.shell-analysis-modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 720px; max-width: 90vw; max-height: 80vh;
display: flex; flex-direction: column; overflow: hidden;
}
.shell-analysis-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid var(--border);
font-weight: 700; font-size: 15px; color: var(--accent);
}
.shell-analysis-modal-body {
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
color: var(--text-primary); word-break: break-word;
}
.shell-modal-overlay { .shell-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 1000; display: flex; align-items: center; justify-content: center; z-index: 1000;
@@ -522,6 +540,9 @@ input::placeholder { color: var(--text-disabled); }
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; } .provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; } .provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; } .provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
.provider-card-model { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
.provider-card-model-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
.provider-card-model-value { font-size: 14px; font-weight: 600; font-family: var(--font-mono); color: var(--accent); }
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } .provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.provider-setup-hint { .provider-setup-hint {
@@ -605,7 +626,7 @@ input::placeholder { color: var(--text-disabled); }
position: relative; position: relative;
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px 16px; border-radius: var(--radius-lg); padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; justify-content: center; gap: 8px;
overflow: hidden; overflow: hidden;
} }
@@ -670,32 +691,38 @@ input::placeholder { color: var(--text-disabled); }
} }
/* Commands */ /* Commands */
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; } .dash-cmd-card .dash-cmd-list { max-height: 220px; }
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
.dash-cmd-row { .dash-cmd-row {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 3px 0; overflow: hidden; padding: 5px 8px; border-radius: var(--radius-sm);
} background: var(--bg-surface); cursor: pointer;
.dash-cmd-shell { transition: background 0.12s;
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
text-transform: uppercase; flex-shrink: 0;
} }
.dash-cmd-row:hover { background: var(--accent-bg); }
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.dash-cmd-text { .dash-cmd-text {
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary); font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
flex: 1; min-width: 0;
} }
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
.dash-cmd-freq-row {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 3px 4px; border-radius: var(--radius-sm);
transition: background 0.12s;
}
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } .dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.dash-cmd-chip {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: var(--radius);
background: var(--bg-surface); border: 1px solid var(--border);
cursor: pointer; transition: all 0.15s;
}
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
.dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
.dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
/* Services */ /* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; } .dash-services { display: flex; flex-direction: column; gap: 6px; }
@@ -808,7 +835,7 @@ input::placeholder { color: var(--text-disabled); }
} }
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; } .feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); } .feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; } .feed-content { font-size: 14px; line-height: 1.5; color: var(--text-primary); word-break: break-word; }
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; } .feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; } .feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
.feed-system-text.compressed { color: var(--accent); font-style: normal; } .feed-system-text.compressed { color: var(--accent); font-style: normal; }
@@ -868,11 +895,11 @@ input::placeholder { color: var(--text-disabled); }
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px; background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
} }
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); } .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: 16px 0 8px; display: block; } .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: 12px 0 6px; display: block; } .msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; } .msg-bullet { display: block; padding-left: 16px; position: relative; margin: 1px 0; }
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); } .msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; } .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; } .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; } .studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } } @keyframes blink { 50% { opacity: 0; } }