fix(mobile): show the Codex shift-arrow keys only on codex sessions

The two keys #408 adds to the mobile keyboard accessory bar send
Shift+Left and Shift+Right, which are Codex bindings (edit the last
queued message, step back through the prompt stack). They shipped on
both agent layouts, so a claude, pi, grok, omp, deepseek or gemini
session got two keys that do nothing. That was not only cosmetic: a tap
goes through sendNavKey(), which adds the session to
_echoPassthroughSessions and hands editing to plain PTY echo until Enter
or Ctrl+C, so on a phone a dead key also switched off the local echo
that makes typing feel instant there.

The reveal now follows the shape the 🧠 key already uses. The buttons
stay in both templates, carry an accessory-btn-codex marker class, and
are display:none in styles.css until the bar element carries
codex-enabled. The class has to live on the bar rather than on the keys
because setMode() rebuilds the buttons' innerHTML on every layout
switch. syncCodexKeys() toggles it from the active session's mode
(the same lookup _isShellSession() uses) and is called at init and from
refreshForActiveSession(), which selectSession() already invokes on
every switch. A session's mode is readonly on the server and fixed at
create, so no other event can change the answer; the welcome screen
(no active session) reads as not codex and hides the keys.

The frontend id-branching guard (test/cli-registry-no-id-branching.test.ts)
scans only src/**/*.ts, so the mode comparison in a public JS file is
in bounds, the same as the existing shell check beside it.

Tests: the new describe block in test/mobile-shell-keyboard.test.ts pins
the marker class in both templates, the CSS pair, the class for a codex
session in both layouts, its absence for claude/shell/pi/omp/deepseek,
the re-sync in both directions on a session switch, the no-session case,
and the init + refresh wiring. All six positive assertions fail without
the source change. README and the changeset now say the keys are
Codex-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 23:30:14 +02:00
parent 1e5a53830f
commit 7c3c5b8f72
5 changed files with 117 additions and 8 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"aicodeman": patch "aicodeman": patch
--- ---
Add Shift+Left and Shift+Right buttons to the default and extended mobile agent keyboard bars, enabling Codex queued-message editing and prompt-stack navigation. Flush locally buffered drafts before navigation and keep terminal focus after taps. Add Shift+Left and Shift+Right buttons to the default and extended mobile agent keyboard bars, shown only on Codex sessions, enabling Codex queued-message editing and prompt-stack navigation. Flush locally buffered drafts before navigation and keep terminal focus after taps.
+1 -1
View File
@@ -209,7 +209,7 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
<tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr> <tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr>
</table> </table>
- **Keyboard accessory bar** — `⇧←` / `⇧→` send Shift+Left / Shift+Right in both default and extended agent layouts (Codex defaults: edit the last queued message / return through the prompt stack); `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident - **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident; on Codex sessions the bar also shows `⇧←` / `⇧→` (Shift+Left / Shift+Right: edit the last queued message / return through the prompt stack)
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded - **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API) - **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling - **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling
+31 -6
View File
@@ -4,8 +4,10 @@
* Defines three exports: * Defines three exports:
* *
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, Tab, Shift+Left/Right, paste, Esc, and dismiss (the extended * keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
* bar adds /clear, /compact, Shift+Tab and more). Tab flushes any locally-buffered * bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
* prompt text to the PTY before sending \t, so completion applies to what was typed. * prompt text to the PTY before sending \t, so completion applies to what was typed.
* The paste button opens a dialog that handles both text paste and image attach * The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages). * (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
@@ -661,8 +663,8 @@ const KeyboardAccessoryBar = {
</button> </button>
<button class="accessory-btn" data-action="init" title="/init">/init</button> <button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button> <button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button> <button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
<button class="accessory-btn" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button> <button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard"> <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"> <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"/> <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"/>
@@ -748,8 +750,8 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">&#x232B; All</button> <button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">&#x232B; All</button>
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button> <button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button> <button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button> <button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
<button class="accessory-btn" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button> <button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button> <button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button> <button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button> <button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
@@ -776,6 +778,9 @@ const KeyboardAccessoryBar = {
// The 🧠 key is opt-in (`readMyMindEnabled`, synced): it ships in both // The 🧠 key is opt-in (`readMyMindEnabled`, synced): it ships in both
// templates but stays display:none until the bar carries the marker class. // templates but stays display:none until the bar carries the marker class.
this.syncReadMyMind(); this.syncReadMyMind();
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
// session's mode instead of a setting.
this.syncCodexKeys();
// Add click handlers — preventDefault stops event from reaching terminal // Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => { this.element.addEventListener('click', (e) => {
@@ -819,6 +824,7 @@ const KeyboardAccessoryBar = {
refreshForActiveSession() { refreshForActiveSession() {
this.clearCtrl(); this.clearCtrl();
this._applyLayout(this._resolveMode()); this._applyLayout(this._resolveMode());
this.syncCodexKeys();
}, },
/** Which layout the current state calls for. */ /** Which layout the current state calls for. */
@@ -831,6 +837,11 @@ const KeyboardAccessoryBar = {
return app.sessions?.get(app.activeSessionId)?.mode === 'shell'; return app.sessions?.get(app.activeSessionId)?.mode === 'shell';
}, },
_isCodexSession() {
if (typeof app === 'undefined' || !app.activeSessionId) return false;
return app.sessions?.get(app.activeSessionId)?.mode === 'codex';
},
/** Swap the button set in the DOM. */ /** Swap the button set in the DOM. */
_applyLayout(mode) { _applyLayout(mode) {
if (!this.element || mode === this._mode) return; if (!this.element || mode === this._mode) return;
@@ -1019,6 +1030,20 @@ const KeyboardAccessoryBar = {
this.element.classList.toggle('rmm-enabled', enabled === true); this.element.classList.toggle('rmm-enabled', enabled === true);
}, },
/** Reveal the ⇧←/⇧→ keys only while the active session runs Codex. They are
* Codex bindings (edit the last queued message / prompt stack back) and do
* nothing in any other CLI, yet a tap still goes through sendNavKey(), which
* hands the session to plain PTY echo for the rest of the prompt, so on a
* phone a dead key would also switch off local echo. Same marker-class
* shape as syncReadMyMind(): the class lives on the BAR because setMode()
* rebuilds the buttons' innerHTML. Synced at init and on every session
* switch (refreshForActiveSession); a session's mode is fixed at create, so
* no other event can change the answer. */
syncCodexKeys() {
if (!this.element) return;
this.element.classList.toggle('codex-enabled', this._isCodexSession());
},
/** Send a slash command to the active session. /** Send a slash command to the active session.
* Sends text and Enter separately so Ink processes them as distinct events. */ * Sends text and Enter separately so Ink processes them as distinct events. */
sendCommand(command) { sendCommand(command) {
+12
View File
@@ -11969,6 +11969,18 @@ kbd {
display: inline-flex; display: inline-flex;
} }
/* Keyboard-accessory ⇧←/⇧→ keys: Codex bindings (edit the last queued
message / prompt stack back), so they are revealed only while the active
session runs Codex. Same marker-class shape as the 🧠 key above, for the
same reason (the bar's innerHTML is rebuilt on every layout switch); the
class is synced from the active session's mode (keyboard-accessory.js). */
.keyboard-accessory-bar .accessory-btn-codex {
display: none;
}
.keyboard-accessory-bar.codex-enabled .accessory-btn-codex {
display: inline-flex;
}
.approvals-badge { .approvals-badge {
position: absolute; position: absolute;
top: 2px; top: 2px;
+72
View File
@@ -668,3 +668,75 @@ describe('composer nav keys from the bar', () => {
expect(app.sendInput).not.toHaveBeenCalled(); expect(app.sendInput).not.toHaveBeenCalled();
}); });
}); });
describe('Codex shift-arrow keys are gated on the active session', () => {
// ⇧←/⇧→ are Codex bindings. They ship in both agent templates, but a tap in
// any other CLI would do nothing AND hand the session to PTY echo
// (sendNavKey adds it to _echoPassthroughSessions), so on a phone a dead key
// would also switch local echo off for the rest of the prompt. The reveal
// follows the 🧠 key's shape: a marker class on the BAR element, because
// setMode() rebuilds the buttons' innerHTML on every layout switch.
const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
it('marks both shift keys in both agent templates so one CSS rule can hide them', () => {
const simple = keyboardSource.match(/_simpleButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
const extended = keyboardSource.match(/_extendedButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
for (const template of [simple, extended]) {
expect(template).toMatch(/accessory-btn-codex[^>]*data-action="shift-left"/);
expect(template).toMatch(/accessory-btn-codex[^>]*data-action="shift-right"/);
}
});
it('hides the keys in styles.css until the bar carries codex-enabled', () => {
expect(stylesSource).toMatch(/\.keyboard-accessory-bar \.accessory-btn-codex \{\s*display: none;/);
expect(stylesSource).toMatch(
/\.keyboard-accessory-bar\.codex-enabled \.accessory-btn-codex \{\s*display: inline-flex;/
);
});
it.each(['simple', 'extended'])('carries codex-enabled for a codex session in the %s layout', (mode) => {
const { bar, barElement } = loadBar('codex');
bar.setMode(mode);
expect(barElement.classList.contains('codex-enabled')).toBe(true);
// The buttons themselves are still in the DOM; the class is what reveals them.
expect(barElement.actions).toContain('shift-left');
expect(barElement.actions).toContain('shift-right');
});
it.each(['claude', 'shell', 'pi', 'omp', 'deepseek'])(
'does not carry codex-enabled for a %s session',
(sessionMode) => {
const { bar, barElement } = loadBar(sessionMode);
bar.setMode('extended');
expect(barElement.classList.contains('codex-enabled')).toBe(false);
}
);
it('re-syncs the class on a session switch, in both directions', () => {
const { app, bar, barElement } = loadBar('codex');
expect(barElement.classList.contains('codex-enabled')).toBe(true);
app.sessions.set('session-2', { mode: 'claude' });
app.activeSessionId = 'session-2';
bar.refreshForActiveSession();
expect(barElement.classList.contains('codex-enabled')).toBe(false);
app.activeSessionId = 'session-1';
bar.refreshForActiveSession();
expect(barElement.classList.contains('codex-enabled')).toBe(true);
});
it('drops the class when no session is active (welcome screen)', () => {
const { app, bar, barElement } = loadBar('codex');
app.activeSessionId = '';
bar.refreshForActiveSession();
expect(barElement.classList.contains('codex-enabled')).toBe(false);
});
it('is wired at init and on every session switch, like the 🧠 key', () => {
const initBody = keyboardSource.match(/\n init\(\) \{([\s\S]*?)\n \},/)?.[1] ?? '';
const refreshBody = keyboardSource.match(/\n refreshForActiveSession\(\) \{([\s\S]*?)\n \},/)?.[1] ?? '';
expect(initBody).toContain('this.syncCodexKeys();');
expect(refreshBody).toContain('this.syncCodexKeys();');
});
});