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
135 changes: 133 additions & 2 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,25 @@ Object.assign(CodemanApp.prototype, {
// Register link provider for clickable file paths in Bash tool output
this.registerFilePathLinkProvider();

// Always use mouse wheel for terminal scrollback, never forward to application.
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
// Mouse wheel: forward to the TUI for strip-mode sessions, local scrollback
// otherwise. Claude Code (2.1.187+) scrolls its own transcript on SGR wheel
// reports — scrolled-away tool blocks re-render live and stay clickable —
// and its select menus no longer capture wheel as option navigation
// (verified against 2.1.202: /model menu highlight ignores wheel reports),
// so the original reason to keep the wheel local is gone for claude mode.
// Shift+wheel always scrolls xterm's local scrollback (Codeman's restored
// history lives there), and once the viewport left the bottom the wheel
// stays local until the user scrolls back down — so both scrollbacks stay
// reachable without a mode switch.
container.addEventListener(
'wheel',
(ev) => {
ev.preventDefault();
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
if (this._shouldForwardWheelToApp(ev)) {
this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines);
return;
}
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
},
Expand Down Expand Up @@ -452,6 +464,15 @@ Object.assign(CodemanApp.prototype, {
}
if (touch && mouseTrackingOn) {
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
} else if (touch && this._sessionUsesServerMouseStrip()) {
// The server strips mouse-tracking DECSETs from claude/codex/gemini
// output (isAltScreenStripMode, session.ts) so the wheel keeps
// scrolling scrollback — which leaves THIS xterm permanently at
// mouseTrackingMode 'none' even though the TUI on the PTY side has
// tracking ON and still understands SGR reports. Encode the report
// ourselves and send it straight to the PTY: no DOM click is
// dispatched, so xterm's local selection can't trigger either.
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
}
this._syncMobileHelperTextareaToCursor();
// Route subsequent typing to the right place: keep the CJK input
Expand All @@ -478,6 +499,16 @@ Object.assign(CodemanApp.prototype, {
);
}

// ── Desktop click-to-position cursor ──────────────────────────────
// A real mouse click normally reaches the PTY through xterm's own mouse
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
// the server strips the enabling DECSETs from claude/codex/gemini output
// (isAltScreenStripMode, session.ts) so the wheel keeps scrolling
// scrollback. Desktop clicks therefore stopped reporting entirely (the
// same breakage the mobile touchend tap branch above works around).
// Hand-encode the SGR report for plain left-clicks on those sessions.
container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev));

// Welcome message
this.showWelcome();

Expand Down Expand Up @@ -2073,6 +2104,106 @@ Object.assign(CodemanApp.prototype, {
}
},

// Mirror of the server's isAltScreenStripMode (session.ts): session modes whose
// output stream has mouse-tracking DECSET sequences stripped before reaching the
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
_sessionUsesServerMouseStrip() {
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
return mode === 'claude' || mode === 'codex' || mode === 'gemini';
},

// True when xterm's viewport shows the live PTY screen (not scrolled up into
// local scrollback). SGR coordinates are only meaningful then: the TUI's
// screen is the bottom `rows` of the buffer, so a report computed from a
// scrolled-up viewport would hit-test a completely different row.
_terminalViewportAtBottom() {
const buf = this.terminal?.buffer?.active;
return !buf || buf.viewportY >= buf.baseY;
},

// Map a viewport point to a 1-based terminal cell the same way xterm maps a
// click: offset inside .xterm-screen divided by the rendered cell size,
// clamped to the grid. Returns null when the terminal isn't measurable yet.
_clientPointToCell(clientX, clientY) {
if (!this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return null;
const screen = this.terminal.element?.querySelector('.xterm-screen');
const cell = this.terminal._core?._renderService?.dimensions?.css?.cell;
if (!screen || !cell?.width || !cell?.height) return null;
const rect = screen.getBoundingClientRect();
const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1));
const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1));
return { col, row };
},

// Encode a tap as an SGR mouse report (press + release at button 0) and send it
// to the PTY directly, bypassing xterm's mouse encoder.
_sendSyntheticSgrTap(clientX, clientY) {
if (!this.activeSessionId) return;
if (!this._terminalViewportAtBottom()) return; // scrollback click → misfire, do nothing
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return;
this._sendInputAsync(this.activeSessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`);
},

// Wheel forwarding gate for the container wheel handler: strip-mode session,
// no Shift override, xterm's own encoder dormant, viewport at the bottom.
_shouldForwardWheelToApp(ev) {
if (ev.shiftKey) return false;
const mode = this.terminal?.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return false;
if (!this._sessionUsesServerMouseStrip()) return false;
return this._terminalViewportAtBottom();
},

// Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer
// cell. Reports are coalesced into one PTY write per ~40ms: a trackpad emits
// dozens of wheel events per second and each _sendInputAsync becomes a tmux
// send-keys on the server — unbatched, a single flick would spawn a process
// storm. Per-event tick count is capped (Claude applies its own scroll-speed
// multiplier and acceleration on top), and the queue is bounded so a wild
// scroll can't build a backlog that keeps scrolling after the finger stops.
_sendSyntheticSgrWheel(clientX, clientY, lines) {
if (!this.activeSessionId || !lines) return;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return;
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), 5);
const queued = this._wheelSgrQueue || '';
if (queued.length > 512) return;
this._wheelSgrQueue = queued + `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
if (this._wheelSgrFlushTimer) return;
this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40);
},

_flushWheelSgrQueue() {
this._wheelSgrFlushTimer = null;
const data = this._wheelSgrQueue;
this._wheelSgrQueue = '';
if (data && this.activeSessionId) this._sendInputAsync(this.activeSessionId, data);
},

// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
// for a plain left-click when the server strips mouse DECSETs (see
// _sessionUsesServerMouseStrip). Every skip below is a click that already has
// a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend
// reported already), modified clicks (shift keeps xterm's selection
// override), double/triple clicks (word/line selection), drag-selections,
// clicks outside the cell grid, and sessions where xterm's own encoder is
// live (it reported the click itself — a second report would double-move).
_handleDesktopTerminalClick(ev) {
if (!this.terminal || !ev?.isTrusted) return;
if (ev.button !== 0 || ev.detail !== 1) return;
if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return;
const mode = this.terminal.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return;
if (!this._sessionUsesServerMouseStrip()) return;
if (this.terminal.hasSelection?.()) return;
if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return;
if (!ev.target?.closest?.('.xterm-screen')) return;
this._sendSyntheticSgrTap(ev.clientX, ev.clientY);
},

_installMobileTapMouseGuard() {
const el = this.terminal?.element;
if (!el || el._codemanTapMouseGuardInstalled) return;
Expand Down
Loading