From a1046da67b364b2843335b56c7a494b05d750b83 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 14:15:14 +0200 Subject: [PATCH] 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)