Open History to see the last commit
1import type { TerminalGrid, TerminalOutputState } from "@t3tools/client-runtime/state/terminal";
2import { memo, useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react";
3import {
4 Pressable,
5 ScrollView,
6 TextInput,
7 View,
8 type LayoutChangeEvent,
9 type NativeSyntheticEvent,
10 type TextInputInstance,
11 type ViewProps,
12} from "react-native";
13
14import { AppText as Text } from "../../components/ui/AppText";
15import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
16import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
17import {
18 resolveNativeTerminalSurfaceView,
19 supportsNativeTerminalOutput,
20 type NativeTerminalSurfaceHandle,
21} from "./nativeTerminalModule";
22import {
23 buildGhosttyThemeConfig,
24 getMobileTerminalTheme,
25 type TerminalTheme,
26} from "./terminalTheme";
27import { useTerminalSurfaceBuffer } from "./useTerminalSurfaceBuffer";
28import { createTerminalOutputWriter } from "./terminalOutputWriter";
29
30interface TerminalInputEvent {
31 readonly data: string;
32}
33
34interface TerminalResizeEvent {
35 readonly cols: number;
36 readonly rows: number;
37}
38
39export interface TerminalSurfaceHandle {
40 /** The text fallback focuses its input; the native view follows `keyboardFocusRequest`. */
41 readonly focusKeyboard: () => void;
42 /** Drops the native field's IME context after input that bypassed it. */
43 readonly resetInput: () => void;
44}
45
46interface TerminalSurfaceProps extends ViewProps {
47 readonly ref?: Ref<TerminalSurfaceHandle>;
48 readonly terminalKey: string;
49 readonly buffer: string | null;
50 readonly output: TerminalOutputState | null;
51 readonly fontSize?: number;
52 readonly grid?: TerminalGrid | null;
53 readonly isRunning: boolean;
54 readonly readOnly?: boolean;
55 readonly autoFocus?: boolean;
56 readonly keyboardFocusRequest?: number;
57 readonly captureRequest?: number;
58 readonly onCapture?: (text: string) => void;
59 readonly theme?: TerminalTheme;
60 readonly onInput: (data: string) => void;
61 readonly onPaste?: (text: string) => void;
62 readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;
63}
64
65type ReadyTerminalSurfaceProps = Omit<TerminalSurfaceProps, "buffer"> & {
66 readonly buffer: string;
67};
68
69function estimateGridSize(input: {
70 readonly width: number;
71 readonly height: number;
72 readonly fontSize: number;
73}): { readonly cols: number; readonly rows: number } {
74 const cellWidth = input.fontSize * 0.62;
75 const cellHeight = input.fontSize * 1.35;
76 return {
77 cols: Math.max(20, Math.min(400, Math.floor(input.width / cellWidth))),
78 rows: Math.max(5, Math.min(200, Math.floor(input.height / cellHeight))),
79 };
80}
81
82const FallbackTerminalSurface = memo(function FallbackTerminalSurface({
83 ref,
84 ...props
85}: ReadyTerminalSurfaceProps) {
86 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
87 const inputRef = useRef<TextInputInstance>(null);
88 const { themeAppearance, themeId } = useAppearancePreferences();
89 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
90 const statusLabel = props.readOnly
91 ? "Viewing terminal output."
92 : props.isRunning
93 ? "Native terminal unavailable. Using text fallback."
94 : "Open terminal to start a shell.";
95
96 const handleLayout = (event: LayoutChangeEvent) => {
97 const { width, height } = event.nativeEvent.layout;
98 props.onResize(estimateGridSize({ width, height, fontSize }));
99 };
100
101 useImperativeHandle(ref, () => ({
102 focusKeyboard: () => {
103 if (props.readOnly) return;
104 // Blur first so a focused input still raises the keyboard; focus lands after the blur commits.
105 inputRef.current?.blur();
106 requestAnimationFrame(() => inputRef.current?.focus());
107 },
108 resetInput: () => {},
109 }));
110
111 return (
112 <View
113 className="flex-1"
114 style={[
115 {
116 backgroundColor: theme.background,
117 borderRadius: 8,
118 overflow: "hidden",
119 },
120 props.style,
121 ]}
122 onLayout={handleLayout}
123 >
124 <View className="flex-1 px-2.5 py-2">
125 <Text
126 className="pb-2 text-2xs"
127 style={{
128 color: theme.mutedForeground,
129 }}
130 >
131 {statusLabel}
132 </Text>
133 <ScrollView
134 className="flex-1"
135 contentContainerClassName="pb-3"
136 showsVerticalScrollIndicator={false}
137 >
138 <Text
139 selectable
140 style={{
141 color: theme.foreground,
142 fontFamily: "Menlo",
143 fontSize,
144 lineHeight: Math.round(fontSize * 1.35),
145 }}
146 >
147 {props.buffer || "$ "}
148 </Text>
149 </ScrollView>
150 </View>
151 <View
152 className="flex-row items-center gap-2 border-t p-2"
153 style={{
154 borderTopColor: theme.border,
155 }}
156 >
157 <TextInput
158 ref={inputRef}
159 autoCapitalize="none"
160 autoCorrect={false}
161 blurOnSubmit={false}
162 editable={props.isRunning && !props.readOnly}
163 placeholder="type and press return"
164 placeholderTextColor={theme.mutedForeground}
165 returnKeyType="send"
166 className="text-sm"
167 style={{
168 color: theme.foreground,
169 flex: 1,
170 fontFamily: "Menlo",
171 padding: 0,
172 }}
173 onSubmitEditing={(event) => {
174 const text = event.nativeEvent.text;
175 if (text.length > 0) {
176 // Terminal Enter is CR. LF is Ctrl+J and raw-mode TUIs can treat it as J.
177 props.onInput(`${text}\r`);
178 }
179 }}
180 />
181 <Pressable
182 disabled={!props.isRunning || props.readOnly}
183 style={({ pressed }) => ({
184 opacity: !props.isRunning || props.readOnly ? 0.35 : pressed ? 0.65 : 1,
185 paddingHorizontal: 10,
186 paddingVertical: 6,
187 borderRadius: 8,
188 backgroundColor: theme.border,
189 })}
190 onPress={() => props.onInput("\u0003")}
191 >
192 <Text className="text-2xs font-t3-bold" style={{ color: theme.foreground }}>
193 Ctrl-C
194 </Text>
195 </Pressable>
196 </View>
197 </View>
198 );
199});
200
201export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
202 const buffer = useTerminalSurfaceBuffer(props);
203 return <ReadyTerminalSurface key={props.terminalKey} {...props} buffer={buffer} />;
204});
205
206const ReadyTerminalSurface = memo(function ReadyTerminalSurface({
207 ref,
208 grid,
209 ...props
210}: ReadyTerminalSurfaceProps) {
211 const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
212 const { themeAppearance, themeId } = useAppearancePreferences();
213 const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
214 const { onInput, onResize } = props;
215 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
216 const gridCols = grid?.cols;
217 const gridRows = grid?.rows;
218 const sharedGrid = useMemo(
219 () => (gridCols !== undefined && gridRows !== undefined ? [gridCols, gridRows] : []),
220 [gridCols, gridRows],
221 );
222 const streaming = supportsNativeTerminalOutput();
223 const nativeRef = useRef<NativeTerminalSurfaceHandle>(null);
224 const writerRef = useRef<ReturnType<typeof createTerminalOutputWriter> | null>(null);
225 const [nativeReady, setNativeReady] = useState(false);
226
227 // The fallback surface binds the same ref itself.
228 useImperativeHandle(NativeTerminalSurfaceView ? ref : undefined, () => ({
229 focusKeyboard: () => {},
230 resetInput: () => {
231 void nativeRef.current?.resetInput?.().catch((error: unknown) => {
232 console.error("Terminal input reset failed", error);
233 });
234 },
235 }));
236
237 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates the output writer for the native terminal view once it is ready and disposes it on cleanup
238 useEffect(() => {
239 const view = nativeRef.current;
240 if (!streaming || !nativeReady || view === null) return;
241 const writer = createTerminalOutputWriter((data, reset) => view.writeOutput(data, reset));
242 writerRef.current = writer;
243 return () => {
244 writerRef.current = null;
245 writer.dispose();
246 };
247 }, [nativeReady, streaming]);
248
249 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- streams new terminal output into the native terminal view through its writer
250 useEffect(() => {
251 if (!streaming || !nativeReady || props.output === null) return;
252 void writerRef.current?.write(props.output).catch((error: unknown) => {
253 console.error("Terminal output delivery failed", error);
254 });
255 }, [nativeReady, props.output, streaming]);
256
257 const handleNativeInput = (event: NativeSyntheticEvent<TerminalInputEvent>) => {
258 if (!props.isRunning || props.readOnly) {
259 return;
260 }
261 onInput(event.nativeEvent.data);
262 };
263 const handleNativeResize = (event: NativeSyntheticEvent<TerminalResizeEvent>) => {
264 // The first grid event comes from the mounted native surface, after registration.
265 setNativeReady(true);
266 onResize({
267 cols: event.nativeEvent.cols,
268 rows: event.nativeEvent.rows,
269 });
270 };
271
272 if (NativeTerminalSurfaceView) {
273 return (
274 <View style={props.style}>
275 {/* oxlint-disable-next-line react/static-components -- The resolver returns a cached native component with stable identity. */}
276 <NativeTerminalSurfaceView
277 ref={nativeRef}
278 appearanceScheme={themeAppearance}
279 autoFocus={!props.readOnly && (props.autoFocus ?? true)}
280 readOnly={props.readOnly ?? false}
281 backgroundColor={theme.background}
282 focusRequest={props.isRunning && !props.readOnly ? (props.keyboardFocusRequest ?? 0) : 0}
283 foregroundColor={theme.foreground}
284 mutedForegroundColor={theme.mutedForeground}
285 terminalKey={props.terminalKey}
286 initialBuffer={streaming ? "" : props.buffer}
287 fontSize={fontSize}
288 sharedGrid={sharedGrid}
289 style={{ flex: 1 }}
290 themeConfig={buildGhosttyThemeConfig(theme)}
291 onInput={handleNativeInput}
292 onPaste={(event) => {
293 if (props.isRunning && !props.readOnly) props.onPaste?.(event.nativeEvent.text);
294 }}
295 onResize={handleNativeResize}
296 captureRequest={props.captureRequest}
297 onCapture={(event) => props.onCapture?.(event.nativeEvent.text)}
298 />
299 </View>
300 );
301 }
302
303 return <FallbackTerminalSurface {...props} ref={ref} fontSize={fontSize} theme={theme} />;
304});