mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(mobile): add Shift arrow keys for Codex prompt navigation
This commit is contained in:
@@ -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.
|
||||||
@@ -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** — `/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
|
- **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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 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, 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
|
* 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.
|
* 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
|
||||||
@@ -492,6 +492,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" 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"/>
|
||||||
@@ -577,6 +579,8 @@ const KeyboardAccessoryBar = {
|
|||||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ 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" 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>
|
||||||
@@ -615,7 +619,7 @@ const KeyboardAccessoryBar = {
|
|||||||
this.handleAction(action, btn);
|
this.handleAction(action, btn);
|
||||||
|
|
||||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
// 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) ||
|
if (refocusActions.has(action) ||
|
||||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||||
if (typeof app !== 'undefined' && app.terminal) {
|
if (typeof app !== 'undefined' && app.terminal) {
|
||||||
@@ -744,6 +748,12 @@ const KeyboardAccessoryBar = {
|
|||||||
case 'arrow-right':
|
case 'arrow-right':
|
||||||
this.sendNavKey('\x1b[C');
|
this.sendNavKey('\x1b[C');
|
||||||
break;
|
break;
|
||||||
|
case 'shift-left':
|
||||||
|
this.sendNavKey('\x1b[1;2D');
|
||||||
|
break;
|
||||||
|
case 'shift-right':
|
||||||
|
this.sendNavKey('\x1b[1;2C');
|
||||||
|
break;
|
||||||
case 'esc':
|
case 'esc':
|
||||||
this.sendKey('\x1b');
|
this.sendKey('\x1b');
|
||||||
break;
|
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
|
* 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
|
* 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,
|
* composer, then hand the session to plain PTY echo until Enter or Ctrl+C,
|
||||||
|
|||||||
@@ -597,6 +597,34 @@ describe('composer nav keys from the bar', () => {
|
|||||||
const sentKeys = (fetchMock: { mock: { calls: unknown[][] } }) =>
|
const sentKeys = (fetchMock: { mock: { calls: unknown[][] } }) =>
|
||||||
fetchMock.mock.calls.map((call) => JSON.parse((call[1] as { body: string }).body).input);
|
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', () => {
|
it('flushes the unsent draft before sending the arrow', () => {
|
||||||
// On a phone the typed text lives in the overlay and has NEVER reached the
|
// 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
|
// PTY, so an arrow sent on its own arrives at a composer the CLI still
|
||||||
|
|||||||
@@ -471,7 +471,18 @@ describe('Virtual Keyboard', () => {
|
|||||||
});
|
});
|
||||||
// Tab replaced /clear in the simple bar; /clear and /compact live in the
|
// Tab replaced /clear in the simple bar; /clear and /compact live in the
|
||||||
// extended bar only.
|
// 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 () => {
|
it('double-tap confirm on /clear button', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user