feat(mobile): shell keyboard bar with a one-shot Ctrl modifier (#262)

The mobile accessory bar was built around coding-agent commands, so a shell
session had no way to send Ctrl chords at all.

A shell-mode session now gets its own bar automatically: Ctrl, Esc, Tab,
four arrows, paste, dismiss. Agent sessions (claude, codex, opencode,
gemini, antigravity) keep the existing bar unchanged.

Ctrl is a one-shot modifier: tap it and it lights up, the next character
typed on the system keyboard is sent as its control byte, and Ctrl disarms.
Tapping it again cancels. That puts Ctrl+C/D/Z/R/L/A/E/W/U/K on a
nine-button bar without a button per chord.

Implementation notes:

* The interception lives in terminal.onData, not a keydown handler: a
  virtual keyboard reports no usable key events, so the character only
  exists as onData text. It sits after shouldSuppressTerminalQueryResponse
  (xterm answers DA/CPR queries through onData too, and letting one of those
  spend the modifier would silently eat the user's Ctrl) and before every
  send path, so the control byte follows the normal control-char route.
* ctrlByteFor() maps `code & 0x1f` over @A-Z[\]^_ and a-z, plus
  Ctrl+Space = NUL and Ctrl+? = DEL. Characters with no control equivalent
  pass through unchanged, like a hardware keyboard.
* The bar now separates the base layout (the extendedKeyboardBar setting)
  from the effective one, resolved per session by refreshForActiveSession().
  A settings save during a shell session cannot yank the bar away, and
  switching back to an agent tab restores the user's choice.
* Ctrl disarms on use, a second tap, any other accessory key, a session
  switch, keyboard dismissal and a layout swap.
* Ctrl joins the refocus set, so tapping it keeps the terminal focused and
  the keyboard open.
* The armed style needs three classes to outrank mobile.css's light-skin
  .accessory-btn rule at (0,2,1).

Verified end to end against a real shell session on an isolated instance:
tapping Ctrl then typing c interrupted a running `sleep 300` (^C in the
pane), the modifier disarmed, plain typing stayed literal, Ctrl+L cleared,
and a cancelled Ctrl typed a literal c.

Tests: test/mobile-shell-keyboard.test.ts (new, runs in CI) covers the
mapping table, layout selection per session mode, base-mode memory and every
disarm path; test/mobile/keyboard.test.ts adds nine browser regressions that
drive the real xterm with page.keyboard.type() and assert on the bytes that
would go out.

Closes #262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-10 03:09:44 +02:00
parent c942bb5dfb
commit 9b9f2c21e9
8 changed files with 728 additions and 7 deletions
+153
View File
@@ -1048,6 +1048,159 @@ describe('Virtual Keyboard', () => {
});
});
// ── Shell keyboard bar + one-shot Ctrl (issue #262) ───────────────────
//
// The bar swaps layouts per session mode, and Ctrl is a one-shot modifier
// applied to the next character typed on the SYSTEM keyboard, which on a
// phone reaches the app as xterm onData text, not a key event. These drive
// the real xterm instance with page.keyboard.type() and assert on what would
// go out on the wire (_sendInputAsync), not on DOM state alone.
describe('Shell keyboard bar', () => {
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
afterAll(async () => {
await context.close();
});
/** Point the app at a fake session of `mode` and re-resolve the bar. */
async function activateSession(mode: string, id = 'kb-shell-1'): Promise<void> {
await page.evaluate(`(function (id, mode) {
app.sessions.set(id, { id, name: id, status: 'idle', mode, workingDir: '/tmp' });
app.activeSessionId = id;
KeyboardAccessoryBar.show();
KeyboardAccessoryBar.refreshForActiveSession();
})('${id}', '${mode}')`);
}
/** Capture what the terminal would send, while typing on the real keyboard. */
async function typeAndCapture(text: string): Promise<string[]> {
await page.evaluate(`(function () {
window.__sent = [];
if (!app.__origSend) app.__origSend = app._sendInputAsync;
app._sendInputAsync = function (sessionId, input) { window.__sent.push(input); };
app.terminal.focus();
})()`);
await page.keyboard.type(text);
await page.waitForTimeout(200);
const sent = (await page.evaluate(`window.__sent`)) as string[];
await page.evaluate(`(function () { app._sendInputAsync = app.__origSend; })()`);
return sent;
}
async function tapCtrl(): Promise<void> {
await page.evaluate(`document.querySelector('.keyboard-accessory-bar [data-action="ctrl"]').click()`);
}
it('shows the terminal bar for shell sessions', async () => {
await activateSession('shell');
const actions = await page.evaluate(() =>
Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
(button) => (button as HTMLElement).dataset.action
)
);
expect(actions).toEqual([
'ctrl',
'esc',
'tab',
'scroll-up',
'scroll-down',
'arrow-left',
'arrow-right',
'paste',
'dismiss',
]);
});
it('keeps the command bar for agent sessions', async () => {
await activateSession('claude', 'kb-agent-1');
const actions = await page.evaluate(() =>
Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
(button) => (button as HTMLElement).dataset.action
)
);
expect(actions).toContain('init');
expect(actions).not.toContain('ctrl');
});
it('sends Ctrl+C for the next typed character and disarms', async () => {
await activateSession('shell');
await tapCtrl();
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
expect(await typeAndCapture('c')).toEqual(['\x03']);
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
// The very next keystroke is a literal c again.
expect(await typeAndCapture('c')).toEqual(['c']);
});
it('sends Ctrl+D for the next typed character', async () => {
await activateSession('shell');
await tapCtrl();
expect(await typeAndCapture('d')).toEqual(['\x04']);
});
it('cancels on a second tap of Ctrl', async () => {
await activateSession('shell');
await tapCtrl();
await tapCtrl();
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
expect(await typeAndCapture('c')).toEqual(['c']);
});
it('shows the armed state and keeps the terminal focused', async () => {
await activateSession('shell');
await tapCtrl();
const state = await page.evaluate(() => {
const button = document.querySelector('.keyboard-accessory-bar [data-action="ctrl"]') as HTMLElement;
const style = getComputedStyle(button);
return {
armed: button.classList.contains('armed'),
pressed: button.getAttribute('aria-pressed'),
background: style.backgroundColor,
focusedTerminal: document.activeElement === (app.terminal as { textarea: Element }).textarea,
};
});
expect(state.armed).toBe(true);
expect(state.pressed).toBe('true');
// Armed styling must actually land (three-class rule beating the skin
// overrides): an invisible modifier is worse than none.
expect(state.background).not.toBe('rgba(0, 0, 0, 0)');
expect(state.focusedTerminal).toBe(true);
});
it('drops the armed modifier when switching sessions', async () => {
await activateSession('shell');
await tapCtrl();
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(true);
await activateSession('shell', 'kb-shell-2');
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
expect(await typeAndCapture('c')).toEqual(['c']);
});
it('drops the armed modifier when the keyboard is dismissed', async () => {
await activateSession('shell');
await tapCtrl();
await page.evaluate(`KeyboardAccessoryBar.hide()`);
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
});
it('spends the modifier on another accessory key instead of the next keystroke', async () => {
await activateSession('shell');
await tapCtrl();
await page.evaluate(`document.querySelector('.keyboard-accessory-bar [data-action="esc"]').click()`);
expect(await page.evaluate(`KeyboardAccessoryBar.isCtrlArmed()`)).toBe(false);
expect(await typeAndCapture('c')).toEqual(['c']);
});
});
// ── Cross-device keyboard behavior ────────────────────────────────────
describe('Cross-device keyboard behavior', () => {