import { memo, useCallback, useEffect, useRef } from "react";import { Pressable, ScrollView, TextInput, View, type LayoutChangeEvent, type NativeSyntheticEvent, type ViewProps, useColorScheme,} from "react-native"; import { AppText as Text } from "../../components/AppText";import { MOBILE_TYPOGRAPHY } from "../../lib/typography";import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";import { buildGhosttyThemeConfig, getPierreTerminalTheme, type TerminalTheme,} from "./terminalTheme";import { terminalDebugLog } from "./terminalDebugLog"; interface TerminalInputEvent { readonly data: string;} interface TerminalResizeEvent { readonly cols: number; readonly rows: number;} interface TerminalSurfaceProps extends ViewProps { readonly terminalKey: string; readonly buffer: string; readonly fontSize?: number; readonly isRunning: boolean; readonly keyboardFocusRequest?: number; readonly theme?: TerminalTheme; readonly onInput: (data: string) => void; readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;} function estimateGridSize(input: { readonly width: number; readonly height: number; readonly fontSize: number;}): { readonly cols: number; readonly rows: number } { const cellWidth = input.fontSize * 0.62; const cellHeight = input.fontSize * 1.35; return { cols: Math.max(20, Math.min(400, Math.floor(input.width / cellWidth))), rows: Math.max(5, Math.min(200, Math.floor(input.height / cellHeight))), };} const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const inputRef = useRef<TextInput>(null); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); const statusLabel = props.isRunning ? "Native terminal unavailable. Using text fallback." : "Open terminal to start a shell."; const handleLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; props.onResize(estimateGridSize({ width, height, fontSize })); }; useEffect(() => { if ((props.keyboardFocusRequest ?? 0) > 0) { inputRef.current?.blur(); const focusFrame = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(focusFrame); } return undefined; }, [props.keyboardFocusRequest]); return ( <View style={[ { flex: 1, backgroundColor: theme.background, borderRadius: 8, overflow: "hidden", }, props.style, ]} onLayout={handleLayout} > <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}> <Text style={{ color: theme.mutedForeground, fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, paddingBottom: 8, }} > {statusLabel} </Text> <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 12 }} showsVerticalScrollIndicator={false} > <Text selectable style={{ color: theme.foreground, fontFamily: "Menlo", fontSize, lineHeight: Math.round(fontSize * 1.35), }} > {props.buffer || "$ "} </Text> </ScrollView> </View> <View style={{ borderTopWidth: 1, borderTopColor: theme.border, flexDirection: "row", alignItems: "center", gap: 8, padding: 8, }} > <TextInput ref={inputRef} autoCapitalize="none" autoCorrect={false} blurOnSubmit={false} editable={props.isRunning} placeholder="type and press return" placeholderTextColor={theme.mutedForeground} returnKeyType="send" style={{ color: theme.foreground, flex: 1, fontFamily: "Menlo", fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize, padding: 0, }} onSubmitEditing={(event) => { const text = event.nativeEvent.text; if (text.length > 0) { props.onInput(`${text}\n`); } }} /> <Pressable disabled={!props.isRunning} style={({ pressed }) => ({ opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1, paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8, backgroundColor: theme.border, })} onPress={() => props.onInput("\u0003")} > <Text style={{ color: theme.foreground, fontFamily: "DMSans_700Bold", fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, }} > Ctrl-C </Text> </Pressable> </View> </View> );}); export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const keyboardInputRef = useRef<TextInput>(null); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); const { onInput, onResize } = props; const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); const hasNativeSurface = Boolean(NativeTerminalSurfaceView); useEffect(() => { terminalDebugLog("native:surface", { terminalKey: props.terminalKey, native: hasNativeSurface, bufferLen: props.buffer.length, isRunning: props.isRunning, }); }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); const handleNativeInput = useCallback( (event: NativeSyntheticEvent<TerminalInputEvent>) => { onInput(event.nativeEvent.data); }, [onInput], ); const handleNativeResize = useCallback( (event: NativeSyntheticEvent<TerminalResizeEvent>) => { onResize({ cols: event.nativeEvent.cols, rows: event.nativeEvent.rows, }); }, [onResize], ); // Reopen focus through React and forward input normally; this avoids a native focus-command bridge. useEffect(() => { if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) { return undefined; } keyboardInputRef.current?.blur(); const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus()); return () => cancelAnimationFrame(focusFrame); }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]); const handleKeyboardInput = useCallback( (data: string) => { if (data.length > 0) { onInput(data); keyboardInputRef.current?.clear(); } }, [onInput], ); if (NativeTerminalSurfaceView) { return ( <View style={props.style}> <NativeTerminalSurfaceView appearanceScheme={appearanceScheme} backgroundColor={theme.background} foregroundColor={theme.foreground} mutedForegroundColor={theme.mutedForeground} terminalKey={props.terminalKey} initialBuffer={props.buffer} fontSize={fontSize} style={{ flex: 1 }} themeConfig={buildGhosttyThemeConfig(theme)} onInput={handleNativeInput} onResize={handleNativeResize} /> <TextInput ref={keyboardInputRef} autoCapitalize="none" autoCorrect={false} blurOnSubmit={false} caretHidden editable={props.isRunning} keyboardType="ascii-capable" style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }} onChangeText={handleKeyboardInput} onKeyPress={(event) => { if (event.nativeEvent.key === "Backspace") { onInput("\u007f"); } }} onSubmitEditing={() => onInput("\n")} /> </View> ); } return <FallbackTerminalSurface {...props} fontSize={fontSize} theme={theme} />;});
import { memo, useCallback, useEffect, useRef } from "react";
import {
Pressable,
ScrollView,
TextInput,
View,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type ViewProps,
useColorScheme,
} from "react-native";
import { AppText as Text } from "../../components/AppText";
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";
import {
buildGhosttyThemeConfig,
getPierreTerminalTheme,
type TerminalTheme,
} from "./terminalTheme";
import { terminalDebugLog } from "./terminalDebugLog";
interface TerminalInputEvent {
readonly data: string;
}
interface TerminalResizeEvent {
readonly cols: number;
readonly rows: number;
}
interface TerminalSurfaceProps extends ViewProps {
readonly terminalKey: string;
readonly buffer: string;
readonly fontSize?: number;
readonly isRunning: boolean;
readonly keyboardFocusRequest?: number;
readonly theme?: TerminalTheme;
readonly onInput: (data: string) => void;
readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;
}
function estimateGridSize(input: {
readonly width: number;
readonly height: number;
readonly fontSize: number;
}): { readonly cols: number; readonly rows: number } {
const cellWidth = input.fontSize * 0.62;
const cellHeight = input.fontSize * 1.35;
return {
cols: Math.max(20, Math.min(400, Math.floor(input.width / cellWidth))),
rows: Math.max(5, Math.min(200, Math.floor(input.height / cellHeight))),
};
}
const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
const inputRef = useRef<TextInput>(null);
const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
const statusLabel = props.isRunning
? "Native terminal unavailable. Using text fallback."
: "Open terminal to start a shell.";
const handleLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
props.onResize(estimateGridSize({ width, height, fontSize }));
};
useEffect(() => {
if ((props.keyboardFocusRequest ?? 0) > 0) {
inputRef.current?.blur();
const focusFrame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(focusFrame);
}
return undefined;
}, [props.keyboardFocusRequest]);
return (
<View
style={[
{
flex: 1,
backgroundColor: theme.background,
borderRadius: 8,
overflow: "hidden",
},
props.style,
]}
onLayout={handleLayout}
>
<View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>
<Text
style={{
color: theme.mutedForeground,
fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
paddingBottom: 8,
}}
>
{statusLabel}
</Text>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 12 }}
showsVerticalScrollIndicator={false}
>
<Text
selectable
style={{
color: theme.foreground,
fontFamily: "Menlo",
fontSize,
lineHeight: Math.round(fontSize * 1.35),
}}
>
{props.buffer || "$ "}
</Text>
</ScrollView>
</View>
<View
style={{
borderTopWidth: 1,
borderTopColor: theme.border,
flexDirection: "row",
alignItems: "center",
gap: 8,
padding: 8,
}}
>
<TextInput
ref={inputRef}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
editable={props.isRunning}
placeholder="type and press return"
placeholderTextColor={theme.mutedForeground}
returnKeyType="send"
style={{
color: theme.foreground,
flex: 1,
fontFamily: "Menlo",
fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,
padding: 0,
}}
onSubmitEditing={(event) => {
const text = event.nativeEvent.text;
if (text.length > 0) {
props.onInput(`${text}\n`);
}
}}
/>
<Pressable
disabled={!props.isRunning}
style={({ pressed }) => ({
opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: theme.border,
})}
onPress={() => props.onInput("\u0003")}
>
<Text
style={{
color: theme.foreground,
fontFamily: "DMSans_700Bold",
fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,
}}
>
Ctrl-C
</Text>
</Pressable>
</View>
</View>
);
});
export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
const keyboardInputRef = useRef<TextInput>(null);
const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
const { onInput, onResize } = props;
const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
const hasNativeSurface = Boolean(NativeTerminalSurfaceView);
useEffect(() => {
terminalDebugLog("native:surface", {
terminalKey: props.terminalKey,
native: hasNativeSurface,
bufferLen: props.buffer.length,
isRunning: props.isRunning,
});
}, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
const handleNativeInput = useCallback(
(event: NativeSyntheticEvent<TerminalInputEvent>) => {
onInput(event.nativeEvent.data);
},
[onInput],
);
const handleNativeResize = useCallback(
(event: NativeSyntheticEvent<TerminalResizeEvent>) => {
onResize({
cols: event.nativeEvent.cols,
rows: event.nativeEvent.rows,
});
},
[onResize],
);
// Reopen focus through React and forward input normally; this avoids a native focus-command bridge.
useEffect(() => {
if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) {
return undefined;
}
keyboardInputRef.current?.blur();
const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus());
return () => cancelAnimationFrame(focusFrame);
}, [NativeTerminalSurfaceView, props.keyboardFocusRequest]);
const handleKeyboardInput = useCallback(
(data: string) => {
if (data.length > 0) {
onInput(data);
keyboardInputRef.current?.clear();
}
},
[onInput],
);
if (NativeTerminalSurfaceView) {
return (
<View style={props.style}>
<NativeTerminalSurfaceView
appearanceScheme={appearanceScheme}
backgroundColor={theme.background}
foregroundColor={theme.foreground}
mutedForegroundColor={theme.mutedForeground}
terminalKey={props.terminalKey}
initialBuffer={props.buffer}
fontSize={fontSize}
style={{ flex: 1 }}
themeConfig={buildGhosttyThemeConfig(theme)}
onInput={handleNativeInput}
onResize={handleNativeResize}
/>
<TextInput
ref={keyboardInputRef}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
caretHidden
editable={props.isRunning}
keyboardType="ascii-capable"
style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }}
onChangeText={handleKeyboardInput}
onKeyPress={(event) => {
if (event.nativeEvent.key === "Backspace") {
onInput("\u007f");
}
}}
onSubmitEditing={() => onInput("\n")}
/>
</View>
);
}
return <FallbackTerminalSurface {...props} fontSize={fontSize} theme={theme} />;
});