Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "claude-code-plugins",
"name": "cruzlauroiii-plugins",
"version": "1.0.0",
"description": "Bundled plugins for Claude Code including Agent SDK development tools, PR review toolkit, and commit workflows",
"owner": {
Expand Down Expand Up @@ -145,6 +145,46 @@
},
"source": "./plugins/security-guidance",
"category": "security"
},
{
"name": "scroll-fix",
"description": "Fixes terminal scroll-to-top regression caused by Ink renderer cursor-up sequences exceeding viewport height. Includes Ctrl+6 freeze toggle. All platforms.",
"version": "1.0.0",
"author": {
"name": "cruzlauroiii"
},
"source": "./plugins/scroll-fix",
"category": "fixes"
},
{
"name": "bridge-fix",
"description": "Fixes Chrome extension bridge connection failure by disabling remote bridge URL resolver, forcing local named pipe usage.",
"version": "1.0.0",
"author": {
"name": "cruzlauroiii"
},
"source": "./plugins/bridge-fix",
"category": "fixes"
},
{
"name": "powershell-default",
"description": "Adds a native Pwsh tool using PowerShell 7+ Preview. Shows as Pwsh(...) in the UI. Commands use PowerShell syntax directly. Works on any OS.",
"version": "1.0.0",
"author": {
"name": "cruzlauroiii"
},
"source": "./plugins/powershell-default",
"category": "tools"
},
{
"name": "scroll-fix",
"description": "Fixes terminal scroll-to-top regression caused by Ink renderer cursor-up sequences exceeding viewport height. Includes Ctrl+6 freeze toggle. All platforms.",
"version": "1.0.0",
"author": {
"name": "cruzlauroiii"
},
"source": "./plugins/scroll-fix",
"category": "fixes"
}
]
}
8 changes: 8 additions & 0 deletions plugins/scroll-fix/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "scroll-fix",
"version": "1.0.0",
"description": "Fixes terminal scroll-to-top regression caused by Ink renderer's cursor-up sequences exceeding viewport height. Includes Ctrl+6 freeze toggle for manual scroll control.",
"author": {
"name": "cruzlauroiii"
}
}
75 changes: 75 additions & 0 deletions plugins/scroll-fix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# scroll-fix

Fixes the terminal scroll-to-top regression in Claude Code. Works on **all platforms** (Windows, macOS, Linux) and **all terminals**.

## Problem

Claude Code's Ink renderer uses cursor-up (`\x1b[{n}A`) sequences to clear previous output before redrawing. When the output exceeds the viewport height, the cursor moves above the visible area and the terminal viewport follows it — snapping to the top of scrollback on every re-render.

**Root cause:** `eraseLines()` generates N cursor-up sequences where N = number of previously rendered lines. Terminals follow cursor position changes, including during synchronized output blocks.

**Upstream issues:**
- [microsoft/terminal#14774](https://github.com/microsoft/terminal/issues/14774) — `SetConsoleCursorPosition` always scrolls viewport to cursor
- [anthropics/claude-code#33814](https://github.com/anthropics/claude-code/issues/33814) — Forces scroll to top when outputting code
- [anthropics/claude-code#826](https://github.com/anthropics/claude-code/issues/826) — Console scrolling top of history
- [anthropics/claude-code#11801](https://github.com/anthropics/claude-code/issues/11801) — Terminal scrolls to the top after each response
- [anthropics/claude-code#3648](https://github.com/anthropics/claude-code/issues/3648) — Terminal scrolling uncontrollably
- [anthropics/claude-code#34794](https://github.com/anthropics/claude-code/issues/34794) — Terminal scrolls to top during agent execution

## Fix

Intercepts `process.stdout.write` and tracks cumulative cursor-up within each synchronized output block (`\x1b[?2026h` … `\x1b[?2026l`). Clamps total cursor-up to `process.stdout.rows` so the cursor stays within the visible viewport.

### Additional feature: Ctrl+6 freeze toggle

Press **Ctrl+6** to freeze all Ink re-render output. Press again to unfreeze and replay buffered frames. This allows scrolling through terminal history without the viewport being yanked back.

## Installation

### Option 1: Node.js preload (recommended)

```bash
# Set in your shell profile (.bashrc, .zshrc, etc.)
export NODE_OPTIONS="--require /path/to/plugins/scroll-fix/scroll-fix.cjs"

# Then run claude normally
claude
```

### Option 2: Patch cli.js directly

```bash
node plugins/scroll-fix/scripts/install.js /path/to/cli.js
```

To remove:
```bash
node plugins/scroll-fix/scripts/install.js --uninstall /path/to/cli.js
```

### Option 3: Install as Claude Code plugin

```bash
claude /plugin install /path/to/plugins/scroll-fix
```

## How it works

1. **Sync block tracking**: Detects `\x1b[?2026h` (synchronized output start) and resets the cursor-up counter
2. **Cursor-up clamping**: Each `\x1b[{n}A` sequence consumes from a budget equal to `process.stdout.rows`. When budget hits 0, further cursor-up sequences are suppressed
3. **Budget restoration**: Newlines (`\n`) and cursor-down (`\x1b[{n}B`) restore the budget, allowing the next render cycle to clear properly
4. **Sync block end**: `\x1b[?2026l` resets the counter

This ensures the cursor never leaves the visible viewport during re-renders, preventing the terminal from snapping to the top.

## Compatibility

| Platform | Status |
|----------|--------|
| Windows Terminal | ✅ Fixed |
| macOS Terminal.app | ✅ Fixed |
| macOS iTerm2 | ✅ Fixed |
| Linux (all terminals) | ✅ Fixed |
| VS Code integrated terminal | ✅ Fixed |
| tmux | ✅ Fixed |
| Ghostty | ✅ (already minimal, now zero) |
16 changes: 16 additions & 0 deletions plugins/scroll-fix/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"description": "Scroll fix plugin — notifies when the scroll-to-top fix is active",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/check-scroll-fix.js",
"timeout": 5
}
]
}
]
}
}
12 changes: 12 additions & 0 deletions plugins/scroll-fix/patches/scroll-fix.patch

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions plugins/scroll-fix/scripts/check-scroll-fix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* SessionStart hook — checks if the scroll fix preload is active.
* If NODE_OPTIONS includes scroll-fix.cjs, we know the fix is loaded.
* Otherwise, output installation instructions as context.
*/

const nodeOpts = process.env.NODE_OPTIONS || "";
const isActive = nodeOpts.includes("scroll-fix");

const output = {
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: isActive
? "Scroll-to-top fix is active. Ctrl+6 toggles output freeze for manual scrolling."
: "NOTE: The scroll-to-top fix preload is not active. To enable it, set:\n NODE_OPTIONS=\"--require <plugin-path>/scroll-fix.cjs\"\nor run: node <plugin-path>/scripts/install.js"
}
};

process.stdout.write(JSON.stringify(output));
process.exit(0);
136 changes: 136 additions & 0 deletions plugins/scroll-fix/scripts/install.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* install.js — Applies the scroll-to-top fix to Claude Code's cli.js
*
* Uses git diff compatible patches when possible, falls back to string replacement.
*
* Usage:
* node install.js [path-to-cli.js]
* node install.js --uninstall [path-to-cli.js]
*/

"use strict";

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { execFileSync } = require("child_process");

const PATCH_MARKER = "/* SCROLL_FIX */";

const PATCH_CODE =
';(function(){var _ow=process.stdout.write.bind(process.stdout);var _frozen=false,_buf=[];' +
'setTimeout(function(){try{process.stdin.on("data",function(d){if(d.toString().indexOf("\\x1e")!==-1){' +
'_frozen=!_frozen;if(_frozen){_ow("\\x1b]0;Claude Code [FROZEN - Ctrl+6 to resume]\\x07")}' +
'else{if(_buf.length>0){var a="";for(var i=0;i<_buf.length;i++)a+=_buf[i];_buf=[];_ow(a)}' +
'_ow("\\x1b]0;Claude Code\\x07")}}})}catch(e){}},2000);' +
'process.stdout.write=function(d,e,c){if(typeof e==="function"){c=e;e=void 0}' +
'var s=typeof d==="string"?d:Buffer.isBuffer(d)?d.toString("utf-8"):String(d);' +
'var maxUp=process.stdout.rows||24;var upBudget=maxUp;' +
's=s.replace(/\\x1b\\[(\\d*)A/g,function(m,p){var n=parseInt(p)||1;' +
'if(upBudget<=0)return"";var allowed=n>upBudget?upBudget:n;upBudget-=allowed;' +
'return"\\x1b["+allowed+"A"});' +
'if(_frozen){_buf.push(s);if(c)c();return true}' +
'if(typeof d==="string")return _ow(s,e,c);return _ow(Buffer.from(s,"utf-8"),e,c)};})();';

function findCliJs(userPath) {
if (userPath && fs.existsSync(userPath)) return userPath;
const candidates = [
"./cli.js",
path.join(process.env.APPDATA || "", "npm/node_modules/@anthropic-ai/claude-code/cli.js"),
path.join(process.env.HOME || "", ".local/lib/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",
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}

function sha256(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}

function tryGitApply(cliPath) {
const patchFile = path.join(__dirname, "..", "patches", "scroll-fix.patch");
if (!fs.existsSync(patchFile)) return false;

const cliDir = path.dirname(cliPath);
try {
execFileSync("git", ["apply", "-C0", "--check", patchFile], {
cwd: cliDir,
timeout: 5000,
stdio: "pipe"
});
execFileSync("git", ["apply", "-C0", patchFile], {
cwd: cliDir,
timeout: 5000,
stdio: "pipe"
});
return true;
} catch {
return false;
}
}

function main() {
const args = process.argv.slice(2);
const uninstall = args.includes("--uninstall");
const userPath = args.find(a => !a.startsWith("-"));

const cliPath = findCliJs(userPath);
if (!cliPath) {
console.error("Could not find cli.js. Pass the path as an argument:");
console.error(" node install.js /path/to/cli.js");
process.exit(1);
}

let content = fs.readFileSync(cliPath, "utf-8");
const origHash = sha256(content);

if (uninstall) {
if (!content.includes(PATCH_MARKER)) {
console.log("Patch not found — nothing to remove.");
return;
}
const patched = content.replace(
new RegExp("^.*" + PATCH_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ".*\\n", "m"), ""
);
fs.writeFileSync(cliPath, patched);
console.log("Patch removed from " + cliPath);
console.log("SHA-256: " + sha256(patched));
return;
}

if (content.includes(PATCH_MARKER)) {
console.log("Patch already applied to " + cliPath);
return;
}

// Try git apply first (git diff compatible)
if (tryGitApply(cliPath)) {
console.log("Patch applied via git apply to " + cliPath);
console.log("Original SHA-256: " + origHash);
console.log("Patched SHA-256: " + sha256(fs.readFileSync(cliPath, "utf-8")));
return;
}

// Fallback: string injection before first import{ or var
let idx = content.indexOf("import{");
if (idx === -1) idx = content.indexOf("\nvar ");
if (idx === -1) {
console.error("Could not find injection point in cli.js");
process.exit(1);
}

const PATCH = PATCH_MARKER + PATCH_CODE + "\n";
const patched = content.slice(0, idx) + PATCH + content.slice(idx);
fs.writeFileSync(cliPath, patched);

console.log("Patch applied to " + cliPath);
console.log("Original SHA-256: " + origHash);
console.log("Patched SHA-256: " + sha256(patched));
}

main();
Loading