mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +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
|
// 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.
|
// cap: a fling must not build a backlog that keeps paging after it stops.
|
||||||
const PAGE_KEY_MAX_PER_BATCH = 3;
|
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
|
// 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
|
// 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
|
// H/F, CSI 1~/4~), Insert/Delete/PgUp/PgDn (CSI 2~/3~/5~/6~, optional
|
||||||
@@ -180,6 +181,7 @@
|
|||||||
KEY_PAGE_DOWN,
|
KEY_PAGE_DOWN,
|
||||||
PAGE_KEY_SCREEN_FRACTION,
|
PAGE_KEY_SCREEN_FRACTION,
|
||||||
PAGE_KEY_MAX_PER_BATCH,
|
PAGE_KEY_MAX_PER_BATCH,
|
||||||
|
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||||
};
|
};
|
||||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||||
@@ -3429,10 +3431,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (promptRow >= 0) {
|
if (promptRow >= 0) {
|
||||||
const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow;
|
const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow;
|
||||||
if (tappedRow >= promptRow && tappedRow <= inputEnd) return 'input';
|
if (tappedRow >= promptRow && tappedRow <= inputEnd) return 'input';
|
||||||
} else if (tappedRow === cursorRow && cursorRow >= rows - 3) {
|
} else if (
|
||||||
// During redraws a CLI can temporarily omit its prompt marker. Keep the
|
tappedRow === cursorRow ||
|
||||||
// live cursor row usable without turning arbitrary transcript rows into
|
tappedRow >=
|
||||||
// keyboard targets.
|
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';
|
return 'input';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
-239
@@ -754,188 +754,25 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(state?.bodyClass).toBe(false);
|
expect(state?.bodyClass).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collapses a terminal readback without focusing the hidden textarea', async () => {
|
it('focuses the terminal helper textarea when the terminal is tapped', async () => {
|
||||||
const point = await page.evaluate(async () => {
|
await page.evaluate(() => {
|
||||||
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 = [];
|
|
||||||
app.activeSessionId = 'mobile-focus-visible-input-test';
|
app.activeSessionId = 'mobile-focus-visible-input-test';
|
||||||
app.sessions.set('mobile-focus-visible-input-test', {
|
app.sessions.set('mobile-focus-visible-input-test', {
|
||||||
id: 'mobile-focus-visible-input-test',
|
id: 'mobile-focus-visible-input-test',
|
||||||
mode: 'codex',
|
mode: 'codex',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
});
|
});
|
||||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
|
||||||
window.__sentInputs.push(input);
|
|
||||||
};
|
|
||||||
app.hideWelcome();
|
app.hideWelcome();
|
||||||
const settings = app.loadAppSettingsFromStorage();
|
const settings = app.loadAppSettingsFromStorage();
|
||||||
settings.cjkInputEnabled = false;
|
settings.cjkInputEnabled = false;
|
||||||
app.saveAppSettingsToStorage(settings);
|
app.saveAppSettingsToStorage(settings);
|
||||||
app._updateCjkInputState();
|
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(() => ({
|
const activeClass = await page.evaluate(() => document.activeElement?.className);
|
||||||
activeClass: document.activeElement?.className,
|
expect(activeClass).toContain('xterm-helper-textarea');
|
||||||
sentInputs: window.__sentInputs,
|
|
||||||
}));
|
|
||||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
|
||||||
expect(state.sentInputs).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
|
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
|
||||||
@@ -1008,6 +845,44 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(state.sentInputs).toEqual([]);
|
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 () => {
|
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
|
||||||
const calls = await page.evaluate(async () => {
|
const calls = await page.evaluate(async () => {
|
||||||
app.activeSessionId = 'mobile-touch-scroll-test';
|
app.activeSessionId = 'mobile-touch-scroll-test';
|
||||||
@@ -1077,77 +952,6 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(calls.some((lines) => lines !== 0)).toBe(true);
|
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 () => {
|
it('keeps typed phone text in the terminal local echo path', async () => {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__sentInputs = [];
|
window.__sentInputs = [];
|
||||||
|
|||||||
@@ -146,6 +146,16 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
expect(app._classifyMobileTerminalTap(9, 65)).toBe('content');
|
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', () => {
|
it('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
|
|||||||
Reference in New Issue
Block a user