mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
fix(mobile): keep promptless terminal input focusable
This commit is contained in:
@@ -53,6 +53,7 @@
|
||||
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
|
||||
// cap: a fling must not build a backlog that keeps paging after it stops.
|
||||
const PAGE_KEY_MAX_PER_BATCH = 3;
|
||||
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
|
||||
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
|
||||
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
|
||||
// H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional
|
||||
@@ -180,6 +181,7 @@
|
||||
KEY_PAGE_DOWN,
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
@@ -3429,10 +3431,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (promptRow >= 0) {
|
||||
const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow;
|
||||
if (tappedRow >= promptRow && tappedRow <= inputEnd) return 'input';
|
||||
} else if (tappedRow === cursorRow && cursorRow >= rows - 3) {
|
||||
// During redraws a CLI can temporarily omit its prompt marker. Keep the
|
||||
// live cursor row usable without turning arbitrary transcript rows into
|
||||
// keyboard targets.
|
||||
} else if (
|
||||
tappedRow === cursorRow ||
|
||||
tappedRow >=
|
||||
Math.max(
|
||||
0,
|
||||
rows -
|
||||
window.CodemanTerminalInput
|
||||
.TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM
|
||||
)
|
||||
) {
|
||||
// During redraws a CLI can temporarily omit its prompt marker or place
|
||||
// the cursor above a status footer. Keep the live cursor and a stable
|
||||
// lower-screen focus band usable without turning transcript rows above
|
||||
// that band into keyboard targets.
|
||||
return 'input';
|
||||
}
|
||||
|
||||
|
||||
+43
-239
@@ -754,188 +754,25 @@ describe('Virtual Keyboard', () => {
|
||||
expect(state?.bodyClass).toBe(false);
|
||||
});
|
||||
|
||||
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('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 = [];
|
||||
it('focuses the terminal helper textarea when the terminal is tapped', async () => {
|
||||
await page.evaluate(() => {
|
||||
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);
|
||||
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
const activeClass = await page.evaluate(() => document.activeElement?.className);
|
||||
expect(activeClass).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
|
||||
@@ -1008,6 +845,44 @@ describe('Virtual Keyboard', () => {
|
||||
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';
|
||||
@@ -1077,77 +952,6 @@ 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 = [];
|
||||
|
||||
@@ -146,6 +146,16 @@ describe('terminal touch tap mouse guard', () => {
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user