Compare commits

..

5 Commits

Author SHA1 Message Date
Augustin
0830e64ae6 fix(shell,config): terminal font size, AI tools, provider keys
All checks were successful
Beta Release / beta (push) Successful in 56s
- Fix terminal default fontSize from 6px to 14px across all references
- Add terminal tool to shell AI via ChatEngine with tool_call streaming
- Fix provider key detection (apiKey → api_key, baseURL → base_url)
- Add mimo provider migration and validation endpoint
- Bump version to 0.4.0

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 22:03:35 +02:00
Augustin
9a218b1904 fix(shell): set default terminal fontSize to 6px
All checks were successful
Beta Release / beta (push) Successful in 49s
All fallbacks were still using 12px. User confirmed 6px is the
correct baseline on their display.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:41:47 +02:00
Augustin
399b845e14 fix(shell): default fontSize 10px and init new tabs immediately
All checks were successful
Beta Release / beta (push) Successful in 48s
- Base font size reduced from 12px to 10px
- New tabs now initialize directly when added (was waiting for
  tab switch because the MutationObserver only fired on visibility
  changes, not on tab additions)
- Zoom level applied to newly created terminals

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:33:49 +02:00
Augustin
436d5c6149 feat(shell): add Ctrl+/- zoom and display all shortcuts in footer
All checks were successful
Beta Release / beta (push) Successful in 48s
- 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 <crush@charm.land>
2026-04-24 22:28:15 +02:00
Augustin
5a9edc076e fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility
All checks were successful
Beta Release / beta (push) Successful in 49s
The addon-web-links registerApcHandler API requires xterm >= 6.1.0.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:19:12 +02:00
14 changed files with 328 additions and 75 deletions

View File

@@ -187,6 +187,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
switch body.Name {
case "minimax":
baseURL = "https://api.minimax.io/v1"
case "mimo":
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
case "openai":
baseURL = "https://api.openai.com/v1"
case "anthropic":

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -51,26 +52,31 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return
}
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
orb.SetTools(s.shellAgentToolsJSON)
if req.Stream {
s.handleShellChatStreamV2(w, orb)
s.handleShellChatStream(w, orb)
} else {
s.handleShellChatNonStreamV2(w, orb)
s.handleShellChatNonStream(w, orb)
}
}
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
func (s *Server) buildShellSystemPrompt(_ ShellChatRequest) string {
var sb strings.Builder
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
RÈGLES STRICTES:
- Tu ne peux JAMAIS exécuter de commande ou de code
- Tu ne peux que analyser, expliquer, et proposer des solutions
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
OUTILS DISPONIBLES:
- terminal: Exécute des commandes shell sur le système local et retourne le résultat
RÈGLES:
- Utilise l'outil terminal pour exécuter des commandes quand c'est nécessaire
- Analyse les résultats et explique-les clairement
- Formate tes réponses en markdown avec des blocs de code quand approprié
- Sois concis et technique
- Quand tu proposes des commandes alternatives, utilise des blocs de code markdown
`)
@@ -89,43 +95,42 @@ RÈGLES STRICTES:
return sb.String()
}
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
// Rebuild history into orchestrator
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] { // all except last user msg
if m.Role == "system" {
continue
}
// Pre-load orchestrator history
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
ctx := context.Background()
messages := s.buildShellContextMessages()
lastUserMsg := history[len(history)-1].Content
var finalContent string
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
finalContent = chunk
sseWriter.Write(map[string]interface{}{"content": chunk})
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
sseWriter.Write(data)
if canFlush {
flusher.Flush()
}
})
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
content := result
if content == "" {
content = finalContent
storeContent := finalContent
if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{
"content": storeContent,
"tool_calls": allToolCalls,
"tool_results": allToolResults,
}
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
}
s.shellConvStore.Add("assistant", storeContent)
sseWriter.Write(map[string]interface{}{
"done": "true",
@@ -133,30 +138,62 @@ func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrato
})
}
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] {
if m.Role == "system" {
continue
}
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
ctx := context.Background()
messages := s.buildShellContextMessages()
lastUserMsg := history[len(history)-1].Content
result, err := orb.Send(lastUserMsg)
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
s.shellConvStore.Add("assistant", finalContent)
writeJSON(w, map[string]interface{}{
"content": result,
"content": finalContent,
"tokens": s.shellConvStore.ApproxTokens(),
})
}
func (s *Server) buildShellContextMessages() []orchestrator.Message {
history := s.shellConvStore.Get()
start := 0
const shellContextWindow = 20
if len(history) > shellContextWindow {
start = len(history) - shellContextWindow
}
messages := make([]orchestrator.Message, 0, len(history[start:]))
for _, m := range history[start:] {
content := m.Content
if m.Role == "assistant" {
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
content = parsed.Content
}
}
role := m.Role
if role == "system" {
continue
}
messages = append(messages, orchestrator.Message{
Role: role,
Content: content,
})
}
return messages
}
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)

View File

@@ -20,6 +20,8 @@ type Server struct {
shellConvStore *ShellConvStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
}
@@ -52,6 +54,14 @@ func NewServer(cfg *config.MuyueConfig) *Server {
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
s.shellAgentRegistry = agent.NewRegistry()
terminalTool, _ := agent.NewTerminalTool()
s.shellAgentRegistry.Register(terminalTool)
shellTools := s.shellAgentRegistry.OpenAITools()
shellToolsJSON, _ := json.Marshal(shellTools)
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
s.routes()
return s

View File

@@ -128,6 +128,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
},
}
func migrateProviders(cfg *MuyueConfig) {
defaults := Default().AI.Providers
for _, dp := range defaults {
found := false
for _, p := range cfg.AI.Providers {
if p.Name == dp.Name {
found = true
break
}
}
if !found {
cfg.AI.Providers = append(cfg.AI.Providers, dp)
}
}
}
func GetTerminalTheme(name string) TerminalTheme {
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
return theme
@@ -206,6 +222,8 @@ func Load() (*MuyueConfig, error) {
}
}
migrateProviders(&cfg)
return &cfg, nil
}
@@ -303,6 +321,7 @@ func Default() *MuyueConfig {
cfg.Terminal.CustomPrompt = true
cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14
return cfg
}

View File

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

8
web/package-lock.json generated
View File

@@ -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/*"

View File

@@ -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"

View File

@@ -141,7 +141,11 @@ const api = {
if (data.error) { reject(new Error(data.error)); return }
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
if (data.content) {
full = data.content
full += data.content
if (onChunk) onChunk(full, data)
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data)
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data)
}
} catch {}

View File

@@ -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') },
],

View File

@@ -101,9 +101,9 @@ export default function Config({ api }) {
...prev,
[p.name]: {
name: p.name,
api_key: p.apiKey || '',
api_key: p.api_key || '',
model: p.model || '',
base_url: p.baseURL || '',
base_url: p.base_url || '',
},
}))
setEditProvider(p.name)
@@ -314,7 +314,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
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 || '' })
await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
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' } }))
@@ -324,9 +324,9 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
useEffect(() => {
providers.forEach(p => {
if (p.apiKey && !keyStatus[p.name]) {
if (p.api_key && !keyStatus[p.name]) {
validateKey(p)
} else if (!p.apiKey) {
} else if (!p.api_key) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
}
})
@@ -370,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<input
className="config-form-input"
type="password"
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)

View File

@@ -204,7 +204,7 @@ function createTerminal(container, settings = {}) {
const term = new XTerm({
cursorBlink: true,
allowProposedApi: true,
fontSize: settings.fontSize || 12,
fontSize: settings.fontSize || 14,
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme,
allowTransparency: false,
@@ -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
})
@@ -329,7 +350,7 @@ export default function Shell({ api }) {
const { t } = useI18n()
const tabsRef = useRef({})
const nextIdRef = useRef(1)
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' })
const settingsRef = useRef({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' })
const pendingCommandsRef = useRef({})
const [tabs, setTabs] = useState(() => {
@@ -378,7 +399,7 @@ export default function Shell({ api }) {
const [editingTab, setEditingTab] = useState(null)
const [editName, setEditName] = useState('')
const [terminalSettings, setTerminalSettings] = useState({
fontSize: 12,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: 'system',
})
@@ -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 || 14
}, [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: '',
})
@@ -448,7 +496,7 @@ export default function Shell({ api }) {
api.getConfig().then(d => {
if (d.terminal) {
setTerminalSettings({
fontSize: d.terminal.font_size || 12,
fontSize: d.terminal.font_size || 14,
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: d.terminal.theme || 'system',
})
@@ -463,8 +511,9 @@ export default function Shell({ api }) {
if (!container) return
const s = settingsRef.current
const effectiveFontSize = s.fontSize + zoomLevel * 2
const { term, fitAddon, searchAddon } = createTerminal(container, {
fontSize: s.fontSize,
fontSize: effectiveFontSize,
fontFamily: s.fontFamily,
theme: s.theme,
})
@@ -658,6 +707,12 @@ export default function Shell({ api }) {
})
}
for (const tab of tabs) {
if (!tabsRef.current[tab.id]) {
tryInitTab(tab, 0)
}
}
const wrapper = document.querySelector('.shell-layout')?.parentElement
let observer
if (wrapper) {
@@ -945,7 +1000,7 @@ export default function Shell({ api }) {
if (trimmed === '/help') {
setAiMessages(prev => [...prev,
{ role: 'user', content: trimmed },
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe peux exécuter des commandes via l\'outil terminal. Les blocs de code proposés peuvent aussi être copiés ou envoyés directement au terminal actif.' }
])
aiLoadingRef.current = false
return
@@ -958,17 +1013,56 @@ export default function Shell({ api }) {
try {
let accumulated = ''
await api.sendShellChat(trimmed, {}, true, (partial) => {
let toolCalls = []
const controller = new AbortController()
await api.sendShellChat(trimmed, {}, true, (partial, event) => {
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
})
return
}
if (event && event.tool_result) {
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 }
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }]
})
}
return
}
if (event && (event.thinking !== undefined || event.thinking_end)) {
return
}
accumulated = partial
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }]
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }]
})
}, controller.signal)
const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab }
if (toolCalls.length > 0) {
finalMsg._toolCalls = toolCalls
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,
})),
})
}
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
return [...filtered, finalMsg]
})
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
@@ -1059,6 +1153,11 @@ export default function Shell({ api }) {
</div>
<div className="shell-tab-actions">
{zoomLevel !== 0 && (
<span className="shell-zoom-badge">
{zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px
</span>
)}
{tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
@@ -1276,6 +1375,40 @@ export default function Shell({ api }) {
)
}
function ShellToolBlock({ call, result }) {
const icon = '⌨'
const label = call.name === 'terminal' ? 'Terminal' : call.name
const isErr = result && result.is_error
let argsPreview = ''
try {
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
if (args.command) argsPreview = args.command
else argsPreview = JSON.stringify(args).slice(0, 80)
} catch {
argsPreview = String(call.args).slice(0, 80)
}
const truncatedResult = result ? (result.content || '').slice(0, 1500) : null
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>
{!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>
{truncatedResult && (
<div className="studio-tool-result">
<pre>{truncatedResult}</pre>
</div>
)}
</div>
)
}
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const content = msg.content || ''
@@ -1288,10 +1421,38 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
return <div className={`ai-message system`}>{content}</div>
}
const parts = renderContent(content)
let parsedToolCalls = null
let parsedToolResults = null
let displayContent = content
let streamingToolCalls = msg._toolCalls || null
if (!streamingToolCalls) {
try {
const parsed = JSON.parse(content)
if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
}
} catch {}
}
const parts = renderContent(displayContent)
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)
: null
const result = resultData
? { content: resultData.result, is_error: resultData.is_error }
: null
return <ShellToolBlock key={tc.tool_call_id || i} call={tc} result={result} />
})}
{parts.map((part, i) => {
if (part.type === 'code') {
return (

View File

@@ -18,6 +18,10 @@ const en = {
newLine: 'New line',
copy: 'Copy',
paste: 'Paste',
search: 'Search',
zoom: 'Zoom +/',
switchTab: 'Switch tab',
nextTab: 'Next tab',
runCommand: 'Run command',
commandHistory: 'Command history',
},

View File

@@ -18,6 +18,10 @@ const fr = {
newLine: 'Nouvelle ligne',
copy: 'Copier',
paste: 'Coller',
search: 'Rechercher',
zoom: 'Zoom +/\u2212',
switchTab: 'Changer d\u2019onglet',
nextTab: 'Onglet suivant',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},

View File

@@ -329,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.shell-zoom-badge {
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
color: var(--accent); background: var(--accent-bg);
padding: 2px 6px; border-radius: 3px;
border: 1px solid var(--accent-dim);
white-space: nowrap;
}
.shell-new-tab-wrapper { position: relative; }
.shell-new-tab-btn {
display: flex; align-items: center; gap: 2px;