Files
Codeman/src/web/public/mobile-handlers.js
T
Rounak DattaandClaude Opus 5 ba843bb272 fix(mobile): keep a long prompt visible instead of hiding it behind the keyboard
Typing a prompt long enough to wrap ran the text off the bottom of the screen: the
tail — the part being typed, where the cursor is — sat behind the on-screen
keyboard, so the user was typing blind. Two independent causes.

**The overlay had no bottom bound.** On touch devices keystrokes are buffered in
the local-echo overlay and do not reach the PTY until Enter, so the CLI never
learns the prompt is long and nothing scrolls or reflows to make room. Meanwhile
the renderer lays its wrapped lines out straight DOWNWARD from the prompt row
(`top = promptRow * cellH`, each line at `i * cellH`) with nothing clamping it to
the visible rows — and with the keyboard up there are only a handful of those.

The block now grows UPWARD once it would pass the last visible row: it is lifted
so its final line lands ON that row. Every line div is opaque, so it covers
transcript above rather than vanishing under the keyboard below — the same thing a
real terminal does when a composer expands. A prompt taller than the whole
viewport keeps its TAIL, for the same reason the fix exists: the end is what the
user is looking at. `startCol` indents only the line that starts at the prompt
marker, so it is dropped along with that line when only the tail fits, and the
cursor follows the last VISIBLE line.

`rows` joins the render key: the layout depends on it, so a keyboard opening —
which changes rows without changing the text — must not be skipped as a redundant
render.

**`_shrinkPaddingToFit()` was reclaiming the bars' own space.** On phones the
toolbar and accessory bar are `position: fixed`, so they occupy no layout space
and `main`'s padding-bottom is the ONLY thing reserving room for them. Shrinking
it by the full sub-row slack pulled the terminal's bottom edge down underneath
them, and the row the following re-fit gained was painted behind them — clipping
the last line of a long prompt. The shrink now has a floor: the MEASURED height of
the currently-visible fixed bars, so genuine over-reservation of the hard-coded
84px is still reclaimed while a device that needs those pixels keeps them. The
floor is `Math.min(currentPadding, measured)`, so it can only ever prevent a
shrink, never cause a grow that would resize the terminal as a side effect.

Overlay behaviour lives in `packages/xterm-zerolag-input/` (single-source; the
vendor bundles are generated), so the fix is in the package with the row count
passed in as an optional `totalRows` — absent, the layout is exactly as before.

Tests: 7 cases in the package's `overlay-renderer.test.ts` (upward lift, tail
retention, indent drop, cursor on the last visible line, and the unclamped
fallbacks) and 7 in a new `test/mobile-keyboard-bottom-padding.test.ts` (reclaim,
floor, partial reclaim, no-grow, hidden bars, CJK strip, whole-row slack). 5 and 4
of them respectively fail without the fix. Package suite 238 pass, including the
codex byte-identity and replay tests.

Verified on Android + Chrome against a live instance: a ~460-character prompt
wrapping ~12 rows stays on screen while typing and arrives at the PTY intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:14:52 +00:00

767 lines
30 KiB
JavaScript

/**
* @fileoverview Mobile device support: detection, keyboard handling, and swipe navigation.
*
* Defines three singleton objects that manage mobile-specific behavior:
*
* - MobileDetection — Device type detection (mobile/tablet/desktop), touch capability,
* iOS/Safari identification, and body class management for CSS targeting.
* - KeyboardHandler — Virtual keyboard show/hide detection via visualViewport API,
* toolbar/accessory bar repositioning, terminal resize on keyboard open/close,
* and input scroll-into-view. Uses 100px threshold for iOS address bar drift.
* - SwipeHandler — Horizontal swipe detection on the terminal area for session switching.
* 80px minimum distance, 300ms maximum time, 100px max vertical drift.
*
* All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE
* reconnect (in handleInit) to prevent stale closures.
*
* @globals {object} MobileDetection
* @globals {object} KeyboardHandler
* @globals {object} SwipeHandler
*
* @dependency keyboard-accessory.js (KeyboardAccessoryBar reference in KeyboardHandler.onKeyboardShow, soft — guarded with typeof check)
* @loadorder 2 of 15 — loaded after constants.js, before voice-input.js
*/
// Codeman — Mobile detection, keyboard handling, and swipe navigation
// Loaded after constants.js, before app.js
// ═══════════════════════════════════════════════════════════════
// Mobile Detection
// ═══════════════════════════════════════════════════════════════
/**
* MobileDetection - Detects device type and touch capability.
* Updates body classes for CSS targeting.
*/
const MobileDetection = {
/** Check if device supports touch input */
isTouchDevice() {
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)
);
},
/**
* Check whether this browser belongs to a handheld device.
*
* Unlike getDeviceType(), this classification must remain stable when a
* foldable changes posture. An unfolded phone can expose a desktop-width
* viewport, but it still needs the same per-device settings that were saved
* while folded. User-Agent Client Hints are preferred where available; the
* legacy token fallback covers Android WebView and iPhone browsers.
*/
isHandheldDevice() {
if (!this.isTouchDevice()) return false;
const userAgent = navigator.userAgent || '';
// Prefer explicit UA form-factor signals. Besides matching real browsers,
// this avoids Chromium emulation reporting userAgentData.mobile=true for
// an iPad/tablet context created with isMobile=true.
if (/iPad|Tablet|Silk|PlayBook|Kindle|Windows NT|CrOS|Macintosh/i.test(userAgent)) {
return false;
}
if (/Android/i.test(userAgent) && !/Mobile/i.test(userAgent)) return false;
if (/Mobi|iPhone|iPod/i.test(userAgent)) return true;
const uaDataMobile = navigator.userAgentData?.mobile;
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
return false;
},
/** Check if device is iOS (iPhone, iPad, iPod) */
isIOS() {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
},
/** Check if browser is Safari */
isSafari() {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
},
/** Check if screen is small (phone-sized, <430px) */
isSmallScreen() {
return window.innerWidth < 430;
},
/** Check if screen is medium (tablet-sized, 430-768px) */
isMediumScreen() {
return window.innerWidth >= 430 && window.innerWidth < 768;
},
/** Get device type based on screen width */
getDeviceType() {
const width = window.innerWidth;
if (width < 430) return 'mobile';
if (width < 768) return 'tablet';
return 'desktop';
},
/** Update body classes based on device detection */
updateBodyClass() {
const body = document.body;
const deviceType = this.getDeviceType();
const isTouch = this.isTouchDevice();
// Remove existing device classes
body.classList.remove(
'device-mobile',
'device-tablet',
'device-desktop',
'touch-device',
'ios-device',
'safari-browser'
);
// Add current device class
body.classList.add(`device-${deviceType}`);
// Add touch device class if applicable
if (isTouch) {
body.classList.add('touch-device');
}
// Add iOS-specific class for safe area handling
if (this.isIOS()) {
body.classList.add('ios-device');
}
// Add Safari class for browser-specific fixes
if (this.isSafari()) {
body.classList.add('safari-browser');
}
},
/** Set --app-height CSS variable from visual viewport.
* On iPad Safari with tabs, 100vh extends behind the tab bar.
* visualViewport.height reflects the actual visible area.
* Skips when virtual keyboard is open — KeyboardHandler manages
* layout via translateY + paddingBottom; shrinking --app-height
* would double-count and leave zero space for the terminal. */
updateAppHeight() {
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
const vh = window.visualViewport?.height || window.innerHeight;
document.documentElement.style.setProperty('--app-height', `${vh}px`);
},
/** Initialize mobile detection and set up resize listener */
init() {
this.updateBodyClass();
this.updateAppHeight();
// Update --app-height on viewport resize (orientation, tab bar toggle)
if (window.visualViewport) {
this._appHeightHandler = () => this.updateAppHeight();
window.visualViewport.addEventListener('resize', this._appHeightHandler);
}
// Debounced resize handler
let resizeTimeout;
this._resizeHandler = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
this.updateBodyClass();
this.updateAppHeight();
// Whether the session sidebar is a docked column or a modal overlay is
// decided at 1024px, so crossing that width has to re-sync the drawer
// state — otherwise the `inert`/aria-hidden set on a closed overlay
// drawer survives into the docked rail and makes it unclickable.
if (typeof app !== 'undefined') app.applySessionListLayout?.();
// Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
// other trigger is a tab content render. No-op on mobile/tablet (method bails).
if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
}, 100);
};
window.addEventListener('resize', this._resizeHandler);
// iOS: prevent pinch-to-zoom (Safari ignores user-scalable=no since iOS 10)
if (this.isIOS()) {
this._gestureStartHandler = (e) => e.preventDefault();
this._gestureChangeHandler = (e) => e.preventDefault();
document.addEventListener('gesturestart', this._gestureStartHandler);
document.addEventListener('gesturechange', this._gestureChangeHandler);
}
},
/** Remove event listeners */
cleanup() {
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
this._resizeHandler = null;
}
if (this._gestureStartHandler) {
document.removeEventListener('gesturestart', this._gestureStartHandler);
document.removeEventListener('gesturechange', this._gestureChangeHandler);
this._gestureStartHandler = null;
this._gestureChangeHandler = null;
}
},
};
// ═══════════════════════════════════════════════════════════════
// Mobile Keyboard Handler
// ═══════════════════════════════════════════════════════════════
/**
* KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears.
* Uses focusin event and scrollIntoView - keeps it simple and reliable.
* Also handles terminal scrolling and toolbar repositioning via visualViewport API.
*/
const KeyboardHandler = {
VIEWPORT_SETTLE_MS: 80,
lastViewportHeight: 0,
keyboardVisible: false,
initialViewportHeight: 0,
_viewportSettleTimer: null,
_settleRestoreScroll: false,
_settlePending: false,
// Scroll intent captured at the start of a settle cycle (#259). `true` =
// following live output, `false` = reading history and _settleAnchorY holds
// the top visible line to return to.
_settleFollowing: true,
_settleAnchorY: null,
/** Initialize keyboard handling */
init() {
// Only initialize on touch devices
if (!MobileDetection.isTouchDevice()) return;
this.initialViewportHeight = window.visualViewport?.height || window.innerHeight;
this.lastViewportHeight = this.initialViewportHeight;
// Simple focus handler - scroll input into view after keyboard appears
this._focusinHandler = (e) => {
const target = e.target;
if (!this.isInputElement(target)) return;
// Wait for keyboard animation, then scroll input into view
setTimeout(() => {
this.scrollInputIntoView(target);
}, 400);
};
document.addEventListener('focusin', this._focusinHandler);
// Use visualViewport to detect keyboard and reposition toolbar
if (window.visualViewport) {
this._viewportResizeHandler = () => {
this.handleViewportResize();
};
this._viewportScrollHandler = () => {
this.updateLayoutForKeyboard();
};
window.visualViewport.addEventListener('resize', this._viewportResizeHandler);
// Also handle scroll (iOS scrolls viewport when keyboard appears)
window.visualViewport.addEventListener('scroll', this._viewportScrollHandler);
}
// Prevent page-level scroll when keyboard is visible.
// iOS Safari scrolls the document to bring xterm's hidden textarea into
// view when the user types, pushing the entire UI off-screen. The CSS
// position:fixed on .app prevents most cases, but reset as a safety net.
this._windowScrollHandler = () => {
if (this.keyboardVisible) {
window.scrollTo(0, 0);
}
};
window.addEventListener('scroll', this._windowScrollHandler);
},
/** Remove event listeners */
cleanup() {
if (this._focusinHandler) {
document.removeEventListener('focusin', this._focusinHandler);
this._focusinHandler = null;
}
if (this._viewportResizeHandler && window.visualViewport) {
window.visualViewport.removeEventListener('resize', this._viewportResizeHandler);
this._viewportResizeHandler = null;
}
if (this._viewportScrollHandler && window.visualViewport) {
window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler);
this._viewportScrollHandler = null;
}
if (this._windowScrollHandler) {
window.removeEventListener('scroll', this._windowScrollHandler);
this._windowScrollHandler = null;
}
if (this._viewportSettleTimer) {
clearTimeout(this._viewportSettleTimer);
this._viewportSettleTimer = null;
}
this._settleRestoreScroll = false;
this._settlePending = false;
this._settleFollowing = true;
this._settleAnchorY = null;
},
/** Handle viewport resize (keyboard show/hide) */
handleViewportResize() {
const currentHeight = window.visualViewport?.height || window.innerHeight;
const heightDiff = this.initialViewportHeight - currentHeight;
// Keyboard appeared (viewport shrunk by more than 150px)
if (heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
document.body.classList.add('keyboard-visible');
// While the keyboard is open, size the app to the visual viewport so
// xterm's bottom row and cursor sit above the OS keyboard.
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
this.onKeyboardShow();
}
// Keyboard hidden (viewport grew back close to initial)
// Use 100px threshold (not 50) to handle iOS address bar drift,
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
else if (heightDiff < 100 && this.keyboardVisible) {
this.keyboardVisible = false;
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
// Re-sync --app-height now that keyboard is gone (MobileDetection skipped
// updates while keyboardVisible was true)
MobileDetection.updateAppHeight();
}
// Update baseline when keyboard is not visible — adapts to address bar
// state changes, orientation changes, and other viewport shifts
if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight;
} else {
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
}
this.updateLayoutForKeyboard();
this._deferViewportSettle();
this.lastViewportHeight = currentHeight;
},
/** Update layout when keyboard shows/hides */
updateLayoutForKeyboard() {
if (!window.visualViewport) return;
if (!MobileDetection.isTouchDevice()) {
this.resetLayout();
return;
}
const cjkInput = document.getElementById('cjkInput');
const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen();
if (this.keyboardVisible) {
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
if (isSmallMedium) {
// Phones/small tablets: toolbar and accessory bar are position:fixed
// via CSS. Use translateY to lift them above the keyboard.
const toolbar = document.querySelector('.toolbar');
const main = document.querySelector('.main');
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
if (toolbar) {
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (main && keyboardHeight > 0) {
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
}
} else if (keyboardHeight > 0) {
// iPad: use direct bottom positioning (translateY unreliable —
// iOS auto-scrolls the visual viewport, making keyboardOffset ≈ 0).
if (accessoryBar) {
accessoryBar.style.bottom = `${keyboardHeight}px`;
}
}
// CJK textarea positioning (always position:fixed on touch devices).
if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
if (isSmallMedium) {
// Phones: use translateY like toolbar/accessory bar.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
cjkInput.style.bottom = '';
} else {
// iPad: direct bottom = keyboard + accessory bar height.
cjkInput.style.bottom = `${keyboardHeight + 44}px`;
cjkInput.style.transform = '';
}
}
} else {
this.resetLayout();
}
},
/** Reset layout to normal (no keyboard) */
resetLayout() {
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
if (toolbar) {
toolbar.style.transform = '';
}
if (accessoryBar) {
accessoryBar.style.transform = '';
accessoryBar.style.bottom = '';
}
if (cjkInput) {
cjkInput.style.transform = '';
cjkInput.style.bottom = '';
}
if (main) {
main.style.paddingBottom = '';
}
},
/** Called when keyboard appears */
onKeyboardShow() {
// Show keyboard accessory bar
if (typeof KeyboardAccessoryBar !== 'undefined') {
KeyboardAccessoryBar.show();
}
// Reset any page scroll that occurred during keyboard open.
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
window.scrollTo(0, 0);
// visualViewport emits multiple heights throughout the OS animation.
// Re-schedule on every event and fit only after the final height settles.
this._scheduleViewportSettle({ restoreScroll: true });
// Reposition subagent windows to stack from bottom (above keyboard)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
},
/** Called when keyboard hides */
onKeyboardHide() {
// Hide keyboard accessory bar
if (typeof KeyboardAccessoryBar !== 'undefined') {
KeyboardAccessoryBar.hide();
}
this.resetLayout();
this._scheduleViewportSettle({ restoreScroll: true });
// Reposition subagent windows to stack from top (below header)
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
},
/**
* Coalesce the keyboard animation into one final xterm reflow and PTY resize.
* Only a real show/hide transition arms the settle work; ongoing viewport
* resize events merely push a pending settle back (_deferViewportSettle).
* A viewport change that never crosses the show/hide thresholds must not
* refit: keyboard detection can miss a fine-grained OS animation entirely
* (each step under 150px, with the baseline chasing the animation), and the
* container is then mid-animation with no keyboard CSS compensation, so a
* fit against it resizes the PTY to transient dims and the SIGWINCH thrash
* garbles the transcript.
*/
_scheduleViewportSettle({ restoreScroll = false } = {}) {
// Capture scroll intent on the FIRST event of a settle cycle, BEFORE any
// fit() has reflowed the buffer — a later capture reads an already-moved
// viewportY. Issue #259: this path used to force scrollToBottom
// unconditionally, so opening the keyboard yanked a user who was reading
// history down to the live output.
if (!this._settlePending) this._captureTerminalScrollIntent();
this._settleRestoreScroll = this._settleRestoreScroll || restoreScroll;
this._settlePending = true;
this._armViewportSettleTimer();
},
/**
* Record whether the terminal is following live output, and if not, the top
* visible line to return to. `_settleFollowing` defaults to true so a
* terminal we cannot read keeps the historical scroll-to-bottom behavior.
*/
_captureTerminalScrollIntent() {
this._settleFollowing = true;
this._settleAnchorY = null;
if (typeof app === 'undefined' || !app.terminal?.buffer?.active) return;
this._settleFollowing = app.isTerminalAtBottom();
if (!this._settleFollowing) this._settleAnchorY = app.terminal.buffer.active.viewportY;
},
/**
* Return to the captured anchor after the keyboard reflow. Reflow can rewrap
* lines, so the anchor is approximate by construction; it is clamped to the
* post-reflow buffer rather than trusted blindly.
*/
_restoreTerminalScrollIntent() {
const term = typeof app !== 'undefined' ? app.terminal : null;
const anchor = this._settleAnchorY;
if (typeof anchor !== 'number' || typeof term?.scrollToLine !== 'function' || !term.buffer?.active) {
term?.scrollToBottom?.();
return;
}
term.scrollToLine(Math.max(0, Math.min(anchor, term.buffer.active.baseY)));
},
/** Push a pending settle back while the viewport is still animating; no-op otherwise. */
_deferViewportSettle() {
if (!this._settlePending) return;
this._armViewportSettleTimer();
},
_armViewportSettleTimer() {
if (this._viewportSettleTimer) clearTimeout(this._viewportSettleTimer);
this._viewportSettleTimer = setTimeout(() => {
this._viewportSettleTimer = null;
this._settlePending = false;
const shouldRestoreScroll = this._settleRestoreScroll;
this._settleRestoreScroll = false;
if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon) {
try {
app.fitAddon.fit();
} catch {}
}
if (this.keyboardVisible) this._shrinkPaddingToFit();
// Following live output → bottom, as before. Reading history → back to
// the pre-reflow anchor instead of being yanked down (#259).
if (shouldRestoreScroll) {
if (this._settleFollowing === false) this._restoreTerminalScrollIntent();
else app.terminal.scrollToBottom();
}
app._syncMobileHelperTextareaToCursor?.();
app._localEchoOverlay?.rerender?.();
this._sendTerminalResize();
}
window.scrollTo(0, 0);
}, this.VIEWPORT_SETTLE_MS);
},
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
_sendTerminalResize() {
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
try {
const dims = app.fitAddon.proposeDimensions();
if (dims) {
const cols = Math.max(dims.cols, 40);
const rows = Math.max(dims.rows, 10);
app._lastResizeDims = { cols, rows };
// Declare the viewport type so resize arbitration can ignore this
// while a desktop connection is sizing the same session.
const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile';
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows, viewportType }),
}).catch(() => {});
}
} catch {}
},
/**
* Shrink .main paddingBottom to eliminate the terminal row quantization gap.
* xterm can only render whole rows, so fractional-row pixels create dead
* space below the last row. After fitAddon.fit(), measure the gap and
* reduce padding by that amount so the terminal sits flush against the bars.
*/
/**
* Combined height of the fixed bars that overlay the terminal's bottom edge.
*
* On phones the toolbar and the accessory bar are `position: fixed`, so they
* occupy no layout space of their own — `main`'s padding-bottom is the only
* thing reserving room for them, and any pixel taken out of it is a pixel of
* terminal painted underneath them.
*/
_fixedBottomBarsHeight() {
let px = 0;
for (const selector of ['.toolbar', '.keyboard-accessory-bar', '#cjkInput.cjk-input-visible']) {
const el = document.querySelector(selector);
if (!el) continue;
const style = window.getComputedStyle?.(el);
if (style && (style.display === 'none' || style.visibility === 'hidden')) continue;
px += el.offsetHeight || 0;
}
return px;
},
/**
* Reclaim sub-row slack at the bottom of the terminal — but never the space the
* fixed bars stand in.
*
* Shrinking the padding by the whole slack pulled the terminal's bottom edge
* DOWN under those bars, and the row the following re-fit then gained was
* painted behind them: on a long wrapped prompt the last line was clipped by
* the accessory bar, i.e. the bottom half of the text being typed. The floor is
* now the bars' MEASURED height, so a device where the hard-coded 84px
* over-reserves still reclaims the difference, while one that genuinely needs
* it keeps every pixel.
*
* ⚠️ The floor can only ever prevent a shrink, never cause a grow
* (`Math.min(currentPadding, …)`): a measured height LARGER than the current
* padding makes this a no-op rather than silently resizing the terminal.
*/
_shrinkPaddingToFit() {
try {
const container = document.getElementById('terminalContainer');
const main = document.querySelector('.main');
if (!container || !main || typeof app === 'undefined' || !app.terminal) return;
const cellH = app.terminal._core?._renderService?.dimensions?.css?.cell?.height;
if (!cellH) return;
const gap = container.clientHeight - app.terminal.rows * cellH;
if (gap > 0 && gap < cellH) {
const currentPadding = parseInt(main.style.paddingBottom) || 0;
const floor = Math.min(currentPadding, this._fixedBottomBarsHeight());
main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px';
if (app.fitAddon)
try {
app.fitAddon.fit();
} catch {}
}
} catch {}
},
/** Check if element is an input that triggers keyboard (excludes terminal) */
isInputElement(el) {
if (!el) return false;
// Exclude xterm.js terminal inputs (they handle their own scroll)
if (el.closest('.xterm') || el.closest('.terminal-container')) {
return false;
}
const tagName = el.tagName?.toLowerCase();
// Exclude type=range, type=checkbox, type=radio (don't trigger keyboard)
if (tagName === 'input') {
const type = el.type?.toLowerCase();
if (type === 'checkbox' || type === 'radio' || type === 'range' || type === 'file') {
return false;
}
}
return tagName === 'input' || tagName === 'textarea' || el.isContentEditable;
},
/** Scroll input into view above the keyboard */
scrollInputIntoView(input) {
// Check if input is still focused (user might have tapped away)
if (document.activeElement !== input) return;
// Find if we're in a modal
const modal = input.closest('.modal.active');
const modalBody = modal?.querySelector('.modal-body');
if (modalBody) {
// For modals - scroll within the modal body
const inputRect = input.getBoundingClientRect();
const modalRect = modalBody.getBoundingClientRect();
// If input is below middle of modal, scroll it up
if (inputRect.top > modalRect.top + modalRect.height * 0.4) {
const scrollAmount = inputRect.top - modalRect.top - 100;
modalBody.scrollBy({ top: scrollAmount, behavior: 'smooth' });
}
} else {
// For page-level - use scrollIntoView
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
},
};
// ═══════════════════════════════════════════════════════════════
// Mobile Swipe Handler
// ═══════════════════════════════════════════════════════════════
/**
* SwipeHandler - Detects horizontal swipes on terminal to switch sessions.
* Only active on mobile/touch devices.
*/
const SwipeHandler = {
startX: 0,
startY: 0,
startTime: 0,
minSwipeDistance: 80, // Minimum pixels for a valid swipe
maxSwipeTime: 300, // Maximum ms for a swipe gesture
maxVerticalDrift: 100, // Max vertical movement allowed
_touchStartHandler: null,
_touchEndHandler: null,
_element: null,
_ignoreGesture: false,
/** Initialize swipe handling */
init() {
// Only on touch devices
if (!MobileDetection.isTouchDevice()) return;
const terminal = document.querySelector('.main');
if (!terminal) return;
this._element = terminal;
this._touchStartHandler = (e) => this.onTouchStart(e);
this._touchEndHandler = (e) => this.onTouchEnd(e);
terminal.addEventListener('touchstart', this._touchStartHandler, { passive: true });
terminal.addEventListener('touchend', this._touchEndHandler, { passive: true });
},
/** Remove swipe listeners */
cleanup() {
if (this._element && this._touchStartHandler) {
this._element.removeEventListener('touchstart', this._touchStartHandler);
this._element.removeEventListener('touchend', this._touchEndHandler);
}
this._touchStartHandler = null;
this._touchEndHandler = null;
this._element = null;
},
onTouchStart(e) {
// The session sidebar is an overlay child of .main, so its touches bubble in
// here. Swiping across the open session drawer — the natural "dismiss it"
// gesture — would otherwise fire nextSession() and drop the user into a
// session they never tapped.
this._ignoreGesture = !!e.target?.closest?.('.session-sidebar');
if (this._ignoreGesture) return;
if (!e.touches || e.touches.length !== 1) return;
this.startX = e.touches[0].clientX;
this.startY = e.touches[0].clientY;
this.startTime = Date.now();
},
onTouchEnd(e) {
if (this._ignoreGesture) {
this._ignoreGesture = false;
return;
}
if (!e.changedTouches || e.changedTouches.length !== 1) return;
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
const elapsed = Date.now() - this.startTime;
// Check if it's a valid swipe
const deltaX = endX - this.startX;
const deltaY = Math.abs(endY - this.startY);
if (elapsed > this.maxSwipeTime) return; // Too slow
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
// Valid swipe detected
if (deltaX > 0) {
// Swipe right -> previous session
if (typeof app !== 'undefined') app.prevSession();
} else {
// Swipe left -> next session
if (typeof app !== 'undefined') app.nextSession();
}
},
};