mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
Merge pull request #47 from TeigenZhang/fix/mobile-cjk-input-and-layout
fix: mobile CJK input, terminal flicker, and layout overflow
This commit is contained in:
+27
-6
@@ -886,6 +886,8 @@ class CodemanApp {
|
|||||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||||
// so reload the buffer to recover from any display corruption.
|
// so reload the buffer to recover from any display corruption.
|
||||||
if (!this.activeSessionId || !this.terminal) return;
|
if (!this.activeSessionId || !this.terminal) return;
|
||||||
|
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
||||||
|
if (this._isLoadingBuffer) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -909,6 +911,12 @@ class CodemanApp {
|
|||||||
|
|
||||||
async _onSessionClearTerminal(data) {
|
async _onSessionClearTerminal(data) {
|
||||||
if (data.id === this.activeSessionId) {
|
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)
|
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
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) {
|
handleInit(data) {
|
||||||
// Clear the init fallback timer since we got data
|
// Clear the init fallback timer since we got data
|
||||||
if (this._initFallbackTimer) {
|
if (this._initFallbackTimer) {
|
||||||
@@ -1307,12 +1325,9 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
const gen = ++this._initGeneration;
|
const gen = ++this._initGeneration;
|
||||||
|
|
||||||
// CJK input form: show/hide based on server env INPUT_CJK_FORM=ON
|
// CJK input form: controlled by user setting (with server env as override)
|
||||||
const cjkEl = document.getElementById('cjkInput');
|
this._serverCjkOverride = data.inputCjkForm || false;
|
||||||
if (cjkEl) {
|
this._updateCjkInputState();
|
||||||
cjkEl.style.display = data.inputCjkForm ? 'block' : 'none';
|
|
||||||
if (!data.inputCjkForm) window.cjkActive = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update version displays (header and toolbar)
|
// Update version displays (header and toolbar)
|
||||||
if (data.version) {
|
if (data.version) {
|
||||||
@@ -1988,6 +2003,12 @@ class CodemanApp {
|
|||||||
|
|
||||||
async selectSession(sessionId) {
|
async selectSession(sessionId) {
|
||||||
if (this.activeSessionId === sessionId) return;
|
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 _selStart = performance.now();
|
||||||
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
|
||||||
_crashDiag.log(`SELECT: ${_selName}`);
|
_crashDiag.log(`SELECT: ${_selName}`);
|
||||||
|
|||||||
@@ -871,6 +871,16 @@
|
|||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-item settings-item-multiline" title="Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.">
|
||||||
|
<div class="settings-item-text">
|
||||||
|
<span class="settings-item-label">CJK Input</span>
|
||||||
|
<span class="settings-item-desc">Dedicated IME input field for CJK languages</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm">
|
||||||
|
<input type="checkbox" id="appSettingsCjkInput">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Header Displays Section -->
|
<!-- Header Displays Section -->
|
||||||
<div class="settings-section-header">Header Displays</div>
|
<div class="settings-section-header">Header Displays</div>
|
||||||
|
|||||||
+169
-31
@@ -3,10 +3,36 @@
|
|||||||
*
|
*
|
||||||
* Always-visible textarea below the terminal (in index.html).
|
* Always-visible textarea below the terminal (in index.html).
|
||||||
* The browser handles IME composition natively — we just read
|
* 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.
|
* While this textarea has focus, window.cjkActive = true blocks xterm's onData.
|
||||||
* Arrow keys and function keys are forwarded to PTY directly.
|
* 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)
|
* @dependency index.html (#cjkInput textarea)
|
||||||
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
|
* @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
|
* @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
|
||||||
@@ -17,10 +43,12 @@ const CjkInput = (() => {
|
|||||||
let _textarea = null;
|
let _textarea = null;
|
||||||
let _send = null;
|
let _send = null;
|
||||||
let _initialized = false;
|
let _initialized = false;
|
||||||
let _onMousedown = null;
|
let _composing = false;
|
||||||
let _onFocus = null;
|
const _listeners = {};
|
||||||
let _onBlur = null;
|
|
||||||
let _onKeydown = null;
|
// 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 = {
|
const PASSTHROUGH_KEYS = {
|
||||||
ArrowUp: '\x1b[A',
|
ArrowUp: '\x1b[A',
|
||||||
@@ -36,66 +64,176 @@ const CjkInput = (() => {
|
|||||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
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 {
|
return {
|
||||||
init({ send }) {
|
init({ send }) {
|
||||||
// Guard against double-init: remove previous listeners
|
|
||||||
if (_initialized) this.destroy();
|
if (_initialized) this.destroy();
|
||||||
|
|
||||||
_send = send;
|
_send = send;
|
||||||
|
_composing = false;
|
||||||
_textarea = document.getElementById('cjkInput');
|
_textarea = document.getElementById('cjkInput');
|
||||||
if (!_textarea) return this;
|
if (!_textarea) return this;
|
||||||
|
|
||||||
_onMousedown = (e) => { e.stopPropagation(); };
|
// Seed the phantom character
|
||||||
_onFocus = () => { window.cjkActive = true; };
|
_resetToPhantom();
|
||||||
_onBlur = () => { window.cjkActive = false; };
|
|
||||||
_textarea.addEventListener('mousedown', _onMousedown);
|
|
||||||
_textarea.addEventListener('focus', _onFocus);
|
|
||||||
_textarea.addEventListener('blur', _onBlur);
|
|
||||||
|
|
||||||
_onKeydown = (e) => {
|
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||||
if (e.isComposing || e.keyCode === 229) return;
|
_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);
|
||||||
|
|
||||||
// Enter: send accumulated text (or bare Enter if empty)
|
// ── 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);
|
||||||
|
|
||||||
|
// ── 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') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (_textarea.value) {
|
_composing = false;
|
||||||
_send(_textarea.value + '\r');
|
const val = _strip(_textarea.value);
|
||||||
_textarea.value = '';
|
if (val) {
|
||||||
|
_send(val + '\r');
|
||||||
} else {
|
} else {
|
||||||
_send('\r');
|
_send('\r');
|
||||||
}
|
}
|
||||||
|
_resetToPhantom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape: clear textarea
|
// Escape: clear textarea (always works)
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_textarea.value = '';
|
_composing = false;
|
||||||
|
_resetToPhantom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl combos: forward to PTY
|
// Ctrl combos: forward to PTY (always works)
|
||||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_send(CTRL_KEYS[e.key]);
|
_send(CTRL_KEYS[e.key]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backspace: delete from textarea if has text, else forward to PTY
|
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||||
if (e.key === 'Backspace' && !_textarea.value) {
|
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();
|
e.preventDefault();
|
||||||
_send('\x7f');
|
_send('\x7f');
|
||||||
|
_resetToPhantom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow/function keys: forward to PTY when textarea is empty
|
// Arrow/function keys: forward to PTY when no real text
|
||||||
if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) {
|
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_send(PASSTHROUGH_KEYS[e.key]);
|
_send(PASSTHROUGH_KEYS[e.key]);
|
||||||
return;
|
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;
|
_initialized = true;
|
||||||
return this;
|
return this;
|
||||||
@@ -103,13 +241,13 @@ const CjkInput = (() => {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (_textarea) {
|
if (_textarea) {
|
||||||
if (_onMousedown) _textarea.removeEventListener('mousedown', _onMousedown);
|
for (const [event, handler] of Object.entries(_listeners)) {
|
||||||
if (_onFocus) _textarea.removeEventListener('focus', _onFocus);
|
if (handler) _textarea.removeEventListener(event, handler);
|
||||||
if (_onBlur) _textarea.removeEventListener('blur', _onBlur);
|
}
|
||||||
if (_onKeydown) _textarea.removeEventListener('keydown', _onKeydown);
|
|
||||||
}
|
}
|
||||||
window.cjkActive = false;
|
window.cjkActive = false;
|
||||||
_onMousedown = _onFocus = _onBlur = _onKeydown = null;
|
_composing = false;
|
||||||
|
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1147,6 +1147,7 @@ html.mobile-init .file-browser-panel {
|
|||||||
|
|
||||||
/* Compact welcome overlay for mobile */
|
/* Compact welcome overlay for mobile */
|
||||||
.welcome-content {
|
.welcome-content {
|
||||||
|
max-width: calc(100vw - 1.5rem);
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -273,6 +273,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
||||||
this.terminal.writeln('');
|
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 {
|
try {
|
||||||
// Get case path first
|
// Get case path first
|
||||||
@@ -493,6 +498,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||||
this.terminal.writeln('');
|
this.terminal.writeln('');
|
||||||
|
// Focus in sync gesture context (see runClaude comment)
|
||||||
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if OpenCode is available
|
// Check if OpenCode is available
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||||
this.loadTunnelStatus();
|
this.loadTunnelStatus();
|
||||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||||
|
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||||
@@ -1180,6 +1181,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||||
|
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||||
@@ -1296,9 +1298,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
||||||
this.updateSubagentWindowVisibility(); // Apply subagent window 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)
|
// Save to server (includes notification prefs for cross-browser persistence)
|
||||||
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
|
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||||
const { localEchoEnabled: _leo, ...serverSettings } = settings;
|
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
|
||||||
try {
|
try {
|
||||||
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||||
|
|
||||||
@@ -1682,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const displayKeys = new Set([
|
const displayKeys = new Set([
|
||||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled',
|
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
|
||||||
]);
|
]);
|
||||||
// Merge settings: non-display keys always sync from server,
|
// Merge settings: non-display keys always sync from server,
|
||||||
// display keys only seed from server when localStorage has no value
|
// display keys only seed from server when localStorage has no value
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ textarea:focus-visible {
|
|||||||
outline: none;
|
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 {
|
.xterm-helper-textarea {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
@@ -102,6 +106,14 @@ textarea:focus-visible {
|
|||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
caret-color: transparent !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 */
|
||||||
.session-tab:focus-visible {
|
.session-tab:focus-visible {
|
||||||
|
|||||||
@@ -174,12 +174,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
||||||
let pixelAccum = 0;
|
let pixelAccum = 0;
|
||||||
|
|
||||||
|
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||||
container.addEventListener('touchstart', (ev) => {
|
container.addEventListener('touchstart', (ev) => {
|
||||||
if (ev.touches.length === 1) {
|
if (ev.touches.length === 1) {
|
||||||
touchLastY = ev.touches[0].clientY;
|
touchLastY = ev.touches[0].clientY;
|
||||||
velocity = 0;
|
velocity = 0;
|
||||||
pixelAccum = 0;
|
pixelAccum = 0;
|
||||||
isTouching = true;
|
isTouching = true;
|
||||||
|
didScroll = false;
|
||||||
lastTime = 0;
|
lastTime = 0;
|
||||||
if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
|
||||||
}
|
}
|
||||||
@@ -187,6 +189,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
container.addEventListener('touchmove', (ev) => {
|
container.addEventListener('touchmove', (ev) => {
|
||||||
if (ev.touches.length === 1 && isTouching) {
|
if (ev.touches.length === 1 && isTouching) {
|
||||||
|
didScroll = true;
|
||||||
const touchY = ev.touches[0].clientY;
|
const touchY = ev.touches[0].clientY;
|
||||||
const delta = touchLastY - touchY; // positive = scroll down
|
const delta = touchLastY - touchY; // positive = scroll down
|
||||||
pixelAccum += delta;
|
pixelAccum += delta;
|
||||||
@@ -207,6 +210,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
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 });
|
}, { passive: true });
|
||||||
|
|
||||||
container.addEventListener('touchcancel', () => {
|
container.addEventListener('touchcancel', () => {
|
||||||
@@ -260,17 +269,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
this.flushFlickerBuffer();
|
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
|
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
||||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
// 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.
|
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||||
@@ -284,6 +282,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!this._lastResizeDims ||
|
if (!this._lastResizeDims ||
|
||||||
cols !== this._lastResizeDims.cols ||
|
cols !== this._lastResizeDims.cols ||
|
||||||
rows !== this._lastResizeDims.rows) {
|
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 };
|
this._lastResizeDims = { cols, rows };
|
||||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1261,6 +1272,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this.fitAddon) this.fitAddon.fit();
|
if (this.fitAddon) this.fitAddon.fit();
|
||||||
const dims = this.getTerminalDimensions();
|
const dims = this.getTerminalDimensions();
|
||||||
if (!dims) return;
|
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
|
// Fast path: WebSocket resize
|
||||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
if (this._wsReady && this._wsSessionId === sessionId) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user