authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 12:55:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 12:55:15-07:00
log7ee60f4bc964c2d8d8cf182c173faaabcf902b7d
treef60be89345cbd713c5fd11e5f0aed6eae97ab03c
parent54bbed660fc5dc99c6bfe335981e6d431275c91e
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): share terminal header and menu configuration (#12391)


1 files changed, 100 insertions(+), 177 deletions(-)

apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+100-177
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";2import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
3import type { MenuAction } from "@react-native-menu/menu";
4import { SymbolView } from "../../components/AppSymbol";3import { SymbolView } from "../../components/AppSymbol";
5import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";4import { ScreenHeader } from "../../components/ScreenHeader";
6import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";6import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { Alert, Platform, Pressable, View } from "react-native";7import { 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";
2120
22import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
23import {21import {
24 ComposerToolbarButton,22 ComposerToolbarButton,
25 ComposerToolbarRow,23 ComposerToolbarRow,
26 ComposerToolbarScroller,24 ComposerToolbarScroller,
27} from "../../components/ComposerToolbar";25} from "../../components/ComposerToolbar";
28import { ControlPillMenu } from "../../components/ControlPill";
29import { EmptyState } from "../../components/EmptyState";26import { EmptyState } from "../../components/EmptyState";
30import { GlassSurface } from "../../components/GlassSurface";27import { GlassSurface } from "../../components/GlassSurface";
31import { LoadingScreen } from "../../components/LoadingScreen";28import { LoadingScreen } from "../../components/LoadingScreen";
...@@ -52,8 +49,6 @@ import {...@@ -52,8 +49,6 @@ import {
52import { useThreadSelection } from "../../state/use-thread-selection";49import { useThreadSelection } from "../../state/use-thread-selection";
53import { useSelectedThreadDetail } from "../../state/use-thread-detail";50import { useSelectedThreadDetail } from "../../state/use-thread-detail";
54import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";51import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
55import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
56import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
57import { TerminalSurface } from "./NativeTerminalSurface";52import { TerminalSurface } from "./NativeTerminalSurface";
58import { getMobileTerminalTheme } from "./terminalTheme";53import { getMobileTerminalTheme } from "./terminalTheme";
59import { terminalDebugLog } from "./terminalDebugLog";54import { terminalDebugLog } from "./terminalDebugLog";
...@@ -85,6 +80,90 @@ import {...@@ -85,6 +80,90 @@ import {
85import { createTerminalPasteSession } from "./terminalPaste";80import { createTerminalPasteSession } from "./terminalPaste";
86import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";81import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
8782
83function 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
88const DEFAULT_TERMINAL_COLS = 80;167const DEFAULT_TERMINAL_COLS = 80;
89const DEFAULT_TERMINAL_ROWS = 24;168const DEFAULT_TERMINAL_ROWS = 24;
90const TERMINAL_ACCESSORY_HEIGHT = 52;169const 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 );
484562
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]);
10051082
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 <NativeStackScreenOptions1188 <TerminalHeader
1173 options={{1189 subtitle={headerSubtitle}
1174 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty1190 isEnvironmentReady={isEnvironmentReady}
1175 // scrolls internally, nothing for glass to sample). Default title/subtitle1191 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 />
11851205
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 <View1207 <View
1285 className="flex-1"1208 className="flex-1"