mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
Merge pull request #268 from Ark0N/feat/mobile-shell-keyboard-262
feat(mobile): shell keyboard bar with a one-shot Ctrl modifier
This commit is contained in:
@@ -848,9 +848,11 @@ class CodemanApp {
|
||||
SwipeHandler.init();
|
||||
VoiceInput.init();
|
||||
KeyboardAccessoryBar.init();
|
||||
// Apply keyboard bar mode from settings
|
||||
// Apply keyboard bar mode from settings. Always set it (not only when the
|
||||
// extended bar is on) so the bar's remembered agent-session layout matches
|
||||
// the setting before the first shell session swaps in the terminal bar.
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
KeyboardAccessoryBar.setMode(_kbSettings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
@@ -4531,6 +4533,10 @@ class CodemanApp {
|
||||
this.loadAttachmentHistory?.(sessionId);
|
||||
}
|
||||
this._updateLocalEchoState();
|
||||
// Shell sessions get the terminal keyboard bar, agent sessions the command
|
||||
// bar (issue #262). Also disarms a one-shot Ctrl left over from the tab we
|
||||
// just left, so it can never fire against the session we just opened.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession();
|
||||
|
||||
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
||||
// the async buffer load. Without this, the offset is 0 during the
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
* Destructive actions (/clear, /compact, extended bar only) 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).
|
||||
* SHELL sessions get their own layout automatically (issue #262): Ctrl, Esc, Tab,
|
||||
* four arrows, paste, dismiss. Ctrl is a ONE-SHOT modifier: arm it, type a
|
||||
* character on the system keyboard, and terminal-ui.js's onData hook swaps the
|
||||
* character for its control byte (ctrlByteFor) and disarms. That is what makes
|
||||
* Ctrl+C/D/Z/R/L/A/E/W/U/K reachable without a button per chord. It resets on
|
||||
* use, on a second tap, on any other accessory key, on a session switch
|
||||
* (refreshForActiveSession) and when the keyboard is dismissed (hide).
|
||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||
* by Link Existing and the extended mobile keyboard bar.
|
||||
*
|
||||
@@ -414,12 +421,58 @@ const PathPicker = {
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Control byte a terminal sends for Ctrl+<char> (issue #262).
|
||||
*
|
||||
* Returns null for characters with no control equivalent (digits, most
|
||||
* punctuation): the caller then sends the character unchanged, matching a
|
||||
* hardware keyboard where Ctrl+7 just types "7".
|
||||
*
|
||||
* `code & 0x1f` covers both ranges a terminal maps: @A-Z[\]^_ (64-95 → 0-31)
|
||||
* and a-z (97-122 → 1-26). Space and ? are the two conventional extras
|
||||
* (Ctrl+Space = NUL, Ctrl+? = DEL) and can't come from the mask.
|
||||
*/
|
||||
function ctrlByteFor(char) {
|
||||
if (typeof char !== 'string' || char.length !== 1) return null;
|
||||
const code = char.charCodeAt(0);
|
||||
if (code === 32) return '\x00';
|
||||
if (code === 63) return '\x7f';
|
||||
if ((code >= 64 && code <= 95) || (code >= 97 && code <= 122)) {
|
||||
return String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an armed one-shot Ctrl to one chunk of terminal input.
|
||||
* Returns `{ data, consumed }`, where `consumed` tells the bar to disarm.
|
||||
*
|
||||
* Multi-character chunks (pastes, escape sequences, IME commits) have no
|
||||
* single key to modify, but they still spend the modifier: leaving it armed
|
||||
* would silently turn the NEXT innocent keystroke into a control byte.
|
||||
*/
|
||||
function applyOneShotCtrl(data) {
|
||||
if (typeof data !== 'string' || data.length === 0) return { data, consumed: false };
|
||||
if (data.length === 1) {
|
||||
const byte = ctrlByteFor(data);
|
||||
return { data: byte === null ? data : byte, consumed: true };
|
||||
}
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
const KeyboardAccessoryBar = {
|
||||
element: null,
|
||||
_mode: 'simple', // 'simple' or 'extended'
|
||||
// Layout currently in the DOM: 'simple' | 'extended' | 'shell'.
|
||||
_mode: 'simple',
|
||||
// Layout the user picked for AGENT sessions ('simple' | 'extended', the
|
||||
// extendedKeyboardBar setting). Shell sessions override it with the shell
|
||||
// bar; this is what we come back to when they switch to an agent tab.
|
||||
_baseMode: 'simple',
|
||||
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
|
||||
_ctrlArmed: false,
|
||||
|
||||
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
|
||||
_simpleButtons: `
|
||||
@@ -448,6 +501,45 @@ const KeyboardAccessoryBar = {
|
||||
</svg>
|
||||
</button>`,
|
||||
|
||||
/** HTML for shell mode (issue #262): terminal controls instead of agent
|
||||
* commands. Ctrl is a one-shot modifier rather than one button per chord,
|
||||
* which is what puts Ctrl+C/D/Z/R/L/A/E/W/U/K on a 9-button bar. */
|
||||
_shellButtons: `
|
||||
<button class="accessory-btn accessory-btn-ctrl" data-action="ctrl" title="Ctrl, then tap a key" aria-pressed="false">Ctrl</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</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"/>
|
||||
</svg>
|
||||
</button>`,
|
||||
|
||||
/** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
|
||||
_extendedButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
@@ -514,7 +606,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// 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', 'clear-input']);
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
@@ -530,14 +622,91 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Switch between 'simple' and 'extended' button layouts */
|
||||
/** Pick the layout the user wants for AGENT sessions ('simple' | 'extended',
|
||||
* the extendedKeyboardBar setting). A shell session keeps the shell bar;
|
||||
* the preference is remembered and applied on the next agent tab. */
|
||||
setMode(mode) {
|
||||
if (mode === this._mode || !this.element) return;
|
||||
this._baseMode = mode === 'extended' ? 'extended' : 'simple';
|
||||
this._applyLayout(this._resolveMode());
|
||||
},
|
||||
|
||||
/** Re-resolve the layout after the active session changed (issue #262):
|
||||
* shell sessions get the terminal bar, everything else the agent bar. Also
|
||||
* disarms Ctrl, because a modifier armed on one session must never fire on
|
||||
* the next one. */
|
||||
refreshForActiveSession() {
|
||||
this.clearCtrl();
|
||||
this._applyLayout(this._resolveMode());
|
||||
},
|
||||
|
||||
/** Which layout the current state calls for. */
|
||||
_resolveMode() {
|
||||
return this._isShellSession() ? 'shell' : this._baseMode;
|
||||
},
|
||||
|
||||
_isShellSession() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return false;
|
||||
return app.sessions?.get(app.activeSessionId)?.mode === 'shell';
|
||||
},
|
||||
|
||||
/** Swap the button set in the DOM. */
|
||||
_applyLayout(mode) {
|
||||
if (!this.element || mode === this._mode) return;
|
||||
this._mode = mode;
|
||||
this.clearConfirm();
|
||||
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
// Reset before the rewrite: _setCtrl() styles the button it can find, and
|
||||
// the one holding the armed class is about to be replaced.
|
||||
this.clearCtrl();
|
||||
this.element.innerHTML =
|
||||
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
},
|
||||
|
||||
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
|
||||
// Tap Ctrl, then type a character on the system keyboard: the character is
|
||||
// replaced by its control byte and Ctrl disarms. Tapping Ctrl again cancels.
|
||||
// The interception lives in the terminal onData handler (terminal-ui.js),
|
||||
// which is where system-keyboard input arrives on a phone. A keydown hook
|
||||
// would miss it, since virtual keyboards report no usable key events.
|
||||
|
||||
/** Is the one-shot Ctrl waiting for a key? */
|
||||
isCtrlArmed() {
|
||||
return this._ctrlArmed === true;
|
||||
},
|
||||
|
||||
/** Arm/cancel the one-shot Ctrl (the Ctrl button toggles). */
|
||||
toggleCtrl() {
|
||||
this._setCtrl(!this._ctrlArmed);
|
||||
},
|
||||
|
||||
/** Disarm: used by session switch, keyboard dismissal and every other key. */
|
||||
clearCtrl() {
|
||||
if (this._ctrlArmed) this._setCtrl(false);
|
||||
},
|
||||
|
||||
_setCtrl(on) {
|
||||
this._ctrlArmed = !!on;
|
||||
const btn = this.element?.querySelector('[data-action="ctrl"]');
|
||||
if (btn) {
|
||||
btn.classList.toggle('armed', this._ctrlArmed);
|
||||
btn.setAttribute('aria-pressed', this._ctrlArmed ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply an armed Ctrl to a chunk of typed input and disarm.
|
||||
* Returns the data unchanged (and leaves the modifier alone) when Ctrl is
|
||||
* not armed, so the caller can pipe every keystroke through it.
|
||||
*/
|
||||
consumeCtrl(data) {
|
||||
if (!this._ctrlArmed) return data;
|
||||
const result = applyOneShotCtrl(data);
|
||||
if (result.consumed) this.clearCtrl();
|
||||
return result.data;
|
||||
},
|
||||
|
||||
/** Exposed for tests: pure char to control byte mapping. */
|
||||
ctrlByteFor,
|
||||
|
||||
_confirmTimer: null,
|
||||
_confirmAction: null,
|
||||
|
||||
@@ -545,7 +714,15 @@ const KeyboardAccessoryBar = {
|
||||
handleAction(action, btn) {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
|
||||
// Any key other than Ctrl itself spends the modifier. It is a one-shot for
|
||||
// the next TYPED character, so an accessory key tapped in between (Esc, an
|
||||
// arrow, paste) must not leave it armed to bite the keystroke after that.
|
||||
if (action !== 'ctrl') this.clearCtrl();
|
||||
|
||||
switch (action) {
|
||||
case 'ctrl':
|
||||
this.toggleCtrl();
|
||||
break;
|
||||
case 'scroll-up':
|
||||
this.sendKey('\x1b[A');
|
||||
break;
|
||||
@@ -784,6 +961,10 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
/** Hide the accessory bar */
|
||||
hide() {
|
||||
// The bar goes away with the keyboard, so an armed Ctrl has nothing left
|
||||
// to modify, and a modifier the user can no longer see must not survive
|
||||
// to the next time they open the keyboard.
|
||||
this.clearCtrl();
|
||||
if (this.element) {
|
||||
this.element.classList.remove('visible');
|
||||
}
|
||||
|
||||
@@ -1174,6 +1174,18 @@ html.mobile-init .file-browser-panel {
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
/* Armed one-shot Ctrl (shell bar, issue #262). Phone palette is hardcoded in
|
||||
this block, so the state needs its own entry here. Three classes beat the
|
||||
plain .accessory-btn rules; the light-skin rule at the bottom of this file
|
||||
is higher still at (0,3,1) and is excluded by hand there, not outranked. */
|
||||
.accessory-btn.accessory-btn-ctrl.armed {
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.9);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.45);
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
@@ -2919,7 +2931,13 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
|
||||
/* `.accessory-btn:not(.armed)` on purpose: this selector is (0,3,1) — `:is()`
|
||||
takes the specificity of its most specific argument, and `.btn-toolbar
|
||||
.btn-shell` is two classes — so it OUTRANKS the (0,3,0) armed-Ctrl rules in
|
||||
both stylesheets and repainted the armed modifier back to a resting button on
|
||||
all four light skins. Excluding the state here fixes phone and tablet at once;
|
||||
adding a class to the armed rules would only have moved the tie. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn:not(.armed)) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -12189,6 +12189,20 @@ body.touch-device.cjk-input-visible .main {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* Armed one-shot Ctrl (shell bar, issue #262). Three classes on purpose, to beat
|
||||
the plain .accessory-btn rules. It still cannot outrank the light-skin
|
||||
compatibility rule in mobile.css, which repaints every .accessory-btn at
|
||||
(0,3,1) — `:is()` inherits its most specific argument — so that rule excludes
|
||||
`.armed` by hand. Without the exclusion the modifier looks unarmed on the four
|
||||
light skins, which is worse than having no armed style at all. */
|
||||
.accessory-btn.accessory-btn-ctrl.armed {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
(function (global) {
|
||||
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
|
||||
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
|
||||
// Pointer and focus reports xterm emits through onData on the terminal's OWN
|
||||
// initiative, with no key pressed: SGR mouse (DECSET 1006, also 1016), legacy
|
||||
// X10 mouse (DECSET 1000 — three raw bytes after CSI M) and focus in/out
|
||||
// (DECSET 1004). They are not query REPLIES, so the query-response filter
|
||||
// above does not match them, and they must keep reaching the PTY. What they
|
||||
// must NOT do is stand in for a keystroke: see isTerminalFocusOrMouseReport.
|
||||
const MOUSE_SGR_REPORT_PATTERN = /^\x1b\[<\d+;\d+;\d+[Mm]$/;
|
||||
const MOUSE_X10_REPORT_PATTERN = /^\x1b\[M[\s\S]{3}$/;
|
||||
const FOCUS_REPORT_PATTERN = /^\x1b\[[IO]$/;
|
||||
// Grace window after a manual scroll-up gesture during which sticky-scroll is
|
||||
// suppressed, so high-frequency Codex status redraws don't snap the viewport
|
||||
// back to the bottom while the user is inspecting earlier output.
|
||||
@@ -106,6 +115,30 @@
|
||||
return isTerminalQueryResponse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the terminal generate this chunk itself, rather than a human pressing a
|
||||
* key? True for mouse and focus reports (issue #262).
|
||||
*
|
||||
* Consumers that treat one onData chunk as "the next keystroke" must skip
|
||||
* these. The one-shot Ctrl modifier is why this exists, and the MOUSE half is
|
||||
* the live one: a shell session keeps the narrow scrollback strip, so mouse
|
||||
* DECSETs reach the browser and anything the user runs that enables tracking
|
||||
* (vim, htop, less) turns a tap into `\x1b[<0;31;23M`. Measured in a real
|
||||
* shell session: with Ctrl armed, one tap on the terminal spent it silently.
|
||||
*
|
||||
* Focus reports are the same class and cost nothing to cover, but they cannot
|
||||
* reach xterm today: `FOCUS_ESCAPE_FILTER` in session.ts strips `\x1b[?1004h`
|
||||
* (and the reports themselves) from every PTY read, so `sendFocusMode` never
|
||||
* turns on. Were that filter to go, the Ctrl button would spend the modifier
|
||||
* on its OWN refocus — the bar refocuses the terminal after every key so the
|
||||
* keyboard stays open, and that refocus emits `\x1b[I`.
|
||||
*/
|
||||
function isTerminalFocusOrMouseReport(data) {
|
||||
return (
|
||||
FOCUS_REPORT_PATTERN.test(data) || MOUSE_SGR_REPORT_PATTERN.test(data) || MOUSE_X10_REPORT_PATTERN.test(data)
|
||||
);
|
||||
}
|
||||
|
||||
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
|
||||
// theme, so default behavior is unchanged. Shared at module scope and exported on the
|
||||
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
|
||||
@@ -134,6 +167,7 @@
|
||||
global.CodemanTerminalInput = {
|
||||
isTerminalQueryResponse,
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
isTerminalFocusOrMouseReport,
|
||||
isComposerNavKey,
|
||||
classifyPredictInput,
|
||||
isCodexComposerRow,
|
||||
@@ -930,6 +964,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── One-shot Ctrl (mobile shell bar, issue #262) ──
|
||||
// A virtual keyboard reports no usable key events, so a keydown hook
|
||||
// would never see the character the modifier applies to: it arrives
|
||||
// here as onData text. Sits AFTER the query-response filter so xterm's
|
||||
// own DA/CPR replies can never spend the modifier, and BEFORE every
|
||||
// send path so the control byte follows the normal control-char route
|
||||
// (immediate flush, local-echo state cleared).
|
||||
//
|
||||
// Mouse and focus reports are skipped rather than suppressed: they are
|
||||
// real bytes the PTY still needs, they just were not typed by anyone.
|
||||
// A shell session passes mouse DECSETs through, so with vim or htop
|
||||
// running, one tap on the terminal used to spend the modifier silently
|
||||
// (measured against a real shell). See isTerminalFocusOrMouseReport.
|
||||
if (
|
||||
typeof KeyboardAccessoryBar !== 'undefined' &&
|
||||
KeyboardAccessoryBar.isCtrlArmed?.() &&
|
||||
!window.CodemanTerminalInput?.isTerminalFocusOrMouseReport(data)
|
||||
) {
|
||||
data = KeyboardAccessoryBar.consumeCtrl(data);
|
||||
}
|
||||
|
||||
this._lastTerminalData = { data, time: performance.now() };
|
||||
|
||||
// ── Local Echo Pass-through ──
|
||||
@@ -2767,6 +2823,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
_crashDiag.log(`CJK send DROP no-session len=${text.length}`);
|
||||
return;
|
||||
}
|
||||
// ── One-shot Ctrl (mobile shell bar, issue #262) ──
|
||||
// While the CJK field is visible it OWNS the keyboard: onData returns early
|
||||
// for everything it swallows, and the focus router even redirects
|
||||
// terminal.focus() into it — which is where the accessory bar sends focus
|
||||
// after every key. So the onData hook never sees these keystrokes, and an
|
||||
// armed modifier could neither fire NOR be spent: it survived until a
|
||||
// session switch and then turned an innocent keystroke into a control byte.
|
||||
// This is the module's single choke point to the PTY, so applying it here
|
||||
// covers typed characters, IME flushes, Enter, backspace and arrows at once.
|
||||
// Same policy as the onData hook: the next single character is modified,
|
||||
// anything longer merely spends the modifier.
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined' && KeyboardAccessoryBar.isCtrlArmed?.()) {
|
||||
text = KeyboardAccessoryBar.consumeCtrl(text);
|
||||
}
|
||||
// Bypasses onData (like insertTerminalText): predictions cannot see this
|
||||
if (this._localEchoPolicy === 'predict') this._predictiveEcho?.clearPredictions();
|
||||
_crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
|
||||
|
||||
Reference in New Issue
Block a user