feat(mobile): add Shift arrow keys for Codex prompt navigation

This commit is contained in:
codeman-local
2026-09-12 20:45:05 +08:00
parent a017e9a8e0
commit b357fe832e
5 changed files with 59 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"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.
+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>
</table>
- **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
- **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
- **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)
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling
+13 -3
View File
@@ -4,7 +4,7 @@
* Defines three exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
* keyboard on mobile: arrow up/down, /init, Tab, Shift+Left/Right, paste, Esc, and dismiss (the extended
* bar adds /clear, /compact, Shift+Tab and more). Tab flushes any locally-buffered
* 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
@@ -492,6 +492,8 @@ const KeyboardAccessoryBar = {
</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="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" 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"/>
@@ -577,6 +579,8 @@ const KeyboardAccessoryBar = {
<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" 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" 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="effort-max" title="/effort max">Max</button>
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
@@ -615,7 +619,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', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', '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) {
@@ -744,6 +748,12 @@ const KeyboardAccessoryBar = {
case 'arrow-right':
this.sendNavKey('\x1b[C');
break;
case 'shift-left':
this.sendNavKey('\x1b[1;2D');
break;
case 'shift-right':
this.sendNavKey('\x1b[1;2C');
break;
case 'esc':
this.sendKey('\x1b');
break;
@@ -875,7 +885,7 @@ const KeyboardAccessoryBar = {
},
/**
* A composer nav key (the four arrows) from the bar, under the SAME contract
* A composer nav key (arrows, including Shift+Left/Right) from the bar, under the SAME contract
* as pressing one on a hardware keyboard (the `isComposerNavKey` branch of
* terminal-ui.js's onData): flush the unsent draft so the key edits the real
* composer, then hand the session to plain PTY echo until Enter or Ctrl+C,
+28
View File
@@ -597,6 +597,34 @@ describe('composer nav keys from the bar', () => {
const sentKeys = (fetchMock: { mock: { calls: unknown[][] } }) =>
fetchMock.mock.calls.map((call) => JSON.parse((call[1] as { body: string }).body).input);
it.each(['simple', 'extended'])('exposes Shift arrows in the %s agent layout', (mode) => {
const { bar, barElement } = loadBar('codex');
bar.setMode(mode);
expect(barElement.actions).toContain('shift-left');
expect(barElement.actions).toContain('shift-right');
});
it.each([
['shift-left', '\x1b[1;2D'],
['shift-right', '\x1b[1;2C'],
])('%s flushes the draft before navigation and hands editing to the PTY', (action, sequence) => {
const { bar, app, overlay, fetchMock } = barWithDraft('unfinished follow-up');
const events: string[] = [];
app.sendInput = vi.fn(() => events.push('draft'));
fetchMock.mockImplementation(() => {
events.push('key');
return Promise.resolve({ ok: true, catch: () => {} });
});
bar.handleAction(action);
expect(app.sendInput).toHaveBeenCalledWith('unfinished follow-up');
expect(events).toEqual(['draft', 'key']);
expect(sentKeys(fetchMock)).toEqual([sequence]);
expect(overlay.pendingText).toBe('');
expect([...(app._echoPassthroughSessions as Set<string>)]).toEqual(['session-1']);
});
it('flushes the unsent draft before sending the arrow', () => {
// On a phone the typed text lives in the overlay and has NEVER reached the
// PTY, so an arrow sent on its own arrives at a composer the CLI still
+12 -1
View File
@@ -471,7 +471,18 @@ describe('Virtual Keyboard', () => {
});
// Tab replaced /clear in the simple bar; /clear and /compact live in the
// extended bar only.
expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'tab', 'paste', 'esc', 'dismiss']);
expect(actions).toEqual([
'scroll-up',
'scroll-down',
'init',
'tab',
'shift-left',
'shift-right',
'paste',
'readmymind',
'esc',
'dismiss',
]);
});
it('double-tap confirm on /clear button', async () => {