1import { memo, useCallback, useEffect, useRef } from "react";
2import {
3 Pressable,
4 ScrollView,
5 TextInput,
6 View,
7 type LayoutChangeEvent,
8 type NativeSyntheticEvent,
9 type TextInputInstance,
10 type ViewProps,
11} from "react-native";
12
13import { AppText as Text } from "../../components/AppText";
14import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
15import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
16import {
17 getNativeTerminalHardwareKeyRevision,
18 resolveNativeTerminalSurfaceView,
19} from "./nativeTerminalModule";
20import {
21 buildGhosttyThemeConfig,
22 getMobileTerminalTheme,
23 type TerminalTheme,
24} from "./terminalTheme";
25import { terminalDebugLog } from "./terminalDebugLog";
26import { useTerminalSurfaceBuffer } from "./useTerminalSurfaceBuffer";
27
28interface TerminalInputEvent {
29 readonly data: string;
30}
31
32interface TerminalResizeEvent {
33 readonly cols: number;
34 readonly rows: number;
35}
36
37interface TerminalSurfaceProps extends ViewProps {
38 readonly terminalKey: string;
39 readonly buffer: string | null;
40 readonly fontSize?: number;
41 readonly isRunning: boolean;
42 readonly readOnly?: boolean;
43 readonly autoFocus?: boolean;
44 readonly keyboardFocusRequest?: number;
45 readonly captureRequest?: number;
46 readonly onCapture?: (text: string) => void;
47 readonly theme?: TerminalTheme;
48 readonly onInput: (data: string) => void;
49 readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;
50}
51
52type ReadyTerminalSurfaceProps = Omit<TerminalSurfaceProps, "buffer"> & {
53 readonly buffer: string;
54};
55
56function estimateGridSize(input: {
57 readonly width: number;
58 readonly height: number;
59 readonly fontSize: number;
60}): { readonly cols: number; readonly rows: number } {
61 const cellWidth = input.fontSize * 0.62;
62 const cellHeight = input.fontSize * 1.35;
63 return {
64 cols: Math.max(20, Math.min(400, Math.floor(input.width / cellWidth))),
65 rows: Math.max(5, Math.min(200, Math.floor(input.height / cellHeight))),
66 };
67}
68
69const FallbackTerminalSurface = memo(function FallbackTerminalSurface(
70 props: ReadyTerminalSurfaceProps,
71) {
72 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
73 const inputRef = useRef<TextInputInstance>(null);
74 const { themeAppearance, themeId } = useAppearancePreferences();
75 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
76 const statusLabel = props.readOnly
77 ? "Viewing terminal output."
78 : props.isRunning
79 ? "Native terminal unavailable. Using text fallback."
80 : "Open terminal to start a shell.";
81
82 const handleLayout = (event: LayoutChangeEvent) => {
83 const { width, height } = event.nativeEvent.layout;
84 props.onResize(estimateGridSize({ width, height, fontSize }));
85 };
86
87 useEffect(() => {
88 if (!props.readOnly && (props.keyboardFocusRequest ?? 0) > 0) {
89 inputRef.current?.blur();
90 const focusFrame = requestAnimationFrame(() => inputRef.current?.focus());
91 return () => cancelAnimationFrame(focusFrame);
92 }
93
94 return undefined;
95 }, [props.keyboardFocusRequest, props.readOnly]);
96
97 return (
98 <View
99 className="flex-1"
100 style={[
101 {
102 backgroundColor: theme.background,
103 borderRadius: 8,
104 overflow: "hidden",
105 },
106 props.style,
107 ]}
108 onLayout={handleLayout}
109 >
110 <View className="flex-1 px-2.5 py-2">
111 <Text
112 className="pb-2 text-2xs"
113 style={{
114 color: theme.mutedForeground,
115 }}
116 >
117 {statusLabel}
118 </Text>
119 <ScrollView
120 className="flex-1"
121 contentContainerClassName="pb-3"
122 showsVerticalScrollIndicator={false}
123 >
124 <Text
125 selectable
126 style={{
127 color: theme.foreground,
128 fontFamily: "Menlo",
129 fontSize,
130 lineHeight: Math.round(fontSize * 1.35),
131 }}
132 >
133 {props.buffer || "$ "}
134 </Text>
135 </ScrollView>
136 </View>
137 <View
138 className="flex-row items-center gap-2 border-t p-2"
139 style={{
140 borderTopColor: theme.border,
141 }}
142 >
143 <TextInput
144 ref={inputRef}
145 autoCapitalize="none"
146 autoCorrect={false}
147 blurOnSubmit={false}
148 editable={props.isRunning && !props.readOnly}
149 placeholder="type and press return"
150 placeholderTextColor={theme.mutedForeground}
151 returnKeyType="send"
152 className="text-sm"
153 style={{
154 color: theme.foreground,
155 flex: 1,
156 fontFamily: "Menlo",
157 padding: 0,
158 }}
159 onSubmitEditing={(event) => {
160 const text = event.nativeEvent.text;
161 if (text.length > 0) {
162 // Terminal Enter is CR. LF is Ctrl+J and raw-mode TUIs can treat it as J.
163 props.onInput(`${text}\r`);
164 }
165 }}
166 />
167 <Pressable
168 disabled={!props.isRunning || props.readOnly}
169 style={({ pressed }) => ({
170 opacity: !props.isRunning || props.readOnly ? 0.35 : pressed ? 0.65 : 1,
171 paddingHorizontal: 10,
172 paddingVertical: 6,
173 borderRadius: 8,
174 backgroundColor: theme.border,
175 })}
176 onPress={() => props.onInput("\u0003")}
177 >
178 <Text className="text-2xs font-t3-bold" style={{ color: theme.foreground }}>
179 Ctrl-C
180 </Text>
181 </Pressable>
182 </View>
183 </View>
184 );
185});
186
187export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
188 const buffer = useTerminalSurfaceBuffer(props);
189 return <ReadyTerminalSurface {...props} buffer={buffer} />;
190});
191
192const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerminalSurfaceProps) {
193 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
194 const { themeAppearance, themeId } = useAppearancePreferences();
195 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
196 const { onInput, onResize } = props;
197 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
198 const hasNativeSurface = Boolean(NativeTerminalSurfaceView);
199
200 useEffect(() => {
201 terminalDebugLog("native:surface", {
202 terminalKey: props.terminalKey,
203 native: hasNativeSurface,
204 // null = installed binary predates native hardware-key handling (rebuild needed).
205 hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(),
206 bufferLen: props.buffer.length,
207 isRunning: props.isRunning,
208 });
209 }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
210 const handleNativeInput = useCallback(
211 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
212 if (!props.isRunning || props.readOnly) {
213 return;
214 }
215 terminalDebugLog("native:onInput", {
216 codes: Array.from(event.nativeEvent.data, (char) => char.codePointAt(0)),
217 });
218 onInput(event.nativeEvent.data);
219 },
220 [onInput, props.isRunning, props.readOnly],
221 );
222 const handleNativeResize = useCallback(
223 (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
224 onResize({
225 cols: event.nativeEvent.cols,
226 rows: event.nativeEvent.rows,
227 });
228 },
229 [onResize],
230 );
231
232 if (NativeTerminalSurfaceView) {
233 return (
234 <View style={props.style}>
235 <NativeTerminalSurfaceView
236 appearanceScheme={themeAppearance}
237 autoFocus={!props.readOnly && (props.autoFocus ?? true)}
238 readOnly={props.readOnly ?? false}
239 backgroundColor={theme.background}
240 focusRequest={props.isRunning && !props.readOnly ? (props.keyboardFocusRequest ?? 0) : 0}
241 foregroundColor={theme.foreground}
242 mutedForegroundColor={theme.mutedForeground}
243 terminalKey={props.terminalKey}
244 initialBuffer={props.buffer}
245 fontSize={fontSize}
246 style={{ flex: 1 }}
247 themeConfig={buildGhosttyThemeConfig(theme)}
248 onInput={handleNativeInput}
249 onResize={handleNativeResize}
250 captureRequest={props.captureRequest}
251 onCapture={(event) => props.onCapture?.(event.nativeEvent.text)}
252 />
253 </View>
254 );
255 }
256
257 return <FallbackTerminalSurface {...props} fontSize={fontSize} theme={theme} />;
258});