diff --git a/src/web/public/app.js b/src/web/public/app.js index b5a824e0..30960fbe 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2206,7 +2206,6 @@ class CodemanApp { document.body.classList.toggle('cjk-input-visible', !!showCjk); cjkEl.style.display = showCjk ? 'block' : 'none'; cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true'); - if (showCjk && cjkEl.value === '\u200B') cjkEl.value = ''; if (!showCjk) window.cjkActive = false; if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard(); } diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index 4a3b6a8d..9390a039 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -14,13 +14,21 @@ * This means compositionstart fires even for English text, and compositionend * may not fire until the user explicitly confirms (space, candidate tap). * - * We use InputEvent.inputType to distinguish: - * - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin) - * - `insertText`: final committed text (confirmed word, punctuation, space) + * During composition, all input events are ignored — only compositionend + * triggers a flush (CJK candidate selection). * - * During composition, `insertText` events are flushed immediately (punctuation, - * English words confirmed by IME). `insertCompositionText` waits for - * compositionend (CJK candidate selection). + * ## iOS dictation challenge (WebKit Bug 261764) + * + * iOS/iPadOS voice dictation does NOT fire composition events. Text arrives + * as bare input events with isComposing === false. Dictation refinement is + * a delete→reinsert cycle (deleteContentBackward + insertReplacementText), + * all within a few ms. Flushing on every input event would send irrevocable + * provisional text to the PTY, causing duplication when the IME replaces it. + * + * Solution: outside composition, flush is DEBOUNCED (200ms). The entire + * delete→reinsert cycle collapses into one flush of the final textarea value. + * Keyboard typing of single printable characters still goes through the + * keydown handler (immediate, no debounce). * * ## Phantom character for Android backspace * @@ -41,16 +49,30 @@ // eslint-disable-next-line no-unused-vars const CjkInput = (() => { let _textarea = null; - let _terminalContainer = null; - let _xtermTextarea = null; let _send = null; let _initialized = false; let _composing = false; + let _flushTimer = null; + let _dictationActive = false; + let _dictationDecayTimer = null; + let _keydownSentAt = 0; const _listeners = {}; - // Zero-width space: always present in textarea so Android backspace has - // something to delete, triggering the `input` event we need to detect it. - const PHANTOM = '\u200B'; + const PHANTOM = '​'; + + // Two-tier debounce for non-composition input: + // - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire + // composition events even for keyboard CJK typing) + // - DICTATION: long debounce (iOS voice dictation sends delete→reinsert + // refinement cycles without composition events — WebKit Bug 261764) + // + // Dictation is detected by deleteContentBackward on non-empty text or + // insertReplacementText — signals that the IME is rewriting provisional + // text. Once detected, dictation mode persists for 3s (covers multi-word + // dictation with natural pauses between words). + const DEBOUNCE_KEYBOARD_MS = 150; + const DEBOUNCE_DICTATION_MS = 1500; + const DICTATION_DECAY_MS = 3000; const PASSTHROUGH_KEYS = { ArrowUp: '\x1b[A', @@ -66,35 +88,15 @@ const CjkInput = (() => { c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05', }; - /** Strip phantom characters from a string */ function _strip(str) { - return str.replace(/\u200B/g, ''); + return str.replace(/​/g, ''); } - /** Reset textarea to phantom-only state with cursor at end */ function _resetToPhantom() { _textarea.value = PHANTOM; _textarea.setSelectionRange(1, 1); } - function _isMobileComposer() { - return !!( - _textarea && - typeof MobileDetection !== 'undefined' && - MobileDetection.isTouchDevice() && - _textarea.classList.contains('cjk-input-visible') - ); - } - - function _resetInput() { - if (_isMobileComposer()) { - _textarea.value = ''; - } else { - _resetToPhantom(); - } - } - - /** Check if textarea contains only phantom(s) or is empty — no real user text */ function _isEffectivelyEmpty() { return !_strip(_textarea.value); } @@ -108,61 +110,73 @@ const CjkInput = (() => { _resetToPhantom(); } + /** Cancel any pending debounced flush */ + function _cancelDebouncedFlush() { + if (_flushTimer) { + clearTimeout(_flushTimer); + _flushTimer = null; + } + } + + /** Mark that dictation rewriting is in progress */ + function _enterDictationMode() { + _dictationActive = true; + clearTimeout(_dictationDecayTimer); + _dictationDecayTimer = setTimeout(() => { + _dictationActive = false; + _dictationDecayTimer = null; + }, DICTATION_DECAY_MS); + } + + /** Schedule a flush after input settles */ + function _debouncedFlush() { + _cancelDebouncedFlush(); + const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS; + _flushTimer = setTimeout(() => { + _flushTimer = null; + _flush(); + }, delay); + } + return { init({ send }) { if (_initialized) this.destroy(); _send = send; _composing = false; + _flushTimer = null; _textarea = document.getElementById('cjkInput'); if (!_textarea) return this; - _terminalContainer = document.getElementById('terminalContainer'); - // Seed the phantom character for the hidden/immediate CJK path. - _resetInput(); + _resetToPhantom(); _listeners.mousedown = (e) => { e.stopPropagation(); }; _listeners.focus = () => { window.cjkActive = true; - if (_isMobileComposer() && _textarea.value === PHANTOM) { - _textarea.value = ''; - return; + if (!_textarea.value) _resetToPhantom(); + }; + _listeners.blur = () => { + // Keep cjkActive while CJK input is visible — iOS dictation and system + // UI may steal focus temporarily, and clearing the flag during that + // window lets xterm's onData process duplicated input. + if (!_textarea.classList.contains('cjk-input-visible')) { + window.cjkActive = false; } - // Restore phantom if textarea was emptied while blurred - if (!_textarea.value && !_isMobileComposer()) _resetToPhantom(); }; - _listeners.blur = () => { window.cjkActive = false; }; _textarea.addEventListener('mousedown', _listeners.mousedown); _textarea.addEventListener('focus', _listeners.focus); _textarea.addEventListener('blur', _listeners.blur); - _listeners.xtermFocusRedirect = () => { - if (!_isMobileComposer()) return; - _textarea.focus(); - }; - if (_terminalContainer) { - _xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea'); - if (_xtermTextarea) { - _xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true }); - } - } - - // ── Composition tracking ── + // ── Composition tracking (keyboard IME — works for CJK typing) ── _listeners.compositionstart = () => { _composing = true; - if (_isMobileComposer()) { - if (_textarea.value === PHANTOM) _textarea.value = ''; - return; - } - // Clear phantom so IME sees a clean textarea — some IMEs include - // existing text in the composition region which would corrupt input. - if (_textarea.value === PHANTOM) { - _textarea.value = ''; - } + _cancelDebouncedFlush(); + // Leave textarea.value untouched — programmatic changes during + // compositionstart cancel the IME composition on iOS Safari. }; _listeners.compositionend = () => { _composing = false; - if (_isMobileComposer()) return; + _cancelDebouncedFlush(); // Defer flush: some Android IMEs haven't committed text to textarea // when compositionend fires. setTimeout(0) ensures we read the final value. setTimeout(_flush, 0); @@ -172,32 +186,28 @@ const CjkInput = (() => { // ── Keydown: special keys work REGARDLESS of composition state ── _listeners.keydown = (e) => { - // Enter: flush accumulated text (or bare Enter if empty). - // No isComposing guard — Android IMEs set isComposing=true for English - // prediction, but Enter should ALWAYS send. We preventDefault to stop - // the IME from also handling Enter (which could double-send or do nothing). if (e.key === 'Enter') { e.preventDefault(); _composing = false; + _cancelDebouncedFlush(); const val = _strip(_textarea.value); if (val) { _send(val + '\r'); } else { _send('\r'); } - _resetInput(); + _resetToPhantom(); return; } - // Escape: clear textarea (always works) if (e.key === 'Escape') { e.preventDefault(); _composing = false; - _resetInput(); + _cancelDebouncedFlush(); + _resetToPhantom(); return; } - // Ctrl combos: forward to PTY (always works) if (e.ctrlKey && CTRL_KEYS[e.key]) { e.preventDefault(); _send(CTRL_KEYS[e.key]); @@ -207,21 +217,7 @@ const CjkInput = (() => { // Below: only when NOT composing (composing keystrokes belong to IME) if (_composing) return; - if (_isMobileComposer()) { - if (e.key === 'Backspace' && _isEffectivelyEmpty()) { - e.preventDefault(); - _send('\x7f'); - return; - } - if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) { - e.preventDefault(); - _send(PASSTHROUGH_KEYS[e.key]); - } - return; - } - // Backspace: forward to PTY when no real text in textarea - // (Desktop path — Android uses the input event + phantom approach) if (e.key === 'Backspace' && _isEffectivelyEmpty()) { e.preventDefault(); _send('\x7f'); @@ -236,62 +232,62 @@ const CjkInput = (() => { return; } - // Single printable character: send immediately to PTY - // (Desktop keyboards with physical keys — Android sends 'Unidentified') + // Single printable character: send immediately to PTY. + // Third-party IMEs on iOS may ignore preventDefault, so the char + // still enters the textarea and fires an input event — _keydownSentAt + // tells the input handler to skip that echo. if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) { e.preventDefault(); _send(e.key); + _keydownSentAt = performance.now(); + _resetToPhantom(); return; } }; _textarea.addEventListener('keydown', _listeners.keydown); - // ── Input event: the primary path for Android virtual keyboards ── - // Android sends keyCode 229 + key "Unidentified" for virtual key presses, - // making keydown unreliable. input fires AFTER character insertion and - // carries inputType which tells us whether the text is final or tentative. + // ── Input event: primary path for virtual keyboards + dictation ── _listeners.input = (e) => { - if (_isMobileComposer()) { - if (_textarea.value.includes(PHANTOM)) { - _textarea.value = _strip(_textarea.value); - } - return; - } - // ── Backspace / delete detection ── - // Android long-press backspace generates rapid deleteContentBackward events. - // The phantom character ensures the textarea is never truly empty, so each - // press/repeat fires an input event that we can catch here. if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') { + if (_composing) return; if (_isEffectivelyEmpty()) { - // No real text left — forward backspace to PTY + _cancelDebouncedFlush(); _send('\x7f'); _resetToPhantom(); return; } - // User is editing their own text in the textarea — let it be. - // Ensure phantom is still present for the NEXT backspace. + // Delete on non-empty text outside composition = dictation rewrite. + // The IME is revising provisional text — switch to long debounce. + _enterDictationMode(); if (!_textarea.value.startsWith(PHANTOM)) { _textarea.value = PHANTOM + _textarea.value; _textarea.setSelectionRange(1, 1); } + _debouncedFlush(); return; } - if (_composing) { - // insertText during composition = IME committed final text - // (e.g., punctuation key inserts 。directly, or IME confirms a word). - // Flush immediately — this text won't change. - if (e.inputType === 'insertText') { - _flush(); - return; - } - // insertCompositionText = IME is still working (pinyin, candidates, - // English prediction). Wait for compositionend to flush. + // insertReplacementText = dictation/autocorrect refinement + if (e.inputType === 'insertReplacementText') { + _enterDictationMode(); + _debouncedFlush(); return; } - // Outside composition: send immediately - _flush(); + + if (_composing) return; + + // Keydown handler already sent this character — just clear the + // textarea echo that the IME inserted despite preventDefault. + if (performance.now() - _keydownSentAt < 100) { + _resetToPhantom(); + return; + } + + // Outside composition: keyboard typing or voice dictation. + // If dictation mode was detected (delete/replacement events seen + // recently), use long debounce. Otherwise short debounce for keyboard. + _debouncedFlush(); }; _textarea.addEventListener('input', _listeners.input); @@ -300,18 +296,16 @@ const CjkInput = (() => { }, destroy() { + _cancelDebouncedFlush(); + clearTimeout(_dictationDecayTimer); + _dictationActive = false; if (_textarea) { for (const [event, handler] of Object.entries(_listeners)) { if (handler) _textarea.removeEventListener(event, handler); } } - if (_xtermTextarea && _listeners.xtermFocusRedirect) { - _xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true }); - } window.cjkActive = false; _composing = false; - _terminalContainer = null; - _xtermTextarea = null; for (const key of Object.keys(_listeners)) delete _listeners[key]; _initialized = false; }, diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index d638aef5..7c5a81e0 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -4,10 +4,10 @@ * Defines two exports: * * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual - * keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss. + * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss. * The paste button opens a dialog that handles both text paste and image attach * (native picker + best-effort image paste, routed through app._uploadAndInsertImages). - * Destructive actions (/clear) require double-tap confirmation (2s amber state). + * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). * Commands are sent as text + Enter separately for Ink compatibility. * Only initializes on touch devices (MobileDetection.isTouchDevice guard). * @@ -58,6 +58,7 @@ const KeyboardAccessoryBar = { +