feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Beta Release / beta (push) Failing after 33s

- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-27 00:01:22 +02:00
parent e8a289ccf3
commit d2bb42b212
23 changed files with 1028 additions and 556 deletions

View File

@@ -36,6 +36,8 @@ const api = {
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
deploySkill: (name) => request('/skills/deploy', { method: 'POST', body: JSON.stringify({ name }) }),
undeploySkill: (name) => request('/skills/undeploy', { method: 'POST', body: JSON.stringify({ name }) }),
getDashboardStatus: () => request('/dashboard/status'),
getProvidersQuota: () => request('/providers/quota'),
getProvidersConsumption: () => request('/providers/consumption'),

View File

@@ -16,8 +16,6 @@ export default function App() {
const [isSudo, setIsSudo] = useState(false)
const [dashRefreshKey, setDashRefreshKey] = useState(0)
const dashRefreshRef = useRef(null)
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null)
const [showOnboarding, setShowOnboarding] = useState(false)
const { t, layout } = useI18n()
@@ -31,8 +29,6 @@ export default function App() {
useEffect(() => {
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getConfig().then(d => {
setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
@@ -82,9 +78,6 @@ export default function App() {
return () => window.removeEventListener('navigate-to-shell', handler)
}, [])
const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(tool => tool.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [],
studio: [
@@ -127,17 +120,6 @@ export default function App() {
<div className="header-spacer" />
<div className="header-indicators">
<span
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
title={t('header.toolsInstalled', { count: installed })}
/>
<span
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
/>
</div>
<span className="header-clock">
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
</span>

View File

@@ -1,11 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
import { User, Brain, Wrench, Monitor, AlertTriangle, Bot, Sparkles, Zap, GitBranch, Container, Circle, Hexagon, Code, Rocket, Download } from 'lucide-react'
import { useI18n } from '../i18n'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw },
{ id: 'skills', icon: Wrench },
{ id: 'system', icon: Monitor },
]
@@ -16,10 +15,7 @@ export default function Config({ api }) {
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(null)
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
@@ -34,8 +30,6 @@ export default function Config({ api }) {
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
}, [api])
@@ -46,83 +40,6 @@ export default function Config({ api }) {
setTimeout(() => setToast(null), 2500)
}
const handleCheckUpdates = async () => {
setChecking(true)
try {
const d = await api.aiTask('check_tools')
const result = d.result
if (result && result.tools) {
const aiTools = result.tools
const newUpdates = aiTools.filter(t => t.installed).map(t => ({
tool: t.name,
current: t.version || '',
latest: t.latest || '',
needsUpdate: t.needs_update || false,
error: t.error || '',
}))
const newTools = aiTools.map(t => ({
name: t.name,
installed: t.installed,
version: t.version || '',
category: t.category || '',
}))
setUpdates(newUpdates)
setTools(newTools)
showToast(t('config.upToDate'))
} else {
showToast(t('config.error'))
}
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setChecking(false)
}
const handleUpdateTool = async (tool) => {
setUpdating(tool)
try {
const d = await api.aiTask('update_tool', tool)
if (d.result && d.result.updated) {
showToast(`${tool} ${t('config.updated') || 'mis à jour'}`)
} else {
showToast(d.result?.error || d.result?.message || t('config.error'))
}
handleCheckUpdates()
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleInstallTool = async (tool) => {
setUpdating(`install-${tool}`)
try {
const d = await api.aiTask('install_tool', tool)
if (d.result && d.result.installed) {
showToast(`${tool} ${t('config.installed') || 'installé'}`)
} else {
showToast(d.result?.error || d.result?.message || t('config.error'))
}
handleCheckUpdates()
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = async () => {
const toUpdate = updates.filter(u => u.needsUpdate)
setUpdating('__all__')
for (const u of toUpdate) {
try {
await api.aiTask('update_tool', u.tool)
} catch (err) {
console.error(`Failed to update ${u.tool}:`, err)
}
}
setUpdating(null)
handleCheckUpdates()
}
const handleSaveProfile = async () => {
try {
@@ -161,9 +78,7 @@ export default function Config({ api }) {
setEditProvider(p.name)
}
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
const installedCount = tools.filter(tool => tool.installed).length
const missingCount = tools.filter(tool => !tool.installed).length
return (
<div className="config-window">
@@ -204,21 +119,8 @@ export default function Config({ api }) {
t={t}
/>
)}
{activePanel === 'updates' && (
<PanelUpdates
updates={updates} tools={tools}
checking={checking} updating={updating}
needsUpdateCount={needsUpdateCount}
installedCount={installedCount} missingCount={missingCount}
handleCheckUpdates={handleCheckUpdates}
handleUpdateTool={handleUpdateTool}
handleInstallTool={handleInstallTool}
handleUpdateAll={handleUpdateAll}
t={t}
/>
)}
{activePanel === 'skills' && (
<PanelSkills skillList={skillList} t={t} />
<PanelSkills skillList={skillList} api={api} loadData={loadData} t={t} />
)}
{activePanel === 'system' && (
<PanelSystem api={api} t={t} />
@@ -459,176 +361,80 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
)
}
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, handleUpdateAll, t }) {
function PanelSkills({ skillList, api, loadData, t }) {
const [deploying, setDeploying] = useState(null)
const missingTools = tools.filter(tool => !tool.installed)
const handleDeploy = async (name) => {
setDeploying(name + '-deploy')
try {
await api.deploySkill(name)
loadData()
} catch (err) {
console.error('deploy skill:', err)
}
setDeploying(null)
}
return (
<>
<div className="config-card">
<div className="config-update-controls">
<div className="config-update-stats">
<span className="badge ok">{installedCount} {t('config.installed')}</span>
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
</div>
<div className="config-update-buttons">
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
</button>
{needsUpdateCount > 0 && (
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
</button>
)}
</div>
</div>
</div>
{missingTools.length > 0 && (
<>
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
<div className="config-update-list">
{missingTools.map((tool, i) => (
<div key={`miss-${i}`} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{tool.name}</span>
<span className="config-update-versions">
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
</span>
</div>
<button
className="sm primary"
onClick={() => handleInstallTool(tool.name)}
>
{t('config.install') || 'Installer'}
</button>
</div>
))}
</div>
</>
)}
{updates.length === 0 ? (
<div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div>
</div>
) : (
<div className="config-update-list">
{updates.map((u, i) => (
<div key={i} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{u.tool}</span>
<span className="config-update-versions">
{u.needsUpdate ? (
<>{u.current} <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
) : (
<span style={{ color: 'var(--success)' }}>{u.current}</span>
)}
</span>
</div>
{u.needsUpdate && (
<button
className="sm"
onClick={() => handleUpdateTool(u.tool)}
disabled={updating === u.tool}
>
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
</button>
)}
</div>
))}
</div>
)}
</>
)
}
function PanelSkills({ skillList, t }) {
const [selected, setSelected] = useState(null)
const handleUndeploy = async (name) => {
setDeploying(name + '-undeploy')
try {
await api.undeploySkill(name)
loadData()
} catch (err) {
console.error('undeploy skill:', err)
}
setDeploying(null)
}
if (skillList.length === 0) {
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
}
return (
<>
<div className="skill-tiles">
{skillList.map((s, i) => (
<div key={i} className="skill-tile" onClick={() => setSelected(s)}>
<div className="skill-tile-name">{s.name}</div>
<div className="skill-tile-desc">{s.description}</div>
<div className="skill-tile-tags">
{s.target && <span className="badge neutral">{s.target}</span>}
{s.version && <span className="badge">{s.version}</span>}
{s.category && <span className="badge" style={{ opacity: 0.7 }}>{s.category}</span>}
<div className="skills-list">
{skillList.map((s, i) => (
<div key={i} className="config-update-row" style={{ alignItems: 'center' }}>
<div className="skill-list-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="config-update-name">{s.name}</span>
{s.deployed ? (
<span className="badge ok">{t('config.installed')}</span>
) : (
<span className="badge neutral">{t('config.notInstalled')}</span>
)}
</div>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>{s.description}</div>
</div>
))}
</div>
{selected && (
<div className="skill-detail-overlay" onClick={() => setSelected(null)}>
<div className="skill-detail-panel" onClick={e => e.stopPropagation()}>
<div className="skill-detail-header">
<span className="skill-detail-name">{selected.name}</span>
<button className="ghost sm" onClick={() => setSelected(null)}></button>
</div>
<div className="skill-detail-body">
<div className="skill-detail-section">
<div className="skill-detail-label">Description</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{selected.description}</div>
</div>
<div className="skill-detail-section">
<div className="skill-detail-label">Métadonnées</div>
<div className="skill-detail-meta">
{selected.target && <span className="badge neutral">{selected.target}</span>}
{selected.version && <span className="badge">{selected.version}</span>}
{selected.category && <span className="badge">{selected.category}</span>}
{selected.author && <span className="badge ghost">{selected.author}</span>}
{selected.languages && selected.languages.map(l => <span key={l} className="badge ghost">{l}</span>)}
</div>
</div>
{selected.tags && selected.tags.length > 0 && (
<div className="skill-detail-section">
<div className="skill-detail-label">Tags</div>
<div className="chip-row">
{selected.tags.map(tag => <span key={tag} className="badge">{tag}</span>)}
</div>
</div>
)}
{selected.content && (
<div className="skill-detail-section">
<div className="skill-detail-label">Contenu</div>
<div className="skill-detail-content">{selected.content}</div>
</div>
)}
{selected.dependencies && selected.dependencies.length > 0 && (
<div className="skill-detail-section">
<div className="skill-detail-label">Dépendances</div>
<div className="skill-detail-deps">
{selected.dependencies.map((d, i) => (
<div key={i} className="skill-detail-dep">
<span className="badge">{d.type}</span>
<span>{d.name}</span>
{d.required === false && <span style={{ fontSize: 11, color: 'var(--text-disabled)' }}>optionnel</span>}
</div>
))}
</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
className="sm primary"
disabled={s.deployed || deploying === s.name + '-deploy'}
onClick={() => handleDeploy(s.name)}
>
{deploying === s.name + '-deploy' ? '...' : t('config.apply')}
</button>
<button
className="sm ghost"
disabled={!s.deployed || deploying === s.name + '-undeploy'}
onClick={() => handleUndeploy(s.name)}
>
{deploying === s.name + '-undeploy' ? '...' : t('config.remove')}
</button>
</div>
</div>
)}
</>
))}
</div>
)
}
function PanelSystem({ api, t }) {
const [showResetModal, setShowResetModal] = useState(false)
const [toast, setToast] = useState(null)
const [isSudo, setIsSudo] = useState(false)
useEffect(() => {
api.getInfo().then(d => setIsSudo(!!d.sudo)).catch(() => {})
}, [api])
const showToast = (msg) => {
setToast(msg)
@@ -646,26 +452,123 @@ function PanelSystem({ api, t }) {
}
}
const handleApplyStarship = () => {
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
const handleSystemUpdate = () => {
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
if (isSudo) {
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Mets à jour le système et tous les outils utilisés par l'application Muyue. Exécute les commandes suivantes dans l'ordre :\n1. Met à jour les paquets système : sudo apt update && sudo apt upgrade -y\n2. Installe les dépendances utiles si manquantes : sudo apt install -y sshpass git curl wget\n3. Mets à jour les outils installés : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n4. Pour chaque outil, vérifie la version actuelle, mets à jour si possible, puis vérifie la nouvelle version\n5. Donne un récapitulatif final de tout ce qui a été mis à jour ou installé` } }))
} else {
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Je n'ai pas les droits sudo sur ce système. Donne-moi les commandes nécessaires pour mettre à jour le système et les outils suivants. Pour chaque outil, indique la commande exacte à exécuter :\n1. Paquets système (apt update && apt upgrade)\n2. Outils à mettre à jour : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n3. Dépendances utiles à installer : sshpass, git, curl, wget\n4. Présente les commandes dans un tableau markdown avec le nom de l'outil, la commande, et si sudo est requis` } }))
}
}
const configureTool = (tool) => {
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: tool.prompt } }))
}
const AI_TOOLS = [
{
id: 'crush',
name: 'Crush',
icon: 'Zap',
description: t('config.toolCrushDesc'),
prompt: `Configure l'outil Crush sur ce système. Vérifie d'abord s'il est installé avec "crush --version". S'il n'est pas installé, installe-le avec la méthode appropriée (npm install -g @anthropic/crush ou via le script officiel). S'il est déjà installé, vérifie sa configuration dans ~/.config/crush/ et affiche son état. Demande-moi les informations nécessaires si besoin (clés API, préférences, etc.).`,
},
{
id: 'claude',
name: 'Claude Code',
icon: 'Bot',
description: t('config.toolClaudeDesc'),
prompt: `Configure l'outil Claude Code (claude) sur ce système. Vérifie d'abord s'il est installé avec "claude --version". S'il n'est pas installé, installe-le avec npm install -g @anthropic-ai/claude-code. S'il est installé, vérifie sa configuration et son authentification. Demande-moi les informations nécessaires si besoin (clé API Anthropic, etc.).`,
},
{
id: 'gh',
name: 'GitHub CLI',
icon: 'GitBranch',
description: t('config.toolGhDesc'),
prompt: `Configure l'outil GitHub CLI (gh) sur ce système. Vérifie d'abord s'il est installé avec "gh --version". S'il n'est pas installé, installe-le avec la méthode appropriée pour ce système. S'il est installé, vérifie son authentification avec "gh auth status". Si non authentifié, guide-moi pour le configurer avec "gh auth login". Demande-moi le token si nécessaire.`,
},
{
id: 'docker',
name: 'Docker',
icon: 'Container',
description: t('config.toolDockerDesc'),
prompt: `Configure Docker sur ce système. Vérifie d'abord s'il est installé avec "docker --version". Vérifie aussi si le daemon tourne avec "docker info". S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que l'utilisateur est dans le groupe docker. Si des problèmes de permissions existent, explique comment les résoudre.`,
},
{
id: 'go',
name: 'Go',
icon: 'Circle',
description: t('config.toolGoDesc'),
prompt: `Configure l'environnement Go sur ce système. Vérifie s'il est installé avec "go version". Vérifie le GOPATH, GOROOT et les variables d'environnement. S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que les binaires Go sont dans le PATH.`,
},
{
id: 'node',
name: 'Node.js',
icon: 'Hexagon',
description: t('config.toolNodeDesc'),
prompt: `Configure l'environnement Node.js sur ce système. Vérifie s'il est installé avec "node --version" et "npm --version". Vérifie aussi pnpm et npx. S'il n'est pas installé, installe-le avec la méthode recommandée (nvm, fnm ou le gestionnaire de paquets). Vérifie la version LTS vs Current.`,
},
{
id: 'python',
name: 'Python',
icon: 'Code',
description: t('config.toolPythonDesc'),
prompt: `Configure l'environnement Python sur ce système. Vérifie python3 --version, pip3 --version, et uv --version. S'ils ne sont pas installés, installe-les avec la méthode appropriée. Vérifie les paquets essentiels (venv, pip). Configure uv si nécessaire.`,
},
{
id: 'starship',
name: 'Starship',
icon: 'Rocket',
description: t('config.toolStarshipDesc'),
prompt: `Configure Starship (prompt shell) sur ce système. Vérifie s'il est installé avec "starship --version". S'il n'est pas installé, installe-le. Ensuite, configure le thème "charm" dans ~/.config/starship.toml. Assure-toi que starship est initialisé dans le shell de l'utilisateur (.bashrc, .zshrc ou config fish).`,
},
]
const ICON_MAP = { Zap, Bot, GitBranch, Container, Circle, Hexagon, Code, Rocket }
return (
<>
{toast && <div className="config-toast">{toast}</div>}
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
<div className="config-card">
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
<div className="section-title" style={{ marginBottom: 8 }}>{t('config.systemConfig')}</div>
<div className="section-title" style={{ marginTop: 4, marginBottom: 8, fontSize: 12, color: 'var(--text-tertiary)', textTransform: 'none', letterSpacing: 0 }}>
<Bot size={13} style={{ verticalAlign: 'middle', marginRight: 6 }} />
{t('config.aiToolsConfig')}
</div>
<div className="config-ai-tools-grid">
{AI_TOOLS.map(tool => {
const Icon = ICON_MAP[tool.icon] || Bot
return (
<div key={tool.id} className="config-ai-tool-card">
<div className="config-ai-tool-header">
<span className="config-ai-tool-icon"><Icon size={16} /></span>
<span className="config-ai-tool-name">{tool.name}</span>
</div>
<div className="config-ai-tool-desc">{tool.description}</div>
<button className="sm primary" onClick={() => configureTool(tool)} style={{ marginTop: 'auto' }}>
<Sparkles size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
{t('config.configureViaAI')}
</button>
</div>
)
})}
</div>
<div className="config-card" style={{ marginTop: 12, marginBottom: 4 }}>
<div className="config-card-row" style={{ alignItems: 'center' }}>
<div>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.systemUpdate')}</span>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
{isSudo ? t('config.systemUpdateDescSudo') : t('config.systemUpdateDescNoSudo')}
</div>
</div>
<button className="sm primary" onClick={handleSystemUpdate}>
<Download size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
{t('config.updateBtn')}
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
Vérifie l'installation de starship et configure le thème charm via l'IA.
</div>
<button className="sm primary" onClick={handleApplyStarship}>
{t('config.applyStarship')}
</button>
</div>
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo, memo, Fragment } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
@@ -409,6 +409,7 @@ export default function Shell({ api, isSudo }) {
})
const activeTabRef = useRef(activeTab)
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
const tabIdsKey = useMemo(() => tabs.map(t => t.id).join(','), [tabs])
const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false)
@@ -474,8 +475,22 @@ export default function Shell({ api, isSudo }) {
const aiLoadedRef = useRef(false)
const aiLoadingRef = useRef(false)
const analysisSavingRef = useRef(false)
const _streamRafRef = useRef(null)
const _streamPendingRef = useRef(null)
const _flushStreamUpdate = useCallback(() => {
_streamRafRef.current = null
const pending = _streamPendingRef.current
if (!pending) return
_streamPendingRef.current = null
setAiMessages(pending)
}, [])
useEffect(() => {
if (_streamRafRef.current) {
cancelAnimationFrame(_streamRafRef.current)
_streamRafRef.current = null
}
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
@@ -760,7 +775,7 @@ export default function Shell({ api, isSudo }) {
pending.forEach(clearTimeout)
observer?.disconnect()
}
}, [tabs, initTerminal, initPendingTabs, configLoaded])
}, [tabIdsKey, initTerminal, initPendingTabs, configLoaded])
useEffect(() => {
const entry = tabsRef.current[activeTab]
@@ -778,12 +793,18 @@ export default function Shell({ api, isSudo }) {
const wrapper = document.querySelector('.shell-layout')?.parentElement
if (wrapper && wrapper.classList.contains('tab-hidden')) return
const entry = tabsRef.current[activeTabRef.current]
if (entry) {
entry.fitAddon.fit()
if (entry && entry.fitAddon && entry.term) {
const container = document.getElementById(`terminal-${activeTabRef.current}`)
if (!container) return
const rect = container.getBoundingClientRect()
const dims = entry.fitAddon.proposeDimensions()
if (dims && entry.term.cols !== dims.cols || entry.term.rows !== dims.rows) {
entry.fitAddon.fit()
}
}
}, 2000)
return () => clearInterval(iv)
}, [tabs])
}, [tabIdsKey])
useEffect(() => {
return () => {
@@ -813,25 +834,26 @@ export default function Shell({ api, isSudo }) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
const currentTabs = tabsRef.current._tabList || []
if (e.key === 'Tab' && e.shiftKey) {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
const idx = tabs.findIndex(t => t.id === activeTab)
const next = (idx + 1) % tabs.length
setActiveTab(tabs[next].id)
const idx = currentTabs.findIndex(t => t.id === activeTabRef.current)
const next = (idx + 1) % currentTabs.length
setActiveTab(currentTabs[next].id)
return
}
const num = parseInt(e.key)
if (num >= 1 && num <= tabs.length) {
if (num >= 1 && num <= currentTabs.length) {
e.preventDefault()
setActiveTab(tabs[num - 1].id)
setActiveTab(currentTabs[num - 1].id)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [tabs])
}, [])
useEffect(() => {
if (showSearch && searchInputRef.current) {
@@ -1103,16 +1125,31 @@ export default function Shell({ api, isSudo }) {
setAiLoading(true)
try {
let accumulated = ''
let toolCalls = []
let segments = []
let textStartIdx = 0
const controller = new AbortController()
const _updateLastText = (text) => {
if (!text) return
const last = segments.length > 0 ? segments[segments.length - 1] : null
if (last && last.type === 'text') {
last.content = text
} else {
segments.push({ type: 'text', content: text })
}
}
await api.sendShellChat(trimmed, {}, true, (partial, event) => {
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
_updateLastText(partial.slice(textStartIdx))
textStartIdx = partial.length
segments.push({ type: 'tool', call: event.tool_call, result: null })
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
_streamPendingRef.current = null
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
})
return
}
@@ -1120,12 +1157,15 @@ export default function Shell({ api, isSudo }) {
if (event.tool_result.sudo_blocked) {
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
}
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
if (idx >= 0) {
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
if (segIdx >= 0) {
segments[segIdx].result = event.tool_result
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
_streamPendingRef.current = null
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
})
}
return
@@ -1133,23 +1173,37 @@ export default function Shell({ api, isSudo }) {
if (event && (event.thinking !== undefined || event.thinking_end)) {
return
}
accumulated = partial
setAiMessages(prev => {
_updateLastText(partial.slice(textStartIdx))
const nextMsgs = prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }]
})
const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) }))
return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }]
}
_streamPendingRef.current = nextMsgs
if (!_streamRafRef.current) {
_streamRafRef.current = requestAnimationFrame(_flushStreamUpdate)
}
}, controller.signal)
const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab }
if (toolCalls.length > 0) {
finalMsg._toolCalls = toolCalls
if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null }
_streamPendingRef.current = null
const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
const toolSegs = segments.filter(s => s.type === 'tool')
const finalMsg = { role: 'assistant', content: allText, _tabId: currentTab }
if (toolSegs.length > 0 || segments.length > 1) {
finalMsg.content = JSON.stringify({
content: accumulated,
tool_calls: toolCalls.map(tc => tc.call),
tool_results: toolCalls.map(tc => ({
tool_call_id: tc.call?.tool_call_id,
result: tc.result?.content || '',
is_error: tc.result?.is_error || false,
segments: segments.map(s => s.type === 'text'
? { type: 'text', content: s.content }
: { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } }
),
content: allText,
tool_calls: toolSegs.map(s => s.call),
tool_results: toolSegs.map(s => ({
tool_call_id: s.call?.tool_call_id,
result: s.result?.content || '',
is_error: s.result?.is_error || false,
})),
})
}
@@ -1159,10 +1213,10 @@ export default function Shell({ api, isSudo }) {
return [...filtered, finalMsg]
})
if (analysisSavingRef.current && accumulated) {
if (analysisSavingRef.current && allText) {
analysisSavingRef.current = false
setAnalysisContent(accumulated)
try { localStorage.setItem('shell_analysis', accumulated) } catch {}
setAnalysisContent(allText)
try { localStorage.setItem('shell_analysis', allText) } catch {}
setAnalyzing(false)
}
@@ -1182,7 +1236,7 @@ export default function Shell({ api, isSudo }) {
}
setAiLoading(false)
aiLoadingRef.current = false
}, [api, t, aiAtLimit, focusAiTerminal])
}, [api, t, aiAtLimit, focusAiTerminal, _flushStreamUpdate])
const handleAiSend = () => _sendAiMessage(aiInput, false)
@@ -1190,7 +1244,7 @@ export default function Shell({ api, isSudo }) {
const handler = (e) => {
const msg = e.detail?.message
if (!msg) return
setAiInput(msg)
setAiInput('')
setTimeout(() => _sendAiMessage(msg, true), 100)
}
window.addEventListener('ask-ai-terminal', handler)
@@ -1378,11 +1432,9 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
<div className="shell-ai-col">
<div className="ai-panel-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>Analyste Système</span>
<span style={{ flex: 1 }}>Analyste Système</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span className={`sudo-indicator ${isSudo ? 'sudo-ok' : 'sudo-blocked'}`} title={isSudo ? 'Sudo sans mot de passe disponible' : 'Sudo bloqué — mot de passe requis'} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="shell-analyze-btn"
onClick={() => setShowAnalysis(true)}
@@ -1627,7 +1679,39 @@ function MermaidBlock({ code }) {
return <div className="shell-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
}
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
const _renderParts = (parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId) => parts.map((part, i) => {
if (part.type === 'code' && part.lang === 'mermaid') {
return (
<div key={i} className="shell-code-block">
<div className="shell-code-lang">mermaid</div>
<MermaidBlock code={part.content} />
</div>
)
}
if (part.type === 'code') {
return (
<div key={i} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
<div className="shell-code-actions">
<button className={copiedIdx === i ? 'copied' : ''} onClick={() => {
navigator.clipboard.writeText(part.content)
setCopiedIdx(i)
setTimeout(() => setCopiedIdx(null), 1500)
}} title="Copier">
<Copy size={12} /> {copiedIdx === i ? 'Copié !' : 'Copier'}
</button>
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
<Send size={12} /> Terminal
</button>
</div>
</div>
)
}
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
})
const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const content = msg.content || ''
const [copiedIdx, setCopiedIdx] = useState(null)
@@ -1640,18 +1724,51 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
return <div className={`ai-message system`}>{content}</div>
}
// Ordered segments (streaming or final with segments)
let segments = msg._segments || null
if (!segments) {
try {
const parsed = JSON.parse(content)
if (parsed && Array.isArray(parsed.segments)) {
segments = parsed.segments
}
} catch {}
}
if (segments && segments.length > 0) {
const hasTools = segments.some(s => s.type === 'tool')
if (hasTools) {
return (
<div className="ai-message assistant">
{segments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
return <Fragment key={`t${i}`}>{_renderParts(renderContent(seg.content), copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}</Fragment>
}
if (seg.type === 'tool') {
const r = seg.result
const result = r && (r.content !== undefined || r.is_error !== undefined)
? { content: r.content, is_error: r.is_error }
: null
return <ShellToolBlock key={`tc${i}`} call={seg.call} result={result} />
}
return null
})}
</div>
)
}
}
// Fallback: old format (all tools then all text)
let parsedToolCalls = null
let parsedToolResults = null
let displayContent = content
let streamingToolCalls = msg._toolCalls || null
try {
const parsed = JSON.parse(content)
if (parsed && Array.isArray(parsed.tool_calls)) {
if (!streamingToolCalls) {
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
}
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
}
} catch {}
@@ -1660,9 +1777,6 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
return (
<div className={`ai-message assistant`}>
{streamingToolCalls && streamingToolCalls.map((tc, i) => (
<ShellToolBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))}
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
@@ -1672,37 +1786,7 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
: null
return <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
})}
{parts.map((part, i) => {
if (part.type === 'code' && part.lang === 'mermaid') {
return (
<div key={i} className="shell-code-block">
<div className="shell-code-lang">mermaid</div>
<MermaidBlock code={part.content} />
</div>
)
}
if (part.type === 'code') {
return (
<div key={i} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
<div className="shell-code-actions">
<button className={copiedIdx === i ? 'copied' : ''} onClick={() => {
navigator.clipboard.writeText(part.content)
setCopiedIdx(i)
setTimeout(() => setCopiedIdx(null), 1500)
}} title="Copier">
<Copy size={12} /> {copiedIdx === i ? 'Copié !' : 'Copier'}
</button>
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
<Send size={12} /> Terminal
</button>
</div>
</div>
)
}
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
})}
{_renderParts(parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}
</div>
)
}
})

View File

@@ -142,10 +142,17 @@ const TOOL_LABELS = {
web_fetch: 'Web Fetch',
}
function ToolCallBlock({ call, result }) {
function ToolCallBlock({ call, result, activeAgents, onModeChange }) {
const icon = TOOL_ICONS[call.name] || '🔧'
const label = TOOL_LABELS[call.name] || call.name
const isErr = result && result.is_error
const isCrush = call.name === 'crush_run'
const isClaude = call.name === 'claude_run'
const isAgent = isCrush || isClaude
const agentType = isCrush ? 'crush' : isClaude ? 'claude' : null
const maxAgents = isCrush ? 2 : isClaude ? 2 : 0
const currentCount = agentType && activeAgents ? (activeAgents[agentType] || 0) : 0
const [mode, setMode] = useState('sync')
let argsPreview = ''
try {
@@ -163,15 +170,39 @@ function ToolCallBlock({ call, result }) {
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
const handleModeChange = (newMode) => {
setMode(newMode)
if (onModeChange) onModeChange(call.tool_call_id, newMode)
}
return (
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
<div className="studio-tool-header">
<span className="studio-tool-icon">{icon}</span>
<span className="studio-tool-name">{label}</span>
{isAgent && !result && (
<span className="studio-agent-badge">{currentCount}/{maxAgents}</span>
)}
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
</div>
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
{isAgent && !result && (
<div className="studio-agent-mode">
<button
className={`studio-mode-btn ${mode === 'sync' ? 'active' : ''}`}
onClick={() => handleModeChange('sync')}
>
Exécuter et attendre
</button>
<button
className={`studio-mode-btn ${mode === 'async' ? 'active' : ''}`}
onClick={() => handleModeChange('async')}
>
Exécuter en arrière-plan
</button>
</div>
)}
{truncatedResult && (
<div className="studio-tool-result">
<pre>{truncatedResult}</pre>
@@ -249,10 +280,16 @@ function FeedItem({ msg }) {
let parsedToolCalls = null
let parsedToolResults = null
let parsedSegments = null
let displayContent = msg.content
try {
const parsed = JSON.parse(msg.content)
if (parsed && Array.isArray(parsed.tool_calls)) {
if (parsed && Array.isArray(parsed.segments)) {
parsedSegments = parsed.segments
parsedToolCalls = parsed.tool_calls || null
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
} else if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
@@ -292,32 +329,63 @@ function FeedItem({ msg }) {
))}
</div>
)}
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
: null
const result = resultData
? { content: resultData.result, is_error: resultData.is_error }
: null
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
})}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
{parsedSegments && parsedSegments.some(s => s.type === 'tool') ? (
parsedSegments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
if (!c) return null
return (
<div key={`t${i}`} className="feed-content">
{renderContent(c).map((part, j) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
)
}
if (seg.type === 'tool') {
const r = seg.result
const result = r && (r.content !== undefined || r.is_error !== undefined)
? { content: r.content, is_error: r.is_error }
: null
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={result} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
}
return null
})
) : (
<>
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
: null
const result = resultData
? { content: resultData.result, is_error: resultData.is_error }
: null
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
})}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
)}
</div>
</>
)}
</div>
</div>
)
}
function StreamingItem({ content, thinking, toolCalls }) {
function StreamingItem({ content, thinking, toolCalls, segments }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
@@ -333,6 +401,8 @@ function StreamingItem({ content, thinking, toolCalls }) {
return formatText(thinking)
}, [thinking])
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
return (
<div className="feed-item assistant">
<div className="feed-avatar ai-rank">
@@ -346,25 +416,54 @@ function StreamingItem({ content, thinking, toolCalls }) {
<span className="feed-role">{rank.label}</span>
</div>
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
{hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))}
{!thinking && !cleanContent && !hasToolCalls && (
{hasOrderedSegments ? (
segments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
const parts = renderContent(seg.content)
return (
<div key={`t${i}`} className="feed-content">
{parts.map((part, j) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
)
}
if (seg.type === 'tool') {
return <ToolCallBlock key={`tc${i}`} call={seg.call} result={seg.result} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
}
return null
})
) : (
<>
{hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
))}
{cleanContent && (
<div className="feed-content">
{renderedContent.map((part, i) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" />
</div>
)}
</>
)}
{!thinking && !cleanContent && !hasToolCalls && !hasOrderedSegments && (
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
)}
{cleanContent && (
<div className="feed-content">
{renderedContent.map((part, i) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" />
</div>
{!hasOrderedSegments && cleanContent && (
<span className="studio-cursor" />
)}
</div>
</div>
@@ -379,12 +478,17 @@ export default function Studio({ api }) {
const [streaming, setStreaming] = useState('')
const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
const [streamSegments, setStreamSegments] = useState(null)
const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const [sudoModal, setSudoModal] = useState(null)
const [attachedImages, setAttachedImages] = useState([])
const [activeAgents, setActiveAgents] = useState({ crush: 0, claude: 0 })
const [toolModes, setToolModes] = useState({})
const MAX_CRUSH_AGENTS = 2
const MAX_CLAUDE_AGENTS = 2
const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null)
@@ -584,9 +688,19 @@ export default function Studio({ api }) {
abortRef.current = controller
try {
let accumulated = ''
let segments = []
let textStartIdx = 0
let thinking = ''
let toolCalls = []
const _updateLastText = (text) => {
if (!text) return
const last = segments.length > 0 ? segments[segments.length - 1] : null
if (last && last.type === 'text') {
last.content = text
} else {
segments.push({ type: 'text', content: text })
}
}
await api.sendChat(text, true, (partial, event) => {
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
@@ -597,28 +711,47 @@ export default function Studio({ api }) {
return
}
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls])
accumulated = ''
setStreaming('')
_updateLastText(partial.slice(textStartIdx))
textStartIdx = partial.length
segments.push({ type: 'tool', call: event.tool_call, result: null })
const toolName = event.tool_call.name
if (toolName === 'crush_run' || toolName === 'claude_run') {
const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
setActiveAgents(prev => ({ ...prev, [agentType]: prev[agentType] + 1 }))
}
const snap = segments.map(s => ({ ...s }))
setStreamToolCalls(snap.filter(s => s.type === 'tool'))
setStreamSegments(snap)
setStreaming(partial)
return
}
if (event && event.tool_result) {
if (event.tool_result.sudo_blocked) {
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
}
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
if (idx >= 0) {
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
setStreamToolCalls([...toolCalls])
const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id)
if (segIdx >= 0) {
segments[segIdx].result = event.tool_result
const toolName = segments[segIdx].call?.name
if (toolName === 'crush_run' || toolName === 'claude_run') {
const agentType = toolName === 'crush_run' ? 'crush' : 'claude'
setActiveAgents(prev => ({ ...prev, [agentType]: Math.max(0, prev[agentType] - 1) }))
}
const snap = segments.map(s => ({ ...s }))
setStreamToolCalls(snap.filter(s => s.type === 'tool'))
setStreamSegments(snap)
}
return
}
accumulated = partial
_updateLastText(partial.slice(textStartIdx))
setStreaming(partial)
const snap = segments.map(s => ({ ...s }))
setStreamSegments(snap)
}, controller.signal, images)
const finalContent = accumulated || t('studio.noResponse')
const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('')
const toolSegs = segments.filter(s => s.type === 'tool')
const finalContent = allText || t('studio.noResponse')
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
@@ -626,14 +759,18 @@ export default function Studio({ api }) {
time: new Date().toISOString(),
}
if (thinking) aiMsg.thinking = thinking
if (toolCalls.length > 0) {
if (toolSegs.length > 0 || segments.length > 1) {
aiMsg.content = JSON.stringify({
content: finalContent,
tool_calls: toolCalls.map(tc => tc.call),
tool_results: toolCalls.map(tc => ({
tool_call_id: tc.call?.tool_call_id,
result: tc.result?.content || '',
is_error: tc.result?.is_error || false,
segments: segments.map(s => s.type === 'text'
? { type: 'text', content: s.content }
: { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } }
),
content: allText,
tool_calls: toolSegs.map(s => s.call),
tool_results: toolSegs.map(s => ({
tool_call_id: s.call?.tool_call_id,
result: s.result?.content || '',
is_error: s.result?.is_error || false,
})),
})
}
@@ -661,6 +798,9 @@ export default function Studio({ api }) {
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
setStreamSegments(null)
setActiveAgents({ crush: 0, claude: 0 })
setToolModes({})
abortRef.current = null
refreshTokens()
}
@@ -672,6 +812,10 @@ export default function Studio({ api }) {
}
}, [])
const handleToolModeChange = useCallback((toolCallId, mode) => {
setToolModes(prev => ({ ...prev, [toolCallId]: mode }))
}, [])
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
const handleKeyDown = (e) => {
@@ -695,29 +839,61 @@ export default function Studio({ api }) {
if (afterSlash) {
const partial = afterSlash[0]
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
if (matches.length === 1) {
const completed = matches[0] + ' '
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
setInput(newText)
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
})
if (matches.length >= 1) {
let completed = matches[0]
for (const m of matches) {
while (!m.startsWith(completed)) completed = completed.slice(0, -1)
}
if (completed === partial && matches.length === 1) completed = matches[0]
if (completed.length > partial.length) {
const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '')
completed += suffix
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
setInput(newText)
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
})
}
}
}
}
}
const [summarizedExpanded, setSummarizedExpanded] = useState(false)
const handleToggleCollapsed = useCallback(() => {
setMessagesCollapsed(prev => !prev)
}, [])
const renderMessages = () => {
if (messagesCollapsed && messages.length > 4) {
const summarizedMsgs = messages.filter(m => m.summarized)
const activeMsgs = messages.filter(m => !m.summarized)
const renderSummaryBlock = () => summarizedMsgs.length > 0 && (
<div className="feed-summary-block">
<div className="feed-summary-header" onClick={() => setSummarizedExpanded(prev => !prev)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span className="feed-summary-text">Résumé · {summarizedMsgs.length} messages</span>
<span className="feed-summary-toggle">{summarizedExpanded ? 'masquer' : 'voir'}</span>
</div>
{summarizedExpanded && summarizedMsgs.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
</div>
)
if (messagesCollapsed && activeMsgs.length > 4) {
const visibleCount = 4
const hiddenCount = messages.length - visibleCount
const hiddenCount = activeMsgs.length - visibleCount
return (
<>
{messages.slice(0, visibleCount).map(msg => (
{renderSummaryBlock()}
{activeMsgs.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
@@ -730,9 +906,15 @@ export default function Studio({ api }) {
</>
)
}
return messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))
return (
<>
{renderSummaryBlock()}
{activeMsgs.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
</>
)
}
if (!loaded) {
@@ -753,7 +935,7 @@ export default function Studio({ api }) {
<div className="studio-feed" ref={feedRef}>
{renderMessages()}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} />
)}
<div ref={messagesEnd} style={{ height: '24px' }} />
</div>

View File

@@ -211,8 +211,27 @@ const en = {
resetConfirm: 'Are you sure? All preferences will be erased.',
resetDone: 'Settings reset.',
applyStarship: 'Apply starship',
apply: 'Apply',
remove: 'Remove',
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
starshipError: 'Failed to apply starship theme.',
systemConfig: 'System Configuration',
aiToolsConfig: 'Tools & Environments',
configureViaAI: 'Configure',
toolCrushDesc: 'Autonomous AI agent for code writing and refactoring.',
toolClaudeDesc: 'AI coding assistant by Anthropic.',
toolGhDesc: 'Command-line interface for GitHub.',
toolDockerDesc: 'Application containerization platform.',
toolGoDesc: 'Programming language and runtime environment.',
toolNodeDesc: 'JavaScript runtime and package manager.',
toolPythonDesc: 'Programming language, pip and uv manager.',
toolStarshipDesc: 'Modern and customizable shell prompt.',
systemUpdate: 'System Update',
systemUpdateDescSudo: 'Updates the system and all tools (sshpass, crush, claude, gh, etc.).',
systemUpdateDescNoSudo: 'Shows update commands to run manually.',
updateBtn: 'Update',
notInstalled: 'Not installed',
install: 'Install',
},
}

View File

@@ -211,8 +211,27 @@ const fr = {
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
applyStarship: 'Appliquer starship',
apply: 'Appliquer',
remove: 'Retirer',
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
systemConfig: 'Configuration Syst\u00e8me',
aiToolsConfig: 'Outils & Environnements',
configureViaAI: 'Configurer',
toolCrushDesc: 'Agent IA autonome pour l\u2019\u00e9criture et le refactoring de code.',
toolClaudeDesc: 'Assistant de codage IA par Anthropic.',
toolGhDesc: 'Interface en ligne de commande pour GitHub.',
toolDockerDesc: 'Plateforme de conteneurisation d\u2019applications.',
toolGoDesc: 'Langage de programmation et environnement d\u2019ex\u00e9cution.',
toolNodeDesc: 'Environnement d\u2019ex\u00e9cution JavaScript et gestionnaire de paquets.',
toolPythonDesc: 'Langage de programmation, pip et gestionnaire uv.',
toolStarshipDesc: 'Prompt shell moderne et personnalisable.',
systemUpdate: 'Mise à jour système',
systemUpdateDescSudo: 'Met à jour le système et tous les outils (sshpass, crush, claude, gh, etc.).',
systemUpdateDescNoSudo: 'Affiche les commandes de mise à jour à exécuter manuellement.',
updateBtn: 'Mettre à jour',
notInstalled: 'Non installé',
install: 'Installer',
},
}

View File

@@ -379,11 +379,11 @@ input::placeholder { color: var(--text-disabled); }
.shell-menu-item-row { display: flex; align-items: center; }
.shell-menu-item-icon {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius);
background: transparent; border: none; color: var(--text-disabled);
width: 26px; height: 26px; border-radius: var(--radius);
background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary);
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
}
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); }
.shell-menu-empty {
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
font-style: italic;
@@ -459,7 +459,7 @@ input::placeholder { color: var(--text-disabled); }
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.shell-ai-token-fill.warn { background: var(--warning); }
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
.ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
@@ -511,7 +511,7 @@ input::placeholder { color: var(--text-disabled); }
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; }
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.ai-message thead, .ai-message tbody { display: table-row-group; }
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
@@ -1024,8 +1024,10 @@ input::placeholder { color: var(--text-disabled); }
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
.feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
.msg-h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; display: block; }
.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
@@ -1133,6 +1135,22 @@ input::placeholder { color: var(--text-disabled); }
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
.feed-summary-block { margin: 4px 0; }
.feed-summary-header {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-summary-header:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
.feed-summary-header svg { color: var(--accent); flex-shrink: 0; }
.feed-summary-text { font-size: 11px; color: var(--text-tertiary); flex: 1; font-weight: 600; }
.feed-summary-toggle { font-size: 10px; color: var(--accent); font-family: var(--font-mono); }
.skill-list-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.skills-list { display: flex; flex-direction: column; gap: 2px; }
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);
@@ -1294,3 +1312,51 @@ input::placeholder { color: var(--text-disabled); }
.shell-xterm-instance .xterm-link:hover {
color: var(--accent-muted) !important;
}
.config-ai-tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.config-ai-tool-card {
display: flex;
flex-direction: column;
padding: 14px;
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
transition: border-color 0.15s;
min-height: 120px;
}
.config-ai-tool-card:hover {
border-color: var(--accent-dim);
}
.config-ai-tool-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.config-ai-tool-icon {
font-size: 18px;
line-height: 1;
}
.config-ai-tool-name {
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
}
.config-ai-tool-desc {
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.4;
margin-bottom: 10px;
flex: 1;
}