mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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:
@@ -4,10 +4,12 @@
|
|||||||
* 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, /clear, /compact, paste, Esc, and dismiss.
|
* keyboard on mobile: arrow up/down, /init, Tab, 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
|
* The paste button opens a dialog that handles both text paste and image attach
|
||||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
||||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||||
@@ -432,7 +434,7 @@ const KeyboardAccessoryBar = {
|
|||||||
</svg>
|
</svg>
|
||||||
</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="clear" title="/clear">/clear</button>
|
<button class="accessory-btn" data-action="tab" title="Tab">Tab</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"/>
|
||||||
@@ -562,9 +564,26 @@ const KeyboardAccessoryBar = {
|
|||||||
case 'opt-enter':
|
case 'opt-enter':
|
||||||
this.sendKey('\x1b\r');
|
this.sendKey('\x1b\r');
|
||||||
break;
|
break;
|
||||||
case 'tab':
|
case 'tab': {
|
||||||
this.sendKey('\t');
|
// Tab means "complete what I just typed", but with local echo the typed
|
||||||
|
// text is still buffered in the overlay and has never reached the PTY —
|
||||||
|
// a bare \t would ask the CLI to complete an empty composer. Flush the
|
||||||
|
// pending text first (same steps as the Shift+Enter branch in
|
||||||
|
// terminal-ui.js), then send \t after the sendCommand settle delay.
|
||||||
|
const overlay = app._localEchoOverlay;
|
||||||
|
const pending = (app._localEchoEnabled && overlay?.pendingText) || '';
|
||||||
|
if (pending) {
|
||||||
|
overlay.clear();
|
||||||
|
overlay.suppressBufferDetection?.();
|
||||||
|
app._flushedOffsets?.delete(app.activeSessionId);
|
||||||
|
app._flushedTexts?.delete(app.activeSessionId);
|
||||||
|
app.sendInput(pending);
|
||||||
|
setTimeout(() => this.sendKey('\t'), 120);
|
||||||
|
} else {
|
||||||
|
this.sendKey('\t');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'shift-tab':
|
case 'shift-tab':
|
||||||
this.sendKey('\x1b[Z');
|
this.sendKey('\x1b[Z');
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -621,6 +621,16 @@ html.mobile-init .file-browser-panel {
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
|
||||||
|
let the pulsing green "working" dot read from arm's length. !important on
|
||||||
|
the glow because the skin block's no-halo rule (styles.css, nested under
|
||||||
|
html:not([data-skin="og"])) outranks any plain class rule here. */
|
||||||
|
.session-tab .tab-status.busy {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Truncate tab names more aggressively on mobile */
|
/* Truncate tab names more aggressively on mobile */
|
||||||
.session-tab .tab-name {
|
.session-tab .tab-name {
|
||||||
max-width: 50px;
|
max-width: 50px;
|
||||||
|
|||||||
@@ -469,7 +469,9 @@ describe('Virtual Keyboard', () => {
|
|||||||
(button) => (button as HTMLElement).dataset.action
|
(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 () => {
|
it('double-tap confirm on /clear button', async () => {
|
||||||
@@ -477,9 +479,11 @@ describe('Virtual Keyboard', () => {
|
|||||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||||
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
|
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(`
|
await page.evaluate(`
|
||||||
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
|
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
|
||||||
|
KeyboardAccessoryBar.setMode('extended');
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Click via JS since the button is positioned outside the viewport
|
// Click via JS since the button is positioned outside the viewport
|
||||||
@@ -503,6 +507,8 @@ describe('Virtual Keyboard', () => {
|
|||||||
return btn?.textContent?.trim();
|
return btn?.textContent?.trim();
|
||||||
});
|
});
|
||||||
expect(text).toBe('Tap again');
|
expect(text).toBe('Tap again');
|
||||||
|
|
||||||
|
await page.evaluate(`KeyboardAccessoryBar.setMode('simple');`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('double-tap expires after 2s', async () => {
|
it('double-tap expires after 2s', async () => {
|
||||||
@@ -511,6 +517,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
|
|
||||||
await page.evaluate(`
|
await page.evaluate(`
|
||||||
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
|
if (typeof app !== 'undefined') app.activeSessionId = 'test-session';
|
||||||
|
KeyboardAccessoryBar.setMode('extended');
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// First tap on clear via JS
|
// First tap on clear via JS
|
||||||
@@ -535,6 +542,8 @@ describe('Virtual Keyboard', () => {
|
|||||||
return btn?.classList.contains('confirming') ?? false;
|
return btn?.classList.contains('confirming') ?? false;
|
||||||
});
|
});
|
||||||
expect(afterExpiry).toBe(false);
|
expect(afterExpiry).toBe(false);
|
||||||
|
|
||||||
|
await page.evaluate(`KeyboardAccessoryBar.setMode('simple');`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dismiss button blurs active element', async () => {
|
it('dismiss button blurs active element', async () => {
|
||||||
|
|||||||
@@ -45,11 +45,12 @@ function loadKeyboardModule() {
|
|||||||
insertTerminalText: vi.fn(),
|
insertTerminalText: vi.fn(),
|
||||||
sendInput: vi.fn(),
|
sendInput: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const fetchMock = vi.fn(() => Promise.resolve({ ok: true }));
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
app,
|
app,
|
||||||
MobileDetection: { isTouchDevice: () => false },
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
URLSearchParams,
|
URLSearchParams,
|
||||||
fetch: vi.fn(),
|
fetch: fetchMock,
|
||||||
document: {},
|
document: {},
|
||||||
setTimeout: (fn: () => void) => {
|
setTimeout: (fn: () => void) => {
|
||||||
fn();
|
fn();
|
||||||
@@ -63,11 +64,66 @@ function loadKeyboardModule() {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
|
fetchMock,
|
||||||
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
|
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
|
||||||
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
|
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', () => {
|
describe('mobile filesystem picker actions', () => {
|
||||||
it('keeps clear-input separate from the destructive /clear command', () => {
|
it('keeps clear-input separate from the destructive /clear command', () => {
|
||||||
const { app, bar } = loadKeyboardModule();
|
const { app, bar } = loadKeyboardModule();
|
||||||
|
|||||||
Reference in New Issue
Block a user