| ... | @@ -1,8 +1,7 @@ | ... | @@ -1,8 +1,7 @@ |
| 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; | 2 | import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; |
| 3 | import type { MenuAction } from "@react-native-menu/menu"; | | |
| 4 | import { SymbolView } from "../../components/AppSymbol"; | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 5 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 4 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 6 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 5 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 8 | import { Alert, Platform, Pressable, View } from "react-native"; | 7 | import { Alert, Platform, Pressable, View } from "react-native"; |
| ... | @@ -19,13 +18,11 @@ import { | ... | @@ -19,13 +18,11 @@ import { |
| 19 | useKeyboardState, | 18 | useKeyboardState, |
| 20 | } from "react-native-keyboard-controller"; | 19 | } from "react-native-keyboard-controller"; |
| 21 | | 20 | |
| 22 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | | |
| 23 | import { | 21 | import { |
| 24 | ComposerToolbarButton, | 22 | ComposerToolbarButton, |
| 25 | ComposerToolbarRow, | 23 | ComposerToolbarRow, |
| 26 | ComposerToolbarScroller, | 24 | ComposerToolbarScroller, |
| 27 | } from "../../components/ComposerToolbar"; | 25 | } from "../../components/ComposerToolbar"; |
| 28 | import { ControlPillMenu } from "../../components/ControlPill"; | | |
| 29 | import { EmptyState } from "../../components/EmptyState"; | 26 | import { EmptyState } from "../../components/EmptyState"; |
| 30 | import { GlassSurface } from "../../components/GlassSurface"; | 27 | import { GlassSurface } from "../../components/GlassSurface"; |
| 31 | import { LoadingScreen } from "../../components/LoadingScreen"; | 28 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| ... | @@ -52,8 +49,6 @@ import { | ... | @@ -52,8 +49,6 @@ import { |
| 52 | import { useThreadSelection } from "../../state/use-thread-selection"; | 49 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 53 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | 50 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 54 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; | 51 | import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; |
| 55 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | | |
| 56 | import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; | | |
| 57 | import { TerminalSurface } from "./NativeTerminalSurface"; | 52 | import { TerminalSurface } from "./NativeTerminalSurface"; |
| 58 | import { getMobileTerminalTheme } from "./terminalTheme"; | 53 | import { getMobileTerminalTheme } from "./terminalTheme"; |
| 59 | import { terminalDebugLog } from "./terminalDebugLog"; | 54 | import { terminalDebugLog } from "./terminalDebugLog"; |
| ... | @@ -85,6 +80,90 @@ import { | ... | @@ -85,6 +80,90 @@ import { |
| 85 | import { createTerminalPasteSession } from "./terminalPaste"; | 80 | import { createTerminalPasteSession } from "./terminalPaste"; |
| 86 | import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; | 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 | const DEFAULT_TERMINAL_COLS = 80; | 167 | const DEFAULT_TERMINAL_COLS = 80; |
| 89 | const DEFAULT_TERMINAL_ROWS = 24; | 168 | const DEFAULT_TERMINAL_ROWS = 24; |
| 90 | const TERMINAL_ACCESSORY_HEIGHT = 52; | 169 | const TERMINAL_ACCESSORY_HEIGHT = 52; |
| ... | @@ -171,7 +250,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -171,7 +250,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 171 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); | 250 | const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); |
| 172 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | 251 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 173 | const { state: workspaceState } = useWorkspaceState(); | 252 | const { state: workspaceState } = useWorkspaceState(); |
| 174 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | | |
| 175 | const params = props.route.params; | 253 | const params = props.route.params; |
| 176 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = | 254 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = |
| 177 | useThreadSelection(); | 255 | useThreadSelection(); |
| ... | @@ -483,7 +561,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -483,7 +561,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 483 | ); | 561 | ); |
| 484 | | 562 | |
| 485 | const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); | 563 | const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); |
| 486 | const usesNativeHeaderGlass = Platform.OS === "ios"; | | |
| 487 | const pendingModifier = | 564 | const pendingModifier = |
| 488 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; | 565 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; |
| 489 | const headerSubtitle = selectedThreadProject?.title ?? ""; | 566 | const headerSubtitle = selectedThreadProject?.title ?? ""; |
| ... | @@ -1003,67 +1080,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -1003,67 +1080,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1003 | setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); | 1080 | setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); |
| 1004 | }, [fontSize, setTerminalFontSize]); | 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 | const handleClearTerminal = useCallback(() => { | 1083 | const handleClearTerminal = useCallback(() => { |
| 1068 | if (!selectedThread) { | 1084 | if (!selectedThread) { |
| 1069 | return; | 1085 | return; |
| ... | @@ -1169,117 +1185,24 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) | ... | @@ -1169,117 +1185,24 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) |
| 1169 | }} | 1185 | }} |
| 1170 | /> | 1186 | /> |
| 1171 | ) : null} | 1187 | ) : null} |
| 1172 | <NativeStackScreenOptions | 1188 | <TerminalHeader |
| 1173 | options={{ | 1189 | subtitle={headerSubtitle} |
| 1174 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty | 1190 | isEnvironmentReady={isEnvironmentReady} |
| 1175 | // scrolls internally, nothing for glass to sample). Default title/subtitle | 1191 | fontSize={fontSize} |
| 1176 | // styling, like every other page. | 1192 | terminalId={terminalId} |
| 1177 | // Android draws its own in-flow header (AndroidScreenHeader below); | 1193 | sessions={terminalMenuSessions} |
| 1178 | // the native stack header stays iOS-only. | 1194 | status={{ |
| 1179 | headerShown: Platform.OS !== "android", | 1195 | status: terminal.status, |
| 1180 | title: "Terminal", | 1196 | hasRunningSubprocess: terminal.hasRunningSubprocess, |
| 1181 | unstable_headerSubtitle: | | |
| 1182 | usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined, | | |
| 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 | <MaterialScreenContent> | 1206 | <MaterialScreenContent> |
| 1284 | <View | 1207 | <View |
| 1285 | className="flex-1" | 1208 | className="flex-1" |