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:
Ark0N
2026-08-10 04:33:22 +02:00
committed by GitHub
8 changed files with 1043 additions and 8 deletions
+8 -2
View File
@@ -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
+186 -5
View File
@@ -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');
}
+19 -1
View File
@@ -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);
+14
View File
@@ -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);
}
+70
View File
@@ -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}`);