Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-codex-shift-arrows.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions src/web/public/keyboard-accessory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"/>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions test/mobile-shell-keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion test/mobile/keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down