From a8e7d60db418c329f98be799bb77d54c5ec9e499 Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 17:54:59 +0800 Subject: [PATCH 1/6] fix(iPad): show stop button on touch devices --- src/web/public/styles.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 691d4aef..df1f120a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3014,6 +3014,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; From 47359e40022f8caa4147d7d7cfccccba01e2b83b Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 20:19:34 +0800 Subject: [PATCH 2/6] fix(iPad): enable terminal touch interaction on all touch devices touch-action: none was only set inside @media (max-width: 430px), so iPad's browser consumed touch events before the JS scroll/tap handler could preventDefault. Move to .touch-device class in styles.css so it applies at any screen width. --- src/web/public/styles.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index df1f120a..cd0a3e78 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2229,6 +2229,15 @@ body.solo-mode .btn-lifecycle-log { background: transparent !important; } +/* Touch devices: JS touch handler owns scroll/tap — prevent browser + from consuming touch events before touchmove preventDefault fires. */ +.touch-device .terminal-container, +.touch-device .terminal-container .xterm, +.touch-device .terminal-container .xterm-viewport, +.touch-device .terminal-container .xterm-screen { + touch-action: none; +} + /* Welcome Overlay */ .welcome-overlay { position: absolute; From b1c4330680a6e67f37ef6584258a192b936c3212 Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 20:33:00 +0800 Subject: [PATCH 3/6] fix(mobile): add tap threshold to terminal touch handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit touchmove fires on any 1px finger drift, marking didScroll=true and skipping the tap handler (which refocuses terminal/CJK input). On iPad's large touch surface and phones with imprecise taps, this makes terminal tap unreliable — cjkActive gets stuck true, blocking all input (CJK and paste). Add 8px TAP_THRESHOLD: finger movement under 8px is still a tap. Also add touch-action:none on .touch-device .terminal-container so the browser doesn't consume touch events before our JS handler. --- src/web/public/input-cjk.js | 10 ++++++++-- src/web/public/styles.css | 12 ++++++++++-- src/web/public/terminal-ui.js | 23 +++++++++++++++++------ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index 9390a039..db4f406c 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -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()) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index cd0a3e78..18091558 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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,8 +2235,10 @@ body.solo-mode .btn-lifecycle-log { background: transparent !important; } -/* Touch devices: JS touch handler owns scroll/tap — prevent browser - from consuming touch events before touchmove preventDefault fires. */ +/* 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, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index a26813ce..64a8a5a5 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -365,11 +365,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 +391,15 @@ 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; + } + // Only preventDefault once it's a real scroll — preventing micro-drift + // touchmove kills click synthesis, which iOS needs to show the keyboard. + if (didScroll) { + ev.preventDefault(); + } const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; velocity = delta * 1.2; @@ -410,19 +419,21 @@ 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) { const cjkInput = document.getElementById('cjkInput'); if (cjkInput?.classList.contains('cjk-input-visible')) { cjkInput.focus(); } else { + // Don't synthesize mousedown — the browser does it naturally + // because we don't preventDefault() for taps (< TAP_THRESHOLD). + // touch-action:none blocks native scroll but NOT click synthesis. + // The browser's mousedown carries detail:1 + correct coordinates, + // so xterm's SelectionService._handleSingleClick picks it up. this._syncMobileHelperTextareaToCursor(); this.terminal.focus(); } From b374121c18fa9d734d520c079dd001902a8e6734 Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 23:18:45 +0800 Subject: [PATCH 4/6] fix(mobile): prevent terminal tap selection --- src/web/public/styles.css | 17 +++++ src/web/public/terminal-ui.js | 99 ++++++++++++++++++++++++--- test/terminal-touch-tap.test.ts | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 test/terminal-touch-tap.test.ts diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 18091558..c9754e42 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2246,6 +2246,23 @@ body.solo-mode .btn-lifecycle-log { 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; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 64a8a5a5..14226ce6 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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) @@ -425,16 +431,32 @@ 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); + } + 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 { - // Don't synthesize mousedown — the browser does it naturally - // because we don't preventDefault() for taps (< TAP_THRESHOLD). - // touch-action:none blocks native scroll but NOT click synthesis. - // The browser's mousedown carries detail:1 + correct coordinates, - // so xterm's SelectionService._handleSingleClick picks it up. - this._syncMobileHelperTextareaToCursor(); this.terminal.focus(); } } @@ -596,8 +618,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 @@ -2005,6 +2032,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)); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts new file mode 100644 index 00000000..04121595 --- /dev/null +++ b/test/terminal-touch-tap.test.ts @@ -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 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(); + }); +}); From 2c81bbc08b0d0248af205b7e2c91a52875bab8d0 Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 17 Jun 2026 23:19:54 +0800 Subject: [PATCH 5/6] feat(terminal): add forced redraw resize --- src/session.ts | 5 +++-- src/web/public/index.html | 8 ++++++++ src/web/public/settings-ui.js | 11 ++++++++++- src/web/public/terminal-ui.js | 19 ++++++++++++------- src/web/routes/session-routes.ts | 8 ++------ src/web/routes/ws-routes.ts | 9 +++------ src/web/schemas.ts | 2 ++ test/routes/session-routes.test.ts | 16 ++++++++++++++-- test/routes/ws-routes.test.ts | 23 +++++++++++++++++++---- test/session-resize-arbitration.test.ts | 9 +++++++++ 10 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/session.ts b/src/session.ts index d4746e77..6b115bc3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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) { diff --git a/src/web/public/index.html b/src/web/public/index.html index dc9ac53e..4c4c7734 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -116,6 +116,7 @@ -- +