mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Merge pull request #244 from Lint111/feat/mobile-terminal-taps
fix(mobile): route terminal taps without breaking keyboard focus
This commit is contained in:
@@ -845,6 +845,283 @@ describe('Virtual Keyboard', () => {
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('collapses a terminal readback without focusing the hidden textarea', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-readback-tap-test';
|
||||
app.sessions.set('mobile-readback-tap-test', {
|
||||
id: 'mobile-readback-tap-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve)
|
||||
);
|
||||
app.terminal.focus();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height / 2,
|
||||
};
|
||||
});
|
||||
expect(point).not.toBeNull();
|
||||
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).not.toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toHaveLength(1);
|
||||
expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/);
|
||||
});
|
||||
|
||||
it('keeps the hidden keyboard input focused after an inert Claude transcript tap', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-claude-transcript-tap-test';
|
||||
app.sessions.set('mobile-claude-transcript-tap-test', {
|
||||
id: 'mobile-claude-transcript-tap-test',
|
||||
mode: 'claude',
|
||||
cliVersion: '2.1.220',
|
||||
status: 'working',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write(
|
||||
'Transcript row one\r\nTranscript row two\r\nTranscript row three\r\nTranscript row four\r\nTranscript row five\r\nTranscript row six\r\nTranscript row seven\r\nTranscript row eight\r\nTranscript row nine\r\nTranscript row ten\r\n\r\n❯ ',
|
||||
resolve
|
||||
)
|
||||
);
|
||||
app.terminal.focus();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!screen || !rect || !cell?.width || !cell?.height) return null;
|
||||
const cursorRow = app.terminal.buffer.active.cursorY;
|
||||
const transcriptRow = Math.max(1, Math.floor(cursorRow / 2));
|
||||
const x = rect.left + cell.width * 2;
|
||||
const y = rect.top + cell.height * (transcriptRow + 0.5);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
intent: app._classifyMobileTerminalTap(x, y),
|
||||
activeClass: document.activeElement?.className,
|
||||
};
|
||||
});
|
||||
expect(point).toEqual(
|
||||
expect.objectContaining({
|
||||
intent: 'content',
|
||||
activeClass: expect.stringContaining('xterm-helper-textarea'),
|
||||
})
|
||||
);
|
||||
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
const activeClass = await page.evaluate(() => document.activeElement?.className);
|
||||
expect(activeClass).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-claude-subagent-tap-test';
|
||||
app.sessions.set('mobile-claude-subagent-tap-test', {
|
||||
id: 'mobile-claude-subagent-tap-test',
|
||||
mode: 'claude',
|
||||
cliVersion: '2.1.220',
|
||||
status: 'working',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
app.terminal.reset();
|
||||
const statusRow = Math.max(0, app.terminal.rows - 2);
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write(
|
||||
`${'\r\n'.repeat(statusRow)}• Working (1m 50s • esc to interrupt) · 1 background teammate`,
|
||||
resolve
|
||||
)
|
||||
);
|
||||
app.terminal.focus();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!screen || !rect || !cell?.width || !cell?.height) return null;
|
||||
const cursorRow = app.terminal.buffer.active.cursorY;
|
||||
const x = rect.left + cell.width * 2;
|
||||
const y = rect.top + cell.height * (cursorRow + 0.5);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
intent: app._classifyMobileTerminalTap(x, y),
|
||||
cursorRow,
|
||||
screenBottom: rect.bottom,
|
||||
};
|
||||
});
|
||||
expect(point).toEqual(
|
||||
expect.objectContaining({
|
||||
intent: 'content',
|
||||
})
|
||||
);
|
||||
|
||||
const dispatch = await page.evaluate(({ x, y }) => {
|
||||
const target = document.querySelector('#terminalContainer .xterm-screen');
|
||||
if (!(target instanceof Element)) {
|
||||
return { prevented: false, insideTerminal: false, targetClass: null };
|
||||
}
|
||||
const touch = new Touch({
|
||||
identifier: 3,
|
||||
target,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pageX: x,
|
||||
pageY: y,
|
||||
});
|
||||
const allowed = target.dispatchEvent(
|
||||
new TouchEvent('touchstart', {
|
||||
touches: [touch],
|
||||
changedTouches: [touch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new TouchEvent('touchend', {
|
||||
touches: [],
|
||||
changedTouches: [touch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
return {
|
||||
prevented: !allowed,
|
||||
insideTerminal: Boolean(target.closest('#terminalContainer')),
|
||||
targetClass: target.className,
|
||||
};
|
||||
}, point!);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(dispatch).toEqual(
|
||||
expect.objectContaining({
|
||||
prevented: true,
|
||||
insideTerminal: true,
|
||||
})
|
||||
);
|
||||
expect(state.activeClass).not.toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('focuses the terminal helper textarea when the visible prompt is tapped', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-focus-visible-input-test';
|
||||
app.sessions.set('mobile-focus-visible-input-test', {
|
||||
id: 'mobile-focus-visible-input-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve)
|
||||
);
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
|
||||
};
|
||||
});
|
||||
expect(point).not.toBeNull();
|
||||
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('focuses the live Claude cursor when a redraw omits the prompt glyph', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-focus-promptless-claude-test';
|
||||
app.sessions.set('mobile-focus-promptless-claude-test', {
|
||||
id: 'mobile-focus-promptless-claude-test',
|
||||
mode: 'claude',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
app.terminal.reset();
|
||||
await new Promise<void>((resolve) => app.terminal.write('Claude response\r\nready for input', resolve));
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
|
||||
};
|
||||
});
|
||||
expect(point).not.toBeNull();
|
||||
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
|
||||
const calls = await page.evaluate(async () => {
|
||||
app.activeSessionId = 'mobile-touch-scroll-test';
|
||||
|
||||
@@ -6,8 +6,17 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
function loadTerminalUiHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
let now = 1_000;
|
||||
let keyboardVisible = false;
|
||||
let activeElement: unknown = null;
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
document: {
|
||||
body: { classList: { contains: () => false } },
|
||||
get activeElement() {
|
||||
return activeElement;
|
||||
},
|
||||
getElementById: () => null,
|
||||
},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
@@ -25,6 +34,11 @@ function loadTerminalUiHarness() {
|
||||
MobileDetection: {
|
||||
isTouchDevice: () => true,
|
||||
},
|
||||
KeyboardHandler: {
|
||||
get keyboardVisible() {
|
||||
return keyboardVisible;
|
||||
},
|
||||
},
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
@@ -38,6 +52,12 @@ function loadTerminalUiHarness() {
|
||||
setNow: (value: number) => {
|
||||
now = value;
|
||||
},
|
||||
setKeyboardVisible: (visible: boolean) => {
|
||||
keyboardVisible = visible;
|
||||
},
|
||||
setActiveElement: (element: unknown) => {
|
||||
activeElement = element;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +75,130 @@ function createElementHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new Set<number>()) {
|
||||
const textarea = {
|
||||
classList: { contains: (name: string) => name === 'xterm-helper-textarea' },
|
||||
blur: vi.fn(),
|
||||
};
|
||||
return {
|
||||
cols: 80,
|
||||
rows: lines.length,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: 0,
|
||||
baseY: 0,
|
||||
cursorY,
|
||||
getLine: (row: number) =>
|
||||
row >= 0 && row < lines.length
|
||||
? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
element: {
|
||||
querySelector: (selector: string) =>
|
||||
selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null,
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
textarea,
|
||||
focus: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('terminal touch tap mouse guard', () => {
|
||||
it('recognizes focus only when a terminal input owns the active element', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
const textarea = { classList: { contains: () => true } };
|
||||
app.terminal = { textarea };
|
||||
|
||||
setActiveElement(null);
|
||||
expect(app._isMobileTerminalInputFocused()).toBe(false);
|
||||
|
||||
setActiveElement(textarea);
|
||||
expect(app._isMobileTerminalInputFocused()).toBe(true);
|
||||
});
|
||||
|
||||
it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 1)).toBe('content'); // inline marker is not a prompt
|
||||
expect(app._classifyMobileTerminalTap(9, 17)).toBe('content'); // row 2: readback
|
||||
expect(app._classifyMobileTerminalTap(9, 65)).toBe('input'); // row 5: prompt
|
||||
expect(app._classifyMobileTerminalTap(9, 81)).toBe('content'); // row 6: status
|
||||
});
|
||||
|
||||
it('classifies Claude background-agent status as content rather than keyboard input', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''],
|
||||
4,
|
||||
new Set([4])
|
||||
);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 65)).toBe('content');
|
||||
});
|
||||
|
||||
it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('input');
|
||||
expect(app._classifyMobileTerminalTap(9, 1)).toBe('content');
|
||||
});
|
||||
|
||||
it('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('content');
|
||||
expect(app._classifyMobileTerminalTap(9, 49)).toBe('content');
|
||||
});
|
||||
|
||||
it('collapses TUI readback content without opening or retaining the keyboard', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
);
|
||||
app._sendInputAsync = vi.fn();
|
||||
setActiveElement(app.terminal.textarea);
|
||||
|
||||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 17 }, true)).toBe('content');
|
||||
expect(app._sendInputAsync).toHaveBeenCalledWith('sess-1', '\x1b[<0;2;2M\x1b[<0;2;2m');
|
||||
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
|
||||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the first prompt tap focus-only so it cannot activate a CLI row', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
);
|
||||
app._sendInputAsync = vi.fn();
|
||||
setActiveElement(null);
|
||||
|
||||
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, false)).toBe('input');
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
|
||||
Reference in New Issue
Block a user