Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class T3TerminalModule : Module() {
view.mutedForegroundColorHex = mutedForegroundColor
}

Events("onInput", "onResize")
Events("onInput", "onResize", "onTerminalFocus")

OnViewDestroys { view: T3TerminalView ->
view.cleanup()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
Expand All @@ -23,6 +25,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
private val inputView = EditText(context)
private val onInput by EventDispatcher()
private val onResize by EventDispatcher()
private val onTerminalFocus by EventDispatcher()
private var terminalHandle = 0L
private var fedBuffer = ""
private var cols = 0
Expand Down Expand Up @@ -189,6 +192,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
if (isCleanedUp) return
isCleanedUp = true
inputView.setOnEditorActionListener(null)
inputView.setOnFocusChangeListener(null)
terminalCanvas.onScrollRows = null
terminalCanvas.onRequestKeyboard = null
terminalCanvas.onCellMetricsChanged = null
Expand All @@ -213,6 +217,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
inputView.setPadding(0, 0, 0, 0)
inputView.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
onTerminalFocus(emptyMap<String, Any>())
}
}
inputView.setOnEditorActionListener { _, actionId, event ->
val isKeyUp = event?.action == KeyEvent.ACTION_UP
val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp
Expand Down Expand Up @@ -372,7 +381,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
}

private fun requestKeyboardFocus() {
// requestFocus on an already-focused EditText fires no focus callback, so
// emit it here when the window insets prove the IME is genuinely visible:
// the keyboard stream is live, and the JS recovery quarantine must lift
// even without a keyboardWillShow. Gating on the insets (not the touch
// that reached this call) keeps a post-resume scroll with a stale
// snapshot from clearing the quarantine.
val retainedFocus = inputView.hasFocus()
inputView.requestFocus()
val imeVisible =
ViewCompat.getRootWindowInsets(this)?.isVisible(WindowInsetsCompat.Type.ime()) == true
if (retainedFocus && imeVisible) {
onTerminalFocus(emptyMap<String, Any>())
}
Comment thread
cursor[bot] marked this conversation as resolved.
val inputMethodManager = context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as? InputMethodManager
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public class T3TerminalModule: Module {
view.mutedForegroundColorHex = mutedForegroundColor
}

Events("onInput", "onResize")
Events("onInput", "onResize", "onTerminalFocus")
}
}
}
2 changes: 2 additions & 0 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {

let onInput = EventDispatcher()
let onResize = EventDispatcher()
let onTerminalFocus = EventDispatcher()

var terminalKey: String = "" {
didSet {
Expand Down Expand Up @@ -440,6 +441,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {

@objc
private func handleInputEditingDidBegin() {
onTerminalFocus()
textInputModeDidChange()
}

Expand Down
47 changes: 47 additions & 0 deletions apps/mobile/src/features/keyboard/androidKeyboardRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vite-plus/test";

import {
getInitialAndroidKeyboardRecoveryState,
isAndroidKeyboardAnimationUsable,
reduceAndroidKeyboardRecovery,
type AndroidKeyboardRecoveryState,
} from "./androidKeyboardRecovery";

describe("getInitialAndroidKeyboardRecoveryState", () => {
it("quarantines Android surfaces mounted while the app is active", () => {
expect(getInitialAndroidKeyboardRecoveryState({ isAndroid: true, isAppActive: true })).toBe(
"quarantined",
);
expect(getInitialAndroidKeyboardRecoveryState({ isAndroid: true, isAppActive: false })).toBe(
"ready",
);
expect(getInitialAndroidKeyboardRecoveryState({ isAndroid: false, isAppActive: true })).toBe(
"ready",
);
});
});

describe("reduceAndroidKeyboardRecovery", () => {
it("quarantines keyboard translation after the app resumes", () => {
expect(reduceAndroidKeyboardRecovery("ready", "resume")).toBe("quarantined");
});

it("keeps the quarantine while the keyboard snapshot is unchanged", () => {
let state: AndroidKeyboardRecoveryState = "ready";
state = reduceAndroidKeyboardRecovery(state, "resume");
state = reduceAndroidKeyboardRecovery(state, "resume");

expect(state).toBe("quarantined");
expect(
isAndroidKeyboardAnimationUsable({
isKeyboardVisible: true,
isQuarantined: state === "quarantined",
}),
).toBe(false);
});

it("releases the quarantine when a live keyboard or input event arrives", () => {
expect(reduceAndroidKeyboardRecovery("quarantined", "keyboard-show")).toBe("ready");
expect(reduceAndroidKeyboardRecovery("quarantined", "input-focus")).toBe("ready");
});
});
28 changes: 28 additions & 0 deletions apps/mobile/src/features/keyboard/androidKeyboardRecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type AndroidKeyboardRecoveryState = "ready" | "quarantined";

export type AndroidKeyboardRecoveryEvent = "resume" | "keyboard-show" | "input-focus";

export function getInitialAndroidKeyboardRecoveryState(input: {
readonly isAndroid: boolean;
readonly isAppActive: boolean;
}): AndroidKeyboardRecoveryState {
return input.isAndroid && input.isAppActive ? "quarantined" : "ready";
}

export function reduceAndroidKeyboardRecovery(
state: AndroidKeyboardRecoveryState,
event: AndroidKeyboardRecoveryEvent,
): AndroidKeyboardRecoveryState {
if (event === "resume") {
return "quarantined";
}

return "ready";
}

export function isAndroidKeyboardAnimationUsable(input: {
readonly isKeyboardVisible: boolean;
readonly isQuarantined: boolean;
}): boolean {
return input.isKeyboardVisible && !input.isQuarantined;
}
53 changes: 53 additions & 0 deletions apps/mobile/src/features/keyboard/useAndroidKeyboardRecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useState } from "react";
import { AppState, Platform } from "react-native";
import { KeyboardEvents } from "react-native-keyboard-controller";

import {
getInitialAndroidKeyboardRecoveryState,
reduceAndroidKeyboardRecovery,
type AndroidKeyboardRecoveryState,
} from "./androidKeyboardRecovery";

export function useAndroidKeyboardRecovery(): {
readonly isQuarantined: boolean;
readonly markInputFocused: () => void;
} {
// A surface mounted while the app is already active has no future resume
// transition to observe, so it starts quarantined. Re-applying "resume" in
// the mount effect would clobber an autoFocus release that landed first.
const [recoveryState, setRecoveryState] = useState<AndroidKeyboardRecoveryState>(() =>
getInitialAndroidKeyboardRecoveryState({
isAndroid: Platform.OS === "android",
isAppActive: AppState.currentState === "active",
}),
);

useEffect(() => {
if (Platform.OS !== "android") {
return;
}

const appStateSubscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "resume"));
}
});
const keyboardShowSubscription = KeyboardEvents.addListener("keyboardWillShow", () => {
setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "keyboard-show"));
});
Comment thread
cursor[bot] marked this conversation as resolved.

return () => {
appStateSubscription.remove();
keyboardShowSubscription.remove();
};
}, []);

const markInputFocused = useCallback(() => {
setRecoveryState((current) => reduceAndroidKeyboardRecovery(current, "input-focus"));
}, []);

return {
isQuarantined: recoveryState === "quarantined",
markInputFocused,
};
}
17 changes: 16 additions & 1 deletion apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { TextInputWrapper } from "expo-paste-input";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
import {
KeyboardAvoidingView,
KeyboardStickyView,
useKeyboardState,
} from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";

Expand All @@ -17,6 +21,8 @@ import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/
import { useNativePaste } from "../../lib/useNativePaste";
import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state";
import { isAndroidKeyboardAnimationUsable } from "../keyboard/androidKeyboardRecovery";
import { useAndroidKeyboardRecovery } from "../keyboard/useAndroidKeyboardRecovery";
import {
clearReviewCommentTarget,
formatReviewCommentContext,
Expand All @@ -43,6 +49,13 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
const isAndroid = Platform.OS === "android";
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
const { isQuarantined: isKeyboardStateQuarantined, markInputFocused } =
useAndroidKeyboardRecovery();
const isKeyboardAnimationUsable = isAndroidKeyboardAnimationUsable({
isKeyboardVisible,
isQuarantined: isKeyboardStateQuarantined,
});
const { width } = useWindowDimensions();
const { themeAppearance: selectedTheme } = useAppearancePreferences();
const target = useReviewCommentTarget();
Expand Down Expand Up @@ -262,6 +275,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
textAlignVertical="top"
value={commentText}
onChangeText={setCommentText}
onFocus={markInputFocused}
className="h-full min-h-0 flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base"
/>
</TextInputWrapper>
Expand Down Expand Up @@ -308,6 +322,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
</KeyboardAvoidingView>
{isAndroid && target ? (
<KeyboardStickyView
enabled={isKeyboardAnimationUsable}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
className="absolute inset-x-0 bottom-0"
offset={{ closed: 0, opened: 0 }}
>
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface TerminalSurfaceProps extends ViewProps {
readonly theme?: TerminalTheme;
readonly onInput: (data: string) => void;
readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;
readonly onTerminalFocus?: () => void;
}

function estimateGridSize(input: {
Expand Down Expand Up @@ -150,6 +151,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
props.onInput(`${text}\r`);
}
}}
onFocus={props.onTerminalFocus}
/>
<Pressable
disabled={!props.isRunning}
Expand Down Expand Up @@ -228,6 +230,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
themeConfig={buildGhosttyThemeConfig(theme)}
onInput={handleNativeInput}
onResize={handleNativeResize}
onTerminalFocus={props.onTerminalFocus}
/>
</View>
);
Expand Down
16 changes: 13 additions & 3 deletions apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
import { useThreadSelection } from "../../state/use-thread-selection";
import { useSelectedThreadDetail } from "../../state/use-thread-detail";
import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
import { isAndroidKeyboardAnimationUsable } from "../keyboard/androidKeyboardRecovery";
import { useAndroidKeyboardRecovery } from "../keyboard/useAndroidKeyboardRecovery";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { TerminalSurface } from "./NativeTerminalSurface";
import { getMobileTerminalTheme } from "./terminalTheme";
Expand Down Expand Up @@ -507,9 +509,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
height: state.height,
isVisible: state.isVisible,
}));
const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed;
const { isQuarantined: isKeyboardStateQuarantined, markInputFocused } =
useAndroidKeyboardRecovery();
const isKeyboardAnimationUsable = isAndroidKeyboardAnimationUsable({
isKeyboardVisible: keyboardState.isVisible,
isQuarantined: isKeyboardStateQuarantined,
});
const isAccessoryVisible = isKeyboardAnimationUsable && !isAccessoryDismissed;
const terminalBottomInset =
(keyboardState.isVisible ? keyboardState.height : 0) +
(isKeyboardAnimationUsable ? keyboardState.height : 0) +
(isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0);

useEffect(() => {
Expand Down Expand Up @@ -1269,6 +1277,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
keyboardFocusRequest={keyboardFocusRequest}
onInput={handleInput}
onResize={handleResize}
onTerminalFocus={markInputFocused}
style={{ flex: 1 }}
terminalKey={terminalKey}
theme={terminalTheme}
Expand All @@ -1277,6 +1286,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)

{isAccessoryVisible ? (
<KeyboardStickyView
enabled={Platform.OS !== "android" || isKeyboardAnimationUsable}
style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
offset={{ closed: 0, opened: 0 }}
>
Expand Down Expand Up @@ -1325,7 +1335,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
</ComposerToolbarRow>
</View>
</KeyboardStickyView>
) : !keyboardState.isVisible ? (
) : !isKeyboardAnimationUsable ? (
Comment thread
cursor[bot] marked this conversation as resolved.
<Pressable
accessibilityLabel="Show keyboard"
accessibilityRole="button"
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/terminal/nativeTerminalModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface NativeTerminalSurfaceProps extends ViewProps {
readonly fontSize: number;
readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void;
readonly onResize?: (event: NativeSyntheticEvent<TerminalResizeEvent>) => void;
readonly onTerminalFocus?: () => void;
}

let cachedNativeTerminalSurfaceView: ComponentType<NativeTerminalSurfaceProps> | undefined;
Expand Down
Loading
Loading