Skip to content
Draft
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
240 changes: 203 additions & 37 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
// short window, only the app's synthetic tap-to-position mouse event should
// reach xterm.
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;

function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
Expand Down Expand Up @@ -62,6 +63,7 @@
shouldSuppressTerminalQueryResponse,
USER_SCROLL_STICKY_SUPPRESS_MS,
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
Expand Down Expand Up @@ -421,6 +423,8 @@ Object.assign(CodemanApp.prototype, {
let lastTime = 0;
let scrollFrame = null;
let isTouching = false;
let touchForwardsToApp = false;
let touchLastX = 0;

const scrollLoop = (timestamp) => {
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1;
Expand All @@ -429,12 +433,20 @@ Object.assign(CodemanApp.prototype, {
if (!isTouching && Math.abs(velocity) > 0.3) {
// Momentum phase — convert pixel velocity to lines
const lines = Math.round(velocity / cellHeight());
if (lines !== 0) this.terminal.scrollLines(lines);
if (lines !== 0) {
if (touchForwardsToApp) {
this._sendSyntheticSgrWheel(touchLastX, touchLastY, lines);
} else {
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
}
}
velocity *= 0.92;
scrollFrame = requestAnimationFrame(scrollLoop);
} else if (!isTouching) {
scrollFrame = null;
velocity = 0;
touchForwardsToApp = false;
} else {
scrollFrame = requestAnimationFrame(scrollLoop);
}
Expand All @@ -445,25 +457,37 @@ Object.assign(CodemanApp.prototype, {

let didScroll = false; // track whether touchmove fired (tap vs scroll)
let touchStartY = 0;
let tapStartedWithTerminalFocus = false;
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
container.addEventListener(
'touchstart',
(ev) => {
if (ev.touches.length === 1) {
touchLastX = ev.touches[0].clientX;
touchLastY = ev.touches[0].clientY;
touchStartY = touchLastY;
velocity = 0;
pixelAccum = 0;
isTouching = true;
didScroll = false;
touchForwardsToApp = this._shouldForwardTouchScrollToApp();
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
const touchStartIntent = this._classifyMobileTerminalTap(touchLastX, touchLastY);
if (touchStartIntent !== 'input') {
// Cancel xterm/browser focus before the compatibility click can
// open the OS keyboard. Content taps are re-emitted as SGR on
// touchend; history taps deliberately remain inert.
ev.preventDefault();
this._blurMobileTerminalInput();
}
lastTime = 0;
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
scrollFrame = null;
}
}
},
{ passive: true }
{ passive: false }
);

container.addEventListener(
Expand All @@ -482,6 +506,7 @@ Object.assign(CodemanApp.prototype, {
// fling, so a jittery tap would both position the cursor AND scroll.
if (!didScroll) return;
ev.preventDefault();
touchLastX = ev.touches[0].clientX;
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
velocity = delta * 1.2;
Expand All @@ -490,8 +515,12 @@ Object.assign(CodemanApp.prototype, {
const ch = cellHeight();
const lines = Math.trunc(pixelAccum / ch);
if (lines !== 0) {
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
if (touchForwardsToApp) {
this._sendSyntheticSgrWheel(touchLastX, touchY, lines);
} else {
this._noteTerminalUserScroll(lines);
this.terminal.scrollLines(lines);
}
pixelAccum -= lines * ch;
}
}
Expand All @@ -507,44 +536,13 @@ Object.assign(CodemanApp.prototype, {
scrollFrame = requestAnimationFrame(scrollLoop);
}
if (!didScroll && this.terminal) {
// ── Tap-to-position cursor ──────────────────────────────────
// Synthesize a click from the real touch point so the foreground app
// moves its cursor to the tapped cell (iOS doesn't reliably do this
// itself under touch-action:none). CRITICAL: only when mouse tracking
// is ON. xterm disables its local SelectionService while mouse events
// are active, so the synthetic click is forwarded to the PTY as an SGR
// report (cursor moves). But when tracking is OFF, that same click
// drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a
// tap on CJK text would select & copy it instead of positioning. So
// gate strictly on the live mouse-tracking mode.
const touch = ev.changedTouches && ev.changedTouches[0];
const mouseMode = this.terminal.modes?.mouseTrackingMode;
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
if (touch) {
this._suppressTrustedTapMouseEvents();
}
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
// field focused when Chinese input is on, otherwise the terminal.
const cjkInput = document.getElementById('cjkInput');
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.focus();
} else {
this.terminal.focus();
this._handleMobileTerminalTap(touch, tapStartedWithTerminalFocus);
}
}
tapStartedWithTerminalFocus = false;
},
{ passive: true }
);
Expand All @@ -555,6 +553,8 @@ Object.assign(CodemanApp.prototype, {
isTouching = false;
velocity = 0;
pixelAccum = 0;
touchForwardsToApp = false;
tapStartedWithTerminalFocus = false;
},
{ passive: true }
);
Expand Down Expand Up @@ -2629,6 +2629,163 @@ Object.assign(CodemanApp.prototype, {
} catch {}
},

_isMobileTerminalInputFocused() {
const active = document.activeElement;
return (
active === this.terminal?.textarea ||
active?.classList?.contains('xterm-helper-textarea') ||
active?.id === 'cjkInput'
);
},

/**
* Separate terminal input from TUI-owned content on touch devices. A hidden
* keyboard must not consume taps on expandable readbacks, tool results, or
* decision rows; those taps belong to the foreground CLI. The visible prompt
* row remains the deliberate keyboard target.
*/
_classifyMobileTerminalTap(clientX, clientY) {
if (!this._terminalViewportAtBottom()) return 'history';

const pos = this._clientPointToCell(clientX, clientY);
if (!pos || !this.terminal) return 'input';

const mouseMode = this.terminal.modes?.mouseTrackingMode;
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
if (!mouseTrackingOn && !this._sessionUsesServerMouseStrip()) return 'input';

// Permission/elicitation prompts own the full live terminal until answered.
if (document.body?.classList?.contains('terminal-action-pending')) return 'content';

const buffer = this.terminal.buffer?.active;
if (!buffer?.getLine) return 'input';

const rows = Math.max(1, this.terminal.rows || 1);
const lines = [];
const wrappedRows = [];
let hasVisibleContent = false;
for (let row = 0; row < rows; row++) {
const line = buffer.getLine(buffer.viewportY + row);
const text = line?.translateToString?.(true) || '';
lines.push(text);
wrappedRows.push(Boolean(line?.isWrapped));
if (text.trim()) hasVisibleContent = true;
}
if (!hasVisibleContent) return 'input';

const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0));
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
let promptRow = -1;
let menuSelectionVisible = false;

if (mode === 'opencode') {
if (lines[cursorRow]?.includes('\u2503')) promptRow = cursorRow;
} else {
for (let row = rows - 1; row >= 0; row--) {
const promptMatch = lines[row].match(/^\s*[❯›]/);
if (!promptMatch) continue;
const tail = lines[row].slice(promptMatch[0].length).trim();
// A highlighted numbered choice is a menu row, not an editable prompt.
const hasSiblingChoice = lines.some(
(line, choiceRow) => choiceRow !== row && /^\s+\d+[.)]\s/.test(line)
);
if (/^\d+[.)]\s/.test(tail) && hasSiblingChoice) {
menuSelectionVisible = true;
break;
}
promptRow = row;
break;
}
}

const tappedRow = pos.row - 1;
let logicalLineStart = tappedRow;
while (logicalLineStart > 0 && wrappedRows[logicalLineStart]) logicalLineStart--;
let logicalLineEnd = tappedRow;
while (logicalLineEnd + 1 < rows && wrappedRows[logicalLineEnd + 1]) logicalLineEnd++;
const tappedLine = lines.slice(logicalLineStart, logicalLineEnd + 1).join('');
if (
mode === 'claude' &&
/^\s*[•·]\s*Working\b.*(?:background|esc to interrupt)/i.test(tappedLine)
) {
return 'content';
}
if (menuSelectionVisible) return 'content';
if (promptRow >= 0) {
const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow;
if (tappedRow >= promptRow && tappedRow <= inputEnd) return 'input';
} else if (
tappedRow === cursorRow ||
tappedRow >=
Math.max(
0,
rows -
window.CodemanTerminalInput
.TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM
)
) {
// During redraws a CLI can temporarily omit its prompt marker or place
// the cursor above a status footer. Keep the live cursor and a stable
// lower-screen focus band usable without turning transcript rows above
// that band into keyboard targets.
return 'input';
}

return 'content';
},

_blurMobileTerminalInput() {
const active = document.activeElement;
if (
active === this.terminal?.textarea ||
active?.classList?.contains('xterm-helper-textarea') ||
active?.id === 'cjkInput'
) {
active.blur?.();
}
},

_focusMobileTerminalInput() {
this._syncMobileHelperTextareaToCursor();
const cjkInput = document.getElementById('cjkInput');
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.focus();
} else {
this.terminal?.focus();
}
},

_handleMobileTerminalTap(touch, startedWithTerminalFocus) {
if (!touch || !this.terminal) return 'history';
const intent = this._classifyMobileTerminalTap(touch.clientX, touch.clientY);
if (intent === 'history') {
this._blurMobileTerminalInput();
return intent;
}

const mouseMode = this.terminal.modes?.mouseTrackingMode;
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
const shouldActivate = intent === 'content' || startedWithTerminalFocus;
if (shouldActivate && mouseTrackingOn) {
// xterm's mouse encoder owns live DECSET modes. The synthetic DOM click
// follows the same path as a desktop click.
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
} else if (shouldActivate && this._sessionUsesServerMouseStrip()) {
// Claude/Codex/Gemini DECSETs are stripped from the browser stream, so
// report directly to the PTY while retaining local touch scrollback.
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
}

if (intent === 'content') {
// A synthetic xterm click can focus its helper textarea. Blur after the
// report so collapsing a readback never opens or retains the keyboard.
this._blurMobileTerminalInput();
} else {
this._focusMobileTerminalInput();
}
return intent;
},

// ═══════════════════════════════════════════════════════════════
// Synthetic tap → mouse report
// ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -2760,6 +2917,15 @@ Object.assign(CodemanApp.prototype, {
return this._terminalViewportAtBottom();
},

// Claude keeps most transcript history inside its own TUI rather than xterm
// scrollback. On verified versions, route a touch drag through the same SGR
// wheel path as desktop. Codex keeps the existing local touch behavior.
_shouldForwardTouchScrollToApp() {
const session = this.sessions?.get(this.activeSessionId);
if (session?.mode !== 'claude') return false;
return this._shouldForwardWheelToApp({ shiftKey: false });
},

// Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer
// cell. Reports are coalesced into one fire-and-forget write per ~40ms: a
// trackpad emits dozens of wheel events per second and each send becomes a
Expand Down
Loading