fix: support Android terminal IME and scroll gestures (#5844)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-19 15:27:53 -07:00
committed by GitHub
co-authored by Orca
parent 0ed080dc59
commit 4f4d520149
5 changed files with 98 additions and 9 deletions
+10 -9
View File
@@ -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()}
+3
View File
@@ -402,6 +402,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(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.
@@ -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')
})
})
@@ -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'
}
@@ -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
}