fix(mobile): restore real-time CJK input and terminal tap interaction

Root cause: the mobile-composer mode (02fa3f3) routed CJK text through
local-echo buffering, which accumulated characters until Enter instead
of sending each composed word to the PTY immediately. Additionally,
xtermFocusRedirect hijacked all terminal taps, preventing cursor
positioning and scroll interaction.

Changes:
- Remove mobile-composer accumulation mode from input-cjk.js — all
  platforms now use the same immediate-flush path (compositionend →
  flush → PTY)
- Bypass local-echo buffering in _handleCjkInput (terminal-ui.js) —
  the CJK textarea already provides visual feedback
- Remove xtermFocusRedirect so terminal taps work normally again
- Reduce CJK textarea height (34px min, 6px padding) for less
  screen intrusion
- Paste dialog now sends Enter after text so pasted content submits
- Hide CJK textarea on welcome screen (no active session)
- Add Opus 4.6 model options to selector
This commit is contained in:
Teigen
2026-06-17 23:40:04 +08:00
parent d5809d1808
commit 70742d400a
5 changed files with 14 additions and 139 deletions
-1
View File
@@ -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();
}
+4 -71
View File
@@ -41,8 +41,6 @@
// 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;
@@ -77,23 +75,6 @@ const CjkInput = (() => {
_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);
@@ -116,44 +97,22 @@ const CjkInput = (() => {
_composing = false;
_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;
}
// Restore phantom if textarea was emptied while blurred
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
if (!_textarea.value) _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 ──
_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) {
@@ -162,7 +121,6 @@ const CjkInput = (() => {
};
_listeners.compositionend = () => {
_composing = false;
if (_isMobileComposer()) return;
// 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);
@@ -185,7 +143,7 @@ const CjkInput = (() => {
} else {
_send('\r');
}
_resetInput();
_resetToPhantom();
return;
}
@@ -193,7 +151,7 @@ const CjkInput = (() => {
if (e.key === 'Escape') {
e.preventDefault();
_composing = false;
_resetInput();
_resetToPhantom();
return;
}
@@ -207,19 +165,6 @@ 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()) {
@@ -251,13 +196,6 @@ const CjkInput = (() => {
// making keydown unreliable. input fires AFTER character insertion and
// carries inputType which tells us whether the text is final or tentative.
_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
@@ -305,13 +243,8 @@ const CjkInput = (() => {
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;
},
+4 -1
View File
@@ -300,7 +300,10 @@ const KeyboardAccessoryBar = {
const sendText = () => {
const text = textarea.value;
close();
if (text) app.sendInput(text);
if (text) {
app.sendInput(text);
setTimeout(() => app.sendInput('\r'), 80);
}
};
// Filter to images, close the dialog, and hand off to the shared
+3 -3
View File
@@ -9307,9 +9307,9 @@ kbd {
bottom: calc(var(--safe-area-bottom) + 40px);
z-index: 52;
display: block;
min-height: 44px;
max-height: 96px;
padding: 12px 10px;
min-height: 34px;
max-height: 68px;
padding: 6px 10px;
border: 1px solid rgba(80, 120, 190, 0.55);
border-left: none;
border-right: none;
+3 -63
View File
@@ -1577,71 +1577,11 @@ Object.assign(CodemanApp.prototype, {
}
},
// CJK textarea already provides visual feedback — bypass local echo
// buffering so each composed word reaches the PTY immediately.
_handleCjkInput(text) {
if (!this.activeSessionId) return;
const sessionId = this.activeSessionId;
const session = this.sessions.get(sessionId);
const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell');
if (!useLocalEcho) {
this._sendInputAsync(sessionId, text);
return;
}
if (text === '\x7f') {
const source = this._localEchoOverlay.removeChar();
if (source === 'flushed') {
// Sync app-level flushed Maps (per-session state for tab switching),
// mirroring the onData backspace path — otherwise switching tabs away
// and back restores a stale, too-long flushed overlay.
const { count, text: flushedText } = this._localEchoOverlay.getFlushed();
if (this._flushedOffsets?.has(sessionId)) {
if (count === 0) {
this._flushedOffsets.delete(sessionId);
this._flushedTexts?.delete(sessionId);
} else {
this._flushedOffsets.set(sessionId, count);
this._flushedTexts?.set(sessionId, flushedText);
}
}
this._sendInputAsync(sessionId, text);
}
return;
}
if (/[\r\n]+$/.test(text)) {
const committed = text.replace(/[\r\n]+$/g, '');
if (committed) this._localEchoOverlay.appendText(committed);
const pending = this._localEchoOverlay.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (pending) this._sendInputAsync(sessionId, pending);
setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0);
return;
}
// Multi-byte escape sequence (arrow/Home/End from a hardware keyboard on
// the composer) — forward to the PTY without touching overlay state,
// mirroring the onData path. Appending it to pending text would type raw
// ESC bytes into the prompt on the next Enter.
if (text.length > 1 && text.charCodeAt(0) === 27) {
this._sendInputAsync(sessionId, text);
return;
}
if (text.length === 1 && text.charCodeAt(0) < 32) {
const pending = this._localEchoOverlay.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (pending) this._sendInputAsync(sessionId, pending);
this._sendInputAsync(sessionId, text);
return;
}
this._localEchoOverlay.appendText(text);
this._sendInputAsync(this.activeSessionId, text);
},
/**