feat: add CJK IME input textarea with env toggle

Add a dedicated textarea below the terminal for CJK (Korean/Japanese/Chinese)
IME input. xterm.js intercepts IME composition events, preventing composed
characters from displaying correctly. This textarea bypasses xterm entirely
by using native browser IME handling — text accumulates until Enter, then
sends to PTY in one shot.

- Always-visible textarea below terminal (inside .terminal-wrap flex column)
- focus/blur sets window.cjkActive flag to block xterm onData
- Enter sends textarea.value + \r to PTY, Escape clears
- Arrow keys, Ctrl+C/D/L/Z, Tab, Backspace pass through to PTY when empty
- attachCustomKeyEventHandler suppresses xterm key handling during composition
- INPUT_CJK_FORM=ON|OFF env var toggle (default: off, passed via SSE init)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jaypark
2026-03-14 16:23:25 +09:00
co-authored by Claude Opus 4.6
parent a6597e4a9a
commit e5aca6aa4c
6 changed files with 168 additions and 2 deletions
+29 -1
View File
@@ -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.
+6 -1
View File
@@ -231,7 +231,11 @@
<!-- Main Terminal Area -->
<main class="main">
<div class="terminal-container" id="terminalContainer"></div>
<div class="terminal-wrap">
<div class="terminal-container" id="terminalContainer"></div>
<textarea id="cjkInput" rows="1" placeholder="CJK 입력 → Enter 전송 / ESC 취소"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<!-- Welcome Overlay (shown when no session active) -->
<div class="welcome-overlay" id="welcomeOverlay">
@@ -1689,6 +1693,7 @@
<script defer src="voice-input.js"></script>
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<script defer src="app.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+92
View File
@@ -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; },
};
})();
+38
View File
@@ -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;
}
+1
View File
@@ -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 };