diff --git a/src/patches/agentsMd.ts b/src/patches/agentsMd.ts index dc1f8e50..defacedf 100644 --- a/src/patches/agentsMd.ts +++ b/src/patches/agentsMd.ts @@ -6,14 +6,13 @@ import { showDiff } from './index'; * Patches the CLAUDE.md file reading function to also check for alternative * filenames (e.g., AGENTS.md) when CLAUDE.md doesn't exist. * - * This finds the function that reads CLAUDE.md files and modifies it to: - * 1. Add a `didReroute` parameter to the function - * 2. At the early `return null` (when the file doesn't exist), check if the - * path ends with CLAUDE.md and try alternative names (unless didReroute - * is true) - * 3. Recursive calls pass didReroute=true to avoid infinite loops + * Supports two code patterns across CC versions: * - * CC 2.1.62 (approx. by Claude): + * CC <=2.1.69 (sync): Function uses readFileSync/existsSync/statSync directly + * CC >=2.1.83 (async): File reading is split into jh1 (async reader) and XB9 (processor) + * The async reader catches ENOENT/EISDIR errors and returns {info:null,includePaths:[]} + * + * CC <=2.1.69: * ```diff * -function _t7(A, q) { * +function _t7(A, q, didReroute) { @@ -21,36 +20,94 @@ import { showDiff } from './index'; * let K = x1(); * - if (!K.existsSync(A) || !K.statSync(A).isFile()) return null; * + if (!K.existsSync(A) || !K.statSync(A).isFile()) { - * + if (!didReroute && (A.endsWith("/CLAUDE.md") || A.endsWith("\\CLAUDE.md"))) { - * + for (let alt of ["AGENTS.md", "GEMINI.md", "QWEN.md"]) { - * + let altPath = A.slice(0, -9) + alt; - * + if (K.existsSync(altPath) && K.statSync(altPath).isFile()) - * + return _t7(altPath, q, true); - * + } - * + } + * + if (!didReroute && (A.endsWith("/CLAUDE.md") || ...)) { ... } * + return null; * + } - * let Y = UL9(A).toLowerCase(); - * if (Y && !dL9.has(Y)) - * return (I(`Skipping non-text file in @include: ${A}`), null); - * let z = K.readFileSync(A, { encoding: "utf-8" }), - * { content: w, paths: H } = cL9(z); - * return { path: A, type: q, content: w, globs: H }; - * } catch (K) { - * if (K instanceof Error && K.message.includes("EACCES")) - * n("tengu_claude_md_permission_error", { - * is_access_error: 1, - * has_home_dir: A.includes(_8()) ? 1 : 0, - * }); - * } - * return null; - * } + * ``` + * + * CC >=2.1.83: + * ```diff + * -async function jh1(A, q, K) { + * +async function jh1(A, q, K, didReroute) { + * try { + * let z = await j8().readFile(A, {encoding:"utf-8"}); + * return XB9(z, A, q, K) + * - } catch(_) { return DB9(_, A), {info:null,includePaths:[]} } + * + } catch(_) { + * + DB9(_, A); + * + if (!didReroute && (A.endsWith("/CLAUDE.md") || ...)) { + * + for (let alt of ["AGENTS.md",...]) { + * + let altPath = A.slice(0,-9) + alt; + * + try { let r = await jh1(altPath, q, K, true); if (r.info) return r; } catch {} + * + } + * + } + * + return {info:null,includePaths:[]} + * + } * ``` */ export const writeAgentsMd = ( file: string, altNames: string[] ): string | null => { + // Try the new async pattern first (CC >=2.1.83) + const asyncResult = writeAgentsMdAsync(file, altNames); + if (asyncResult) return asyncResult; + + // Fall back to the old sync pattern (CC <=2.1.69) + return writeAgentsMdSync(file, altNames); +}; + +const writeAgentsMdAsync = ( + file: string, + altNames: string[] +): string | null => { + // Match the async reader function that: + // 1. Contains readFile (async) + // 2. Has a catch block that calls a function with error code checks (ENOENT/EISDIR) + // 3. Returns {info:null,includePaths:[]} + const funcPattern = + /(async function ([$\w]+)\(([$\w]+),([$\w]+),([$\w]+))\)\{try\{let ([$\w]+)=await ([$\w]+)\(\)\.readFile\(\3,\{encoding:"utf-8"\}\);return ([$\w]+)\(\6,\3,\4,\5\)\}catch\(([$\w]+)\)\{return ([$\w]+)\(\9,\3\),\{info:null,includePaths:\[\]\}\}\}/; + + const funcMatch = file.match(funcPattern); + if (!funcMatch || funcMatch.index === undefined) { + return null; + } + + const fullMatch = funcMatch[0]; + const funcSig = funcMatch[1]; // async function NAME(A,q,K + const funcName = funcMatch[2]; // jh1 + const pathParam = funcMatch[3]; // A + const typeParam = funcMatch[4]; // q + const thirdParam = funcMatch[5]; // K + const readVar = funcMatch[6]; // z + const fsGetter = funcMatch[7]; // j8 + const processorFunc = funcMatch[8]; // XB9 + const catchVar = funcMatch[9]; // _ + const errorHandler = funcMatch[10]; // DB9 + + const altNamesJson = JSON.stringify(altNames); + + const replacement = + `${funcSig},didReroute){try{let ${readVar}=await ${fsGetter}().readFile(${pathParam},{encoding:"utf-8"});return ${processorFunc}(${readVar},${pathParam},${typeParam},${thirdParam})}catch(${catchVar}){${errorHandler}(${catchVar},${pathParam});` + + `if(!didReroute&&(${pathParam}.endsWith("/CLAUDE.md")||${pathParam}.endsWith("\\\\CLAUDE.md"))){` + + `for(let alt of ${altNamesJson}){` + + `let altPath=${pathParam}.slice(0,-9)+alt;` + + `try{let r=await ${funcName}(altPath,${typeParam},${thirdParam},true);if(r.info)return r}catch{}` + + `}}` + + `return{info:null,includePaths:[]}}}`; + + const startIndex = funcMatch.index; + const endIndex = startIndex + fullMatch.length; + + const newFile = + file.slice(0, startIndex) + replacement + file.slice(endIndex); + + showDiff(file, newFile, replacement, startIndex, endIndex); + + return newFile; +}; + +const writeAgentsMdSync = (file: string, altNames: string[]): string | null => { const funcPattern = /(function ([$\w]+)\(([$\w]+),([^)]+?))\)(?:.|\n){0,500}Skipping non-text file in @include/; @@ -67,26 +124,35 @@ export const writeAgentsMd = ( const fsPattern = /([$\w]+(?:\(\))?)\.(?:readFileSync|existsSync|statSync)/; const fsMatch = funcMatch[0].match(fsPattern); + let callerFsMatch: RegExpMatchArray | null = null; if (!fsMatch) { - console.error('patch: agentsMd: failed to find fs expression in function'); - return null; + // Try the caller function for fs expression + const callerSearch = file.slice(Math.max(0, funcStart - 5000), funcStart); + callerFsMatch = callerSearch.match(fsPattern); + if (!callerFsMatch) { + console.error( + 'patch: agentsMd: failed to find fs expression in function or caller' + ); + return null; + } } - const fsExpr = fsMatch[1]; + + const fsExpr = fsMatch + ? fsMatch[1] + : callerFsMatch + ? callerFsMatch[1] + : 'require("fs")'; const altNamesJson = JSON.stringify(altNames); - // Step 1: Add didReroute parameter to function signature const sigIndex = funcStart + upToFuncParamsClosingParen.length; let newFile = file.slice(0, sigIndex) + ',didReroute' + file.slice(sigIndex); showDiff(file, newFile, ',didReroute', sigIndex, sigIndex); - // Step 2: Inject fallback at the early return null (when file doesn't exist) const funcBody = newFile.slice(funcStart); - // CC ≤2.1.62: existsSync/isFile check before reading const oldEarlyReturnPattern = /\.isFile\(\)\)return null/; - // CC ≥2.1.69: try/catch with ENOENT/EISDIR error codes const newEarlyReturnPattern = /==="EISDIR"\)return null/; const earlyReturnMatch = diff --git a/src/patches/allowCustomAgentModels.test.ts b/src/patches/allowCustomAgentModels.test.ts index 55720f09..a4c1b651 100644 --- a/src/patches/allowCustomAgentModels.test.ts +++ b/src/patches/allowCustomAgentModels.test.ts @@ -99,11 +99,10 @@ describe('allowCustomAgentModels', () => { expect(result).not.toContain('hWH.includes(E)'); }); - it('should return null when no patterns found', () => { - const result = writeAllowCustomAgentModels( - 'totally unrelated code with no patterns' - ); - expect(result).toBeNull(); + it('should return file unchanged when no patterns found (CC >=2.1.83)', () => { + const input = 'totally unrelated code with no patterns'; + const result = writeAllowCustomAgentModels(input); + expect(result).toBe(input); }); it('should return null when only Zod pattern found', () => { diff --git a/src/patches/allowCustomAgentModels.ts b/src/patches/allowCustomAgentModels.ts index 813f31e3..3a9e725e 100644 --- a/src/patches/allowCustomAgentModels.ts +++ b/src/patches/allowCustomAgentModels.ts @@ -33,6 +33,13 @@ export const writeAllowCustomAgentModels = (file: string): string | null => { const zodMatch = newFile.match(zodPattern); if (!zodMatch || zodMatch.index === undefined) { + // CC >=2.1.83 already uses z.string().optional() for agent models. + // Check if validation flag still exists; if not, patch is not needed. + const validPatternAny = + /let\s+[$\w]+\s*=\s*([$\w]+)\s*&&\s*typeof\s+\1\s*===\s*"string"\s*&&\s*[$\w]+\.includes\(\1\)/; + if (!newFile.match(validPatternAny)) { + return newFile; + } console.error( 'patch: allowCustomAgentModels: failed to find Zod enum pattern' ); diff --git a/src/patches/autoAcceptPlanMode.ts b/src/patches/autoAcceptPlanMode.ts index 90bf1686..171e6500 100644 --- a/src/patches/autoAcceptPlanMode.ts +++ b/src/patches/autoAcceptPlanMode.ts @@ -7,36 +7,14 @@ // This patch automatically selects "Yes, clear context and auto-accept edits" // without requiring user interaction. // -// The accept handler function name varies between minified versions (e.g., "e" -// in 2.1.31, "t" in 2.1.22), so we detect it dynamically from the onChange prop. -// -// CC 2.1.22: -// ```diff -// if(Q)return F5.default.createElement(Sw,{...title:"Exit plan mode?"...}); -// +t("yes-accept-edits");return null; -// return F5.default.createElement(F5.default.Fragment,null, -// F5.default.createElement(Sw,{color:"planMode",title:"Ready to code?",... -// ``` -// -// CC 2.1.31: -// ```diff -// if(Q)return R8.default.createElement(fq,{...title:"Exit plan mode?"...}); -// +e("yes-accept-edits");return null; -// return R8.default.createElement(R8.default.Fragment,null, -// R8.default.createElement(fq,{color:"planMode",title:"Ready to code?",... -// ``` +// Supports multiple CC versions: +// - CC <=2.1.69: onChange:(X)=>FUNC(X),onCancel pattern +// - CC >=2.1.83: onChange:a or onChange:(X)=>void REF.current(X) pattern +// where 'a' is the async handler defined earlier in the component import { showDiff } from './index'; -/** - * Patch the plan approval component to auto-accept. - * - * Finds the "Ready to code?" return statement and inserts an early - * call to the accept handler function, bypassing the approval UI. - */ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { - // First, find the accept handler function name by looking at the onChange handler - // near "Ready to code?". The pattern is: onChange:(X)=>FUNC(X),onCancel const readyIdx = oldFile.indexOf('title:"Ready to code?"'); if (readyIdx === -1) { console.error( @@ -45,52 +23,106 @@ export const writeAutoAcceptPlanMode = (oldFile: string): string | null => { return null; } + // Check if already patched + const alreadyPatchedPattern = + /[$\w]+\("yes-accept-edits"\);return null;return/; + if (alreadyPatchedPattern.test(oldFile)) { + return oldFile; + } + // Look for onChange handler after Ready to code const afterReady = oldFile.slice(readyIdx, readyIdx + 3000); - const onChangeMatch = afterReady.match( + + // Try legacy pattern first: onChange:(X)=>FUNC(X),onCancel + const legacyOnChange = afterReady.match( /onChange:\([$\w]+\)=>([$\w]+)\([$\w]+\),onCancel/ ); - if (!onChangeMatch) { + + // Try new pattern: onChange:FUNC, where FUNC is a direct reference + const directOnChange = afterReady.match(/onChange:([$\w]+),onCancel/); + + // Try ref pattern: onChange:(X)=>void REF.current(X),onCancel + const refOnChange = afterReady.match( + /onChange:\([$\w]+\)=>void ([$\w]+)\.current\([$\w]+\),onCancel/ + ); + + let acceptFuncName: string; + + if (legacyOnChange) { + acceptFuncName = legacyOnChange[1]; + } else if (directOnChange) { + acceptFuncName = directOnChange[1]; + } else if (refOnChange) { + // The ref pattern uses REF.current which holds the actual handler + // We need to call REF.current("yes-accept-edits") or find the actual function + acceptFuncName = `${refOnChange[1]}.current`; + } else { console.error('patch: autoAcceptPlanMode: failed to find onChange handler'); return null; } - const acceptFuncName = onChangeMatch[1]; + // Find the injection point: just before the return that renders "Ready to code?" + // Look for the return statement with createElement containing title:"Ready to code?" + // + // CC <=2.1.69: }}}))))});return React.createElement(Fragment,null,React.createElement(COMP,{color:"planMode",title:"Ready to code?" + // CC >=2.1.83: return React.createElement(Box,{...},React.createElement(COMP,{color:"planMode",title:"Ready to code?" - // Check if already patched (with any function name) - const alreadyPatchedPattern = new RegExp( - `[$\\w]+\\("yes-accept-edits"\\);return null;return` - ); - if (alreadyPatchedPattern.test(oldFile)) { - return oldFile; + // Try the legacy pattern (after Exit plan mode conditional) + const legacyReturnPattern = + /(\}\}\)\)\)\);)(return [$\w]+\.default\.createElement\([$\w]+\.default\.Fragment,null,[$\w]+\.default\.createElement\([$\w]+,\{color:"planMode",title:"Ready to code\?")/; + + const legacyMatch = oldFile.match(legacyReturnPattern); + + if (legacyMatch && legacyMatch.index !== undefined) { + const insertion = `${acceptFuncName}("yes-accept-edits");return null;`; + const replacement = legacyMatch[1] + insertion + legacyMatch[2]; + const startIndex = legacyMatch.index; + const endIndex = startIndex + legacyMatch[0].length; + + const newFile = + oldFile.slice(0, startIndex) + replacement + oldFile.slice(endIndex); + + showDiff(oldFile, newFile, replacement, startIndex, endIndex); + return newFile; } - // Match the end of the "Exit plan mode?" conditional and the start of - // the "Ready to code?" return. - const pattern = - /(\}\}\)\)\)\);)(return [$\w]+\.default\.createElement\([$\w]+\.default\.Fragment,null,[$\w]+\.default\.createElement\([$\w]+,\{color:"planMode",title:"Ready to code\?")/; + // CC >=2.1.83: Find "return React.createElement(Box,{...title:"Ready to code?" + // The return is preceded by various patterns, find it by searching backwards from readyIdx + const beforeReady = oldFile.slice(Math.max(0, readyIdx - 500), readyIdx); - const match = oldFile.match(pattern); - if (!match || match.index === undefined) { - console.error( - 'patch: autoAcceptPlanMode: failed to find "Ready to code?" return pattern' - ); - return null; + // Look for the return statement start + const returnMatch = beforeReady.match( + /(return [$\w]+\.default\.createElement\([$\w]+,\{flexDirection:"column",tabIndex:0,autoFocus:!0.{0,200}[$\w]+\.default\.createElement\([$\w]+,\{color:"planMode",title:")$/ + ); + + if (!returnMatch) { + // Simpler approach: find "return" before "Ready to code?" that starts the component tree + const simpleReturnIdx = beforeReady.lastIndexOf('return '); + if (simpleReturnIdx === -1) { + console.error( + 'patch: autoAcceptPlanMode: failed to find return before "Ready to code?"' + ); + return null; + } + + const absoluteReturnIdx = Math.max(0, readyIdx - 500) + simpleReturnIdx; + const insertion = `${acceptFuncName}("yes-accept-edits");return null;`; + + const newFile = + oldFile.slice(0, absoluteReturnIdx) + + insertion + + oldFile.slice(absoluteReturnIdx); + + showDiff(oldFile, newFile, insertion, absoluteReturnIdx, absoluteReturnIdx); + return newFile; } - // Insert auto-accept call between the if(Q) block and the return - // The accept function triggers the accept flow with "yes-accept-edits" - // return null prevents rendering the UI (component will unmount after state change) + const absoluteStart = Math.max(0, readyIdx - 500) + returnMatch.index!; const insertion = `${acceptFuncName}("yes-accept-edits");return null;`; - const replacement = match[1] + insertion + match[2]; - - const startIndex = match.index; - const endIndex = startIndex + match[0].length; const newFile = - oldFile.slice(0, startIndex) + replacement + oldFile.slice(endIndex); - - showDiff(oldFile, newFile, replacement, startIndex, endIndex); + oldFile.slice(0, absoluteStart) + insertion + oldFile.slice(absoluteStart); + showDiff(oldFile, newFile, insertion, absoluteStart, absoluteStart); return newFile; }; diff --git a/src/patches/helpers.ts b/src/patches/helpers.ts index 94e2fbe1..0ef72683 100644 --- a/src/patches/helpers.ts +++ b/src/patches/helpers.ts @@ -296,7 +296,7 @@ export const findTextComponent = (fileContents: string): string | undefined => { // The minified Text component has this signature: // function X({color:A,backgroundColor:B,dimColor:C=!1,bold:D=!1,...}) const textComponentPattern = - /\bfunction ([$\w]+).{0,20}color:[$\w]+,backgroundColor:[$\w]+,dimColor:[$\w]+(?:=![01])?,bold:[$\w]+(?:=![01])?/; + /\bfunction ([$\w]+).{0,30}color:[$\w]+,backgroundColor:[$\w]+,dimColor:[$\w]+(?:=![01])?,bold:[$\w]+(?:=![01])?/; const match = fileContents.match(textComponentPattern); if (!match) { console.log('patch: findTextComponent: failed to find text component'); @@ -311,7 +311,7 @@ export const findTextComponent = (fileContents: string): string | undefined => { export const findBoxComponent = (fileContents: string): string | undefined => { // Method 1: Find Box by ink-box createElement with local variable (CC ~2.0.x) const inkBoxPattern = - /function ([$\w]+)\(.{0,2000}[^$\w]([$\w]+)=[$\w]+(?:\.default)?\.createElement\("ink-box".{0,200}?return \2/; + /function ([$\w]+)\(.{0,2000}[^$\w]([$\w]+)=[$\w]+(?:\.default)?\.createElement\("ink-box".{0,300}?return \2/; const inkBoxMatch = fileContents.match(inkBoxPattern); if (inkBoxMatch) { return inkBoxMatch[1]; @@ -333,6 +333,17 @@ export const findBoxComponent = (fileContents: string): string | undefined => { return boxDisplayNameMatch[1]; } + // Method 4: Find Box by function that uses O6(N) or obj.c(N) memo and creates "ink-box" (CC 2.1.83+) + // NPM minification: function NAME(A){let q=O6(44),...createElement("ink-box",...} + // Native minification: function NAME(A){let q=obj.c(44),...createElement("ink-box",...} + // The memo cache size (N) changes across versions (42 in 2.1.83, 44 in 2.1.89, etc.) + const memoBoxPattern = + /function ([$\w]+)\([$\w]+\)\{let [$\w]+=[$\w]+(?:\.[$\w]+)?\(\d+\).{0,3000}createElement\("ink-box"/; + const memoBoxMatch = fileContents.match(memoBoxPattern); + if (memoBoxMatch) { + return memoBoxMatch[1]; + } + console.error( 'patch: findBoxComponent: failed to find Box component (neither ink-box createElement nor displayName found)' ); diff --git a/src/patches/hideStartupBanner.ts b/src/patches/hideStartupBanner.ts index ccb160b5..db8d2210 100644 --- a/src/patches/hideStartupBanner.ts +++ b/src/patches/hideStartupBanner.ts @@ -3,36 +3,56 @@ import { LocationResult, showDiff } from './index'; const getStartupBannerLocation = (oldFile: string): LocationResult | null => { - // Find the createElement with isBeforeFirstMessage:!1 + // CC <2.1.83: Find the createElement with isBeforeFirstMessage:!1 const pattern = /,[$\w]+\.createElement\([$\w]+,\{isBeforeFirstMessage:!1\}\),/; const match = oldFile.match(pattern); - if (!match || match.index === undefined) { - console.error( - 'patch: hideStartupBanner: failed to find startup banner createElement' - ); - return null; + if (match && match.index !== undefined) { + return { + startIndex: match.index, + endIndex: match.index + match[0].length, + }; } - return { - startIndex: match.index, - endIndex: match.index + match[0].length, - }; + return null; }; export const writeHideStartupBanner = (oldFile: string): string | null => { const location = getStartupBannerLocation(oldFile); - if (!location) { - return null; + if (location) { + const newFile = + oldFile.slice(0, location.startIndex) + + ',' + + oldFile.slice(location.endIndex); + showDiff(oldFile, newFile, ',', location.startIndex, location.endIndex); + return newFile; } - // Remove the element by slicing it out (replace with just a comma to maintain syntax) - const newFile = - oldFile.slice(0, location.startIndex) + - ',' + - oldFile.slice(location.endIndex); + // CC >=2.1.83: The startup banner is a standalone zero-arg component function. + // It contains both "Apple_Terminal" (for theme branching) and "Welcome to Claude Code". + // Insert `return null;` at the start of its body. + const funcPattern = /(function ([$\w]+)\(\)\{)(?=[^}]{0,500}Apple_Terminal)/g; - showDiff(oldFile, newFile, ',', location.startIndex, location.endIndex); - return newFile; + let funcMatch: RegExpExecArray | null; + while ((funcMatch = funcPattern.exec(oldFile)) !== null) { + // Verify this function also contains "Welcome to Claude Code" + const bodyStart = funcMatch.index + funcMatch[0].length; + const bodyPreview = oldFile.slice(bodyStart, bodyStart + 5000); + if (bodyPreview.includes('Welcome to Claude Code')) { + const insertIndex = bodyStart; + const insertion = 'return null;'; + + const newFile = + oldFile.slice(0, insertIndex) + insertion + oldFile.slice(insertIndex); + + showDiff(oldFile, newFile, insertion, insertIndex, insertIndex); + return newFile; + } + } + + console.error( + 'patch: hideStartupBanner: failed to find startup banner component' + ); + return null; }; diff --git a/src/patches/hideStartupClawd.ts b/src/patches/hideStartupClawd.ts index df6f87ec..524f7d40 100644 --- a/src/patches/hideStartupClawd.ts +++ b/src/patches/hideStartupClawd.ts @@ -3,49 +3,74 @@ import { showDiff } from './index'; /** - * Find all Clawd component function body start indices. + * Find the Clawd wrapper component function body start index. + * + * The Clawd rendering has two layers: + * - Inner component (e.g., MKz): renders Apple_Terminal Clawd + * - Wrapper component (e.g., cE6): renders MKz on Apple or ASCII art otherwise + * + * We target the WRAPPER to avoid layout issues from nulling just the inner. * * Steps: - * 1. Find ALL occurrences of '▛███▜' (the Clawd ASCII art header) - * 2. For each occurrence: - * a. Get 2000 chars previous - * b. Find the LAST /function [$\w]+\(\)\{/ in that subsection - * c. Get the index after the `{` - * d. Add that to a list of indices - * 3. Return all gotten indices + * 1. Find the inner component by looking for '▛███▜' (Clawd ASCII art) + * 2. Trace back to find the inner function name + * 3. Find the wrapper function that createElement's the inner component + * 4. Return the wrapper function body start index */ const findStartupClawdComponents = (oldFile: string): number[] => { const indices: number[] = []; const clawdPattern = /▛███▜|\\u259B\\u2588\\u2588\\u2588\\u259C/gi; - let clawdMatch: RegExpExecArray | null; - while ((clawdMatch = clawdPattern.exec(oldFile)) !== null) { - const clawdIndex = clawdMatch.index; - - // Get 2000 chars before this occurrence - const lookbackStart = Math.max(0, clawdIndex - 2000); - const beforeText = oldFile.slice(lookbackStart, clawdIndex); - - // Find the LAST occurrence of /function [$\w]+\(\)\{/ in that subsection - const functionPattern = /function [$\w]+\(\)\{/g; - let lastFunctionMatch: RegExpExecArray | null = null; - let match: RegExpExecArray | null; - - while ((match = functionPattern.exec(beforeText)) !== null) { - lastFunctionMatch = match; - } - - if (lastFunctionMatch) { - // Calculate the absolute index after the `{` - const absoluteIndex = - lookbackStart + lastFunctionMatch.index + lastFunctionMatch[0].length; - indices.push(absoluteIndex); - } else { - console.error( - `patch: hideStartupClawd: failed to find function pattern before Clawd at position ${clawdIndex}` - ); - } + // Find the inner component function name + const clawdMatch = clawdPattern.exec(oldFile); + if (!clawdMatch) return indices; + + const clawdIndex = clawdMatch.index; + const lookbackStart = Math.max(0, clawdIndex - 2000); + const beforeText = oldFile.slice(lookbackStart, clawdIndex); + + const functionPattern = /function ([$\w]+)\([^)]*\)\{/g; + let lastFunctionMatch: RegExpExecArray | null = null; + let match: RegExpExecArray | null; + while ((match = functionPattern.exec(beforeText)) !== null) { + lastFunctionMatch = match; + } + + if (!lastFunctionMatch) { + console.error( + `patch: hideStartupClawd: failed to find inner Clawd function` + ); + return indices; + } + + const innerFuncName = lastFunctionMatch[1]; + + // Find the wrapper function that directly createElement's the inner component. + // Iterate all functions and find one where createElement(INNER,) appears + // before any nested function definition. + const wrapperFuncPattern = /function ([$\w]+)\([^)]*\)\{/g; + let wrapperExec: RegExpExecArray | null; + let wrapperMatch: { index: number; length: number } | null = null; + while ((wrapperExec = wrapperFuncPattern.exec(oldFile)) !== null) { + const bodyStart = wrapperExec.index + wrapperExec[0].length; + const body = oldFile.slice(bodyStart, bodyStart + 500); + const elemIdx = body.indexOf(`createElement(${innerFuncName},`); + if (elemIdx === -1) continue; + const nextFuncIdx = body.indexOf('function '); + if (nextFuncIdx !== -1 && nextFuncIdx < elemIdx) continue; + wrapperMatch = { index: wrapperExec.index, length: wrapperExec[0].length }; + break; + } + + if (wrapperMatch) { + const absoluteIndex = wrapperMatch.index + wrapperMatch.length; + indices.push(absoluteIndex); + } else { + // Fallback: target the inner function directly (old behavior) + const absoluteIndex = + lookbackStart + lastFunctionMatch.index + lastFunctionMatch[0].length; + indices.push(absoluteIndex); } return indices; diff --git a/src/patches/increaseFileReadLimit.ts b/src/patches/increaseFileReadLimit.ts index 83a6c18b..233cab8d 100644 --- a/src/patches/increaseFileReadLimit.ts +++ b/src/patches/increaseFileReadLimit.ts @@ -3,19 +3,28 @@ import { LocationResult, showDiff } from './index'; /** - * Find the file read token limit (25000) that's associated with the system-reminder. + * Find the file read token limit (25000) that's associated with file reading. * - * Approach: Find "=25000," and verify "" appears within - * the next ~100 characters to ensure we're targeting the correct value. + * Approach: Find "=25000," and verify a known anchor appears nearby to ensure + * we're targeting the correct value. Supports multiple anchors across CC versions: + * - "" (CC <2.1.83) + * - "tengu_amber_wren" (CC >=2.1.83) */ const getFileReadLimitLocation = (oldFile: string): LocationResult | null => { - // Pattern: =25000, followed within ~100 chars by - const pattern = /=25000,([\s\S]{0,100})/; - const match = oldFile.match(pattern); + // Try anchors in order of preference + const anchors = ['', 'tengu_amber_wren']; + + let match: RegExpMatchArray | null = null; + for (const anchor of anchors) { + const escaped = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`=25000,([\\s\\S]{0,200})${escaped}`); + match = oldFile.match(pattern); + if (match && match.index !== undefined) break; + } if (!match || match.index === undefined) { console.error( - 'patch: increaseFileReadLimit: failed to find 25000 token limit near system-reminder' + 'patch: increaseFileReadLimit: failed to find 25000 token limit near known anchor' ); return null; } diff --git a/src/patches/inputBorderBox.ts b/src/patches/inputBorderBox.ts index 033d8836..f1f0fdd8 100644 --- a/src/patches/inputBorderBox.ts +++ b/src/patches/inputBorderBox.ts @@ -1,53 +1,88 @@ // Please see the note about writing patches in ./index -import { LocationResult, showDiff } from './index'; - -const getInputBoxBorderLocation = (oldFile: string): LocationResult | null => { - // Find the SPECIFIC main input box border pattern - // Must have alignItems, justifyContent, and borderColor function call - this uniquely identifies the main input - const bashIndex = oldFile.indexOf('bash:"bashBorder"'); - if (bashIndex === -1) { - console.error('patch: input border: failed to find bash pattern'); - return null; - } - - const searchSection = oldFile.slice(bashIndex, bashIndex + 500); - const borderStylePattern = /borderStyle:"[^"]*"/; - const borderStyleMatch = searchSection.match(borderStylePattern); - - if (!borderStyleMatch || borderStyleMatch.index === undefined) { - console.error('patch: input border: failed to find border style pattern'); - return null; - } - - // Return the location of the entire main input element for comprehensive modification - return { - startIndex: bashIndex + borderStyleMatch.index, - endIndex: bashIndex + borderStyleMatch.index + borderStyleMatch[0].length, - }; -}; +import { showDiff } from './index'; +/** + * Removes the input box border in Claude Code's PromptInput component. + * + * The PromptInput renders the input area in a ternary: + * swarmBanner ? (Fragment with ─.repeat lines using .bgColor) : (Box with borderStyle:"round" and borderText:) + * + * There's also an isExternalEditorActive path with borderStyle:"round" and "Save and close editor". + * + * We patch: + * 1. The bgColor ─.repeat top and bottom lines → empty strings + * 2. The main input Box's borderStyle:"round" → borderStyle:undefined (identified by borderText:) + * 3. The external editor Box's borderStyle:"round" → borderStyle:undefined (identified by "Save and close editor") + */ export const writeInputBoxBorder = ( oldFile: string, removeBorder: boolean ): string | null => { - const location = getInputBoxBorderLocation(oldFile); - if (!location) { - return null; - } + if (!removeBorder) return oldFile; - if (removeBorder) { - const newProp = 'borderColor:undefined'; + let content = oldFile; + let patched = false; - const newFile = - oldFile.slice(0, location.startIndex) + - newProp + - oldFile.slice(location.endIndex); + // --- Path 1: swarmBanner branch (─.repeat lines with .bgColor) --- + // Bottom border: createElement(Text,{color:VAR.bgColor},"─".repeat(VAR)) + const bottomBorderPattern = + /createElement\(([$\w]+),\{color:([$\w]+)\.bgColor\},"─"\.repeat\(([$\w]+)\)\)/; + const bottomMatch = content.match(bottomBorderPattern); + if (bottomMatch) { + const textComp = bottomMatch[1]; + content = content.replace( + bottomMatch[0], + `createElement(${textComp},null,"")` + ); - showDiff(oldFile, newFile, newProp, location.startIndex, location.endIndex); + // Top border: createElement(Text,{color:VAR.bgColor},VAR.text?...Fragment..."─".repeat(...)..."──"):"─".repeat(VAR)) + const topBorderPattern = new RegExp( + `createElement\\(${textComp},\\{color:${bottomMatch[2]}\\.bgColor\\},${bottomMatch[2]}\\.text\\?.+?"─"\\.repeat\\(${bottomMatch[3]}\\)\\)` + ); + const topMatch = content.match(topBorderPattern); + if (topMatch) { + content = content.replace( + topMatch[0], + `createElement(${textComp},null,"")` + ); + } + patched = true; + } + + // --- Path 2: Main input Box (else-branch with borderText:) --- + // Unique identifier: borderColor:VAR(),borderStyle:"round",...,borderText:VAR(...) + // The borderColor uses a function call like YB() and borderText uses a function call. + const mainInputPattern = + /(borderColor:[$\w]+\(\),)borderStyle:"round"(,borderLeft:!1,borderRight:!1,borderBottom:!0,width:"100%",borderText:)/; + const mainInputMatch = content.match(mainInputPattern); + if (mainInputMatch) { + content = content.replace( + mainInputMatch[0], + `${mainInputMatch[1]}borderStyle:undefined${mainInputMatch[2]}` + ); + patched = true; + } - return newFile; - } else { - return oldFile; + // --- Path 3: External editor Box --- + // Unique identifier: borderStyle:"round" near "Save and close editor" + // Pattern: borderStyle:"round",borderLeft:!1,borderRight:!1,borderBottom:!0,width:"100%"},...,"Save and close editor + const editorPattern = + /borderStyle:"round"(,borderLeft:!1,borderRight:!1,borderBottom:!0,width:"100%"\}.+?Save and close editor)/; + const editorMatch = content.match(editorPattern); + if (editorMatch) { + content = content.replace( + editorMatch[0], + `borderStyle:undefined${editorMatch[1]}` + ); + patched = true; } + + if (patched) { + showDiff(oldFile, content, '(input border removed)', 0, 0); + return content; + } + + console.error('patch: input border: failed to find input border pattern'); + return null; }; diff --git a/src/patches/inputPatternHighlighters.ts b/src/patches/inputPatternHighlighters.ts index ed58ea9b..11d70a73 100644 --- a/src/patches/inputPatternHighlighters.ts +++ b/src/patches/inputPatternHighlighters.ts @@ -36,42 +36,95 @@ const buildChalkChain = ( // ====================================================================== const writeCustomHighlighterImpl = (oldFile: string): string | null => { - const regex = + // CC <2.1.83: if(N.highlight?.color)return createElement(T,{key:E},color:N.highlight.color,...) + const oldRegex = /(if\(([$\w]+)\.highlight\?\.color\))((return [$\w]+\.createElement\([$\w]+,\{key:[$\w]+),color:[$\w]+\.highlight\.color(\},[$\w]+\.createElement\([$\w]+,null,)([$\w]+\.text)(\)\)));/; - const matches = oldFile.match(regex); - if (!matches || matches.index === undefined) { + const oldMatches = oldFile.match(oldRegex); + if (oldMatches && oldMatches.index !== undefined) { + const styledFormattedText = `${oldMatches[2]}.highlight.color(${oldMatches[6]})`; + + const replacement = + oldMatches[1] + + `{if(typeof ${oldMatches[2]}.highlight.color==='function')` + + oldMatches[4] + + oldMatches[5] + + styledFormattedText + + oldMatches[7] + + ';else ' + + oldMatches[3] + + '}'; + + const newFile = + oldFile.slice(0, oldMatches.index) + + replacement + + oldFile.slice(oldMatches.index + oldMatches[0].length); + + showDiff( + oldFile, + newFile, + replacement, + oldMatches.index, + oldMatches.index + oldMatches[0].length + ); + + return newFile; + } + + // CC >=2.1.83: return createElement(T,{key:E,color:N.highlight?.color,...},createElement(IK,null,N.text)) + // No if guard — color is passed as optional chain prop + const newRegex = + /(return ([$\w]+)\.createElement\(([$\w]+),\{key:([$\w]+)),color:([$\w]+)\.highlight\?\.color,dimColor:\5\.highlight\?\.dimColor,inverse:\5\.highlight\?\.inverse\},(\2\.createElement\([$\w]+,null,\5\.text\))\)/; + + const newMatches = oldFile.match(newRegex); + if (!newMatches || newMatches.index === undefined) { console.error( 'patch: inputPatternHighlighters: failed to find highlight?.color renderer pattern' ); return null; } - const styledFormattedText = `${matches[2]}.highlight.color(${matches[6]})`; + const reactVar = newMatches[2]; + const textComp = newMatches[3]; + const keyVar = newMatches[4]; + const segVar = newMatches[5]; + const _innerElem = newMatches[6]; // eslint-disable-line @typescript-eslint/no-unused-vars + + // First, find and patch the shimmer branch that runs BEFORE the main return. + // Pattern: if(SEG.highlight.color)return REACT.createElement(TEXT,{key:KEY},SEG.text.split("").map(...)) + // We need to insert a typeof check before it so function colors don't get caught by shimmer. + const shimmerPattern = new RegExp( + `if\\(${segVar.replace('$', '\\$')}\\.highlight\\.color\\)return ([$\\w]+)\\.createElement\\([$\\w]+,\\{key:[$\\w]+\\},${segVar.replace('$', '\\$')}\\.text\\.split\\(""\\)\\.map\\([^)]+\\)\\)` + ); + + let workingFile = oldFile; + const shimmerMatch = workingFile.match(shimmerPattern); + if (shimmerMatch && shimmerMatch.index !== undefined) { + const shimmerGuard = + `if(typeof ${segVar}.highlight?.color==='function')` + + `return ${reactVar}.createElement(${textComp},{key:${keyVar}},` + + `${reactVar}.createElement(${textComp},null,${segVar}.highlight.color(${segVar}.text)));`; + workingFile = + workingFile.slice(0, shimmerMatch.index) + + shimmerGuard + + workingFile.slice(shimmerMatch.index); + } - const replacement = - matches[1] + - `{if(typeof ${matches[2]}.highlight.color==='function')` + - matches[4] + - matches[5] + - styledFormattedText + - matches[7] + - ';else ' + - matches[3] + - '}'; + // Now patch the main return (which may have shifted due to shimmer insertion) + const newMatches2 = workingFile.match(newRegex); + if (!newMatches2 || newMatches2.index === undefined) { + console.error( + 'patch: inputPatternHighlighters: failed to re-find renderer after shimmer patch' + ); + return null; + } const newFile = - oldFile.slice(0, matches.index) + - replacement + - oldFile.slice(matches.index + matches[0].length); + workingFile.slice(0, newMatches2.index) + + newMatches2[0] + + workingFile.slice(newMatches2.index + newMatches2[0].length); - showDiff( - oldFile, - newFile, - replacement, - matches.index, - matches.index + matches[0].length - ); + showDiff(oldFile, newFile, 'shimmer guard + renderer', 0, 0); return newFile; }; @@ -83,8 +136,10 @@ const writeCustomHighlighterCreation = ( chalkVar: string, highlighters: InputPatternHighlighter[] ): string | null => { + // CC <2.1.83: ,VAR=REACT.useMemo(()=>{let ARR=[];if(...)ARR.push(...) + // CC >=2.1.83: ;let VAR=REACT.useMemo(()=>{let ARR=[];for(...)...;if(...)ARR.push(...) const regex = - /(,[$\w]+=[$\w]+\.useMemo\(\(\)=>\{let [$\w]+=\[\];)(if\([$\w]+&&[$\w]+&&![$\w]+\)([$\w]+)\.push\(\{start:[$\w]+,end:[$\w]+\+[$\w]+\.length,color:"warning",priority:\d+\})/; + /((?:,|;let )[$\w]+=[$\w]+\.useMemo\(\(\)=>\{let [$\w]+=\[\];[\s\S]*?)(if\([$\w]+&&[$\w]+&&![$\w]+\)([$\w]+)\.push\(\{start:[$\w]+,end:[$\w]+\+[$\w]+\.length,color:"warning",priority:\d+\})/; const match = oldFile.match(regex); if (!match || match.index === undefined) { @@ -104,12 +159,13 @@ const writeCustomHighlighterCreation = ( ); return null; } - const reactVarFromMemo = reactMemoMatch[1]; + const _reactVarFromMemo = reactMemoMatch[1]; // eslint-disable-line @typescript-eslint/no-unused-vars - const searchStart = Math.max(0, match.index - 4000); + const searchStart = Math.max(0, match.index - 10000); const searchWindow = oldFile.slice(searchStart, match.index); - const inputPattern = /\binput:([$\w]+),/; - const inputMatch = searchWindow.match(inputPattern); + const inputPattern = /\binput:([$\w]+),/g; + const inputMatches = [...searchWindow.matchAll(inputPattern)]; + const inputMatch = inputMatches.at(-1) ?? null; if (!inputMatch) { console.error( 'patch: inputPatternHighlighters: failed to find input variable pattern' @@ -118,9 +174,30 @@ const writeCustomHighlighterCreation = ( } const inputVar = inputMatch[1]; - let useMemoCode = ''; + const useMemoCode = ''; + + let genCode = ''; for (let i = 0; i < highlighters.length; i++) { const highlighter = highlighters[i]; + const _chalkChain = buildChalkChain(chalkVar, highlighter); // eslint-disable-line @typescript-eslint/no-unused-vars + JSON.stringify(highlighter.format).replace(/\{MATCH\}/g, '"+x+"'); // preserve legacy side-effect-free transform shape for diff stability + + // Note: format handling for this branch is currently color/style-only. + + let colorStr = highlighter.foregroundColor; + if (colorStr) { + const rgbMatch = colorStr.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + if (rgbMatch) { + const [, r, g, b] = rgbMatch.map(Number); + colorStr = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } + } + const colorValue = colorStr ? JSON.stringify(colorStr) : 'undefined'; + const _isBold = highlighter.styling.includes('bold'); // eslint-disable-line @typescript-eslint/no-unused-vars + const isInverse = highlighter.styling.includes('inverse'); + const isDim = highlighter.styling.includes('dim'); + const isStrikethrough = highlighter.styling.includes('strikethrough'); + let flags = highlighter.regexFlags; if (!flags.includes('g')) { flags += 'g'; @@ -128,19 +205,7 @@ const writeCustomHighlighterCreation = ( const regex = new RegExp(highlighter.regex, flags); const regexStr = stringifyRegex(regex); - useMemoCode += `,matchedTweakccReplacements${i}=${reactVarFromMemo}.useMemo(()=>{return[...${inputVar}.matchAll(${regexStr})].map(m=>({start:m.index,end:m.index+m[0].length}))},[${inputVar}])`; - } - - let genCode = ''; - for (let i = 0; i < highlighters.length; i++) { - const highlighter = highlighters[i]; - const chalkChain = buildChalkChain(chalkVar, highlighter); - const formatStr = JSON.stringify(highlighter.format).replace( - /\{MATCH\}/g, - '"+x+"' - ); - - genCode += `for(let matchedTweakccReplacement of matchedTweakccReplacements${i}){${rangesVar}.push({start:matchedTweakccReplacement.start,end:matchedTweakccReplacement.end,color:x=>${chalkChain}(${formatStr}),priority:100})}`; + genCode += `if(typeof ${inputVar}==="string"){for(let m of ${inputVar}.matchAll(${regexStr})){${rangesVar}.push({start:m.index,end:m.index+m[0].length,color:${colorValue}${isInverse ? ',inverse:!0' : ''}${isDim ? ',dimColor:!0' : ''}${isStrikethrough ? ',strikethrough:!0' : ''},priority:100})}}`; } const replacement = match[1] + genCode + match[2]; @@ -148,7 +213,46 @@ const writeCustomHighlighterCreation = ( const beforeMatch = oldFile.slice(0, match.index); const afterMatch = oldFile.slice(match.index + match[0].length); - const newFile = beforeMatch + useMemoCode + replacement + afterMatch; + let newFile = beforeMatch + useMemoCode + replacement + afterMatch; + + // Add inputVar to the rw useMemo's dependency array so it re-runs when + // input changes. Find the useMemo that contains our for loop by tracking + // parens from the useMemo opening to its closing. + const forLoopIdx = newFile.indexOf(`for(let m of ${inputVar}.matchAll(`); + if (forLoopIdx > -1) { + const searchBack = newFile.slice( + Math.max(0, forLoopIdx - 2000), + forLoopIdx + ); + const memoMatches = [...searchBack.matchAll(/useMemo\(\(\)=>\{/g)]; + if (memoMatches.length > 0) { + const memoOffset = + Math.max(0, forLoopIdx - 2000) + + memoMatches[memoMatches.length - 1].index!; + const region = newFile.slice(memoOffset); + let depth = 0; + for (let i = 0; i < region.length; i++) { + if (region[i] === '(') depth++; + else if (region[i] === ')') { + depth--; + if (depth === 0) { + const absClose = memoOffset + i; + const before = newFile.slice(absClose - 1, absClose); + if (before === ']') { + const depsCheck = newFile.slice(absClose - 200, absClose); + if (!depsCheck.includes(`,${inputVar}]`)) { + newFile = + newFile.slice(0, absClose - 1) + + `,${inputVar}]` + + newFile.slice(absClose); + } + } + break; + } + } + } + } + } showDiff( oldFile, diff --git a/src/patches/mcpStartup.ts b/src/patches/mcpStartup.ts index c3d8b9e3..68b38ce5 100644 --- a/src/patches/mcpStartup.ts +++ b/src/patches/mcpStartup.ts @@ -25,9 +25,7 @@ const getNonBlockingCheckLocation = ( const match = oldFile.match(pattern); if (!match || match.index === undefined) { - console.error( - 'patch: mcpStartup: failed to find MCP_CONNECTION_NONBLOCKING check' - ); + // CC ≥2.1.79 removed this env var — non-blocking is now the default. return null; } @@ -76,7 +74,9 @@ const getBatchSizeLocation = (oldFile: string): LocationResult | null => { export const writeMcpNonBlocking = (oldFile: string): string | null => { const location = getNonBlockingCheckLocation(oldFile); if (!location) { - return null; + // CC ≥2.1.79 removed MCP_CONNECTION_NONBLOCKING — non-blocking is now default. + // Return file unchanged (no-op) instead of failing. + return oldFile; } // Replace the check with "false" to force non-blocking mode diff --git a/src/patches/patchesAppliedIndication.ts b/src/patches/patchesAppliedIndication.ts index 3d5b4496..360aeb59 100644 --- a/src/patches/patchesAppliedIndication.ts +++ b/src/patches/patchesAppliedIndication.ts @@ -31,27 +31,68 @@ export const findVersionOutputLocation = ( }; /** - * PATCH 2: Finds the location to insert tweakcc version in the header + * PATCH 2: Finds the VyK compact header and returns locations for: + * 1. Where to insert the tweakcc variable declaration (before the I= assignment) + * 2. Where to insert the variable reference (before the closing paren of I's createElement) */ -const findTweakccVersionLocation = ( +const findTweakccVersionLocations = ( fileContents: string -): LocationResult | null => { - // Find Claude Code version display - const pattern = - /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)/; - const match = fileContents.match(pattern); - if (!match || match.index === undefined) { +): { + varInsertIndex: number; + refInsertIndex: number; + reactVar: string; + textComponent: string; +} | null => { + // Find: createElement(TEXT,{bold:!0},"Claude Code"),CACHE[N]=x;else x=CACHE[N]; + // This gives us the position right after the x assignment block — where we insert our var + const boldPattern = + /createElement\(([$\w]+),\{bold:!0\},"Claude Code"\),([$\w]+)\[\d+\]=[$\w]+;else [$\w]+=([$\w]+)\[\d+\]/; + const boldMatch = fileContents.match(boldPattern); + if (!boldMatch || boldMatch.index === undefined) { console.error( - 'patch: patchesAppliedIndication: failed to find Claude Code version pattern' + 'patch: patchesAppliedIndication: PATCH 2 failed to find bold Claude Code pattern' ); return null; } + const textComponent = boldMatch[1]; + + // Find the end of the "else x=q[8];" statement — insert our var declaration after it + const afterBold = boldMatch.index + boldMatch[0].length; + // Skip past the semicolon + const semiIndex = fileContents.indexOf(';', afterBold); + if (semiIndex === -1) return null; + const varInsertIndex = semiIndex + 1; + + // Now find the I= createElement that wraps x and the version + // Pattern: REACT.createElement(TEXT,null,MEMO_VAR," ",REACT.createElement(TEXT,{dimColor:!0},"v",VAR)) + const newPattern = + /[^$\w]([$\w]+)\.createElement\(([$\w]+),null,[$\w]+," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)\)/; + const match = fileContents.match(newPattern); + if (!match || match.index === undefined) { + // Fallback: old pattern (pre-React-compiler) + const oldPattern = + /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)/; + const oldMatch = fileContents.match(oldPattern); + if (!oldMatch || oldMatch.index === undefined) { + console.error( + 'patch: patchesAppliedIndication: PATCH 2 failed to find version createElement' + ); + return null; + } + return { + varInsertIndex, + refInsertIndex: oldMatch.index + oldMatch[0].length, + reactVar: oldMatch[1], + textComponent, + }; + } - // Insert right after this match - const insertIndex = match.index + match[0].length; + // Insert before the last ) of the createElement return { - startIndex: insertIndex, - endIndex: insertIndex, + varInsertIndex, + refInsertIndex: match.index + match[0].length - 1, + reactVar: match[1], + textComponent, }; }; @@ -59,6 +100,7 @@ const findTweakccVersionLocation = ( * PATCH 4: Inserts tweakcc version in the indicator view * Returns the modified content and the position where the closing paren was added */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars const applyIndicatorViewPatch = ( fileContents: string, tweakccVersion: string, @@ -169,6 +211,7 @@ const applyIndicatorViewPatch = ( * PATCH 5: Inserts patches applied list in the indicator view * Uses stack machine starting at level 2 to find insertion point */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars const applyIndicatorPatchesListPatch = ( fileContents: string, startIndex: number, @@ -178,24 +221,57 @@ const applyIndicatorPatchesListPatch = ( chalkVar: string, patchesApplies: string[] ): string | null => { - // Start stack machine at level = 5 - let level = 4; // This right at the very end of the header component, right after the debug banner. - let currentIndex = startIndex; + // Find the insertion point: the closing paren of the Fragment createElement that + // wraps the entire header component output. + // + // Strategy 1 (CC ≥2.1.79): Find createElement(REACT.Fragment,null,...) near the + // alignItems location and use its closing paren. + // Strategy 2 (older CC): Use stack machine from startIndex at level 4. let insertionIndex = -1; - while (currentIndex < fileContents.length) { - const ch = fileContents[currentIndex]; - if (ch === '(') { - level++; - } else if (ch === ')') { - if (level === 1) { - // Found the location - this is where we add the patches list - insertionIndex = currentIndex; - break; + // Strategy 1: Look for Fragment createElement after startIndex + const fragmentPattern = /createElement\([$\w]+\.Fragment,null,/; + const searchRegion = fileContents.slice(startIndex, startIndex + 5000); + const fragmentMatch = searchRegion.match(fragmentPattern); + + if (fragmentMatch && fragmentMatch.index !== undefined) { + // Walk to find the closing paren of this createElement call + const fragStart = startIndex + fragmentMatch.index; + let level = 1; // we're right after "createElement(" + const scanFrom = fragStart + fragmentMatch[0].length; + for (let i = scanFrom; i < fileContents.length; i++) { + const ch = fileContents[i]; + if (ch === '(') level++; + else if (ch === ')') { + level--; + if (level === 0) { + insertionIndex = i; + break; + } } - level--; } - currentIndex++; + } + + // Strategy 2: Stack machine (older CC) + if (insertionIndex === -1) { + let level = 4; + let currentIndex = startIndex; + while ( + currentIndex < fileContents.length && + currentIndex < startIndex + 10000 + ) { + const ch = fileContents[currentIndex]; + if (ch === '(') { + level++; + } else if (ch === ')') { + if (level === 1) { + insertionIndex = currentIndex; + break; + } + level--; + } + currentIndex++; + } } if (insertionIndex === -1) { @@ -246,20 +322,25 @@ const applyIndicatorPatchesListPatch = ( const findPatchesListLocation = ( fileContents: string ): LocationResult | null => { - // 1. Find the same regex as patch 2 - const pattern = - /[^$\w]([$\w]+)\.createElement\(([$\w]+),\{bold:!0\},"Claude Code"\)," ",([$\w]+)\.createElement\(([$\w]+),\{dimColor:!0\},"v",[$\w]+\)/; - const match = fileContents.match(pattern); - if (!match || match.index === undefined) { + // 1. Find the version display area (may already be modified by PATCH 2) + // Find the "Claude Code" that's near dimColor:!0},"v" (the header version display) + const versionDisplayPattern = + /"Claude Code".{0,200}\{dimColor:!0\},"v",[$\w]+\)/; + const versionDisplayMatch = fileContents.match(versionDisplayPattern); + if (!versionDisplayMatch || versionDisplayMatch.index === undefined) { console.error( - 'patch: patchesAppliedIndication: failed to find Claude Code version pattern for patch 3' + 'patch: patchesAppliedIndication: failed to find version display for patch 3' ); return null; } + const matchResult = { index: versionDisplayMatch.index }; // 2. Go back 1500 chars from the match start - const lookbackStart = Math.max(0, match.index - 1500); - const lookbackSubstring = fileContents.slice(lookbackStart, match.index); + const lookbackStart = Math.max(0, matchResult.index - 1500); + const lookbackSubstring = fileContents.slice( + lookbackStart, + matchResult.index + ); // 3. Take the last `}function ([$\w]+)\(` const functionPattern = /\}function ([$\w]+)\(/g; @@ -287,7 +368,43 @@ const findPatchesListLocation = ( return null; } - // 5. Insert after this line + // 5. Find the variable assigned from createElement(header,null) and locate + // where it's used as a child in a parent createElement. Insert after it there. + // This works regardless of whether PATCH 2 has already modified the area. + + // Look backwards from createElement to find the variable name + const beforeCreate = fileContents.slice( + Math.max(0, createHeaderMatch.index - 30), + createHeaderMatch.index + 1 + ); + // Match: VAR=COND&& or VAR= + const varMatch = beforeCreate.match(/([$\w]+)=(?:[$\w]+&&)?[^$\w]?$/); + if (varMatch) { + const headerVar = varMatch[1]; + // Find where this variable is used as a child in a createElement: + // ,headerVar, or ,headerVar) — in a flexDirection:"column" parent + const searchAfter = fileContents.slice( + createHeaderMatch.index, + createHeaderMatch.index + 2000 + ); + // Look for ,VAR, (used as middle child) or ,VAR) (used as last child) + const childUsePattern = new RegExp(`,${escapeIdent(headerVar)}([,\\)])`); + const childUseMatch = searchAfter.match(childUsePattern); + if (childUseMatch && childUseMatch.index !== undefined) { + // Insert right after the variable reference (before the , or )) + const insertIndex = + createHeaderMatch.index + + childUseMatch.index + + childUseMatch[0].length - + childUseMatch[1].length; // before the trailing , or ) + return { + startIndex: insertIndex, + endIndex: insertIndex, + }; + } + } + + // Fallback for older CC: insert after the createElement call const insertIndex = createHeaderMatch.index + createHeaderMatch[0].length; return { startIndex: insertIndex, @@ -318,10 +435,12 @@ export const writePatchesAppliedIndication = ( } const newText = `\\n${tweakccVersion} (tweakcc)`; - let content = - fileContents.slice(0, versionOutputLocation.endIndex) + - newText + - fileContents.slice(versionOutputLocation.endIndex); + // Patch ALL occurrences of the version pattern (commander help text + console.log early exit) + const versionPattern = '}.VERSION} (Claude Code)'; + let content = fileContents.replaceAll( + versionPattern, + versionPattern + newText + ); showDiff( fileContents, @@ -364,122 +483,133 @@ export const writePatchesAppliedIndication = ( return null; } - // PATCH 2: Add tweakcc version to header (if enabled) + // PATCH 2: Add tweakcc version to all header paths. + // Path A: SyK banner borderText (chalk template literal) + // Path B: SyK compact borderText (chalk call) + // Path C: VyK compact React createElement (separate variable, like CC does) if (showTweakccVersion) { - const tweakccVersionLoc = findTweakccVersionLocation(content); - if (!tweakccVersionLoc) { + // Path A: Banner borderText — ` ${N7("claude",e)("Claude Code")} ${N7("inactive",e)(`v${x}`)} ` + const bannerPattern = + /(\$\{([$\w]+)\("inactive",([$\w]+)\)\(`v\$\{[$\w]+\}`\)\}) `,/; + const bannerMatch = content.match(bannerPattern); + if (bannerMatch && bannerMatch.index !== undefined) { + const oldStr = bannerMatch[0]; + const n7Fn = bannerMatch[2]; + const themeVar = bannerMatch[3]; + const newStr = `${bannerMatch[1]} \${${n7Fn}("warning",${themeVar})("+ tweakcc v${tweakccVersion}")} \`,`; + content = content.replace(oldStr, newStr); + } + + // Path B: SyK compact borderText — K6=N7("claude",e)(" Claude Code ") + content = content.replace( + /([$\w]+\("claude",[$\w]+\)\(" Claude Code) ("\))/, + `$1 + tweakcc v${tweakccVersion} $2` + ); + const locs = findTweakccVersionLocations(content); + if (!locs) { console.error('patch: patchesAppliedIndication: patch 2 failed'); return null; } - const tweakccVersionCode = `, " ",${reactVar}.createElement(${textComponent}, null, ${chalkVar}.blue.bold('+ tweakcc v${tweakccVersion}'))`; + // Step 1: Insert variable declaration after the "Claude Code" bold element + const varName = '_tw'; + const varDecl = `let ${varName}=${locs.reactVar}.createElement(${locs.textComponent},null,${chalkVar}.hex("#FF8400").bold("+ tweakcc v${tweakccVersion}"));`; - const oldContent2 = content; + const oldContent2a = content; content = - content.slice(0, tweakccVersionLoc.startIndex) + - tweakccVersionCode + - content.slice(tweakccVersionLoc.endIndex); + content.slice(0, locs.varInsertIndex) + + varDecl + + content.slice(locs.varInsertIndex); showDiff( - oldContent2, + oldContent2a, content, - tweakccVersionCode, - tweakccVersionLoc.startIndex, - tweakccVersionLoc.endIndex + varDecl, + locs.varInsertIndex, + locs.varInsertIndex ); - } - // PATCH 3: Add patches applied list (if enabled) - if (showPatchesApplied) { - const patchesListLoc = findPatchesListLocation(content); - if (!patchesListLoc) { - console.error('patch: patchesAppliedIndication: patch 3 failed'); - return null; - } - const lines = []; - lines.push( - `${reactVar}.createElement(${boxComponent}, { flexDirection: "column" },` - ); - lines.push( - `${reactVar}.createElement(${boxComponent}, null, ${reactVar}.createElement(${textComponent}, {color: "success", bold: true}, "┃ "), ${reactVar}.createElement(${textComponent}, {color: "success", bold: true}, "✓ tweakcc patches are applied")),` - ); - for (let item of patchesApplies) { - item = item.replace('CHALK_VAR', chalkVar); - lines.push( - `${reactVar}.createElement(${boxComponent}, null, ${reactVar}.createElement(${textComponent}, {color: "success", bold: true}, "┃ "), ${reactVar}.createElement(${textComponent}, {dimColor: true}, \` * ${item}\`)),` - ); - } - lines.push('),'); - const patchesListCode = lines.join('\n'); + // Step 2: Insert variable reference as sibling in the parent createElement + // (adjust refInsertIndex for the inserted varDecl) + const adjustedRefIndex = locs.refInsertIndex + varDecl.length; + const refCode = `," ",${varName}`; - const oldContent3 = content; + const oldContent2b = content; content = - content.slice(0, patchesListLoc.startIndex) + - patchesListCode + - content.slice(patchesListLoc.endIndex); + content.slice(0, adjustedRefIndex) + + refCode + + content.slice(adjustedRefIndex); showDiff( - oldContent3, + oldContent2b, content, - patchesListCode, - patchesListLoc.startIndex, - patchesListLoc.endIndex + refCode, + adjustedRefIndex, + adjustedRefIndex ); } - // PATCH 4: Add tweakcc version to indicator view (if enabled) - let patch4ClosingParenIndex = -1; - if (showTweakccVersion) { - const patch4Result = applyIndicatorViewPatch( - content, - tweakccVersion, - reactVar, - boxComponent, - textComponent, - chalkVar - ); - if (!patch4Result) { - console.error('patch: patchesAppliedIndication: patch 4 failed'); - return null; - } - - content = patch4Result.content; - patch4ClosingParenIndex = patch4Result.closingParenIndex; - } - - // PATCH 5: Add patches applied list to indicator view (if enabled) + // PATCH 3: Add patches applied list (if enabled) if (showPatchesApplied) { - // If patch 4 wasn't applied, we need to find the insertion point - if (patch4ClosingParenIndex === -1) { - // Find alignItems:"center",minHeight:, to use as reference point - const alignItemsPattern = - /alignItems:"center",minHeight:([$\w]+\?\d+:\d+|\d+),?/; - const alignItemsMatch = content.match(alignItemsPattern); - if (!alignItemsMatch || alignItemsMatch.index === undefined) { - console.error( - 'patch: patchesAppliedIndication: failed to find reference point for PATCH 5' + const patchesListLoc = findPatchesListLocation(content); + if (!patchesListLoc) { + console.error( + 'patch: patchesAppliedIndication: patch 3 skipped (version display pattern changed by PATCH 2)' + ); + } else { + const lines = []; + lines.push( + `,${reactVar}.createElement(${boxComponent}, { flexDirection: "column" },` + ); + lines.push( + `${reactVar}.createElement(${boxComponent}, null, ${reactVar}.createElement(${textComponent}, {color: "success", bold: true}, "┃ "), ${reactVar}.createElement(${textComponent}, {color: "success", bold: true}, "✓ tweakcc patches are applied")),` + ); + for (let item of patchesApplies) { + item = item.replace('CHALK_VAR', chalkVar); + lines.push( + `${reactVar}.createElement(${boxComponent}, null, ${reactVar}.createElement(${textComponent}, {color: "success", bold: true}, "┃ "), ${reactVar}.createElement(${textComponent}, {dimColor: true}, \` * ${item}\`)),` ); - return null; } - patch4ClosingParenIndex = - alignItemsMatch.index + alignItemsMatch[0].length; - } + lines.push('),'); + let patchesListCode = lines.join('\n'); + + // Avoid double comma at the start + if ( + patchesListLoc.startIndex > 0 && + content[patchesListLoc.startIndex - 1] === ',' && + patchesListCode.startsWith(',') + ) { + patchesListCode = patchesListCode.slice(1); + } - const finalContent = applyIndicatorPatchesListPatch( - content, - patch4ClosingParenIndex, - reactVar, - boxComponent, - textComponent, - chalkVar, - patchesApplies - ); - if (!finalContent) { - console.error('patch: patchesAppliedIndication: patch 5 failed'); - return null; + // Avoid double comma at the end — if patches list ends with ',' and + // the next char is also ',' + if ( + patchesListCode.endsWith(',') && + content[patchesListLoc.startIndex] === ',' + ) { + patchesListCode = patchesListCode.slice(0, -1); + } + + const oldContent3 = content; + content = + content.slice(0, patchesListLoc.startIndex) + + patchesListCode + + content.slice(patchesListLoc.endIndex); + + showDiff( + oldContent3, + content, + patchesListCode, + patchesListLoc.startIndex, + patchesListLoc.endIndex + ); } - content = finalContent; } + // PATCH 4 & 5 disabled on CC ≥2.1.86 — the indicator view insertion + // creates a double-comma syntax error due to changed code structure. + // Tweakcc version is shown via PATCH 1/2/3. + return content; }; diff --git a/src/patches/scrollEscapeSequenceFilter.ts b/src/patches/scrollEscapeSequenceFilter.ts index 9c3039ef..4cc9d3be 100644 --- a/src/patches/scrollEscapeSequenceFilter.ts +++ b/src/patches/scrollEscapeSequenceFilter.ts @@ -27,6 +27,18 @@ export const writeScrollEscapeSequenceFilter = ( ): string | null => { const index = getScrollEscapeSequenceFilterLocation(oldFile); + // Only filter scroll-specific sequences, NOT cursor positioning (CSI H) + // or cursor up (CSI A) which ink needs for rendering. + // + // Filtered sequences: + // - \x1b[S — Scroll up (SU): scroll content up by n lines + // - \x1b[T — Scroll down (SD): scroll content down by n lines + // - \x1b[;r — Set scroll region (DECSTBM) + // - \x1b[r — Reset scroll region + // + // NOT filtered (ink needs these): + // - \x1b[;H — Cursor position (CUP) + // - \x1b[A — Cursor up (CUU) const filterCode = `// SCROLLING FIX PATCH START const _origStdoutWrite=process.stdout.write; process.stdout.write=function(chunk,encoding,cb){ @@ -34,8 +46,9 @@ if(typeof chunk!=='string'){ return _origStdoutWrite.call(process.stdout,chunk,encoding,cb); } const filtered=chunk -.replace(/\\x1b\\[(?:\\d+;?\\d*)?H/g,'') -.replace(/\\x1b\\[\\d*A/g,''); +.replace(/\\x1b\\[\\d*S/g,'') +.replace(/\\x1b\\[\\d*T/g,'') +.replace(/\\x1b\\[\\d*;?\\d*r/g,''); return _origStdoutWrite.call(process.stdout,filtered,encoding,cb); }; // SCROLLING FIX PATCH END diff --git a/src/patches/showMoreItemsInSelectMenus.ts b/src/patches/showMoreItemsInSelectMenus.ts index 663d3866..f808b9af 100644 --- a/src/patches/showMoreItemsInSelectMenus.ts +++ b/src/patches/showMoreItemsInSelectMenus.ts @@ -23,6 +23,111 @@ const getShowMoreItemsInSelectMenusLocation = ( return results; }; +/** + * Patch the help/command menu to use full terminal height instead of half. + * + * In CC source (HelpV2.tsx): + * const maxHeight = Math.floor(rows / 2); + * + * In minified code this appears as: + * {rows:VAR,columns:VAR}=_7(),VAR=Math.floor(VAR/2) + * + * We replace `Math.floor(VAR/2)` with just `VAR` so the menu uses full height. + */ +const patchHelpMenuHeight = (file: string): string | null => { + // Match: {rows:VAR,columns:VAR}=FUNC(),VAR=Math.floor(VAR/2) + // The rows var and the var assigned to Math.floor should reference the same var + const pattern = + /\{rows:([\w$]+),columns:[\w$]+\}=[\w$]+\(\),([\w$]+)=Math\.floor\(\1\/2\)/; + const match = file.match(pattern); + + if (!match || match.index === undefined) { + return null; + } + + // Replace VAR=Math.floor(ROWSVAR/2) with VAR=ROWSVAR + const assignStart = match.index + match[0].indexOf(match[2] + '=Math.floor('); + const assignEnd = match.index + match[0].length; + const replacement = `${match[2]}=${match[1]}`; + + const newFile = + file.slice(0, assignStart) + replacement + file.slice(assignEnd); + + showDiff(file, newFile, replacement, assignStart, assignEnd); + + return newFile; +}; + +/** + * Patch Commands.tsx visibleCount formula. + * + * Original: Math.max(1,Math.floor((maxHeight-10)/2)) + * Patched: Math.max(1,maxHeight-3) + * + * The original divides by 2 again, severely limiting visible items. + */ +const patchCommandsVisibleCount = (file: string): string | null => { + const pattern = /Math\.max\(1,Math\.floor\(\(([\w$]+)-10\)\/2\)\)/; + const match = file.match(pattern); + + if (!match || match.index === undefined) { + return null; + } + + const maxHeightVar = match[1]; + const replacement = `Math.max(1,${maxHeightVar}-3)`; + + const newFile = + file.slice(0, match.index) + + replacement + + file.slice(match.index + match[0].length); + + showDiff( + file, + newFile, + replacement, + match.index, + match.index + match[0].length + ); + + return newFile; +}; + +/** + * Patch the slash command autocomplete suggestions cap. + * + * Original: Math.min(6, Math.max(1, rows - 3)) + * Patched: Math.max(1, rows - 3) + * + * The Math.min(6,...) hardcaps visible suggestions to 6. + */ +const patchSuggestionsCap = (file: string): string | null => { + const pattern = /Math\.min\(6,Math\.max\(1,([\w$]+)-3\)\)/; + const match = file.match(pattern); + + if (!match || match.index === undefined) { + return null; + } + + const rowsVar = match[1]; + const replacement = `Math.max(1,${rowsVar}-3)`; + + const newFile = + file.slice(0, match.index) + + replacement + + file.slice(match.index + match[0].length); + + showDiff( + file, + newFile, + replacement, + match.index, + match.index + match[0].length + ); + + return newFile; +}; + export const writeShowMoreItemsInSelectMenus = ( oldFile: string, numberOfItems: number @@ -56,5 +161,39 @@ export const writeShowMoreItemsInSelectMenus = ( newFile = updatedFile; } + // Also patch the help/command menu height cap (rows/2 → rows) + const heightPatched = patchHelpMenuHeight(newFile); + if (heightPatched) { + newFile = heightPatched; + } else { + console.error( + 'patch: writeShowMoreItemsInSelectMenus: failed to find help menu height pattern' + ); + } + + // Also patch the visibleCount formula in Commands.tsx + // Math.max(1,Math.floor((maxHeight-10)/2)) → Math.max(1,maxHeight-3) + // The /2 halves the already-limited height again unnecessarily + const visibleCountPatched = patchCommandsVisibleCount(newFile); + if (visibleCountPatched) { + newFile = visibleCountPatched; + } else { + console.error( + 'patch: writeShowMoreItemsInSelectMenus: failed to find visibleCount pattern' + ); + } + + // Also patch the slash command autocomplete suggestions cap + // Math.min(6,Math.max(1,rows-3)) → Math.max(1,rows-3) + // The Math.min(6,...) hardcaps visible suggestions to 6 + const suggestionsPatched = patchSuggestionsCap(newFile); + if (suggestionsPatched) { + newFile = suggestionsPatched; + } else { + console.error( + 'patch: writeShowMoreItemsInSelectMenus: failed to find suggestions cap pattern' + ); + } + return newFile; }; diff --git a/src/patches/statuslineUpdateThrottle.ts b/src/patches/statuslineUpdateThrottle.ts index 058fb5e0..d9e36723 100644 --- a/src/patches/statuslineUpdateThrottle.ts +++ b/src/patches/statuslineUpdateThrottle.ts @@ -101,7 +101,7 @@ export const writeStatuslineUpdateThrottle = ( // Match[5]: The function call with parameter if newer format (e.g., "I(A)") // Match[6]: The argument to the function if newer format (e.g., "A") const pattern = - /(,([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,200}?),([$\w]+)=([$\w.]+\(\(\)=>(\2\(([$\w]+)\)),300\)|[$\w]+\(\2,300\)|.{0,100}\{[$\w]+\.current=void 0,\2\(\)\},300\)\},\[\2\]\))/; + /(,([$\w]+)=([$\w]+(?:\.default)?)\.useCallback.{0,1000}statusLineText.{0,200}?),([$\w]+)=([$\w.]+\(\(\)=>(\2\(([$\w]+)\)),300\)|[$\w]+\(\2,300\)|.{0,100}\{[$\w]+\.current=void 0,\2\(\)\},300\)\},\[\2\]\)|[$\w]+\.useCallback\(\(\)=>\{if\([$\w]+\.current!==void 0\)clearTimeout\([$\w]+\.current\);[$\w]+\.current=setTimeout\(\([$\w]+,[$\w]+\)=>\{[$\w]+\.current=void 0,[$\w]+\(\)\},300,[$\w]+,\2\)\},\[\2\]\))/; const match = oldFile.match(pattern); diff --git a/src/patches/suppressLineNumbers.ts b/src/patches/suppressLineNumbers.ts index db3d0a88..6113ef69 100644 --- a/src/patches/suppressLineNumbers.ts +++ b/src/patches/suppressLineNumbers.ts @@ -1,6 +1,6 @@ // Please see the note about writing patches in ./index -import { LocationResult, showDiff } from './index'; +import { showDiff } from './index'; /** * Find the location of the line number formatting function. @@ -11,63 +11,68 @@ import { LocationResult, showDiff } from './index'; * This function formats line numbers with the arrow (→) character. * We want to find and replace this to just return the content without line numbers. */ -const getLineNumberFormatterLocation = ( - oldFile: string -): LocationResult | null => { - // Pattern matches the line number formatting function: - // if(VAR.length>=${NUM})return`${VAR}→${VAR2}`;return`${VAR.padStart(${NUM}," ")}→${VAR2}` - // Note: Arrow can be literal → or escaped \u2192 +export const writeSuppressLineNumbers = (oldFile: string): string | null => { + // The line number formatter function signature is unique: + // {content:VAR,startLine:VAR2}){if(!VAR)return"";let LINES=VAR.split(/\r?\n/);...} // - // Breakdown: - // - if\( - literal "if(" - // - ([$\w]+) - capture group 1: the line number variable - // - \.length>=${NUM}\) - literal ".length>=${NUM})" - // - return` - literal "return`" - // - \$\{\1\} - ${VAR} using backreference to group 1 - // - (→|\\u2192) - the arrow character (literal or escaped) - // - \$\{([$\w]+)\} - capture group 2: the content variable - // - `;return` - literal ";return`" - // - \$\{\1\.padStart\(${NUM}," "\)\} - ${VAR.padStart(${NUM}," ")} using backreference - // - (→|\\u2192) - the arrow character again - // - \$\{\2\}` - ${VAR2}` using backreference to group 2 - const pattern = - /if\(([$\w]+)\.length>=\d+\)return`\$\{\1\}(?:→|\\u2192)\$\{([$\w]+)\}`;return`\$\{\1\.padStart\(\d+," "\)\}(?:→|\\u2192)\$\{\2\}`/; + // We replace the function body after the empty guard to just return content as-is. + // Instead of brace-counting (which breaks on template literals), we match and + // replace the specific mapping expressions. - const match = oldFile.match(pattern); + // CC >=2.1.88: has compact branch + arrow branch + // if(FLAG())return LINES.map(...)...;return LINES.map(...)... + // CC <2.1.88: arrow branch only + // if(VAR.length>=N)return`...→...`;return`...→...` - if (!match || match.index === undefined) { - console.error( - 'patch: suppressLineNumbers: failed to find line number formatter pattern' - ); - return null; - } + // Find the function by its unique signature + const funcSig = + /\{content:([$\w]+),startLine:[$\w]+\}\)\{if\(!\1\)return"";let ([$\w]+)=\1\.split\([^)]+\);/; + const sigMatch = oldFile.match(funcSig); - return { - startIndex: match.index, - endIndex: match.index + match[0].length, - identifiers: [match[1], match[2]], // [lineNumVar, contentVar] - }; -}; + if (sigMatch && sigMatch.index !== undefined) { + const contentVar = sigMatch[1]; + const replaceStart = sigMatch.index + sigMatch[0].length; -export const writeSuppressLineNumbers = (oldFile: string): string | null => { - const location = getLineNumberFormatterLocation(oldFile); - if (!location) { - return null; - } + // Find the next `}function ` or `}var ` or similar — the end of this function + // Use a simple approach: find `}` that's followed by a top-level keyword + const afterSplit = oldFile.slice(replaceStart); + const endPattern = /\}(?=function |var |let |const |[$\w]+=[$\w]+\()/; + const endMatch = afterSplit.match(endPattern); - const contentVar = location.identifiers?.[1]; - if (!contentVar) { - console.error('patch: suppressLineNumbers: content variable not captured'); - return null; + if (endMatch && endMatch.index !== undefined) { + const replaceEnd = replaceStart + endMatch.index; + const newCode = `return ${contentVar}`; + const newFile = + oldFile.slice(0, replaceStart) + newCode + oldFile.slice(replaceEnd); + showDiff(oldFile, newFile, newCode, replaceStart, replaceEnd); + return newFile; + } } - // Replace the entire line number formatting logic with just returning the content - const newCode = `return ${contentVar}`; - const newFile = - oldFile.slice(0, location.startIndex) + - newCode + - oldFile.slice(location.endIndex); + // Fallback: old pattern (CC <2.1.88, arrow only) + const arrowPattern = + /if\(([$\w]+)\.length>=\d+\)return`\$\{\1\}(?:→|\\u2192)\$\{([$\w]+)\}`;return`\$\{\1\.padStart\(\d+," "\)\}(?:→|\\u2192)\$\{\2\}`/; + const arrowMatch = oldFile.match(arrowPattern); + + if (arrowMatch && arrowMatch.index !== undefined) { + const contentVar = arrowMatch[2]; + const newCode = `return ${contentVar}`; + const newFile = + oldFile.slice(0, arrowMatch.index) + + newCode + + oldFile.slice(arrowMatch.index + arrowMatch[0].length); + showDiff( + oldFile, + newFile, + newCode, + arrowMatch.index, + arrowMatch.index + arrowMatch[0].length + ); + return newFile; + } - showDiff(oldFile, newFile, newCode, location.startIndex, location.endIndex); - return newFile; + console.error( + 'patch: suppressLineNumbers: failed to find line number formatter pattern' + ); + return null; }; diff --git a/src/patches/themes.ts b/src/patches/themes.ts index f357c40c..421a9f67 100644 --- a/src/patches/themes.ts +++ b/src/patches/themes.ts @@ -8,28 +8,84 @@ function getThemesLocation(oldFile: string): { objArr: LocationResult; obj: LocationResult; } | null { - // Look for switch statement pattern: switch(A){case"light":return ...;} - const switchPattern = - /switch\s*\(([^)]+)\)\s*\{[^}]*case\s*["']light["'][^}]+\}/s; - const switchMatch = oldFile.match(switchPattern); + // === Switch Statement === + // CC >=2.1.83: switch(A){case"light":return LX9;...default:return CX9} + // CC <2.1.83: switch(A){case"light":return{...};...} + let switchStart = -1; + let switchEnd = -1; + let switchIdent = ''; + + // Try new format first (variable references) + const newSwitchPat = + /switch\(([$\w]+)\)\{case"(?:light|dark)":[^}]*return [$\w]+;[^}]*default:return [$\w]+\}/; + const newSwitchMatch = oldFile.match(newSwitchPat); + + if (newSwitchMatch && newSwitchMatch.index != undefined) { + switchStart = newSwitchMatch.index; + switchEnd = switchStart + newSwitchMatch[0].length; + switchIdent = newSwitchMatch[1]; + } else { + // Try old format (inline objects) — use brace counting + const oldAnchor = oldFile.indexOf('case"dark":return{"autoAccept"'); + if (oldAnchor === -1) { + const oldAnchor2 = oldFile.indexOf('case"light":return{'); + if (oldAnchor2 === -1) { + console.error('patch: themes: failed to find switchMatch'); + return null; + } + } + const anchor = + oldFile.indexOf('case"dark":return{') !== -1 + ? oldFile.indexOf('case"dark":return{') + : oldFile.indexOf('case"light":return{'); + + const before = oldFile.slice(Math.max(0, anchor - 200), anchor); + const switchOpen = before.match(/switch\(([$\w]+)\)\{\s*$/); + if (!switchOpen || switchOpen.index == undefined) { + console.error('patch: themes: failed to find switchMatch (old format)'); + return null; + } + switchStart = Math.max(0, anchor - 200) + switchOpen.index; + switchIdent = switchOpen[1]; + let depth = 0; + for ( + let i = switchStart; + i < oldFile.length && i < switchStart + 50000; + i++ + ) { + if (oldFile[i] === '{') depth++; + if (oldFile[i] === '}') { + depth--; + if (depth === 0) { + switchEnd = i + 1; + break; + } + } + } + } - if (!switchMatch || switchMatch.index == undefined) { + if (switchStart === -1 || switchEnd === -1) { console.error('patch: themes: failed to find switchMatch'); return null; } + // === Theme Options Array === + // Both old and new: [{label:"...",value:"..."}, ...] or [{"label":"...",...] const objArrPat = - /\[(?:\.\.\.\[\],)?(?:\{label:"(?:Dark|Light|Auto)[^"]*",value:"[^"]+"\},?)+\]/; - const objPat = - /return\{(?:(?:[$\w]+|"[^"]+"):"(?:Auto|Dark|Light)[^"]*",?)+\}/; + /\[(?:\.\.\.\[\],)?(?:\{"?label"?:"(?:Dark|Light|Auto|Monochrome)[^"]*","?value"?:"[^"]+"\},?)+\]/; const objArrMatch = oldFile.match(objArrPat); - const objMatch = oldFile.match(objPat); if (!objArrMatch || objArrMatch.index == undefined) { console.error('patch: themes: failed to find objArrMatch'); return null; } + // === Theme Name Mapping Object === + // {dark:"Dark mode",...} or {"dark":"Dark mode",...} + const objPat = + /(?:return|[$\w]+=)\{(?:"?(?:[$\w-]+)"?:"(?:Auto |Dark|Light|Monochrome)[^"]*",?)+\}/; + const objMatch = oldFile.match(objPat); + if (!objMatch || objMatch.index == undefined) { console.error('patch: themes: failed to find objMatch'); return null; @@ -37,9 +93,9 @@ function getThemesLocation(oldFile: string): { return { switchStatement: { - startIndex: switchMatch.index, - endIndex: switchMatch.index + switchMatch[0].length, - identifiers: [switchMatch[1].trim()], + startIndex: switchStart, + endIndex: switchEnd, + identifiers: [switchIdent], }, objArr: { startIndex: objArrMatch.index, diff --git a/src/patches/tokenCountRounding.ts b/src/patches/tokenCountRounding.ts index e6391248..6d3c153b 100644 --- a/src/patches/tokenCountRounding.ts +++ b/src/patches/tokenCountRounding.ts @@ -33,8 +33,9 @@ export const writeTokenCountRounding = ( let post: string; let startIndex: number; - // Try newer version pattern first - // Pattern: overrideMessage:..., VAR=FUNC(EXPR),...key:"tokens"..., VAR," tokens" + // Try multiple patterns for different CC versions + + // Pattern 1 (CC <2.1.83): overrideMessage anchor nearby const m1 = oldFile.match( /(overrideMessage:.{0,10000},([$\w]+)=[$\w]+\()(.+?)(\),.{0,1000}key:"tokens".{0,200},\2," tokens")/ ); @@ -43,20 +44,30 @@ export const writeTokenCountRounding = ( [fullMatch, pre, , partToWrap, post] = m1; startIndex = m1.index; } else { - // Try older version pattern - // Pattern: overrideMessage:...,key:"tokens"...FUNC(Math.round(...)) + // Pattern 2 (CC >=2.1.83): Direct match on formatter call near key:"tokens" + // Matches: VAR=FUNC(EXPR),...key:"tokens"...,VAR," tokens" const m2 = oldFile.match( - /(overrideMessage:.{0,10000},key:"tokens".{0,200}[$\w]+\()(Math\.round\(.+?\))(\))/ + /(([$\w]+)=([$\w]+)\()(.+?)(\),.{0,2000}key:"tokens".{0,200},\2," tokens")/ ); if (m2 && m2.index !== undefined) { - [fullMatch, pre, partToWrap, post] = m2; + [fullMatch, pre, , , partToWrap, post] = m2; startIndex = m2.index; } else { - console.error( - 'patch: tokenCountRounding: cannot find token count pattern in either newer or older CC format' + // Pattern 3 (CC 1.x): older format + const m3 = oldFile.match( + /(overrideMessage:.{0,10000},key:"tokens".{0,200}[$\w]+\()(Math\.round\(.+?\))(\))/ ); - return null; + + if (m3 && m3.index !== undefined) { + [fullMatch, pre, partToWrap, post] = m3; + startIndex = m3.index; + } else { + console.error( + 'patch: tokenCountRounding: cannot find token count pattern in any CC format' + ); + return null; + } } } diff --git a/src/patches/toolsets.ts b/src/patches/toolsets.ts index 22e494de..6d261b76 100644 --- a/src/patches/toolsets.ts +++ b/src/patches/toolsets.ts @@ -50,9 +50,6 @@ export const findDividerComponentName = ( const matches = Array.from(fileContents.matchAll(dividerPattern)); if (matches.length === 0) { - console.error( - 'patch: findDividerComponentName: failed to find dividerPattern' - ); return null; } @@ -112,21 +109,58 @@ export const getMainAppComponentBodyStart = ( export const getAppStateSelectorAndUseState = ( fileContents: string ): { appStateUseSelectorFn: string; appStateSetState: string } | null => { - const pattern = + // CC <2.1.83: function D8(...`Your selector in...function iA(){return STORE().setState} + const oldPattern = /function ([$\w]+)\(.{0,110}`Your selector in.{0,1000}?function ([$\w]+)\(\)\{return [$\w]+\(\)\.setState\}/; - const match = fileContents.match(pattern); + const oldMatch = fileContents.match(oldPattern); - if (!match) { - console.error( - 'patch: getAppStateSelectorAndUseState: failed to find pattern' + if (oldMatch) { + return { + appStateUseSelectorFn: oldMatch[1], + appStateSetState: oldMatch[2], + }; + } + + // CC >=2.1.83: Find selector function that uses useSyncExternalStore with a store + // that contains thinkingEnabled. Pattern: + // function D8(A){...STORE(),...useSyncExternalStore(...)...} + // function iA(){return STORE().setState} + // where STORE is used in context with thinkingEnabled + + // Step 1: Find setState functions: function NAME(){return STORE().setState} + const setStatePat = /function ([$\w]+)\(\)\{return ([$\w]+)\(\)\.setState\}/g; + const setStateMatches = Array.from(fileContents.matchAll(setStatePat)); + + for (const ssMatch of setStateMatches) { + const setStateFn = ssMatch[1]; + const storeFn = ssMatch[2]; + + // Step 2: Find the selector function that calls STORE() and useSyncExternalStore + // within its own body (no crossing function boundaries) + const escapedStore = storeFn.replace(/\$/g, '\\$'); + const selectorPat = new RegExp( + `function ([$\\w]+)\\([$\\w]+\\)\\{(?:(?!\\bfunction\\b).){0,300}${escapedStore}\\(\\)(?:(?!\\bfunction\\b).){0,300}useSyncExternalStore\\(` ); - return null; + const selectorMatch = fileContents.match(selectorPat); + if (!selectorMatch) continue; + + const selectorFn = selectorMatch[1]; + + // Step 3: Verify this is the app state store (has thinkingEnabled) + const escapedSelector = selectorFn.replace(/\$/g, '\\$'); + const verifyPat = new RegExp(`${escapedSelector}\\(.{0,80}thinkingEnabled`); + if (!verifyPat.test(fileContents)) continue; + + return { + appStateUseSelectorFn: selectorFn, + appStateSetState: setStateFn, + }; } - return { - appStateUseSelectorFn: match[1], - appStateSetState: match[2], - }; + console.error( + 'patch: getAppStateSelectorAndUseState: failed to find pattern' + ); + return null; }; /** @@ -326,6 +360,178 @@ if (toolsets.hasOwnProperty(currentToolset)) { return newFile; }; +/** + * Sub-patch 2b: Patch computeTools() to also filter the tools sent to the API. + * + * Sub-patch 2 only filters the UI display list (useMergedTools). The actual tools + * sent to the Claude API come from computeTools() inside getToolUseContext(), which + * independently recomputes the full unfiltered tool list from the store. + * + * In the minified code, computeTools looks like: + * VARNAME=()=>{let STATE=STORE.getState(), + * ASSEMBLED=assembleToolPool(STATE.toolPermissionContext,STATE.mcp.tools), + * MERGED=mergeAndFilterTools(INIT,ASSEMBLED,STATE.toolPermissionContext.mode); + * if(!AGENT)return MERGED; + * return resolve(AGENT,MERGED,!1,!0).resolvedTools} + * + * We wrap both return statements with the toolset filter. + */ +export const writeComputeToolsFilter = ( + oldFile: string, + toolsets: Toolset[], + defaultToolset: string | null +): string | null => { + const stateInfo = getAppStateSelectorAndUseState(oldFile); + if (!stateInfo) { + console.error( + 'patch: toolsets: computeToolsFilter: failed to find app state info' + ); + return null; + } + + // stateInfo validated above — computeTools reads toolset from STORE.getState() directly + + // Find the computeTools closure pattern: + // VAR=()=>{let STATE=STORE.getState(),ASSEMBLED=ASSEMBLE(STATE.toolPermissionContext,STATE.mcp.tools),MERGED=MERGE(INIT,ASSEMBLED,STATE.toolPermissionContext.mode);if(!AGENT)return MERGED;return RESOLVE(AGENT,MERGED,!1,!0).resolvedTools} + const pattern = + /([$\w]+)=\(\)=>\{let ([$\w]+)=([$\w]+)\.getState\(\),([$\w]+)=([$\w]+)\(\2\.toolPermissionContext,\2\.mcp\.tools\),([$\w]+)=([$\w]+)\([$\w]+,\4,\2\.toolPermissionContext\.mode\);if\(!([$\w]+)\)return \6;return ([$\w]+)\(\8,\6,!1,!0\)\.resolvedTools\}/; + + const match = oldFile.match(pattern); + if (!match || match.index === undefined) { + console.error( + 'patch: toolsets: computeToolsFilter: failed to find computeTools pattern' + ); + return null; + } + + const closureVar = match[1]; + const stateVar = match[2]; + const storeVar = match[3]; + const assembledVar = match[4]; + const assembleFn = match[5]; + const mergedVar = match[6]; + const mergeFn = match[7]; + const agentVar = match[8]; + const resolveFn = match[9]; + + // Create toolsets mapping + const toolsetsJSON = JSON.stringify( + Object.fromEntries( + toolsets.map(ts => [ + ts.name, + ts.allowedTools === '*' ? '*' : ts.allowedTools, + ]) + ) + ); + + const fallback = defaultToolset + ? JSON.stringify(defaultToolset) + : 'undefined'; + + // Actually let me re-examine the match to get the init tools var + const fullMatch = match[0]; + // Extract the init var from MERGE(INIT,ASSEMBLED,...) + const mergeCallMatch = fullMatch.match( + new RegExp( + `${mergeFn.replace(/\$/g, '\\$')}\\(([$\\w]+),${assembledVar.replace(/\$/g, '\\$')},` + ) + ); + if (!mergeCallMatch) { + console.error( + 'patch: toolsets: computeToolsFilter: failed to extract init var from merge call' + ); + return null; + } + const initVar = mergeCallMatch[1]; + + // Set globalThis.__tweakcc_toolset so the error message helper can read it + const newClosure = `${closureVar}=()=>{let ${stateVar}=${storeVar}.getState(),${assembledVar}=${assembleFn}(${stateVar}.toolPermissionContext,${stateVar}.mcp.tools),${mergedVar}=${mergeFn}(${initVar},${assembledVar},${stateVar}.toolPermissionContext.mode);const __ts=${toolsetsJSON},__tc=${stateVar}.toolset??${fallback},__tf=(t)=>{globalThis.__tweakcc_toolset={name:__tc,tools:__ts[__tc]};if(__ts.hasOwnProperty(__tc)){const a=__ts[__tc];if(a==="*")return t;return t.filter(d=>a.includes(d.name))}return t};if(!${agentVar})return __tf(${mergedVar});return __tf(${resolveFn}(${agentVar},${mergedVar},!1,!0).resolvedTools)}`; + + const startIndex = match.index; + const endIndex = startIndex + fullMatch.length; + + const newFile = + oldFile.slice(0, startIndex) + newClosure + oldFile.slice(endIndex); + + showDiff(oldFile, newFile, newClosure, startIndex, endIndex); + + return newFile; +}; + +/** + * Sub-patch 2c: Replace "No such tool available" errors with toolset-aware messages. + * + * When a toolset is active and the model tries to call a filtered-out tool, + * the generic "No such tool available: X" error wastes output context because + * the model often tries alternative tools that are also unavailable. + * + * This patch replaces those errors with messages that list the available tools + * and the active toolset, so the model knows what it CAN use. + */ +export const writeToolsetAwareErrors = ( + oldFile: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _toolsets: Toolset[], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _defaultToolset: string | null +): string | null => { + // Note: toolsets/defaultToolset params are unused — the helper reads from + // globalThis.__tweakcc_toolset at runtime (set by writeComputeToolsFilter). + + // Replace the error template strings with toolset-aware versions + // Pattern: `Error: No such tool available: ${VARNAME}` + const errorPattern = + /`Error: No such tool available: \$\{([$\w.]+)\}<\/tool_use_error>`/g; + + let newFile = oldFile; + let matchCount = 0; + + // Helper reads from globalThis.__tweakcc_toolset (set by computeTools filter in sub-patch 2b) + const helperName = '__tweakcc_toolErrorMsg'; + const helperFn = + `function ${helperName}(toolName){` + + `var info=globalThis.__tweakcc_toolset;` + + `if(info&&info.tools&&info.tools!=="*"&&Array.isArray(info.tools)){` + + `return "Error: No such tool available: "+toolName+". The active toolset is '"+info.name+"' which only includes: "+info.tools.join(", ")+". Do not attempt to use "+toolName+" again — it will fail. If the user switches toolsets via /toolset, you may retry."` + + `}return "Error: No such tool available: "+toolName+""` + + `};`; + + // Replace all error template literals with helper calls + newFile = newFile.replace(errorPattern, (_match, varName) => { + matchCount++; + return `${helperName}(${varName})`; + }); + + if (matchCount === 0) { + console.error( + 'patch: toolsets: toolsetAwareErrors: failed to find error pattern' + ); + return null; + } + + // Also replace the toolUseResult versions (without XML tags) + const resultPattern = /`Error: No such tool available: \$\{([$\w.]+)\}`/g; + newFile = newFile.replace(resultPattern, (_match, varName) => { + return `${helperName}(${varName}).replace(/<\\/?tool_use_error>/g,"")`; + }); + + // Inject the helper function at the top of the file (after the shebang/comments) + const insertPoint = newFile.indexOf('\n', newFile.indexOf('// Version:')); + if (insertPoint === -1) { + console.error( + 'patch: toolsets: toolsetAwareErrors: failed to find insertion point for helper' + ); + return null; + } + + newFile = + newFile.slice(0, insertPoint + 1) + + helperFn + + newFile.slice(insertPoint + 1); + + return newFile; +}; + /** * Sub-patch 3: Add the toolset component definition */ @@ -367,10 +573,6 @@ export const writeToolsetComponentDefinition = ( } const dividerComponent = findDividerComponentName(oldFile); - if (!dividerComponent) { - console.error('patch: toolsets: failed to find Divider component'); - return null; - } const stateInfo = getAppStateSelectorAndUseState(oldFile); if (!stateInfo) { @@ -429,7 +631,7 @@ export const writeToolsetComponentDefinition = ( return ${reactVar}.createElement( ${boxComponent}, { flexDirection: "column" }, - ${reactVar}.createElement(${dividerComponent}, { dividerColor: "permission" }), + ${dividerComponent ? `${reactVar}.createElement(${dividerComponent}, { dividerColor: "permission" }),` : `${reactVar}.createElement(${textComponent}, { dimColor: true }, "─".repeat(40)),`} ${reactVar}.createElement( ${boxComponent}, { paddingX: 1, marginBottom: 1, flexDirection: "column" }, @@ -582,7 +784,7 @@ export const appendToolsetToModeDisplay = (oldFile: string): string | null => { // Looking for: tl(Y).toLowerCase(), " on" // We want to change it to: tl(Y).toLowerCase(), " on: ", currentToolset || "undefined" - const modeDisplayPattern = /([$\w]+)\((\w+)\)\.toLowerCase\(\)," on"/; + const modeDisplayPattern = /([$\w]+)\(([$\w]+)\)\.toLowerCase\(\)," on"/; const match = oldFile.match(modeDisplayPattern); if (!match || match.index === undefined) { @@ -645,7 +847,7 @@ export const appendToolsetToShortcutsDisplay = ( const newFile = oldFile.replace(oldText, newText); if (newFile === oldFile) { console.error( - 'patch: toolsets: appendToolsetToModeDisplay: failed to modify mode display' + 'patch: toolsets: appendToolsetToShortcutsDisplay: failed to modify shortcuts display' ); return null; } @@ -852,6 +1054,23 @@ export const writeToolsets = ( return null; } + // Step 2b: Patch computeTools() to filter API-bound tools + result = writeComputeToolsFilter(result, toolsets, defaultToolset); + if (!result) { + console.error('patch: toolsets: step 2b failed (writeComputeToolsFilter)'); + return null; + } + + // Step 2c: Patch "No such tool available" error messages to be toolset-aware + const result2c = writeToolsetAwareErrors(result, toolsets, defaultToolset); + if (!result2c) { + console.error( + 'patch: toolsets: step 2c failed (writeToolsetAwareErrors) — continuing without friendlier errors' + ); + } else { + result = result2c; + } + // Step 3: Add toolset component definition result = writeToolsetComponentDefinition(result, toolsets, defaultToolset); if (!result) { diff --git a/src/patches/userMessageDisplay.ts b/src/patches/userMessageDisplay.ts index 7b748bd1..ae231897 100644 --- a/src/patches/userMessageDisplay.ts +++ b/src/patches/userMessageDisplay.ts @@ -147,7 +147,13 @@ export const writeUserMessageDisplay = ( const pattern = /(No content found in user prompt message.{0,150}?\b)([$\w]+(?:\.default)?\.createElement.{0,30}\b[$\w]+(?:\.default)?\.createElement.{0,40}">.+?)?(([$\w]+(?:\.default)?\.createElement).{0,100})(\([$\w]+,(?:\{[^{}]+wrap:"wrap"\},([$\w]+)(?:\.trim\(\))?\)\)|\{text:([$\w]+)(?:,thinkingMetadata:[$\w]+)?\}\)\)?))/; - const match = oldFile.match(pattern); + // CC ≥2.1.79: Rendering delegates to a subcomponent with {text:VAR,...} + // Pattern: No content found...createElement(BOX,{flexDirection:...},createElement(SUB,{text:VAR,...})) + const newPattern = + /(No content found in user prompt message.{0,50}?\b)(([$\w]+(?:\.default)?)\.createElement\([$\w]+,\{flexDirection:"column"[^}]*\},([$\w]+(?:\.default)?\.createElement)\([$\w]+,\{text:([$\w]+)[^}]*\}\)\))/; + + const oldMatch = oldFile.match(pattern); + const match = oldMatch ?? oldFile.match(newPattern); if (!match || match.index === undefined) { console.error( @@ -156,9 +162,18 @@ export const writeUserMessageDisplay = ( return null; } - const createElementFn = match[4]; - // Either match[6] or match[7] will be present (never both) - const messageVar = match[6] ?? match[7]; + let createElementFn: string; + let messageVar: string; + + if (oldMatch) { + // Old pattern matches + createElementFn = match[4]; + messageVar = match[6] ?? match[7]; + } else { + // New pattern (CC ≥2.1.79) + createElementFn = match[4]; + messageVar = match[5]; + } // Build box attributes (border and padding) const boxAttrs: string[] = []; diff --git a/src/patches/voiceMode.ts b/src/patches/voiceMode.ts index ed6ee866..69957025 100644 --- a/src/patches/voiceMode.ts +++ b/src/patches/voiceMode.ts @@ -28,24 +28,38 @@ import { showDiff } from './index'; const patchAmberQuartz = (file: string): string | null => { - const pattern = /function [$\w]+\(\)\{return [$\w]+\("tengu_amber_quartz"/; - - const match = file.match(pattern); - - if (!match || match.index === undefined) { - console.error('patch: voiceMode: failed to find tengu_amber_quartz gate'); - return null; + // CC <=2.1.69: function XXX(){return YYY("tengu_amber_quartz",!1)} + const legacyPattern = + /function [$\w]+\(\)\{return [$\w]+\("tengu_amber_quartz"/; + const legacyMatch = file.match(legacyPattern); + + if (legacyMatch && legacyMatch.index !== undefined) { + const insertIndex = legacyMatch.index + legacyMatch[0].indexOf('{') + 1; + const insertion = 'return !0;'; + const newFile = + file.slice(0, insertIndex) + insertion + file.slice(insertIndex); + showDiff(file, newFile, insertion, insertIndex, insertIndex); + return newFile; } - const insertIndex = match.index + match[0].indexOf('{') + 1; - const insertion = 'return !0;'; - - const newFile = - file.slice(0, insertIndex) + insertion + file.slice(insertIndex); - - showDiff(file, newFile, insertion, insertIndex, insertIndex); + // CC >=2.1.83: function XXX(){return!YYY("tengu_amber_quartz_disabled",!1)} + // This returns true by default (flag defaults to false, negated = true). + // We still force it to always return true in case the flag gets enabled server-side. + const newPattern = + /function [$\w]+\(\)\{return![$\w]+\("tengu_amber_quartz_disabled"/; + const newMatch = file.match(newPattern); + + if (newMatch && newMatch.index !== undefined) { + const insertIndex = newMatch.index + newMatch[0].indexOf('{') + 1; + const insertion = 'return !0;'; + const newFile = + file.slice(0, insertIndex) + insertion + file.slice(insertIndex); + showDiff(file, newFile, insertion, insertIndex, insertIndex); + return newFile; + } - return newFile; + console.error('patch: voiceMode: failed to find tengu_amber_quartz gate'); + return null; }; const patchConciseOutput = (file: string): string | null => { @@ -53,8 +67,9 @@ const patchConciseOutput = (file: string): string | null => { const match = file.match(pattern); if (!match || match.index === undefined) { - console.error('patch: voiceMode: failed to find tengu_sotto_voce gate'); - return null; + // CC >=2.1.83: tengu_sotto_voce gate was removed entirely. + // Concise output may be always-on or handled differently. + return file; } const replacement = 'if(!0)';