Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/web/public/terminal-ui.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -3595,6 +3595,20 @@ Object.assign(CodemanApp.prototype, {
// A synthetic xterm click can focus its helper textarea. Blur after the
// report so collapsing a readback never opens or retains the keyboard.
this._blurMobileTerminalInput();
} else if (intent === 'content' && startedWithTerminalFocus) {
// Tapping INERT transcript with the keyboard already up closes it.
//
// Every terminal tap re-focuses, so once the keyboard is open the only way
// to close it is the accessory bar's dismiss chevron. Tapping the
// transcript to get the screen back is the obvious gesture, and nothing
// else claims it: an inert row has no action to trigger, so by this point
// the tap has already done its only other job (the mouse report above).
//
// Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps
// focus-then-position, so a second tap there still places the caret —
// pinned by "keeps the first prompt tap focus-only so it cannot activate a
// CLI row". Toggling there would trade away real capability.
this._blurMobileTerminalInput();
} else {
this._focusMobileTerminalInput();
}
Expand Down
8 changes: 6 additions & 2 deletions test/mobile/keyboard.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -890,7 +890,7 @@ describe('Virtual Keyboard', () => {
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 () => {
it('toggles the keyboard shut on a second inert Claude transcript tap', async () => {
const point = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-claude-transcript-tap-test';
Expand DownExpand Up@@ -941,8 +941,12 @@ describe('Virtual Keyboard', () => {

await page.touchscreen.tap(point!.x, point!.y);

// The setup above leaves the terminal focused, so this tap is the SECOND
// one on an inert row — the case that now closes the keyboard. Previously
// it re-focused, which left the accessory bar's chevron as the only way to
// dismiss. The prompt row is unaffected and still positions the caret.
const activeClass = await page.evaluate(() => document.activeElement?.className);
expect(activeClass).toContain('xterm-helper-textarea');
expect(activeClass).not.toContain('xterm-helper-textarea');
});

it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => {
Expand Down
37 changes: 37 additions & 0 deletions test/terminal-touch-tap.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -199,6 +199,43 @@ describe('terminal touch tap mouse guard', () => {
expect(app.terminal.focus).toHaveBeenCalledOnce();
});

it('closes the keyboard on a second tap of INERT transcript content', () => {
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
app._sendInputAsync = vi.fn();

// Keyboard DOWN: the tap opens it.
setActiveElement(null);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false)).toBe('content');
expect(app.terminal.focus).toHaveBeenCalledOnce();
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();

// Keyboard UP on the same inert row: the tap closes it.
app.terminal.focus.mockClear();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, true)).toBe('content');
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
expect(app.terminal.focus).not.toHaveBeenCalled();
});

it('keeps the prompt row focusing rather than toggling, so the caret can still be placed', () => {
// The toggle is scoped to 'content' on purpose: a second tap on the PROMPT
// must still position the cursor. This is the guarantee that makes the
// change safe to make, so it is pinned separately.
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ask', ''], 4);
app._sendInputAsync = vi.fn();

setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, true)).toBe('input');
expect(app.terminal.textarea.blur).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();
Expand Down