feat(mobile): bigger green working dot on tabs; Tab key replaces /clear in the simple keyboard bar

The working dot is the one glance-state a phone needs: busy tabs now get a
9px pulsing dot with a green glow (idle stays 4px). The glow needs !important
because the skin block's no-halo rule outranks mobile.css.

The simple keyboard accessory bar swaps /clear for Tab (/clear and /compact
stay in the extended bar with their double-tap confirm). The tab action now
flushes locally-buffered prompt text to the PTY before sending \t, so
completion applies to what was just typed instead of an empty composer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-09 17:32:24 +02:00
parent 6fc772f697
commit 3533c4332b
4 changed files with 102 additions and 8 deletions
+11 -2
View File
@@ -469,7 +469,9 @@ describe('Virtual Keyboard', () => {
(button) => (button as HTMLElement).dataset.action
);
});
expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'dismiss']);
// 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']);
});
it('double-tap confirm on /clear button', async () => {
@@ -477,9 +479,11 @@ describe('Virtual Keyboard', () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
// handleAction() early-returns if app.activeSessionId is falsy — mock it
// handleAction() early-returns if app.activeSessionId is falsy — mock it.
// /clear only exists in the extended bar now, so switch modes first.
await page.evaluate(`
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
KeyboardAccessoryBar.setMode('extended');
`);
// Click via JS since the button is positioned outside the viewport
@@ -503,6 +507,8 @@ describe('Virtual Keyboard', () => {
return btn?.textContent?.trim();
});
expect(text).toBe('Tap again');
await page.evaluate(`KeyboardAccessoryBar.setMode('simple');`);
});
it('double-tap expires after 2s', async () => {
@@ -511,6 +517,7 @@ describe('Virtual Keyboard', () => {
await page.evaluate(`
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
KeyboardAccessoryBar.setMode('extended');
`);
// First tap on clear via JS
@@ -535,6 +542,8 @@ describe('Virtual Keyboard', () => {
return btn?.classList.contains('confirming') ?? false;
});
expect(afterExpiry).toBe(false);
await page.evaluate(`KeyboardAccessoryBar.setMode('simple');`);
});
it('dismiss button blurs active element', async () => {
+57 -1
View File
@@ -45,11 +45,12 @@ function loadKeyboardModule() {
insertTerminalText: vi.fn(),
sendInput: vi.fn(),
};
const fetchMock = vi.fn(() => Promise.resolve({ ok: true }));
const context = vm.createContext({
app,
MobileDetection: { isTouchDevice: () => false },
URLSearchParams,
fetch: vi.fn(),
fetch: fetchMock,
document: {},
setTimeout: (fn: () => void) => {
fn();
@@ -63,11 +64,66 @@ function loadKeyboardModule() {
);
return {
app,
fetchMock,
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
};
}
describe('accessory Tab key', () => {
it('replaced /clear in the simple bar; /clear stays extended-only', () => {
const simple = keyboardSource.slice(
keyboardSource.indexOf('_simpleButtons'),
keyboardSource.indexOf('_extendedButtons')
);
expect(simple).toContain('data-action="tab"');
expect(simple).not.toContain('data-action="clear" title="/clear"');
expect(simple).not.toContain('data-action="compact"');
});
it('sends a bare \\t when nothing is buffered locally', () => {
const { app, bar, fetchMock } = loadKeyboardModule();
bar.handleAction('tab');
expect(app.sendInput).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/sessions/session-1/input');
expect(JSON.parse(init.body)).toEqual({ input: '\t' });
});
it('flushes locally-buffered prompt text to the PTY before sending Tab', () => {
const { app, bar, fetchMock } = loadKeyboardModule() as ReturnType<typeof loadKeyboardModule> & {
app: Record<string, unknown>;
};
const overlay = {
pendingText: 'git sta',
clear: vi.fn(),
suppressBufferDetection: vi.fn(),
};
Object.assign(app, {
_localEchoEnabled: true,
_localEchoOverlay: overlay,
_flushedOffsets: new Map([['session-1', 3]]),
_flushedTexts: new Map([['session-1', 'git']]),
});
bar.handleAction('tab');
expect(overlay.clear).toHaveBeenCalledOnce();
expect(overlay.suppressBufferDetection).toHaveBeenCalledOnce();
expect((app as { _flushedOffsets: Map<string, number> })._flushedOffsets.has('session-1')).toBe(false);
expect((app as { _flushedTexts: Map<string, string> })._flushedTexts.has('session-1')).toBe(false);
expect(app.sendInput).toHaveBeenCalledWith('git sta');
expect(fetchMock).toHaveBeenCalledOnce();
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ input: '\t' });
// Text must reach the PTY before the completion request.
const sendInputOrder = (app.sendInput as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
const fetchOrder = fetchMock.mock.invocationCallOrder[0];
expect(sendInputOrder).toBeLessThan(fetchOrder);
});
});
describe('mobile filesystem picker actions', () => {
it('keeps clear-input separate from the destructive /clear command', () => {
const { app, bar } = loadKeyboardModule();