@@ -1198,6 +1198,13 @@ function register(ctx) {
11981198 }
11991199 mainWindow . webContents . send ( 'llm-tool-generating' , {
12001200 callIndex : _tIdx , functionName : _tName , paramsText, done : false ,
1201+ // Fix 30B: Send accurate line count from untruncated content
1202+ lineCount : ( ( ) => {
1203+ const cIdx = raw . indexOf ( '"content"' ) ;
1204+ if ( cIdx === - 1 ) return 0 ;
1205+ const afterContent = raw . slice ( cIdx ) ;
1206+ return ( afterContent . match ( / \\ n / g) || [ ] ) . length + 1 ;
1207+ } ) ( ) ,
12011208 } ) ;
12021209 }
12031210 } , ( thinkToken ) => {
@@ -1803,16 +1810,17 @@ function register(ctx) {
18031810 if ( _hasUnclosedToolFence ) {
18041811 const partialFence = _stitchedForMcp . slice ( _fenceIdx ) ;
18051812
1806- // ── Salvage-and-Append: when a write_file call is truncated mid-content,
1807- // salvage the partial file , write it to disk, then switch the model to
1808- // append_to_file for the remaining content. This prevents the model from
1809- // re-generating the entire file (which causes 87%+ overlap → rotation) . ──
1813+ // ── Salvage-and-Append: when a write_file or append_to_file call is
1814+ // truncated mid-content, salvage the partial content , write/append it
1815+ // to disk, then tell the model to continue with append_to_file for the
1816+ // remaining content. This prevents content loss during long generations . ──
18101817 const _isWriteFile = partialFence . includes ( '"write_file"' ) ;
1818+ const _isAppendFile = partialFence . includes ( '"append_to_file"' ) ;
18111819 const _hasFP = / " f i l e P a t h " \s * : \s * " [ ^ " ] + " / . test ( partialFence ) ;
18121820 const _hasLongContent = / " c o n t e n t " \s * : \s * " [ \s \S ] { 200 , } / . test ( partialFence ) ;
18131821
18141822 let _didSalvageAppend = false ;
1815- if ( _isWriteFile && _hasFP && _hasLongContent ) {
1823+ if ( ( _isWriteFile || _isAppendFile ) && _hasFP && _hasLongContent ) {
18161824 const salvaged = salvagePartialToolCall ( _stitchedForMcp , _fenceIdx ) ;
18171825 if ( salvaged ) {
18181826 try {
@@ -1822,47 +1830,64 @@ function register(ctx) {
18221830 const salvageContent = salvageJson ?. params ?. content || '' ;
18231831
18241832 if ( salvagePath && salvageContent . length >= 100 ) {
1825- const writeResult = await mcpToolServer . executeTool ( 'write_file' , {
1833+ const salvageTool = _isAppendFile ? 'append_to_file' : 'write_file' ;
1834+ const writeResult = await mcpToolServer . executeTool ( salvageTool , {
18261835 filePath : salvagePath ,
18271836 content : salvageContent ,
18281837 } ) ;
1829- const lineCount = salvageContent . split ( '\n' ) . length ;
1830- console . log ( `[AI Chat] Salvage-and-append: wrote ${ lineCount } lines to "${ salvagePath } "` ) ;
1838+ // For append, use fullContent (entire file) for accurate line count
1839+ const finalContent = ( salvageTool === 'append_to_file' && writeResult ?. fullContent )
1840+ ? writeResult . fullContent : salvageContent ;
1841+ const lineCount = finalContent . split ( '\n' ) . length ;
1842+ console . log ( `[AI Chat] Salvage-and-append: ${ salvageTool === 'append_to_file' ? 'appended' : 'wrote' } ${ lineCount } lines to "${ salvagePath } "` ) ;
18311843
18321844 // Update writeFileHistory — blocks future write_file, forces append_to_file
18331845 // Set count=2 (not 1) so guard fires even when continuationCount resets to 0
18341846 // (wfLimit = continuationCount>0 ? 1 : 2; guard fires when count >= wfLimit)
18351847 if ( ! writeFileHistory [ salvagePath ] ) writeFileHistory [ salvagePath ] = { count : 0 , maxLen : 0 } ;
18361848 writeFileHistory [ salvagePath ] . count = Math . max ( writeFileHistory [ salvagePath ] . count + 1 , 2 ) ;
1837- if ( salvageContent . length > writeFileHistory [ salvagePath ] . maxLen ) {
1838- writeFileHistory [ salvagePath ] . maxLen = salvageContent . length ;
1849+ if ( finalContent . length > writeFileHistory [ salvagePath ] . maxLen ) {
1850+ writeFileHistory [ salvagePath ] . maxLen = finalContent . length ;
18391851 }
18401852
1841- // Send UI event for the artifact
1853+ // Send UI events for the artifact — first executing, then results,
1854+ // so the frontend's completedStreamingTools picks up the code block.
1855+ // For append, use fullContent so the unified code block shows the entire file.
1856+ const salvageDisplayContent = ( salvageTool === 'append_to_file' && writeResult ?. fullContent )
1857+ ? writeResult . fullContent : salvageContent ;
1858+ const salvageToolEntry = {
1859+ tool : salvageTool ,
1860+ params : { filePath : salvagePath , content : salvageDisplayContent } ,
1861+ result : writeResult ,
1862+ } ;
18421863 if ( mainWindow && ! mainWindow . isDestroyed ( ) ) {
1843- mainWindow . webContents . send ( 'mcp-tool-results ' , [ {
1844- tool : 'write_file' ,
1845- params : { filePath : salvagePath , content : '...(salvaged partial)' } ,
1846- result : writeResult ,
1847- } ] ) ;
1864+ mainWindow . webContents . send ( 'mcp-executing-tools ' , [ { tool : salvageTool , params : { filePath : salvagePath , content : salvageDisplayContent } } ] ) ;
1865+ mainWindow . webContents . send ( 'mcp- tool-results' , [ salvageToolEntry ] ) ;
1866+ // Notify file explorer that a file was created/modified
1867+ mainWindow . webContents . send ( 'files-changed' ) ;
1868+ mainWindow . webContents . send ( 'open-file' , salvagePath ) ;
18481869 }
1870+ // Track in allToolResults so the committed message includes this code block
1871+ allToolResults . push ( salvageToolEntry ) ;
18491872
18501873 // Track in summarizers + execution state (include content for accurate line counting)
18511874 try {
18521875 const salvageParams = { filePath : salvagePath , content : salvageContent } ;
1853- summarizer . recordToolCall ( 'write_file' , salvageParams , writeResult ) ;
1854- rollingSummary . recordToolCall ( 'write_file' , salvageParams , writeResult , iteration ) ;
1855- rollingSummary . recordToolResult ( 'write_file' , salvageParams , writeResult , iteration ) ;
1856- executionState . update ( 'write_file' , salvageParams , writeResult , iteration ) ;
1876+ summarizer . recordToolCall ( salvageTool , salvageParams , writeResult ) ;
1877+ rollingSummary . recordToolCall ( salvageTool , salvageParams , writeResult , iteration ) ;
1878+ rollingSummary . recordToolResult ( salvageTool , salvageParams , writeResult , iteration ) ;
1879+ executionState . update ( salvageTool , salvageParams , writeResult , iteration ) ;
18571880 } catch ( _ ) { }
18581881
18591882 // Build continuation prompt with completeness detection
1860- const allLines = salvageContent . split ( '\n' ) ;
1883+ // For append, use finalContent (full file) for completeness check
1884+ const checkContent = finalContent ;
1885+ const allLines = checkContent . split ( '\n' ) ;
18611886 const lastLines = allLines . slice ( - 10 ) . join ( '\n' ) ;
18621887 _pendingPartialBlock = null ; // switch from JSON stitching to free-form
18631888
18641889 // Heuristic: detect if salvaged file looks syntactically complete
1865- const trimmedEnd = salvageContent . trimEnd ( ) ;
1890+ const trimmedEnd = checkContent . trimEnd ( ) ;
18661891 const lastCodeLine = trimmedEnd . split ( '\n' ) . pop ( ) . trim ( ) ;
18671892 const _ext = ( salvagePath . match ( / \. ( [ ^ . ] + ) $ / ) || [ ] ) [ 1 ] || '' ;
18681893 let looksComplete = false ;
@@ -1876,7 +1901,7 @@ function register(ctx) {
18761901 looksComplete = / ^ ( m o d u l e \. e x p o r t s \s * = | e x p o r t \s + ( d e f a u l t \s + ) ? | \} \s * ; ? \s * $ | \} \) \s * ; ? \s * $ ) / . test ( lastCodeLine ) ;
18771902 }
18781903 // Secondary check: if file has open HTML tags without closing counterparts, it's not complete
1879- if ( looksComplete && / < ( s t y l e | s c r i p t ) \b / i. test ( salvageContent ) && ! / < \/ ( s t y l e | s c r i p t ) \s * > / i. test ( salvageContent ) ) {
1904+ if ( looksComplete && / < ( s t y l e | s c r i p t ) \b / i. test ( checkContent ) && ! / < \/ ( s t y l e | s c r i p t ) \s * > / i. test ( checkContent ) ) {
18801905 looksComplete = false ;
18811906 }
18821907
@@ -2183,7 +2208,15 @@ function register(ctx) {
21832208 const uiToolResults = toolResults . results . filter ( tr => ! tr . _deferred ) ;
21842209
21852210 // Accumulate only non-deferred tool results for UI
2186- allToolResults . push ( ...uiToolResults ) ;
2211+ // For append_to_file, replace params.content with the full file content
2212+ // so the committed message shows one unified code block per file
2213+ const enrichedForStorage = uiToolResults . map ( tr => {
2214+ if ( tr . tool === 'append_to_file' && tr . result ?. fullContent ) {
2215+ return { ...tr , params : { ...tr . params , content : tr . result . fullContent } } ;
2216+ }
2217+ return tr ;
2218+ } ) ;
2219+ allToolResults . push ( ...enrichedForStorage ) ;
21872220 capArray ( allToolResults , 50 ) ;
21882221
21892222 // Compress old tool results
@@ -2244,7 +2277,17 @@ function register(ctx) {
22442277 snapFeedback = `\n### Page snapshot after ${ snapResult . triggerTool } \n${ snapResult . snapshotText } \n\n**${ snapResult . elementCount } elements.** Use [ref=N] with browser_click/type.\n` ;
22452278 }
22462279
2247- if ( mainWindow ) mainWindow . webContents . send ( 'mcp-tool-results' , uiToolResults ) ;
2280+ if ( mainWindow ) {
2281+ // For append_to_file, replace params.content with the full file content
2282+ // so the frontend can display one unified code block per file
2283+ const enrichedResults = uiToolResults . map ( tr => {
2284+ if ( tr . tool === 'append_to_file' && tr . result ?. fullContent ) {
2285+ return { ...tr , params : { ...tr . params , content : tr . result . fullContent } } ;
2286+ }
2287+ return tr ;
2288+ } ) ;
2289+ mainWindow . webContents . send ( 'mcp-tool-results' , enrichedResults ) ;
2290+ }
22482291 fullResponseText += toolFeedback + snapFeedback ;
22492292 if ( fullResponseText . length > MAX_RESPONSE_SIZE ) {
22502293 fullResponseText = fullResponseText . substring ( fullResponseText . length - MAX_RESPONSE_SIZE ) ;
@@ -2407,12 +2450,29 @@ function register(ctx) {
24072450 const localTokensUsed = estimateTokens ( fullResponseText ) ;
24082451 _reportTokenStats ( localTokensUsed , mainWindow ) ;
24092452
2453+ // Dedup write tools by filePath: keep only the latest entry per file
2454+ // so the committed message shows one unified code block per file
2455+ const WRITE_TOOLS_DEDUP = new Set ( [ 'write_file' , 'create_file' , 'edit_file' , 'append_to_file' ] ) ;
2456+ const writePathLatest = new Map ( ) ;
2457+ for ( let i = allToolResults . length - 1 ; i >= 0 ; i -- ) {
2458+ const tr = allToolResults [ i ] ;
2459+ if ( WRITE_TOOLS_DEDUP . has ( tr . tool ) && tr . params ?. filePath ) {
2460+ if ( ! writePathLatest . has ( tr . params . filePath ) ) {
2461+ writePathLatest . set ( tr . params . filePath , i ) ;
2462+ }
2463+ }
2464+ }
2465+ const dedupedToolResults = allToolResults . filter ( ( tr , idx ) => {
2466+ if ( ! WRITE_TOOLS_DEDUP . has ( tr . tool ) || ! tr . params ?. filePath ) return true ;
2467+ return writePathLatest . get ( tr . params . filePath ) === idx ;
2468+ } ) ;
2469+
24102470 return {
24112471 success : true ,
24122472 text : cleanResponse ,
24132473 model : modelStatus . modelInfo ?. name || 'local' ,
24142474 tokensUsed : localTokensUsed ,
2415- toolResults : allToolResults . length > 0 ? allToolResults : undefined ,
2475+ toolResults : dedupedToolResults . length > 0 ? dedupedToolResults : undefined ,
24162476 iterations : iteration ,
24172477 } ;
24182478 }
0 commit comments