fix(mobile): route Claude terminal gestures

This commit is contained in:
lior
2026-08-10 13:21:15 +03:00
parent 6831d79127
commit 92ae46246c
3 changed files with 234 additions and 3 deletions
+32 -1
View File
@@ -664,6 +664,14 @@ Object.assign(CodemanApp.prototype, {
isTouching = true;
didScroll = false;
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
const touchStartIntent = this._classifyMobileTerminalTap(touchLastX, touchLastY);
if (touchStartIntent !== 'input') {
// Cancel xterm/browser focus before the compatibility click can
// open the OS keyboard. Content taps are re-emitted as SGR on
// touchend; history taps deliberately remain inert.
ev.preventDefault();
this._blurMobileTerminalInput();
}
lastTime = 0;
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
@@ -671,7 +679,7 @@ Object.assign(CodemanApp.prototype, {
}
}
},
{ passive: true }
{ passive: false }
);
container.addEventListener(
@@ -690,6 +698,7 @@ Object.assign(CodemanApp.prototype, {
// fling, so a jittery tap would both position the cursor AND scroll.
if (!didScroll) return;
ev.preventDefault();
touchLastX = ev.touches[0].clientX;
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
velocity = delta * 1.2;
@@ -3368,11 +3377,13 @@ Object.assign(CodemanApp.prototype, {
const rows = Math.max(1, this.terminal.rows || 1);
const lines = [];
const wrappedRows = [];
let hasVisibleContent = false;
for (let row = 0; row < rows; row++) {
const line = buffer.getLine(buffer.viewportY + row);
const text = line?.translateToString?.(true) || '';
lines.push(text);
wrappedRows.push(Boolean(line?.isWrapped));
if (text.trim()) hasVisibleContent = true;
}
if (!hasVisibleContent) return 'input';
@@ -3403,6 +3414,17 @@ Object.assign(CodemanApp.prototype, {
}
const tappedRow = pos.row - 1;
let logicalLineStart = tappedRow;
while (logicalLineStart > 0 && wrappedRows[logicalLineStart]) logicalLineStart--;
let logicalLineEnd = tappedRow;
while (logicalLineEnd + 1 < rows && wrappedRows[logicalLineEnd + 1]) logicalLineEnd++;
const tappedLine = lines.slice(logicalLineStart, logicalLineEnd + 1).join('');
if (
mode === 'claude' &&
/^\s*[•·]\s*Working\b.*(?:background|esc to interrupt)/i.test(tappedLine)
) {
return 'content';
}
if (menuSelectionVisible) return 'content';
if (promptRow >= 0) {
const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow;
@@ -3652,6 +3674,15 @@ Object.assign(CodemanApp.prototype, {
return true;
},
// Claude keeps most transcript history inside its own TUI rather than xterm
// scrollback. On verified versions, route a touch drag through the same SGR
// wheel path as desktop. Codex keeps the existing local touch behavior.
_shouldForwardTouchScrollToApp() {
const session = this.sessions?.get(this.activeSessionId);
if (session?.mode !== 'claude') return false;
return this._shouldForwardWheelToApp({ shiftKey: false });
},
// Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer
// cell. Reports are coalesced into one fire-and-forget write per ~40ms: a
// trackpad emits dozens of wheel events per second and each send becomes a
+166
View File
@@ -799,6 +799,101 @@ describe('Virtual Keyboard', () => {
expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/);
});
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 = [];
@@ -982,6 +1077,77 @@ describe('Virtual Keyboard', () => {
expect(calls.some((lines) => lines !== 0)).toBe(true);
});
it('routes Claude touch drags to its transcript without moving local xterm history', async () => {
const result = await page.evaluate(async () => {
app.activeSessionId = 'mobile-claude-scroll-test';
app.sessions.set('mobile-claude-scroll-test', {
id: 'mobile-claude-scroll-test',
mode: 'claude',
cliVersion: '2.1.220',
status: 'running',
});
app.hideWelcome();
const sgrCalls: number[] = [];
const localCalls: number[] = [];
app._sendSyntheticSgrWheel = (_x: number, _y: number, lines: number) => {
sgrCalls.push(lines);
};
app.terminal.scrollLines = (lines: number) => {
localCalls.push(lines);
};
const target =
document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer');
if (!target) return { sgrCalls, localCalls };
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const startY = rect.top + Math.min(100, rect.height - 20);
const endY = startY + 100;
function createTouch(y: number) {
return new Touch({
identifier: 2,
target,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
}
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [createTouch(startY)],
changedTouches: [createTouch(startY)],
bubbles: true,
cancelable: true,
})
);
target.dispatchEvent(
new TouchEvent('touchmove', {
touches: [createTouch(endY)],
changedTouches: [createTouch(endY)],
bubbles: true,
cancelable: true,
})
);
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [],
changedTouches: [createTouch(endY)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 50));
return { sgrCalls, localCalls };
});
expect(result.sgrCalls.some((lines) => lines < 0)).toBe(true);
expect(result.localCalls).toEqual([]);
});
it('keeps typed phone text in the terminal local echo path', async () => {
await page.evaluate(() => {
window.__sentInputs = [];
+36 -2
View File
@@ -75,7 +75,7 @@ function createElementHarness() {
};
}
function createTerminalGrid(lines: string[], cursorY: number) {
function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new Set<number>()) {
const textarea = {
classList: { contains: (name: string) => name === 'xterm-helper-textarea' },
blur: vi.fn(),
@@ -90,7 +90,9 @@ function createTerminalGrid(lines: string[], cursorY: number) {
baseY: 0,
cursorY,
getLine: (row: number) =>
row >= 0 && row < lines.length ? { translateToString: () => lines[row] } : undefined,
row >= 0 && row < lines.length
? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] }
: undefined,
},
},
element: {
@@ -131,6 +133,19 @@ describe('terminal touch tap mouse guard', () => {
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('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
@@ -518,6 +533,25 @@ describe('terminal touch tap mouse guard', () => {
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
it('touch: forwards verified Claude transcript scrolling but keeps Codex touch in local history', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
};
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]);
expect(app._shouldForwardTouchScrollToApp()).toBe(true);
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
expect(app._shouldForwardTouchScrollToApp()).toBe(false);
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
expect(app._shouldForwardTouchScrollToApp()).toBe(false);
});
it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';