diff --git a/src/web/public/app.js b/src/web/public/app.js
index 17f1322a..4c555f24 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -886,6 +886,8 @@ class CodemanApp {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
if (!this.activeSessionId || !this.terminal) return;
+ // Skip if buffer load already in progress — avoids competing clear+rewrite cycles
+ if (this._isLoadingBuffer) return;
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
const data = await res.json();
@@ -909,6 +911,12 @@ class CodemanApp {
async _onSessionClearTerminal(data) {
if (data.id === this.activeSessionId) {
+ // Skip if selectSession is already loading the buffer — clearTerminal arriving
+ // during buffer load would clear the terminal mid-write, causing visible flicker
+ // and a race between two concurrent chunkedTerminalWrite calls (especially on mobile
+ // where rAF is slower). selectSession will handle the final buffer state.
+ if (this._isLoadingBuffer) return;
+
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try {
const res = await fetch(`/api/sessions/${data.id}/terminal`);
@@ -1299,6 +1307,16 @@ class CodemanApp {
});
}
+ /** Show/hide the CJK input textarea based on user setting or server override */
+ _updateCjkInputState() {
+ const cjkEl = document.getElementById('cjkInput');
+ if (!cjkEl) return;
+ const settings = this.loadAppSettingsFromStorage();
+ const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false;
+ cjkEl.style.display = showCjk ? 'block' : 'none';
+ if (!showCjk) window.cjkActive = false;
+ }
+
handleInit(data) {
// Clear the init fallback timer since we got data
if (this._initFallbackTimer) {
@@ -1307,12 +1325,9 @@ class CodemanApp {
}
const gen = ++this._initGeneration;
- // CJK input form: show/hide based on server env INPUT_CJK_FORM=ON
- const cjkEl = document.getElementById('cjkInput');
- if (cjkEl) {
- cjkEl.style.display = data.inputCjkForm ? 'block' : 'none';
- if (!data.inputCjkForm) window.cjkActive = false;
- }
+ // CJK input form: controlled by user setting (with server env as override)
+ this._serverCjkOverride = data.inputCjkForm || false;
+ this._updateCjkInputState();
// Update version displays (header and toolbar)
if (data.version) {
@@ -1988,6 +2003,12 @@ class CodemanApp {
async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return;
+ // Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
+ // programmatic focus() within the user-gesture call stack (e.g. tab click).
+ // After the first await the gesture context is lost and focus() is silently
+ // ignored, leaving the keyboard unable to send input to the terminal.
+ if (this.terminal) this.terminal.focus();
+
const _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
_crashDiag.log(`SELECT: ${_selName}`);
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 450a4687..f4ba982a 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -871,6 +871,16 @@
App Settings
+
+
+ CJK Input
+ Dedicated IME input field for CJK languages
+
+
+
Header Displays
diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js
index ca7782b5..1ad1ca9a 100644
--- a/src/web/public/input-cjk.js
+++ b/src/web/public/input-cjk.js
@@ -3,10 +3,36 @@
*
* Always-visible textarea below the terminal (in index.html).
* The browser handles IME composition natively — we just read
- * textarea.value on Enter and send it to PTY.
+ * textarea.value and send it to PTY.
* While this textarea has focus, window.cjkActive = true blocks xterm's onData.
* Arrow keys and function keys are forwarded to PTY directly.
*
+ * ## Android IME challenge
+ *
+ * Android virtual keyboards (WeChat, Sogou, Gboard in Chinese mode) use
+ * composition for EVERYTHING — including English prediction and punctuation.
+ * 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, `insertText` events are flushed immediately (punctuation,
+ * English words confirmed by IME). `insertCompositionText` waits for
+ * compositionend (CJK candidate selection).
+ *
+ * ## Phantom character for Android backspace
+ *
+ * Android virtual keyboards don't generate key-repeat keydown events for held
+ * keys. When the textarea is empty, backspace produces no `input` event either
+ * (nothing to delete). We keep a zero-width space (U+200B) "phantom" in the
+ * textarea at all times. Backspace deletes the phantom → `input` fires with
+ * `deleteContentBackward` → we send \x7f to PTY and restore the phantom.
+ * Long-press backspace generates rapid deleteContentBackward events, each
+ * handled the same way — giving continuous deletion at the keyboard's native
+ * repeat rate.
+ *
* @dependency index.html (#cjkInput textarea)
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
* @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
@@ -17,10 +43,12 @@ const CjkInput = (() => {
let _textarea = null;
let _send = null;
let _initialized = false;
- let _onMousedown = null;
- let _onFocus = null;
- let _onBlur = null;
- let _onKeydown = null;
+ let _composing = false;
+ 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 PASSTHROUGH_KEYS = {
ArrowUp: '\x1b[A',
@@ -36,66 +64,176 @@ 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, '');
+ }
+
+ /** Reset textarea to phantom-only state with cursor at end */
+ function _resetToPhantom() {
+ _textarea.value = PHANTOM;
+ _textarea.setSelectionRange(1, 1);
+ }
+
+ /** Check if textarea contains only phantom(s) or is empty — no real user text */
+ function _isEffectivelyEmpty() {
+ return !_strip(_textarea.value);
+ }
+
+ /** Flush textarea: send real text to PTY and reset to phantom */
+ function _flush() {
+ const val = _strip(_textarea.value);
+ if (val) {
+ _send(val);
+ }
+ _resetToPhantom();
+ }
+
return {
init({ send }) {
- // Guard against double-init: remove previous listeners
if (_initialized) this.destroy();
_send = send;
+ _composing = false;
_textarea = document.getElementById('cjkInput');
if (!_textarea) return this;
- _onMousedown = (e) => { e.stopPropagation(); };
- _onFocus = () => { window.cjkActive = true; };
- _onBlur = () => { window.cjkActive = false; };
- _textarea.addEventListener('mousedown', _onMousedown);
- _textarea.addEventListener('focus', _onFocus);
- _textarea.addEventListener('blur', _onBlur);
+ // Seed the phantom character
+ _resetToPhantom();
+
+ _listeners.mousedown = (e) => { e.stopPropagation(); };
+ _listeners.focus = () => {
+ window.cjkActive = true;
+ // Restore phantom if textarea was emptied while blurred
+ if (!_textarea.value) _resetToPhantom();
+ };
+ _listeners.blur = () => { window.cjkActive = false; };
+ _textarea.addEventListener('mousedown', _listeners.mousedown);
+ _textarea.addEventListener('focus', _listeners.focus);
+ _textarea.addEventListener('blur', _listeners.blur);
- _onKeydown = (e) => {
- if (e.isComposing || e.keyCode === 229) return;
+ // ── Composition tracking ──
+ _listeners.compositionstart = () => {
+ _composing = true;
+ // 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 = '';
+ }
+ };
+ _listeners.compositionend = () => {
+ _composing = false;
+ // 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);
+ };
+ _textarea.addEventListener('compositionstart', _listeners.compositionstart);
+ _textarea.addEventListener('compositionend', _listeners.compositionend);
- // Enter: send accumulated text (or bare Enter if empty)
+ // ── 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();
- if (_textarea.value) {
- _send(_textarea.value + '\r');
- _textarea.value = '';
+ _composing = false;
+ const val = _strip(_textarea.value);
+ if (val) {
+ _send(val + '\r');
} else {
_send('\r');
}
+ _resetToPhantom();
return;
}
- // Escape: clear textarea
+ // Escape: clear textarea (always works)
if (e.key === 'Escape') {
e.preventDefault();
- _textarea.value = '';
+ _composing = false;
+ _resetToPhantom();
return;
}
- // Ctrl combos: forward to PTY
+ // Ctrl combos: forward to PTY (always works)
if (e.ctrlKey && CTRL_KEYS[e.key]) {
e.preventDefault();
_send(CTRL_KEYS[e.key]);
return;
}
- // Backspace: delete from textarea if has text, else forward to PTY
- if (e.key === 'Backspace' && !_textarea.value) {
+ // Below: only when NOT composing (composing keystrokes belong to IME)
+ if (_composing) 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');
+ _resetToPhantom();
return;
}
- // Arrow/function keys: forward to PTY when textarea is empty
- if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) {
+ // Arrow/function keys: forward to PTY when no real text
+ if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
e.preventDefault();
_send(PASSTHROUGH_KEYS[e.key]);
return;
}
+
+ // Single printable character: send immediately to PTY
+ // (Desktop keyboards with physical keys — Android sends 'Unidentified')
+ if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
+ e.preventDefault();
+ _send(e.key);
+ return;
+ }
};
- _textarea.addEventListener('keydown', _onKeydown);
+ _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.
+ _listeners.input = (e) => {
+ // ── 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 (_isEffectivelyEmpty()) {
+ // No real text left — forward backspace to PTY
+ _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.
+ if (!_textarea.value.startsWith(PHANTOM)) {
+ _textarea.value = PHANTOM + _textarea.value;
+ _textarea.setSelectionRange(1, 1);
+ }
+ 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.
+ return;
+ }
+ // Outside composition: send immediately
+ _flush();
+ };
+ _textarea.addEventListener('input', _listeners.input);
_initialized = true;
return this;
@@ -103,13 +241,13 @@ const CjkInput = (() => {
destroy() {
if (_textarea) {
- if (_onMousedown) _textarea.removeEventListener('mousedown', _onMousedown);
- if (_onFocus) _textarea.removeEventListener('focus', _onFocus);
- if (_onBlur) _textarea.removeEventListener('blur', _onBlur);
- if (_onKeydown) _textarea.removeEventListener('keydown', _onKeydown);
+ for (const [event, handler] of Object.entries(_listeners)) {
+ if (handler) _textarea.removeEventListener(event, handler);
+ }
}
window.cjkActive = false;
- _onMousedown = _onFocus = _onBlur = _onKeydown = null;
+ _composing = false;
+ for (const key of Object.keys(_listeners)) delete _listeners[key];
_initialized = false;
},
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index b9ddf2e4..9ac68cfc 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -1147,6 +1147,7 @@ html.mobile-init .file-browser-panel {
/* Compact welcome overlay for mobile */
.welcome-content {
+ max-width: calc(100vw - 1.5rem);
padding: 1rem 0.75rem;
}
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index 18d5b6a5..bf5e287c 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -273,6 +273,11 @@ Object.assign(CodemanApp.prototype, {
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
+ // Focus terminal NOW, in the synchronous user-gesture context (button click).
+ // iOS Safari ignores programmatic focus() after any await, so this must happen
+ // before the first async call. The keyboard opens here and stays open through
+ // the session creation flow; selectSession at the end inherits the focus state.
+ this.terminal.focus();
try {
// Get case path first
@@ -493,6 +498,8 @@ Object.assign(CodemanApp.prototype, {
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
this.terminal.writeln('');
+ // Focus in sync gesture context (see runClaude comment)
+ this.terminal.focus();
try {
// Check if OpenCode is available
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 943b9ad6..523935d2 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -364,6 +364,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
+ document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
@@ -1180,6 +1181,7 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
+ cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -1296,9 +1298,12 @@ Object.assign(CodemanApp.prototype, {
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
+ // Apply CJK input visibility immediately
+ this._updateCjkInputState();
+
// Save to server (includes notification prefs for cross-browser persistence)
- // Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
- const { localEchoEnabled: _leo, ...serverSettings } = settings;
+ // Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
+ const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
@@ -1682,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
- 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled',
+ 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
]);
// Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 5d54ff8d..270868fd 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -94,7 +94,11 @@ textarea:focus-visible {
outline: none;
}
-/* xterm.js hidden textarea — must never show focus ring or caret */
+/* xterm.js hidden textarea — must never show focus ring or caret.
+ * On mobile (touch devices), override xterm.css defaults that position the
+ * textarea at left:-9999em with width/height:0. iOS Safari ignores keyboard
+ * input to off-screen zero-size textareas, so we move it on-screen with a
+ * minimal size while keeping it visually invisible. */
.xterm-helper-textarea {
border: none !important;
box-shadow: none !important;
@@ -102,6 +106,14 @@ textarea:focus-visible {
opacity: 0 !important;
caret-color: transparent !important;
}
+.touch-device .xterm .xterm-helper-textarea {
+ left: 0 !important;
+ top: 0 !important;
+ width: 1px !important;
+ height: 1px !important;
+ z-index: -1 !important;
+ font-size: 16px !important; /* prevent iOS auto-zoom on focus */
+}
/* Session tab focus */
.session-tab:focus-visible {
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 5c9b1a3b..40b91bc4 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -174,12 +174,14 @@ Object.assign(CodemanApp.prototype, {
// Accumulate sub-line pixel deltas so slow swipes still scroll
let pixelAccum = 0;
+ let didScroll = false; // track whether touchmove fired (tap vs scroll)
container.addEventListener('touchstart', (ev) => {
if (ev.touches.length === 1) {
touchLastY = ev.touches[0].clientY;
velocity = 0;
pixelAccum = 0;
isTouching = true;
+ didScroll = false;
lastTime = 0;
if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
}
@@ -187,6 +189,7 @@ Object.assign(CodemanApp.prototype, {
container.addEventListener('touchmove', (ev) => {
if (ev.touches.length === 1 && isTouching) {
+ didScroll = true;
const touchY = ev.touches[0].clientY;
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
@@ -207,6 +210,12 @@ Object.assign(CodemanApp.prototype, {
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) {
+ this.terminal.focus();
+ }
}, { passive: true });
container.addEventListener('touchcancel', () => {
@@ -260,17 +269,6 @@ Object.assign(CodemanApp.prototype, {
}
this.flushFlickerBuffer();
}
- // Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
- // fitAddon.fit() reflows content: lines at old width may wrap to more rows,
- // pushing overflow into scrollback. Ink's cursor-up count is based on the
- // pre-reflow line count, so ghost renders accumulate in scrollback.
- // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
- // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
- const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
- if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended
- && this.terminal && this.isTerminalAtBottom()) {
- this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
- }
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
@@ -284,6 +282,19 @@ Object.assign(CodemanApp.prototype, {
if (!this._lastResizeDims ||
cols !== this._lastResizeDims.cols ||
rows !== this._lastResizeDims.rows) {
+ // Clear viewport + scrollback ONLY when dimensions actually change.
+ // fitAddon.fit() reflows content: lines at old width may wrap to more rows,
+ // pushing overflow into scrollback. Ink's cursor-up count is based on the
+ // pre-reflow line count, so ghost renders accumulate in scrollback.
+ // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
+ // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
+ // IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
+ // Clearing without a subsequent Ink redraw leaves the terminal blank.
+ const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
+ if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended
+ && this.terminal && this.isTerminalAtBottom()) {
+ this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
+ }
this._lastResizeDims = { cols, rows };
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
@@ -1261,6 +1272,10 @@ Object.assign(CodemanApp.prototype, {
if (this.fitAddon) this.fitAddon.fit();
const dims = this.getTerminalDimensions();
if (!dims) return;
+ // Update _lastResizeDims so the throttledResize handler won't redundantly
+ // clear the terminal for the same dimensions (which would blank the screen
+ // without a subsequent Ink redraw to repaint it).
+ this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
// Fast path: WebSocket resize
if (this._wsReady && this._wsSessionId === sessionId) {
try {