Merge PR #131: terminal touch tap interaction + forced redraw resize

feat(terminal): touch tap interaction + forced redraw resize
This commit is contained in:
Ark0N
2026-06-17 18:08:07 +02:00
committed by GitHub
13 changed files with 351 additions and 39 deletions
+3 -2
View File
@@ -2321,7 +2321,7 @@ export class Session extends EventEmitter {
* @param cols - Number of columns (width in characters)
* @param rows - Number of rows (height in lines)
*/
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void {
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
if (options.viewportType === 'desktop') {
this._lastDesktopDims = { cols, rows };
@@ -2334,7 +2334,8 @@ export class Session extends EventEmitter {
}
this._mobileSizeOverride = true;
}
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
if (this.ptyProcess && (dimsChanged || options.force)) {
this._ptyCols = cols;
this._ptyRows = rows;
if (this._mux && this._muxSession) {
+8
View File
@@ -116,6 +116,7 @@
<span class="stat-value" id="statMem">--</span>
</div>
</div>
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
@@ -1058,6 +1059,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
<span class="settings-item-label">Redraw Terminal Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowRedrawButton">
<span class="slider"></span>
</label>
</div>
<!-- Tab Bar Section -->
<div class="settings-section-header">Tab Bar</div>
+8 -2
View File
@@ -162,6 +162,10 @@ const CjkInput = (() => {
if (!_textarea.classList.contains('cjk-input-visible')) {
window.cjkActive = false;
}
// Reset composing state — some IMEs fire compositionstart without a
// matching compositionend, leaving _composing stuck true and blocking
// all subsequent input events.
_composing = false;
};
_textarea.addEventListener('mousedown', _listeners.mousedown);
_textarea.addEventListener('focus', _listeners.focus);
@@ -214,8 +218,10 @@ const CjkInput = (() => {
return;
}
// Below: only when NOT composing (composing keystrokes belong to IME)
if (_composing) return;
// Below: only when NOT composing (composing keystrokes belong to IME).
// Also check isComposing/keyCode 229 — the first keydown of a CJK
// sequence arrives BEFORE compositionstart, so _composing is still false.
if (_composing || e.isComposing || e.keyCode === 229) return;
// Backspace: forward to PTY when no real text in textarea
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
+10 -1
View File
@@ -318,6 +318,7 @@ Object.assign(CodemanApp.prototype, {
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -1417,6 +1418,7 @@ Object.assign(CodemanApp.prototype, {
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -1734,6 +1736,7 @@ Object.assign(CodemanApp.prototype, {
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
showRedrawButton: false,
// Input
gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile
@@ -1873,6 +1876,12 @@ Object.assign(CodemanApp.prototype, {
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
}
const showRedrawButton = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
const redrawBtn = document.querySelector('.btn-redraw-terminal');
if (redrawBtn) {
redrawBtn.classList.toggle('btn-redraw-terminal--hidden', !showRedrawButton);
}
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
const notifBtn = document.querySelector('.btn-notifications');
@@ -2106,7 +2115,7 @@ Object.assign(CodemanApp.prototype, {
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
+39
View File
@@ -996,6 +996,12 @@ body {
display: none !important;
}
/* Redraw terminal header button: hidden by default (opt-in via App Settings →
Display → "Header Displays"). Pure client-side toggle. */
.btn-redraw-terminal--hidden {
display: none !important;
}
.btn-icon-header.btn-settings {
width: 30px;
height: 30px;
@@ -2229,6 +2235,34 @@ body.solo-mode .btn-lifecycle-log {
background: transparent !important;
}
/* Touch devices: prevent browser from claiming the touch gesture before
our JS touchmove handler fires. Without this, the browser starts native
scrolling during the first few px of finger travel and ignores our
subsequent preventDefault() — breaking custom terminal scroll. */
.touch-device .terminal-container,
.touch-device .terminal-container .xterm,
.touch-device .terminal-container .xterm-viewport,
.touch-device .terminal-container .xterm-screen {
touch-action: none;
}
/* Mobile terminal taps are cursor-positioning gestures, not text-selection gestures.
xterm re-enables selectable DOM text in its accessibility tree, so force the
whole terminal subtree back to non-selectable on touch devices. Synthetic mouse
events for tap-to-position still dispatch normally through JavaScript. */
body.touch-device .terminal-container .xterm,
body.touch-device .terminal-container .xterm .xterm-viewport,
body.touch-device .terminal-container .xterm .xterm-screen,
body.touch-device .terminal-container .xterm .xterm-rows,
body.touch-device .terminal-container .xterm .xterm-accessibility-tree,
body.touch-device .terminal-container .xterm .xterm-accessibility-tree *,
body.touch-device .terminal-container .xterm .xterm-selection,
body.touch-device .terminal-container .xterm .xterm-helper-textarea {
user-select: none !important;
-webkit-user-select: none !important;
-webkit-touch-callout: none !important;
}
/* Welcome Overlay */
.welcome-overlay {
position: absolute;
@@ -3014,6 +3048,11 @@ body.solo-mode .btn-lifecycle-log {
white-space: nowrap;
}
/* Touch devices (iPad, tablets): show stop button — no physical Ctrl+C */
.touch-device .btn-toolbar.btn-stop {
display: flex;
}
/* Mobile app settings gear - hidden on desktop, shown on mobile */
.btn-settings-mobile {
display: none;
+118 -16
View File
@@ -19,6 +19,10 @@
// suppressed, so high-frequency Codex status redraws don't snap the viewport
// back to the bottom while the user is inspecting earlier output.
const USER_SCROLL_STICKY_SUPPRESS_MS = 1500;
// Mobile browsers synthesize trusted mouse events after touchend. During this
// short window, only the app's synthetic tap-to-position mouse event should
// reach xterm.
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
@@ -46,6 +50,7 @@
isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse,
USER_SCROLL_STICKY_SUPPRESS_MS,
TOUCH_COMPAT_MOUSE_SUPPRESS_MS,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
@@ -107,6 +112,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._installMobileTapMouseGuard();
// Suppress xterm key handling during CJK IME composition.
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
@@ -365,11 +371,14 @@ Object.assign(CodemanApp.prototype, {
let pixelAccum = 0;
let didScroll = false; // track whether touchmove fired (tap vs scroll)
let touchStartY = 0;
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
container.addEventListener(
'touchstart',
(ev) => {
if (ev.touches.length === 1) {
touchLastY = ev.touches[0].clientY;
touchStartY = touchLastY;
velocity = 0;
pixelAccum = 0;
isTouching = true;
@@ -388,9 +397,18 @@ Object.assign(CodemanApp.prototype, {
'touchmove',
(ev) => {
if (ev.touches.length === 1 && isTouching) {
ev.preventDefault();
didScroll = true;
const touchY = ev.touches[0].clientY;
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
didScroll = true;
}
// Below the tap threshold, treat the gesture as a potential tap:
// don't preventDefault (iOS needs click synthesis to show the
// keyboard) and don't accumulate scroll distance or velocity. Without
// this guard, sub-threshold micro-drift still scrolls a line and
// leaves a non-zero velocity that touchend turns into a momentum
// fling, so a jittery tap would both position the cursor AND scroll.
if (!didScroll) return;
ev.preventDefault();
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
velocity = delta * 1.2;
@@ -410,20 +428,38 @@ Object.assign(CodemanApp.prototype, {
container.addEventListener(
'touchend',
() => {
(ev) => {
isTouching = false;
if (!scrollFrame && Math.abs(velocity) > 0.3) {
scrollFrame = requestAnimationFrame(scrollLoop);
}
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
// routes back to the terminal. Without this, a tap on the terminal area
// consumes the touch event but xterm's textarea never regains focus.
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);
}
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._syncMobileHelperTextareaToCursor();
this.terminal.focus();
}
}
@@ -585,8 +621,13 @@ Object.assign(CodemanApp.prototype, {
// survives tab switches and reconnects.
this.terminal.onData((data) => {
// CJK input has focus — block xterm from sending to PTY
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
// the PTY even while the CJK input field owns focus. Without this exception
// tapping to move the cursor silently does nothing whenever Chinese input
// is on, because cjkActive stays true the whole time the field is visible.
const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data);
// CJK input has focus — block xterm from sending keystrokes to PTY
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return;
if (this.activeSessionId) {
// Filter terminal query replies generated by xterm.js itself.
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
@@ -1935,9 +1976,10 @@ Object.assign(CodemanApp.prototype, {
}
try {
// Send resize to restore proper dimensions (with minimum enforcement).
// The PTY's SIGWINCH on real dim change is enough for Ink to redraw.
await this.sendResize(this.activeSessionId);
// Force resize even when dimensions match the server's last known state —
// another device may have changed the PTY size since this client last sent,
// and force guarantees a SIGWINCH → Ink redraw at the current device's size.
await this.sendResize(this.activeSessionId, { force: true });
this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success');
} catch (err) {
@@ -1994,6 +2036,62 @@ Object.assign(CodemanApp.prototype, {
} catch {}
},
// ═══════════════════════════════════════════════════════════════
// Synthetic tap → mouse report
// ═══════════════════════════════════════════════════════════════
// Dispatch a mousedown+mouseup pair at viewport coords (clientX/clientY) to
// xterm's root element. xterm's mouse-reporting handler reads the event's
// client coords, maps them to a terminal cell relative to .xterm-screen, and
// — when the foreground app has mouse tracking active (DECSET 1000/1002/1006,
// which Claude's input enables) — encodes an SGR mouse report to the PTY.
// That is the same path a real desktop click takes; on touch devices the
// browser's own compatibility-event synthesis is unreliable (and suppressed
// by touch-action:none), so we drive it explicitly. With mouse tracking off
// it degrades to a harmless zero-length click (no drag → no text selection).
_dispatchSyntheticTerminalClick(clientX, clientY) {
const el = this.terminal?.element;
if (!el || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
// xterm registers its mouseup listener on document during mousedown, so a
// bubbling mouseup reaches it; dispatch both to the root element in order.
const base = {
bubbles: true,
cancelable: true,
view: window,
clientX,
clientY,
screenX: clientX,
screenY: clientY,
button: 0,
detail: 1,
};
try {
el.dispatchEvent(new MouseEvent('mousedown', { ...base, buttons: 1 }));
el.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 }));
} catch {
/* MouseEvent constructor unavailable — tap-to-position simply no-ops */
}
},
_installMobileTapMouseGuard() {
const el = this.terminal?.element;
if (!el || el._codemanTapMouseGuardInstalled) return;
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice && !MobileDetection.isTouchDevice()) return;
el._codemanTapMouseGuardInstalled = true;
const suppressTrustedCompatMouse = (ev) => {
const suppressUntil = this._trustedTapMouseSuppressUntil || 0;
if (!ev.isTrusted || performance.now() > suppressUntil) return;
ev.preventDefault();
ev.stopImmediatePropagation();
};
el.addEventListener('mousedown', suppressTrustedCompatMouse, true);
el.addEventListener('mouseup', suppressTrustedCompatMouse, true);
},
_suppressTrustedTapMouseEvents() {
const ms = window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450;
this._trustedTapMouseSuppressUntil = performance.now() + ms;
},
increaseFontSize() {
const current = this.terminal.options.fontSize || 14;
this.setFontSize(Math.min(current + 2, 24));
@@ -2043,8 +2141,8 @@ Object.assign(CodemanApp.prototype, {
/**
* Send resize to a session with minimum dimension enforcement.
* @param {string} sessionId
* @param {{ forceHttp?: boolean }} [options]
* @returns {Promise<void>}
* @param {{ forceHttp?: boolean, force?: boolean }} [options]
* @returns {Promise<boolean>} Whether dimensions changed from the last send
*/
async sendResize(sessionId, options = {}) {
// Fit terminal to container before reading dimensions — ensures local
@@ -2073,16 +2171,20 @@ Object.assign(CodemanApp.prototype, {
// Fast path: WebSocket resize
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
try {
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType }));
const msg = { t: 'z', c: dims.cols, r: dims.rows, v: viewportType };
if (options.force) msg.f = true;
this._ws.send(JSON.stringify(msg));
return changed;
} catch {
// Fall through to HTTP POST
}
}
const body = { ...dims, viewportType };
if (options.force) body.force = true;
await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dims, viewportType }),
body: JSON.stringify(body),
});
return changed;
},
+2 -6
View File
@@ -746,14 +746,10 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/resize', async (req) => {
const { id } = req.params as { id: string };
const { cols, rows, viewportType } = parseBody(ResizeSchema, req.body);
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
const session = findSessionOrFail(ctx, id);
if (viewportType) {
session.resize(cols, rows, { viewportType });
} else {
session.resize(cols, rows);
}
session.resize(cols, rows, { viewportType, force });
return {};
});
+3 -6
View File
@@ -23,7 +23,7 @@
* {"t":"r"} — needs refresh (reload buffer)
* Client -> Server:
* {"t":"i","d":"..."} — input (keystroke or paste)
* {"t":"z","c":N,"r":N} — resize terminal
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
*/
import { FastifyInstance } from 'fastify';
@@ -146,11 +146,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
session.releaseDesktopSizing(sizingToken);
holdsDesktopClaim = false;
}
if (viewportType) {
session.resize(msg.c, msg.r, { viewportType });
} else {
session.resize(msg.c, msg.r);
}
const force = msg.f === true;
session.resize(msg.c, msg.r, { viewportType, force });
}
} catch {
// Ignore malformed messages
+2
View File
@@ -186,6 +186,7 @@ export const ResizeSchema = z.object({
cols: z.number().int().min(1).max(500),
rows: z.number().int().min(1).max(200),
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
force: z.boolean().optional(),
});
/**
@@ -384,6 +385,7 @@ export const SettingsUpdateSchema = z
// (client-side), but telemetry COLLECTION is server-side, so the per-device
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
statusLineTelemetry: z.boolean().optional(),
showRedrawButton: z.boolean().optional(),
// Input
gestureControlEnabled: z.boolean().optional(),
// Claude CLI settings
+14 -2
View File
@@ -369,7 +369,7 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: undefined });
});
it('passes viewport type through for resize arbitration', async () => {
@@ -381,7 +381,19 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: undefined });
});
it('passes force resize through for redraw requests', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 120, rows: 40, force: true },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
});
it('rejects cols exceeding max (500)', async () => {
+19 -4
View File
@@ -285,7 +285,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(120, 40);
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: false });
});
} finally {
ws.close();
@@ -300,7 +300,22 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile', force: false });
});
} finally {
ws.close();
}
});
it('passes force resize through for redraw requests', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40, f: true }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true });
});
} finally {
ws.close();
@@ -342,7 +357,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(1, 1);
expect(session.resize).toHaveBeenCalledWith(1, 1, { viewportType: undefined, force: false });
});
} finally {
ws.close();
@@ -357,7 +372,7 @@ describe('ws-routes', () => {
ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(500, 200);
expect(session.resize).toHaveBeenCalledWith(500, 200, { viewportType: undefined, force: false });
});
} finally {
ws.close();
+9
View File
@@ -113,6 +113,15 @@ describe('Session resize arbitration', () => {
expect(resize).toHaveBeenCalledWith(100, 30);
});
it('applies forced resizes even when the dimensions did not change', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 120, 40);
session.resize(120, 40, { force: true });
expect(resize).toHaveBeenCalledWith(120, 40);
});
describe('idle-desktop override (whoever is active wins)', () => {
afterEach(() => {
vi.useRealTimers();
+116
View File
@@ -0,0 +1,116 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadTerminalUiHarness() {
const CodemanApp = function CodemanApp(this: any) {};
let now = 1_000;
const context = vm.createContext({
window: {},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => now },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
Blob: function Blob() {},
URL: {
createObjectURL: () => 'blob:yield',
revokeObjectURL: () => {},
},
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: {
isTouchDevice: () => true,
},
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as any)();
return {
app,
setNow: (value: number) => {
now = value;
},
};
}
function createElementHarness() {
const listeners = new Map<string, (ev: any) => void>();
return {
element: {
addEventListener: vi.fn((type: string, listener: (ev: any) => void) => {
listeners.set(type, listener);
}),
},
dispatch(type: string, event: any) {
listeners.get(type)?.(event);
},
};
}
describe('terminal touch tap mouse guard', () => {
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
const { app } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
const event = {
isTrusted: true,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(event.stopImmediatePropagation).toHaveBeenCalledOnce();
});
it('allows the app synthetic mouse event through the tap window', () => {
const { app } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
const event = {
isTrusted: false,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
});
it('allows trusted mouse events after the tap window expires', () => {
const { app, setNow } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();
app.terminal = { element };
app._installMobileTapMouseGuard();
app._suppressTrustedTapMouseEvents();
setNow(2_000);
const event = {
isTrusted: true,
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
dispatch('mousedown', event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
});
});