Last active
January 26, 2026 17:34
-
-
Save Haleclipse/e60e52941ddb30061623e33c711eae54 to your computer and use it in GitHub Desktop.
Claude Code ink2/render_v2 ANSI Ghost Characters Fix (Issue #19820)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Claude Code ink2/render_v2 ANSI Ghost Characters Fix Script (Windows Version) | |
| .DESCRIPTION | |
| Fixes GitHub issue: anthropics/claude-code#19820 (duplicate of #17519) | |
| THE BUG: | |
| When statusLine contains ANSI escapes + Nerd Font icons under ink2/render_v2, | |
| "ghost characters" appear - symbols leak into the message area and persist. | |
| ROOT CAUSE: | |
| Multiple places use String.length (UTF-16 code unit count) to approximate | |
| terminal column width. For non-BMP characters (surrogate pairs), this | |
| incorrectly adds extra width. | |
| FIX POINTS: | |
| 1) mHA: z += H ? 2 : J.length -> z += H ? 2 : 1 | |
| 2) RvA: w += O.fullWidth ? 2 : O.value.length -> w += O.fullWidth ? 2 : 1 | |
| 3) yvA.get(): let f = V.fullWidth || V.value.length > 1 -> let f = V.fullWidth | |
| .PARAMETER Check | |
| Check if fix is needed without making changes | |
| .PARAMETER Restore | |
| Restore original file from backup | |
| .PARAMETER Help | |
| Show help information | |
| .PARAMETER CliPath | |
| Path to cli.js file (optional, auto-detect if not provided) | |
| .EXAMPLE | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 | |
| Apply the fix (auto-detect cli.js location) | |
| .EXAMPLE | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 -CliPath "C:\path\to\cli.js" | |
| Apply fix to specific file | |
| .EXAMPLE | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 -Check | |
| Check if fix is needed | |
| .EXAMPLE | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 -Restore | |
| Restore from backup | |
| .EXAMPLE | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 -Check -CliPath "C:\path\to\cli.js" | |
| Check specific file | |
| .NOTES | |
| Requirements: | |
| - Node.js (already installed if you have Claude Code) | |
| - Internet connection (downloads acorn parser on first run) | |
| Note: This patch will be overwritten when Claude Code updates. | |
| Re-run this script after updates if ghost characters reappear. | |
| #> | |
| param( | |
| [switch]$Check, | |
| [switch]$Restore, | |
| [switch]$Help, | |
| [string]$CliPath | |
| ) | |
| # Color output functions | |
| function Write-Success { param($Message) Write-Host "[OK] " -ForegroundColor Green -NoNewline; Write-Host $Message } | |
| function Write-Warning { param($Message) Write-Host "[!] " -ForegroundColor Yellow -NoNewline; Write-Host $Message } | |
| function Write-FixError { param($Message) Write-Host "[X] " -ForegroundColor Red -NoNewline; Write-Host $Message } | |
| function Write-Info { param($Message) Write-Host "[>] " -ForegroundColor Blue -NoNewline; Write-Host $Message } | |
| # Main function to avoid exit closing terminal when run via iex | |
| function Invoke-GhostCharsFix { | |
| param( | |
| [switch]$Check, | |
| [switch]$Restore, | |
| [switch]$Help, | |
| [string]$CliPath | |
| ) | |
| # Show help | |
| if ($Help) { | |
| Write-Host @" | |
| Claude Code ink2/render_v2 ANSI Ghost Characters Fix Script | |
| Usage: | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 [options] | |
| Options: | |
| -Check Check if fix is needed without making changes | |
| -Restore Restore original file from backup | |
| -CliPath Path to cli.js file (optional, auto-detect if not provided) | |
| -Help Show this help message | |
| Examples: | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 -Check | |
| .\apply-claude-code-ink2-ghost-chars-fix.ps1 -CliPath "C:\path\to\cli.js" | |
| "@ | |
| return 0 | |
| } | |
| # Find Claude Code cli.js path | |
| function Find-CliPath { | |
| $locations = @( | |
| (Join-Path $env:USERPROFILE ".claude\local\node_modules\@anthropic-ai\claude-code\cli.js"), | |
| (Join-Path $env:APPDATA "npm\node_modules\@anthropic-ai\claude-code\cli.js"), | |
| (Join-Path $env:ProgramFiles "nodejs\node_modules\@anthropic-ai\claude-code\cli.js"), | |
| (Join-Path ${env:ProgramFiles(x86)} "nodejs\node_modules\@anthropic-ai\claude-code\cli.js") | |
| ) | |
| # Try to get global path from npm | |
| try { | |
| $npmRoot = & npm root -g 2>$null | |
| if ($npmRoot) { | |
| $locations += Join-Path $npmRoot "@anthropic-ai\claude-code\cli.js" | |
| } | |
| } catch {} | |
| foreach ($path in $locations) { | |
| if (Test-Path $path) { | |
| return $path | |
| } | |
| } | |
| return $null | |
| } | |
| # Determine cliPath: use provided path or auto-detect | |
| if ($CliPath) { | |
| # User provided a path | |
| if (Test-Path $CliPath) { | |
| $cliPathResolved = $CliPath | |
| Write-Info "Using specified cli.js: $cliPathResolved" | |
| } else { | |
| Write-FixError "Specified file not found: $CliPath" | |
| return 1 | |
| } | |
| } else { | |
| # Auto-detect | |
| $cliPathResolved = Find-CliPath | |
| if (-not $cliPathResolved) { | |
| Write-FixError "Claude Code cli.js not found" | |
| Write-Host "" | |
| Write-Host "Searched locations:" | |
| Write-Host " ~\.claude\local\node_modules\@anthropic-ai\claude-code\cli.js" | |
| Write-Host " %APPDATA%\npm\node_modules\@anthropic-ai\claude-code\cli.js" | |
| Write-Host " %ProgramFiles%\nodejs\node_modules\@anthropic-ai\claude-code\cli.js" | |
| Write-Host " `$(npm root -g)\@anthropic-ai\claude-code\cli.js" | |
| Write-Host "" | |
| Write-Host "Tip: You can specify the path directly:" | |
| Write-Host " .\apply-claude-code-ink2-ghost-chars-fix.ps1 -CliPath 'C:\path\to\cli.js'" | |
| return 1 | |
| } | |
| Write-Info "Found Claude Code: $cliPathResolved" | |
| } | |
| # Use resolved path | |
| $cliPath = $cliPathResolved | |
| # Restore backup | |
| if ($Restore) { | |
| $backups = Get-ChildItem -Path (Split-Path $cliPath) -Filter "cli.js.backup-ghost-*" -ErrorAction SilentlyContinue | | |
| Sort-Object LastWriteTime -Descending | |
| if ($backups.Count -gt 0) { | |
| $latestBackup = $backups[0].FullName | |
| Copy-Item $latestBackup $cliPath -Force | |
| Write-Success "Restored from backup: $latestBackup" | |
| return 0 | |
| } else { | |
| Write-FixError "No backup file found (cli.js.backup-ghost-*)" | |
| return 1 | |
| } | |
| } | |
| Write-Host "" | |
| # Download acorn parser if needed | |
| $acornPath = Join-Path $env:TEMP "acorn-claude-fix.js" | |
| if (-not (Test-Path $acornPath)) { | |
| Write-Info "Downloading acorn parser..." | |
| try { | |
| Invoke-WebRequest -Uri "https://unpkg.com/[email protected]/dist/acorn.js" -OutFile $acornPath -UseBasicParsing | |
| } catch { | |
| Write-FixError "Failed to download acorn parser" | |
| return 1 | |
| } | |
| } | |
| # Create patch script | |
| $patchScript = @' | |
| const fs = require('fs'); | |
| const acornPath = process.argv[2]; | |
| const acorn = require(acornPath); | |
| const cliPath = process.argv[3]; | |
| const checkOnly = process.argv[4] === '--check'; | |
| let code = fs.readFileSync(cliPath, 'utf-8'); | |
| // Preserve shebang | |
| let shebang = ''; | |
| if (code.startsWith('#!')) { | |
| const idx = code.indexOf('\n'); | |
| shebang = code.slice(0, idx + 1); | |
| code = code.slice(idx + 1); | |
| } | |
| // Track fix status | |
| let fixes = { | |
| mHA: { found: false, patched: false }, | |
| RvA: { found: false, patched: false }, | |
| yvA: { found: false, patched: false } | |
| }; | |
| // Parse AST | |
| let ast; | |
| try { | |
| ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: 'module' }); | |
| } catch (e) { | |
| console.error('PARSE_ERROR:' + e.message); | |
| process.exit(1); | |
| } | |
| // AST helper functions | |
| function findNodes(node, predicate, results = []) { | |
| if (!node || typeof node !== 'object') return results; | |
| if (predicate(node)) results.push(node); | |
| for (const key in node) { | |
| if (node[key] && typeof node[key] === 'object') { | |
| if (Array.isArray(node[key])) { | |
| node[key].forEach(child => findNodes(child, predicate, results)); | |
| } else { | |
| findNodes(node[key], predicate, results); | |
| } | |
| } | |
| } | |
| return results; | |
| } | |
| // Get source code snippet | |
| const src = (node) => code.slice(node.start, node.end); | |
| // ============================================================ | |
| // Fix 1: mHA function - z += H ? 2 : J.length -> z += H ? 2 : 1 | |
| // AST pattern: SequenceExpression containing: | |
| // - AssignmentExpression: Y += J.length | |
| // - AssignmentExpression: z += H ? 2 : J.length (ConditionalExpression) | |
| // Key: same variable J appears in both .length accesses | |
| // ============================================================ | |
| const allFunctions = findNodes(ast, n => | |
| n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression' | |
| ); | |
| for (const fn of allFunctions) { | |
| const fnSrc = src(fn); | |
| // Must contain String.fromCodePoint to be the target function | |
| if (!fnSrc.includes('String.fromCodePoint')) continue; | |
| // Find SequenceExpressions in this function | |
| const seqExprs = findNodes(fn, n => n.type === 'SequenceExpression'); | |
| for (const seq of seqExprs) { | |
| const exprs = seq.expressions; | |
| if (exprs.length < 2) continue; | |
| // Look for pattern: X+=Y.length, Z+=W?2:Y.length | |
| for (let i = 0; i < exprs.length - 1; i++) { | |
| const first = exprs[i]; | |
| const second = exprs[i + 1]; | |
| // First must be: var += var.length | |
| if (first.type !== 'AssignmentExpression' || first.operator !== '+=') continue; | |
| if (first.right.type !== 'MemberExpression') continue; | |
| if (first.right.property.type !== 'Identifier' || first.right.property.name !== 'length') continue; | |
| const charVar = first.right.object.name; // e.g., 'J' | |
| if (!charVar) continue; | |
| // Second must be: var += cond ? 2 : charVar.length | |
| if (second.type !== 'AssignmentExpression' || second.operator !== '+=') continue; | |
| if (second.right.type !== 'ConditionalExpression') continue; | |
| const cond = second.right; | |
| // consequent should be literal 2 | |
| if (cond.consequent.type !== 'Literal' || cond.consequent.value !== 2) continue; | |
| // alternate should be charVar.length | |
| if (cond.alternate.type !== 'MemberExpression') continue; | |
| if (cond.alternate.property.name !== 'length') continue; | |
| if (cond.alternate.object.name !== charVar) continue; | |
| // Found the pattern! | |
| fixes.mHA.found = true; | |
| fixes.mHA.node = second; // Store for precise replacement | |
| console.log('FOUND:mHA pattern -> ' + src(second)); | |
| break; | |
| } | |
| if (fixes.mHA.found) break; | |
| } | |
| if (fixes.mHA.found) break; | |
| } | |
| // ============================================================ | |
| // Fix 2: RvA function - w += O.fullWidth ? 2 : O.value.length -> w += O.fullWidth ? 2 : 1 | |
| // AST pattern: AssignmentExpression where right is ConditionalExpression | |
| // - test: obj.fullWidth | |
| // - consequent: 2 | |
| // - alternate: obj.value.length (same obj) | |
| // ============================================================ | |
| const assignExprs = findNodes(ast, n => | |
| n.type === 'AssignmentExpression' && n.operator === '+=' | |
| ); | |
| for (const assign of assignExprs) { | |
| if (assign.right.type !== 'ConditionalExpression') continue; | |
| const cond = assign.right; | |
| // test should be obj.fullWidth | |
| if (cond.test.type !== 'MemberExpression') continue; | |
| if (cond.test.property.name !== 'fullWidth') continue; | |
| const objName = cond.test.object.name; | |
| if (!objName) continue; | |
| // consequent should be 2 | |
| if (cond.consequent.type !== 'Literal' || cond.consequent.value !== 2) continue; | |
| // alternate should be obj.value.length (same obj) | |
| if (cond.alternate.type !== 'MemberExpression') continue; | |
| if (cond.alternate.property.name !== 'length') continue; | |
| if (cond.alternate.object.type !== 'MemberExpression') continue; | |
| if (cond.alternate.object.property.name !== 'value') continue; | |
| if (cond.alternate.object.object.name !== objName) continue; | |
| // Found the pattern! | |
| fixes.RvA.found = true; | |
| fixes.RvA.node = assign; | |
| console.log('FOUND:RvA pattern -> ' + src(assign)); | |
| break; | |
| } | |
| // ============================================================ | |
| // Fix 3: yvA.get() - let f = V.fullWidth || V.value.length > 1 -> let f = V.fullWidth | |
| // AST pattern: VariableDeclarator where init is LogicalExpression (||) | |
| // - left: obj.fullWidth | |
| // - right: BinaryExpression (obj.value.length > 1) | |
| // ============================================================ | |
| const varDeclarators = findNodes(ast, n => n.type === 'VariableDeclarator'); | |
| for (const decl of varDeclarators) { | |
| if (!decl.init || decl.init.type !== 'LogicalExpression') continue; | |
| if (decl.init.operator !== '||') continue; | |
| const logic = decl.init; | |
| // left should be obj.fullWidth | |
| if (logic.left.type !== 'MemberExpression') continue; | |
| if (logic.left.property.name !== 'fullWidth') continue; | |
| const objName = logic.left.object.name; | |
| if (!objName) continue; | |
| // right should be obj.value.length > 1 | |
| if (logic.right.type !== 'BinaryExpression') continue; | |
| if (logic.right.operator !== '>') continue; | |
| if (logic.right.right.type !== 'Literal' || logic.right.right.value !== 1) continue; | |
| // right.left should be obj.value.length | |
| const lenExpr = logic.right.left; | |
| if (lenExpr.type !== 'MemberExpression') continue; | |
| if (lenExpr.property.name !== 'length') continue; | |
| if (lenExpr.object.type !== 'MemberExpression') continue; | |
| if (lenExpr.object.property.name !== 'value') continue; | |
| if (lenExpr.object.object.name !== objName) continue; | |
| // Found the pattern! | |
| fixes.yvA.found = true; | |
| fixes.yvA.node = decl; | |
| console.log('FOUND:yvA pattern -> ' + src(decl)); | |
| break; | |
| } | |
| // Check if none found (may already be patched) | |
| if (!fixes.mHA.found && !fixes.RvA.found && !fixes.yvA.found) { | |
| // Verify if code is already in patched form | |
| const patchedMHA = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\+=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\?\s*2\s*:\s*1(?![a-zA-Z0-9_$])/.test(code); | |
| const patchedRvA = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\+=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\.fullWidth\s*\?\s*2\s*:\s*1(?![a-zA-Z0-9_$])/.test(code); | |
| if (patchedMHA || patchedRvA) { | |
| console.log('ALREADY_PATCHED'); | |
| process.exit(2); | |
| } | |
| console.error('NOT_FOUND:Unable to locate any code patterns requiring fix'); | |
| process.exit(1); | |
| } | |
| if (checkOnly) { | |
| console.log('NEEDS_PATCH'); | |
| let count = 0; | |
| if (fixes.mHA.found) count++; | |
| if (fixes.RvA.found) count++; | |
| if (fixes.yvA.found) count++; | |
| console.log('PATCH_COUNT:' + count); | |
| process.exit(1); | |
| } | |
| // Apply fixes | |
| let newCode = code; | |
| // Helper: replace code at specific position | |
| function replaceAt(str, start, end, replacement) { | |
| return str.slice(0, start) + replacement + str.slice(end); | |
| } | |
| // Collect all replacements (must apply from end to start to preserve positions) | |
| let replacements = []; | |
| // Fix 1: mHA - z += H ? 2 : J.length -> z += H ? 2 : 1 | |
| if (fixes.mHA.found && fixes.mHA.node) { | |
| const node = fixes.mHA.node; | |
| const cond = node.right; // ConditionalExpression | |
| // Replace only the alternate part (J.length -> 1) | |
| replacements.push({ | |
| start: cond.alternate.start, | |
| end: cond.alternate.end, | |
| replacement: '1', | |
| name: 'mHA' | |
| }); | |
| fixes.mHA.patched = true; | |
| console.log('PATCH:mHA - Fixed width accumulation: var.length -> 1'); | |
| } | |
| // Fix 2: RvA - w += O.fullWidth ? 2 : O.value.length -> w += O.fullWidth ? 2 : 1 | |
| if (fixes.RvA.found && fixes.RvA.node) { | |
| const node = fixes.RvA.node; | |
| const cond = node.right; // ConditionalExpression | |
| // Replace only the alternate part (O.value.length -> 1) | |
| replacements.push({ | |
| start: cond.alternate.start, | |
| end: cond.alternate.end, | |
| replacement: '1', | |
| name: 'RvA' | |
| }); | |
| fixes.RvA.patched = true; | |
| console.log('PATCH:RvA - Fixed clip width counting: .value.length -> 1'); | |
| } | |
| // Fix 3: yvA - let f = V.fullWidth || V.value.length > 1 -> let f = V.fullWidth | |
| if (fixes.yvA.found && fixes.yvA.node) { | |
| const node = fixes.yvA.node; | |
| const logic = node.init; // LogicalExpression | |
| // Replace the entire init with just the left part (V.fullWidth) | |
| replacements.push({ | |
| start: logic.start, | |
| end: logic.end, | |
| replacement: src(logic.left), | |
| name: 'yvA' | |
| }); | |
| fixes.yvA.patched = true; | |
| console.log('PATCH:yvA - Fixed wide char detection: removed || .value.length > 1'); | |
| } | |
| // Apply replacements from end to start to preserve positions | |
| replacements.sort((a, b) => b.start - a.start); | |
| for (const r of replacements) { | |
| newCode = replaceAt(newCode, r.start, r.end, r.replacement); | |
| } | |
| // Verify fixes applied | |
| let patchedCount = 0; | |
| if (fixes.mHA.patched) patchedCount++; | |
| if (fixes.RvA.patched) patchedCount++; | |
| if (fixes.yvA.patched) patchedCount++; | |
| if (patchedCount === 0) { | |
| console.error('VERIFY_FAILED:No fixes were applied'); | |
| process.exit(1); | |
| } | |
| // Backup original file | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); | |
| const backupPath = cliPath + '.backup-ghost-' + timestamp; | |
| fs.copyFileSync(cliPath, backupPath); | |
| console.log('BACKUP:' + backupPath); | |
| // Write patched file | |
| fs.writeFileSync(cliPath, shebang + newCode); | |
| console.log('SUCCESS:' + patchedCount); | |
| '@ | |
| $tempPatchScript = Join-Path $env:TEMP "claude-ghost-patch-$PID.js" | |
| $patchScript | Out-File -FilePath $tempPatchScript -Encoding UTF8 | |
| # Run patch script | |
| $checkArg = if ($Check) { "--check" } else { "" } | |
| $output = & node $tempPatchScript $acornPath $cliPath $checkArg 2>&1 | |
| $scriptExitCode = $LASTEXITCODE | |
| # Cleanup temp script | |
| Remove-Item $tempPatchScript -ErrorAction SilentlyContinue | |
| # Process output | |
| foreach ($line in $output) { | |
| switch -Regex ($line) { | |
| "^ALREADY_PATCHED" { Write-Success "Already patched"; return 0 } | |
| "^PARSE_ERROR:(.+)" { Write-FixError "Failed to parse cli.js: $($Matches[1])"; return 1 } | |
| "^NOT_FOUND:(.+)" { Write-FixError "Target code not found: $($Matches[1])"; return 1 } | |
| "^FOUND:(.+)" { Write-Info "Found: $($Matches[1])" } | |
| "^PATCH:(.+)" { Write-Info "Patch: $($Matches[1])" } | |
| "^NEEDS_PATCH" { | |
| Write-Host "" | |
| Write-Warning "Patch needed - run without -Check to apply" | |
| } | |
| "^PATCH_COUNT:(.+)" { | |
| Write-Info "Need to patch $($Matches[1]) location(s)" | |
| return 1 | |
| } | |
| "^BACKUP:(.+)" { Write-Host ""; Write-Host "Backup: $($Matches[1])" } | |
| "^SUCCESS:(.+)" { | |
| Write-Host "" | |
| Write-Success "Fix applied successfully! Patched $($Matches[1]) location(s)" | |
| Write-Host "" | |
| Write-Warning "Restart Claude Code for changes to take effect" | |
| } | |
| "^VERIFY_FAILED:(.+)" { Write-FixError "Verification failed: $($Matches[1])"; return 1 } | |
| } | |
| } | |
| return $scriptExitCode | |
| } | |
| # Invoke main function with passed parameters | |
| Invoke-GhostCharsFix -Check:$Check -Restore:$Restore -Help:$Help -CliPath $CliPath |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # | |
| # Claude Code ink2/render_v2 ANSI Ghost Characters Fix Script | |
| # | |
| # Fixes GitHub issue: anthropics/claude-code#19820 (duplicate of #17519) | |
| # | |
| # THE BUG: | |
| # When statusLine contains ANSI escapes + Nerd Font icons under ink2/render_v2, | |
| # "ghost characters" appear - symbols leak into the message area and persist. | |
| # | |
| # ROOT CAUSE: | |
| # Multiple places use String.length (UTF-16 code unit count) to approximate | |
| # terminal column width. For non-BMP characters (surrogate pairs), this | |
| # incorrectly adds extra width. | |
| # | |
| # FIX POINTS: | |
| # 1) mHA: z += H ? 2 : J.length → z += H ? 2 : 1 | |
| # 2) RvA: w += O.fullWidth ? 2 : O.value.length → w += O.fullWidth ? 2 : 1 | |
| # 3) yvA.get(): let f = V.fullWidth || V.value.length > 1 → let f = V.fullWidth | |
| # | |
| # Usage: | |
| # ./apply-claude-code-ink2-ghost-chars-fix.sh # Apply fix (auto-detect) | |
| # ./apply-claude-code-ink2-ghost-chars-fix.sh /path/to/cli.js # Apply fix to specific file | |
| # ./apply-claude-code-ink2-ghost-chars-fix.sh --check # Check only | |
| # ./apply-claude-code-ink2-ghost-chars-fix.sh --restore # Restore backup | |
| # | |
| set -e | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| NC='\033[0m' | |
| success() { echo -e "${GREEN}[OK]${NC} $1"; } | |
| warning() { echo -e "${YELLOW}[!]${NC} $1"; } | |
| error() { echo -e "${RED}[X]${NC} $1"; } | |
| info() { echo -e "${BLUE}[>]${NC} $1"; } | |
| CHECK_ONLY=false | |
| RESTORE=false | |
| CLI_PATH_ARG="" | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| --check|-c) CHECK_ONLY=true; shift ;; | |
| --restore|-r) RESTORE=true; shift ;; | |
| --help|-h) | |
| echo "Usage: $0 [options] [cli.js path]" | |
| echo "" | |
| echo "Fix ink2/render_v2 statusLine ANSI + Nerd Font ghost characters issue" | |
| echo "" | |
| echo "Arguments:" | |
| echo " cli.js path Path to cli.js file (optional, auto-detect if not provided)" | |
| echo "" | |
| echo "Options:" | |
| echo " --check, -c Check if fix is needed without making changes" | |
| echo " --restore, -r Restore original file from backup" | |
| echo " --help, -h Show help information" | |
| echo "" | |
| echo "Examples:" | |
| echo " $0 # Auto-detect and apply fix" | |
| echo " $0 /path/to/cli.js # Apply fix to specific file" | |
| echo " $0 --check /path/to/cli.js # Check specific file" | |
| echo " $0 /path/to/cli.js --check # Same as above" | |
| exit 0 | |
| ;; | |
| -*) | |
| error "Unknown option: $1" | |
| exit 1 | |
| ;; | |
| *) | |
| # Positional argument: cli.js path | |
| if [[ -z "$CLI_PATH_ARG" ]]; then | |
| CLI_PATH_ARG="$1" | |
| else | |
| error "Unexpected argument: $1" | |
| exit 1 | |
| fi | |
| shift | |
| ;; | |
| esac | |
| done | |
| find_cli_path() { | |
| local locations=( | |
| "$HOME/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js" | |
| "/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js" | |
| "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js" | |
| ) | |
| if command -v npm &> /dev/null; then | |
| local npm_root | |
| npm_root=$(npm root -g 2>/dev/null || true) | |
| if [[ -n "$npm_root" ]]; then | |
| locations+=("$npm_root/@anthropic-ai/claude-code/cli.js") | |
| fi | |
| fi | |
| for path in "${locations[@]}"; do | |
| if [[ -f "$path" ]]; then | |
| echo "$path" | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| # Determine CLI_PATH: use provided path or auto-detect | |
| if [[ -n "$CLI_PATH_ARG" ]]; then | |
| # User provided a path | |
| if [[ -f "$CLI_PATH_ARG" ]]; then | |
| CLI_PATH="$CLI_PATH_ARG" | |
| info "Using specified cli.js: $CLI_PATH" | |
| else | |
| error "Specified file not found: $CLI_PATH_ARG" | |
| exit 1 | |
| fi | |
| else | |
| # Auto-detect | |
| CLI_PATH=$(find_cli_path) || { | |
| error "Claude Code cli.js not found" | |
| echo "" | |
| echo "Searched locations:" | |
| echo " ~/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js" | |
| echo " /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js" | |
| echo " \$(npm root -g)/@anthropic-ai/claude-code/cli.js" | |
| echo "" | |
| echo "Tip: You can specify the path directly:" | |
| echo " $0 /path/to/cli.js" | |
| exit 1 | |
| } | |
| info "Found Claude Code: $CLI_PATH" | |
| fi | |
| CLI_DIR=$(dirname "$CLI_PATH") | |
| if $RESTORE; then | |
| LATEST_BACKUP=$(ls -t "$CLI_DIR"/cli.js.backup-ghost-* 2>/dev/null | head -1) | |
| if [[ -n "$LATEST_BACKUP" ]]; then | |
| cp "$LATEST_BACKUP" "$CLI_PATH" | |
| success "Restored from backup: $LATEST_BACKUP" | |
| exit 0 | |
| else | |
| error "No backup file found (cli.js.backup-ghost-*)" | |
| exit 1 | |
| fi | |
| fi | |
| echo "" | |
| # Download acorn parser if needed | |
| ACORN_PATH="/tmp/acorn-claude-fix.js" | |
| if [[ ! -f "$ACORN_PATH" ]]; then | |
| info "Downloading acorn parser..." | |
| curl -sL "https://unpkg.com/[email protected]/dist/acorn.js" -o "$ACORN_PATH" || { | |
| error "Failed to download acorn parser" | |
| exit 1 | |
| } | |
| fi | |
| PATCH_SCRIPT=$(mktemp) | |
| cat > "$PATCH_SCRIPT" << 'PATCH_EOF' | |
| const fs = require('fs'); | |
| const acornPath = process.argv[2]; | |
| const acorn = require(acornPath); | |
| const cliPath = process.argv[3]; | |
| const checkOnly = process.argv[4] === '--check'; | |
| let code = fs.readFileSync(cliPath, 'utf-8'); | |
| // Preserve shebang | |
| let shebang = ''; | |
| if (code.startsWith('#!')) { | |
| const idx = code.indexOf('\n'); | |
| shebang = code.slice(0, idx + 1); | |
| code = code.slice(idx + 1); | |
| } | |
| // Track fix status | |
| let fixes = { | |
| mHA: { found: false, patched: false }, | |
| RvA: { found: false, patched: false }, | |
| yvA: { found: false, patched: false } | |
| }; | |
| // Parse AST | |
| let ast; | |
| try { | |
| ast = acorn.parse(code, { ecmaVersion: 2022, sourceType: 'module' }); | |
| } catch (e) { | |
| console.error('PARSE_ERROR:' + e.message); | |
| process.exit(1); | |
| } | |
| // AST helper functions | |
| function findNodes(node, predicate, results = []) { | |
| if (!node || typeof node !== 'object') return results; | |
| if (predicate(node)) results.push(node); | |
| for (const key in node) { | |
| if (node[key] && typeof node[key] === 'object') { | |
| if (Array.isArray(node[key])) { | |
| node[key].forEach(child => findNodes(child, predicate, results)); | |
| } else { | |
| findNodes(node[key], predicate, results); | |
| } | |
| } | |
| } | |
| return results; | |
| } | |
| // Get source code snippet | |
| const src = (node) => code.slice(node.start, node.end); | |
| // ============================================================ | |
| // Fix 1: mHA function - z += H ? 2 : J.length → z += H ? 2 : 1 | |
| // AST pattern: SequenceExpression containing: | |
| // - AssignmentExpression: Y += J.length | |
| // - AssignmentExpression: z += H ? 2 : J.length (ConditionalExpression) | |
| // Key: same variable J appears in both .length accesses | |
| // ============================================================ | |
| const allFunctions = findNodes(ast, n => | |
| n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression' | |
| ); | |
| for (const fn of allFunctions) { | |
| const fnSrc = src(fn); | |
| // Must contain String.fromCodePoint to be the target function | |
| if (!fnSrc.includes('String.fromCodePoint')) continue; | |
| // Find SequenceExpressions in this function | |
| const seqExprs = findNodes(fn, n => n.type === 'SequenceExpression'); | |
| for (const seq of seqExprs) { | |
| const exprs = seq.expressions; | |
| if (exprs.length < 2) continue; | |
| // Look for pattern: X+=Y.length, Z+=W?2:Y.length | |
| for (let i = 0; i < exprs.length - 1; i++) { | |
| const first = exprs[i]; | |
| const second = exprs[i + 1]; | |
| // First must be: var += var.length | |
| if (first.type !== 'AssignmentExpression' || first.operator !== '+=') continue; | |
| if (first.right.type !== 'MemberExpression') continue; | |
| if (first.right.property.type !== 'Identifier' || first.right.property.name !== 'length') continue; | |
| const charVar = first.right.object.name; // e.g., 'J' | |
| if (!charVar) continue; | |
| // Second must be: var += cond ? 2 : charVar.length | |
| if (second.type !== 'AssignmentExpression' || second.operator !== '+=') continue; | |
| if (second.right.type !== 'ConditionalExpression') continue; | |
| const cond = second.right; | |
| // consequent should be literal 2 | |
| if (cond.consequent.type !== 'Literal' || cond.consequent.value !== 2) continue; | |
| // alternate should be charVar.length | |
| if (cond.alternate.type !== 'MemberExpression') continue; | |
| if (cond.alternate.property.name !== 'length') continue; | |
| if (cond.alternate.object.name !== charVar) continue; | |
| // Found the pattern! | |
| fixes.mHA.found = true; | |
| fixes.mHA.node = second; // Store for precise replacement | |
| console.log('FOUND:mHA pattern -> ' + src(second)); | |
| break; | |
| } | |
| if (fixes.mHA.found) break; | |
| } | |
| if (fixes.mHA.found) break; | |
| } | |
| // ============================================================ | |
| // Fix 2: RvA function - w += O.fullWidth ? 2 : O.value.length → w += O.fullWidth ? 2 : 1 | |
| // AST pattern: AssignmentExpression where right is ConditionalExpression | |
| // - test: obj.fullWidth | |
| // - consequent: 2 | |
| // - alternate: obj.value.length (same obj) | |
| // ============================================================ | |
| const assignExprs = findNodes(ast, n => | |
| n.type === 'AssignmentExpression' && n.operator === '+=' | |
| ); | |
| for (const assign of assignExprs) { | |
| if (assign.right.type !== 'ConditionalExpression') continue; | |
| const cond = assign.right; | |
| // test should be obj.fullWidth | |
| if (cond.test.type !== 'MemberExpression') continue; | |
| if (cond.test.property.name !== 'fullWidth') continue; | |
| const objName = cond.test.object.name; | |
| if (!objName) continue; | |
| // consequent should be 2 | |
| if (cond.consequent.type !== 'Literal' || cond.consequent.value !== 2) continue; | |
| // alternate should be obj.value.length (same obj) | |
| if (cond.alternate.type !== 'MemberExpression') continue; | |
| if (cond.alternate.property.name !== 'length') continue; | |
| if (cond.alternate.object.type !== 'MemberExpression') continue; | |
| if (cond.alternate.object.property.name !== 'value') continue; | |
| if (cond.alternate.object.object.name !== objName) continue; | |
| // Found the pattern! | |
| fixes.RvA.found = true; | |
| fixes.RvA.node = assign; | |
| console.log('FOUND:RvA pattern -> ' + src(assign)); | |
| break; | |
| } | |
| // ============================================================ | |
| // Fix 3: yvA.get() - let f = V.fullWidth || V.value.length > 1 → let f = V.fullWidth | |
| // AST pattern: VariableDeclarator where init is LogicalExpression (||) | |
| // - left: obj.fullWidth | |
| // - right: BinaryExpression (obj.value.length > 1) | |
| // ============================================================ | |
| const varDeclarators = findNodes(ast, n => n.type === 'VariableDeclarator'); | |
| for (const decl of varDeclarators) { | |
| if (!decl.init || decl.init.type !== 'LogicalExpression') continue; | |
| if (decl.init.operator !== '||') continue; | |
| const logic = decl.init; | |
| // left should be obj.fullWidth | |
| if (logic.left.type !== 'MemberExpression') continue; | |
| if (logic.left.property.name !== 'fullWidth') continue; | |
| const objName = logic.left.object.name; | |
| if (!objName) continue; | |
| // right should be obj.value.length > 1 | |
| if (logic.right.type !== 'BinaryExpression') continue; | |
| if (logic.right.operator !== '>') continue; | |
| if (logic.right.right.type !== 'Literal' || logic.right.right.value !== 1) continue; | |
| // right.left should be obj.value.length | |
| const lenExpr = logic.right.left; | |
| if (lenExpr.type !== 'MemberExpression') continue; | |
| if (lenExpr.property.name !== 'length') continue; | |
| if (lenExpr.object.type !== 'MemberExpression') continue; | |
| if (lenExpr.object.property.name !== 'value') continue; | |
| if (lenExpr.object.object.name !== objName) continue; | |
| // Found the pattern! | |
| fixes.yvA.found = true; | |
| fixes.yvA.node = decl; | |
| console.log('FOUND:yvA pattern -> ' + src(decl)); | |
| break; | |
| } | |
| // Check if none found (may already be patched) | |
| if (!fixes.mHA.found && !fixes.RvA.found && !fixes.yvA.found) { | |
| // Verify if code is already in patched form | |
| const patchedMHA = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\+=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\?\s*2\s*:\s*1(?![a-zA-Z0-9_$])/.test(code); | |
| const patchedRvA = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\+=\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\.fullWidth\s*\?\s*2\s*:\s*1(?![a-zA-Z0-9_$])/.test(code); | |
| if (patchedMHA || patchedRvA) { | |
| console.log('ALREADY_PATCHED'); | |
| process.exit(2); | |
| } | |
| console.error('NOT_FOUND:Unable to locate any code patterns requiring fix'); | |
| process.exit(1); | |
| } | |
| if (checkOnly) { | |
| console.log('NEEDS_PATCH'); | |
| let count = 0; | |
| if (fixes.mHA.found) count++; | |
| if (fixes.RvA.found) count++; | |
| if (fixes.yvA.found) count++; | |
| console.log('PATCH_COUNT:' + count); | |
| process.exit(1); | |
| } | |
| // Apply fixes | |
| let newCode = code; | |
| // Helper: replace code at specific position | |
| function replaceAt(str, start, end, replacement) { | |
| return str.slice(0, start) + replacement + str.slice(end); | |
| } | |
| // Collect all replacements (must apply from end to start to preserve positions) | |
| let replacements = []; | |
| // Fix 1: mHA - z += H ? 2 : J.length → z += H ? 2 : 1 | |
| if (fixes.mHA.found && fixes.mHA.node) { | |
| const node = fixes.mHA.node; | |
| const cond = node.right; // ConditionalExpression | |
| // Replace only the alternate part (J.length -> 1) | |
| replacements.push({ | |
| start: cond.alternate.start, | |
| end: cond.alternate.end, | |
| replacement: '1', | |
| name: 'mHA' | |
| }); | |
| fixes.mHA.patched = true; | |
| console.log('PATCH:mHA - Fixed width accumulation: var.length -> 1'); | |
| } | |
| // Fix 2: RvA - w += O.fullWidth ? 2 : O.value.length → w += O.fullWidth ? 2 : 1 | |
| if (fixes.RvA.found && fixes.RvA.node) { | |
| const node = fixes.RvA.node; | |
| const cond = node.right; // ConditionalExpression | |
| // Replace only the alternate part (O.value.length -> 1) | |
| replacements.push({ | |
| start: cond.alternate.start, | |
| end: cond.alternate.end, | |
| replacement: '1', | |
| name: 'RvA' | |
| }); | |
| fixes.RvA.patched = true; | |
| console.log('PATCH:RvA - Fixed clip width counting: .value.length -> 1'); | |
| } | |
| // Fix 3: yvA - let f = V.fullWidth || V.value.length > 1 → let f = V.fullWidth | |
| if (fixes.yvA.found && fixes.yvA.node) { | |
| const node = fixes.yvA.node; | |
| const logic = node.init; // LogicalExpression | |
| // Replace the entire init with just the left part (V.fullWidth) | |
| replacements.push({ | |
| start: logic.start, | |
| end: logic.end, | |
| replacement: src(logic.left), | |
| name: 'yvA' | |
| }); | |
| fixes.yvA.patched = true; | |
| console.log('PATCH:yvA - Fixed wide char detection: removed || .value.length > 1'); | |
| } | |
| // Apply replacements from end to start to preserve positions | |
| replacements.sort((a, b) => b.start - a.start); | |
| for (const r of replacements) { | |
| newCode = replaceAt(newCode, r.start, r.end, r.replacement); | |
| } | |
| // Verify fixes applied | |
| let patchedCount = 0; | |
| if (fixes.mHA.patched) patchedCount++; | |
| if (fixes.RvA.patched) patchedCount++; | |
| if (fixes.yvA.patched) patchedCount++; | |
| if (patchedCount === 0) { | |
| console.error('VERIFY_FAILED:No fixes were applied'); | |
| process.exit(1); | |
| } | |
| // Backup original file | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); | |
| const backupPath = cliPath + '.backup-ghost-' + timestamp; | |
| fs.copyFileSync(cliPath, backupPath); | |
| console.log('BACKUP:' + backupPath); | |
| // Write patched file | |
| fs.writeFileSync(cliPath, shebang + newCode); | |
| console.log('SUCCESS:' + patchedCount); | |
| PATCH_EOF | |
| CHECK_ARG="" | |
| if $CHECK_ONLY; then | |
| CHECK_ARG="--check" | |
| fi | |
| OUTPUT=$(node "$PATCH_SCRIPT" "$ACORN_PATH" "$CLI_PATH" "$CHECK_ARG" 2>&1) || true | |
| EXIT_CODE=$? | |
| rm -f "$PATCH_SCRIPT" | |
| while IFS= read -r line; do | |
| case "$line" in | |
| ALREADY_PATCHED) | |
| success "Already patched" | |
| exit 0 | |
| ;; | |
| PARSE_ERROR:*) | |
| error "Failed to parse cli.js: ${line#PARSE_ERROR:}" | |
| exit 1 | |
| ;; | |
| NOT_FOUND:*) | |
| error "Target code not found: ${line#NOT_FOUND:}" | |
| exit 1 | |
| ;; | |
| FOUND:*) | |
| info "Found: ${line#FOUND:}" | |
| ;; | |
| PATCH:*) | |
| info "Patch: ${line#PATCH:}" | |
| ;; | |
| NEEDS_PATCH) | |
| echo "" | |
| warning "Patch needed - run without --check to apply" | |
| ;; | |
| PATCH_COUNT:*) | |
| info "Need to patch ${line#PATCH_COUNT:} location(s)" | |
| exit 1 | |
| ;; | |
| BACKUP:*) | |
| echo "" | |
| echo "Backup: ${line#BACKUP:}" | |
| ;; | |
| SUCCESS:*) | |
| echo "" | |
| success "Fix applied successfully! Patched ${line#SUCCESS:} location(s)" | |
| echo "" | |
| warning "Restart Claude Code for changes to take effect" | |
| ;; | |
| VERIFY_FAILED:*) | |
| error "Verification failed: ${line#VERIFY_FAILED:}" | |
| exit 1 | |
| ;; | |
| esac | |
| done <<< "$OUTPUT" | |
| exit $EXIT_CODE |
Author
Does this work with the native install of Claude?
No, we cannot handle "native" Claude Code built with Bun. Although we can extract cli.js from the bun build. it’s the same file as the one in the npm package. But it can’t be repackaged.
Even many versions ago, I’ve been recommending the use of the npm package over the Bun-built Claude Code.
Anthropic acquired bun. Bun is indeed more convenient than Node and better suited for building binaries.
However, based on the current situation with Claude Code, npm packages are more flexible, free, and convenient.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Does this work with the native install of Claude?