Skip to content

Commit 16b9d23

Browse files
author
Brendan Gray
committed
v1.8.21: Streaming UI fixes - naked JSON prevention, code block collapse, diff bar persistence
1 parent a974a1d commit 16b9d23

6 files changed

Lines changed: 177 additions & 34 deletions

File tree

main/ipc/fileSystemHandlers.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,13 @@ function register(ctx) {
8787

8888
ipcMain.handle('delete-file', async (_, filePath) => {
8989
if (!ctx.isPathAllowed(filePath)) return { success: false, error: 'Access denied: path outside allowed directories' };
90-
try { await fs.unlink(filePath); if (ctx.scheduleIncrementalReindex) ctx.scheduleIncrementalReindex(); return { success: true }; }
90+
try {
91+
await fs.unlink(filePath);
92+
if (ctx.scheduleIncrementalReindex) ctx.scheduleIncrementalReindex();
93+
// Notify renderer so Editor can clear any pending diff for this file
94+
ctx.mainWindow?.webContents?.send('file-deleted', filePath);
95+
return { success: true };
96+
}
9197
catch (error) { return { success: false, error: error.message }; }
9298
});
9399

preload.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
4747
liveServerStop: () => ipcRenderer.invoke('live-server-stop'),
4848
liveServerStatus: () => ipcRenderer.invoke('live-server-status'),
4949
restRequest: (opts) => ipcRenderer.invoke('rest-request', opts),
50+
onFileDeleted: (callback) => _on('file-deleted', callback),
5051

5152
// ── Dialogs ──
5253
showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options),

src/components/Chat/ChatPanel.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useRef, useEffect, useCallback } from 'react';
22
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
3-
import { splitInlineToolCalls, parseToolCall, extractToolResults, stripTrailingPartialToolCall } from '@/utils/chatContentParser';
3+
import { splitInlineToolCalls, parseToolCall, extractToolResults, stripTrailingPartialToolCall, stripFunctionCallTools } from '@/utils/chatContentParser';
44
import {
55
X, Cpu, Globe, Code, Bug, FileCode, Terminal, Plus,
66
ChevronDown, Trash2, Key, Loader2,
@@ -1915,6 +1915,11 @@ ${e.message}`,
19151915
// and leave the trailing incomplete block as plain text
19161916
// Also merges tool calls with their results like renderContentParts
19171917
const renderStreamingContent = (text: string) => {
1918+
// Strip function-call style tool invocations early — these appear naked during streaming
1919+
// because backend only detects them after generation completes (fallback detection).
1920+
// E.g. write_file("path", "content") or edit_file("path", "oldText", "newText")
1921+
text = stripFunctionCallTools(text);
1922+
19181923
// Pre-extract tool results for merging (same as renderContentParts)
19191924
const toolResultMap = extractToolResults(text);
19201925

@@ -2075,17 +2080,29 @@ ${e.message}`,
20752080
const hasClosingFence = hasOpenFence && remaining.indexOf('```', openFenceIdx + 3) !== -1;
20762081
if (hasOpenFence && !hasClosingFence) {
20772082
// Incomplete fence — render text before the fence, then render partial code as a live CodeBlock
2078-
const beforeFence = remaining.substring(0, openFenceIdx).trim();
2079-
if (beforeFence) {
2080-
parts.push(<InlineMarkdownText key={`s-${idx}`} content={beforeFence} />);
2081-
idx++;
2082-
}
2083+
let beforeFence = remaining.substring(0, openFenceIdx).trim();
20832084
// Parse fence opener: ``` followed by optional language tag, then newline, then code
20842085
const fenceContent = remaining.substring(openFenceIdx + 3);
20852086
const firstNewlineInFence = fenceContent.indexOf('\n');
20862087
const fenceLang = firstNewlineInFence > 0 ? fenceContent.substring(0, firstNewlineInFence).trim() : '';
20872088
// Only render once the first newline has arrived — before that we only have the language tag, not code
2088-
const partialCode = firstNewlineInFence >= 0 ? fenceContent.substring(firstNewlineInFence + 1) : '';
2089+
let partialCode = firstNewlineInFence >= 0 ? fenceContent.substring(firstNewlineInFence + 1) : '';
2090+
2091+
// Heuristic: if beforeFence looks like leaked code (model closed fence early or
2092+
// streaming boundary issue), prepend it to the code block instead of rendering as text.
2093+
// Check for code-like patterns: leading spaces, braces, colons, semicolons, equals signs
2094+
// in positions suggesting code syntax rather than prose.
2095+
const looksLikeCode = /^\s{2,}|\{|\}|;$|^\s*(?:import|from|def|class|function|const|let|var|return|if|for|while|elif|else:|except|try|with|async|await)\b/.test(beforeFence);
2096+
if (looksLikeCode && beforeFence) {
2097+
// Prepend leaked code to the partial code block
2098+
partialCode = beforeFence + '\n' + partialCode;
2099+
beforeFence = '';
2100+
}
2101+
2102+
if (beforeFence) {
2103+
parts.push(<InlineMarkdownText key={`s-${idx}`} content={beforeFence} />);
2104+
idx++;
2105+
}
20892106
if (partialCode.trim()) {
20902107
parts.push(<CodeBlock key={`streaming-${idx}`} code={partialCode} language={fenceLang || 'code'} onApply={() => {}} isStreaming={true} />);
20912108
idx++;

src/components/Chat/ChatWidgets.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ export const CodeBlock: React.FC<{ code: string; language: string; onApply: () =
318318
const [copied, setCopied] = useState(false);
319319
const lineCount = code.split('\n').length;
320320
const isLong = lineCount > COLLAPSE_LINE_THRESHOLD;
321-
const [expanded, setExpanded] = useState(!isLong || !!isToolCall); // Tool call results start expanded
321+
// Default to collapsed for long blocks; keep streaming blocks expanded so users can watch generation
322+
const [expanded, setExpanded] = useState(!!isStreaming || !isLong);
322323

323324
const handleCopy = () => {
324325
navigator.clipboard.writeText(code);

src/components/Editor/Editor.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,31 @@ export const Editor = forwardRef<EditorHandle, EditorProps>(({
106106
onTabsChange?.(tabs.length > 0);
107107
}, [tabs.length, onTabsChange]);
108108

109+
// Clear diff bar if the target tab was closed or file was deleted
110+
useEffect(() => {
111+
if (showDiffBar && diffTabId) {
112+
const diffTab = tabs.find(t => t.id === diffTabId);
113+
// Tab closed or pendingChange cleared → dismiss diff bar
114+
if (!diffTab || !diffTab.pendingChange) {
115+
setShowDiffBar(false);
116+
setDiffTabId(null);
117+
}
118+
}
119+
}, [tabs, showDiffBar, diffTabId]);
120+
121+
// Listen for file-deleted events from main process — clear pending changes for deleted files
122+
useEffect(() => {
123+
const api = window.electronAPI;
124+
if (!api?.onFileDeleted) return;
125+
const unsubscribe = api.onFileDeleted((deletedPath: string) => {
126+
// Clear pendingChange and close diff bar for the deleted file
127+
setTabs(prev => prev.map(t =>
128+
t.filePath === deletedPath ? { ...t, pendingChange: undefined } : t
129+
));
130+
});
131+
return unsubscribe;
132+
}, []);
133+
109134
// Close tab — defined before useImperativeHandle so it can be referenced
110135
const closeTab = useCallback((tabId: string, e?: React.MouseEvent) => {
111136
e?.stopPropagation();

src/utils/chatContentParser.ts

Lines changed: 118 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,100 @@ function isValidToolName(name: string): boolean {
5454
return VALID_TOOL_NAMES.has(lower) || lower in TOOL_ALIASES;
5555
}
5656

57+
/**
58+
* Strip function-call style tool invocations from streaming text.
59+
* Models sometimes output: write_file("path", "content") or write_file("path", """content""")
60+
* These are detected by the backend after generation completes, but appear naked in the UI
61+
* during streaming. This function strips them so they don't clutter the chat.
62+
*/
63+
export function stripFunctionCallTools(text: string): string {
64+
// Match: toolname("arg1", "arg2...") or toolname('arg1', 'arg2...')
65+
// Handles multi-line content with """/''' and regular "/'.
66+
// This is a greedy strip — once we see tool_name( we consume until the matching ) or end of text
67+
const TOOL_NAMES = ['write_file', 'read_file', 'edit_file', 'delete_file', 'run_command', 'list_directory', 'find_files'];
68+
let result = text;
69+
for (const tool of TOOL_NAMES) {
70+
// Pattern: tool_name( followed by quoted args — consume everything until balanced )
71+
const startPattern = new RegExp(`\\b${tool}\\s*\\(\\s*['"\`]`, 'gi');
72+
let match;
73+
while ((match = startPattern.exec(result)) !== null) {
74+
// Find the matching closing paren by counting parens (accounting for strings)
75+
let depth = 1;
76+
let j = match.index + match[0].length;
77+
let inString = true; // We started inside the first string quote
78+
let stringChar = match[0].slice(-1); // The quote char that opened the string
79+
let escaped = false;
80+
let tripleQuote = false;
81+
82+
// Check for triple-quote
83+
if (j < result.length && result[j] === stringChar && j + 1 < result.length && result[j + 1] === stringChar) {
84+
tripleQuote = true;
85+
j += 2; // Skip the other two quotes
86+
}
87+
88+
while (j < result.length && depth > 0) {
89+
const ch = result[j];
90+
if (escaped) {
91+
escaped = false;
92+
j++;
93+
continue;
94+
}
95+
if (ch === '\\' && inString) {
96+
escaped = true;
97+
j++;
98+
continue;
99+
}
100+
if (inString) {
101+
if (tripleQuote) {
102+
// Look for closing triple-quote
103+
if (ch === stringChar && j + 2 < result.length && result[j + 1] === stringChar && result[j + 2] === stringChar) {
104+
inString = false;
105+
tripleQuote = false;
106+
j += 3;
107+
continue;
108+
}
109+
} else {
110+
if (ch === stringChar) {
111+
inString = false;
112+
}
113+
}
114+
j++;
115+
continue;
116+
}
117+
// Not in string
118+
if (ch === "'" || ch === '"' || ch === '`') {
119+
inString = true;
120+
stringChar = ch;
121+
// Check for triple-quote
122+
if (j + 2 < result.length && result[j + 1] === ch && result[j + 2] === ch) {
123+
tripleQuote = true;
124+
j += 3;
125+
continue;
126+
}
127+
} else if (ch === '(') {
128+
depth++;
129+
} else if (ch === ')') {
130+
depth--;
131+
}
132+
j++;
133+
}
134+
135+
if (depth === 0) {
136+
// Found complete function call — strip it
137+
const before = result.slice(0, match.index).trimEnd();
138+
const after = result.slice(j).trimStart();
139+
result = before + (before && after ? '\n' : '') + after;
140+
startPattern.lastIndex = 0; // Reset to search from beginning
141+
} else {
142+
// Incomplete function call (still streaming) — strip from start to end
143+
result = result.slice(0, match.index).trimEnd();
144+
break;
145+
}
146+
}
147+
}
148+
return result;
149+
}
150+
57151
/**
58152
* Strip tool execution result sections, orphan headers, and internal reasoning from text.
59153
*/
@@ -62,6 +156,13 @@ export function stripToolArtifacts(text: string): string {
62156
// Remove <think>/<thinking> blocks that weren't caught earlier
63157
cleaned = cleaned.replace(/<think(?:ing)?>\s*[\s\S]*?<\/think(?:ing)?>/gi, '');
64158
cleaned = cleaned.replace(/<\/?think(?:ing)?>/gi, '');
159+
// Strip function-call style tool invocations (write_file("path", "content") etc.)
160+
cleaned = stripFunctionCallTools(cleaned);
161+
// Strip naked JSON tool calls that couldn't be parsed by splitInlineToolCalls
162+
// (e.g., malformed JSON with literal newlines inside strings). These are already
163+
// handled by the backend after generation completes; we just hide them during streaming.
164+
// The pattern matches {"tool":"..." or {"name":"..." followed by any content until } or end.
165+
cleaned = cleaned.replace(/\{\s*"(?:tool|name)"\s*:\s*"[^"]*"[\s\S]*?(?:\}(?:\s*\})?|$)/g, '');
65166
// (model output filters removed — model text is shown verbatim)
66167
// Strip bare code-fence language labels leaked by the model without backtick fences
67168
// e.g. the model outputs `json\n{"tool":...}` instead of ```json\n{...}\n```. These labels
@@ -175,36 +276,28 @@ export function splitInlineToolCalls(text: string): ContentSegment[] {
175276
jsonRegex.lastIndex = lastIndex;
176277
}
177278
} catch {
178-
// Malformed JSON (e.g. unescaped HTML inside content param) — skip the entire
179-
// blob so it doesn't leak into "remaining text" and appear as raw JSON in chat.
180-
// When HTML/CSS content confuses the brace counter (unescaped quotes make inString
181-
// tracking lose sync), endIdx may be set too early. Any text after endIdx that is
182-
// still part of the blob would leak as raw content. For tool-call blobs we extend
183-
// the skip window: find the furthest plausible closing braces after endIdx.
279+
// Malformed JSON (e.g. literal newlines inside strings, unescaped quotes) — skip
280+
// the entire blob so it doesn't leak as naked text. The backend will parse and
281+
// execute tool calls properly after generation completes; we just need to hide
282+
// the raw JSON during streaming.
184283
if (startIdx > lastIndex) {
185284
const before = stripToolArtifacts(text.substring(lastIndex, startIdx)).replace(/\[\s*$/, '').trim();
186285
if (before) results.push({ type: 'text', content: before });
187286
}
188-
let skipEnd = endIdx;
189-
// If the blob looked like a tool call, extend past any trailing content that
190-
// was likely part of the same JSON blob (e.g., leaked HTML/CSS after premature close)
191-
const blobHead = text.substring(startIdx, Math.min(startIdx + 200, text.length));
192-
if (/"(?:tool|name)"\s*:\s*"/.test(blobHead)) {
193-
// Look for the next clearly non-JSON content boundary: a line starting with
194-
// a letter/header/markdown that isn't part of code content, or end of text
195-
const afterBlob = text.substring(endIdx);
196-
// Find the next double-newline paragraph break — content before it is likely
197-
// leaked code from the blob, content after it is likely the model's prose response.
198-
const paraBreak = afterBlob.indexOf('\n\n');
199-
if (paraBreak > 0) {
200-
// Only extend to the paragraph break — preserve everything after it
201-
skipEnd = endIdx + paraBreak;
202-
}
203-
// If no paragraph break found, DON'T consume to end — the brace counter's endIdx
204-
// is the best we have. Some content may leak, but that's better than swallowing
205-
// the model's actual follow-up prose.
287+
// Mark this as a tool segment even though we couldn't parse it — this prevents
288+
// the JSON from rendering as text. The tool bubble from llm-tool-generating IPC
289+
// already shows the user what tool is being called.
290+
const toolNameMatch = match[0].match(/"(?:tool|name)"\s*:\s*"([^"]*)"/);
291+
const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown';
292+
if (isValidToolName(toolName)) {
293+
// Consume to end of text — the malformed JSON likely continues to the end
294+
// (model is still streaming the content param). Backend handles actual execution.
295+
results.push({ type: 'tool', content: text.substring(startIdx), toolCall: { tool: toolName, params: {} } });
296+
lastIndex = text.length;
297+
break;
206298
}
207-
lastIndex = skipEnd;
299+
// Unknown tool name — skip past the detected JSON blob boundaries
300+
lastIndex = endIdx;
208301
jsonRegex.lastIndex = lastIndex;
209302
}
210303
}

0 commit comments

Comments
 (0)