1import {
2 AuthTerminalReadScope,
3 AuthTerminalOperateScope,
4 DEFAULT_TERMINAL_ID,
5 EnvironmentId,
6 ThreadId,
7 sessionGrantsScope,
8} from "@t3tools/contracts";
9import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
10import { SymbolView } from "../../components/AppSymbol";
11import { ScreenHeader } from "../../components/ScreenHeader";
12import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
13import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
14import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15import { Alert, Platform, Pressable, View } from "react-native";
16import { useSafeAreaInsets } from "react-native-safe-area-context";
17import { AppText as Text } from "../../components/AppText";
18import { TerminalContextSheet } from "./TerminalContextSheet";
19import { hasNativeTerminalSurface } from "./nativeTerminalModule";
20import * as Clipboard from "expo-clipboard";
21import * as Schema from "effect/Schema";
22import {
23 KeyboardController,
24 KeyboardEvents,
25 KeyboardStickyView,
26 useKeyboardState,
27} from "react-native-keyboard-controller";
28
29import {
30 ComposerToolbarButton,
31 ComposerToolbarRow,
32 ComposerToolbarScroller,
33} from "../../components/ComposerToolbar";
34import { EmptyState } from "../../components/EmptyState";
35import { GlassSurface } from "../../components/GlassSurface";
36import { LoadingScreen } from "../../components/LoadingScreen";
37import { MaterialScreenContent } from "../../components/MaterialScreenContent";
38import { MaterialButton } from "../../components/MaterialButton";
39import { MaterialIconButton } from "../../components/MaterialIconButton";
40import { environmentCatalog } from "../../connection/catalog";
41import { useEnvironmentPresentation } from "../../state/presentation";
42import { terminalEnvironment } from "../../state/terminal";
43import { environmentSession, readEnvironmentScope } from "../../state/session";
44import { useEnvironmentQuery } from "../../state/query";
45import { useAtomCommand } from "../../state/use-atom-command";
46import { uuidv4 } from "../../lib/uuid";
47import { useServerConfigs } from "../../state/entities";
48import { useConnectionsReady } from "../../state/workspace";
49import {
50 MAX_TERMINAL_FONT_SIZE,
51 MIN_TERMINAL_FONT_SIZE,
52 TERMINAL_FONT_SIZE_STEP,
53 stepTerminalFontSize,
54} from "../../lib/appearancePreferences";
55import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
56import {
57 useAttachedTerminalSession,
58 useKnownTerminalSessions,
59} from "../../state/use-terminal-session";
60import { useThreadSelection } from "../../state/use-thread-selection";
61import { useSelectedThreadProjection } from "../../state/use-thread-detail";
62import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
63import { TerminalSurface } from "./NativeTerminalSurface";
64import { getMobileTerminalTheme } from "./terminalTheme";
65import { terminalDebugLog } from "./terminalDebugLog";
66import {
67 getTerminalBufferReplayKey,
68 getTerminalSurfaceReplayBuffer,
69 TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
70} from "./terminalBufferReplay";
71import {
72 resolveTerminalOpenLocation,
73 takePendingTerminalLaunch,
74 type PendingTerminalLaunch,
75} from "./terminalLaunchContext";
76import {
77 basename,
78 buildTerminalMenuSessions,
79 getTerminalStatusLabel,
80 nextOpenTerminalId,
81 previousLiveTerminalId,
82 resolveTerminalSessionLabel,
83 type TerminalMenuSession,
84} from "./terminalMenu";
85import {
86 hostPlatformFromOs,
87 resolveModifiedTerminalInput,
88 type HostPlatform,
89 type PendingModifier,
90} from "./terminalInput";
91import { createTerminalPasteSession } from "./terminalPaste";
92import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
93import { useTerminalGridSync } from "./useTerminalGridSync";
94import { useTerminalLifecycle } from "./useTerminalLifecycle";
95
96function TerminalHeader(props: {
97 readonly subtitle: string;
98 readonly isEnvironmentReady: boolean;
99 readonly canOperateTerminal: boolean;
100 readonly fontSize: number;
101 readonly terminalId: string;
102 readonly sessions: ReadonlyArray<TerminalMenuSession>;
103 readonly status: Parameters<typeof getTerminalStatusLabel>[0];
104 readonly workspaceRoot: string;
105 readonly onCloseTerminal: () => void;
106 readonly onDecreaseFontSize: () => void;
107 readonly onIncreaseFontSize: () => void;
108 readonly onOpenNewTerminal: () => void;
109 readonly onSelectTerminal: (terminalId: string) => void;
110}) {
111 return (
112 <ScreenHeader
113 title="Terminal"
114 subtitle={props.subtitle}
115 onBack={props.onCloseTerminal}
116 backInSplitView={{
117 accessibilityLabel: "Back to chat",
118 icon: "chevron.left",
119 }}
120 menus={
121 props.isEnvironmentReady
122 ? [
123 {
124 title: "Terminal options",
125 icon: "terminal",
126 status: getTerminalStatusLabel(props.status),
127 items: [
128 {
129 id: "text-size",
130 title: "Text size",
131 icon: "textformat.size",
132 inline: true,
133 items: [
134 {
135 id: "font-decrease",
136 title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, props.fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`,
137 disabled: props.fontSize <= MIN_TERMINAL_FONT_SIZE,
138 onPress: props.onDecreaseFontSize,
139 },
140 {
141 id: "font-increase",
142 title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, props.fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`,
143 disabled: props.fontSize >= MAX_TERMINAL_FONT_SIZE,
144 onPress: props.onIncreaseFontSize,
145 },
146 ],
147 },
148 ...props.sessions.map((session) => ({
149 id: `terminal-session:${session.terminalId}`,
150 title: session.displayLabel,
151 icon: "terminal",
152 subtitle: [
153 getTerminalStatusLabel({
154 status: session.status,
155 hasRunningSubprocess: session.hasRunningSubprocess,
156 }),
157 basename(session.cwd),
158 ]
159 .filter(Boolean)
160 .join(" · "),
161 selected: session.terminalId === props.terminalId,
162 onPress: () => props.onSelectTerminal(session.terminalId),
163 })),
164 {
165 id: "terminal-new",
166 disabled: !props.canOperateTerminal,
167 title: "Open new terminal",
168 icon: "plus",
169 subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`,
170 onPress: props.onOpenNewTerminal,
171 },
172 ],
173 },
174 ]
175 : undefined
176 }
177 />
178 );
179}
180
181const DEFAULT_TERMINAL_COLS = 80;
182const DEFAULT_TERMINAL_ROWS = 24;
183const TERMINAL_ACCESSORY_HEIGHT = 52;
184const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
185
186class TerminalClipboardReadError extends Schema.TaggedError<TerminalClipboardReadError>()(
187 "TerminalClipboardReadError",
188 { terminalId: Schema.String, cause: Schema.Defect() },
189) {
190 override get message(): string {
191 return `Failed to read the clipboard for a paste into terminal ${this.terminalId}.`;
192 }
193}
194
195type TerminalToolbarAction =
196 | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string }
197 | { readonly kind: "clear"; readonly key: string; readonly label: string }
198 | { readonly kind: "paste"; readonly key: string; readonly label: string }
199 | {
200 readonly kind: "modifier";
201 readonly key: string;
202 readonly label: string;
203 readonly modifier: PendingModifier;
204 };
205
206// A blank param (a hand-typed deep link) is treated as missing, since branded
207// IDs reject whitespace-only values.
208function firstRouteParam(value: string | string[] | undefined): string | null {
209 const first = Array.isArray(value) ? value[0] : value;
210 return first === undefined || first.trim().length === 0 ? null : first;
211}
212
213function inferHostPlatform(environmentLabel: string | null): HostPlatform {
214 const value = environmentLabel?.toLowerCase() ?? "";
215 if (
216 value.includes("mac") ||
217 value.includes("macbook") ||
218 value.includes("mac mini") ||
219 value.includes("imac") ||
220 value.includes("darwin")
221 ) {
222 return "mac";
223 }
224 if (value.includes("windows") || value.includes("win")) {
225 return "windows";
226 }
227 if (value.includes("linux") || value.includes("ubuntu") || value.includes("debian")) {
228 return "linux";
229 }
230
231 return "unknown";
232}
233
234function pickRunningTerminalSessionForBootstrap(
235 sessions: ReadonlyArray<KnownTerminalSession>,
236): KnownTerminalSession | null {
237 const running = sessions.filter(
238 (session) => session.state.status === "running" || session.state.status === "starting",
239 );
240 if (running.length === 0) {
241 return null;
242 }
243 return (
244 running.find((session) => session.target.terminalId === DEFAULT_TERMINAL_ID) ??
245 running[0] ??
246 null
247 );
248}
249
250type ThreadTerminalRouteScreenProps = StaticScreenProps<{
251 readonly environmentId: string;
252 readonly threadId: string;
253 readonly terminalId?: string;
254}>;
255
256export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {
257 const insets = useSafeAreaInsets();
258 const nativeColumnMetrics = useNativeColumnLayoutMetrics();
259 const navigation = useNavigation();
260 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
261 const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize");
262 const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");
263 const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
264 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
265 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
266 const connectionsReady = useConnectionsReady();
267 const params = props.route.params;
268 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
269 useThreadSelection();
270 const selectedThreadDetail = useSelectedThreadProjection();
271 const selectedThreadDetailWorktreePath =
272 selectedThreadDetail?.projection.thread.worktreePath ?? null;
273 const routeEnvironmentIdRaw = firstRouteParam(params.environmentId);
274 const routeThreadIdRaw = firstRouteParam(params.threadId);
275 const routeEnvironmentId = routeEnvironmentIdRaw
276 ? EnvironmentId.make(routeEnvironmentIdRaw)
277 : null;
278 const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null;
279 const terminalSession = useEnvironmentQuery(
280 routeEnvironmentId === null ? null : environmentSession.sessionStateAtom(routeEnvironmentId),
281 );
282 const isAuthenticated =
283 terminalSession.error === null && terminalSession.data?.authenticated === true;
284 const canOperateTerminal =
285 isAuthenticated &&
286 terminalSession.data !== null &&
287 sessionGrantsScope(terminalSession.data, AuthTerminalOperateScope);
288 const canReadTerminal =
289 isAuthenticated &&
290 terminalSession.data !== null &&
291 sessionGrantsScope(terminalSession.data, AuthTerminalReadScope);
292 const environment = useEnvironmentPresentation(routeEnvironmentId);
293 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
294 const requestedTerminalId = firstRouteParam(params.terminalId);
295 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;
296 const [captureRequest, setCaptureRequest] = useState(0);
297 const [capturedOutput, setCapturedOutput] = useState<string | null>(null);
298 const {
299 isReady: hasResolvedFontPreference,
300 appearance,
301 themeAppearance: appearanceScheme,
302 themeId,
303 setTerminalFontSize,
304 themeVariables,
305 } = useAppearancePreferences();
306 const fontSize = appearance.terminalFontSize;
307 const cachedRouteGridSize =
308 routeEnvironmentId && routeThreadId
309 ? getCachedTerminalGridSize({
310 environmentId: routeEnvironmentId,
311 threadId: routeThreadId,
312 terminalId,
313 })
314 : null;
315 const {
316 sessions: knownSessions,
317 isPending: sessionsPending,
318 error: sessionsError,
319 } = useKnownTerminalSessions({
320 environmentId: selectedThread?.environmentId ?? null,
321 threadId: selectedThread?.id ?? null,
322 });
323 const runningSession = useMemo(
324 () =>
325 pickRunningTerminalSessionForBootstrap(knownSessions ?? []) ??
326 (canOperateTerminal ? null : (knownSessions?.[0] ?? null)),
327 [canOperateTerminal, knownSessions],
328 );
329 const activeKnownSession = useMemo(
330 () => knownSessions?.find((session) => session.target.terminalId === terminalId) ?? null,
331 [knownSessions, terminalId],
332 );
333 const hasTerminalTarget = requestedTerminalId !== null || activeKnownSession !== null;
334 const launchTarget = useMemo(
335 () =>
336 selectedThread
337 ? {
338 environmentId: selectedThread.environmentId,
339 threadId: selectedThread.id,
340 terminalId,
341 }
342 : null,
343 [selectedThread, terminalId],
344 );
345 const launchTargetKey = launchTarget
346 ? `${launchTarget.environmentId}:${launchTarget.threadId}:${launchTarget.terminalId}`
347 : null;
348 const [pendingLaunchEntry, setPendingLaunchEntry] = useState<{
349 readonly key: string | null;
350 readonly launch: PendingTerminalLaunch | null;
351 }>(() => ({
352 key: launchTargetKey,
353 launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget),
354 }));
355 const pendingLaunch =
356 pendingLaunchEntry.key === launchTargetKey ? pendingLaunchEntry.launch : null;
357 const hasResolvedPendingLaunch = pendingLaunchEntry.key === launchTargetKey;
358 const [initialAttachGridEntry, setInitialAttachGridEntry] = useState(() => ({
359 key: launchTargetKey,
360 size: cachedRouteGridSize ?? {
361 cols: DEFAULT_TERMINAL_COLS,
362 rows: DEFAULT_TERMINAL_ROWS,
363 },
364 }));
365 const initialAttachGridSize =
366 initialAttachGridEntry.key === launchTargetKey ? initialAttachGridEntry.size : null;
367 const [lastGridSize, setLastGridSize] = useState(
368 cachedRouteGridSize ?? {
369 cols: DEFAULT_TERMINAL_COLS,
370 rows: DEFAULT_TERMINAL_ROWS,
371 },
372 );
373 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);
374 const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false);
375 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
376 const firstNonEmptyBufferLoggedRef = useRef(false);
377 const lastBufferReplayKeyRef = useRef<string | null>(null);
378 const sentInitialInputKeyRef = useRef<string | null>(null);
379 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
380 /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */
381 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true);
382 const [pendingModifierState, setPendingModifierState] = useState<{
383 readonly terminalId: string;
384 readonly value: PendingModifier | null;
385 }>({
386 terminalId,
387 value: null,
388 });
389 const shouldRedirectToRunningTerminal =
390 requestedTerminalId === null &&
391 runningSession !== null &&
392 runningSession.target.terminalId !== terminalId;
393 const launchLocationCandidate = useMemo(() => {
394 if (!selectedThread || !selectedThreadProject?.workspaceRoot) {
395 return null;
396 }
397 if (pendingLaunch) {
398 return {
399 cwd: pendingLaunch.cwd,
400 worktreePath: pendingLaunch.worktreePath,
401 };
402 }
403 return resolveTerminalOpenLocation({
404 terminalLocation: activeKnownSession?.state.summary ?? null,
405 activeSessionLocation: activeKnownSession?.state.summary ?? null,
406 workspaceRoot: selectedThreadProject.workspaceRoot,
407 threadShellWorktreePath: selectedThread.worktreePath ?? null,
408 threadDetailWorktreePath: selectedThreadDetailWorktreePath,
409 });
410 }, [
411 activeKnownSession?.state.summary,
412 pendingLaunch,
413 selectedThread,
414 selectedThreadDetailWorktreePath,
415 selectedThreadProject?.workspaceRoot,
416 ]);
417 const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({
418 key: launchTargetKey,
419 location: launchLocationCandidate,
420 }));
421 const launchLocation =
422 initialLaunchLocationEntry.key === launchTargetKey ? initialLaunchLocationEntry.location : null;
423 const terminalAttachInput = useMemo(
424 () =>
425 selectedThread !== null &&
426 launchLocation !== null &&
427 hasResolvedPendingLaunch &&
428 initialAttachGridSize !== null &&
429 hasResolvedFontPreference &&
430 hasMeasuredSurface &&
431 isEnvironmentReady &&
432 !shouldRedirectToRunningTerminal
433 ? {
434 threadId: selectedThread.id,
435 terminalId,
436 cwd: launchLocation.cwd,
437 worktreePath: launchLocation.worktreePath,
438 cols: initialAttachGridSize.cols,
439 rows: initialAttachGridSize.rows,
440 ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}),
441 ...(pendingLaunch ? { restartIfNotRunning: true } : {}),
442 }
443 : null,
444 [
445 hasMeasuredSurface,
446 hasResolvedFontPreference,
447 hasResolvedPendingLaunch,
448 initialAttachGridSize,
449 isEnvironmentReady,
450 launchLocation,
451 pendingLaunch,
452 selectedThread,
453 shouldRedirectToRunningTerminal,
454 terminalId,
455 ],
456 );
457 const observingTerminal =
458 !canOperateTerminal && canReadTerminal && selectedThread !== null && hasTerminalTarget;
459 const terminal = useAttachedTerminalSession({
460 environmentId: selectedThread?.environmentId ?? null,
461 terminal: canOperateTerminal
462 ? terminalAttachInput
463 : observingTerminal
464 ? { threadId: selectedThread.id, terminalId }
465 : null,
466 });
467 const terminalKey = selectedThread
468 ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}`
469 : terminalId;
470 useTerminalGridSync({
471 environmentId: selectedThread?.environmentId ?? null,
472 threadId: selectedThread?.id ?? null,
473 terminalId,
474 canOperate: canOperateTerminal,
475 terminal,
476 size: lastGridSize,
477 resize: resizeTerminal,
478 });
479 const bufferReplayKey = useMemo(
480 () => getTerminalBufferReplayKey({ terminalKey, fontSize }),
481 [fontSize, terminalKey],
482 );
483 if (lastBufferReplayKeyRef.current === null) {
484 lastBufferReplayKeyRef.current = bufferReplayKey;
485 }
486 const terminalSurfaceBuffer = getTerminalSurfaceReplayBuffer({
487 buffer: terminal.buffer,
488 replayKey: bufferReplayKey,
489 readyReplayKey: readyBufferReplayKey,
490 });
491 const isRunning = terminal.status === "running" || terminal.status === "starting";
492
493 const pendingExitNavigationRef = useRef<string | null>(null);
494
495 useEffect(() => {
496 terminalDebugLog("surface:props", {
497 terminalKey,
498 atomBufferLen: terminal.buffer.length,
499 surfaceBufferLen: terminalSurfaceBuffer.length,
500 replayKey: bufferReplayKey,
501 readyReplayKey: readyBufferReplayKey,
502 status: terminal.status,
503 version: terminal.version,
504 });
505 }, [
506 bufferReplayKey,
507 readyBufferReplayKey,
508 terminal.buffer.length,
509 terminal.status,
510 terminal.version,
511 terminalKey,
512 terminalSurfaceBuffer.length,
513 ]);
514
515 useEffect(() => {
516 terminalDebugLog("session:status", {
517 terminalKey,
518 status: terminal.status,
519 error: terminal.error,
520 summary: terminal.summary?.cwd ?? null,
521 bufferLen: terminal.buffer.length,
522 version: terminal.version,
523 });
524 }, [
525 terminal.buffer.length,
526 terminal.error,
527 terminal.status,
528 terminal.summary?.cwd,
529 terminal.version,
530 terminalKey,
531 ]);
532
533 useEffect(() => {
534 if (terminal.buffer.length === 0 || firstNonEmptyBufferLoggedRef.current) {
535 return;
536 }
537 firstNonEmptyBufferLoggedRef.current = true;
538 terminalDebugLog("session:first-nonempty-buffer", {
539 terminalKey,
540 length: terminal.buffer.length,
541 preview: terminal.buffer.slice(0, 160),
542 });
543 }, [terminal.buffer, terminal.buffer.length, terminalKey]);
544 const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;
545 const serverConfigs = useServerConfigs();
546 const hostOs =
547 routeEnvironmentId === null
548 ? null
549 : (serverConfigs.get(routeEnvironmentId)?.environment.platform.os ?? null);
550 // The descriptor is authoritative; the label is only a hint until it arrives.
551 const hostPlatform = useMemo(
552 () =>
553 hostPlatformFromOs(hostOs) ??
554 inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null),
555 [hostOs, selectedEnvironmentConnection?.environmentLabel],
556 );
557
558 const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme);
559 const pendingModifier =
560 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
561 const headerSubtitle = selectedThreadProject?.title ?? "";
562 const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => {
563 const modifierActions: ReadonlyArray<TerminalToolbarAction> =
564 hostPlatform === "mac"
565 ? [
566 { kind: "modifier", key: "cmd", label: "cmd", modifier: "meta" },
567 { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" },
568 ]
569 : [
570 { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" },
571 { kind: "modifier", key: "alt", label: "alt", modifier: "meta" },
572 ];
573
574 return [
575 { kind: "send", key: "esc", label: "esc", data: "\u001b" },
576 ...modifierActions,
577 { kind: "send", key: "tab", label: "tab", data: "\t" },
578 { kind: "paste", key: "paste", label: "paste" },
579 { kind: "clear", key: "clear", label: "clear" },
580 { kind: "send", key: "up", label: "↑", data: "\u001b[A" },
581 { kind: "send", key: "down", label: "↓", data: "\u001b[B" },
582 { kind: "send", key: "left", label: "←", data: "\u001b[D" },
583 { kind: "send", key: "right", label: "→", data: "\u001b[C" },
584 { kind: "send", key: "tilde", label: "~", data: "~" },
585 { kind: "send", key: "pipe", label: "|", data: "|" },
586 { kind: "send", key: "slash", label: "/", data: "/" },
587 { kind: "send", key: "dash", label: "-", data: "-" },
588 ];
589 }, [hostPlatform]);
590 const keyboardState = useKeyboardState((state) => ({
591 height: state.height,
592 isVisible: state.isVisible,
593 }));
594 const isAccessoryVisible = canOperateTerminal && keyboardState.isVisible && !isAccessoryDismissed;
595 // Android's terminal owns an EditText; turning off autoFocus also clears its native focus.
596 const terminalAutoFocus =
597 Platform.OS === "android"
598 ? !isAccessoryDismissed &&
599 (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || keyboardState.isVisible)
600 : !SHOWCASE_ENABLED;
601 const terminalBottomInset =
602 (keyboardState.isVisible ? keyboardState.height : 0) +
603 (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0);
604
605 useEffect(() => {
606 const keyboardWillShow = KeyboardEvents.addListener("keyboardWillShow", () => {
607 setIsAccessoryDismissed(false);
608 });
609 const keyboardWillHide = KeyboardEvents.addListener("keyboardWillHide", () => {
610 setIsAccessoryDismissed(true);
611 });
612
613 return () => {
614 keyboardWillShow.remove();
615 keyboardWillHide.remove();
616 };
617 }, []);
618
619 const terminalMenuSessions = useMemo<ReadonlyArray<TerminalMenuSession>>(
620 () =>
621 buildTerminalMenuSessions({
622 knownSessions: knownSessions ?? [],
623 workspaceRoot: selectedThreadProject?.workspaceRoot ?? null,
624 currentSession: {
625 terminalId,
626 cwd: cwd ?? null,
627 status: terminal.status,
628 hasRunningSubprocess: terminal.hasRunningSubprocess,
629 displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary),
630 updatedAt: terminal.updatedAt,
631 },
632 }),
633 [
634 cwd,
635 knownSessions,
636 selectedThreadProject?.workspaceRoot,
637 terminal.hasRunningSubprocess,
638 terminal.summary,
639 terminal.status,
640 terminal.updatedAt,
641 terminalId,
642 ],
643 );
644
645 useEffect(() => {
646 if (pendingLaunchEntry.key === launchTargetKey) {
647 return;
648 }
649 setPendingLaunchEntry({
650 key: launchTargetKey,
651 launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget),
652 });
653 }, [launchTarget, launchTargetKey, pendingLaunchEntry.key]);
654
655 useEffect(() => {
656 if (initialAttachGridEntry.key === launchTargetKey) {
657 return;
658 }
659 setInitialAttachGridEntry({
660 key: launchTargetKey,
661 size: cachedRouteGridSize ?? {
662 cols: DEFAULT_TERMINAL_COLS,
663 rows: DEFAULT_TERMINAL_ROWS,
664 },
665 });
666 }, [cachedRouteGridSize, initialAttachGridEntry.key, launchTargetKey]);
667
668 useEffect(() => {
669 if (
670 initialLaunchLocationEntry.key === launchTargetKey &&
671 initialLaunchLocationEntry.location !== null
672 ) {
673 return;
674 }
675 if (initialLaunchLocationEntry.key === launchTargetKey && launchLocationCandidate === null) {
676 return;
677 }
678 setInitialLaunchLocationEntry({
679 key: launchTargetKey,
680 location: launchLocationCandidate,
681 });
682 }, [
683 initialLaunchLocationEntry.key,
684 initialLaunchLocationEntry.location,
685 launchLocationCandidate,
686 launchTargetKey,
687 ]);
688
689 useEffect(() => {
690 if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) {
691 return;
692 }
693 navigation.dispatch(
694 StackActions.replace("ThreadTerminal", {
695 environmentId: String(selectedThread.environmentId),
696 threadId: String(selectedThread.id),
697 terminalId: runningSession.target.terminalId,
698 }),
699 );
700 }, [navigation, runningSession, selectedThread, shouldRedirectToRunningTerminal]);
701
702 useEffect(() => {
703 const initialInput = pendingLaunch?.initialInput;
704 if (
705 !canOperateTerminal ||
706 !initialInput ||
707 !selectedThread ||
708 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) ||
709 terminal.version === 0 ||
710 sentInitialInputKeyRef.current === launchTargetKey
711 ) {
712 return;
713 }
714 sentInitialInputKeyRef.current = launchTargetKey;
715 void writeTerminal({
716 environmentId: selectedThread.environmentId,
717 input: {
718 threadId: selectedThread.id,
719 terminalId,
720 data: initialInput,
721 },
722 });
723 }, [
724 launchTargetKey,
725 pendingLaunch?.initialInput,
726 selectedThread,
727 terminal.version,
728 terminalId,
729 writeTerminal,
730 canOperateTerminal,
731 ]);
732
733 useEffect(() => {
734 firstNonEmptyBufferLoggedRef.current = false;
735 sentInitialInputKeyRef.current = null;
736 }, [terminalKey]);
737
738 const clearBufferReplayTimer = useCallback(() => {
739 if (bufferReplayTimerRef.current !== null) {
740 clearTimeout(bufferReplayTimerRef.current);
741 bufferReplayTimerRef.current = null;
742 }
743 }, []);
744
745 const scheduleBufferReplayReady = useCallback(() => {
746 clearBufferReplayTimer();
747 const replayKey = bufferReplayKey;
748 terminalDebugLog("replay:schedule-ready", {
749 replayKey,
750 delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
751 });
752 bufferReplayTimerRef.current = setTimeout(() => {
753 bufferReplayTimerRef.current = null;
754 setReadyBufferReplayKey(replayKey);
755 terminalDebugLog("replay:ready", { replayKey });
756 }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS);
757 }, [bufferReplayKey, clearBufferReplayTimer]);
758
759 useEffect(() => {
760 if (lastBufferReplayKeyRef.current === bufferReplayKey) {
761 return;
762 }
763
764 lastBufferReplayKeyRef.current = bufferReplayKey;
765 clearBufferReplayTimer();
766 setReadyBufferReplayKey(null);
767 }, [bufferReplayKey, clearBufferReplayTimer]);
768
769 useEffect(() => clearBufferReplayTimer, [clearBufferReplayTimer]);
770
771 useEffect(() => {
772 if (!routeEnvironmentId || !routeThreadId) {
773 setLastGridSize({
774 cols: DEFAULT_TERMINAL_COLS,
775 rows: DEFAULT_TERMINAL_ROWS,
776 });
777 return;
778 }
779
780 setLastGridSize(
781 getCachedTerminalGridSize({
782 environmentId: routeEnvironmentId,
783 threadId: routeThreadId,
784 terminalId,
785 }) ?? {
786 cols: DEFAULT_TERMINAL_COLS,
787 rows: DEFAULT_TERMINAL_ROWS,
788 },
789 );
790 setHasMeasuredSurface(true);
791 }, [routeEnvironmentId, routeThreadId, terminalId]);
792
793 /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */
794 const writeInput = useCallback(
795 async (data: string): Promise<boolean> => {
796 if (
797 !selectedThread ||
798 !isRunning ||
799 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
800 ) {
801 return false;
802 }
803
804 const result = await writeTerminal({
805 environmentId: selectedThread.environmentId,
806 input: {
807 threadId: selectedThread.id,
808 terminalId,
809 data,
810 },
811 });
812 return result._tag === "Success";
813 },
814 [isRunning, selectedThread, terminalId, writeTerminal],
815 );
816
817 const pasteSessionRef = useRef<ReturnType<typeof createTerminalPasteSession> | null>(null);
818 if (pasteSessionRef.current === null) {
819 pasteSessionRef.current = createTerminalPasteSession();
820 }
821 const pasteSession = pasteSessionRef.current;
822
823 // Drop delayed clipboard reads whenever the route or attached pty changes.
824 useEffect(() => {
825 pasteSession.reset(canOperateTerminal && isRunning);
826 return () => {
827 pasteSession.reset(false);
828 };
829 }, [canOperateTerminal, isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]);
830
831 const pasteFromClipboard = useCallback(async () => {
832 await pasteSession.paste({
833 readText: Clipboard.getStringAsync,
834 write: writeInput,
835 onReadError: (cause) => {
836 console.error(new TerminalClipboardReadError({ terminalId, cause }));
837 },
838 });
839 }, [pasteSession, terminalId, writeInput]);
840
841 /** Sends a key through the armed toolbar modifier, if any, and disarms it. */
842 const writeModifiedInput = useCallback(
843 (data: string) => {
844 if (pendingModifier === null) {
845 void writeInput(data);
846 return;
847 }
848
849 setPendingModifierState({ terminalId, value: null });
850 const resolved = resolveModifiedTerminalInput({
851 data,
852 modifier: pendingModifier,
853 hostPlatform,
854 });
855 if (resolved.kind === "paste") {
856 void pasteFromClipboard();
857 return;
858 }
859 void writeInput(resolved.data);
860 },
861 [hostPlatform, pasteFromClipboard, pendingModifier, terminalId, writeInput],
862 );
863
864 const handleInput = useCallback(
865 (data: string) => {
866 if (data.length === 0) {
867 return;
868 }
869
870 writeModifiedInput(data);
871 },
872 [writeModifiedInput],
873 );
874
875 const handleResize = useCallback(
876 (size: { readonly cols: number; readonly rows: number }) => {
877 terminalDebugLog("native:onResize", {
878 cols: size.cols,
879 rows: size.rows,
880 terminalKey,
881 });
882 setHasMeasuredSurface(true);
883 if (readyBufferReplayKey !== bufferReplayKey) {
884 scheduleBufferReplayReady();
885 }
886 if (routeEnvironmentId && routeThreadId) {
887 cacheTerminalGridSize(
888 {
889 environmentId: routeEnvironmentId,
890 threadId: routeThreadId,
891 terminalId,
892 },
893 size,
894 );
895 }
896 if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) {
897 return;
898 }
899
900 setLastGridSize(size);
901 },
902 [
903 lastGridSize.cols,
904 lastGridSize.rows,
905 bufferReplayKey,
906 readyBufferReplayKey,
907 routeEnvironmentId,
908 routeThreadId,
909 scheduleBufferReplayReady,
910 terminalId,
911 terminalKey,
912 ],
913 );
914
915 const handleSelectTerminal = useCallback(
916 (nextTerminalId: string) => {
917 if (!selectedThread || nextTerminalId === terminalId) {
918 return;
919 }
920
921 navigation.dispatch(
922 StackActions.replace("ThreadTerminal", {
923 environmentId: String(selectedThread.environmentId),
924 threadId: String(selectedThread.id),
925 terminalId: nextTerminalId,
926 }),
927 );
928 },
929 [navigation, selectedThread, terminalId],
930 );
931
932 const handleCloseTerminal = useCallback(() => {
933 if (navigation.canGoBack()) {
934 navigation.goBack();
935 return;
936 }
937 navigation.dispatch(
938 StackActions.replace("Thread", {
939 environmentId: params.environmentId,
940 threadId: params.threadId,
941 }),
942 );
943 }, [navigation, params.environmentId, params.threadId]);
944
945 const navigateAwayAfterExit = useCallback(() => {
946 // With other shells still live, fall through to the previous one instead
947 // of dropping the user back on the thread.
948 const fallbackTerminalId = previousLiveTerminalId({
949 sessions: terminalMenuSessions,
950 exitedTerminalId: terminalId,
951 });
952 if (fallbackTerminalId !== null && selectedThread) {
953 navigation.dispatch(
954 StackActions.replace("ThreadTerminal", {
955 environmentId: String(selectedThread.environmentId),
956 threadId: String(selectedThread.id),
957 terminalId: fallbackTerminalId,
958 }),
959 );
960 return;
961 }
962 if (navigation.canGoBack()) {
963 navigation.goBack();
964 return;
965 }
966 // Deep-linked/root mounts have nothing to pop; land on the thread
967 // instead of stranding the user on a dead terminal.
968 if (selectedThread) {
969 navigation.dispatch(
970 StackActions.replace("Thread", {
971 environmentId: String(selectedThread.environmentId),
972 threadId: String(selectedThread.id),
973 }),
974 );
975 }
976 }, [navigation, selectedThread, terminalId, terminalMenuSessions]);
977
978 useTerminalLifecycle({
979 terminalKey,
980 canOperate: canOperateTerminal,
981 observing: observingTerminal,
982 attached: terminalAttachInput !== null && selectedThread !== null,
983 terminal,
984 reopen: async () => {
985 if (
986 terminalAttachInput === null ||
987 selectedThread === null ||
988 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
989 )
990 return false;
991 const result = await openTerminal({
992 environmentId: selectedThread.environmentId,
993 input: {
994 threadId: selectedThread.id,
995 terminalId,
996 cwd: terminalAttachInput.cwd,
997 worktreePath: terminalAttachInput.worktreePath,
998 cols: terminalAttachInput.cols,
999 rows: terminalAttachInput.rows,
1000 ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}),
1001 },
1002 });
1003 return result._tag === "Success";
1004 },
1005 onRunning: () => {
1006 pendingExitNavigationRef.current = null;
1007 },
1008 onExit: () => {
1009 if (
1010 selectedThread === null ||
1011 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1012 )
1013 return;
1014 void closeTerminal({
1015 environmentId: selectedThread.environmentId,
1016 input: { threadId: selectedThread.id, terminalId },
1017 });
1018 if (navigation.isFocused()) {
1019 navigateAwayAfterExit();
1020 return;
1021 }
1022 // Leave a background terminal screen when it is focused again.
1023 pendingExitNavigationRef.current = terminalKey;
1024 },
1025 });
1026
1027 useEffect(
1028 () =>
1029 navigation.addListener("focus", () => {
1030 if (pendingExitNavigationRef.current !== terminalKey) {
1031 return;
1032 }
1033 pendingExitNavigationRef.current = null;
1034 navigateAwayAfterExit();
1035 }),
1036 [navigateAwayAfterExit, navigation, terminalKey],
1037 );
1038
1039 const handleOpenNewTerminal = useCallback(() => {
1040 if (
1041 !selectedThread ||
1042 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1043 ) {
1044 return;
1045 }
1046
1047 navigation.dispatch(
1048 StackActions.replace("ThreadTerminal", {
1049 environmentId: String(selectedThread.environmentId),
1050 threadId: String(selectedThread.id),
1051 terminalId: nextOpenTerminalId({
1052 listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
1053 activeRouteTerminalId: terminalId,
1054 ...(knownSessions === null ||
1055 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
1056 ? { uniqueSuffix: uuidv4() }
1057 : {}),
1058 }),
1059 }),
1060 );
1061 }, [knownSessions, navigation, selectedThread, terminalId, terminalMenuSessions]);
1062
1063 const handleDecreaseFontSize = useCallback(() => {
1064 setTerminalFontSize(stepTerminalFontSize(fontSize, -1));
1065 }, [fontSize, setTerminalFontSize]);
1066
1067 const handleIncreaseFontSize = useCallback(() => {
1068 setTerminalFontSize(stepTerminalFontSize(fontSize, 1));
1069 }, [fontSize, setTerminalFontSize]);
1070
1071 const handleClearTerminal = useCallback(() => {
1072 if (
1073 !selectedThread ||
1074 !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
1075 ) {
1076 return;
1077 }
1078
1079 setPendingModifierState({ terminalId, value: null });
1080 void clearTerminal({
1081 environmentId: selectedThread.environmentId,
1082 input: {
1083 threadId: selectedThread.id,
1084 terminalId,
1085 },
1086 });
1087 }, [clearTerminal, selectedThread, terminalId]);
1088
1089 const handleToolbarActionPress = useCallback(
1090 (action: TerminalToolbarAction) => {
1091 if (action.kind === "modifier") {
1092 setPendingModifierState((current) => ({
1093 terminalId,
1094 value:
1095 (current.terminalId === terminalId ? current.value : null) === action.modifier
1096 ? null
1097 : action.modifier,
1098 }));
1099 return;
1100 }
1101
1102 if (action.kind === "clear") {
1103 handleClearTerminal();
1104 return;
1105 }
1106
1107 if (action.kind === "paste") {
1108 setPendingModifierState({ terminalId, value: null });
1109 void pasteFromClipboard();
1110 return;
1111 }
1112
1113 writeModifiedInput(action.data);
1114 },
1115 [handleClearTerminal, pasteFromClipboard, terminalId, writeModifiedInput],
1116 );
1117
1118 const handleDismissKeyboard = useCallback(() => {
1119 setIsAccessoryDismissed(true);
1120 void KeyboardController.dismiss();
1121 }, []);
1122
1123 const handleShowKeyboard = useCallback(() => {
1124 if (!canOperateTerminal) return;
1125 setIsAccessoryDismissed(false);
1126 setKeyboardFocusRequest((current) => current + 1);
1127 }, [canOperateTerminal]);
1128 const handleRetryEnvironment = useCallback(() => {
1129 if (routeEnvironmentId !== null) {
1130 void retryEnvironment(routeEnvironmentId);
1131 }
1132 }, [retryEnvironment, routeEnvironmentId]);
1133
1134 if (!selectedThread) {
1135 if (!connectionsReady) {
1136 return <LoadingScreen message="Opening terminal…" />;
1137 }
1138
1139 return (
1140 <View className="flex-1 bg-screen">
1141 <EmptyState
1142 title="Thread unavailable"
1143 detail="This terminal route needs an active thread and workspace."
1144 />
1145 </View>
1146 );
1147 }
1148
1149 if (!selectedThreadProject?.workspaceRoot) {
1150 return (
1151 <View className="flex-1 bg-screen">
1152 <EmptyState
1153 title="Terminal unavailable"
1154 detail="This thread does not have a workspace root yet, so there is nowhere to open a shell."
1155 />
1156 </View>
1157 );
1158 }
1159
1160 if (!environment.isReady && environment.presentation === null) {
1161 return <LoadingScreen message="Opening terminal…" />;
1162 }
1163
1164 return (
1165 <>
1166 {capturedOutput !== null && selectedThread ? (
1167 <TerminalContextSheet
1168 text={capturedOutput}
1169 environmentId={selectedThread.environmentId}
1170 threadId={selectedThread.id}
1171 terminalId={terminalId}
1172 terminalLabel={resolveTerminalSessionLabel(terminalId, terminal.summary)}
1173 onClose={() => setCapturedOutput(null)}
1174 onAttach={() => {
1175 setCapturedOutput(null);
1176 if (navigation.canGoBack()) navigation.goBack();
1177 }}
1178 />
1179 ) : null}
1180 <TerminalHeader
1181 canOperateTerminal={canOperateTerminal}
1182 subtitle={headerSubtitle}
1183 isEnvironmentReady={isEnvironmentReady}
1184 fontSize={fontSize}
1185 terminalId={terminalId}
1186 sessions={terminalMenuSessions}
1187 status={{
1188 status: terminal.status,
1189 hasRunningSubprocess: terminal.hasRunningSubprocess,
1190 }}
1191 workspaceRoot={selectedThreadProject.workspaceRoot}
1192 onCloseTerminal={handleCloseTerminal}
1193 onDecreaseFontSize={handleDecreaseFontSize}
1194 onIncreaseFontSize={handleIncreaseFontSize}
1195 onOpenNewTerminal={handleOpenNewTerminal}
1196 onSelectTerminal={handleSelectTerminal}
1197 />
1198
1199 <MaterialScreenContent>
1200 <View
1201 className="flex-1"
1202 style={{
1203 backgroundColor:
1204 Platform.OS === "android"
1205 ? themeVariables["--color-card-alt"]
1206 : terminalTheme.background,
1207 // The v5 column extends behind UIKit chrome. Its measured top
1208 // reservation is zero when UIKit has already inset the body or moved the bar.
1209 paddingTop:
1210 Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0,
1211 paddingBottom:
1212 Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0,
1213 }}
1214 >
1215 {!isEnvironmentReady ? (
1216 <EnvironmentConnectionNotice
1217 environmentLabel={
1218 environment.presentation?.entry.target.label ??
1219 selectedEnvironmentConnection?.environmentLabel ??
1220 "Environment"
1221 }
1222 connection={
1223 environment.presentation?.connection ?? {
1224 phase: "available",
1225 error: null,
1226 traceId: null,
1227 }
1228 }
1229 resourceName="terminal"
1230 onRetry={handleRetryEnvironment}
1231 />
1232 ) : terminalSession.data === null && terminalSession.error === null ? (
1233 <EmptyState
1234 title="Checking terminal access"
1235 detail="Waiting for this connection's permissions."
1236 />
1237 ) : !canReadTerminal && !canOperateTerminal ? (
1238 <EmptyState
1239 title={
1240 terminalSession.error
1241 ? "Could not check terminal access"
1242 : "Terminal access unavailable"
1243 }
1244 detail={
1245 terminalSession.error ??
1246 "This connection does not have permission to view terminals."
1247 }
1248 />
1249 ) : !canOperateTerminal && !hasTerminalTarget && sessionsPending ? (
1250 <EmptyState title="Loading terminals" detail="Reading existing terminal sessions." />
1251 ) : !canOperateTerminal && !hasTerminalTarget && sessionsError !== null ? (
1252 <EmptyState title="Could not load terminals" detail={sessionsError} />
1253 ) : !canOperateTerminal && !hasTerminalTarget ? (
1254 <EmptyState
1255 title="No terminal sessions"
1256 detail="Existing terminals will appear here when another client opens one."
1257 />
1258 ) : !canOperateTerminal && terminal.error !== null ? (
1259 <EmptyState title="Terminal unavailable" detail={terminal.error} />
1260 ) : (
1261 <>
1262 <View
1263 style={{
1264 flex: 1,
1265 paddingBottom: terminalBottomInset,
1266 }}
1267 >
1268 <View
1269 pointerEvents="none"
1270 style={{
1271 position: "absolute",
1272 inset: 0,
1273 backgroundColor: terminalTheme.background,
1274 }}
1275 />
1276 <TerminalSurface
1277 autoFocus={canOperateTerminal && terminalAutoFocus}
1278 readOnly={!canOperateTerminal}
1279 buffer={terminal.version === 0 ? null : terminalSurfaceBuffer}
1280 fontSize={fontSize}
1281 isRunning={isRunning}
1282 keyboardFocusRequest={keyboardFocusRequest}
1283 captureRequest={captureRequest}
1284 onCapture={(text) => {
1285 if (text.trim()) setCapturedOutput(text);
1286 else Alert.alert("No terminal output", "There is no visible output to attach.");
1287 }}
1288 onInput={handleInput}
1289 onResize={handleResize}
1290 style={{ flex: 1 }}
1291 terminalKey={terminalKey}
1292 theme={terminalTheme}
1293 />
1294 </View>
1295
1296 {Platform.OS === "android" && !keyboardState.isVisible ? (
1297 <View className="min-h-14 flex-row items-center gap-2 bg-card-alt px-2">
1298 {selectedThread && hasNativeTerminalSurface() ? (
1299 <MaterialButton
1300 label="Attach output"
1301 tone="text"
1302 onPress={() => setCaptureRequest((value) => value + 1)}
1303 />
1304 ) : null}
1305 <View className="flex-1" />
1306 <MaterialIconButton
1307 accessibilityLabel="Show keyboard"
1308 disabled={!canOperateTerminal}
1309 icon="keyboard"
1310 onPress={handleShowKeyboard}
1311 />
1312 </View>
1313 ) : Platform.OS !== "android" && selectedThread && hasNativeTerminalSurface() ? (
1314 <Pressable
1315 accessibilityRole="button"
1316 onPress={() => {
1317 KeyboardController.dismiss();
1318 setCaptureRequest((value) => value + 1);
1319 }}
1320 className="px-4 py-2"
1321 >
1322 <Text style={{ color: terminalTheme.foreground }}>Attach visible output</Text>
1323 </Pressable>
1324 ) : null}
1325 {isAccessoryVisible ? (
1326 <KeyboardStickyView
1327 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1328 offset={{ closed: 0, opened: 0 }}
1329 >
1330 <View
1331 className="border-t"
1332 style={{
1333 backgroundColor: terminalTheme.background,
1334 borderTopColor: terminalTheme.border,
1335 minHeight: TERMINAL_ACCESSORY_HEIGHT,
1336 }}
1337 >
1338 <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}>
1339 <ComposerToolbarScroller
1340 contentPaddingRight={2}
1341 fadeOpaque={terminalTheme.background}
1342 fadeTransparent={`${terminalTheme.background}00`}
1343 >
1344 {terminalToolbarActions.map((action) => {
1345 const active =
1346 action.kind === "modifier" && pendingModifier === action.modifier;
1347
1348 return (
1349 <ComposerToolbarButton
1350 key={action.key}
1351 active={active}
1352 label={action.label}
1353 maxWidth={120}
1354 minWidth={action.label.length > 1 ? 56 : 44}
1355 onPress={() => handleToolbarActionPress(action)}
1356 showChevron={false}
1357 textTransform={
1358 action.kind === "modifier" || action.kind === "clear"
1359 ? "uppercase"
1360 : "none"
1361 }
1362 />
1363 );
1364 })}
1365 </ComposerToolbarScroller>
1366 <ComposerToolbarButton
1367 accessibilityLabel="Dismiss keyboard"
1368 icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }}
1369 onPress={handleDismissKeyboard}
1370 showChevron={false}
1371 />
1372 </ComposerToolbarRow>
1373 </View>
1374 </KeyboardStickyView>
1375 ) : canOperateTerminal && !keyboardState.isVisible && Platform.OS !== "android" ? (
1376 <Pressable
1377 accessibilityLabel="Show keyboard"
1378 accessibilityRole="button"
1379 onPress={handleShowKeyboard}
1380 style={({ pressed }) => ({
1381 bottom: 16,
1382 borderRadius: 28,
1383 opacity: pressed ? 0.72 : 1,
1384 position: "absolute",
1385 right: 16,
1386 })}
1387 >
1388 <GlassSurface
1389 chrome="none"
1390 fallbackColor={terminalTheme.background}
1391 glassEffectStyle="regular"
1392 tintColor="transparent"
1393 style={{
1394 alignItems: "center",
1395 borderRadius: 24,
1396 height: 48,
1397 justifyContent: "center",
1398 width: 48,
1399 }}
1400 pointerEvents="none"
1401 >
1402 <SymbolView
1403 name={{ ios: "keyboard", android: "keyboard" }}
1404 size={20}
1405 tintColor={terminalTheme.foreground}
1406 type="monochrome"
1407 />
1408 </GlassSurface>
1409 </Pressable>
1410 ) : null}
1411 </>
1412 )}
1413 </View>
1414 </MaterialScreenContent>
1415 </>
1416 );
1417}