mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
fix(mobile): route Claude terminal gestures
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user