Merge PR #130: mobile CJK input reliability + iPad keyboard accessory bar

fix(mobile): CJK input reliability + iPad keyboard accessory bar
This commit is contained in:
Ark0N
2026-06-17 18:02:37 +02:00
committed by GitHub
7 changed files with 380 additions and 406 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();
}
+115 -121
View File
@@ -14,13 +14,21 @@
* 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, all input events are ignored — only compositionend
* triggers a flush (CJK candidate selection).
*
* During composition, `insertText` events are flushed immediately (punctuation,
* English words confirmed by IME). `insertCompositionText` waits for
* compositionend (CJK candidate selection).
* ## iOS dictation challenge (WebKit Bug 261764)
*
* iOS/iPadOS voice dictation does NOT fire composition events. Text arrives
* as bare input events with isComposing === false. Dictation refinement is
* a delete→reinsert cycle (deleteContentBackward + insertReplacementText),
* all within a few ms. Flushing on every input event would send irrevocable
* provisional text to the PTY, causing duplication when the IME replaces it.
*
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
* delete→reinsert cycle collapses into one flush of the final textarea value.
* Keyboard typing of single printable characters still goes through the
* keydown handler (immediate, no debounce).
*
* ## Phantom character for Android backspace
*
@@ -41,16 +49,30 @@
// 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;
let _flushTimer = null;
let _dictationActive = false;
let _dictationDecayTimer = null;
let _keydownSentAt = 0;
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 PHANTOM = '​';
// Two-tier debounce for non-composition input:
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
// composition events even for keyboard CJK typing)
// - DICTATION: long debounce (iOS voice dictation sends delete→reinsert
// refinement cycles without composition events — WebKit Bug 261764)
//
// Dictation is detected by deleteContentBackward on non-empty text or
// insertReplacementText — signals that the IME is rewriting provisional
// text. Once detected, dictation mode persists for 3s (covers multi-word
// dictation with natural pauses between words).
const DEBOUNCE_KEYBOARD_MS = 150;
const DEBOUNCE_DICTATION_MS = 1500;
const DICTATION_DECAY_MS = 3000;
const PASSTHROUGH_KEYS = {
ArrowUp: '\x1b[A',
@@ -66,35 +88,15 @@ 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, '');
return str.replace(/​/g, '');
}
/** Reset textarea to phantom-only state with cursor at end */
function _resetToPhantom() {
_textarea.value = PHANTOM;
_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);
}
@@ -108,61 +110,73 @@ const CjkInput = (() => {
_resetToPhantom();
}
/** Cancel any pending debounced flush */
function _cancelDebouncedFlush() {
if (_flushTimer) {
clearTimeout(_flushTimer);
_flushTimer = null;
}
}
/** Mark that dictation rewriting is in progress */
function _enterDictationMode() {
_dictationActive = true;
clearTimeout(_dictationDecayTimer);
_dictationDecayTimer = setTimeout(() => {
_dictationActive = false;
_dictationDecayTimer = null;
}, DICTATION_DECAY_MS);
}
/** Schedule a flush after input settles */
function _debouncedFlush() {
_cancelDebouncedFlush();
const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS;
_flushTimer = setTimeout(() => {
_flushTimer = null;
_flush();
}, delay);
}
return {
init({ send }) {
if (_initialized) this.destroy();
_send = send;
_composing = false;
_flushTimer = null;
_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;
if (!_textarea.value) _resetToPhantom();
};
_listeners.blur = () => {
// Keep cjkActive while CJK input is visible — iOS dictation and system
// UI may steal focus temporarily, and clearing the flag during that
// window lets xterm's onData process duplicated input.
if (!_textarea.classList.contains('cjk-input-visible')) {
window.cjkActive = false;
}
// Restore phantom if textarea was emptied while blurred
if (!_textarea.value && !_isMobileComposer()) _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 ──
// ── Composition tracking (keyboard IME — works for CJK typing) ──
_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) {
_textarea.value = '';
}
_cancelDebouncedFlush();
// Leave textarea.value untouched — programmatic changes during
// compositionstart cancel the IME composition on iOS Safari.
};
_listeners.compositionend = () => {
_composing = false;
if (_isMobileComposer()) return;
_cancelDebouncedFlush();
// 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);
@@ -172,32 +186,28 @@ const CjkInput = (() => {
// ── 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();
_composing = false;
_cancelDebouncedFlush();
const val = _strip(_textarea.value);
if (val) {
_send(val + '\r');
} else {
_send('\r');
}
_resetInput();
_resetToPhantom();
return;
}
// Escape: clear textarea (always works)
if (e.key === 'Escape') {
e.preventDefault();
_composing = false;
_resetInput();
_cancelDebouncedFlush();
_resetToPhantom();
return;
}
// Ctrl combos: forward to PTY (always works)
if (e.ctrlKey && CTRL_KEYS[e.key]) {
e.preventDefault();
_send(CTRL_KEYS[e.key]);
@@ -207,21 +217,7 @@ 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()) {
e.preventDefault();
_send('\x7f');
@@ -236,62 +232,62 @@ const CjkInput = (() => {
return;
}
// Single printable character: send immediately to PTY
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
// Single printable character: send immediately to PTY.
// Third-party IMEs on iOS may ignore preventDefault, so the char
// still enters the textarea and fires an input event — _keydownSentAt
// tells the input handler to skip that echo.
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
e.preventDefault();
_send(e.key);
_keydownSentAt = performance.now();
_resetToPhantom();
return;
}
};
_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.
// ── Input event: primary path for virtual keyboards + dictation ──
_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
// press/repeat fires an input event that we can catch here.
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
if (_composing) return;
if (_isEffectivelyEmpty()) {
// No real text left — forward backspace to PTY
_cancelDebouncedFlush();
_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.
// Delete on non-empty text outside composition = dictation rewrite.
// The IME is revising provisional text — switch to long debounce.
_enterDictationMode();
if (!_textarea.value.startsWith(PHANTOM)) {
_textarea.value = PHANTOM + _textarea.value;
_textarea.setSelectionRange(1, 1);
}
_debouncedFlush();
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.
// insertReplacementText = dictation/autocorrect refinement
if (e.inputType === 'insertReplacementText') {
_enterDictationMode();
_debouncedFlush();
return;
}
// Outside composition: send immediately
_flush();
if (_composing) return;
// Keydown handler already sent this character — just clear the
// textarea echo that the IME inserted despite preventDefault.
if (performance.now() - _keydownSentAt < 100) {
_resetToPhantom();
return;
}
// Outside composition: keyboard typing or voice dictation.
// If dictation mode was detected (delete/replacement events seen
// recently), use long debounce. Otherwise short debounce for keyboard.
_debouncedFlush();
};
_textarea.addEventListener('input', _listeners.input);
@@ -300,18 +296,16 @@ const CjkInput = (() => {
},
destroy() {
_cancelDebouncedFlush();
clearTimeout(_dictationDecayTimer);
_dictationActive = false;
if (_textarea) {
for (const [event, handler] of Object.entries(_listeners)) {
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;
},
+13 -7
View File
@@ -4,10 +4,10 @@
* Defines two exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss.
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
*
@@ -58,6 +58,7 @@ const KeyboardAccessoryBar = {
</svg>
</button>
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
@@ -100,6 +101,7 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
@@ -129,7 +131,7 @@ const KeyboardAccessoryBar = {
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
if (refocusActions.has(action) ||
(action === 'clear' && this._confirmAction)) {
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
}
@@ -192,11 +194,12 @@ const KeyboardAccessoryBar = {
case 'init':
this.sendCommand('/init');
break;
case 'clear': {
// Require double-tap: first tap turns amber, second tap within 2s sends
case 'clear':
case 'compact': {
const cmd = action === 'clear' ? '/clear' : '/compact';
if (this._confirmAction === action && this._confirmTimer) {
this.clearConfirm();
this.sendCommand('/clear');
this.sendCommand(cmd);
} else {
this.setConfirm(action, btn);
}
@@ -300,7 +303,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
+48 -43
View File
@@ -291,57 +291,60 @@ const KeyboardHandler = {
updateLayoutForKeyboard() {
if (!window.visualViewport) return;
// Only adjust on mobile
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
if (!MobileDetection.isTouchDevice()) {
this.resetLayout();
return;
}
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen();
if (this.keyboardVisible) {
// Calculate how far the toolbar (position:fixed, bottom:0) needs to
// translate up so it sits at the bottom of the visual viewport.
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
// when the user types in xterm's hidden textarea.
//
// MUST measure against the LAYOUT viewport (window.innerHeight): the
// bars are position:fixed, which anchors to the layout viewport — on
// iOS that keeps its full height while the keyboard is open. Measuring
// the shrunken .app instead (its height tracks --app-height = visual
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
// and accessory bar behind the OS keyboard (0.9.8 regression). On
// Android the layout viewport itself shrinks with the keyboard, so
// innerHeight === visualBottom and the offset is naturally 0 there.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
// Move toolbar and accessory bar above keyboard.
// When keyboardOffset is 0 (viewport scrolled to layout bottom),
// the bars are naturally positioned via their CSS bottom values —
// just clear the transforms. Never dismiss keyboard state here;
// that's handleViewportResize's job.
if (toolbar) {
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
// Reserve only Codeman's visible controls. The OS keyboard is outside
// the visual viewport; adding its height here creates a large blank area
// above the mobile toolbar on iPhone.
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
if (main && keyboardHeight > 0) {
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
if (isSmallMedium) {
// Phones/small tablets: toolbar and accessory bar are position:fixed
// via CSS. Use translateY to lift them above the keyboard.
const toolbar = document.querySelector('.toolbar');
const main = document.querySelector('.main');
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
if (toolbar) {
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (main && keyboardHeight > 0) {
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
}
} else if (keyboardHeight > 0) {
// iPad: use direct bottom positioning (translateY unreliable —
// iOS auto-scrolls the visual viewport, making keyboardOffset ≈ 0).
if (accessoryBar) {
accessoryBar.style.bottom = `${keyboardHeight}px`;
}
}
// CJK textarea positioning (always position:fixed on touch devices).
if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
if (isSmallMedium) {
// Phones: use translateY like toolbar/accessory bar.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
cjkInput.style.bottom = '';
} else {
// iPad: direct bottom = keyboard + accessory bar height.
cjkInput.style.bottom = `${keyboardHeight + 44}px`;
cjkInput.style.transform = '';
}
}
} else {
this.resetLayout();
@@ -360,9 +363,11 @@ const KeyboardHandler = {
}
if (accessoryBar) {
accessoryBar.style.transform = '';
accessoryBar.style.bottom = '';
}
if (cjkInput) {
cjkInput.style.transform = '';
cjkInput.style.bottom = '';
}
if (main) {
main.style.paddingBottom = '';
+4 -168
View File
@@ -1065,85 +1065,7 @@ html.mobile-init .file-browser-panel {
}
/* Paste overlay for iOS clipboard access */
.paste-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.paste-dialog {
background: var(--bg-secondary, #1e1e2e);
border: 1px solid var(--border-color, #444);
border-radius: 12px;
padding: 12px;
width: calc(100% - 24px);
max-width: 400px;
}
.paste-textarea {
width: 100%;
min-height: 80px;
max-height: 200px;
background: var(--bg-primary, #0d0d14);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #444);
border-radius: 8px;
padding: 8px;
font-family: inherit;
font-size: 16px;
resize: none;
box-sizing: border-box;
}
.paste-textarea:focus {
outline: none;
border-color: var(--accent-color, #7aa2f7);
}
.paste-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.paste-cancel, .paste-new, .paste-send, .paste-image {
padding: 8px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
/* Image attach button — left-aligned, accent outline */
.paste-image {
margin-right: auto;
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-cancel {
background: var(--bg-tertiary, #333);
color: var(--text-secondary, #aaa);
}
.paste-new {
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-send {
background: var(--accent-color, #7aa2f7);
color: #fff;
font-weight: 600;
}
/* Paste overlay styles extracted to universal section below (line ~2293+) */
/* LEGACY: Hide old toolbar select (no longer used on mobile) */
.toolbar-select {
@@ -2177,95 +2099,9 @@ html.mobile-init .file-browser-panel {
}
/* ============================================================================
Keyboard Accessory Bar — all mobile/tablet sizes
Visual styles extracted from phone breakpoint so they apply on iPad too.
Phone-specific positioning (position: fixed) remains in @media (max-width: 430px).
============================================================================ */
.keyboard-accessory-bar {
display: none;
height: 44px;
background: #1a1a1a;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 8px;
gap: 8px;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
z-index: 51;
}
.keyboard-accessory-bar.visible {
display: flex;
}
.keyboard-accessory-bar::-webkit-scrollbar {
display: none;
}
.accessory-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 4px;
padding: 6px 12px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e5e5e5;
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.accessory-btn.confirming {
background: #6b4f00;
border-color: #b8860b;
color: #ffd54f;
}
.accessory-btn:active {
background: #3a3a3a;
}
.accessory-btn svg {
width: 14px;
height: 14px;
}
.accessory-btn-arrow {
padding: 6px 10px;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
}
.accessory-btn-arrow:active {
background: #1d4ed8;
}
.accessory-btn-dismiss {
margin-left: auto;
flex: 1 1 0;
max-width: 80px;
padding: 10px 8px;
background: #334d6e;
border-color: rgba(100, 150, 200, 0.4);
color: #c0d4e8;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 20px;
height: 20px;
}
.accessory-btn-dismiss:active {
background: #3d5f85;
}
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
(always loaded — covers iPad landscape where mobile.css doesn't load).
Phone-specific overrides remain in @media (max-width: 430px) above. */
/* ============================================================================
iOS Safari Specific Fixes
+197 -3
View File
@@ -2719,6 +2719,13 @@ body.solo-mode .btn-lifecycle-log {
contain: style;
}
/* backdrop-filter creates a stacking context, trapping the popover's
z-index inside the toolbar. When the popover is open, raise the toolbar
above the CJK input (z-index 52) so the popover is interactable. */
.toolbar:has(.case-settings-popover:not(.hidden)) {
z-index: 100;
}
.toolbar-left,
.toolbar-right {
display: flex;
@@ -9307,9 +9314,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;
@@ -9328,6 +9335,193 @@ body.touch-device.cjk-input-visible .main {
padding-bottom: calc(84px + var(--safe-area-bottom));
}
/* ═══════════════════════════════════════════════════════════════
Keyboard Accessory Bar
Base styles in styles.css (always loaded) so iPad landscape (≥1024px,
where mobile.css doesn't load) still gets dark styling. Phone overrides
remain in mobile.css @media (max-width: 430px).
═══════════════════════════════════════════════════════════════ */
.keyboard-accessory-bar {
display: none;
height: 44px;
background: #1a1a1a;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 8px;
gap: 8px;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
z-index: 51;
}
.keyboard-accessory-bar.visible {
display: flex;
}
.keyboard-accessory-bar::-webkit-scrollbar {
display: none;
}
/* Touch devices: fixed positioning to float above keyboard.
Phone CSS (mobile.css) adds bottom + safe-area padding;
iPad JS sets bottom dynamically via updateLayoutForKeyboard(). */
.touch-device .keyboard-accessory-bar {
position: fixed;
left: 0;
right: 0;
transition: transform 0.15s ease-out;
will-change: transform;
}
.accessory-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 4px;
padding: 6px 12px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e5e5e5;
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.accessory-btn.confirming {
background: #6b4f00;
border-color: #b8860b;
color: #ffd54f;
}
.accessory-btn:active {
background: #3a3a3a;
}
.accessory-btn svg {
width: 14px;
height: 14px;
}
.accessory-btn-arrow {
padding: 6px 10px;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
}
.accessory-btn-arrow:active {
background: #1d4ed8;
}
.accessory-btn-dismiss {
margin-left: auto;
flex: 1 1 0;
max-width: 100px;
padding: 10px 8px;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 22px;
height: 22px;
}
.accessory-btn-dismiss:active {
background: #1d4ed8;
}
/* ═══════════════════════════════════════════════════════════════
Paste Overlay
Base styles in styles.css so iPad landscape works.
═══════════════════════════════════════════════════════════════ */
.paste-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.paste-dialog {
background: var(--bg-secondary, #1e1e2e);
border: 1px solid var(--border-color, #444);
border-radius: 12px;
padding: 12px;
width: calc(100% - 24px);
max-width: 400px;
}
.paste-textarea {
width: 100%;
min-height: 80px;
max-height: 200px;
background: var(--bg-primary, #0d0d14);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #444);
border-radius: 8px;
padding: 8px;
font-family: inherit;
font-size: 16px;
resize: none;
box-sizing: border-box;
}
.paste-textarea:focus {
outline: none;
border-color: var(--accent-color, #7aa2f7);
}
.paste-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.paste-cancel, .paste-new, .paste-send, .paste-image {
padding: 8px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.paste-image {
margin-right: auto;
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-cancel {
background: var(--bg-tertiary, #333);
color: var(--text-secondary, #aaa);
}
.paste-new {
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-send {
background: var(--accent-color, #7aa2f7);
color: #fff;
font-weight: 600;
}
/* ═══════════════════════════════════════════════════════════════
Orchestrator Panel
═══════════════════════════════════════════════════════════════ */
+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);
},
/**