Skip to content

Commit af50be8

Browse files
author
Brendan Gray
committed
Fix 59A-C + Fix 60: Full UI overhaul - design system, theme migration, mmproj filtering, VRAM fix, TodoPanel scroll
Fix 59A: File Structure Digest - project structure in system prompt Fix 59B: Suppress rotation acknowledgments in preamble Fix 59C: Post-write structural validation Fix 60A: Design foundation - Tailwind tokens + index.css component classes Fix 60B: StatusBar GPU VRAM undefined fix Fix 60C: mmproj file filtering from all model selectors Fix 60D: Chat Panel full theme migration (header, messages, input, toolbar) Fix 60E: Settings Panel design system (slider, accordion classes) Fix 60F: Activity Bar VS Code-style left accent indicators Fix 60G: Terminal Panel full theme migration Fix 60H: TodoPanel scroll (max-height 180px) + theme migration
1 parent 5a9bdb9 commit af50be8

13 files changed

Lines changed: 943 additions & 667 deletions

main/agenticChat.js

Lines changed: 235 additions & 473 deletions
Large diffs are not rendered by default.

main/agenticChatHelpers.js

Lines changed: 143 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,19 @@ function formatSuccessfulToolResult(tr, opts = {}) {
476476
switch (tr.tool) {
477477
case 'read_file':
478478
text += `**File:** ${tr.params?.filePath}${tr.result.readRange ? ` (lines ${tr.result.readRange})` : ''}\n`;
479-
text += `\`\`\`\n${(tr.result.content || '').substring(0, 2000)}\n\`\`\`\n`;
479+
{
480+
// Fix 58B: Show head+tail for large files so the model can see both
481+
// the file structure AND where it left off (critical for append workflows)
482+
const content = tr.result.content || '';
483+
if (content.length > 4000) {
484+
const lines = content.split('\n');
485+
const head = lines.slice(0, 15).join('\n');
486+
const tail = lines.slice(-40).join('\n');
487+
text += `\`\`\`\n${head}\n... (${lines.length} lines total, middle omitted) ...\n${tail}\n\`\`\`\n`;
488+
} else {
489+
text += `\`\`\`\n${content.substring(0, 3000)}\n\`\`\`\n`;
490+
}
491+
}
480492
break;
481493

482494
case 'write_file':
@@ -516,6 +528,28 @@ function formatSuccessfulToolResult(tr, opts = {}) {
516528
text += `*Content appended successfully.*\n`;
517529
}
518530
}
531+
532+
// Fix 59C: Post-write structural validation — immediate feedback loop
533+
// Provides IDE-level diagnostics (like LSP Problems panel) so the model
534+
// knows the structural state of the file RIGHT AFTER writing, not just at rotation.
535+
const writtenFilePath = tr.result?.path || tr.params?.filePath || '';
536+
const fullWrittenContent = (tr.tool === 'append_to_file' && tr.result?.fullContent)
537+
? tr.result.fullContent : (tr.params?.content || '');
538+
if (writtenFilePath && fullWrittenContent.length > 100) {
539+
const digest = buildFileStructureDigest(writtenFilePath, fullWrittenContent);
540+
if (digest) {
541+
// Extract only the structural warnings — skip LAST 3 LINES and full header to stay compact
542+
const digestLines = digest.split('\n');
543+
const structuralNotes = digestLines.filter(l =>
544+
l.startsWith('HTML TAGS MISSING') ||
545+
l.startsWith('CSS SELECTORS ALREADY DEFINED') ||
546+
l.startsWith('STATUS:')
547+
);
548+
if (structuralNotes.length > 0) {
549+
text += `**Structure:** ${structuralNotes.join(' | ')}\n`;
550+
}
551+
}
552+
}
519553
break;
520554
}
521555

@@ -636,9 +670,6 @@ class ExecutionState {
636670
if (toolName === 'write_file' && result?.success && params?.filePath) {
637671
this.filesCreated.push({ path: params.filePath, iteration });
638672
}
639-
if (toolName === 'append_to_file' && result?.success && params?.filePath) {
640-
this.filesCreated.push({ path: params.filePath, iteration, append: true });
641-
}
642673
if (toolName === 'edit_file' && result?.success && params?.filePath) {
643674
this.filesEdited.push({ path: params.filePath, iteration });
644675
}
@@ -657,22 +688,7 @@ class ExecutionState {
657688
parts.push(`URLs visited: ${recent.map(v => `${v.success ? 'OK' : 'FAIL'} ${v.url}`).join(', ')}`);
658689
}
659690
if (this.filesCreated.length > 0) {
660-
// Fix 61: Show per-file write counts so the model can see when it's looping
661-
const fileCounts = {};
662-
for (const f of this.filesCreated) {
663-
if (!fileCounts[f.path]) fileCounts[f.path] = { writes: 0, appends: 0 };
664-
if (f.append) fileCounts[f.path].appends++;
665-
else fileCounts[f.path].writes++;
666-
}
667-
const fileList = Object.entries(fileCounts).map(([p, c]) => {
668-
const total = c.writes + c.appends;
669-
if (total <= 1) return p;
670-
const detail = [];
671-
if (c.writes > 0) detail.push(`${c.writes}× written`);
672-
if (c.appends > 0) detail.push(`${c.appends}× appended`);
673-
return `${p} (${detail.join(', ')})`;
674-
});
675-
parts.push(`Files created/modified: ${fileList.join(', ')}`);
691+
parts.push(`Files created: ${this.filesCreated.map(f => f.path).join(', ')}`);
676692
}
677693
if (this.filesEdited.length > 0) {
678694
parts.push(`Files edited: ${this.filesEdited.map(f => f.path).join(', ')}`);
@@ -698,6 +714,112 @@ class ExecutionState {
698714
}
699715
}
700716

717+
/**
718+
* Build a compact structural digest of a file's content.
719+
* This digest survives context rotation and tells the model what's already
720+
* on disk — preventing duplicate CSS selectors, reopened tags, etc.
721+
*
722+
* @param {string} filePath - File path (used for extension detection)
723+
* @param {string} content - Full file content
724+
* @returns {string} Compact multi-line digest for injection into prompts
725+
*/
726+
function buildFileStructureDigest(filePath, content) {
727+
if (!content || content.length < 10) return '';
728+
const ext = (filePath || '').split('.').pop().toLowerCase();
729+
const lines = content.split('\n');
730+
const totalLines = lines.length;
731+
const sections = [];
732+
733+
// --- HTML / CSS structure ---
734+
if (ext === 'html' || ext === 'htm' || ext === 'css' || ext === 'svelte' || ext === 'vue') {
735+
// Detect HTML structural tags present
736+
const htmlTags = [];
737+
const htmlMissing = [];
738+
const structChecks = [
739+
['<!DOCTYPE', '<!DOCTYPE>'],
740+
['<html', '<html>'],
741+
['<head', '<head>'],
742+
['</head>', '</head>'],
743+
['<style', '<style>'],
744+
['</style>', '</style>'],
745+
['<body', '<body>'],
746+
['</body>', '</body>'],
747+
['<header', '<header>'],
748+
['</header>', '</header>'],
749+
['<main', '<main>'],
750+
['<footer', '<footer>'],
751+
['</footer>', '</footer>'],
752+
['</html>', '</html>'],
753+
];
754+
for (const [search, label] of structChecks) {
755+
if (content.includes(search)) htmlTags.push(label);
756+
else htmlMissing.push(label);
757+
}
758+
if (htmlTags.length > 0) sections.push(`HTML TAGS PRESENT: ${htmlTags.join(', ')}`);
759+
if (htmlMissing.length > 0 && ext === 'html') sections.push(`HTML TAGS MISSING (still needed): ${htmlMissing.join(', ')}`);
760+
761+
// Extract CSS selectors (anything before { that is a valid selector)
762+
const selectorSet = new Set();
763+
const selectorRegex = /^[ \t]*([^{}@/\n*][^{]*?)\s*\{/gm;
764+
let m;
765+
while ((m = selectorRegex.exec(content)) !== null) {
766+
let sel = m[1].trim();
767+
// Skip CSS property lines that leaked through (contain : before {)
768+
if (sel.includes(':') && !sel.includes('::') && !sel.includes(':hover') &&
769+
!sel.includes(':focus') && !sel.includes(':active') && !sel.includes(':first') &&
770+
!sel.includes(':last') && !sel.includes(':nth') && !sel.includes(':not') &&
771+
!sel.includes(':root')) continue;
772+
if (sel.length > 0 && sel.length < 80) selectorSet.add(sel);
773+
}
774+
if (selectorSet.size > 0) {
775+
const selList = [...selectorSet];
776+
// Cap to 60 selectors to stay compact
777+
const display = selList.length > 60 ? selList.slice(0, 60).join(', ') + ` ... (${selList.length} total)` : selList.join(', ');
778+
sections.push(`CSS SELECTORS ALREADY DEFINED (do NOT redefine): ${display}`);
779+
}
780+
781+
// Detect if <style> is open but not closed
782+
const styleOpens = (content.match(/<style[\s>]/gi) || []).length;
783+
const styleCloses = (content.match(/<\/style>/gi) || []).length;
784+
if (styleOpens > styleCloses) {
785+
sections.push(`STATUS: <style> tag is OPEN (not closed). Close </style> before starting <body>.`);
786+
}
787+
}
788+
789+
// --- JavaScript / TypeScript structure ---
790+
if (ext === 'js' || ext === 'ts' || ext === 'jsx' || ext === 'tsx' || ext === 'mjs' || ext === 'cjs') {
791+
const funcs = new Set();
792+
const funcRegex = /(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[^=])\s*=>|class\s+(\w+))/g;
793+
let fm;
794+
while ((fm = funcRegex.exec(content)) !== null) {
795+
const name = fm[1] || fm[2] || fm[3];
796+
if (name) funcs.add(name);
797+
}
798+
if (funcs.size > 0) {
799+
const display = [...funcs].slice(0, 40).join(', ');
800+
sections.push(`DEFINED: ${display}`);
801+
}
802+
// Detect exports
803+
const expMatch = content.match(/module\.exports\s*=|export\s+(?:default|{)/g);
804+
if (expMatch) sections.push(`EXPORTS: ${expMatch.length} export statement(s)`);
805+
}
806+
807+
// --- Python structure ---
808+
if (ext === 'py') {
809+
const pyDefs = new Set();
810+
const pyRegex = /^(?:class|def)\s+(\w+)/gm;
811+
let pm;
812+
while ((pm = pyRegex.exec(content)) !== null) pyDefs.add(pm[1]);
813+
if (pyDefs.size > 0) sections.push(`DEFINED: ${[...pyDefs].join(', ')}`);
814+
}
815+
816+
// --- Universal: last 3 lines for continuation context ---
817+
const lastLines = lines.slice(-3).map(l => l.trimEnd()).join('\n');
818+
sections.push(`LAST 3 LINES:\n${lastLines}`);
819+
820+
return `FILE: ${filePath} (${totalLines} lines, ${content.length} chars)\n${sections.join('\n')}`;
821+
}
822+
701823
module.exports = {
702824
isNearDuplicate,
703825
checkFileCompleteness,
@@ -713,5 +835,6 @@ module.exports = {
713835
classifyResponseFailure,
714836
progressiveContextCompaction,
715837
buildToolFeedback,
838+
buildFileStructureDigest,
716839
ExecutionState,
717840
};

main/conversationSummarizer.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ class ConversationSummarizer {
2626
this.incrementalTask = null; // {type, target, current} — tracks progress on large tasks
2727
}
2828

29+
// ─── Digest builder injection ───
30+
// Called once from agenticChat.js to inject the buildFileStructureDigest function
31+
// without creating a circular dependency
32+
setDigestBuilder(fn) {
33+
this._digestBuilder = fn;
34+
}
35+
2936
// ─── Goal ───
3037
setGoal(message) {
3138
if (!message) return;
@@ -155,18 +162,29 @@ class ConversationSummarizer {
155162
const chars = content.length;
156163
if (filePath) {
157164
if (!this.fileProgress[filePath]) {
158-
this.fileProgress[filePath] = { writtenLines: 0, writtenChars: 0, writes: 0 };
165+
this.fileProgress[filePath] = { writtenLines: 0, writtenChars: 0, writes: 0, structureDigest: '' };
159166
}
160167
if (toolName === 'write_file') {
161168
// write_file replaces — reset count
162-
this.fileProgress[filePath] = { writtenLines: lines, writtenChars: chars, writes: 1 };
169+
this.fileProgress[filePath] = { writtenLines: lines, writtenChars: chars, writes: 1, structureDigest: '' };
163170
} else {
164171
// append_to_file adds
165172
this.fileProgress[filePath].writtenLines += lines;
166173
this.fileProgress[filePath].writtenChars += chars;
167174
this.fileProgress[filePath].writes++;
168175
}
169176

177+
// Build structural digest from full file content
178+
// For write_file, params.content IS the full content
179+
// For append_to_file, result.fullContent is the full file after append
180+
const fullContent = (toolName === 'append_to_file' && result?.fullContent)
181+
? result.fullContent : content;
182+
if (this._digestBuilder) {
183+
try {
184+
this.fileProgress[filePath].structureDigest = this._digestBuilder(filePath, fullContent);
185+
} catch (_) {}
186+
}
187+
170188
// Update incremental task progress if tracking lines
171189
if (this.incrementalTask && this.incrementalTask.type === 'lines') {
172190
// Sum all written lines across all files
@@ -363,14 +381,16 @@ class ConversationSummarizer {
363381
sections.push(`## RECENT RESULTS (pre-rotation)\n${this._warmTierResults.join('\n')}`);
364382
}
365383

366-
// 5d. File progress for incremental tasks
384+
// 5d. File progress for incremental tasks — includes structural digest
367385
const fileProgressKeys = Object.keys(this.fileProgress);
368386
if (fileProgressKeys.length > 0) {
369387
const progressLines = fileProgressKeys.map(fp => {
370388
const p = this.fileProgress[fp];
389+
// If a structural digest exists, use it (it includes line/char counts)
390+
if (p.structureDigest) return p.structureDigest;
371391
return `- ${fp}: ${p.writtenLines} lines (${p.writtenChars} chars) written in ${p.writes} operation(s)`;
372392
});
373-
sections.push(`## FILE PROGRESS\n${progressLines.join('\n')}\n**Use append_to_file to continue adding content to existing files.**`);
393+
sections.push(`## FILE PROGRESS\n${progressLines.join('\n')}\n**Use append_to_file to continue adding content to existing files. Do NOT redefine any selectors or functions listed above.**`);
374394
}
375395

376396
// 5e. Incremental task tracking

main/mcpToolServer.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,25 @@ class MCPToolServer {
13371337
this._setFileBackup(fullPath, { original: null, timestamp: Date.now(), tool: 'write_file', isNew: true });
13381338
}
13391339

1340+
// Fix 57: Overwrite regression guard — if the file already exists with substantially
1341+
// more content than what's being written, block the write to prevent data loss.
1342+
// This catches ALL paths: salvage, native tool calls, text-mode parsing.
1343+
// The model should use append_to_file to add content, not write_file to replace.
1344+
if (!isNew && existingContent && existingContent.length > 500) {
1345+
const newLen = (content || '').length;
1346+
if (newLen < existingContent.length * 0.5) {
1347+
const existingLines = existingContent.split('\n').length;
1348+
const newLines = (content || '').split('\n').length;
1349+
console.log(`[MCP] Fix 57: BLOCKED write_file regression — "${filePath}" has ${existingLines} lines (${existingContent.length} chars) but write_file called with only ${newLines} lines (${newLen} chars). Use append_to_file instead.`);
1350+
return {
1351+
success: false,
1352+
error: `BLOCKED: File "${filePath}" already has ${existingLines} lines (${existingContent.length} chars). Your write_file call contains only ${newLines} lines (${newLen} chars) which would DESTROY existing content. Use append_to_file to add content, or edit_file to modify specific sections. Do NOT use write_file on files you have already written.`,
1353+
existingLines,
1354+
existingChars: existingContent.length,
1355+
};
1356+
}
1357+
}
1358+
13401359
await fs.mkdir(path.dirname(fullPath), { recursive: true });
13411360
// Defensive unescape: if content has no real newlines but has literal \n sequences,
13421361
// the model double-escaped during JSON generation. Convert to real newlines.
@@ -2387,15 +2406,7 @@ class MCPToolServer {
23872406

23882407
_updateTodo(params) {
23892408
const { id, status, text } = params;
2390-
// Fix 56: Robust ID resolution — accept the todo's assigned ID (1-based) OR the
2391-
// array index (0-based). Small models (2-4B) routinely send id=0 for the first todo
2392-
// because they assume 0-indexed arrays. Instead of failing with "TODO #0 not found",
2393-
// fall back to treating the id as an array index. Also handle string IDs ("1" vs 1).
2394-
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
2395-
let todo = this._todos.find(t => t.id === numId);
2396-
if (!todo && numId >= 0 && numId < this._todos.length) {
2397-
todo = this._todos[numId]; // Treat as 0-based array index
2398-
}
2409+
const todo = this._todos.find(t => t.id === id);
23992410
if (!todo) return { success: false, error: `TODO #${id} not found` };
24002411
if (status && ['pending', 'in-progress', 'done'].includes(status)) {
24012412
todo.status = status;

0 commit comments

Comments
 (0)