From 24b31b0b47db526bb134ba2905b6c9802d42e7e8 Mon Sep 17 00:00:00 2001 From: Augustin Date: Thu, 23 Apr 2026 23:24:43 +0200 Subject: [PATCH 01/44] fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/api/handlers_info.go | 13 ++++++++++++- web/src/components/Dashboard.jsx | 5 +++-- web/src/components/Shell.jsx | 32 ++++++++++++++++++++++++++++---- web/src/components/Studio.jsx | 11 ++++++++++- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index f031df0..46cf8e3 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "time" @@ -553,10 +554,11 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) { shell = "zsh" } lines := strings.Split(string(data), "\n") - start := len(lines) - 25 + start := len(lines) - 50 if start < 0 { start = 0 } + for i := len(lines) - 1; i >= start; i-- { line := strings.TrimSpace(lines[i]) if line == "" || strings.HasPrefix(line, "#") { @@ -573,6 +575,15 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) { if line == "" { continue } + + base := strings.Fields(line)[0] + if len(base) < 2 { + continue + } + if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) { + continue + } + entries = append(entries, cmdEntry{Cmd: line, Shell: shell}) } } diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index e74080b..cece2be 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -93,13 +93,14 @@ export default function Dashboard({ api, refreshRef }) { const minimax = (quota || []).find(p => p.name === 'minimax') const zai = (quota || []).find(p => p.name === 'zai') - const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history'] + 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 counts = {} for (const c of recentCmds) { 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 } return Object.entries(counts) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index dd8a6f5..2a695d2 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -28,7 +28,16 @@ function renderContent(text) { lastIndex = match.index + full.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 } @@ -308,7 +317,7 @@ export default function Shell({ api }) { if (tabsRef.current[tabId]) return const container = document.getElementById(`terminal-${tabId}`) - if (!container || container.offsetHeight === 0) return + if (!container) return const s = settingsRef.current const { term, fitAddon } = createTerminal(container, { @@ -368,7 +377,12 @@ export default function Shell({ api }) { if (!tab) return const tryInit = (attempt) => { - if (attempt > 10) return + if (attempt > 20) return + const shellCol = document.querySelector('.shell-terminal-col') + if (!shellCol || shellCol.offsetParent === null) { + setTimeout(() => tryInit(attempt + 1), 150) + return + } const container = document.getElementById(`terminal-${tab.id}`) if (!container || container.offsetHeight === 0) { setTimeout(() => tryInit(attempt + 1), 100) @@ -404,7 +418,17 @@ export default function Shell({ api }) { useEffect(() => { const onKey = (e) => { 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) if (num >= 1 && num <= tabs.length) { diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 11d48fc..915ffda 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -47,7 +47,16 @@ function renderContent(text) { lastIndex = match.index + full.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 } From a23c0c5b9414bcb6610f90c4e044e6314b4fec82 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 13:15:51 +0200 Subject: [PATCH 02/44] fix: display all quota models, center card content vertically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/api/handlers_info.go | 30 +++++++++++++++++++++--------- web/src/styles/global.css | 2 +- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 46cf8e3..e6ced66 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -496,22 +496,34 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) { if json.Unmarshal(body, &data) == nil { if d, ok := data["data"].(map[string]interface{}); ok { if limits, ok := d["limits"].([]interface{}); ok { - timeLimit := map[string]interface{}{} + models := make([]map[string]interface{}, 0) for _, l := range limits { - if lm, ok := l.(map[string]interface{}); ok && lm["type"] == "TIME_LIMIT" { + 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 - timeLimit = map[string]interface{}{ - "model": "Z.AI", - "used": usage, - "total": total, - "remaining": 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(timeLimit) > 0 { - q.Data = map[string]interface{}{"models": []map[string]interface{}{timeLimit}} + if len(models) > 0 { + q.Data = map[string]interface{}{"models": models} q.Healthy = true } } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index aee1922..a9eb05f 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -626,7 +626,7 @@ input::placeholder { color: var(--text-disabled); } position: relative; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px 16px; - display: flex; flex-direction: column; gap: 8px; + display: flex; flex-direction: column; justify-content: center; gap: 8px; overflow: hidden; } From 167ab829784073b4ed8cb43680ee988db175c2f0 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 13:16:08 +0200 Subject: [PATCH 03/44] bump: v0.3.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- internal/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/version/version.go b/internal/version/version.go index 8ac3608..a7385cd 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.3.4" + Version = "0.3.5" Author = "La LĂ©gion de Muyue" ) From 00118f0803aa1cdab0e64c3fc777626f637c22ff Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 13:49:12 +0200 Subject: [PATCH 04/44] refactor: remove locale panel, improve provider validation and terminal buffer persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/components/Config.jsx | 247 ++++++++++++++-------------------- web/src/components/Shell.jsx | 153 +++++++++++++-------- web/src/components/Studio.jsx | 11 +- 3 files changed, 200 insertions(+), 211 deletions(-) diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index f96bc2c..a5b41e2 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -1,13 +1,11 @@ import { useState, useEffect, useCallback } from 'react' -import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react' -import { useI18n, LANGUAGES } from '../i18n' -import { getLayoutList } from '../i18n/keyboards' +import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle, X } from 'lucide-react' +import { useI18n } from '../i18n' const PANELS = [ { id: 'profile', icon: User }, { id: 'providers', icon: Brain }, { id: 'updates', icon: RefreshCw }, - { id: 'locale', icon: Globe }, { id: 'skills', icon: Wrench }, { id: 'system', icon: Monitor }, ] @@ -29,8 +27,6 @@ export default function Config({ api }) { const [toast, setToast] = useState(null) - const layouts = getLayoutList() - const loadData = useCallback(() => { api.getConfig().then(d => { setConfig(d) @@ -168,13 +164,6 @@ export default function Config({ api }) { t={t} /> )} - {activePanel === 'locale' && ( - - )} {activePanel === 'skills' && ( )} @@ -320,21 +309,36 @@ function getFieldLabel(key, t) { function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) { const [validating, setValidating] = useState(null) - const [validationStatus, setValidationStatus] = useState(null) + const [keyStatus, setKeyStatus] = useState({}) + + 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 validateKey = async (p) => { + setValidating(p.name) + try { + await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' }) + setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } })) + } catch (err) { + setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'ClĂ© invalide' } })) + } + setValidating(null) + } const handleValidate = async (name, apiKey, model, baseUrl) => { setValidating(name) - setValidationStatus(null) try { await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl }) - setValidationStatus({ provider: name, valid: true }) + setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } })) } catch (err) { - const msg = err.message || '' - if (msg.includes('invalid_api_key')) { - setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') }) - } else { - setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` }) - } + setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'ClĂ© invalide' } })) } setValidating(null) } @@ -345,8 +349,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
{displayed.map((p, i) => { const isEditing = editProvider === p.name - const isValidationTarget = validationStatus?.provider === p.name const currentModel = providerForm[p.name]?.model || p.model + const status = keyStatus[p.name] return (
@@ -354,8 +358,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
{p.name.toUpperCase()} {p.active && active} - {isValidationTarget && validationStatus?.valid && {t('config.keyValid')}} - {isValidationTarget && !validationStatus?.valid && {validationStatus?.error}} + {status?.checked && status?.valid && ✓ {t('config.keyValid')}} + {status?.checked && !status?.valid && ✗ {status.error || t('config.keyInvalid')}}
@@ -402,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(t => !t.installed) + return ( <>
@@ -425,6 +436,30 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
+ {missingTools.length > 0 && ( + <> +
{t('config.missing') || 'Modules manquants'}
+
+ {missingTools.map((tool, i) => ( +
+
+ {tool.name} + + {t('config.notInstalled') || 'Non installé'} + +
+ +
+ ))} +
+ + )} + {updates.length === 0 ? (
{t('config.noUpdates')}
@@ -460,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 ( -
- {toast &&
{toast}
} -
-
- {t('config.language')} - {currentLang?.name || language} -
-
- {t('config.keyboardLayout')} - {currentKbd?.name || keyboard} -
-
- {editLocale && ( -
-
- {t('config.language')} -
- {LANGUAGES.map(lang => ( -
setDraftLang(lang.id)} - > - {lang.name} -
- ))} -
-
-
- {t('config.keyboardLayout')} -
- {layouts.map(l => ( -
setDraftKbd(l.id)} - > - {l.name} -
- ))} -
-
-
- )} -
-
- {editLocale ? ( - <> - - - - ) : ( - - )} -
-
-
- ) -} function PanelSkills({ skillList, t }) { const [selected, setSelected] = useState(null) @@ -634,7 +578,7 @@ function PanelSkills({ skillList, t }) { } function PanelSystem({ api, t }) { - const [resetConfirm, setResetConfirm] = useState(false) + const [showResetModal, setShowResetModal] = useState(false) const [toast, setToast] = useState(null) const showToast = (msg) => { @@ -645,7 +589,7 @@ function PanelSystem({ api, t }) { const handleReset = async () => { try { await api.resetConfig() - setResetConfirm(false) + setShowResetModal(false) showToast(t('config.resetDone')) setTimeout(() => window.location.reload(), 1500) } catch (err) { @@ -653,49 +597,66 @@ function PanelSystem({ api, t }) { } } - const handleApplyStarship = async () => { - try { - await api.applyStarshipTheme('charm') - showToast(t('config.starshipApplied')) - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } + const handleApplyStarship = () => { + window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) + window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le systÚme. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thÚme "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } })) } return ( <> {toast &&
{toast}
} + +
Configuration SystĂšme
{t('config.applyStarship')}
-
- {t('config.starshipApplied')} +
+ Vérifie l'installation de starship et configure le thÚme charm via l'IA.
-
+ +
+ + Zone Rouge +
+
- {t('config.resetConfig')} + {t('config.resetConfig')}
- {resetConfirm ? ( -
-
- {t('config.resetConfirm')} +
+ Cette action supprimera toute votre configuration et relancera l'application. +
+ +
+ + {showResetModal && ( +
setShowResetModal(false)}> +
e.stopPropagation()}> +
+ + {t('config.resetConfig')}
-
- - +
+

+ {t('config.resetConfirm')} +

+

+ Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée. +

+
+
+ +
- ) : ( - - )} -
+
+ )} ) } diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 2a695d2..859d4a0 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,15 +1,15 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' -import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } 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 { useI18n } from '../i18n' -const AI_TAB_ID = 0 const MAX_TABS = 7 const SHELL_MAX_TOKENS = 100000 const TABS_STORAGE_KEY = 'muyue_shell_tabs' +const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers' function renderContent(text) { const parts = [] @@ -132,7 +132,7 @@ function createTerminal(container, settings = {}) { const theme = getTheme(settings.theme || 'default') const term = new XTerm({ cursorBlink: true, - fontSize: settings.fontSize || 14, + fontSize: settings.fontSize || 12, fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme, allowTransparency: false, @@ -201,7 +201,7 @@ export default function Shell({ api }) { const { t } = useI18n() const tabsRef = useRef({}) const nextIdRef = useRef(1) - const settingsRef = useRef({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' }) + const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' }) const savedTabs = (() => { try { @@ -217,15 +217,13 @@ export default function Shell({ api }) { })() const [tabs, setTabs] = useState(savedTabs || [ - { id: AI_TAB_ID, name: 'AI Terminal', type: 'ai', shell: '', connected: false, ai: true }, { id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false }, ]) const [activeTab, setActiveTab] = useState(() => { if (savedTabs) { - const aiTab = savedTabs.find(t => t.ai) - return aiTab ? aiTab.id : savedTabs[0].id + return savedTabs[0]?.id || 1 } - return AI_TAB_ID + return 1 }) const [sshConnections, setSshConnections] = useState([]) const [systemTerminals, setSystemTerminals] = useState([]) @@ -234,7 +232,7 @@ export default function Shell({ api }) { const [editingTab, setEditingTab] = useState(null) const [editName, setEditName] = useState('') const [terminalSettings, setTerminalSettings] = useState({ - fontSize: 14, + fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: 'default', }) @@ -253,7 +251,6 @@ export default function Shell({ api }) { const [analyzing, setAnalyzing] = useState(false) const [showAnalysis, setShowAnalysis] = useState(false) const [analysisContent, setAnalysisContent] = useState('') - const [renderTick, setRenderTick] = useState(0) const aiMessagesRef = useRef(null) const aiLoadedRef = useRef(false) @@ -261,12 +258,6 @@ export default function Shell({ api }) { aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) }, [aiMessages]) - useEffect(() => { - const ms = aiLoading ? 1000 : 5000 - const iv = setInterval(() => setRenderTick(t => t + 1), ms) - return () => clearInterval(iv) - }, [aiLoading]) - useEffect(() => { api.getShellAnalysis?.().then(d => { if (d?.analysis) setAnalysisContent(d.analysis) @@ -305,7 +296,7 @@ export default function Shell({ api }) { api.getConfig().then(d => { if (d.terminal) { 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", theme: d.terminal.theme || 'default', }) @@ -346,11 +337,58 @@ export default function Shell({ api }) { const ws = connectWebSocket(term, fitAddon, initPayload) + // Restore saved terminal buffer + try { + const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + if (savedBuffers[tabId]) { + term.write(savedBuffers[tabId]) + } + } catch {} + + const saveBuffer = () => { + try { + const buf = term.buffer.active + const lines = [] + for (let i = 0; i < buf.length; i++) { + const line = buf.getLine(i) + if (line) lines.push(line.translateToString(true)) + } + const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + savedBuffers[tabId] = lines.join('\n') + localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch {} + } + + const bufferSaveInterval = setInterval(saveBuffer, 5000) + + // Detect clear command to wipe saved buffer + let inputBuffer = '' + term.onData((data) => { + if (data === '\r') { + const cmd = inputBuffer.replace(/[\x1b\x00-\x1f]/g, '').trim().toLowerCase() + if (cmd === 'clear') { + try { + const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + delete savedBuffers[tabId] + localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch {} + } + inputBuffer = '' + } else if (data === '\x7f' || data === '\b') { + inputBuffer = inputBuffer.slice(0, -1) + } else if (data === '\x03') { + inputBuffer = '' + } else if (data.length === 1 && data.charCodeAt(0) >= 32) { + inputBuffer += data + } + }) + ws.onopen = () => { setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t)) } ws.onclose = () => { + saveBuffer() setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t)) } @@ -369,7 +407,7 @@ export default function Shell({ api }) { resizeObserver.observe(container) window.addEventListener('resize', onResize) - tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize } + tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer } }, []) useEffect(() => { @@ -444,7 +482,7 @@ export default function Shell({ api }) { if (tabs.length >= MAX_TABS) return const id = nextIdRef.current++ const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false } - setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, ai: t.ai || false, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) + setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) setActiveTab(id) setShowMenu(false) } @@ -462,7 +500,7 @@ export default function Shell({ api }) { key_path: conn.key_path || '', connected: false, } - setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, ai: t.ai || false, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) + setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) setActiveTab(id) setShowMenu(false) } @@ -470,10 +508,12 @@ export default function Shell({ api }) { const closeTab = (tabId, e) => { if (e) e.stopPropagation() const tab = tabs.find(t => t.id === tabId) - if (!tab || tab.ai || tabs.length <= 1) return + if (!tab || tabs.length <= 1) return if (tabsRef.current[tabId]) { - const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId] + const { ws, resizeObserver, onResize, term, bufferSaveInterval, saveBuffer } = tabsRef.current[tabId] + if (saveBuffer) saveBuffer() + if (bufferSaveInterval) clearInterval(bufferSaveInterval) window.removeEventListener('resize', onResize) resizeObserver.disconnect() ws.close() @@ -527,19 +567,16 @@ export default function Shell({ api }) { } const sendToTerminal = useCallback((code) => { - const aiEntry = tabsRef.current[AI_TAB_ID] - if (aiEntry?.ws && aiEntry.ws.readyState === WebSocket.OPEN) { - aiEntry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) + const entry = tabsRef.current[activeTab] + if (entry?.ws && entry.ws.readyState === WebSocket.OPEN) { + entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) } - }, []) + }, [activeTab]) const focusAiTerminal = useCallback(() => { - setActiveTab(AI_TAB_ID) - setTimeout(() => { - const entry = tabsRef.current[AI_TAB_ID] - if (entry) entry.term.focus() - }, 150) - }, []) + const entry = tabsRef.current[activeTab] + if (entry) entry.term.focus() + }, [activeTab]) const handleAiSend = async () => { if (!aiInput.trim() || aiLoading || aiAtLimit) return @@ -596,21 +633,7 @@ export default function Shell({ api }) { setAiLoading(false) } - useEffect(() => { - const handler = (e) => { - const msg = e.detail?.message - if (!msg) return - setAiInput(msg) - setActiveTab(AI_TAB_ID) - setTimeout(() => { - handleAiSendDirect(msg) - }, 100) - } - window.addEventListener('ask-ai-terminal', handler) - return () => window.removeEventListener('ask-ai-terminal', handler) - }, []) - - const handleAiSendDirect = async (text) => { + const handleAiSendDirect = useCallback(async (text) => { if (!text || aiLoading || aiAtLimit) return setAiInput('') @@ -652,7 +675,20 @@ export default function Shell({ api }) { setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) } setAiLoading(false) - } + }, [api, t, aiLoading, aiAtLimit]) + + useEffect(() => { + const handler = (e) => { + const msg = e.detail?.message + if (!msg) return + setAiInput(msg) + setTimeout(() => { + handleAiSendDirect(msg) + }, 100) + } + window.addEventListener('ask-ai-terminal', handler) + return () => window.removeEventListener('ask-ai-terminal', handler) + }, [handleAiSendDirect]) const handleAnalyze = async () => { setAnalyzing(true) @@ -681,14 +717,13 @@ export default function Shell({ api }) { {tabs.map((tab, i) => (
setActiveTab(tab.id)} - onDoubleClick={(e) => !tab.ai && startRename(tab.id, e)} + onDoubleClick={(e) => startRename(tab.id, e)} > - {tab.ai && } - {!tab.ai && tab.type === 'ssh' && } - {!tab.ai && tab.type === 'local' && } + {tab.type === 'ssh' && } + {tab.type === 'local' && } {editingTab === tab.id ? ( {tab.name} )} {i + 1} - {!tab.ai && tabs.length > 1 && ( + {tabs.length > 1 && (
{aiMessages.map((msg, i) => ( - + ))} {aiLoading &&
}
@@ -915,7 +950,7 @@ export default function Shell({ api }) { ) } -function ShellAIMessage({ msg, sendToTerminal, renderTick }) { +function ShellAIMessage({ msg, sendToTerminal }) { const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant' const content = msg.content || '' @@ -934,7 +969,7 @@ function ShellAIMessage({ msg, sendToTerminal, renderTick }) { {parts.map((part, i) => { if (part.type === 'code') { return ( -
+
{part.lang &&
{part.lang}
}
{part.content}
@@ -948,7 +983,7 @@ function ShellAIMessage({ msg, sendToTerminal, renderTick }) {
) } - return + return })}
) diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 915ffda..c35e2a1 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -309,7 +309,6 @@ export default function Studio({ api }) { const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 }) const [contextCollapsed, setContextCollapsed] = useState(false) const [messagesCollapsed, setMessagesCollapsed] = useState(false) - const [renderTick, setRenderTick] = useState(0) const messagesEnd = useRef(null) const feedRef = useRef(null) const textareaRef = useRef(null) @@ -342,12 +341,6 @@ export default function Studio({ api }) { messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages, streaming, streamThinking, streamToolCalls]) - useEffect(() => { - const ms = loading ? 1000 : 5000 - const iv = setInterval(() => setRenderTick(t => t + 1), ms) - return () => clearInterval(iv) - }, [loading]) - useEffect(() => { const onTab = (e) => { if (e.key !== 'Tab') return @@ -648,7 +641,7 @@ export default function Studio({ api }) { return ( <> {messages.slice(0, visibleCount).map(msg => ( - + ))}
@@ -661,7 +654,7 @@ export default function Studio({ api }) { ) } return messages.map(msg => ( - + )) } From 233368c954f9f164503c91e819878424ffcdb83e Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 14:15:14 +0200 Subject: [PATCH 05/44] fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/api/chat_engine.go | 9 +- web/src/components/Shell.jsx | 175 ++++++++++++++++------------------- 2 files changed, 81 insertions(+), 103 deletions(-) diff --git a/internal/api/chat_engine.go b/internal/api/chat_engine.go index 28feab8..79193a0 100644 --- a/internal/api/chat_engine.go +++ b/internal/api/chat_engine.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "net/http" - "strings" "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/orchestrator" @@ -76,12 +75,8 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator. content := cleanThinkingTags(choice.Message.Content) if content != "" { - words := strings.Fields(content) - for _, w := range words { - chunk := w - if ce.onChunk != nil { - ce.onChunk(map[string]interface{}{"content": chunk}) - } + if ce.onChunk != nil { + ce.onChunk(map[string]interface{}{"content": content}) } finalContent = content } diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 859d4a0..c03fbfd 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -337,13 +337,17 @@ export default function Shell({ api }) { const ws = connectWebSocket(term, fitAddon, initPayload) - // Restore saved terminal buffer - try { - const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') - if (savedBuffers[tabId]) { - term.write(savedBuffers[tabId]) - } - } catch {} + // Restore saved terminal buffer after first output settles + const restoreBuffer = () => { + try { + const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + if (savedBuffers[tabId]) { + term.write('\x1b[90m— session restaurĂ©e —\x1b[0m\r\n') + term.write(savedBuffers[tabId]) + } + } catch {} + } + setTimeout(restoreBuffer, 300) const saveBuffer = () => { try { @@ -362,24 +366,30 @@ export default function Shell({ api }) { const bufferSaveInterval = setInterval(saveBuffer, 5000) // Detect clear command to wipe saved buffer - let inputBuffer = '' - term.onData((data) => { - if (data === '\r') { - const cmd = inputBuffer.replace(/[\x1b\x00-\x1f]/g, '').trim().toLowerCase() - if (cmd === 'clear') { - try { + // We read the current line from the terminal buffer on Enter + // instead of trying to reconstruct from keystrokes (unreliable with history, ANSI, etc.) + const clearBufferOnClear = () => { + try { + const buf = term.buffer.active + const lineY = buf.baseY + buf.cursorY + 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(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') delete savedBuffers[tabId] localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) - } catch {} + } } - inputBuffer = '' - } else if (data === '\x7f' || data === '\b') { - inputBuffer = inputBuffer.slice(0, -1) - } else if (data === '\x03') { - inputBuffer = '' - } else if (data.length === 1 && data.charCodeAt(0) >= 32) { - inputBuffer += data + } catch {} + } + + // Hook into onData to detect Enter for clear detection + // The connectWebSocket already registered its own onData for WS forwarding, + // this one is purely for clear detection + term.onData((data) => { + if (data === '\r') { + clearBufferOnClear() } }) @@ -507,21 +517,30 @@ export default function Shell({ api }) { const closeTab = (tabId, e) => { if (e) e.stopPropagation() - const tab = tabs.find(t => t.id === tabId) - if (!tab || tabs.length <= 1) return - - if (tabsRef.current[tabId]) { - const { ws, resizeObserver, onResize, term, bufferSaveInterval, saveBuffer } = tabsRef.current[tabId] - if (saveBuffer) saveBuffer() - if (bufferSaveInterval) clearInterval(bufferSaveInterval) - window.removeEventListener('resize', onResize) - resizeObserver.disconnect() - ws.close() - term.dispose() - delete tabsRef.current[tabId] - } setTabs(prev => { + if (prev.length <= 1) return prev + const tab = prev.find(t => t.id === tabId) + if (!tab) return prev + + const entry = tabsRef.current[tabId] + if (entry) { + entry.saveBuffer?.() + if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) + window.removeEventListener('resize', entry.onResize) + entry.resizeObserver.disconnect() + entry.ws.close() + entry.term.dispose() + delete tabsRef.current[tabId] + } + + // Clean buffer from localStorage + try { + const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + delete savedBuffers[tabId] + localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch {} + const next = prev.filter(t => t.id !== tabId) if (activeTab === tabId && next.length > 0) { setActiveTab(next[next.length - 1].id) @@ -568,9 +587,15 @@ export default function Shell({ api }) { const sendToTerminal = useCallback((code) => { const entry = tabsRef.current[activeTab] - if (entry?.ws && entry.ws.readyState === WebSocket.OPEN) { - entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) + if (!entry) { + console.warn('sendToTerminal: no terminal initialized for tab', activeTab) + return } + if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) { + console.warn('sendToTerminal: WebSocket not ready for tab', activeTab) + return + } + entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) }, [activeTab]) const focusAiTerminal = useCallback(() => { @@ -578,13 +603,16 @@ export default function Shell({ api }) { if (entry) entry.term.focus() }, [activeTab]) - const handleAiSend = async () => { - if (!aiInput.trim() || aiLoading || aiAtLimit) return - const text = aiInput.trim() - setAiInput('') - focusAiTerminal() + const _sendAiMessage = useCallback(async (text, fromEvent = false) => { + if (!text || !text.trim() || aiLoading || aiAtLimit) return + const trimmed = text.trim() - if (text === '/clear') { + if (!fromEvent) { + setAiInput('') + focusAiTerminal() + } + + if (trimmed === '/clear') { try { await api.clearShellChat() setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacĂ©. PrĂȘt.' }]) @@ -594,65 +622,20 @@ export default function Shell({ api }) { return } - if (text === '/help') { + if (trimmed === '/help') { 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.' } ]) return } - setAiMessages(prev => [...prev, { role: 'user', content: text }]) + setAiMessages(prev => [...prev, { role: 'user', content: trimmed }]) setAiLoading(true) try { let accumulated = '' - await api.sendShellChat(text, {}, true, (partial) => { - accumulated = partial - setAiMessages(prev => { - const filtered = prev.filter(m => !m._streaming) - return [...filtered, { role: 'assistant', content: partial, _streaming: true }] - }) - }) - - setAiMessages(prev => { - const filtered = prev.filter(m => !m._streaming) - return [...filtered, { role: 'assistant', content: accumulated }] - }) - // Refresh token count - api.getShellChatHistory().then(d => { - setAiTokens(d.tokens || 0) - setAiAtLimit(d.at_limit || false) - }).catch(() => {}) - } catch (err) { - if (err.message.includes('context limit')) { - setAiAtLimit(true) - } - setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) - } - setAiLoading(false) - } - - const handleAiSendDirect = useCallback(async (text) => { - if (!text || aiLoading || aiAtLimit) return - setAiInput('') - - if (text === '/clear') { - try { - await api.clearShellChat() - setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacĂ©. PrĂȘt.' }]) - setAiTokens(0) - setAiAtLimit(false) - } catch {} - return - } - - setAiMessages(prev => [...prev, { role: 'user', content: text }]) - setAiLoading(true) - - try { - let accumulated = '' - await api.sendShellChat(text, {}, true, (partial) => { + await api.sendShellChat(trimmed, {}, true, (partial) => { accumulated = partial setAiMessages(prev => { const filtered = prev.filter(m => !m._streaming) @@ -675,20 +658,20 @@ export default function Shell({ api }) { setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) } setAiLoading(false) - }, [api, t, aiLoading, aiAtLimit]) + }, [api, t, aiLoading, aiAtLimit, focusAiTerminal]) + + const handleAiSend = () => _sendAiMessage(aiInput, false) useEffect(() => { const handler = (e) => { const msg = e.detail?.message if (!msg) return setAiInput(msg) - setTimeout(() => { - handleAiSendDirect(msg) - }, 100) + setTimeout(() => _sendAiMessage(msg, true), 100) } window.addEventListener('ask-ai-terminal', handler) return () => window.removeEventListener('ask-ai-terminal', handler) - }, [handleAiSendDirect]) + }, [_sendAiMessage]) const handleAnalyze = async () => { setAnalyzing(true) From 40ec493bae03836de8ce8c46efd578b68b547ec9 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 15:20:48 +0200 Subject: [PATCH 06/44] fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/api/terminal.go | 19 +++++---------- web/src/components/Shell.jsx | 47 ++++++++++++++---------------------- 2 files changed, 24 insertions(+), 42 deletions(-) diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 31d2e87..236f794 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -146,13 +146,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { return } log.Printf("terminal: pty started successfully") - defer func() { - ptmx.Close() - if cmd.Process != nil { - cmd.Process.Kill() - cmd.Wait() - } - }() var once sync.Once cleanup := func() { @@ -164,6 +157,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { } }) } + defer cleanup() go func() { buf := make([]byte, 4096) @@ -230,12 +224,11 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) return } var body struct { - Name string `json:"name"` - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - Password string `json:"password"` - KeyPath string `json:"key_path"` + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + KeyPath string `json:"key_path"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, err.Error(), http.StatusBadRequest) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index c03fbfd..ce4da13 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) { return { term, fitAddon } } -function connectWebSocket(term, fitAddon, initPayload) { +function connectWebSocket(term, fitAddon, initPayload, onStateChange) { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) @@ -159,6 +159,7 @@ function connectWebSocket(term, fitAddon, initPayload) { if (dims) { ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols })) } + if (onStateChange) onStateChange(true) }) ws.addEventListener('message', (event) => { @@ -176,10 +177,12 @@ function connectWebSocket(term, fitAddon, initPayload) { ws.addEventListener('close', () => { term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n') + if (onStateChange) onStateChange(false) }) ws.addEventListener('error', () => { term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n') + if (onStateChange) onStateChange(false) }) term.onData((data) => { @@ -335,7 +338,11 @@ export default function Shell({ api }) { } } - const ws = connectWebSocket(term, fitAddon, initPayload) + const onWsState = (connected) => { + if (!connected) saveBuffer() + setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t)) + } + const ws = connectWebSocket(term, fitAddon, initPayload, onWsState) // Restore saved terminal buffer after first output settles const restoreBuffer = () => { @@ -365,13 +372,10 @@ export default function Shell({ api }) { const bufferSaveInterval = setInterval(saveBuffer, 5000) - // Detect clear command to wipe saved buffer - // We read the current line from the terminal buffer on Enter - // instead of trying to reconstruct from keystrokes (unreliable with history, ANSI, etc.) const clearBufferOnClear = () => { try { const buf = term.buffer.active - const lineY = buf.baseY + buf.cursorY + const lineY = buf.viewportY + buf.cursorY const line = buf.getLine(lineY) if (line) { const text = line.translateToString(true).trim().toLowerCase() @@ -384,28 +388,12 @@ export default function Shell({ api }) { } catch {} } - // Hook into onData to detect Enter for clear detection - // The connectWebSocket already registered its own onData for WS forwarding, - // this one is purely for clear detection term.onData((data) => { if (data === '\r') { clearBufferOnClear() } }) - ws.onopen = () => { - setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t)) - } - - ws.onclose = () => { - saveBuffer() - setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t)) - } - - ws.onerror = () => { - setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t)) - } - const onResize = () => { const el = document.getElementById(`terminal-${tabId}`) if (el && el.offsetParent !== null) { @@ -585,14 +573,15 @@ export default function Shell({ api }) { } } - const sendToTerminal = useCallback((code) => { - const entry = tabsRef.current[activeTab] + const sendToTerminal = useCallback((code, tabId) => { + const targetId = tabId || activeTab + const entry = tabsRef.current[targetId] if (!entry) { - console.warn('sendToTerminal: no terminal initialized for tab', activeTab) + console.warn('sendToTerminal: no terminal initialized for tab', targetId) return } if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) { - console.warn('sendToTerminal: WebSocket not ready for tab', activeTab) + console.warn('sendToTerminal: WebSocket not ready for tab', targetId) return } entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) @@ -841,7 +830,7 @@ export default function Shell({ api }) {
{aiMessages.map((msg, i) => ( - + ))} {aiLoading &&
}
@@ -933,7 +922,7 @@ 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 content = msg.content || '' @@ -959,7 +948,7 @@ function ShellAIMessage({ msg, sendToTerminal }) { -
From 1704b196cf6fbac486e347ba819e6384a91476d4 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 15:41:01 +0200 Subject: [PATCH 07/44] fix(terminal): refactor WebSocket cleanup, buffer management, and disposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/api/terminal.go | 2 - web/src/api/client.js | 3 +- web/src/components/Shell.jsx | 152 +++++++++++++++++++++-------------- 3 files changed, 92 insertions(+), 65 deletions(-) diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 236f794..3b3018a 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -165,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { n, err := ptmx.Read(buf) if err != nil { cleanup() - conn.WriteMessage(websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) return } if err := conn.WriteJSON(wsMessage{ diff --git a/web/src/api/client.js b/web/src/api/client.js index 0946736..3845dea 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -105,7 +105,7 @@ const api = { }).catch(reject) }) }, - sendShellChat: (message, context = {}, stream = true, onChunk) => { + sendShellChat: (message, context = {}, stream = true, onChunk, signal) => { const payload = { message, cwd: context.cwd || '', @@ -120,6 +120,7 @@ const api = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + signal, }).then(async (res) => { if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index ce4da13..c24ebf8 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) { return { term, fitAddon } } -function connectWebSocket(term, fitAddon, initPayload, onStateChange) { +function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) @@ -162,7 +162,12 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) { if (onStateChange) onStateChange(true) }) + let firstMessage = true ws.addEventListener('message', (event) => { + if (firstMessage) { + firstMessage = false + if (onFirstMessage) onFirstMessage() + } try { const msg = JSON.parse(event.data) if (msg.type === 'output') { @@ -185,12 +190,6 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) { if (onStateChange) onStateChange(false) }) - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'input', data })) - } - }) - term.onResize(({ rows, cols }) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'resize', rows, cols })) @@ -256,6 +255,7 @@ export default function Shell({ api }) { const [analysisContent, setAnalysisContent] = useState('') const aiMessagesRef = useRef(null) const aiLoadedRef = useRef(false) + const aiLoadingRef = useRef(false) useEffect(() => { aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) @@ -338,23 +338,7 @@ export default function Shell({ api }) { } } - const onWsState = (connected) => { - if (!connected) saveBuffer() - setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t)) - } - const ws = connectWebSocket(term, fitAddon, initPayload, onWsState) - - // Restore saved terminal buffer after first output settles - const restoreBuffer = () => { - try { - const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') - if (savedBuffers[tabId]) { - term.write('\x1b[90m— session restaurĂ©e —\x1b[0m\r\n') - term.write(savedBuffers[tabId]) - } - } catch {} - } - setTimeout(restoreBuffer, 300) + let disposed = false const saveBuffer = () => { try { @@ -364,33 +348,50 @@ export default function Shell({ api }) { const line = buf.getLine(i) if (line) lines.push(line.translateToString(true)) } - const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') savedBuffers[tabId] = lines.join('\n') - localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) - } catch {} + sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch (e) { console.warn('[Shell] Buffer save failed:', e) } } - const bufferSaveInterval = setInterval(saveBuffer, 5000) + const onWsState = (connected) => { + if (disposed) return + if (!connected) saveBuffer() + setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t)) + } + + const restoreBuffer = () => { + try { + const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + if (savedBuffers[tabId]) { + term.write('\x1b[90m— session restaurĂ©e —\x1b[0m\r\n') + term.write(savedBuffers[tabId]) + } + } catch (e) { console.warn('[Shell] Buffer restore failed:', e) } + } + + const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer) const clearBufferOnClear = () => { try { const buf = term.buffer.active - const lineY = buf.viewportY + buf.cursorY + 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(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') delete savedBuffers[tabId] - localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) } } - } catch {} + } catch (e) { console.warn('[Shell] Clear detection failed:', e) } } term.onData((data) => { - if (data === '\r') { - clearBufferOnClear() + if (data === '\r') clearBufferOnClear() + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'input', data })) } }) @@ -405,35 +406,47 @@ export default function Shell({ api }) { resizeObserver.observe(container) window.addEventListener('resize', onResize) - tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer } + const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000) + + tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed } + const origDispose = () => { disposed = true } + tabsRef.current[tabId]._markDisposed = origDispose }, []) useEffect(() => { const tab = tabs.find(t => t.id === activeTab) if (!tab) return + let cancelled = false + const pending = [] + const tryInit = (attempt) => { - if (attempt > 20) return + if (cancelled || attempt > 20) return const shellCol = document.querySelector('.shell-terminal-col') if (!shellCol || shellCol.offsetParent === null) { - setTimeout(() => tryInit(attempt + 1), 150) + pending.push(setTimeout(() => tryInit(attempt + 1), 150)) return } const container = document.getElementById(`terminal-${tab.id}`) if (!container || container.offsetHeight === 0) { - setTimeout(() => tryInit(attempt + 1), 100) + pending.push(setTimeout(() => tryInit(attempt + 1), 100)) return } if (!tabsRef.current[tab.id]) { initTerminal(tab.id, tab) } requestAnimationFrame(() => { + if (cancelled) return const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() }) } tryInit(0) + return () => { + cancelled = true + pending.forEach(clearTimeout) + } }, [activeTab, tabs, initTerminal]) useEffect(() => { @@ -451,6 +464,20 @@ export default function Shell({ api }) { return () => clearInterval(iv) }, [tabs]) + useEffect(() => { + return () => { + for (const [tabId, entry] of Object.entries(tabsRef.current)) { + entry._markDisposed?.() + if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) + window.removeEventListener('resize', entry.onResize) + entry.resizeObserver?.disconnect() + entry.ws?.close() + entry.term?.dispose() + } + tabsRef.current = {} + } + }, []) + useEffect(() => { const onKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return @@ -506,29 +533,26 @@ export default function Shell({ api }) { const closeTab = (tabId, e) => { if (e) e.stopPropagation() + const entry = tabsRef.current[tabId] + if (entry) { + entry._markDisposed?.() + entry.saveBuffer?.() + if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) + window.removeEventListener('resize', entry.onResize) + entry.resizeObserver.disconnect() + entry.ws.close() + entry.term.dispose() + delete tabsRef.current[tabId] + } + + try { + const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + delete savedBuffers[tabId] + sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) } + setTabs(prev => { if (prev.length <= 1) return prev - const tab = prev.find(t => t.id === tabId) - if (!tab) return prev - - const entry = tabsRef.current[tabId] - if (entry) { - entry.saveBuffer?.() - if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) - window.removeEventListener('resize', entry.onResize) - entry.resizeObserver.disconnect() - entry.ws.close() - entry.term.dispose() - delete tabsRef.current[tabId] - } - - // Clean buffer from localStorage - try { - const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') - delete savedBuffers[tabId] - localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) - } catch {} - const next = prev.filter(t => t.id !== tabId) if (activeTab === tabId && next.length > 0) { setActiveTab(next[next.length - 1].id) @@ -593,8 +617,9 @@ export default function Shell({ api }) { }, [activeTab]) const _sendAiMessage = useCallback(async (text, fromEvent = false) => { - if (!text || !text.trim() || aiLoading || aiAtLimit) return + if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return const trimmed = text.trim() + aiLoadingRef.current = true if (!fromEvent) { setAiInput('') @@ -608,6 +633,7 @@ export default function Shell({ api }) { setAiTokens(0) setAiAtLimit(false) } catch {} + aiLoadingRef.current = false return } @@ -616,6 +642,7 @@ export default function Shell({ api }) { { 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.' } ]) + aiLoadingRef.current = false return } @@ -641,13 +668,14 @@ export default function Shell({ api }) { setAiAtLimit(d.at_limit || false) }).catch(() => {}) } catch (err) { - if (err.message.includes('context limit')) { + if (err.message?.includes('context limit')) { setAiAtLimit(true) } setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) } setAiLoading(false) - }, [api, t, aiLoading, aiAtLimit, focusAiTerminal]) + aiLoadingRef.current = false + }, [api, t, aiAtLimit, focusAiTerminal]) const handleAiSend = () => _sendAiMessage(aiInput, false) From 92f943c3e6b3a04cdfa5848c8a4f56fbb617b787 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 15:53:13 +0200 Subject: [PATCH 08/44] fix(shell): add debug logging for tab tracking and WebSocket state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/Shell.jsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index c24ebf8..50493b9 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -408,9 +408,11 @@ export default function Shell({ api }) { const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000) + console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} container=${!!container}`) tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed } const origDispose = () => { disposed = true } tabsRef.current[tabId]._markDisposed = origDispose + console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current)) }, []) useEffect(() => { @@ -601,13 +603,14 @@ export default function Shell({ api }) { const targetId = tabId || activeTab const entry = tabsRef.current[targetId] if (!entry) { - console.warn('sendToTerminal: no terminal initialized for tab', targetId) + console.warn(`[Shell] sendToTerminal: tab ${targetId} not in tabsRef. Available:`, Object.keys(tabsRef.current), 'activeTab:', activeTab, 'requested tabId:', tabId) return } if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) { - console.warn('sendToTerminal: WebSocket not ready for tab', targetId) + console.warn(`[Shell] sendToTerminal: WebSocket not ready for tab ${targetId}, state:`, entry.ws?.readyState) return } + console.log(`[Shell] sendToTerminal: sending code to tab ${targetId} (${code.length} chars)`) entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) }, [activeTab]) @@ -646,7 +649,8 @@ export default function Shell({ api }) { return } - setAiMessages(prev => [...prev, { role: 'user', content: trimmed }]) + const currentTab = activeTab + setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }]) setAiLoading(true) try { @@ -655,13 +659,13 @@ export default function Shell({ api }) { accumulated = partial setAiMessages(prev => { 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 => { const filtered = prev.filter(m => !m._streaming) - return [...filtered, { role: 'assistant', content: accumulated }] + return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }] }) api.getShellChatHistory().then(d => { setAiTokens(d.tokens || 0) @@ -858,7 +862,7 @@ export default function Shell({ api }) {
{aiMessages.map((msg, i) => ( - + ))} {aiLoading &&
}
From 1edd4f053af1adbb460e52aaf639078c8fd8f924 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 16:10:54 +0200 Subject: [PATCH 09/44] fix(shell): improve tab reference stability and command queueing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/Shell.jsx | 41 ++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 50493b9..5667fba 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -204,6 +204,10 @@ export default function Shell({ api }) { const tabsRef = useRef({}) const nextIdRef = useRef(1) const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' }) + const activeTabRef = useRef(activeTab) + const pendingCommandsRef = useRef({}) + + useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) const savedTabs = (() => { try { @@ -408,11 +412,21 @@ export default function Shell({ api }) { const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000) - console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} container=${!!container}`) + 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 } - const origDispose = () => { disposed = true } - tabsRef.current[tabId]._markDisposed = origDispose + 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(() => { @@ -600,24 +614,28 @@ export default function Shell({ api }) { } const sendToTerminal = useCallback((code, tabId) => { - const targetId = tabId || activeTab + const targetId = tabId || activeTabRef.current const entry = tabsRef.current[targetId] if (!entry) { - console.warn(`[Shell] sendToTerminal: tab ${targetId} not in tabsRef. Available:`, Object.keys(tabsRef.current), 'activeTab:', activeTab, 'requested tabId:', tabId) + 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: WebSocket not ready for tab ${targetId}, state:`, entry.ws?.readyState) + 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: sending code to tab ${targetId} (${code.length} chars)`) + console.log(`[Shell] sendToTerminal: tab ${targetId} ← ${code.length} chars`) entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) - }, [activeTab]) + }, []) const focusAiTerminal = useCallback(() => { - const entry = tabsRef.current[activeTab] + const entry = tabsRef.current[activeTabRef.current] if (entry) entry.term.focus() - }, [activeTab]) + }, []) const _sendAiMessage = useCallback(async (text, fromEvent = false) => { if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return @@ -649,7 +667,8 @@ export default function Shell({ api }) { return } - const currentTab = activeTab + 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) From e9696ef82bf93203d32c8f13ccc4708e06f12dc7 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 16:22:54 +0200 Subject: [PATCH 10/44] fix(studio): add tool results serialization and improve message handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/components/Shell.jsx | 2 +- web/src/components/Studio.jsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 5667fba..70b2df8 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -204,7 +204,7 @@ export default function Shell({ api }) { const tabsRef = useRef({}) const nextIdRef = useRef(1) const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' }) - const activeTabRef = useRef(activeTab) + const activeTabRef = useRef(null) const pendingCommandsRef = useRef({}) useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index c35e2a1..f49a53b 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -197,7 +197,7 @@ function FeedItem({ msg }) { ) } - const cleanContent = displayContent.replace(/]*>[\s\S]*?<\/think>/gi, '') + let cleanContent = displayContent.replace(/]*>[\s\S]*?<\/think>/gi, '') return (
@@ -532,6 +532,8 @@ export default function Studio({ api }) { if (event && event.tool_call) { toolCalls = [...toolCalls, { call: event.tool_call, result: null }] setStreamToolCalls([...toolCalls]) + accumulated = '' + setStreaming('') return } if (event && event.tool_result) { @@ -558,6 +560,11 @@ export default function Studio({ api }) { aiMsg.content = JSON.stringify({ content: finalContent, tool_calls: toolCalls.map(tc => tc.call), + tool_results: toolCalls.map(tc => ({ + tool_call_id: tc.call?.tool_call_id, + result: tc.result?.content || '', + is_error: tc.result?.is_error || false, + })), }) } setMessages(prev => [...prev, aiMsg]) From 8d10d2182ed85e3a254b4342606c60e7bf71be77 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 16:33:09 +0200 Subject: [PATCH 11/44] fix(config): remove unused import, reorder hooks, and improve variable naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/Config.jsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index a5b41e2..ac38210 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle, X } from 'lucide-react' +import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react' import { useI18n } from '../i18n' const PANELS = [ @@ -311,16 +311,6 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm const [validating, setValidating] = useState(null) const [keyStatus, setKeyStatus] = useState({}) - 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 validateKey = async (p) => { setValidating(p.name) try { @@ -332,6 +322,16 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm 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 { @@ -412,7 +412,7 @@ function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, in 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(t => !t.installed) + const missingTools = tools.filter(tool => !tool.installed) return ( <> From cbbb22472589c4e262bfe9aa9b89f9e19abc7a25 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 16:44:02 +0200 Subject: [PATCH 12/44] fix(shell): initialize activeTabRef with activeTab and move useEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/Shell.jsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 70b2df8..633c7a8 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -204,11 +204,8 @@ export default function Shell({ api }) { const tabsRef = useRef({}) const nextIdRef = useRef(1) const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' }) - const activeTabRef = useRef(null) const pendingCommandsRef = useRef({}) - useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) - const savedTabs = (() => { try { const raw = localStorage.getItem(TABS_STORAGE_KEY) @@ -231,6 +228,8 @@ export default function Shell({ api }) { } return 1 }) + const activeTabRef = useRef(activeTab) + useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) const [sshConnections, setSshConnections] = useState([]) const [systemTerminals, setSystemTerminals] = useState([]) const [showMenu, setShowMenu] = useState(false) From c91931f42fbfc72eaeebb55510f2354c618ff833 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 16:53:59 +0200 Subject: [PATCH 13/44] feat(ui): redesign recent commands display and fix terminal visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/components/Dashboard.jsx | 28 ++++++++++++------- web/src/components/Shell.jsx | 25 ++++++++++------- web/src/styles/global.css | 46 +++++++++++++++++--------------- 3 files changed, 58 insertions(+), 41 deletions(-) diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index cece2be..b6a7d4f 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -197,26 +197,34 @@ export default function Dashboard({ api, refreshRef }) {
{/* Recent Commands */} -
+
Recent Commands + {recentUnique.length}
{topCmds.length > 0 && ( -
+
+ Most used {topCmds.map((c, i) => ( -
{ navigator.clipboard.writeText(c.cmd); setCopiedIdx(i); setTimeout(() => setCopiedIdx(-1), 1200); }}> - {copiedIdx === i ? '✓ CopiĂ©' : c.cmd} - {c.count}× +
copyCmd(c.cmd, `top-${i}`)} title={c.cmd}> + {copiedSet.has(`top-${i}`) ? '✓ CopiĂ©' : c.cmd} +
+
+
+ {c.count}×
))}
)}
- {recentCmds.length === 0 && No history} - {recentCmds.map((c, i) => ( -
- {c.shell} - {c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd} + {recentUnique.length === 0 && No history} + {recentUnique.map((c, i) => ( +
copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}> +
+ {c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd} + {relativeTime(c.ts)} +
+ {copiedSet.has(`list-${i}`) ? '✓' : '⎘'}
))}
diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 633c7a8..64235ca 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -400,7 +400,7 @@ export default function Shell({ api }) { const onResize = () => { const el = document.getElementById(`terminal-${tabId}`) - if (el && el.offsetParent !== null) { + if (el && el.style.visibility !== 'hidden' && el.style.position !== 'absolute') { fitAddon.fit() } } @@ -438,23 +438,25 @@ export default function Shell({ api }) { const tryInit = (attempt) => { if (cancelled || attempt > 20) return const shellCol = document.querySelector('.shell-terminal-col') - if (!shellCol || shellCol.offsetParent === null) { + if (!shellCol) { pending.push(setTimeout(() => tryInit(attempt + 1), 150)) return } const container = document.getElementById(`terminal-${tab.id}`) - if (!container || container.offsetHeight === 0) { + if (!container) { pending.push(setTimeout(() => tryInit(attempt + 1), 100)) return } if (!tabsRef.current[tab.id]) { initTerminal(tab.id, tab) } - requestAnimationFrame(() => { - if (cancelled) return - const entry = tabsRef.current[tab.id] - if (entry) entry.fitAddon.fit() - }) + if (activeTab === tab.id) { + requestAnimationFrame(() => { + if (cancelled) return + const entry = tabsRef.current[tab.id] + if (entry) entry.fitAddon.fit() + }) + } } tryInit(0) @@ -470,7 +472,7 @@ export default function Shell({ api }) { const entry = tabsRef.current[tab.id] if (entry) { const el = document.getElementById(`terminal-${tab.id}`) - if (el && el.offsetParent !== null) { + if (el && el.style.visibility !== 'hidden') { entry.fitAddon.fit() } } @@ -839,7 +841,10 @@ export default function Shell({ api }) { key={tab.id} id={`terminal-${tab.id}`} className="shell-xterm-instance" - style={{ display: activeTab === tab.id ? 'block' : 'none' }} + style={activeTab === tab.id + ? { visibility: 'visible', pointerEvents: 'auto' } + : { visibility: 'hidden', pointerEvents: 'none' } + } /> ))}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index a9eb05f..9c69072 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -691,34 +691,38 @@ input::placeholder { color: var(--text-disabled); } } /* 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 { - display: flex; align-items: center; gap: 6px; - padding: 3px 0; overflow: hidden; -} -.dash-cmd-shell { - font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled); - background: var(--bg-input); padding: 1px 4px; border-radius: 3px; - text-transform: uppercase; flex-shrink: 0; + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 5px 8px; border-radius: var(--radius-sm); + background: var(--bg-surface); cursor: pointer; + transition: background 0.12s; } +.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 { - 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; - 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-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-copied { border-color: var(--accent) !important; background: var(--accent-bg) !important; } -.dash-cmd-chip-copied .dash-cmd-chip-name { color: var(--accent); } -.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 */ .dash-services { display: flex; flex-direction: column; gap: 6px; } From 199a7e409a4de06562712e7a0c5f5f4ec4d10152 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 17:01:08 +0200 Subject: [PATCH 14/44] feat(ui): add recentUnique to deduplicate recent commands in Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/Dashboard.jsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index b6a7d4f..cea51de 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -109,6 +109,15 @@ export default function Dashboard({ api, refreshRef }) { .map(([cmd, count]) => ({ cmd, count })) })() + const recentUnique = (() => { + const seen = new Set() + return recentCmds.filter(c => { + if (seen.has(c.cmd)) return false + seen.add(c.cmd) + return true + }) + })() + return (
{/* CPU */} From 401292ec5b45020fa19ae26cd03ffd9a52db2191 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 17:04:38 +0200 Subject: [PATCH 15/44] feat(ui): refactor copy state to Set and add helper functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/components/Dashboard.jsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index cea51de..9c567c0 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -43,7 +43,7 @@ export default function Dashboard({ api, refreshRef }) { const [recentCmds, setRecentCmds] = useState([]) const [processes, setProcesses] = useState([]) const [metrics, setMetrics] = useState(null) - const [copiedIdx, setCopiedIdx] = useState(-1) + const [copiedSet, setCopiedSet] = useState(new Set()) const cpuRef = useRef([]) const memRef = useRef([]) const netRxRef = useRef([]) @@ -109,6 +109,23 @@ export default function Dashboard({ api, refreshRef }) { .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 => { From 47fa2e01bbeeeff425cd8fb3969cee33c58554a0 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 17:23:54 +0200 Subject: [PATCH 16/44] fix(terminal): use display:none instead of visibility for tab hiding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/Shell.jsx | 25 ++++++++++--------------- web/src/styles/global.css | 1 - 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 64235ca..d03f1d9 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -400,7 +400,7 @@ export default function Shell({ api }) { const onResize = () => { const el = document.getElementById(`terminal-${tabId}`) - if (el && el.style.visibility !== 'hidden' && el.style.position !== 'absolute') { + if (el && el.style.display !== 'none') { fitAddon.fit() } } @@ -438,25 +438,23 @@ export default function Shell({ api }) { const tryInit = (attempt) => { if (cancelled || attempt > 20) return const shellCol = document.querySelector('.shell-terminal-col') - if (!shellCol) { + if (!shellCol || shellCol.offsetParent === null) { pending.push(setTimeout(() => tryInit(attempt + 1), 150)) return } const container = document.getElementById(`terminal-${tab.id}`) - if (!container) { + if (!container || container.offsetHeight === 0) { pending.push(setTimeout(() => tryInit(attempt + 1), 100)) return } if (!tabsRef.current[tab.id]) { initTerminal(tab.id, tab) } - if (activeTab === tab.id) { - requestAnimationFrame(() => { - if (cancelled) return - const entry = tabsRef.current[tab.id] - if (entry) entry.fitAddon.fit() - }) - } + requestAnimationFrame(() => { + if (cancelled) return + const entry = tabsRef.current[tab.id] + if (entry) entry.fitAddon.fit() + }) } tryInit(0) @@ -472,7 +470,7 @@ export default function Shell({ api }) { const entry = tabsRef.current[tab.id] if (entry) { const el = document.getElementById(`terminal-${tab.id}`) - if (el && el.style.visibility !== 'hidden') { + if (el && el.style.display !== 'none') { entry.fitAddon.fit() } } @@ -841,10 +839,7 @@ export default function Shell({ api }) { key={tab.id} id={`terminal-${tab.id}`} className="shell-xterm-instance" - style={activeTab === tab.id - ? { visibility: 'visible', pointerEvents: 'auto' } - : { visibility: 'hidden', pointerEvents: 'none' } - } + style={{ display: activeTab === tab.id ? 'block' : 'none' }} /> ))}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 9c69072..5436052 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -385,7 +385,6 @@ input::placeholder { color: var(--text-disabled); } .shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; } .shell-xterm-instance { position: absolute; inset: 0; padding: 4px; - display: block !important; } .shell-xterm-instance .xterm { height: 100%; padding: 4px; } From 3a09e0e0c21648145a1d99ef517cce9c20bfaf42 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 17:38:21 +0200 Subject: [PATCH 17/44] fix(ui): adjust global CSS styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/styles/global.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 5436052..fc8d13d 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -384,7 +384,8 @@ input::placeholder { color: var(--text-disabled); } .shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; } .shell-xterm-instance { - position: absolute; inset: 0; padding: 4px; + height: 100%; + padding: 4px; } .shell-xterm-instance .xterm { height: 100%; padding: 4px; } From 3cf701b0025b3535e100716b907cc2adb48c2564 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 17:59:48 +0200 Subject: [PATCH 18/44] fix(terminal): improve tab visibility checks and positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add null check for container before accessing offsetHeight - Validate activeTabRef during initialization and fit operations - Check for display:none as visibility indicator - Simplify useEffect dependency array - Use absolute positioning for terminal wrapper/instance 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/Shell.jsx | 10 ++++++++-- web/src/styles/global.css | 8 ++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index d03f1d9..ca8ee6a 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -443,7 +443,12 @@ export default function Shell({ api }) { return } const container = document.getElementById(`terminal-${tab.id}`) - if (!container || container.offsetHeight === 0) { + if (!container) { + pending.push(setTimeout(() => tryInit(attempt + 1), 100)) + return + } + if (activeTabRef.current !== tab.id) return + if (container.offsetHeight === 0 || container.style.display === 'none') { pending.push(setTimeout(() => tryInit(attempt + 1), 100)) return } @@ -452,6 +457,7 @@ export default function Shell({ api }) { } requestAnimationFrame(() => { if (cancelled) return + if (activeTabRef.current !== tab.id) return const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() }) @@ -462,7 +468,7 @@ export default function Shell({ api }) { cancelled = true pending.forEach(clearTimeout) } - }, [activeTab, tabs, initTerminal]) + }, [activeTab, initTerminal]) useEffect(() => { const iv = setInterval(() => { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index fc8d13d..94e7be2 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -382,12 +382,12 @@ input::placeholder { color: var(--text-disabled); } } .shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } -.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; } +.shell-xterm-wrapper { flex: 1; height: 100%; background: var(--bg); overflow: hidden; position: relative; } .shell-xterm-instance { - height: 100%; - padding: 4px; + position: absolute; + inset: 0; } -.shell-xterm-instance .xterm { height: 100%; padding: 4px; } +.shell-xterm-instance .xterm { height: 100%; } .connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; } .connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); } From 13e937a11bf86bb2a974eb70e8fc449a34c7df9e Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 20:13:21 +0200 Subject: [PATCH 19/44] fix(terminal): init all tabs on load, fix excessive zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use visibility:hidden instead of display:none for inactive terminal tabs so xterm containers retain their dimensions. This allows all terminals to initialize independently and prevents fitAddon from miscalculating cell sizes on zero-height containers. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 56 +++++++++++++++++++----------------- web/src/styles/global.css | 6 ++++ 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index ca8ee6a..efa9521 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -399,10 +399,7 @@ export default function Shell({ api }) { }) const onResize = () => { - const el = document.getElementById(`terminal-${tabId}`) - if (el && el.style.display !== 'none') { - fitAddon.fit() - } + fitAddon.fit() } const resizeObserver = new ResizeObserver(onResize) @@ -429,27 +426,23 @@ export default function Shell({ api }) { }, []) useEffect(() => { - const tab = tabs.find(t => t.id === activeTab) - if (!tab) return - let cancelled = false const pending = [] - const tryInit = (attempt) => { - if (cancelled || attempt > 20) return + const tryInitTab = (tab, attempt) => { + if (cancelled || attempt > 30) return const shellCol = document.querySelector('.shell-terminal-col') if (!shellCol || shellCol.offsetParent === null) { - pending.push(setTimeout(() => tryInit(attempt + 1), 150)) + pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150)) return } const container = document.getElementById(`terminal-${tab.id}`) if (!container) { - pending.push(setTimeout(() => tryInit(attempt + 1), 100)) + pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100)) return } - if (activeTabRef.current !== tab.id) return - if (container.offsetHeight === 0 || container.style.display === 'none') { - pending.push(setTimeout(() => tryInit(attempt + 1), 100)) + if (container.offsetHeight === 0) { + pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100)) return } if (!tabsRef.current[tab.id]) { @@ -457,29 +450,39 @@ export default function Shell({ api }) { } requestAnimationFrame(() => { if (cancelled) return - if (activeTabRef.current !== tab.id) return const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() }) } - tryInit(0) + for (const tab of tabs) { + if (!tabsRef.current[tab.id]) { + tryInitTab(tab, 0) + } + } + return () => { cancelled = true pending.forEach(clearTimeout) } - }, [activeTab, initTerminal]) + }, [tabs, initTerminal]) + + useEffect(() => { + const entry = tabsRef.current[activeTab] + if (entry) { + requestAnimationFrame(() => { + if (activeTabRef.current === activeTab) { + entry.fitAddon.fit() + } + }) + } + }, [activeTab]) 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() - } - } + const entry = tabsRef.current[activeTabRef.current] + if (entry) { + entry.fitAddon.fit() } }, 2000) return () => clearInterval(iv) @@ -844,8 +847,7 @@ export default function Shell({ api }) {
))}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 94e7be2..2ccf368 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -386,6 +386,12 @@ input::placeholder { color: var(--text-disabled); } .shell-xterm-instance { position: absolute; inset: 0; + visibility: hidden; + pointer-events: none; +} +.shell-xterm-instance.active { + visibility: visible; + pointer-events: auto; } .shell-xterm-instance .xterm { height: 100%; } From 08dc1fd53b4d1379174d6a1fc07e576e644a93cf Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 20:28:02 +0200 Subject: [PATCH 20/44] fix(terminal): detect shell tab visibility via MutationObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell is always mounted inside a display:none parent when the app loads on a different tab. Added MutationObserver on the wrapper to detect when the shell tab becomes visible and initialize/fit all pending terminals at that moment. Removed attempt limit so retries continue until the tab is actually shown. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 41 +++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index efa9521..2ae3c00 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -425,15 +425,36 @@ export default function Shell({ api }) { } }, []) + const initPendingTabs = useCallback(() => { + for (const tab of tabsRef.current._tabList || []) { + if (!tabsRef.current[tab.id]) { + const container = document.getElementById(`terminal-${tab.id}`) + if (container && container.offsetHeight > 0) { + initTerminal(tab.id, tab) + } + } + } + requestAnimationFrame(() => { + for (const tab of tabsRef.current._tabList || []) { + const entry = tabsRef.current[tab.id] + if (entry) entry.fitAddon.fit() + } + }) + }, [initTerminal]) + + useEffect(() => { + tabsRef.current._tabList = tabs + }, [tabs]) + useEffect(() => { let cancelled = false const pending = [] const tryInitTab = (tab, attempt) => { - if (cancelled || attempt > 30) return + if (cancelled) return const shellCol = document.querySelector('.shell-terminal-col') if (!shellCol || shellCol.offsetParent === null) { - pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150)) + pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 200)) return } const container = document.getElementById(`terminal-${tab.id}`) @@ -461,11 +482,23 @@ export default function Shell({ api }) { } } + const wrapper = document.querySelector('.shell-layout')?.parentElement + let observer + if (wrapper) { + observer = new MutationObserver(() => { + if (!wrapper.classList.contains('tab-hidden') && wrapper.offsetParent !== null) { + initPendingTabs() + } + }) + observer.observe(wrapper, { attributes: true, attributeFilter: ['class'] }) + } + return () => { cancelled = true pending.forEach(clearTimeout) + observer?.disconnect() } - }, [tabs, initTerminal]) + }, [tabs, initTerminal, initPendingTabs]) useEffect(() => { const entry = tabsRef.current[activeTab] @@ -480,6 +513,8 @@ export default function Shell({ api }) { useEffect(() => { const iv = setInterval(() => { + const wrapper = document.querySelector('.shell-layout')?.parentElement + if (wrapper && wrapper.classList.contains('tab-hidden')) return const entry = tabsRef.current[activeTabRef.current] if (entry) { entry.fitAddon.fit() From bf8c0fd380333f0e3521ece0b339a04505295d1b Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 20:35:49 +0200 Subject: [PATCH 21/44] fix(terminal): improve terminal dimensions and fit timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use min-height:0 on xterm-wrapper (flex child) instead of height:100% to properly fill available space in flex layout. Add delayed fit() calls after initialization to let the layout stabilize before calculating terminal cell dimensions. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 14 ++++++++++---- web/src/styles/global.css | 8 ++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 2ae3c00..c5ef38f 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -439,6 +439,12 @@ export default function Shell({ api }) { const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() } + setTimeout(() => { + for (const tab of tabsRef.current._tabList || []) { + const entry = tabsRef.current[tab.id] + if (entry) entry.fitAddon.fit() + } + }, 150) }) }, [initTerminal]) @@ -472,11 +478,11 @@ export default function Shell({ api }) { requestAnimationFrame(() => { if (cancelled) return const entry = tabsRef.current[tab.id] - if (entry) entry.fitAddon.fit() + if (entry) { + entry.fitAddon.fit() + setTimeout(() => { if (!cancelled) entry.fitAddon.fit() }, 100) + } }) - } - - for (const tab of tabs) { if (!tabsRef.current[tab.id]) { tryInitTab(tab, 0) } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 2ccf368..7308dc1 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -276,8 +276,8 @@ input::placeholder { color: var(--text-disabled); } .sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); } .sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; } -.shell-layout { display: flex; height: 100%; } -.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } +.shell-layout { display: flex; height: 100%; overflow: hidden; } +.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; } .shell-tabs-bar { display: flex; align-items: center; background: var(--bg-surface); @@ -382,7 +382,7 @@ input::placeholder { color: var(--text-disabled); } } .shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } -.shell-xterm-wrapper { flex: 1; height: 100%; background: var(--bg); overflow: hidden; position: relative; } +.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; } .shell-xterm-instance { position: absolute; inset: 0; @@ -402,7 +402,7 @@ input::placeholder { color: var(--text-disabled); } .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; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; } .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 { display: flex; align-items: center; gap: 4px; From 7cc206dc20c79ee9a52e9396750f89ae556ca613 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:07:36 +0200 Subject: [PATCH 22/44] fix(shell): prevent Enter in AI chat from leaking to terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop propagation of Enter keydown in AI input and defer terminal focus to next event loop tick to prevent xterm from capturing the same key event. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index c5ef38f..b14a698 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -693,7 +693,7 @@ export default function Shell({ api }) { if (!fromEvent) { setAiInput('') - focusAiTerminal() + setTimeout(() => focusAiTerminal(), 0) } if (trimmed === '/clear') { @@ -937,7 +937,7 @@ export default function Shell({ api }) { setAiInput(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAiSend()} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend() } }} placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')} disabled={aiAtLimit && aiInput !== '/clear'} /> From b85ebb8e54186a7401fef28fc4d999c1772bff9d Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:10:24 +0200 Subject: [PATCH 23/44] feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xterm captures all keyboard input which prevents standard clipboard operations. Add custom key handler to intercept Ctrl+Shift+C for copy (selection) and Ctrl+Shift+V for paste, without interfering with Ctrl+C (SIGINT) or browser devtools shortcut. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/App.jsx | 2 ++ web/src/components/Shell.jsx | 26 ++++++++++++++++++++++++++ web/src/i18n/en.js | 2 ++ web/src/i18n/fr.js | 2 ++ 4 files changed, 32 insertions(+) diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 1a681c6..5232c0d 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -92,6 +92,8 @@ export default function App() { { keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') }, ], shell: [ + { keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') }, + { keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') }, { keys: layout.keys.enter, desc: t('statusbar.runCommand') }, { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, ], diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index b14a698..e3b113d 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -143,6 +143,32 @@ function createTerminal(container, settings = {}) { const webLinksAddon = new WebLinksAddon() term.loadAddon(fitAddon) term.loadAddon(webLinksAddon) + + term.attachCustomKeyEventHandler((e) => { + if (e.type !== 'keydown') return true + const ctrl = e.ctrlKey || e.metaKey + const shift = e.shiftKey + + if (ctrl && shift && e.key === 'C') { + e.preventDefault() + e.stopPropagation() + const selection = term.getSelection() + if (selection) navigator.clipboard.writeText(selection) + return false + } + + if (ctrl && shift && e.key === 'V') { + e.preventDefault() + e.stopPropagation() + navigator.clipboard.readText().then(text => { + if (text) term.paste(text) + }).catch(() => {}) + return false + } + + return true + }) + term.open(container) fitAddon.fit() diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 4303d7d..0428a47 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -16,6 +16,8 @@ const en = { switchWindow: 'Switch window', sendMessage: 'Send message', newLine: 'New line', + copy: 'Copy', + paste: 'Paste', runCommand: 'Run command', commandHistory: 'Command history', }, diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index fc580e3..8d38c09 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -16,6 +16,8 @@ const fr = { switchWindow: 'Changer de fen\u00eatre', sendMessage: 'Envoyer le message', newLine: 'Nouvelle ligne', + copy: 'Copier', + paste: 'Coller', runCommand: 'Ex\u00e9cuter', commandHistory: 'Historique', }, From cbf623b98bbc7d8f6c8576c506de92e9622228b9 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:13:20 +0200 Subject: [PATCH 24/44] fix(terminal): use absolute positioning for content panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit height:100% on .content>div fails because .content uses flex:1 without explicit height. Switch to position:absolute;inset:0 which correctly fills the content area and gives xterm proper container dimensions for fitAddon. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/styles/global.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 7308dc1..63d34f6 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -155,7 +155,7 @@ input::placeholder { color: var(--text-disabled); } .header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; } .content { flex: 1; overflow: hidden; position: relative; } -.content > div { height: 100%; } +.content > div { position: absolute; inset: 0; overflow: hidden; } .tab-hidden { display: none; } .statusbar { From 7d0f807fb07ebc7ce58e442d9d9cbb39467af008 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:22:34 +0200 Subject: [PATCH 25/44] feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MiMo-V2.5-Pro from Xiaomi Token Plan as a new AI provider with base URL https://token-plan-ams.xiaomimimo.com/v1. The /model change command now switches between MiniMax and MiMo only. ZAI is always placed last in the fallback chain as the provider of ultimate resort. Config panel shows MiniMax and MiMo cards. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- internal/api/handlers_info.go | 5 +++++ internal/config/config.go | 6 ++++++ internal/orchestrator/orchestrator.go | 12 +++++++++++- web/src/components/Config.jsx | 2 +- web/src/components/Studio.jsx | 10 +++++----- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index e6ced66..921481a 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -530,6 +530,11 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) { } } } + case "mimo": + q.Healthy = p.APIKey != "" + if p.APIKey == "" { + q.Error = "no API key" + } case "claude", "anthropic": // Claude Code n'a pas d'API externe, vĂ©rifier l'installation claudePath := "/usr/bin/claude" diff --git a/internal/config/config.go b/internal/config/config.go index cb26ec0..ab693b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -269,6 +269,12 @@ func Default() *MuyueConfig { BaseURL: "https://api.minimax.io/v1", Active: true, }, + { + Name: "mimo", + Model: "MiMo-V2.5-Pro", + BaseURL: "https://token-plan-ams.xiaomimimo.com/v1", + Active: false, + }, { Name: "zai", Model: "glm", diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 7c70887..235c41e 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -476,6 +476,8 @@ func getProviderBaseURL(name string) string { return "https://api.openai.com/v1" case "zai": return "https://api.z.ai/v1" + case "mimo": + return "https://token-plan-ams.xiaomimimo.com/v1" default: return "" } @@ -503,11 +505,19 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str if o.provider != nil { providerOrder = append(providerOrder, o.provider) } + var zaiProvider *config.AIProvider for _, p := range providers { if o.provider == nil || p.Name != o.provider.Name { - providerOrder = append(providerOrder, p) + if p.Name == "zai" { + zaiProvider = p + } else { + providerOrder = append(providerOrder, p) + } } } + if zaiProvider != nil { + providerOrder = append(providerOrder, zaiProvider) + } var lastErr error var triedProviders []string diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index ac38210..1534c7b 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -343,7 +343,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm setValidating(null) } - const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai') + const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'mimo') return (
diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index f49a53b..ca85ee0 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -452,15 +452,15 @@ export default function Studio({ api }) { api.getProviders().then(data => { const providers = data.providers || [] const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX') - const zai = providers.find(p => p.name.toUpperCase() === 'ZAI') - if (!minimax || !zai) { - setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent ĂȘtre configurĂ©s pour utiliser `/model change`.', time: new Date().toISOString() }]) + const mimo = providers.find(p => p.name.toUpperCase() === 'MIMO') + if (!minimax || !mimo) { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et MiMo doivent ĂȘtre configurĂ©s pour utiliser `/model change`.', time: new Date().toISOString() }]) return } const active = providers.find(p => p.active) const activeName = active ? active.name.toUpperCase() : '' - const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX' - const target = switchTo === 'MINIMAX' ? minimax : zai + const switchTo = activeName === 'MINIMAX' ? 'MIMO' : 'MINIMAX' + const target = switchTo === 'MINIMAX' ? minimax : mimo api.saveProvider({ name: target.name, active: true }).then(() => { setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changĂ©: **${target.name}** (${target.model})`, time: new Date().toISOString() }]) }).catch(() => { From d27872572aeaddf8094f06a071137e0a13cbbc40 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:28:22 +0200 Subject: [PATCH 26/44] fix(dashboard): show MiMo quota instead of ZAI on dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Z.AI quota display with MiMo provider in the API Quota card. ZAI is now a hidden fallback and should not appear in the dashboard. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Dashboard.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 9c567c0..44111b5 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -91,7 +91,7 @@ export default function Dashboard({ api, refreshRef }) { }, [loadData, refreshRef]) const minimax = (quota || []).find(p => p.name === 'minimax') - const zai = (quota || []).find(p => p.name === 'zai') + const mimo = (quota || []).find(p => p.name === 'mimo') const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help'] @@ -186,22 +186,22 @@ export default function Dashboard({ api, refreshRef }) { {minimax.error || 'no data'}
)} - {zai && zai.data?.models?.map((m, i) => ( + {mimo && mimo.data?.models?.map((m, i) => (
- {String(m.model)} + {String(m.model).replace('MiMo-', '')}
{m.used}/{m.total}
))} - {zai && !zai.data?.models?.length && ( + {mimo && !mimo.data?.models?.length && (
- Z.AI - {zai.error || 'no data'} + MiMo + {mimo.error || (mimo.healthy ? '✓ configured' : 'no key')}
)} - {!minimax && !zai && No providers} + {!minimax && !mimo && No providers}
From 5627ddd2ce4732e0e32ffdf1ebf03cca595cad68 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:30:07 +0200 Subject: [PATCH 27/44] fix(terminal): improve dimension calculation and tab init reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guarantee minimum 24x80 dimensions on WebSocket open - Force reflow before init attempts - Multiple fit attempts with increasing delays (0/50/100/200/400ms) - Validate saved tabs structure from localStorage - Resize active tab after closing another tab 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 113 ++++++++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 27 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index e3b113d..4d8b8a0 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -182,9 +182,20 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes ws.addEventListener('open', () => { ws.send(JSON.stringify(initPayload)) const dims = fitAddon.proposeDimensions() - if (dims) { - ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols })) - } + // Envoyer resize avec dimensions minimales garanties (24x80) + const rows = dims?.rows || 24 + const cols = dims?.cols || 80 + ws.send(JSON.stringify({ type: 'resize', rows, cols })) + // Forcer un fit aprĂšs l'ouverture + setTimeout(() => { + try { + fitAddon.fit() + const newDims = fitAddon.proposeDimensions() + if (newDims && newDims.rows > 0 && newDims.cols > 0) { + ws.send(JSON.stringify({ type: 'resize', rows: newDims.rows, cols: newDims.cols })) + } + } catch (e) { console.warn('[Shell] fit failed:', e) } + }, 50) if (onStateChange) onStateChange(true) }) @@ -232,22 +243,33 @@ export default function Shell({ api }) { const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' }) const pendingCommandsRef = useRef({}) - const savedTabs = (() => { + const [tabs, setTabs] = useState(() => { try { const raw = localStorage.getItem(TABS_STORAGE_KEY) if (raw) { const parsed = JSON.parse(raw) - if (Array.isArray(parsed) && parsed.length > 0) { - return parsed.map(t => ({ ...t, connected: false })) + if (Array.isArray(parsed) && parsed.length > 0 && parsed.length <= MAX_TABS) { + return parsed.map((t, i) => ({ + id: t.id || i + 1, + name: t.name || `Tab ${i + 1}`, + type: t.type || 'local', + shell: t.shell || '', + host: t.host, + port: t.port, + user: t.user, + key_path: t.key_path, + connected: false + })) } } - } catch {} - return null - })() - - const [tabs, setTabs] = useState(savedTabs || [ - { id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false }, - ]) + } catch (e) { + console.warn('[Shell] Failed to parse saved tabs:', e) + localStorage.removeItem(TABS_STORAGE_KEY) + } + return [ + { id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false }, + ] + }) const [activeTab, setActiveTab] = useState(() => { if (savedTabs) { return savedTabs[0]?.id || 1 @@ -482,36 +504,60 @@ export default function Shell({ api }) { let cancelled = false const pending = [] + // Forcer le layout Ă  se calculer + const forceLayout = () => { + const el = document.querySelector('.shell-terminal-col') + if (el) { + el.style.height = '' + el.style.minHeight = '' + // Forcer reflow + void el.offsetHeight + } + } + const tryInitTab = (tab, attempt) => { if (cancelled) return - const shellCol = document.querySelector('.shell-terminal-col') - if (!shellCol || shellCol.offsetParent === null) { - pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 200)) + if (attempt > 20) { + console.warn(`[Shell] max attempts reached for tab ${tab.id}`) return } + + forceLayout() + const shellCol = document.querySelector('.shell-terminal-col') + if (!shellCol) { + pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150)) + return + } + const container = document.getElementById(`terminal-${tab.id}`) if (!container) { pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100)) return } - if (container.offsetHeight === 0) { + + const rect = container.getBoundingClientRect() + if (rect.height < 10 || rect.width < 10) { pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100)) return } + if (!tabsRef.current[tab.id]) { initTerminal(tab.id, tab) } - requestAnimationFrame(() => { - if (cancelled) return - const entry = tabsRef.current[tab.id] - if (entry) { - entry.fitAddon.fit() - setTimeout(() => { if (!cancelled) entry.fitAddon.fit() }, 100) - } + + // Multiple fit attempts avec dĂ©lais croissants + const fitAttempts = [0, 50, 100, 200, 400] + fitAttempts.forEach(delay => { + setTimeout(() => { + if (cancelled) return + const entry = tabsRef.current[tab.id] + if (entry && entry.fitAddon) { + try { + entry.fitAddon.fit() + } catch (e) { console.warn(`[Shell] fit attempt ${delay}ms failed:`, e) } + } + }, delay) }) - if (!tabsRef.current[tab.id]) { - tryInitTab(tab, 0) - } } const wrapper = document.querySelector('.shell-layout')?.parentElement @@ -650,6 +696,19 @@ export default function Shell({ api }) { } return next }) + + // Redimensionner le nouveau tab actif + setTimeout(() => { + const newActiveTabId = next.length > 0 ? next[next.length - 1].id : null + if (newActiveTabId) { + const entry = tabsRef.current[newActiveTabId] + if (entry && entry.fitAddon) { + try { + entry.fitAddon.fit() + } catch (e) { console.warn('[Shell] fit after close failed:', e) } + } + } + }, 100) } const startRename = (tabId, e) => { From b8aa935bec9a356c4877f98d14a1b61d8a7aa8b1 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:36:25 +0200 Subject: [PATCH 28/44] fix(shell): resolve savedTabs undefined ReferenceError in activeTab init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 4d8b8a0..6897709 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -271,9 +271,13 @@ export default function Shell({ api }) { ] }) const [activeTab, setActiveTab] = useState(() => { - if (savedTabs) { - return savedTabs[0]?.id || 1 - } + try { + const raw = localStorage.getItem(TABS_STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed) && parsed.length > 0) return parsed[0]?.id || 1 + } + } catch {} return 1 }) const activeTabRef = useRef(activeTab) From 50ca75180c5f1b6b4a9a22a791dcce2f66ef2f20 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:43:10 +0200 Subject: [PATCH 29/44] fix(terminal): improve dimensions handling and add system theme for xterm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/Shell.jsx | 133 +++++++++++++++++------------------ web/src/styles/global.css | 73 +++++++++++++++++++ 2 files changed, 139 insertions(+), 67 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 6897709..637b7d4 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,73 +1,69 @@ import { useState, useRef, useEffect, useCallback } from 'react' -import { Terminal as XTerm } from '@xterm/xterm' -import { FitAddon } from '@xterm/addon-fit' -import { WebLinksAddon } from '@xterm/addon-web-links' -import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye } from 'lucide-react' -import '@xterm/xterm/css/xterm.css' -import { useI18n } from '../i18n' - -const MAX_TABS = 7 -const SHELL_MAX_TOKENS = 100000 -const TABS_STORAGE_KEY = 'muyue_shell_tabs' -const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers' - -function renderContent(text) { - const parts = [] - 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 +impo +// === Style thĂšme systĂšme pour xterm === +function getCSSVariable(varName) { + if (typeof document === 'undefined') return null; + return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || null; } -function formatText(text) { - let html = text - .replace(/&/g, '&').replace(//g, '>') +function parseHexColor(hex) { + if (!hex || hex.startsWith('var(')) return null; + hex = hex.replace('#', ''); + if (hex.length === 3) hex = hex.split('').map(c => c + c).join(''); + if (hex.length !== 6) return null; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return { r, g, b }; +} - html = html - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/^### (.+)$/gm, '

$1

') - .replace(/^## (.+)$/gm, '

$1

') - .replace(/^# (.+)$/gm, '

$1

') - .replace(/^\s*[-*] (.+)$/gm, '
‱ $1
') - .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') - .replace(/\n/g, '
') +function toRgbString(hex) { + const c = parseHexColor(hex); + if (!c) return '#000000'; + return `#${c.r.toString(16).padStart(2, '0')}${c.g.toString(16).padStart(2, '0')}${c.b.toString(16).padStart(2, '0')}`; +} - html = html - .replace(/\s*/g, '
') - .replace(/\s*( { @@ -291,7 +290,7 @@ export default function Shell({ api }) { const [terminalSettings, setTerminalSettings] = useState({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", - theme: 'default', + theme: 'system', }) useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings]) @@ -356,7 +355,7 @@ export default function Shell({ api }) { setTerminalSettings({ fontSize: d.terminal.font_size || 12, fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", - theme: d.terminal.theme || 'default', + theme: d.terminal.theme || 'system', }) } }).catch(() => {}) diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 63d34f6..1443ab8 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -1058,3 +1058,76 @@ input::placeholder { color: var(--text-disabled); } word-break: break-word; background: var(--bg); } + +/* === XTerm Custom Styling === */ +/* Styles for xterm.js integrated with Muyue theme */ +.shell-xterm-instance .xterm { + padding: 4px 8px; +} + +.shell-xterm-instance .xterm-viewport { + background-color: var(--bg-base) !important; +} + +.shell-xterm-instance .xterm-screen { + background-color: var(--bg-base); +} + +/* Scrollbar styling for xterm */ +.shell-xterm-instance .xterm-viewport::-webkit-scrollbar { + width: 8px; +} + +.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track { + background: var(--bg-surface); +} + +.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb { + background: var(--accent-dim); + border-radius: 4px; +} + +.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: var(--accent-dark); +} + +/* Selection styling */ +.shell-xterm-instance .xterm-selection { + background: var(--accent-dim) !important; +} + +/* Focus ring styling */ +.shell-xterm-instance .xterm:focus .xterm-helper-text-container { + box-shadow: none; +} + +/* Ensure consistent font rendering */ +.shell-xterm-instance .xterm .xterm-char-measure-element { + font-family: var(--font-mono) !important; +} + +/* Bell animation styling */ +.shell-xterm-instance .xterm-bell { + animation: xterm-bell-flash 0.3s ease-out; +} + +@keyframes xterm-bell-flash { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 0; } +} + +/* Cursor styling */ +.shell-xterm-instance .xterm-cursor { + outline: none !important; +} + +/* Link styling for web links addon */ +.shell-xterm-instance .xterm-link { + color: var(--accent-light) !important; + text-decoration: underline; +} + +.shell-xterm-instance .xterm-link:hover { + color: var(--accent-muted) !important; +} From c1b1fc653f37ae5584685910e1b13e2892dd0c2d Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:50:12 +0200 Subject: [PATCH 30/44] fix(shell): remove stray 'impo' typo causing ReferenceError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 637b7d4..3c5fff0 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,5 +1,4 @@ import { useState, useRef, useEffect, useCallback } from 'react' -impo // === Style thùme systùme pour xterm === function getCSSVariable(varName) { if (typeof document === 'undefined') return null; From 034b9ee0e4fce0c86893f0281e8e76b2d2509a36 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:51:54 +0200 Subject: [PATCH 31/44] fix(shell): add missing useI18n import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 3c5fff0..a68d047 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react' +import { useI18n } from '../i18n' // === Style thùme systùme pour xterm === function getCSSVariable(varName) { if (typeof document === 'undefined') return null; From 9a1ff6e8dc82c14e194c8083382d3e6d87f1ad36 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:53:38 +0200 Subject: [PATCH 32/44] fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index a68d047..bafecdb 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -236,6 +236,9 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes } export default function Shell({ api }) { + const MAX_TABS = 7 + const TABS_STORAGE_KEY = 'muyue_shell_tabs' + const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers' const { t } = useI18n() const tabsRef = useRef({}) const nextIdRef = useRef(1) From 98ff0dd57845fa64b223483bb93ed035b7e1e9a7 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 21:56:32 +0200 Subject: [PATCH 33/44] fix(shell): add missing Monitor import from lucide-react MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index bafecdb..8318745 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' +import { Monitor } from 'lucide-react' // === Style thùme systùme pour xterm === function getCSSVariable(varName) { if (typeof document === 'undefined') return null; From de52f4ebd6df8743f278cf7ff31bb9af0a7e9c01 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 22:02:36 +0200 Subject: [PATCH 34/44] fix(shell): restore all missing imports, constants, and utility functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore xterm imports (Terminal, FitAddon, WebLinksAddon) - Restore all lucide-react icons (Globe, X, Plus, ChevronDown, etc.) - Restore module-level constants (AI_TAB_ID, MAX_TABS, SHELL_MAX_TOKENS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY) - Restore renderContent() and formatText() utility functions - Add @xterm/xterm CSS import - Remove duplicate constants from inside Shell component 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 74 +++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 8318745..9e66382 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,6 +1,73 @@ -import { useState, useRef, useEffect, useCallback } from 'react' +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { Terminal as XTerm } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react' +import '@xterm/xterm/css/xterm.css' import { useI18n } from '../i18n' -import { Monitor } from 'lucide-react' + +const AI_TAB_ID = 0 +const MAX_TABS = 7 +const SHELL_MAX_TOKENS = 100000 +const TABS_STORAGE_KEY = 'muyue_shell_tabs' +const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers' + +function renderContent(text) { + const parts = [] + 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, '&').replace(//g, '>') + + html = html + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/^\s*[-*] (.+)$/gm, '
‱ $1
') + .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') + .replace(/\n/g, '
') + + html = html + .replace(/\s*/g, '
') + .replace(/\s*( Date: Fri, 24 Apr 2026 22:10:15 +0200 Subject: [PATCH 35/44] feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and inline image protocol. All existing functionality preserved. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/package-lock.json | 40 ++++++++++++++ web/package.json | 4 ++ web/src/components/Shell.jsx | 104 ++++++++++++++++++++++++++++++++++- web/src/styles/global.css | 30 ++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index c95235a..d1a3899 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,7 +7,11 @@ "name": "muyue-web", "dependencies": { "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-image": "^0.10.0-beta.203", + "@xterm/addon-search": "^0.17.0-beta.203", + "@xterm/addon-unicode11": "^0.10.0-beta.203", "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.20.0-beta.202", "@xterm/xterm": "^6.0.0", "lucide-react": "^1.8.0", "react": "^19.2.5", @@ -406,12 +410,48 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-image": { + "version": "0.10.0-beta.203", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.203.tgz", + "integrity": "sha512-1hRy7/jYCYvUhc6GYu177EdsW44QQQHsq71Odvo6cEhHKEEoqFsrOnLpe9WuNWZXgqpCwy2Cnp6FepHm960Eiw==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.203" + } + }, + "node_modules/@xterm/addon-search": { + "version": "0.17.0-beta.203", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.203.tgz", + "integrity": "sha512-agxzh30h4L82kjGlTwWEsaXnXzOuMIAm80+zcNElFL/hHuT/nLvcwRng+s7RzOWNNLG3pB4jbTHqbBaM+nW8mg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.203" + } + }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.10.0-beta.203", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.203.tgz", + "integrity": "sha512-KqMOqqpeEPQw5TQLb8jNHPESjZSwenFzhBPNA1g2zcPY5JtZ15pFzzoFxXdzS5LYmdYxexpd8s2ianf8WmQKyg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.203" + } + }, "node_modules/@xterm/addon-web-links": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", "license": "MIT" }, + "node_modules/@xterm/addon-webgl": { + "version": "0.20.0-beta.202", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.202.tgz", + "integrity": "sha512-GCh0QlUv77XX8cJt8/7AVdDUNFpa1f6MGX/skhciu5ZRK88hR1m8T+8MZ3FYfddLV6phY0ksmiO9ErC0R+7G/A==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.203" + } + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/web/package.json b/web/package.json index 814fd30..5c24f86 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,11 @@ }, "dependencies": { "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-image": "^0.10.0-beta.203", + "@xterm/addon-search": "^0.17.0-beta.203", + "@xterm/addon-unicode11": "^0.10.0-beta.203", "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.20.0-beta.202", "@xterm/xterm": "^6.0.0", "lucide-react": "^1.8.0", "react": "^19.2.5", diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 9e66382..0af85e7 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -2,6 +2,10 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' +import { WebglAddon } from '@xterm/addon-webgl' +import { SearchAddon } from '@xterm/addon-search' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { ImageAddon } from '@xterm/addon-image' import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react' import '@xterm/xterm/css/xterm.css' import { useI18n } from '../i18n' @@ -208,8 +212,25 @@ function createTerminal(container, settings = {}) { const fitAddon = new FitAddon() const webLinksAddon = new WebLinksAddon() + const searchAddon = new SearchAddon() + const unicode11Addon = new Unicode11Addon() + const imageAddon = new ImageAddon() + term.loadAddon(fitAddon) term.loadAddon(webLinksAddon) + term.loadAddon(searchAddon) + term.loadAddon(unicode11Addon) + term.loadAddon(imageAddon) + + term.unicode.activeVersion = '11' + + try { + const webglAddon = new WebglAddon() + webglAddon.onContextLoss(() => { webglAddon.dispose() }) + term.loadAddon(webglAddon) + } catch (e) { + console.warn('[Shell] WebGL renderer not available, using DOM fallback:', e) + } term.attachCustomKeyEventHandler((e) => { if (e.type !== 'keydown') return true @@ -239,7 +260,7 @@ function createTerminal(container, settings = {}) { term.open(container) fitAddon.fit() - return { term, fitAddon } + return { term, fitAddon, searchAddon } } function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) { @@ -361,6 +382,11 @@ export default function Shell({ api }) { theme: 'system', }) + const [showSearch, setShowSearch] = useState(false) + const [searchText, setSearchText] = useState('') + const searchInputRef = useRef(null) + const searchDecorationsRef = useRef(null) + useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings]) const [sshForm, setSshForm] = useState({ @@ -436,7 +462,7 @@ export default function Shell({ api }) { if (!container) return const s = settingsRef.current - const { term, fitAddon } = createTerminal(container, { + const { term, fitAddon, searchAddon } = createTerminal(container, { fontSize: s.fontSize, fontFamily: s.fontFamily, theme: s.theme, @@ -528,7 +554,7 @@ export default function Shell({ api }) { const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000) console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`) - tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed } + tabsRef.current[tabId] = { term, fitAddon, searchAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed } tabsRef.current[tabId]._markDisposed = () => { disposed = true } console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current)) @@ -688,6 +714,15 @@ export default function Shell({ api }) { useEffect(() => { const onKey = (e) => { + const ctrl = e.ctrlKey || e.metaKey + if (ctrl && e.shiftKey && e.key === 'F') { + const shellTab = document.querySelector('.shell-layout') + if (!shellTab || shellTab.closest('.tab-hidden')) return + e.preventDefault() + setShowSearch(prev => !prev) + return + } + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return @@ -711,6 +746,49 @@ export default function Shell({ api }) { return () => window.removeEventListener('keydown', onKey) }, [tabs]) + useEffect(() => { + if (showSearch && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [showSearch]) + + const handleSearchChange = useCallback((value) => { + setSearchText(value) + const entry = tabsRef.current[activeTabRef.current] + if (!entry?.searchAddon) return + if (!value) { + entry.searchAddon.clearDecorations() + entry.searchAddon.clearActiveDecoration() + return + } + try { + searchDecorationsRef.current = entry.searchAddon.findNext(value) + } catch {} + }, []) + + const handleSearchNext = useCallback(() => { + const entry = tabsRef.current[activeTabRef.current] + if (!entry?.searchAddon || !searchText) return + try { entry.searchAddon.findNext(searchText) } catch {} + }, [searchText]) + + const handleSearchPrev = useCallback(() => { + const entry = tabsRef.current[activeTabRef.current] + if (!entry?.searchAddon || !searchText) return + try { entry.searchAddon.findPrevious(searchText) } catch {} + }, [searchText]) + + const handleCloseSearch = useCallback(() => { + setShowSearch(false) + setSearchText('') + const entry = tabsRef.current[activeTabRef.current] + if (entry?.searchAddon) { + entry.searchAddon.clearDecorations() + entry.searchAddon.clearActiveDecoration() + } + if (entry?.term) entry.term.focus() + }, []) + const addLocalTab = (shell, name) => { if (tabs.length >= MAX_TABS) return const id = nextIdRef.current++ @@ -1040,6 +1118,26 @@ export default function Shell({ api }) {
+ {showSearch && ( +
+ + handleSearchChange(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() } + if (e.key === 'Escape') handleCloseSearch() + e.stopPropagation() + }} + placeholder="Rechercher..." + /> + + + +
+ )} {tabs.map(tab => (
Date: Fri, 24 Apr 2026 22:12:05 +0200 Subject: [PATCH 36/44] fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/.npmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/.npmrc diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true From 5bdc7a6429426175c162d447966ad6df1655fcf0 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 22:16:23 +0200 Subject: [PATCH 37/44] fix(shell): enable allowProposedApi for Unicode11 addon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/Shell.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 0af85e7..0d01481 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -203,6 +203,7 @@ function createTerminal(container, settings = {}) { const theme = getTheme(settings.theme || 'system') const term = new XTerm({ cursorBlink: true, + allowProposedApi: true, fontSize: settings.fontSize || 12, fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme, From 5a9edc076e43e6cd38b134d2bf36ccef78e5dd05 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 22:19:12 +0200 Subject: [PATCH 38/44] fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The addon-web-links registerApcHandler API requires xterm >= 6.1.0. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/package-lock.json | 8 ++++---- web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index d1a3899..cc97aa7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,7 +12,7 @@ "@xterm/addon-unicode11": "^0.10.0-beta.203", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.20.0-beta.202", - "@xterm/xterm": "^6.0.0", + "@xterm/xterm": "^6.1.0-beta.203", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5" @@ -453,9 +453,9 @@ } }, "node_modules/@xterm/xterm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", - "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "version": "6.1.0-beta.203", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.203.tgz", + "integrity": "sha512-Ctqf05M6fPWZkfKxC4hy2+PP5P2BlVnJLbIsXZMpkCz/MjJvcf5OwwsGkq+nzhFDuojSX+rc2RxIetLONUBGqw==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/web/package.json b/web/package.json index 5c24f86..5f53677 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ "@xterm/addon-unicode11": "^0.10.0-beta.203", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.20.0-beta.202", - "@xterm/xterm": "^6.0.0", + "@xterm/xterm": "^6.1.0-beta.203", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5" From 436d5c6149b875837b297a46478dc9cbf06df206 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 22:28:15 +0200 Subject: [PATCH 39/44] feat(shell): add Ctrl+/- zoom and display all shortcuts in footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl+/Ctrl-/Ctrl+0 to zoom in/out/reset terminal font size - Zoom badge indicator in tab bar - All shell shortcuts now shown in statusbar footer - Added i18n labels for search, zoom, switch tab, next tab 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- web/src/components/App.jsx | 4 +++ web/src/components/Shell.jsx | 53 ++++++++++++++++++++++++++++++++++++ web/src/i18n/en.js | 4 +++ web/src/i18n/fr.js | 4 +++ web/src/styles/global.css | 8 ++++++ 5 files changed, 73 insertions(+) diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 5232c0d..563a7ba 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -94,6 +94,10 @@ export default function App() { shell: [ { keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') }, { keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') }, + { keys: `${layout.keys.ctrl}+F`, desc: t('statusbar.search') }, + { keys: `${layout.keys.ctrl}+/Ctrl−`, desc: t('statusbar.zoom') }, + { keys: `Alt+1-7`, desc: t('statusbar.switchTab') }, + { keys: `${layout.keys.shift}+Tab`, desc: t('statusbar.nextTab') }, { keys: layout.keys.enter, desc: t('statusbar.runCommand') }, { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, ], diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 0d01481..5e88be7 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -255,6 +255,27 @@ function createTerminal(container, settings = {}) { return false } + if (ctrl && (e.key === '=' || e.key === '+')) { + e.preventDefault() + e.stopPropagation() + window.dispatchEvent(new CustomEvent('shell-zoom', { detail: 1 })) + return false + } + + if (ctrl && e.key === '-') { + e.preventDefault() + e.stopPropagation() + window.dispatchEvent(new CustomEvent('shell-zoom', { detail: -1 })) + return false + } + + if (ctrl && e.key === '0') { + e.preventDefault() + e.stopPropagation() + window.dispatchEvent(new CustomEvent('shell-zoom', { detail: 0 })) + return false + } + return true }) @@ -387,9 +408,36 @@ export default function Shell({ api }) { const [searchText, setSearchText] = useState('') const searchInputRef = useRef(null) const searchDecorationsRef = useRef(null) + const [zoomLevel, setZoomLevel] = useState(0) + const baseFontSizeRef = useRef(12) useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings]) + useEffect(() => { + baseFontSizeRef.current = terminalSettings.fontSize || 12 + }, [terminalSettings.fontSize]) + + useEffect(() => { + const handler = (e) => { + const direction = e.detail + setZoomLevel(prev => { + let next + if (direction === 0) next = 0 + else next = Math.max(-8, Math.min(10, prev + direction)) + const newSize = baseFontSizeRef.current + next * 2 + for (const entry of Object.values(tabsRef.current)) { + if (entry.term && !entry.term._disposed) { + entry.term.options.fontSize = newSize + try { entry.fitAddon.fit() } catch {} + } + } + return next + }) + } + window.addEventListener('shell-zoom', handler) + return () => window.removeEventListener('shell-zoom', handler) + }, []) + const [sshForm, setSshForm] = useState({ name: '', host: '', port: 22, user: '', key_path: '', }) @@ -1059,6 +1107,11 @@ export default function Shell({ api }) {
+ {zoomLevel !== 0 && ( + + {zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px + + )} {tabs.length < MAX_TABS && (
+
+
+
+ )}
) } @@ -1409,12 +1531,36 @@ function ShellToolBlock({ call, result }) { ) } +let mermaidIdCounter = 0 + +function MermaidBlock({ code }) { + const ref = useRef(null) + const [svg, setSvg] = useState('') + const [error, setError] = useState(false) + + useEffect(() => { + let cancelled = false + const id = `mermaid-${++mermaidIdCounter}` + mermaid.render(id, code).then(({ svg }) => { + if (!cancelled) setSvg(svg) + }).catch(() => { + if (!cancelled) setError(true) + }) + return () => { cancelled = true } + }, [code]) + + if (error) return
{code}
+ if (!svg) return
Chargement du diagramme...
+ return
+} + function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant' const content = msg.content || '' + const [copiedIdx, setCopiedIdx] = useState(null) if (role === 'user') { - return
{content}
+ return
} if (role === 'system') { @@ -1426,16 +1572,16 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { let displayContent = content let streamingToolCalls = msg._toolCalls || null - if (!streamingToolCalls) { - try { - const parsed = JSON.parse(content) - if (parsed && Array.isArray(parsed.tool_calls)) { + try { + const parsed = JSON.parse(content) + if (parsed && Array.isArray(parsed.tool_calls)) { + if (!streamingToolCalls) { parsedToolCalls = parsed.tool_calls parsedToolResults = parsed.tool_results || null - displayContent = parsed.content || '' } - } catch {} - } + displayContent = parsed.content || '' + } + } catch {} const parts = renderContent(displayContent) @@ -1454,14 +1600,26 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { return })} {parts.map((part, i) => { + if (part.type === 'code' && part.lang === 'mermaid') { + return ( +
+
mermaid
+ +
+ ) + } if (part.type === 'code') { return (
{part.lang &&
{part.lang}
}
{part.content}
- +
+ +
+ ) + } + return ( +
+
+ {part.lang && {part.lang}} + +
+
{part.content}
+
+ ) +} + function FeedItem({ msg }) { const isUser = msg.role === 'user' const isSystem = msg.role === 'system' const rank = getRank(msg.role) + const [copiedIdx, setCopiedIdx] = useState(null) const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' @@ -226,10 +297,7 @@ function FeedItem({ msg }) {
{renderContent(cleanContent).map((part, i) => part.type === 'code' ? ( -
- {part.lang &&
{part.lang}
} -
{part.content}
-
+ ) : ( ) @@ -245,6 +313,7 @@ function StreamingItem({ content, thinking, toolCalls }) { const rank = RANKS.general const cleanContent = content.replace(/]*>[\s\S]*?<\/think>/gi, '') const hasToolCalls = toolCalls && toolCalls.length > 0 + const [copiedIdx, setCopiedIdx] = useState(null) const renderedContent = useMemo(() => { if (!cleanContent) return [] @@ -281,10 +350,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
{renderedContent.map((part, i) => part.type === 'code' ? ( -
- {part.lang &&
{part.lang}
} -
{part.content}
-
+ ) : ( ) @@ -309,6 +375,7 @@ export default function Studio({ api }) { const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 }) const [contextCollapsed, setContextCollapsed] = useState(false) const [messagesCollapsed, setMessagesCollapsed] = useState(false) + const [sudoModal, setSudoModal] = useState(null) const messagesEnd = useRef(null) const feedRef = useRef(null) const textareaRef = useRef(null) @@ -404,7 +471,7 @@ export default function Studio({ api }) { const text = input.trim() setInput('') - const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t) + const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t) if (text.startsWith('/') && !isSlashCommand(text)) { setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }]) @@ -424,19 +491,8 @@ export default function Studio({ api }) { '- `/clear` - Effacer la conversation', '- `/summarize` - RĂ©sumer la conversation prĂ©cĂ©dente', '- `/help` - Afficher cette aide', - '- `/plan ` - Demander un plan structurĂ©', - '- `/export` - Exporter la conversation en Markdown', '- `/model` - Afficher le provider et modĂšle actifs', - '- `/model change` - Basculer entre MiniMax et ZAI', - '', - '## Tools disponibles', - '- Terminal - ExĂ©cuter des commandes', - '- read_file - Lire des fichiers', - '- list_files - Lister des fichiers', - '- search_files - Rechercher des fichiers', - '- grep_content - Rechercher dans le contenu', - '- get_config - Lire la configuration', - '- web_fetch - RĂ©cupĂ©rer une page web', + '- `/model change` - Basculer entre MiniMax et MiMo', ].join('\n') setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }]) return @@ -481,31 +537,6 @@ export default function Studio({ api }) { return } - if (text.startsWith('/plan ')) { - const objective = text.slice(6).trim() - if (!objective) { - setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Usage: `/plan `\nEx: `/plan crĂ©er un fichier de test`', time: new Date().toISOString() }]) - return - } - setInput(`CrĂ©e un plan structurĂ© en Ă©tapes numĂ©rotĂ©es pour: ${objective}. Chaque Ă©tape devrait avoir une estimation de complexitĂ© et de temps.`) - handleSend() - return - } - - if (text === '/export') { - api.getChatHistory().then(data => { - let markdown = '# Conversation Export\n\n' - data.messages?.forEach((msg, i) => { - const roleLabel = msg.role === 'user' ? 'đŸ‘€' : (msg.role === 'assistant' ? 'đŸ€–' : '⚙') - markdown += `## [${i + 1}] ${roleLabel} ${msg.role}\n${msg.content}\n\n---\n\n` - }) - setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Conversation exportĂ©e:\n```markdown\n' + markdown + '```', time: new Date().toISOString() }]) - }).catch(() => { - setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible d\'exporter la conversation', time: new Date().toISOString() }]) - }) - return - } - const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() } setMessages(prev => [...prev, userMsg]) setLoading(true) @@ -537,6 +568,9 @@ export default function Studio({ api }) { return } if (event && event.tool_result) { + if (event.tool_result.sudo_blocked) { + setSudoModal({ command: event.tool_result.command || event.tool_result.content }) + } const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id) if (idx >= 0) { toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result } @@ -602,7 +636,7 @@ export default function Studio({ api }) { } }, []) - const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change'] + const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change'] const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -744,9 +778,25 @@ export default function Studio({ api }) { )}
- {t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change + {t('studio.inputHint')} · /clear /summarize /help /model
+ + {sudoModal && ( +
setSudoModal(null)}> +
e.stopPropagation()}> +
Commande bloquée
+
+

L'IA a tenté d'exécuter une commande nécessitant des privilÚges administrateur :

+
{sudoModal.command}
+

La commande a été bloquée. L'IA en a été informée et cherchera une alternative.

+
+
+ +
+
+
+ )}
) } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index f168472..50eae3b 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -458,6 +458,8 @@ input::placeholder { color: var(--text-disabled); } .shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; } .ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } .ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; } +.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); } +.ai-message.user.analysis { border-left-color: var(--info); background: color-mix(in srgb, var(--info) 8%, var(--bg-elevated)); } .ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); } .ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; } .ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); } @@ -492,6 +494,23 @@ input::placeholder { color: var(--text-disabled); } } .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.copied { background: var(--accent-bg); color: var(--accent); animation: copy-flash 0.3s ease; } + +.shell-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; } +.shell-mermaid-container svg { max-width: 100%; height: auto; } +.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; } +.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; } + +.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; } +.ai-message th { background: var(--bg-surface); padding: 6px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); } +.ai-message td { padding: 5px 10px; border: 1px solid var(--border); color: var(--text-primary); } +.ai-message tr:nth-child(even) td { background: var(--bg-surface); } + +@keyframes copy-flash { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); background: color-mix(in srgb, var(--accent) 20%, transparent); } + 100% { transform: scale(1); } +} .shell-analysis-modal { background: var(--bg-elevated); border: 1px solid var(--border); @@ -720,6 +739,25 @@ input::placeholder { color: var(--text-disabled); } white-space: nowrap; } +/* Consumption */ +.dash-consumption-list { display: flex; flex-direction: column; gap: 10px; max-height: 270px; overflow-y: auto; } +.dash-consumption-provider { display: flex; flex-direction: column; gap: 4px; } +.dash-consumption-head { display: flex; align-items: center; justify-content: space-between; } +.dash-consumption-name { + font-size: 11px; font-weight: 700; letter-spacing: 0.5px; +} +.dash-consumption-total { + font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); +} +.dash-consumption-days { + display: flex; gap: 4px; flex-wrap: wrap; +} +.dash-consumption-day { + font-size: 9px; font-family: var(--font-mono); color: var(--text-tertiary); + background: var(--bg-input); padding: 1px 5px; border-radius: 4px; +} +.dash-consumption-day strong { color: var(--text-secondary); } + /* Processes */ .dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; } .dash-proc-row { @@ -933,11 +971,33 @@ input::placeholder { color: var(--text-disabled); } background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin: 8px 0; } +.studio-code-header { + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-surface); border-bottom: 1px solid var(--border); +} .studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; } .studio-code-lang { padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary); - background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px; + background: var(--bg-surface); text-transform: uppercase; letter-spacing: 0.5px; } +.studio-copy-btn { + padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary); + background: transparent; border: none; border-left: 1px solid var(--border); + cursor: pointer; transition: all 0.15s; font-family: var(--font-sans); + white-space: nowrap; +} +.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); } +.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); } + +.studio-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; } +.studio-mermaid-container svg { max-width: 100%; height: auto; } +.studio-mermaid-loading { padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px; } +.studio-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; } + +.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; } +.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); } +.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); } +.feed-content tr:nth-child(even) td { background: var(--bg-surface); } .inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); } .msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; } .msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; } From 12000e523c1f06d48eaf60b80d4300ec3c94bcf4 Mon Sep 17 00:00:00 2001 From: Augustin Date: Sun, 26 Apr 2026 15:19:26 +0200 Subject: [PATCH 44/44] fix: token persistence, context windows, CSS tables/bullets/hr, image attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix token count reset on app restart: persist realTokens in conversation.json - Fix token/context window values: Studio 150K (summarize at 120K), Terminal 100K - Fix table rendering in terminal tab: correct thead/tbody display model - Fix copy button always top-right in Studio code blocks - Add markdown horizontal rule (---) support in Studio and Terminal - Fix bullet list double dot: remove CSS ::before duplicate bullet point - Add image attachments support (VLM description, file mentions @file.ext) - Add sudo detection with cache (sync.Once) - Fix message content serialization (TextContent wrapper) - Guide AI to use read_file instead of cat in studio prompt 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- CRUSH_ARCHITECTURE_REPORT.md | 1073 +++++++++++++++++++++++ internal/agent/definitions.go | 25 +- internal/agent/prompts/studio_system.md | 1 + internal/api/chat_engine.go | 8 +- internal/api/conversation.go | 51 +- internal/api/handlers_chat.go | 178 +++- internal/api/handlers_info.go | 3 +- internal/api/handlers_shell_chat.go | 12 +- internal/api/image_cache.go | 104 +++ internal/api/server.go | 3 +- internal/orchestrator/orchestrator.go | 46 +- internal/workflow/planner.go | 2 +- web/src/api/client.js | 6 +- web/src/components/App.jsx | 2 +- web/src/components/Shell.jsx | 119 ++- web/src/components/Studio.jsx | 86 +- web/src/styles/global.css | 76 +- 17 files changed, 1686 insertions(+), 109 deletions(-) create mode 100644 CRUSH_ARCHITECTURE_REPORT.md create mode 100644 internal/api/image_cache.go diff --git a/CRUSH_ARCHITECTURE_REPORT.md b/CRUSH_ARCHITECTURE_REPORT.md new file mode 100644 index 0000000..5ac3af2 --- /dev/null +++ b/CRUSH_ARCHITECTURE_REPORT.md @@ -0,0 +1,1073 @@ +# Rapport d'Architecture : CharmBracelet Crush + +> Analyse complĂšte de l'application [charmbracelet/crush](https://github.com/charmbracelet/crush.git) — comment elle communique avec les IA, gĂšre les outils, optimise les tokens et les performances. + +--- + +## Table des matiĂšres + +1. [Architecture globale](#1-architecture-globale) +2. [Ce qui est envoyĂ© Ă  l'IA](#2-ce-qui-est-envoyĂ©-Ă -lia) +3. [SystĂšme de prompts](#3-systĂšme-de-prompts) +4. [SystĂšme de rĂ©sumĂ© / compaction](#4-systĂšme-de-rĂ©sumĂ©--compaction) +5. [Fonctionnement des outils (Tools)](#5-fonctionnement-des-outils-tools) +6. [Optimisation de la consommation de tokens](#6-optimisation-de-la-consommation-de-tokens) +7. [Optimisations de performance](#7-optimisations-de-performance) +8. [SystĂšme de permissions](#8-systĂšme-de-permissions) +9. [Providers et modĂšles](#9-providers-et-modĂšles) +10. [SystĂšme de skills](#10-systĂšme-de-skills) +11. [IntĂ©gration LSP](#11-intĂ©gration-lsp) +12. [IntĂ©gration MCP](#12-intĂ©gration-mcp) +13. [Structure de la base de donnĂ©es](#13-structure-de-la-base-de-donnĂ©es) +14. [Fichiers clĂ©s Ă  explorer](#14-fichiers-clĂ©s-Ă -explorer) +15. [Leçons pour notre application](#15-leçons-pour-notre-application) + +--- + +## 1. Architecture globale + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ TUI / CLI │────▶│ Backend │────▶│ Coordinator │ +│ (UI chat) │ │ (workspace) │ │ (orchestrateur) │ +└──────────────┘ └──────────────┘ └────────┬─────────┘ + │ + ┌────────▌─────────┐ + │ SessionAgent │ + │ (moteur IA) │ + └────────┬─────────┘ + │ + ┌────────────────────┌────────────────────┐ + │ │ │ + ┌────────▌──────┐ ┌────────▌──────┐ ┌────────▌──────┐ + │ fantasy │ │ SQLite DB │ │ Tools │ + │ (LLM client) │ │ (messages) │ │ (22+ outils) │ + └───────────────┘ └───────────────┘ └───────────────┘ +``` + +**Flow de donnĂ©es complet :** + +``` +User Prompt + → Backend.SendMessage() + → Coordinator.Run() + → UpdateModels() (recharge models + tools) + → mergeCallOptions() (merge JSON des options provider) + → SessionAgent.Run() + → preparePrompt() (construit l'historique, filtre les orphelins) + → fantasy.Agent.Stream() avec : + - System prompt = coder.md.tpl + instructions MCP + - System prompt prefix = prefix provider + - Messages = historique filtrĂ© (tronquĂ© au rĂ©sumĂ© si existe) + - Files = piĂšces jointes binaires + - Tools = ensemble filtrĂ© d'outils + - Provider options = options merged Anthropic/OpenAI/Google/etc. + - Cache control = ephemeral sur dernier system + 2 derniers messages + → Auto-summarize si fenĂȘtre de contexte quasi pleine + → Queue + recurse pour messages en attente +``` + +### Fichiers clĂ©s de l'architecture + +| Fichier | RĂŽle | +|---------|------| +| `internal/agent/agent.go` | Moteur principal — construction des messages, streaming, rĂ©sumĂ© | +| `internal/agent/coordinator.go` | Orchestration — crĂ©ation agents, assembly tools/models | +| `internal/agent/prompts.go` | Factory de prompts systĂšme | +| `internal/agent/templates/coder.md.tpl` | Template du prompt systĂšme principal (405 lignes) | +| `internal/backend/backend.go` | Gestion des workspaces | +| `internal/backend/agent.go` | API transport-agnostic vers le coordinator | + +--- + +## 2. Ce qui est envoyĂ© Ă  l'IA + +### 2.1 System Prompt + +Le system prompt est construit Ă  partir du template `coder.md.tpl` et contient : + +``` +System Message: +├── — 13 rĂšgles absolues (read before edit, autonomous, etc.) +├── — Style de rĂ©ponse (concis, <4 lignes) +├── — SĂ©quence de travail (search → read → edit → test) +├── — DĂ©cisions autonomes vs. bloquantes +├── — RĂšgles d'Ă©dition (exact match, whitespace) +├── — Checklist prĂ©cision +├── — ComplĂ©tion exhaustive des tĂąches +├── — StratĂ©gies de rĂ©cupĂ©ration d'erreurs +├── — Gestion des fichiers mĂ©moire +├── — Conventions de code +├── — RĂšgles de test aprĂšs changements +├── — Utilisation des outils +├── — ProactivitĂ© vs. intention utilisateur +├── — Format des rĂ©ponses finales +├── — Variables dynamiques : +│ ├── Working Directory: {{.WorkingDir}} +│ ├── Is Git Repo: {{.IsGitRepo}} +│ ├── Platform: {{.Platform}} +│ ├── Date: {{.Date}} +│ └── Git Status (branch, git status --short | head -20, git log --oneline -n 3) +├── (optionnel) — État des serveurs LSP +├── — Skills disponibles en XML +├── — Instructions d'utilisation des skills +├── — Fichiers de contexte (crush.md, AGENTS.md, etc.) +└── — Instructions MCP injectĂ©es dynamiquement +``` + +### 2.2 Historique des messages + +Chaque appel Ă  l'IA inclut l'historique complet de la session, formatĂ© comme : + +``` +Messages (array de fantasy.Message): +├── [0] User: "This is a reminder that your todo list is currently empty..." +├── [1] User: "Premier prompt de l'utilisateur" +├── [2] Assistant: "RĂ©ponse IA + tool_calls" +├── [3] Tool: "RĂ©sultat du tool call" +├── [4] Assistant: "RĂ©ponse suivante" +├── ... +└── [N] User: "Nouveau prompt" +``` + +**Types de contenu dans les messages :** +- `ReasoningContent` — ChaĂźne de pensĂ©e (pour modĂšles thinking) +- `TextContent` — Texte brut +- `ImageURLContent` — Images (URL ou base64) +- `BinaryContent` — Fichiers binaires +- `ToolCall` — Appel d'outil (ID, nom, input) +- `ToolResult` — RĂ©sultat d'outil (text, error, ou media) +- `Finish` — MĂ©tadonnĂ©es de fin (end_turn, max_tokens, tool_use, canceled, error) + +### 2.3 Fichiers joints + +Les piĂšces jointes sont traitĂ©es diffĂ©remment selon leur type : +- **Fichiers texte** : contenus inline dans le prompt utilisateur via `PromptWithTextAttachments()` +- **Fichiers binaires** : envoyĂ©s comme `FilePart` sĂ©parĂ©s +- **Images** : envoyĂ©es comme `ImageURLContent` (base64) si le modĂšle les supporte + +### 2.4 Options provider fusionnĂ©es + +Les options sont fusionnĂ©es en 3 couches (le plus profond gagne) : + +``` +1. catwalk.Model.Options.ProviderOptions (dĂ©fauts du catalogue) +2. ProviderConfig.ProviderOptions (config du provider) +3. SelectedModel.ProviderOptions (choix utilisateur) +→ JSON merge avec sĂ©mantique "deepest wins" +``` + +Options spĂ©cifiques par provider : +- **Anthropic** : thinking, reasoning_effort, beta headers (`interleaved-thinking-2025-05-14`) +- **OpenAI** : responses API, reasoning +- **Google** : thinking_config +- **OpenRouter** : reasoning, suffixe `:exacto` + +--- + +## 3. SystĂšme de prompts + +### 3.1 Templates embarquĂ©s + +| Template | But | Taille | +|----------|-----|--------| +| `coder.md.tpl` | Prompt systĂšme principal de l'agent coder | ~405 lignes | +| `task.md.tpl` | Prompt systĂšme des sous-agents | ~15 lignes | +| `initialize.md.tpl` | Prompt d'initialisation du codebase | Analyse + gĂ©nĂ©ration AGENTS.md | +| `summary.md` | Prompt de rĂ©sumĂ©/compaction | Sections structurĂ©es obligatoires | +| `title.md` | GĂ©nĂ©ration de titre de session | ≀50 chars | +| `agent_tool.md` | Instructions du tool `agent` | Sous-agent read-only | +| `agentic_fetch.md` | Instructions du tool `agentic_fetch` | Sous-agent web | +| `agentic_fetch_prompt.md.tpl` | Prompt du sous-agent de fetch | Template dynamique | + +### 3.2 Prompt de rĂ©sumĂ© (`summary.md`) + +Ce prompt est crucial — il dĂ©finit comment la conversation est compactĂ©e : + +**Sections obligatoires du rĂ©sumĂ© :** +1. **Current State** — État actuel de la tĂąche +2. **Files & Changes** — Tous les fichiers modifiĂ©s et les changements +3. **Technical Context** — Stack technique, dĂ©pendances, patterns +4. **Strategy & Approach** — StratĂ©gie adoptĂ©e +5. **Exact Next Steps** — Prochaines Ă©tapes exactes + +**Philosophie :** *"No limit. Err on the side of too much detail rather than too little."* + +### 3.3 Prompt de sous-agent (`task.md.tpl`) + +Minimal et ciblĂ© : +``` +Rules: +1. Be concise, direct +2. Share file names and paths +3. Use absolute paths only + + +Working Directory: {{.WorkingDir}} +Is Git Repo: {{.IsGitRepo}} +Platform: {{.Platform}} +Date: {{.Date}} + +``` + +### 3.4 Variables dynamiques injectĂ©es + +Le systĂšme injecte des donnĂ©es en temps rĂ©el via le template : + +```go +type PromptData struct { + WorkingDir string + IsGitRepo bool + Platform string + Date string + GitStatus string // branch + git status --short | head -20 + git log --oneline -n 3 + Config Config + AvailSkillXML string // XML des skills disponibles + ContextFiles string // Contenu des fichiers de contexte +} +``` + +### 3.5 Fichiers de contexte (Memory) + +Chemins par dĂ©faut explorĂ©s et injectĂ©s dans `` : +``` +.github/copilot-instructions.md +.cursorrules +.cursor/rules/ +CLAUDE.md +CLAUDE.local.md +GEMINI.md +crush.md +CRUSH.md +AGENTS.md +``` + +--- + +## 4. SystĂšme de rĂ©sumĂ© / compaction + +### 4.1 DĂ©clenchement automatique + +Deux conditions de dĂ©clenchement vĂ©rifiĂ©es aprĂšs **chaque Ă©tape** de l'agent : + +``` +remaining = contextWindow - (completionTokens + promptTokens) + +if contextWindow > 200,000: + threshold = 20,000 tokens (buffer fixe) +else: + threshold = 20% × contextWindow (ratio) + +if remaining ≀ threshold AND !disableAutoSummarize: + → DÉCLENCHE LE RÉSUMÉ + +if contextWindow == 0 (modĂšle inconnu/local): + → PAS de rĂ©sumĂ© auto (Ă©vite la troncature aveugle) +``` + +**Constantes clĂ©s :** +- `largeContextWindowThreshold = 200,000` +- `largeContextWindowBuffer = 20,000` +- `smallContextWindowRatio = 0.2` + +### 4.2 Processus de rĂ©sumĂ© + +``` +┌─────────────────────────────────────────────┐ +│ 1. RĂ©cupĂ©rer tous les messages de session │ +│ 2. Convertir en fantasy.Message[] │ +│ 3. CrĂ©er message assistant │ +│ avec IsSummaryMessage: true │ +│ 4. Streamer le rĂ©sumĂ© via summary.md prompt │ +│ + todo list courante │ +│ 5. Update session: │ +│ - SummaryMessageID = summary.ID │ +│ - CompletionTokens = rĂ©sumĂ© output │ +│ - PromptTokens = 0 │ +└─────────────────────────────────────────────┘ +``` + +### 4.3 Comment la compaction fonctionne au chargement + +```go +func getSessionMessages(session): + msgs = loadAllMessages(sessionID) + + if session.SummaryMessageID != "": + summaryIndex = findIndex(msgs, summaryMessageID) + msgs = msgs[summaryIndex:] // TRONQUE tout avant le rĂ©sumĂ© + msgs[0].Role = "user" // Change rĂŽle assistant → user + + return msgs +``` + +**RĂ©sultat :** Seuls le rĂ©sumĂ© + les messages aprĂšs le rĂ©sumĂ© sont envoyĂ©s Ă  l'IA. Tout l'historique prĂ©cĂ©dent est Ă©liminĂ© du contexte. + +### 4.4 DĂ©tection de boucle (Loop Detection) + +MĂ©canisme secondaire de dĂ©clenchement du rĂ©sumĂ© : + +``` +- FenĂȘtre glissante de 10 derniĂšres Ă©tapes +- Signature = SHA-256(ToolName + \x00 + Input + \x00 + Output + \x00) +- Si une signature apparaĂźt > 5 fois dans la fenĂȘtre → BOUCLE DÉTECTÉE +- DĂ©clenche le rĂ©sumĂ© + arrĂȘt de l'agent +``` + +### 4.5 Reprise aprĂšs interruption + +Si le rĂ©sumĂ© est dĂ©clenchĂ© pendant des appels d'outils en cours : +``` +Re-queue le prompt original avec : +"The previous session was interrupted because it got too long, +the initial user request was: " +``` + +--- + +## 5. Fonctionnement des outils (Tools) + +### 5.1 Liste complĂšte des outils (22+) + +| Outil | Type | SĂ©quentiel/ParallĂšle | But | +|-------|------|---------------------|-----| +| `bash` | Core | SĂ©quentiel | ExĂ©cution de commandes shell | +| `edit` | Core | SĂ©quentiel | Remplacement find-and-replace dans un fichier | +| `multiedit` | Core | SĂ©quentiel | Multiples remplacements sĂ©quentiels | +| `write` | Core | SĂ©quentiel | CrĂ©ation/Ă©crasement de fichier | +| `view` | Core | SĂ©quentiel | Lecture de fichier avec line numbers | +| `ls` | Core | SĂ©quentiel | Arborescence de rĂ©pertoire | +| `glob` | Core | SĂ©quentiel | Recherche de fichiers par pattern | +| `grep` | Core | SĂ©quentiel | Recherche dans le contenu de fichiers | +| `fetch` | Core | ParallĂšle | Fetch URL brut (text/markdown/html) | +| `agentic_fetch` | Core | ParallĂšle | Fetch IA avec extraction/rĂ©sumĂ© | +| `sourcegraph` | Core | ParallĂšle | Recherche de code sur GitHub | +| `download` | Core | ParallĂšle | TĂ©lĂ©chargement de fichier | +| `agent` | Agent | ParallĂšle | Sous-agent de recherche/analyse | +| `todos` | Core | SĂ©quentiel | Gestion de la todo list | +| `crush_info` | Core | SĂ©quentiel | État runtime de Crush | +| `crush_logs` | Core | SĂ©quentiel | Logs internes de Crush | +| `job_output` | Core | SĂ©quentiel | Output de processus background | +| `job_kill` | Core | SĂ©quentiel | Terminaison de processus background | +| `lsp_diagnostics` | LSP | SĂ©quentiel | Diagnostics LSP | +| `lsp_references` | LSP | SĂ©quentiel | RĂ©fĂ©rences LSP | +| `lsp_restart` | LSP | SĂ©quentiel | RedĂ©marrage LSP | +| `list_mcp_resources` | MCP | ParallĂšle | Liste ressources MCP | +| `read_mcp_resource` | MCP | ParallĂšle | Lecture ressource MCP | +| `mcp_{server}_{tool}` | MCP | ParallĂšle | Outils dynamiques MCP | + +### 5.2 Architecture d'un outil + +Chaque outil suit le pattern : + +```go +fantasy.NewAgentTool(name, description, params, func(ctx context.Context, params Params) (fantasy.ToolResponse, error) { + // 1. Validation des paramĂštres + // 2. Extraction du contexte (sessionID, messageID, workingDir) + // 3. VĂ©rification de permission (si mutation) + // 4. ExĂ©cution de la logique + // 5. Retour de la rĂ©ponse avec mĂ©tadonnĂ©es +}) +``` + +**Deux constructeurs :** +- `fantasy.NewAgentTool` — Outil sĂ©quentiel (bloquant) +- `fantasy.NewParallelAgentTool` — Outil parallĂšle (peut tourner en //) + +### 5.3 Types de rĂ©ponses + +```go +fantasy.NewTextResponse(content) // SuccĂšs texte +fantasy.NewTextErrorResponse(message) // Erreur (IsError=true) +fantasy.NewImageResponse(data, mimeType) // Image +fantasy.NewMediaResponse(data, mimeType) // Autre mĂ©dia +fantasy.WithResponseMetadata(resp, meta) // Attache mĂ©tadonnĂ©es typĂ©es +``` + +### 5.4 DĂ©tails des outils clĂ©s + +#### `bash` — ExĂ©cution de commandes + +``` +ParamĂštres: {Description, Command, WorkingDir, RunInBackground, AutoBackgroundAfter} + +SĂ©curitĂ©: +├── safeCommands: ls, cat, head, tail, pwd, echo, which, env, git status/diff/log +│ → Pas de permission requise (read-only) +├── Banned: curl, wget, sudo, apt, npm, etc. +│ → BloquĂ© au niveau shell +├── Background: si RunInBackground=true ou > AutoBackgroundAfter (60s) +│ → Retourne ShellID pour suivi +└── Output: tronquĂ© Ă  30,000 chars (dĂ©but + fin + compte lignes tronquĂ©es) +``` + +#### `view` — Lecture de fichiers + +``` +ParamĂštres: {FilePath, Offset, Limit} + +Comportement: +├── crush: prefix → lecture depuis FS embarquĂ© (skills builtins) +├── Chemins relatifs → rĂ©solus via SmartJoin(workingDir, ...) +├── Permission requise si hors workingDir +├── Max fichier: 100KB, dĂ©faut 2000 lignes +├── Images JPG/PNG/GIF/WebP → NewImageResponse +├── Ajoute numĂ©ros de ligne (padding 6 chars) +├── LSP: ouvre fichier, attend 300ms pour diagnostics +├── Enregistre lecture via filetracker.RecordRead() +└── Suggestions si fichier non trouvĂ© +``` + +#### `edit` — Édition de fichiers + +``` +ParamĂštres: {FilePath, OldString, NewString, ReplaceAll} + +3 modes: +├── OldString="" → CrĂ©er nouveau fichier +├── NewString="" → Supprimer contenu +└── Les deux → Remplacer contenu + +SĂ©curitĂ©: +├── Doit avoir lu le fichier avant (filetracker check) +├── Fichier non modifiĂ© depuis la lecture (ModTime check) +├── OldString unique (sauf ReplaceAll=true) +├── Permission "write" avec diff affichĂ© +├── Gestion CRLF automatique +└── LSP notifiĂ© aprĂšs Ă©criture +``` + +#### `grep` — Recherche de contenu + +``` +ParamĂštres: {Pattern, Path, Include, LiteralText} + +Optimisations: +├── ripgrep (rg --json) en prioritĂ©, fallback Go regex +├── Respecte .gitignore et .crushignore +├── Cache de regex compilĂ©s (csync.Map thread-safe) +├── RĂ©sultats triĂ©s par date de modification (plus rĂ©cent d'abord) +├── Max 100 rĂ©sultats +├── Lignes tronquĂ©es Ă  500 chars +└── Timeout configurable +``` + +### 5.5 Outils spĂ©ciaux + +#### `agent` — Sous-agent + +``` +Flow: +1. Valide params.Prompt non vide +2. CrĂ©e une session enfant (CreateTaskSession) +3. Lance un SessionAgent sĂ©parĂ© (NonInteractive: true) +4. Outils du sous-agent: glob, grep, ls, sourcegraph, view (READ-ONLY) +5. Propage le coĂ»t de la session enfant → session parent +``` + +#### `agentic_fetch` — Fetch intelligent + +``` +Flow: +1. Mode URL: fetch + convert (HTML→MD) + → Si contenu > 50KB: sauve en temp file, dit au sous-agent d'utiliser view/grep +2. Mode Search: construit prompt pour web_search + web_fetch +3. Sous-agent avec petit modĂšle + outils restreints +4. Auto-approve des permissions pour le sous-agent +``` + +--- + +## 6. Optimisation de la consommation de tokens + +### 6.1 RĂ©sumĂ© automatique (Auto-Summarization) + +**La plus grande optimisation.** Voir [Section 4](#4-systĂšme-de-rĂ©sumĂ©--compaction). + +Quand la fenĂȘtre de contexte approche sa limite, toute la conversation est remplacĂ©e par un rĂ©sumĂ© dĂ©taillĂ©. Ce rĂ©sumĂ© devient le nouveau point de dĂ©part. + +### 6.2 Anthropic Prompt Caching + +Marqueurs `ephemeral` ajoutĂ©s automatiquement : + +```go +// Cache control placement: +├── Dernier message systĂšme → ephemeral +├── Avant-dernier message → ephemeral +└── AntĂ©pĂ©nultiĂšme message → ephemeral +``` + +Cela permet Ă  Anthropic de mettre en cache ces messages et de rĂ©duire les tokens d'input sur les tours suivants. + +### 6.3 Filtrage des orphelins de tool calls + +```go +// Avant d'envoyer Ă  l'IA: +filterOrphanedToolResults() // Supprime tool results sans tool call correspondant +syntheticToolResultsForOrphanedCalls() // Injecte erreurs synthĂ©tiques pour tool calls orphelins +``` + +Cela nettoie l'historique des messages inutiles qui consomment des tokens. + +### 6.4 Deux modĂšles (Large/Small) + +``` +├── Large Model: tĂąches principales (coder, rĂ©sumĂ©) +└── Small Model: titre de session, agentic_fetch + → Économise les tokens de haute qualitĂ© pour les tĂąches simples +``` + +### 6.5 Limitations de taille des outils + +| Outil | Limite | Impact token | +|-------|--------|-------------| +| bash output | 30,000 chars | Tronque les longues sorties | +| view | 100KB max, 2000 lignes | Limite les gros fichiers | +| grep | 100 rĂ©sultats, 500 chars/ligne | Limite les recherches larges | +| glob | 100 fichiers | Limite les rĂ©sultats | +| ls | 1000 fichiers | Limite les rĂ©pertoires massifs | +| fetch | 100KB | Limite les pages web | +| sourcegraph | 20 rĂ©sultats max | Limite les recherches code | +| description tools | FirstLineDescription() | N'envoie que la 1Ăšre ligne de description | + +### 6.6 Short Tool Descriptions + +```go +// Par dĂ©faut: seul le premier ligne non-vide de la description est envoyĂ© +FirstLineDescription(fullMarkdownDescription) + +// DĂ©sactivable via: CRUSH_SHORT_TOOL_DESCRIPTIONS=0 +``` + +Chaque outil a une description markdown complĂšte, mais seule la premiĂšre ligne est envoyĂ©e Ă  l'IA — Ă©conomie significative sur 22+ outils. + +### 6.7 Workaround mĂ©dia par provider + +Pour les providers non-Anthropic/Bedrock, les images dans les tool results sont converties en messages utilisateur sĂ©parĂ©s avec piĂšces jointes — Ă©vitant les erreurs API et les tokens gaspillĂ©s. + +### 6.8 Injection system_reminder minimale + +Seul un rappel minimal est injectĂ© comme premier message utilisateur : +``` +"This is a reminder that your todo list is currently empty. +DO NOT mention this to the user explicitly..." +``` + +Ce message est court et sert de garde-fou sans consommer beaucoup de tokens. + +### 6.9 Estimation de tokens + +```go +ApproxTokenCount(text) = len(text) / 4 // Heuristique 4 chars = 1 token +``` + +UtilisĂ© pour le logging et les mĂ©triques, pas pour le contrĂŽle strict. + +--- + +## 7. Optimisations de performance + +### 7.1 Concurrence + +``` +├── sync.WaitGroup pour chargement providers (Catwalk + Hyper en //) +├── sync.OnceValue pour cache Hyper (computed once) +├── fastwalk pour dĂ©couverte des skills (concurent, suit symlinks) +├── errgroup.Group pour readiness des agents (system prompt + tools en //) +├── csync.Map pour maps concurrent-safe (providers, regex cache) +├── sync.RWMutex pour skill tracker +└── Shell mutex sĂ©rialise les commandes par instance shell +``` + +### 7.2 File d'attente de messages (Message Queue) + +``` +Si agent occupĂ© → message en queue +↓ +Dans PrepareStep → injecte messages en queue comme user messages supplĂ©mentaires +↓ +Pas de perte de messages, traitement sĂ©quentiel garanti +``` + +### 7.3 Cache providers + +``` +├── Cache JSON: $XDG_DATA_HOME/crush/providers.json +├── ETag support pour revalidation HTTP +├── Fallback: frais → cache → embarquĂ© +└── Chargement concurrent Catwalk + Hyper +``` + +### 7.4 CGO et GC + +``` +├── CGO_ENABLED=0 (pas de CGO overhead) +└── GOEXPERIMENT=greenteagc (GC optimisĂ©) +``` + +### 7.5 Base de donnĂ©es SQLite + +``` +├── SQLC pour code SQL type-safe gĂ©nĂ©rĂ© +├── Transactions avec retry (3 tentatives) pour conflits UNIQUE +├── Atomic SQL increment pour token usage (Ă©vite race conditions) +└── Index sur (session_id, created_at) pour requĂȘtes messages rapides +``` + +### 7.6 Pub/Sub + +``` +├── SystĂšme d'Ă©vĂ©nements dĂ©couplĂ© +├── Canal d'Ă©vĂ©nements par workspace +└── Pas de polling — push-based +``` + +--- + +## 8. SystĂšme de permissions + +### 8.1 Actions + +| Action | Quand | Outils concernĂ©s | +|--------|-------|-----------------| +| `"read"` | Lecture hors workingDir | view, ls | +| `"write"` | Modification de fichiers | edit, multiedit, write | +| `"execute"` | Commande shell non-safe | bash | +| `"fetch"` | RequĂȘte rĂ©seau | fetch, agentic_fetch | +| `"download"` | TĂ©lĂ©chargement | download | +| `"list"` | Liste ressources MCP | list_mcp_resources | +| `"read"` | Lecture ressource MCP | read_mcp_resource | + +### 8.2 Auto-approbations + +- Commandes bash `safeCommands` (ls, cat, pwd, git status, etc.) → **pas de permission** +- Docker MCP tools whitelistĂ©s (`mcp_docker_mcp-find`, etc.) → **auto-approuvĂ©** +- Sous-agents (agent, agentic_fetch) → **auto-approuvĂ©** + +### 8.3 SĂ©curitĂ© shell + +``` +Banned commands: alias, aria2c, axel, chrome, curl, curlie, firefox, +http-prompt, httpie, links, lynx, nc, safari, scp, ssh, telnet, w3m, +wget, xh, doas, su, sudo, apk, apt, apt-cache, apt-get, dnf, dpkg, +emerge, home-manager, makepkg, opkg, pacman, paru, pkg, pkg_add, +pkg_delete, portage, rpm, yay, yum, zypper, at, batch, chkconfig, +crontab, fdisk, mkfs, mount, parted, service, systemctl, umount, +firewall-cmd, ifconfig, ip, iptables, netstat, pfctl, route, ufw +``` + +--- + +## 9. Providers et modĂšles + +### 9.1 Types de providers supportĂ©s + +``` +├── OpenAI +├── Anthropic +├── OpenRouter (suffixe :exacto pour modĂšles supportĂ©s) +├── Vercel +├── Azure +├── AWS Bedrock +├── Google (Gemini) +├── Google Vertex AI +├── OpenAI-compatible (gĂ©nĂ©rique) +└── Hyper (Charm's meta-provider) +``` + +### 9.2 Hyper Provider + +``` +├── Endpoint: https://hyper.charm.land/api/v1/fantasy +├── Activation: HYPER, HYPERCRUSH, HYPER_ENABLE, HYPER_ENABLED env vars +├── ModĂšles: GLM-5, GLM-5.1, gpt-oss-120b, Kimi K2.5, Kimi K2.6 +├── Routage: Anthropic/OpenAI/Google/OpenAI-compat selon model ID +└── Header: x-crush-id pour identification +``` + +### 9.3 Construction du provider + +```go +buildProvider(config) → fantasy.Provider: + 1. Parse le type de provider + 2. Configure base URL, API key, headers + 3. Ajoute beta headers si thinking model (Anthropic) + 4. Pour Hyper: route vers le bon provider selon model ID + 5. Pour OpenRouter: ajoute :exacto si supportĂ© +``` + +--- + +## 10. SystĂšme de skills + +### 10.1 Standard Agent Skills + +Crush implĂ©mente le standard ouvert [agentskills.io](https://agentskills.io). + +### 10.2 DĂ©couverte + +``` +Chemins explorĂ©s: +├── $CRUSH_SKILLS_DIR +├── ~/.config/agents/skills/ +├── ~/.config/crush/skills/ +├── .agents/skills/ +├── .crush/skills/ +├── .claude/skills/ +├── .cursor/skills/ +└── Custom via options.skills_paths + +Recherche: +├── fastwalk (concurent, suit symlinks) +├── Cherche fichiers SKILL.md +├── Parse YAML frontmatter (entre ---) +├── Validation: nom alphanum-hyphens ≀64 chars, description ≀1024 chars +└── DĂ©duplication: dernier occurence gagne (user > builtin) +``` + +### 10.3 Injection dans le prompt + +```xml + + + skill-name + Description courte + /path/to/SKILL.md + builtin + + + + + When a user task matches a skill's description, read the skill's SKILL.md file... + +``` + +**Important :** Seules les mĂ©tadonnĂ©es (nom, description, chemin) sont injectĂ©es dans le system prompt. Les **instructions complĂštes** ne sont lues que quand l'agent dĂ©cide d'activer le skill — Ă©conomie de tokens. + +### 10.4 Skill Tracker + +```go +type Tracker struct { + active map[string]bool // Skills actifs (post-dedup, post-filter) + loaded map[string]bool // Skills dont les instructions ont Ă©tĂ© lues + mu sync.RWMutex +} + +MarkLoaded(name) // Marque un skill comme lu (uniquement si dans active set) +IsLoaded(name) // VĂ©rifie si dĂ©jĂ  chargĂ© +LoadedNames() // Liste des skills chargĂ©s +``` + +--- + +## 11. IntĂ©gration LSP + +### 11.1 Configuration + +```yaml +lsp: + - command: "gopls" + args: ["serve"] + env: {} + file_types: [".go"] + root_markers: ["go.mod"] + init_options: {} +``` + +### 11.2 Outils LSP + +- `lsp_diagnostics` — Diagnostics par fichier ou projet entier (max 10 fichiers, 5s wait) +- `lsp_references` — Recherche de rĂ©fĂ©rences symboliques (grep + LSP FindReferences) +- `lsp_restart` — RedĂ©marrage d'un ou tous les clients LSP + +### 11.3 IntĂ©gration dans les outils + +``` +view → openInLSPs + wait 300ms pour diagnostics +edit → notifyLSPs + wait 5s pour diagnostics +write → notifyLSPs + wait 5s pour diagnostics +``` + +Les diagnostics sont appendĂ©s dans les rĂ©ponses des outils via des tags XML : +```xml +... +... +errors: N, warnings: M +``` + +--- + +## 12. IntĂ©gration MCP + +### 12.1 Configuration + +```yaml +mcp: + - name: "my-server" + command: "npx" # Mode stdio + args: ["-y", "my-mcp"] + env: {} + url: "" # OU mode HTTP/SSE + timeout: 30s + disabled_tools: [] +``` + +### 12.2 Outils MCP dynamiques + +- Outils nommĂ©s `mcp_{server}_{tool}` +- SchĂ©ma extrait de `InputSchema` MCP +- Permission requise (sauf whitelist Docker) +- Support image/mĂ©dia dans les rĂ©sultats + +### 12.3 Instructions MCP injectĂ©es + +Les instructions des serveurs MCP connectĂ©s sont injectĂ©es dans le system prompt via ``. + +--- + +## 13. Structure de la base de donnĂ©es + +### 13.1 Tables principales + +```sql +-- Sessions +sessions ( + id, title, prompt_tokens, completion_tokens, + summary_message_id, -- ID du message rĂ©sumĂ© (NULL si pas de rĂ©sumĂ©) + cost, todos, -- CoĂ»t et todo list + created_at, updated_at +) + +-- Messages +messages ( + id, session_id, role, -- user/assistant/system/tool + parts, -- JSON array de {type, data} + model, provider, + is_summary_message, -- Flag de compaction + created_at, finished_at +) + +-- Fichiers (version history) +files ( + id, session_id, path, + content, version, -- Auto-incrĂ©mentĂ© par path + is_new, + created_at +) + +-- Fichiers lus (read tracking) +read_files ( + path, session_id, + last_read_time -- Pour "read before edit" +) +``` + +### 13.2 RequĂȘtes clĂ©s + +```sql +-- Messages d'une session (chronologique) +SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC; + +-- Update atomique des tokens (Ă©vite race conditions) +UPDATE sessions SET + prompt_tokens = prompt_tokens + ?, + completion_tokens = completion_tokens + ?, + cost = cost + ? +WHERE id = ?; + +-- Derniers fichiers par path (version max) +SELECT f.* FROM files f +INNER JOIN ( + SELECT path, MAX(version) as max_ver + FROM files WHERE session_id = ? + GROUP BY path +) latest ON f.path = latest.path AND f.version = latest.max_ver; + +-- Stats: utilisation des outils via JSON +SELECT json_extract(part.data, '$.name') as tool_name, COUNT(*) +FROM messages, json_each(messages.parts) as part +WHERE json_extract(part.value, '$.type') = 'tool_call' +GROUP BY tool_name; +``` + +--- + +## 14. Fichiers clĂ©s Ă  explorer + +### Architecture et flow principal + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/agent/agent.go` | Moteur IA — construction messages, streaming, rĂ©sumĂ©, queue | +| `internal/agent/coordinator.go` | Orchestration — crĂ©ation agents, tools, models, provider options | +| `internal/agent/prompts.go` | Factory de prompts systĂšme | +| `internal/agent/loop_detection.go` | DĂ©tection de boucles (SHA-256 signatures) | + +### Templates (ce qui est envoyĂ© Ă  l'IA) + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/agent/templates/coder.md.tpl` | System prompt principal (405 lignes) | +| `internal/agent/templates/task.md.tpl` | Prompt sous-agent | +| `internal/agent/templates/summary.md` | Prompt de rĂ©sumĂ©/compaction | +| `internal/agent/templates/title.md` | Prompt de gĂ©nĂ©ration de titre | +| `internal/agent/templates/agent_tool.md` | Instructions tool agent | +| `internal/agent/templates/agentic_fetch_prompt.md.tpl` | Prompt sous-agent fetch | + +### Outils + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/agent/agent_tool.go` | Tool agent (sous-agent spawner) | +| `internal/agent/agentic_fetch_tool.go` | Tool agentic_fetch | +| `internal/agent/tools/bash.go` | Tool bash | +| `internal/agent/tools/edit.go` | Tool edit | +| `internal/agent/tools/multiedit.go` | Tool multiedit | +| `internal/agent/tools/write.go` | Tool write | +| `internal/agent/tools/view.go` | Tool view | +| `internal/agent/tools/glob.go` | Tool glob | +| `internal/agent/tools/grep.go` | Tool grep | +| `internal/agent/tools/ls.go` | Tool ls | +| `internal/agent/tools/fetch.go` | Tool fetch | +| `internal/agent/tools/sourcegraph.go` | Tool sourcegraph | +| `internal/agent/tools/web_search.go` | Tool web_search (DuckDuckGo) | +| `internal/agent/tools/web_fetch.go` | Tool web_fetch | +| `internal/agent/tools/download.go` | Tool download | +| `internal/agent/tools/todos.go` | Tool todos | +| `internal/agent/tools/diagnostics.go` | Tool LSP diagnostics + helpers | +| `internal/agent/tools/references.go` | Tool LSP references | +| `internal/agent/tools/lsp_restart.go` | Tool LSP restart | +| `internal/agent/tools/crush_info.go` | Tool crush_info | +| `internal/agent/tools/crush_logs.go` | Tool crush_logs | +| `internal/agent/tools/mcp-tools.go` | Outils MCP dynamiques | + +### Messages et donnĂ©es + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/message/message.go` | Service de messages (CRUD + events) | +| `internal/message/content.go` | Types de contenu + conversion fantasy.Message | +| `internal/message/attachment.go` | PiĂšces jointes | +| `internal/history/file.go` | Historique de versions fichiers | +| `internal/db/sql/sessions.sql` | RequĂȘtes SQL sessions | +| `internal/db/sql/messages.sql` | RequĂȘtes SQL messages | +| `internal/db/sql/files.sql` | RequĂȘtes SQL fichiers | +| `internal/db/sql/stats.sql` | RequĂȘtes SQL statistiques | + +### Configuration et providers + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/config/config.go` | Structures de config (models, providers, tools, agents) | +| `internal/config/provider.go` | Chargement des providers (Catwalk + Hyper + cache) | +| `internal/backend/config.go` | API config backend | +| `internal/agent/hyper/provider.go` | Provider Hyper | +| `internal/agent/hyper/provider.json` | DĂ©finition des modĂšles Hyper | + +### Infrastructure + +| Fichier | Ce qu'il contient | +|---------|------------------| +| `internal/shell/shell.go` | Shell POSIX cross-platform (mvdan/sh) | +| `internal/diff/diff.go` | GĂ©nĂ©ration de diffs unifiĂ©s | +| `internal/skills/skills.go` | SystĂšme de skills (dĂ©couverte, injection) | +| `internal/skills/tracker.go` | Suivi des skills chargĂ©s | +| `internal/lsp/manager.go` | Gestionnaire LSP | +| `internal/filetracker/service.go` | Suivi des fichiers lus | + +--- + +## 15. Leçons pour notre application + +### 15.1 Patterns Ă  adopter + +| Pattern | Pourquoi | Comment Crush le fait | +|---------|----------|----------------------| +| **RĂ©sumĂ© automatique adaptatif** | GĂšre les longues conversations sans perte | Seuil 20K tokens (fenĂȘtres >200K) ou 20% (fenĂȘtres <200K), skip si fenĂȘtre inconnue | +| **Deux niveaux de modĂšle** | Économise les tokens coĂ»teux | Large pour coder, Small pour titres et fetch | +| **Skills avec lazy loading** | N'injecte que les mĂ©tadonnĂ©es, charge les instructions Ă  la demande | MĂ©tadonnĂ©es dans system prompt, instructions lues via view | +| **Short tool descriptions** | Économise des tokens sur 22+ outils | FirstLineDescription() par dĂ©faut | +| **Anthropic prompt caching** | RĂ©duit les tokens d'input sur les tours suivants | `ephemeral` sur dernier system + 2 derniers messages | +| **Filetracker read-before-edit** | SĂ©curitĂ© + contexte pour l'IA | Enregistre chaque lecture, vĂ©rifie avant Ă©dition | +| **Loop detection** | ArrĂȘte les boucles infinies coĂ»teuses | SHA-256 signature sur fenĂȘtre de 10 Ă©tapes, max 5 rĂ©pĂ©titions | +| **Orphan filtering** | Nettoie l'historique des messages inutiles | Supprime tool results orphelins, injecte erreurs synthĂ©tiques | +| **Atomic SQL increments** | Pas de race conditions sur les compteurs | `prompt_tokens = prompt_tokens + ?` au lieu de read-modify-write | +| **Message queue** | Pas de perte de messages | Queue + injection dans PrepareStep | + +### 15.2 Optimisations token spĂ©cifiques + +1. **Troncature de sortie** — bash (30K), grep (500 chars/ligne), view (2000 lignes) +2. **Limites de rĂ©sultats** — glob (100), grep (100), ls (1000), sourcegraph (20) +3. **PremiĂšre ligne de description** — Seulement la 1Ăšre ligne des descriptions markdown d'outils +4. **Estimation heuristique** — `len(text) / 4` pour logging sans appel API +5. **RĂ©sumĂ© dĂ©taillĂ© structurĂ©** — Sections obligatoires assurent pas de perte d'information critique +6. **Reset des compteurs aprĂšs rĂ©sumĂ©** — PromptTokens=0, CompletionTokens=rĂ©sumĂ© seulement + +### 15.3 Points d'attention + +- **Le system prompt fait ~405 lignes** — C'est Ă©norme mais structurĂ© en sections XML claires +- **Pas de troncature du system prompt** — Les skills et context files sont injectĂ©s en entier +- **Le rĂ©sumĂ© n'a pas de limite** — *"Err on the side of too much detail"* +- **La dĂ©tection de boucle est basique** — SHA-256 sur ToolName+Input+Output, mais ne dĂ©tecte pas les boucles sĂ©mantiques +- **Pas de token counting prĂ©cis** — Heuristique 4 chars/token, pas de tiktoken +- **Le file history persiste** — Les versions de fichiers survivent au rĂ©sumĂ© (pas dans le contexte LLM mais disponibles dans l'UI) + +### 15.4 Architecture recommandĂ©e + +``` +Pour notre app, inspirons-nous de: + +1. Coordinator Pattern + └── Orchestrateur central qui assemble models + tools + prompts + └── Agents par session avec state isolĂ© + +2. Summary/Compaction Pipeline + └── Seuils adaptatifs (fenĂȘtre large vs small) + └── RĂ©sumĂ© structurĂ© avec sections obligatoires + └── Reset des compteurs aprĂšs compaction + +3. Tool Architecture + └── Interface uniforme (params, execute, response) + └── Permission system par action + └── Output truncation systĂ©matique + └── Metadata sur chaque rĂ©ponse + +4. Provider Layer + └── JSON merge en 3 couches pour options + └── Support multi-provider avec routage + └── Cache avec ETag + fallback + +5. Lazy Skill Loading + └── MĂ©tadonnĂ©es dans system prompt + └── Instructions complĂštes Ă  la demande + └── Tracker pour Ă©viter les rechargements +``` + +--- + +## Annexe : SchĂ©ma de dĂ©pendances + +``` +main.go + └── internal/cmd/ (CLI commands) + └── internal/backend/ (workspace management) + └── internal/app/ (application logic) + └── internal/agent/coordinator.go (orchestration) + ├── internal/agent/agent.go (moteur IA) + │ ├── internal/agent/templates/ (prompts) + │ ├── internal/agent/prompt/ (prompt builder) + │ ├── internal/message/ (message types) + │ └── internal/agent/loop_detection.go + ├── internal/agent/tools/ (22+ outils) + ├── internal/skills/ (skill discovery) + ├── internal/config/ (configuration) + ├── internal/lsp/ (LSP management) + ├── internal/shell/ (shell execution) + ├── internal/db/ (SQLite persistence) + ├── internal/filetracker/ (read tracking) + └── internal/diff/ (diff generation) +``` + +--- + +*Rapport gĂ©nĂ©rĂ© le 26 avril 2026 — BasĂ© sur l'analyse du commit `HEAD` de [charmbracelet/crush](https://github.com/charmbracelet/crush)* diff --git a/internal/agent/definitions.go b/internal/agent/definitions.go index 898b421..affc551 100644 --- a/internal/agent/definitions.go +++ b/internal/agent/definitions.go @@ -7,9 +7,32 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" ) +var ( + sudoCache bool + sudoCacheSet bool + sudoCacheOnce sync.Once +) + +func NeedsSudoPassword() bool { + sudoCacheOnce.Do(func() { + if os.Geteuid() == 0 { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := exec.CommandContext(ctx, "sudo", "-n", "true").Run() + sudoCacheSet = true + sudoCache = err != nil + } else { + sudoCache = true + sudoCacheSet = true + } + }) + return sudoCache +} + type TerminalParams struct { Command string `json:"command" description:"The shell command to execute"` Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"` @@ -30,7 +53,7 @@ func NewTerminalTool() (*ToolDefinition, error) { return TextErrorResponse("command is required"), nil } - if os.Geteuid() != 0 { + if NeedsSudoPassword() { trimmed := strings.TrimSpace(p.Command) lower := strings.ToLower(trimmed) if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") { diff --git a/internal/agent/prompts/studio_system.md b/internal/agent/prompts/studio_system.md index d81b6bb..fc16ae4 100644 --- a/internal/agent/prompts/studio_system.md +++ b/internal/agent/prompts/studio_system.md @@ -39,6 +39,7 @@ Muyue gĂšre : - **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'Ă©tat du systĂšme - **DĂ©lĂ©gation intelligente** — Pour les tĂąches complexes (refactoring, crĂ©ation de fichiers, debug multi-fichiers), utilise `crush_run` au lieu d'enchaĂźner des commandes terminal +- **Lecture de fichiers** — Utilise TOUJOURS `read_file` pour lire le contenu d'un fichier. N'utilise PAS `terminal` avec `cat` pour lire des fichiers — `read_file` est plus rapide, plus prĂ©cis, et consomme moins de tokens - **ParallĂ©lisme** — Lance plusieurs appels d'outils en parallĂšle quand les opĂ©rations sont indĂ©pendantes - **Troncature** — Si un rĂ©sultat d'outil dĂ©passe 2000 caractĂšres, rĂ©sume les points clĂ©s au lieu de tout afficher - **Une chose Ă  la fois** — Sauf si les opĂ©rations sont indĂ©pendantes, exĂ©cute sĂ©quentiellement diff --git a/internal/api/chat_engine.go b/internal/api/chat_engine.go index 65d01f6..cc463df 100644 --- a/internal/api/chat_engine.go +++ b/internal/api/chat_engine.go @@ -92,7 +92,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator. assistantMsg := orchestrator.Message{ Role: "assistant", - Content: content, + Content: orchestrator.TextContent(content), ToolCalls: choice.Message.ToolCalls, } messages = append(messages, assistantMsg) @@ -147,7 +147,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator. messages = append(messages, orchestrator.Message{ Role: "tool", - Content: result.Content, + Content: orchestrator.TextContent(result.Content), ToolCallID: tc.ID, Name: tc.Function.Name, }) @@ -191,7 +191,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator. assistantMsg := orchestrator.Message{ Role: "assistant", - Content: content, + Content: orchestrator.TextContent(content), ToolCalls: choice.Message.ToolCalls, } messages = append(messages, assistantMsg) @@ -213,7 +213,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator. messages = append(messages, orchestrator.Message{ Role: "tool", - Content: result.Content, + Content: orchestrator.TextContent(result.Content), ToolCallID: tc.ID, Name: tc.Function.Name, }) diff --git a/internal/api/conversation.go b/internal/api/conversation.go index 195c846..9382870 100644 --- a/internal/api/conversation.go +++ b/internal/api/conversation.go @@ -13,22 +13,24 @@ import ( "github.com/muyue/muyue/internal/config" ) -const maxTokensApprox = 100000 -const summarizeThreshold = 80000 +const contextWindowTokens = 150000 +const summarizeRatio = 0.80 const charsPerToken = 4 type FeedMessage struct { - ID string `json:"id"` - Role string `json:"role"` - Content string `json:"content"` - Time string `json:"time"` + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Time string `json:"time"` + Images []string `json:"images,omitempty"` } type Conversation struct { - Messages []FeedMessage `json:"messages"` - Summary string `json:"summary,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Messages []FeedMessage `json:"messages"` + Summary string `json:"summary,omitempty"` + RealTokens int `json:"real_tokens,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type ConversationStore struct { @@ -85,6 +87,7 @@ func (cs *ConversationStore) load() { conv.Messages = []FeedMessage{} } cs.conv = &conv + cs.realTokens = conv.RealTokens } func (cs *ConversationStore) save() error { @@ -127,15 +130,40 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage { return msg } +func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage { + cs.mu.Lock() + defer cs.mu.Unlock() + + msg := FeedMessage{ + ID: generateMsgID(), + Role: role, + Content: content, + Time: time.Now().Format(time.RFC3339), + Images: imageIDs, + } + cs.conv.Messages = append(cs.conv.Messages, msg) + cs.save() + return msg +} + func (cs *ConversationStore) Clear() { cs.mu.Lock() defer cs.mu.Unlock() + + var imageIDs []string + for _, m := range cs.conv.Messages { + imageIDs = append(imageIDs, m.Images...) + } + cs.conv.Messages = []FeedMessage{} cs.conv.Summary = "" + cs.conv.RealTokens = 0 cs.conv.CreatedAt = time.Now().Format(time.RFC3339) cs.conv.UpdatedAt = time.Now().Format(time.RFC3339) cs.realTokens = 0 cs.save() + + go cleanupImages(imageIDs) } func (cs *ConversationStore) SetSummary(summary string) { @@ -169,6 +197,7 @@ func (cs *ConversationStore) AddRealTokens(tokens int) { } cs.mu.Lock() cs.realTokens += tokens + cs.conv.RealTokens = cs.realTokens cs.mu.Unlock() } @@ -196,7 +225,7 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount { } func (cs *ConversationStore) NeedsSummarization() bool { - return cs.ApproxTokenCount() > summarizeThreshold + return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio) } func (cs *ConversationStore) Search(query string) []SearchResult { diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index 673f3a3..b3b7f9f 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -1,11 +1,15 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" + "io" + "log" "net/http" "os" + "path/filepath" "regexp" "strings" "time" @@ -15,6 +19,114 @@ import ( ) var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) +var fileMentionRegex = regexp.MustCompile(`@(\S+\.[a-zA-Z0-9]+)`) + +type ImageAttachment struct { + Data string `json:"data"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` +} + +func resolveFileMentions(text string) string { + return fileMentionRegex.ReplaceAllStringFunc(text, func(match string) string { + filePath := match[1:] + if strings.HasPrefix(filePath, "~/") { + if home, err := os.UserHomeDir(); err == nil { + filePath = filepath.Join(home, filePath[2:]) + } + } + if !filepath.IsAbs(filePath) { + if home, err := os.UserHomeDir(); err == nil { + filePath = filepath.Join(home, filePath) + } + } + data, err := os.ReadFile(filePath) + if err != nil { + return match + fmt.Sprintf(" (erreur: fichier non trouve)") + } + content := string(data) + if len(content) > 50000 { + content = content[:50000] + "\n... (tronque a 50Ko)" + } + return fmt.Sprintf("[Fichier: %s]\n%s\n[Fin du fichier: %s]", filepath.Base(filePath), content, filepath.Base(filePath)) + }) +} + +var vlmClient = &http.Client{Timeout: 60 * time.Second} + +func (s *Server) describeImages(images []ImageAttachment) []string { + var apiKey string + for i := range s.config.AI.Providers { + if s.config.AI.Providers[i].Active { + apiKey = s.config.AI.Providers[i].APIKey + break + } + } + if apiKey == "" { + log.Printf("[vlm] no API key found for image description") + return nil + } + + descriptions := make([]string, 0, len(images)) + for i, img := range images { + desc, err := s.callVLM(apiKey, img) + if err != nil { + log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err) + descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err)) + } else { + descriptions = append(descriptions, desc) + } + } + return descriptions +} + +func (s *Server) callVLM(apiKey string, img ImageAttachment) (string, error) { + payload := map[string]string{ + "prompt": "Describe this image in detail. Include all text, UI elements, code, diagrams, or data visible. Be thorough and specific.", + "image_url": img.Data, + } + body, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("marshal vlm request: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.minimax.io/v1/coding_plan/vlm", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create vlm request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := vlmClient.Do(req) + if err != nil { + return "", fmt.Errorf("vlm request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read vlm response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("vlm API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Content string `json:"content"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("parse vlm response: %w", err) + } + + if result.Content == "" { + return "(empty description)", nil + } + return result.Content, nil +} func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { @@ -22,8 +134,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { return } var body struct { - Message string `json:"message"` - Stream bool `json:"stream"` + Message string `json:"message"` + Stream bool `json:"stream"` + Images []ImageAttachment `json:"images"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, err.Error(), http.StatusBadRequest) @@ -33,8 +146,44 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeError(w, "no message", http.StatusMethodNotAllowed) return } + if len(body.Images) > 3 { + writeError(w, "max 3 images", http.StatusBadRequest) + return + } - s.convStore.Add("user", body.Message) + enrichedMessage := resolveFileMentions(body.Message) + + var imageIDs []string + if len(body.Images) > 0 { + descriptions := s.describeImages(body.Images) + var imgContext strings.Builder + for i, desc := range descriptions { + imgContext.WriteString(fmt.Sprintf("\n[Image %d (%s): %s]\n", i+1, body.Images[i].Filename, desc)) + + id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType) + if err != nil { + log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err) + } else { + imageIDs = append(imageIDs, id) + } + } + enrichedMessage = imgContext.String() + enrichedMessage + } + + displayMsg := body.Message + if len(body.Images) > 0 { + imgNames := make([]string, len(body.Images)) + for i, img := range body.Images { + imgNames[i] = img.Filename + } + displayMsg += " [" + strings.Join(imgNames, ", ") + "]" + } + + if len(imageIDs) > 0 { + s.convStore.AddWithImages("user", displayMsg, imageIDs) + } else { + s.convStore.Add("user", displayMsg) + } if s.convStore.NeedsSummarization() { s.autoSummarize() @@ -48,17 +197,20 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { var studioPrompt strings.Builder studioPrompt.WriteString(agent.StudioSystemPrompt()) studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05"))) - studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", os.Geteuid() == 0)) - if os.Geteuid() != 0 { - studioPrompt.WriteString("⚠ Session utilisateur standard — les commandes sudo/doas nĂ©cessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n") + canSudo := !agent.NeedsSudoPassword() + studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo)) + if !canSudo { + studioPrompt.WriteString("⚠ Session sans sudo sans mot de passe — les commandes sudo/doas nĂ©cessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n") + } else { + studioPrompt.WriteString("⚠ Session avec privilĂšges sudo sans mot de passe — les commandes sudo s'exĂ©cuteront directement.\n") } orb.SetSystemPrompt(studioPrompt.String()) orb.SetTools(s.agentToolsJSON) if body.Stream { - s.handleStreamChat(w, orb, body.Message) + s.handleStreamChat(w, orb, enrichedMessage) } else { - s.handleNonStreamChat(w, orb, body.Message) + s.handleNonStreamChat(w, orb, enrichedMessage) } } @@ -146,7 +298,7 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message if summary != "" { messages = append(messages, orchestrator.Message{ Role: "system", - Content: "RĂ©sumĂ© de la conversation prĂ©cĂ©dente:\n" + summary, + Content: orchestrator.TextContent("RĂ©sumĂ© de la conversation prĂ©cĂ©dente:\n" + summary), }) } @@ -171,13 +323,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message } messages = append(messages, orchestrator.Message{ Role: role, - Content: content, + Content: orchestrator.TextContent(content), }) } messages = append(messages, orchestrator.Message{ Role: "user", - Content: userMessage, + Content: orchestrator.TextContent(userMessage), }) return messages @@ -225,8 +377,8 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{ "messages": messages, "tokens": s.convStore.ApproxTokenCount(), - "max_tokens": maxTokensApprox, - "summarize_at": summarizeThreshold, + "max_tokens": contextWindowTokens, + "summarize_at": int(float64(contextWindowTokens) * summarizeRatio), "summary": s.convStore.GetSummary(), }) } diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 33850c4..cf99f6b 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/scanner" @@ -24,7 +25,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { "name": version.Name, "version": version.Version, "author": version.Author, - "sudo": os.Geteuid() == 0, + "sudo": !agent.NeedsSudoPassword(), }) } diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index d1cd38a..bb736a0 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -83,12 +83,12 @@ func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string { sb.WriteString("User: " + user + "\n") } - isRoot := os.Geteuid() == 0 - sb.WriteString(fmt.Sprintf("Root: %t\n", isRoot)) - if isRoot { - sb.WriteString("⚠ Session en root — toutes les commandes ont les privilĂšges administrateur.\n") + canSudo := !agent.NeedsSudoPassword() + sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo)) + if canSudo { + sb.WriteString("⚠ Session avec privilĂšges sudo sans mot de passe — les commandes sudo s'exĂ©cuteront directement.\n") } else { - sb.WriteString("⚠ Session utilisateur standard — les commandes sudo/doas nĂ©cessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n") + sb.WriteString("⚠ Session sans sudo sans mot de passe — les commandes sudo/doas nĂ©cessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n") } now := time.Now() @@ -196,7 +196,7 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message { } messages = append(messages, orchestrator.Message{ Role: role, - Content: content, + Content: orchestrator.TextContent(content), }) } diff --git a/internal/api/image_cache.go b/internal/api/image_cache.go new file mode 100644 index 0000000..f5dd286 --- /dev/null +++ b/internal/api/image_cache.go @@ -0,0 +1,104 @@ +package api + +import ( + "encoding/base64" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/muyue/muyue/internal/config" +) + +var imageDir string + +func init() { + dir, err := config.ConfigDir() + if err != nil { + dir = "/tmp/muyue" + } + imageDir = filepath.Join(dir, "images") + os.MkdirAll(imageDir, 0755) +} + +var imageCounter uint64 + +func saveImage(dataURI, filename, mimeType string) (string, error) { + parts := strings.SplitN(dataURI, ",", 2) + if len(parts) != 2 { + return "", fmt.Errorf("invalid data URI") + } + encoded := parts[1] + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("base64 decode: %w", err) + } + + id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1)) + ext := ".png" + switch mimeType { + case "image/jpeg": + ext = ".jpg" + case "image/webp": + ext = ".webp" + } + + filePath := filepath.Join(imageDir, id+ext) + if err := os.WriteFile(filePath, decoded, 0600); err != nil { + return "", fmt.Errorf("write image: %w", err) + } + + return id + ext, nil +} + +func imagePath(id string) string { + return filepath.Join(imageDir, filepath.Base(id)) +} + +func cleanupImages(ids []string) { + for _, id := range ids { + p := imagePath(id) + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + log.Printf("[images] failed to delete %s: %v", id, err) + } + } +} + +func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/images/") + if id == "" { + writeError(w, "image id required", http.StatusBadRequest) + return + } + + filePath := imagePath(id) + if _, err := os.Stat(filePath); err != nil { + writeError(w, "image not found", http.StatusNotFound) + return + } + + ext := strings.ToLower(filepath.Ext(id)) + switch ext { + case ".jpg", ".jpeg": + w.Header().Set("Content-Type", "image/jpeg") + case ".png": + w.Header().Set("Content-Type", "image/png") + case ".webp": + w.Header().Set("Content-Type", "image/webp") + default: + w.Header().Set("Content-Type", "application/octet-stream") + } + w.Header().Set("Cache-Control", "public, max-age=86400") + + http.ServeFile(w, r, filePath) +} diff --git a/internal/api/server.go b/internal/api/server.go index 1b37478..8ead886 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -96,6 +96,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme) s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider) s.mux.HandleFunc("/api/update/run", s.handleRunUpdate) + s.mux.HandleFunc("/api/images/", s.handleServeImage) s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/clear", s.handleChatClear) @@ -140,7 +141,7 @@ func (s *Server) routes() { } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/ws/") { + if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") { s.mux.ServeHTTP(w, r) return } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 235c41e..57e95ae 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -20,14 +20,42 @@ var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) const maxHistorySize = 100 +type ContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` +} + +type ImageURL struct { + URL string `json:"url"` +} + type Message struct { Role string `json:"role"` - Content string `json:"content,omitempty"` + Content json.RawMessage `json:"content,omitempty"` ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` Name string `json:"name,omitempty"` } +func TextContent(s string) json.RawMessage { + b, _ := json.Marshal(s) + return b +} + +func PartsContent(parts []ContentPart) json.RawMessage { + b, _ := json.Marshal(parts) + return b +} + +func (m Message) ContentString() string { + var s string + if json.Unmarshal(m.Content, &s) == nil { + return s + } + return string(m.Content) +} + type ToolCallMsg struct { ID string `json:"id"` Type string `json:"type"` @@ -143,7 +171,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { o.histMu.Lock() o.history = append(o.history, Message{ Role: "user", - Content: userMessage, + Content: TextContent(userMessage), }) if len(o.history) > maxHistorySize { @@ -152,7 +180,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { messages := make([]Message, 0, len(o.history)+1) if o.systemPrompt != "" { - messages = append(messages, Message{Role: "system", Content: o.systemPrompt}) + messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)}) } messages = append(messages, o.history...) @@ -173,7 +201,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { o.histMu.Lock() o.history = append(o.history, Message{ Role: "assistant", - Content: content, + Content: TextContent(content), }) _ = providerName o.histMu.Unlock() @@ -185,7 +213,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str o.histMu.Lock() o.history = append(o.history, Message{ Role: "user", - Content: userMessage, + Content: TextContent(userMessage), }) if len(o.history) > maxHistorySize { @@ -194,7 +222,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str messages := make([]Message, 0, len(o.history)+1) if o.systemPrompt != "" { - messages = append(messages, Message{Role: "system", Content: o.systemPrompt}) + messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)}) } messages = append(messages, o.history...) @@ -273,7 +301,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str o.histMu.Lock() o.history = append(o.history, Message{ Role: "assistant", - Content: content, + Content: TextContent(content), }) o.histMu.Unlock() @@ -283,7 +311,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) { fullMessages := make([]Message, 0, len(messages)+1) if o.systemPrompt != "" { - fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt}) + fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)}) } fullMessages = append(fullMessages, messages...) @@ -314,7 +342,7 @@ type ChunkCallback func(content string, toolCalls []ToolCallMsg) func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) { fullMessages := make([]Message, 0, len(messages)+1) if o.systemPrompt != "" { - fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt}) + fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)}) } fullMessages = append(fullMessages, messages...) diff --git a/internal/workflow/planner.go b/internal/workflow/planner.go index 3beeba0..1794879 100644 --- a/internal/workflow/planner.go +++ b/internal/workflow/planner.go @@ -27,7 +27,7 @@ func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error) prompt := buildPlanPrompt(goal) messages := []orchestrator.Message{ - {Role: "user", Content: prompt}, + {Role: "user", Content: orchestrator.TextContent(prompt)}, } resp, err := p.orchestrator.SendWithTools(messages) diff --git a/web/src/api/client.js b/web/src/api/client.js index 0c1defa..f67b839 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -62,15 +62,15 @@ const api = { clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }), analyzeSystem: () => request('/shell/analyze', { method: 'POST' }), getShellAnalysis: () => request('/shell/analysis'), - sendChat: (message, stream = true, onChunk, signal) => { + sendChat: (message, stream = true, onChunk, signal, images = []) => { if (!stream) { - return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) + return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false, images }) }) } return new Promise((resolve, reject) => { fetch(`${API_BASE}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message, stream: true }), + body: JSON.stringify({ message, stream: true, images }), signal, }).then(async (res) => { if (!res.ok) { diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 6d707b4..13925e6 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -152,7 +152,7 @@ export default function App() {
- {isSudo && ⚡ ROOT} + {isSudo && ⚡ SUDO} {activeTab === 'dash' && ( {layout.keys.ctrl}+R refresh diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 3b6f346..6baff83 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -70,14 +70,15 @@ function formatText(text) { .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') - .replace(/^\s*[-*] (.+)$/gm, '
‱ $1
') + .replace(/^---+$/gm, '
') + .replace(/^\s*[-*] (.+)$/gm, '
\u2022 $1
') .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') .replace(/\n/g, '
') html = html .replace(/\s*/g, '
') - .replace(/\s*(|<\/table>|
)\s*/g, '$1') .replace(/\s+on\w+=["'][^"']*["']/gi, '') .replace(/javascript:/gi, '') .replace(/data:/gi, '') @@ -470,6 +471,7 @@ export default function Shell({ api }) { const aiMessagesRef = useRef(null) const aiLoadedRef = useRef(false) const aiLoadingRef = useRef(false) + const analysisSavingRef = useRef(false) useEffect(() => { aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) @@ -482,6 +484,8 @@ export default function Shell({ api }) { const stored = localStorage.getItem('shell_analysis') if (stored) setAnalysisContent(stored) }) + const stored = localStorage.getItem('shell_analysis') + if (stored && !analysisContent) setAnalysisContent(stored) }, []) useEffect(() => { @@ -1138,6 +1142,14 @@ export default function Shell({ api }) { const filtered = prev.filter(m => !m._streaming) return [...filtered, finalMsg] }) + + if (analysisSavingRef.current && accumulated) { + analysisSavingRef.current = false + setAnalysisContent(accumulated) + try { localStorage.setItem('shell_analysis', accumulated) } catch {} + setAnalyzing(false) + } + api.getShellChatHistory().then(d => { setAiTokens(d.tokens || 0) setAiAtLimit(d.at_limit || false) @@ -1147,6 +1159,10 @@ export default function Shell({ api }) { setAiAtLimit(true) } setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) + if (analysisSavingRef.current) { + analysisSavingRef.current = false + setAnalyzing(false) + } } setAiLoading(false) aiLoadingRef.current = false @@ -1165,7 +1181,25 @@ export default function Shell({ api }) { return () => window.removeEventListener('ask-ai-terminal', handler) }, [_sendAiMessage]) - const handleAnalyze = () => { + const handleClearChat = async () => { + try { + await api.clearShellChat() + setAiMessages([]) + setAiTokens(0) + setAiAtLimit(false) + } catch {} + } + + const handleAnalyze = async () => { + if (analyzing) return + setAnalyzing(true) + try { + await api.clearShellChat() + setAiMessages([]) + setAiTokens(0) + setAiAtLimit(false) + } catch {} + analysisSavingRef.current = true _sendAiMessage(`Fais une analyse complĂšte du systĂšme. Utilise l'outil terminal pour explorer et rĂ©dige un rapport structurĂ© en markdown. Couvre: 1. **OS & MatĂ©riel** — distrib, kernel, CPU, RAM, GPU, hostname @@ -1359,44 +1393,49 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L {aiLoading &&
}
- setAiInput(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend(); return } - if (e.key === 'Tab') { - e.preventDefault() - const val = aiInput - const pos = e.target.selectionStart - const before = val.slice(0, pos) - const afterSlash = before.match(/\/[\w ]*$/) - if (afterSlash) { - const partial = afterSlash[0] - const matches = SHELL_AI_COMMANDS.filter(c => c.startsWith(partial) && c !== partial) - if (matches.length >= 1) { - let completed = matches[0] - for (const m of matches) { - while (!m.startsWith(completed)) completed = completed.slice(0, -1) - } - if (completed === partial && matches.length === 1) completed = matches[0] - if (completed.length > partial.length) { - const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '') - completed += suffix - const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos) - setAiInput(newText) - requestAnimationFrame(() => { - e.target.selectionStart = e.target.selectionEnd = pos - afterSlash[0].length + completed.length - }) + {aiAtLimit ? ( + + ) : ( + <> + setAiInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend(); return } + if (e.key === 'Tab') { + e.preventDefault() + const val = aiInput + const pos = e.target.selectionStart + const before = val.slice(0, pos) + const afterSlash = before.match(/\/[\w ]*$/) + if (afterSlash) { + const partial = afterSlash[0] + const matches = SHELL_AI_COMMANDS.filter(c => c.startsWith(partial) && c !== partial) + if (matches.length >= 1) { + let completed = matches[0] + for (const m of matches) { + while (!m.startsWith(completed)) completed = completed.slice(0, -1) + } + if (completed === partial && matches.length === 1) completed = matches[0] + if (completed.length > partial.length) { + const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '') + completed += suffix + const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos) + setAiInput(newText) + requestAnimationFrame(() => { + e.target.selectionStart = e.target.selectionEnd = pos - afterSlash[0].length + completed.length + }) + } + } } + return } - } - return - } - }} - placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')} - disabled={aiAtLimit && aiInput !== '/clear'} - /> - + }} + placeholder={t('shell.askAi')} + /> + + + )}
diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 9369c30..ddb7f5a 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -83,14 +83,15 @@ function formatText(text) { .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') - .replace(/^\s*[-*] (.+)$/gm, '
‱ $1
') + .replace(/^---+$/gm, '
') + .replace(/^\s*[-*] (.+)$/gm, '
\u2022 $1
') .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') .replace(/\n/g, '
') html = html .replace(/\s*/g, '
') - .replace(/\s*(|<\/table>|
)\s*/g, '$1') .replace(/\s+on\w+=["'][^"']*["']/gi, '') .replace(/javascript:/gi, '') .replace(/data:/gi, '') @@ -284,6 +285,13 @@ function FeedItem({ msg }) { {timeStr && {timeStr}}
{msg.thinking && } + {msg.images && msg.images.length > 0 && ( +
+ {msg.images.map((imgId, i) => ( + {`Image + ))} +
+ )} {parsedToolCalls && parsedToolCalls.map((tc, i) => { const resultData = parsedToolResults ? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id) @@ -372,14 +380,16 @@ export default function Studio({ api }) { const [streamThinking, setStreamThinking] = useState('') const [streamToolCalls, setStreamToolCalls] = useState([]) const [loaded, setLoaded] = useState(false) - const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 }) + const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 }) const [contextCollapsed, setContextCollapsed] = useState(false) const [messagesCollapsed, setMessagesCollapsed] = useState(false) const [sudoModal, setSudoModal] = useState(null) + const [attachedImages, setAttachedImages] = useState([]) const messagesEnd = useRef(null) const feedRef = useRef(null) const textareaRef = useRef(null) const abortRef = useRef(null) + const fileInputRef = useRef(null) useEffect(() => { api.getChatHistory().then(data => { @@ -392,8 +402,8 @@ export default function Studio({ api }) { } setTokenInfo({ used: data.tokens || 0, - max: data.max_tokens || 100000, - summarizeAt: data.summarize_at || 80000, + max: data.max_tokens || 150000, + summarizeAt: data.summarize_at || 120000, }) setLoaded(true) }).catch(() => { @@ -434,8 +444,8 @@ export default function Studio({ api }) { const data = await api.getChatHistory() setTokenInfo({ used: data.tokens || 0, - max: data.max_tokens || 100000, - summarizeAt: data.summarize_at || 80000, + max: data.max_tokens || 150000, + summarizeAt: data.summarize_at || 120000, }) } catch {} }, [api]) @@ -466,10 +476,36 @@ export default function Studio({ api }) { } catch {} }, [api, t]) + const handleImageSelect = useCallback((e) => { + const files = Array.from(e.target.files || []) + if (files.length === 0) return + const remaining = 3 - attachedImages.length + const toProcess = files.slice(0, remaining) + toProcess.forEach(file => { + if (!file.type.match(/^image\/(jpeg|jpg|png|webp)$/)) return + if (file.size > 50 * 1024 * 1024) return + const reader = new FileReader() + reader.onload = (ev) => { + setAttachedImages(prev => { + if (prev.length >= 3) return prev + return [...prev, { data: ev.target.result, filename: file.name, mime_type: file.type }] + }) + } + reader.readAsDataURL(file) + }) + e.target.value = '' + }, [attachedImages.length]) + + const removeImage = useCallback((index) => { + setAttachedImages(prev => prev.filter((_, i) => i !== index)) + }, []) + const handleSend = useCallback(async () => { if (!input.trim() || loading) return const text = input.trim() + const images = [...attachedImages] setInput('') + setAttachedImages([]) const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t) @@ -580,7 +616,7 @@ export default function Studio({ api }) { } accumulated = partial setStreaming(partial) - }, controller.signal) + }, controller.signal, images) const finalContent = accumulated || t('studio.noResponse') const aiMsg = { @@ -628,7 +664,7 @@ export default function Studio({ api }) { abortRef.current = null refreshTokens() } - }, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize]) + }, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize, attachedImages]) const handleStop = useCallback(() => { if (abortRef.current) { @@ -732,6 +768,16 @@ export default function Studio({ api }) {
+ {attachedImages.length > 0 && ( +
+ {attachedImages.map((img, i) => ( +
+ {img.filename} + +
+ ))} +
+ )}
+ +