diff --git a/Cargo.lock b/Cargo.lock index b562eac02..e47070c24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agentmux" -version = "0.32.73" +version = "0.32.74" dependencies = [ "chrono", "dirs 5.0.1", @@ -46,7 +46,7 @@ dependencies = [ [[package]] name = "agentmuxsrv-rs" -version = "0.32.73" +version = "0.32.74" dependencies = [ "async-stream", "axum", @@ -2314,9 +2314,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -4871,9 +4871,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.6" +version = "0.34.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.0", "block2 0.6.2", @@ -7174,7 +7174,7 @@ dependencies = [ [[package]] name = "wsh-rs" -version = "0.32.73" +version = "0.32.74" dependencies = [ "base64 0.22.1", "clap", diff --git a/agentmuxsrv-rs/Cargo.toml b/agentmuxsrv-rs/Cargo.toml index 65d9ffcf1..dc3ebb292 100644 --- a/agentmuxsrv-rs/Cargo.toml +++ b/agentmuxsrv-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmuxsrv-rs" -version = "0.32.73" +version = "0.32.74" edition = "2021" description = "AgentMux Rust backend (drop-in replacement for Go agentmuxsrv)" diff --git a/agentmuxsrv-rs/src/backend/reactive/handler.rs b/agentmuxsrv-rs/src/backend/reactive/handler.rs index 71fd516a3..bde923267 100644 --- a/agentmuxsrv-rs/src/backend/reactive/handler.rs +++ b/agentmuxsrv-rs/src/backend/reactive/handler.rs @@ -239,6 +239,53 @@ impl Handler { self.include_source_in_message, ); + // Container agent delivery: block_id starts with "container:" (registered by + // agents running in Docker via entrypoint.sh's register_with_agentmux()). + // Delivery is via `docker attach` which writes to the container's PTY master, + // simulating keyboard input — identical semantics to the PTY InputSender path. + if block_id.starts_with("container:") { + let container_name = block_id["container:".len()..].to_string(); + let msg = final_msg.clone(); + let agent_for_log = req.target_agent.clone(); + tracing::info!( + target_agent = %req.target_agent, + container = %container_name, + "inject: routing to container via docker attach" + ); + tokio::spawn(async move { + if let Err(e) = inject_via_docker_attach(&container_name, &msg).await { + tracing::warn!( + container = %container_name, + agent = %agent_for_log, + error = %e, + "inject: docker attach delivery failed" + ); + } else { + tracing::info!( + container = %container_name, + agent = %agent_for_log, + "inject: docker attach delivery succeeded" + ); + } + }); + self.log_audit( + req.source_agent.as_deref(), + &req.target_agent, + &block_id, + &sanitized, + true, + None, + &request_id, + ); + return InjectionResponse { + success: true, + request_id, + block_id: Some(block_id), + error: None, + timestamp: now, + }; + } + // Send message via input sender let sender = match &self.input_sender { Some(s) => s.clone(), @@ -465,3 +512,86 @@ static GLOBAL_HANDLER: OnceLock = OnceLock::new(); pub fn get_global_handler() -> &'static ReactiveHandler { GLOBAL_HANDLER.get_or_init(ReactiveHandler::new) } + +// ---- Container delivery ---- + +/// Deliver a jekt message to a Docker container agent's terminal via `docker attach`. +/// +/// Writes `\r{message}\r` followed by 3 delayed `\r` keypresses to the container's +/// PTY master via `docker attach --sig-proxy=false`, simulating keyboard input. The +/// container must have `tty: true` and `stdin_open: true` (set in docker-compose). +/// +/// The container name is the part after "container:" in the block_id. For example, +/// block_id="container:agent4" → container_name="agent4" → `docker attach agent4`. +/// +/// Closing stdin causes `docker attach` to detach without stopping the container. +/// +/// # Platform +/// Uses the `docker` CLI (must be on PATH). Works on Linux, macOS, and Windows +/// (Docker Desktop routes through `\\.\pipe\docker_engine` transparently). +async fn inject_via_docker_attach(container_name: &str, message: &str) -> anyhow::Result<()> { + use tokio::io::AsyncWriteExt; + use tokio::process::Command; + + // Validate container name: Docker names must match [a-zA-Z0-9][a-zA-Z0-9_.-]* + // Reject anything outside that set before passing to the CLI. + if container_name.is_empty() + || !container_name + .chars() + .next() + .map_or(false, |c| c.is_ascii_alphanumeric()) + || !container_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') + { + return Err(anyhow::anyhow!( + "invalid container name {:?}: must match [a-zA-Z0-9][a-zA-Z0-9_.-]*", + container_name + )); + } + + let payload = format!("\r{}\r", message); + + let mut child = Command::new("docker") + .args(["attach", "--sig-proxy=false", container_name]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| { + anyhow::anyhow!( + "failed to spawn 'docker attach {}': {} (is docker on PATH?)", + container_name, + e + ) + })?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(payload.as_bytes()) + .await + .map_err(|e| anyhow::anyhow!("docker attach stdin write failed: {}", e))?; + stdin.flush().await.ok(); + + // Delayed \r keypresses — same 200ms cadence as the PTY InputSender path. + // See specs/jekt-inject-timing.md for why three are needed. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + stdin.write_all(b"\r").await.ok(); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + stdin.write_all(b"\r").await.ok(); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + stdin.write_all(b"\r").await.ok(); + + // Dropping stdin sends EOF → docker attach detaches cleanly. + drop(stdin); + } + + // Force-kill docker attach in case it's still running (e.g. tty not yet closed), + // then wait to reap the child process. Kill must come before wait to avoid + // waiting indefinitely on a process that hasn't exited on its own. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + child.kill().await.ok(); // send SIGKILL / TerminateProcess; no-op if already exited + child.wait().await.ok(); // reap — always call after kill to avoid zombies + + Ok(()) +} diff --git a/docs/investigations/jekt-host-vs-container.md b/docs/investigations/jekt-host-vs-container.md new file mode 100644 index 000000000..a58523dae --- /dev/null +++ b/docs/investigations/jekt-host-vs-container.md @@ -0,0 +1,210 @@ +# Jekt (inject_terminal): Why It Works for Host Agents but Not Containers + +**Date:** 2026-03-22 +**Status:** Investigation complete — root cause identified +**Scope:** Why `inject_terminal` delivers instantly for AgentX/AgentY but times out for Agent1-5 + +--- + +## Problem Statement + +Calling `inject_terminal` (jekt) targeting a host agent (AgentX, AgentY) returns `status: delivered` +in under 1 second. The same call targeting a container agent (Agent1-5) returns: + +``` +Delivery not confirmed within 15s. Target agent may be offline or unregistered. +``` + +Live test results: +- Self-jekt to `agenty`: **success / delivered** ✅ — returned immediately +- Jekt to `agent4`: **timeout** ❌ — "Delivery not confirmed within 15s" + +--- + +## Delivery Architecture + +`inject_terminal` in `agentbus-client/src/client.ts` has two delivery paths: + +### Path A: Local (synchronous, preferred) + +Requires `AGENTMUX_LOCAL_URL` in the MCP server's environment. + +``` +injectTerminalLocal() + → POST {AGENTMUX_LOCAL_URL}/wave/reactive/inject + → agentmuxsrv-rs ReactiveHandler + → agent_to_block lookup + → InputSender → PTY bytes + → returns success immediately +``` + +### Path B: Cloud (async, fallback) + +Used when local path fails ("agent not found") or `AGENTMUX_LOCAL_URL` not set. + +``` +POST {AGENTBUS_URL}/reactive/inject → DynamoDB pending injection +poll /reactive/status/{id} every 1s → wait for ACK + ← receiver: poll /reactive/pending/{agentId} every 5s + ← receiver: delivers, calls /reactive/ack + → sender sees "delivered" (or times out after 15s) +``` + +--- + +## Why Host Agents Work + +### 1. `AGENTMUX_LOCAL_URL` is in the shell environment + +`agentmuxsrv-rs/src/main.rs` calls `std::env::set_var("AGENTMUX_LOCAL_URL", ...)` immediately +after binding the web listener. `shell.rs` re-injects it explicitly into every PTY's env: + +```rust +if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") { + c.env("AGENTMUX_LOCAL_URL", &local_url); +} +``` + +The MCP server subprocess inherits this — it is **not** in `.mcp.json`. + +Verified in agenty's shell: +``` +AGENTMUX_LOCAL_URL = http://127.0.0.1:53968 +AGENTMUX_AGENT_ID = AgentY +AGENTMUX_BLOCKID = acfb92c4-1af6-41b1-99f0-53661f0b98f4 +``` + +### 2. Agent auto-registers in ReactiveHandler on shell spawn + +`shell.rs` detects `AGENTMUX_AGENT_ID` in the pane env and calls: + +```rust +reactive::get_global_handler().register_agent(agent_id, &self.block_id, Some(&self.tab_id)) +``` + +This creates the bidirectional mapping `agent_id ↔ block_id` in the in-memory `ReactiveHandler` +and writes to the cross-instance file registry at `{data_dir}/agents/{agent_id}.json`. + +### 3. Injection flow (agenty → agenty) + +1. Sender: `AGENTMUX_LOCAL_URL` set → calls `injectTerminalLocal("http://127.0.0.1:53968", "agenty", ...)` +2. AgentMux: looks up `agent_to_block["agenty"]` → finds `acfb92c4...` +3. `InputSender` writes `\r{message}\r` to PTY + 3× delayed `\r` at 200ms +4. Returns `{success: true}` → jekt returns `status: delivered` immediately + +--- + +## Why Container Agents Fail + +### Problem 1: Agent4 is not registered in ReactiveHandler + +Container agents (Agent1-5) run inside Docker. They have no AgentMux terminal pane — there is +no xterm.js block, no `block_id`, and `shell.rs` never runs inside Docker. + +When agenty jekets to agent4: +- `injectTerminalLocal()` POSTs to `:53968/wave/reactive/inject` with `target_agent=agent4` +- `ReactiveHandler.inject_message()` calls `agent_to_block.get("agent4")` → **None** +- Checks cross-instance file registry → **not found** (no AgentMux in the container) +- Returns `{success: false, error: "agent not found: agent4"}` +- `injectTerminalLocal()` sees `data.error.includes('not found')` → returns `null` +- Falls back to cloud path + +### Problem 2: Port 1717 on the host is not listening + +Container `.mcp.json` has `AGENTMUX_LOCAL_URL=http://host.docker.internal:1717`. +Port 1717 on the host is **closed** — the planned fixed-port proxy was never implemented. + +``` +CLOSED port 1717 ← container's AGENTMUX_LOCAL_URL target +CLOSED port 3100 ← WAVEMUX_REACTIVE_PORT (env var set but nothing listening) +OPEN port 53968 ← actual AgentMux backend (dynamic, OS-assigned) +``` + +When agent4's MCP server polls and finds a pending cloud injection: +1. Tries `POST http://host.docker.internal:1717/wave/reactive/inject` → connection refused +2. Exception caught → falls back to `console.error()` (MCP server stderr) +3. **Stderr is NOT fed back as user input in Claude Code** → injection silently lost +4. Still pushes to `deliveredIds` → calls `/reactive/ack` → cloud marks as "delivered" + +### Problem 3: Even if port 1717 existed, there is no PTY to inject into + +`ReactiveHandler.inject_message()` requires a `block_id` → PTY mapping. +Container agents don't have AgentMux terminal pane blocks — there is no PTY to write to. + +### Why the test saw timeout (not "delivered") + +The cloud path ACKs when agent4's MCP poller fetches the injection (every 5s). But: +- If agent4's Claude Code session is **not active**: no MCP server → no polling → no ACK → 15s timeout ← **what we observed** +- If agent4's Claude Code session **is active**: MCP server polls, ACKs within 5s, sender sees "delivered" — but Claude still doesn't receive the message as terminal input (only stderr) + +--- + +## Component Map + +| File | Role | +|------|------| +| `agentbus/packages/agentbus-client/src/client.ts` | `injectTerminal()`, `injectTerminalLocal()`, `pollAndDeliverInjections()`, `startInjectionPolling()` | +| `agentbus/packages/agentbus-client/src/index.ts` | MCP server startup — starts `startInjectionPolling()` if token set | +| `agentmuxsrv-rs/src/backend/reactive/handler.rs` | `ReactiveHandler` — in-memory `agent_id ↔ block_id` registry, `inject_message()` | +| `agentmuxsrv-rs/src/backend/reactive/registry.rs` | File-based cross-instance registry at `{data_dir}/agents/{agent_id}.json` | +| `agentmuxsrv-rs/src/server/reactive.rs` | HTTP handlers: `/wave/reactive/inject`, `/wave/reactive/register`, etc. | +| `agentmuxsrv-rs/src/backend/blockcontroller/shell.rs` | Auto-registers agent on shell spawn via `AGENTMUX_AGENT_ID` | +| `agentmuxsrv-rs/src/main.rs` | Sets `AGENTMUX_LOCAL_URL` env var after binding web listener | + +Relevant specs: +- `specs/agentmux-local-url-injection.md` — why/how `AGENTMUX_LOCAL_URL` is injected into pane env +- `specs/jekt-auto-registration.md` — auto-registration via `AGENTMUX_AGENT_ID` on shell spawn +- `specs/jekt-inject-timing.md` — PTY write timing (`\r` + 3× delayed `\r` at 200ms) + +--- + +## Root Cause Summary + +| | Host agents (AgentX/Y) | Container agents (Agent1-5) | +|---|---|---| +| `AGENTMUX_LOCAL_URL` | `:PORT` (real, injected by AgentMux) | `:1717` (not listening) | +| Registered in ReactiveHandler | Yes (auto on shell spawn) | No (no AgentMux pane) | +| PTY block_id mapping | Yes | No | +| jekt local delivery | PTY direct, instant | N/A — no PTY | +| jekt cloud delivery | Works if needed | ACKs but Claude doesn't see it | +| Correct comms tool | `inject_terminal` | `send_message` / `read_messages` | + +**jekt is a terminal-pane feature.** It writes bytes to an xterm.js PTY block via the AgentMux +reactive handler. Container agents have no such block — the delivery path does not exist. + +--- + +## Fix Options + +### Option 1: Use `send_message` (no code changes — correct tool) + +Container agents should use the mailbox path: +- Sender: `send_message(target="agent4", message="...")` +- Receiver: `read_messages()` (polls inbox) + +This is what the mailbox exists for. Works today. + +### Option 2: Fixed-port bridge on host (implements planned port 1717) + +Add a secondary fixed-port listener to `agentmuxsrv-rs` that: +1. Accepts inject/register requests from Docker containers via `host.docker.internal:1717` +2. Registers container agents with a virtual block_id +3. Delivers messages via Docker exec API (`docker exec -i {container} cat`) rather than xterm.js PTY + +Requires: fixed-port listener in Rust, Docker socket access from AgentMux, container agent registration on startup. + +### Option 3: Cloud path + container stdin write + +When container agent's MCP server receives a pending injection: +- Instead of `console.error()`, write to `process.stdin` of the Claude Code process +- This requires understanding how Claude Code reads its terminal input + +Not straightforward — Claude Code uses readline/TTY, not raw stdin. + +--- + +## Recommended Action + +**Short term:** Document that `inject_terminal` is host-agent only; use `send_message` for containers. + +**Medium term:** Option 2 (fixed-port bridge) if reliable container jekt is needed. diff --git a/docs/investigations/xterm-claude-code-scroll-issue.md b/docs/investigations/xterm-claude-code-scroll-issue.md new file mode 100644 index 000000000..e4c381174 --- /dev/null +++ b/docs/investigations/xterm-claude-code-scroll-issue.md @@ -0,0 +1,249 @@ +# xterm.js + Claude Code: Unexpected Scroll Investigation + +**Date:** 2026-03-22 +**Status:** Research complete — mitigations identified +**Scope:** Why Claude Code causes xterm.js to scroll unexpectedly, and what AgentMux can do about it + +--- + +## Problem Statement + +When Claude Code (or any Ink-based CLI) runs inside an AgentMux terminal pane, the xterm.js viewport repeatedly jumps — scrolling the entire screen — without user interaction. This is most visible during streaming output and tool execution (spinners, status lines). It is one of the most-reported xterm.js user-experience issues: [claude-code#826](https://github.com/anthropics/claude-code/issues/826) has 634 upvotes and 337 comments. + +--- + +## Root Causes + +### 1. Ink's "Erase-and-Redraw" Rendering Architecture (Primary) + +Claude Code is built on React + [Ink](https://github.com/vadimdemedes/ink). On every React state change (streaming token, spinner tick, status bar update), Ink's renderer executes: + +```javascript +stream.write(ansiEscapes.eraseLines(previousLineCount) + newOutput) + +// eraseLines implementation: +eraseLines = (count) => { + let str = ""; + for (let i = 0; i < count; i++) + str += eraseLine + (i < count - 1 ? cursorUp() : ""); + if (count) str += cursorLeft; + return str; +} +``` + +When the UI is 60 lines tall, this generates 60 pairs of `ESC[2K` (erase line) + `ESC[1A` (cursor up), followed by the full new content redrawn from scratch. This is **not** a diff — it is a complete erase and full redraw every render cycle. + +**The xterm.js problem:** When the cursor moves up via repeated `ESC[1A`, xterm.js scrolls the viewport to track the cursor, jumping far up into the scrollback buffer. Then when new content is written and the cursor returns to the bottom, the viewport snaps back down. This fires multiple times per second because Ink re-renders for every streamed token, every spinner tick, and every status line update — concurrently. + +### 2. Specific CSI/ANSI Sequences That Trigger Viewport Changes + +Beyond cursor-up, these sequences also cause viewport jumps: + +| Sequence | Name | Effect | +|---|---|---| +| `ESC[H` | CUP (Cursor Home) | Moves cursor to 0,0 — xterm.js scrolls to top | +| `ESC[2J` | ED2 (Erase Display) | Clears screen; when `scrollOnEraseInDisplay` is on, pushes to scrollback | +| `ESC[r` | DECSTBM (Set Scroll Region) | xterm.js repositions viewport when margins change | +| `ESC[?1049h/l` | Alternate Screen Buffer | Complete viewport context switch | +| `ESC[S` | SU (Scroll Up) | Physically shifts viewport upward | +| `ESC c` | RIS (Full Reset) | Clears scrollback entirely ([xterm.js #3315](https://github.com/xtermjs/xterm.js/issues/3315)) | + +### 3. xterm.js Scroll-on-Output (Partially Fixed) + +Early xterm.js scrolled unconditionally to bottom on any output. [PR #336](https://github.com/xtermjs/xterm.js/pull/336) added a `userScrolling` flag that prevents scroll-to-bottom when the user has scrolled up. However, this only covers line-output scrolling — scrolling caused by **cursor movement sequences** (the Ink pattern) bypasses this flag. xterm.js always moves the viewport to track cursor position. + +Additionally, user input always scrolled to bottom until [PR #4289](https://github.com/xtermjs/xterm.js/pull/4289) (xterm.js 5.1.0) added `scrollOnUserInput: false`. + +### 4. macOS Trackpad Momentum Scroll Compounding + +On macOS, after lifting a finger, the OS continues generating `WheelEvent`s with decaying `deltaY` values. When this fires simultaneously with Ink's cursor-up sequences (which move the viewport), the two compound into a "rocket scroll" feedback loop — oscillating between top and bottom at high speed. + +### 5. Focus Events Causing Viewport Resets (Claude Code v2.1.5+ Regression) + +[Issue #18299](https://github.com/anthropics/claude-code/issues/18299): A regression in CC v2.1.5 caused the TUI to reset scroll position on terminal focus-in/focus-out events, even when Claude Code is completely idle with no output. Every time the window gains or loses focus, the viewport jumps. Fixed in subsequent CC versions. + +### 6. Windows Terminal Bug with Cursor Positioning (Windows-specific) + +[microsoft/terminal#14774](https://github.com/microsoft/terminal/issues/14774): `SetConsoleCursorPosition` always scrolls the viewport to the cursor position — even when the cursor is already visible. This means Ink's cursor-up movement always causes visible viewport scrolling on Windows Terminal with no in-process workaround. + +--- + +## What Anthropic Has Done + +**Differential Renderer (January 2026):** Rewrote Ink's rendering layer to diff previous output and only emit changed lines. Significantly reduced `eraseLines` frequency. Does **not** eliminate the issue — any render that changes output height still emits cursor-up sequences. + +--- + +## xterm.js Options That Affect Scroll Behavior + +| Option | Default | Effect | +|---|---|---| +| `scrollOnUserInput` | `true` | Set `false` to prevent scroll-to-bottom on keystrokes (requires xterm.js ≥ 5.1.0) | +| `scrollOnEraseInDisplay` | `false` | When `true`, ED2 (`ESC[2J`) pushes content to scrollback | +| `smoothScrollDuration` | `0` | Keep at `0` — animated scroll makes cursor jumps more disorienting | +| `scrollback` | `1000` | Larger values worsen scroll jump visibility | +| `scrollSensitivity` | — | Wheel scroll multiplier | + +**Critical gap:** There is **no option** to suppress cursor-tracking-induced viewport scrolling. This is baked into xterm.js's core rendering contract. + +--- + +## xterm.js API for Interception + +| API | Notes | +|---|---| +| `terminal.attachCustomWheelEventHandler(fn)` | Intercept wheel events before xterm processes them; return `false` to cancel | +| `terminal.parser.registerCsiHandler({ final: 'A' }, fn)` | Hook CSI sequences (e.g., cursor-up `ESC[A`) | +| `terminal.buffer.active.viewportY` | Current viewport scroll position | +| `terminal.scrollToLine(n)` | Programmatically restore scroll position | +| `terminal.modes.synchronizedOutputMode` | True when DEC 2026 is active | +| `terminal.onScroll` | **Warning:** Only fires on new lines added — NOT on user scroll | + +--- + +## Mitigation Strategy (Tiered) + +### Tier 1: xterm.js Options (Zero Risk — Do Now) + +```typescript +// In termwrap.ts, add to terminal options: +scrollOnUserInput: false, // Prevent scroll-to-bottom on keystrokes (xterm.js >= 5.1.0) +smoothScrollDuration: 0, // No animated scroll (makes jumps less disorienting) +// scrollOnEraseInDisplay: leave at false (default) +``` + +AgentMux is on xterm.js 5.5.0, so `scrollOnUserInput` is available. + +### Tier 2: Block Momentum Scroll (Low Risk) + +```typescript +// In termwrap.ts constructor, after terminal creation: +this.terminal.attachCustomWheelEventHandler((ev) => { + // Block macOS trackpad momentum scroll (tiny decaying deltaY values) + if (Math.abs(ev.deltaY) < 4) return false; + return true; +}); +``` + +This eliminates the "rocket scroll" feedback loop on macOS without affecting normal scrolling. + +### Tier 3: Verify DEC 2026 Synchronized Output Is Working + +xterm.js 5.5.0 has native DEC 2026 support. Claude Code emits `ESC[?2026h`/`ESC[?2026l` markers. When working, entire re-renders arrive as one atomic write — intermediate cursor-tracking states are invisible. + +**Verification:** Log `this.terminal.modes.synchronizedOutputMode` during Claude Code output. If it never toggles, Claude Code isn't detecting DEC 2026 support from the terminfo/`TERM` env. + +### Tier 4: CSI Parser Hook to Preserve Scroll Position + +xterm.js exposes a full parser hooks API ([Hooks Guide](https://xtermjs.org/docs/guides/hooks/)) to intercept CSI sequences. Can intercept cursor-up (`ESC[A`) and cursor-home (`ESC[H`) to restore user scroll position after cursor tracking: + +```typescript +// In termwrap.ts: +let isUserScrolled = false; +let savedViewportY = 0; + +this.terminal.attachCustomWheelEventHandler((ev) => { + isUserScrolled = true; + savedViewportY = this.terminal.buffer.active.viewportY; + return true; +}); + +// Hook cursor-up (CSI A) — fires when Ink erases lines +this.terminal.parser.registerCsiHandler({ final: 'A' }, (params) => { + if (isUserScrolled) { + const saved = savedViewportY; + // Restore after xterm processes the sequence + Promise.resolve().then(() => this.terminal.scrollToLine(saved)); + } + return false; // still process normally +}); +``` + +**Note:** This causes visible flicker because the viewport jumps and snaps back. Better combined with Tier 5. + +### Tier 5: RAF-Based Write Batching + +Accumulate PTY data within one animation frame and flush once per frame. Reduces `terminal.write()` calls from hundreds/sec to ~60/sec, each causing fewer viewport events. + +```typescript +// In termwrap.ts: +private pendingData: Uint8Array[] = []; +private rafPending = false; + +private scheduleWrite(data: Uint8Array) { + // Fast path: small writes (interactive input) bypass batching + if (!this.rafPending && data.length < 256) { + this.doTerminalWrite(data, null); + return; + } + this.pendingData.push(data); + if (!this.rafPending) { + this.rafPending = true; + requestAnimationFrame(() => { + const merged = mergeUint8Arrays(this.pendingData); + this.pendingData = []; + this.rafPending = false; + this.doTerminalWrite(merged, null); + }); + } +} +``` + +### Tier 6: PTY-Level Differential Rendering (Most Effective) + +The [claude-chill](https://github.com/davidbeesley/claude-chill) approach: run Claude Code through a PTY proxy that maintains an in-memory VT100 state machine, diffs each render frame, and passes only changed lines to the terminal — wrapped in DEC 2026 sync markers. Instead of 60× `ESC[2K ESC[1A]` + 60 lines redrawn, only the cells that actually changed reach xterm.js. The cursor never moves up into the scrollback zone. + +For AgentMux, this could be implemented in the Rust backend (`agentmuxsrv-rs`) as a stream transform on the PTY output pipe — intercept and diff before forwarding over WebSocket. This is architecturally clean (zero frontend changes) but the most substantial implementation effort. + +--- + +## Recommended Implementation Order + +1. **Now:** Add `scrollOnUserInput: false` and `smoothScrollDuration: 0` to terminal options in `termwrap.ts` +2. **Short-term:** Add `attachCustomWheelEventHandler` to block momentum scroll +3. **Verify:** Check if DEC 2026 mode is already working (log `synchronizedOutputMode`) +4. **Medium-term:** Implement RAF batching for bulk PTY output +5. **Long-term:** Evaluate PTY-level differential rendering in the Rust backend + +--- + +## Tracking Issues + +| Issue | Description | Status | +|---|---|---| +| [claude-code #826](https://github.com/anthropics/claude-code/issues/826) | Console scrolling to top (634 upvotes, 337 comments) | OPEN | +| [claude-code #3648](https://github.com/anthropics/claude-code/issues/3648) | Terminal scrolling uncontrollably | CLOSED | +| [claude-code #18299](https://github.com/anthropics/claude-code/issues/18299) | Scroll position lost after v2.1.5 flickering fix | CLOSED | +| [claude-code #34794](https://github.com/anthropics/claude-code/issues/34794) | Terminal scrolls to top during agent execution (Windows) | OPEN | +| [xterm.js #1824](https://github.com/xtermjs/xterm.js/issues/1824) | scrollOnUserInput configurable | CLOSED (PR #4289) | +| [xterm.js #5453](https://github.com/xtermjs/xterm.js/pull/5453) | DEC mode 2026 synchronized output | MERGED | +| [microsoft/terminal#14774](https://github.com/microsoft/terminal/issues/14774) | SetConsoleCursorPosition always scrolls viewport | OPEN | +| [github/copilot-cli #1805](https://github.com/github/copilot-cli/issues/1805) | 4-layer rocket scroll fix reference | OPEN | + +--- + +## Files to Modify + +- `frontend/app/view/term/termwrap.ts` + - `constructor()` — add `scrollOnUserInput`, `smoothScrollDuration` options; add wheel handler + - `doTerminalWrite()` — optionally add RAF batching + - `init()` — verify DEC 2026 mode logging + +No backend changes required for Tiers 1–4. Tier 6 (differential rendering) requires `agentmuxsrv-rs` stream transform work. + +--- + +## References + +- [anthropics/claude-code #826](https://github.com/anthropics/claude-code/issues/826) +- [anthropics/claude-code #34794](https://github.com/anthropics/claude-code/issues/34794) +- [github/copilot-cli #1805 — 4-Layer Rocket Scroll Fix](https://github.com/github/copilot-cli/issues/1805) +- [claude-chill PTY proxy](https://github.com/davidbeesley/claude-chill) +- [xterm.js ITerminalOptions](https://xtermjs.org/docs/api/terminal/interfaces/iterminaloptions/) +- [xterm.js Terminal API](https://xtermjs.org/docs/api/terminal/classes/terminal/) +- [xterm.js Parser Hooks Guide](https://xtermjs.org/docs/guides/hooks/) +- [xterm.js PR #5453 — DEC mode 2026](https://github.com/xtermjs/xterm.js/pull/5453) +- [xterm.js PR #4289 — scrollOnUserInput](https://github.com/xtermjs/xterm.js/pull/4289) +- [INK-ANALYSIS.md — Ink rendering pipeline](https://github.com/atxtechbro/test-ink-flickering/blob/main/INK-ANALYSIS.md) +- [Taming ANSI-Induced Scrolling — Termdock approach](https://app.daily.dev/posts/taming-ansi-induced-scrolling-in-xterm-js-termdock-s-80-fix-for-claude-code-jumping-wugci510e) +- [microsoft/terminal#14774 — Windows cursor positioning bug](https://github.com/microsoft/terminal/issues/14774) diff --git a/package.json b/package.json index 30c535605..3de4b82aa 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "productName": "AgentMux", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.32.73", + "version": "0.32.74", "homepage": "https://github.com/agentmuxai/agentmux", "build": { "appId": "ai.agentmux.app" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b5da88fc6..e42e042bc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentmux" -version = "0.32.73" +version = "0.32.74" description = "AgentMux - AI-Native Terminal" authors = ["AgentMux Corp"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4df8a9b47..eb30346eb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "AgentMux", - "version": "0.32.73", - "identifier": "ai.agentmux.app.v0-32-73", + "version": "0.32.74", + "identifier": "ai.agentmux.app.v0-32-74", "build": { "devUrl": "http://localhost:5173", "frontendDist": "../dist/frontend", diff --git a/wsh-rs/Cargo.toml b/wsh-rs/Cargo.toml index f2c0b321f..0a92693b0 100644 --- a/wsh-rs/Cargo.toml +++ b/wsh-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wsh-rs" -version = "0.32.73" +version = "0.32.74" edition = "2021" description = "Shell integration CLI for AgentMux"