| ... | ... | @@ -1,8 +1,7 @@ |
| 1 | 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 3 | | import type { MenuAction } from "@react-native-menu/menu"; |
| 4 | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 5 | | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 4 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 6 | 5 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 7 | 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 8 | 7 | import { Alert, Platform, Pressable, View } from "react-native"; |
| ... | ... | @@ -19,13 +18,11 @@ import { |
| 19 | 18 | useKeyboardState, |
| 20 | 19 | } from "react-native-keyboard-controller"; |
| 21 | 20 | |
| 22 | | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 23 | 21 | import { |
| 24 | 22 | ComposerToolbarButton, |
| 25 | 23 | ComposerToolbarRow, |
| 26 | 24 | ComposerToolbarScroller, |
| 27 | 25 | } from "../../components/ComposerToolbar"; |
| 28 | | import { ControlPillMenu } from "../../components/ControlPill"; |
| 29 | 26 | import { EmptyState } from "../../components/EmptyState"; |
| 30 | 27 | import { GlassSurface } from "../../components/GlassSurface"; |
| 31 | 28 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| ... | ... | @@ -52,8 +49,6 @@ import { |
| 52 | 49 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 53 | 50 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 54 | 51 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 55 | | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 56 | | import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; |
| 57 | 52 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 58 | 53 | import { getMobileTerminalTheme } from "./terminalTheme"; |
| 59 | 54 | import { terminalDebugLog } from "./terminalDebugLog"; |
| ... | ... | @@ -85,6 +80,90 @@ import { |
| 85 | 80 | import { createTerminalPasteSession } from "./terminalPaste"; |
| 86 | 81 | import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; |
| 87 | 82 | |
| 83 | function TerminalHeader(props: { |
| 84 | readonly subtitle: string; |
| 85 | readonly isEnvironmentReady: boolean; |
| 86 | readonly fontSize: number; |
| 87 | readonly terminalId: string; |
| 88 | readonly sessions: ReadonlyArray<TerminalMenuSession>; |
| 89 | readonly status: Parameters<typeof getTerminalStatusLabel>[0]; |
| 90 | readonly workspaceRoot: string; |
| 91 | readonly onCloseTerminal: () => void; |
| 92 | readonly onDecreaseFontSize: () => void; |
| 93 | readonly onIncreaseFontSize: () => void; |
| 94 | readonly onOpenNewTerminal: () => void; |
| 95 | readonly onSelectTerminal: (terminalId: string) => void; |
| 96 | }) { |
| 97 | return ( |
| 98 | <ScreenHeader |
| 99 | title="Terminal" |
| 100 | subtitle={props.subtitle} |
| 101 | onBack={props.onCloseTerminal} |
| 102 | backInSplitView={{ |
| 103 | accessibilityLabel: "Close terminal", |
| 104 | icon: "xmark", |
| 105 | separateBackground: true, |
| 106 | }} |
| 107 | menus={ |
| 108 | props.isEnvironmentReady |
| 109 | ? [ |
| 110 | { |
| 111 | title: "Terminal options", |
| 112 | icon: "terminal", |
| 113 | status: getTerminalStatusLabel(props.status), |
| 114 | items: [ |
| 115 | { |
| 116 | id: "text-size", |
| 117 | title: "Text size", |
| 118 | icon: "textformat.size", |
| 119 | inline: true, |
| 120 | items: [ |
| 121 | { |
| 122 | id: "font-decrease", |
| 123 | title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, props.fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, |
| 124 | disabled: props.fontSize <= MIN_TERMINAL_FONT_SIZE, |
| 125 | onPress: props.onDecreaseFontSize, |
| 126 | }, |
| 127 | { |
| 128 | id: "font-increase", |
| 129 | title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, props.fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, |
| 130 | disabled: props.fontSize >= MAX_TERMINAL_FONT_SIZE, |
| 131 | onPress: props.onIncreaseFontSize, |
| 132 | }, |
| 133 | ], |
| 134 | }, |
| 135 | ...props.sessions.map((session) => ({ |
| 136 | id: `terminal-session:${session.terminalId}`, |
| 137 | title: session.displayLabel, |
| 138 | icon: "terminal", |
| 139 | subtitle: [ |
| 140 | getTerminalStatusLabel({ |
| 141 | status: session.status, |
| 142 | hasRunningSubprocess: session.hasRunningSubprocess, |
| 143 | }), |
| 144 | basename(session.cwd), |
| 145 | ] |
| 146 | .filter(Boolean) |
| 147 | .join(" · "), |
| 148 | selected: session.terminalId === props.terminalId, |
| 149 | onPress: () => props.onSelectTerminal(session.terminalId), |
| 150 | })), |
| 151 | { |
| 152 | id: "terminal-new", |
| 153 | title: "Open new terminal", |
| 154 | icon: "plus", |
| 155 | subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`, |
| 156 | onPress: props.onOpenNewTerminal, |
| 157 | }, |
| 158 | ], |
| 159 | }, |
| 160 | ] |
| 161 | : undefined |
| 162 | } |
| 163 | /> |
| 164 | ); |
| 165 | } |
| 166 | |
| 88 | 167 | const DEFAULT_TERMINAL_COLS = 80; |
| 89 | 168 | const DEFAULT_TERMINAL_ROWS = 24; |
| 90 | 169 | const TERMINAL_ACCESSORY_HEIGHT = 52; |
| ... | ... | @@ -171,7 +250,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 171 | 250 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); |
| 172 | 251 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 173 | 252 | const { state: workspaceState } = useWorkspaceState(); |
| 174 | | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); |
| 175 | 253 | const params = props.route.params; |
| 176 | 254 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = |
| 177 | 255 | useThreadSelection(); |
| ... | ... | @@ -483,7 +561,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 483 | 561 | ); |
| 484 | 562 | |
| 485 | 563 | const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); |
| 486 | | const usesNativeHeaderGlass = Platform.OS === "ios"; |
| 487 | 564 | const pendingModifier = |
| 488 | 565 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; |
| 489 | 566 | const headerSubtitle = selectedThreadProject?.title ?? ""; |
| ... | ... | @@ -1003,67 +1080,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1003 | 1080 | setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); |
| 1004 | 1081 | }, [fontSize, setTerminalFontSize]); |
| 1005 | 1082 | |
| 1006 | | // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text |
| 1007 | | // size, session switching, and "Open new terminal", rendered through the |
| 1008 | | // token-styled anchored menu (the native header items are iOS-only). |
| 1009 | | const androidTerminalMenuActions = useMemo<MenuAction[]>( |
| 1010 | | () => [ |
| 1011 | | { |
| 1012 | | id: "text-size", |
| 1013 | | title: "Text size", |
| 1014 | | subactions: [ |
| 1015 | | { |
| 1016 | | id: "font-decrease", |
| 1017 | | title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, |
| 1018 | | attributes: fontSize <= MIN_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, |
| 1019 | | }, |
| 1020 | | { |
| 1021 | | id: "font-increase", |
| 1022 | | title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, |
| 1023 | | attributes: fontSize >= MAX_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, |
| 1024 | | }, |
| 1025 | | ], |
| 1026 | | }, |
| 1027 | | ...terminalMenuSessions.map((session): MenuAction => ({ |
| 1028 | | id: `terminal-session:${session.terminalId}`, |
| 1029 | | title: session.displayLabel, |
| 1030 | | subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] |
| 1031 | | .filter(Boolean) |
| 1032 | | .join(" · "), |
| 1033 | | state: session.terminalId === terminalId ? ("on" as const) : undefined, |
| 1034 | | })), |
| 1035 | | { |
| 1036 | | id: "terminal-new", |
| 1037 | | title: "Open new terminal", |
| 1038 | | image: "plus", |
| 1039 | | subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, |
| 1040 | | }, |
| 1041 | | ], |
| 1042 | | [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], |
| 1043 | | ); |
| 1044 | | |
| 1045 | | const handleAndroidTerminalMenuAction = useCallback( |
| 1046 | | (event: { nativeEvent: { event: string } }) => { |
| 1047 | | const id = event.nativeEvent.event; |
| 1048 | | if (id === "font-decrease") { |
| 1049 | | handleDecreaseFontSize(); |
| 1050 | | return; |
| 1051 | | } |
| 1052 | | if (id === "font-increase") { |
| 1053 | | handleIncreaseFontSize(); |
| 1054 | | return; |
| 1055 | | } |
| 1056 | | if (id === "terminal-new") { |
| 1057 | | handleOpenNewTerminal(); |
| 1058 | | return; |
| 1059 | | } |
| 1060 | | if (id.startsWith("terminal-session:")) { |
| 1061 | | handleSelectTerminal(id.slice("terminal-session:".length)); |
| 1062 | | } |
| 1063 | | }, |
| 1064 | | [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal], |
| 1065 | | ); |
| 1066 | | |
| 1067 | 1083 | const handleClearTerminal = useCallback(() => { |
| 1068 | 1084 | if (!selectedThread) { |
| 1069 | 1085 | return; |
| ... | ... | @@ -1169,117 +1185,24 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1169 | 1185 | }} |
| 1170 | 1186 | /> |
| 1171 | 1187 | ) : null} |
| 1172 | | <NativeStackScreenOptions |
| 1173 | | options={{ |
| 1174 | | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty |
| 1175 | | // scrolls internally, nothing for glass to sample). Default title/subtitle |
| 1176 | | // styling, like every other page. |
| 1177 | | // Android draws its own in-flow header (AndroidScreenHeader below); |
| 1178 | | // the native stack header stays iOS-only. |
| 1179 | | headerShown: Platform.OS !== "android", |
| 1180 | | title: "Terminal", |
| 1181 | | unstable_headerSubtitle: |
| 1182 | | usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined, |
| 1188 | <TerminalHeader |
| 1189 | subtitle={headerSubtitle} |
| 1190 | isEnvironmentReady={isEnvironmentReady} |
| 1191 | fontSize={fontSize} |
| 1192 | terminalId={terminalId} |
| 1193 | sessions={terminalMenuSessions} |
| 1194 | status={{ |
| 1195 | status: terminal.status, |
| 1196 | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 1183 | 1197 | }} |
| 1198 | workspaceRoot={selectedThreadProject.workspaceRoot} |
| 1199 | onCloseTerminal={handleCloseTerminal} |
| 1200 | onDecreaseFontSize={handleDecreaseFontSize} |
| 1201 | onIncreaseFontSize={handleIncreaseFontSize} |
| 1202 | onOpenNewTerminal={handleOpenNewTerminal} |
| 1203 | onSelectTerminal={handleSelectTerminal} |
| 1184 | 1204 | /> |
| 1185 | 1205 | |
| 1186 | | {Platform.OS === "android" ? ( |
| 1187 | | <AndroidScreenHeader |
| 1188 | | title="Terminal" |
| 1189 | | subtitle={headerSubtitle} |
| 1190 | | leading={<AndroidWorkspaceSidebarButton />} |
| 1191 | | onBack={handleCloseTerminal} |
| 1192 | | trailing={ |
| 1193 | | <> |
| 1194 | | {isEnvironmentReady ? ( |
| 1195 | | <ControlPillMenu |
| 1196 | | actions={androidTerminalMenuActions} |
| 1197 | | isAnchoredToRight |
| 1198 | | title={getTerminalStatusLabel({ |
| 1199 | | status: terminal.status, |
| 1200 | | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 1201 | | })} |
| 1202 | | onPressAction={handleAndroidTerminalMenuAction} |
| 1203 | | > |
| 1204 | | <AndroidHeaderIconButton accessibilityLabel="Terminal options" icon="terminal" /> |
| 1205 | | </ControlPillMenu> |
| 1206 | | ) : null} |
| 1207 | | </> |
| 1208 | | } |
| 1209 | | /> |
| 1210 | | ) : null} |
| 1211 | | |
| 1212 | | {layout.usesSplitView ? ( |
| 1213 | | <NativeHeaderToolbar placement="left"> |
| 1214 | | <NativeHeaderToolbar.Button |
| 1215 | | accessibilityLabel="Close terminal" |
| 1216 | | icon="xmark" |
| 1217 | | onPress={handleCloseTerminal} |
| 1218 | | separateBackground |
| 1219 | | /> |
| 1220 | | <NativeHeaderToolbar.Button |
| 1221 | | accessibilityLabel={panes.primarySidebarVisible ? "Maximize terminal" : "Show threads"} |
| 1222 | | icon={ |
| 1223 | | panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left" |
| 1224 | | } |
| 1225 | | onPress={togglePrimarySidebar} |
| 1226 | | separateBackground |
| 1227 | | /> |
| 1228 | | </NativeHeaderToolbar> |
| 1229 | | ) : null} |
| 1230 | | |
| 1231 | | {isEnvironmentReady ? ( |
| 1232 | | <NativeHeaderToolbar placement="right"> |
| 1233 | | <NativeHeaderToolbar.Menu icon="terminal" title="Terminal options" separateBackground> |
| 1234 | | <NativeHeaderToolbar.Label> |
| 1235 | | {getTerminalStatusLabel({ |
| 1236 | | status: terminal.status, |
| 1237 | | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 1238 | | })} |
| 1239 | | </NativeHeaderToolbar.Label> |
| 1240 | | <NativeHeaderToolbar.Menu icon="textformat.size" inline title="Text size"> |
| 1241 | | <NativeHeaderToolbar.Label>Text size</NativeHeaderToolbar.Label> |
| 1242 | | <NativeHeaderToolbar.MenuAction |
| 1243 | | disabled={fontSize <= MIN_TERMINAL_FONT_SIZE} |
| 1244 | | discoverabilityLabel="Decrease terminal text size" |
| 1245 | | onPress={handleDecreaseFontSize} |
| 1246 | | > |
| 1247 | | <NativeHeaderToolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label> |
| 1248 | | </NativeHeaderToolbar.MenuAction> |
| 1249 | | <NativeHeaderToolbar.MenuAction |
| 1250 | | disabled={fontSize >= MAX_TERMINAL_FONT_SIZE} |
| 1251 | | discoverabilityLabel="Increase terminal text size" |
| 1252 | | onPress={handleIncreaseFontSize} |
| 1253 | | > |
| 1254 | | <NativeHeaderToolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</NativeHeaderToolbar.Label> |
| 1255 | | </NativeHeaderToolbar.MenuAction> |
| 1256 | | </NativeHeaderToolbar.Menu> |
| 1257 | | {terminalMenuSessions.map((session) => ( |
| 1258 | | <NativeHeaderToolbar.MenuAction |
| 1259 | | key={session.terminalId} |
| 1260 | | icon={session.terminalId === terminalId ? "checkmark" : "terminal"} |
| 1261 | | onPress={() => handleSelectTerminal(session.terminalId)} |
| 1262 | | subtitle={[ |
| 1263 | | getTerminalStatusLabel({ status: session.status }), |
| 1264 | | basename(session.cwd), |
| 1265 | | ] |
| 1266 | | .filter(Boolean) |
| 1267 | | .join(" · ")} |
| 1268 | | > |
| 1269 | | <NativeHeaderToolbar.Label>{session.displayLabel}</NativeHeaderToolbar.Label> |
| 1270 | | </NativeHeaderToolbar.MenuAction> |
| 1271 | | ))} |
| 1272 | | <NativeHeaderToolbar.MenuAction |
| 1273 | | icon="plus" |
| 1274 | | onPress={handleOpenNewTerminal} |
| 1275 | | subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} |
| 1276 | | > |
| 1277 | | <NativeHeaderToolbar.Label>Open new terminal</NativeHeaderToolbar.Label> |
| 1278 | | </NativeHeaderToolbar.MenuAction> |
| 1279 | | </NativeHeaderToolbar.Menu> |
| 1280 | | </NativeHeaderToolbar> |
| 1281 | | ) : null} |
| 1282 | | |
| 1283 | 1206 | <MaterialScreenContent> |
| 1284 | 1207 | <View |
| 1285 | 1208 | className="flex-1" |