diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0822e265bb7..1b6c3680696 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -103,6 +103,10 @@ import { isTerminalLiveInputWithinByteLimit, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import { + getTerminalCommandKeyboardType, + getTerminalLiveInputKeyboardType +} from '../../../../src/terminal/terminal-keyboard-type' import { normalizeTerminalTextInput } from '../../../../src/terminal/terminal-text-input-normalization' import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input' import { MobileBrowserPane } from '../../../../src/browser/MobileBrowserPane' @@ -4824,7 +4828,7 @@ export default function SessionScreen() { autoCorrect={false} spellCheck={false} smartInsertDelete={false} - keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'} + keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)} returnKeyType="default" blurOnSubmit={false} editable={canSend} @@ -4855,15 +4859,12 @@ export default function SessionScreen() { autoCorrect={autocompleteEnabled} spellCheck={autocompleteEnabled} smartInsertDelete={false} - // Why: the default keyboard exposes autocomplete/autocorrect; - // ascii-capable (iOS) / visible-password (Android) suppress it. - keyboardType={ + // Why: Android's default keyboard is required for CJK IME + // composition; iOS can still use ASCII when autocomplete is off. + keyboardType={getTerminalCommandKeyboardType( + Platform.OS, autocompleteEnabled - ? 'default' - : Platform.OS === 'ios' - ? 'ascii-capable' - : 'visible-password' - } + )} returnKeyType="send" editable={canSend} onSubmitEditing={() => void handleSend()} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index a45188d7712..a964ac0a9f6 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -402,6 +402,9 @@ export const TerminalWebView = forwardRef(function originWhitelist={['*']} javaScriptEnabled scrollEnabled={false} + // Why: Android parent gesture containers can intercept vertical drags + // before the injected xterm scroll router sees them. + nestedScrollEnabled scalesPageToFit={false} // Why: Android WebView defaults textZoom to the system font scale, inflating // xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it. diff --git a/mobile/src/terminal/terminal-keyboard-type.test.ts b/mobile/src/terminal/terminal-keyboard-type.test.ts new file mode 100644 index 00000000000..697fc4a7347 --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-type.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + getTerminalCommandKeyboardType, + getTerminalLiveInputKeyboardType +} from './terminal-keyboard-type' + +describe('terminal keyboard type', () => { + it('uses the Android system keyboard for live terminal input', () => { + expect(getTerminalLiveInputKeyboardType('android')).toBe('default') + }) + + it('uses the Android system keyboard for buffered command input', () => { + expect(getTerminalCommandKeyboardType('android', false)).toBe('default') + expect(getTerminalCommandKeyboardType('android', true)).toBe('default') + }) + + it('keeps the iOS ASCII keyboard when terminal autocomplete is disabled', () => { + expect(getTerminalLiveInputKeyboardType('ios')).toBe('ascii-capable') + expect(getTerminalCommandKeyboardType('ios', false)).toBe('ascii-capable') + expect(getTerminalCommandKeyboardType('ios', true)).toBe('default') + }) +}) diff --git a/mobile/src/terminal/terminal-keyboard-type.ts b/mobile/src/terminal/terminal-keyboard-type.ts new file mode 100644 index 00000000000..71bf5042930 --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-type.ts @@ -0,0 +1,20 @@ +export type TerminalKeyboardPlatform = 'android' | 'ios' | 'web' | 'windows' | 'macos' +export type TerminalKeyboardType = 'ascii-capable' | 'default' + +export function getTerminalLiveInputKeyboardType( + platform: TerminalKeyboardPlatform +): TerminalKeyboardType { + // Why: Android CJK IMEs need the normal system keyboard; password-style + // input types suppress composition and break Chinese terminal input. + return platform === 'ios' ? 'ascii-capable' : 'default' +} + +export function getTerminalCommandKeyboardType( + platform: TerminalKeyboardPlatform, + autocompleteEnabled: boolean +): TerminalKeyboardType { + if (autocompleteEnabled) { + return 'default' + } + return platform === 'ios' ? 'ascii-capable' : 'default' +} diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 354127acbb4..391ccd3a840 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -27,6 +27,26 @@ function sliceBetween(startPattern: string, endPattern: string): string { } describe('TerminalWebView scroll routing', () => { + it('keeps Android touch drags inside the terminal WebView', () => { + expect(source).toContain('nestedScrollEnabled') + }) + + it('maps a downward pull at the bottom to older scrollback rows', () => { + expect(source).toContain('var deltaY = ts.lastY - y;') + expect(source).toContain('smoothScrollOffsetY -= deltaY;') + expect(source).toContain('var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);') + + const nextViewportY = simulateNormalBufferPull({ + baseY: 120, + viewportY: 120, + startY: 300, + endY: 340, + cellHeight: 20 + }) + + expect(nextViewportY).toBe(118) + }) + it('routes alternate-screen and mouse-aware scroll before smooth normal scroll', () => { expect(source).toContain( 'return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();' @@ -233,3 +253,26 @@ describe('TerminalWebView scroll routing', () => { ) }) }) + +function simulateNormalBufferPull({ + baseY, + viewportY, + startY, + endY, + cellHeight +}: { + baseY: number + viewportY: number + startY: number + endY: number + cellHeight: number +}): number { + const deltaY = startY - endY + if (deltaY > 0 ? viewportY >= baseY : viewportY <= 0) { + return viewportY + } + const smoothScrollOffsetY = -deltaY + const lines = Math.trunc(-smoothScrollOffsetY / cellHeight) + const applied = Math.max(lines, -viewportY) + return viewportY + applied +}