diff --git a/scripts/build.mjs b/scripts/build.mjs index 2f28dd6d..dde50490 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -59,6 +59,7 @@ appendFileSync( ); // 4. Minify frontend assets +run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite'); run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite'); @@ -75,6 +76,7 @@ console.log('\n[build] content-hash cache busting'); 'voice-input.js', 'notification-manager.js', 'keyboard-accessory.js', + 'input-cjk.js', 'app.js', 'ralph-wizard.js', 'api-client.js', diff --git a/src/web/public/app.js b/src/web/public/app.js index 8479d365..8467b145 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -623,6 +623,14 @@ class CodemanApp { const container = document.getElementById('terminalContainer'); this.terminal.open(container); + // Suppress xterm key handling during CJK IME composition. + // Without this, xterm processes raw keyDown events (e.g., "Process" key) + // during composition, causing duplicate or garbled input. + this.terminal.attachCustomKeyEventHandler((ev) => { + if (ev.isComposing || ev.keyCode === 229) return false; + return true; + }); + // WebGL renderer for GPU-accelerated terminal rendering. // Previously caused "page unresponsive" crashes from synchronous GPU stalls, // but the 48KB/frame flush cap in flushPendingWrites() now prevents @@ -645,6 +653,18 @@ class CodemanApp { this._localEchoOverlay = new LocalEchoOverlay(this.terminal); + // CJK IME input — textarea in index.html, just wire up send + this._cjkInput = null; + if (typeof CjkInput !== 'undefined') { + this._cjkInput = CjkInput.init({ + send: (text) => { + if (this.activeSessionId) { + this._sendInputAsync(this.activeSessionId, text); + } + }, + }); + } + // On mobile Safari, delay initial fit() to allow layout to settle // This prevents 0-column terminals caused by fit() running before container is sized const isMobileSafari = MobileDetection.getDeviceType() === 'mobile' && @@ -857,6 +877,8 @@ class CodemanApp { // 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; if (this.activeSessionId) { // Filter out terminal query responses that xterm.js generates automatically. // These are responses to DA (Device Attributes), DSR (Device Status Report), etc. @@ -1632,6 +1654,9 @@ class CodemanApp { setupEventListeners() { // Use capture to handle before terminal document.addEventListener('keydown', (e) => { + // Don't intercept keys during CJK IME composition + if (e.isComposing || e.keyCode === 229) return; + // Escape - close panels and modals if (e.key === 'Escape') { this.closeAllPanels(); @@ -2960,6 +2985,10 @@ 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'; + // Update version displays (header and toolbar) if (data.version) { const versionEl = this.$('versionDisplay'); @@ -3669,7 +3698,6 @@ class CodemanApp { if (ta) ta.dispatchEvent(new CompositionEvent('compositionend', { data: '' })); } } catch {} - // Flush local echo text to PTY before switching tabs. // Send as a single batch (no Enter) so it lands in the session's readline // input buffer — avoids "old text resent on Enter" and overlay render bugs. diff --git a/src/web/public/index.html b/src/web/public/index.html index 5838bd6d..275285c6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -231,7 +231,11 @@
-
+
+
+ +
@@ -1689,6 +1693,7 @@ + diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js new file mode 100644 index 00000000..a3d0774a --- /dev/null +++ b/src/web/public/input-cjk.js @@ -0,0 +1,92 @@ +/** + * @fileoverview CJK IME input for xterm.js terminal. + * + * 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. + * While this textarea has focus, window.cjkActive = true blocks xterm's onData. + * Arrow keys and function keys are forwarded to PTY directly. + * + * @globals {object} CjkInput + * @loadorder 5.5 of 9 — loaded after keyboard-accessory.js, before app.js + */ + +// eslint-disable-next-line no-unused-vars +const CjkInput = (() => { + let _textarea = null; + let _send = null; + + const PASSTHROUGH_KEYS = { + ArrowUp: '\x1b[A', + ArrowDown: '\x1b[B', + ArrowLeft: '\x1b[D', + ArrowRight: '\x1b[C', + Home: '\x1b[H', + End: '\x1b[F', + Tab: '\t', + }; + + const CTRL_KEYS = { + c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05', + }; + + return { + init({ send }) { + _send = send; + _textarea = document.getElementById('cjkInput'); + if (!_textarea) return this; + + _textarea.addEventListener('mousedown', (e) => { e.stopPropagation(); }); + _textarea.addEventListener('focus', () => { window.cjkActive = true; }); + _textarea.addEventListener('blur', () => { window.cjkActive = false; }); + + _textarea.addEventListener('keydown', (e) => { + if (e.isComposing || e.keyCode === 229) return; + + // Enter: send accumulated text (or bare Enter if empty) + if (e.key === 'Enter') { + e.preventDefault(); + if (_textarea.value) { + _send(_textarea.value + '\r'); + _textarea.value = ''; + } else { + _send('\r'); + } + return; + } + + // Escape: clear textarea + if (e.key === 'Escape') { + e.preventDefault(); + _textarea.value = ''; + return; + } + + // Ctrl combos: forward to PTY + 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) { + e.preventDefault(); + _send('\x7f'); + return; + } + + // Arrow/function keys: forward to PTY when textarea is empty + if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) { + e.preventDefault(); + _send(PASSTHROUGH_KEYS[e.key]); + return; + } + }); + + return this; + }, + + get element() { return _textarea; }, + }; +})(); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 41cccfd5..7470f124 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1882,6 +1882,13 @@ body { position: relative; } +.terminal-wrap { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + .terminal-container { flex: 1; background: #0d0d0d; @@ -7502,3 +7509,34 @@ kbd { .advanced-options-content { padding-left: 0.5rem; } + +/* ═══════════════════════════════════════════════════════════════ + CJK IME Input + ═══════════════════════════════════════════════════════════════ */ + +#cjkInput { + display: none; + flex-shrink: 0; + width: 100%; + font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace; + font-size: 14px; + background: #1a1a2e; + color: #e0e0e0; + border: 1px solid #333; + border-top: none; + padding: 6px 10px; + outline: none; + resize: none; + line-height: 1.4; + box-sizing: border-box; +} + +#cjkInput:focus { + border-color: #339af0; + background: #111; +} + +#cjkInput::placeholder { + color: #495057; + font-size: 12px; +} diff --git a/src/web/server.ts b/src/web/server.ts index ccbd8574..389ecb6f 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1947,6 +1947,7 @@ export class WebServer extends EventEmitter { globalStats: this.store.getAggregateStats(activeSessionTokens), subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents timestamp: now, + inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON', }; this.cachedLightState = { data: result, timestamp: now };