feat(mobile): add explicit keyboard dismiss control to terminal command dock (#5917)

* feat(mobile): add explicit keyboard dismiss control to terminal command dock

Add a fixed Hide control at the left of the terminal command dock accessory
bar whenever the software keyboard is open (keyboardHeight > 0). Tapping it
clears any pending live-input focus timer, blurs the live and buffered command
inputs, and dismisses the keyboard without sending bytes, switching input mode,
or clearing typed text.

The dismiss behavior lives in a dedicated, unit-tested terminal-keyboard-dismiss
module rather than the customizable accessory-key path, so the escape hatch
cannot be hidden by user shortcut customization. Available on every platform
where the IME covers the app (iOS and Android).

* review: harden keyboard dismiss control per adversarial review

- document the load-bearing clear-before-blur order in dismissTerminalKeyboard
- cover the both-handles-missing case in unit tests (5/5)
- move the #5106 first-tap comment onto the accessory ScrollView and add a
  why-comment for the fixed Hide control
- add accessibilityRole=button and hitSlop to the Hide control for a larger,
  semantically-correct touch target

* fix(mobile): harden hide button visibility and scroll layout

* refactor(mobile): use stacked keyboard+chevron glyph for dismiss control

Replace the icon+'Hide' text with the iOS-native dismiss glyph (keyboard
with a chevron-down beneath it). Narrower in the accessory row, removes the
icon/word redundancy, and reads as distinct from the >> input-mode toggle.
Accessibility label/hint/role unchanged.

* fix(mobile): align keyboard dismiss accessory height

* test(mobile): align vitest transform with Vite 8

---------

Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
Wolfie 2026-07-08 17:18:16 -07:00 committed by GitHub
parent 582fe5948e
commit 60037d60ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 173 additions and 17 deletions

View File

@ -24,6 +24,7 @@ import {
AlertTriangle,
ArrowUp,
Bot,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsRight,
@ -100,6 +101,7 @@ import {
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
@ -1006,6 +1008,7 @@ export default function SessionScreen() {
const viewportMeasuredRef = useRef(false)
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const liveInputRef = useRef<TextInput>(null)
const commandInputRef = useRef<TextInput>(null)
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
@ -3208,6 +3211,15 @@ export default function SessionScreen() {
]
)
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
commandInput: commandInputRef.current,
dismissKeyboard: () => Keyboard.dismiss(),
liveInput: liveInputRef.current
})
}, [])
const handleTerminalTap = useCallback(
(handle: string) => {
if (handle !== activeHandleRef.current) {
@ -4780,10 +4792,38 @@ export default function SessionScreen() {
>
{/* Accessory keys */}
<View style={styles.accessoryBar}>
{/* Why: a fixed, always-visible escape hatch from the open
keyboard. Kept outside the horizontal ScrollView so it does
not scroll away, and out of the terminal-byte shortcut path so
it cannot be hidden by user shortcut customization (#5106). */}
{keyboardLift > 0 && (
<Pressable
style={({ pressed }) => [
styles.keyboardDismissKey,
pressed && styles.accessoryKeyPressed
]}
onPress={dismissSoftwareKeyboard}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
)}
{/* Why: with default tap handling the first tap on any accessory
key dismisses the open keyboard and is swallowed, so live
input lost its keyboard on every Esc/Tab press (#5106). */}
<ScrollView
style={styles.accessoryScroll}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
@ -5009,6 +5049,7 @@ export default function SessionScreen() {
) : (
<View style={styles.inputBar}>
<TextInput
ref={commandInputRef}
// Why: Android caches the IME inputType at mount, so toggling
// autocomplete must remount there; iOS can update without a focus-costly remount.
key={

View File

@ -68,10 +68,16 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
zIndex: 20
},
accessoryBar: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
accessoryScroll: {
flex: 1,
minWidth: 0
},
accessoryContent: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
@ -110,6 +116,29 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
accessoryKeyTextDisabled: {
color: colors.textMuted
},
keyboardDismissKey: {
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
marginVertical: spacing.xs,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm + 2,
paddingVertical: 0,
borderRadius: radii.button,
minWidth: 36,
height: 28
},
keyboardDismissGlyph: {
alignItems: 'center',
height: 18,
justifyContent: 'flex-start',
position: 'relative',
width: 18
},
keyboardDismissChevron: {
bottom: -2,
position: 'absolute'
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',

View File

@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { dismissTerminalKeyboard } from './terminal-keyboard-dismiss'
describe('dismissTerminalKeyboard', () => {
it('clears pending live input focus before blur and dismiss', () => {
const calls: string[] = []
const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear'))
const liveInput = { blur: vi.fn(() => calls.push('live-blur')) }
const commandInput = { blur: vi.fn(() => calls.push('command-blur')) }
const dismissKeyboard = vi.fn(() => calls.push('dismiss'))
dismissTerminalKeyboard({
clearPendingLiveInputFocus,
commandInput,
dismissKeyboard,
liveInput
})
expect(calls).toEqual(['clear', 'live-blur', 'command-blur', 'dismiss'])
})
it('blurs both live and buffered command inputs', () => {
const liveInput = { blur: vi.fn() }
const commandInput = { blur: vi.fn() }
dismissTerminalKeyboard({
clearPendingLiveInputFocus: vi.fn(),
commandInput,
dismissKeyboard: vi.fn(),
liveInput
})
expect(liveInput.blur).toHaveBeenCalledTimes(1)
expect(commandInput.blur).toHaveBeenCalledTimes(1)
})
it('dismisses the keyboard without a live input handle', () => {
const commandInput = { blur: vi.fn() }
const dismissKeyboard = vi.fn()
dismissTerminalKeyboard({
clearPendingLiveInputFocus: vi.fn(),
commandInput,
dismissKeyboard,
liveInput: null
})
expect(commandInput.blur).toHaveBeenCalledTimes(1)
expect(dismissKeyboard).toHaveBeenCalledTimes(1)
})
it('dismisses the keyboard without a buffered command input handle', () => {
const liveInput = { blur: vi.fn() }
const dismissKeyboard = vi.fn()
dismissTerminalKeyboard({
clearPendingLiveInputFocus: vi.fn(),
commandInput: undefined,
dismissKeyboard,
liveInput
})
expect(liveInput.blur).toHaveBeenCalledTimes(1)
expect(dismissKeyboard).toHaveBeenCalledTimes(1)
})
it('still clears focus and dismisses when both input handles are missing', () => {
const calls: string[] = []
const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear'))
const dismissKeyboard = vi.fn(() => calls.push('dismiss'))
dismissTerminalKeyboard({
clearPendingLiveInputFocus,
commandInput: undefined,
dismissKeyboard,
liveInput: null
})
expect(calls).toEqual(['clear', 'dismiss'])
})
})

View File

@ -0,0 +1,17 @@
export type TerminalKeyboardDismissHandle = { blur: () => void } | null | undefined
export type DismissTerminalKeyboardOptions = {
clearPendingLiveInputFocus: () => void
commandInput: TerminalKeyboardDismissHandle
dismissKeyboard: () => void
liveInput: TerminalKeyboardDismissHandle
}
export function dismissTerminalKeyboard(options: DismissTerminalKeyboardOptions): void {
// Why: clear the queued live-input focus before blurring/dismissing so a
// pending deferred focus cannot re-open the iOS keyboard right after Hide.
options.clearPendingLiveInputFocus()
options.liveInput?.blur()
options.commandInput?.blur()
options.dismissKeyboard()
}

View File

@ -1,25 +1,12 @@
import { defineConfig } from 'vitest/config'
const tsconfigRaw = JSON.stringify({
compilerOptions: {
jsx: 'react-jsx',
module: 'esnext',
moduleResolution: 'bundler',
strict: true,
target: 'es2022'
}
})
const vitestOxcConfig = { tsconfig: false } as never
export default defineConfig({
root: import.meta.dirname,
esbuild: {
tsconfigRaw
},
optimizeDeps: {
esbuildOptions: {
tsconfigRaw
}
},
// Why: the app tsconfig intentionally excludes tests; Vite 8's OXC transform
// otherwise fails before Vitest can run the test modules.
oxc: vitestOxcConfig,
test: {
environment: 'node',
include: ['src/**/*.test.ts']