History

Stage remote terminal launches and replay hydrated buffers

- Carry pending launch context into terminal open - Delay buffer replay until surface hydration is stable - Co-authored-by: codex <codex@users.noreply.github.com>

Julius Marminge committed 7 months ago

Commit 034a8bc1 parent 97263c515 files · +697 −171Browse files

Unified · Split

15 files
  1. apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +2 −1
  2. apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +12 −17
  3. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +17 −23
  4. apps/mobile/src/features/threads/ThreadTerminalRouteScreen.test.ts +44 −2
  5. apps/mobile/src/features/threads/ThreadTerminalRouteScreen.tsx +262 −56
  6. apps/mobile/src/features/threads/terminalBufferReplay.test.ts +43 −0
  7. apps/mobile/src/features/threads/terminalBufferReplay.ts +16 −0
  8. apps/mobile/src/features/threads/terminalLaunchContext.test.ts +68 −0
  9. apps/mobile/src/features/threads/terminalLaunchContext.ts +49 −0
  10. apps/mobile/src/features/threads/terminalRouteBootstrap.ts +9 −0
  11. apps/mobile/src/features/threads/terminalUiState.test.ts +52 −0
  12. apps/mobile/src/features/threads/terminalUiState.ts +50 −0
  13. apps/mobile/src/native/terminal/NativeTerminalSurface.tsx +3 −3
  14. apps/mobile/src/native/terminal/terminalTheme.test.ts +20 −20
  15. apps/mobile/src/native/terminal/terminalTheme.ts +50 −49
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt+2−1
@@ -47,10 +47,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex · 46 unmodified lines
47 }47 }
48 }48 }
49 49
50 var fontSize: Float = 12f50 var fontSize: Float = 10f
51 set(value) {51 set(value) {
52 field = value52 field = value
53 textView.textSize = value53 textView.textSize = value
54 inputView.textSize = max(value, 13f)
54 emitResize()55 emitResize()
55 }56 }
56 57
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+12−17
@@ -100,23 +100,26 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 99 unmodified lines
100 }100 }
101 }101 }
102 102
103 var fontSize: CGFloat = 12 {103 var fontSize: CGFloat = 10 {
104 didSet {104 didSet {
105 guard oldValue != fontSize else { return }
105 inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)106 inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
106 resetSurface()107 refreshSurface()
107 }108 }
108 }109 }
109 110
110 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {111 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
111 didSet {112 didSet {
113 guard oldValue != appearanceScheme else { return }
112 appearance = TerminalAppearanceScheme(value: appearanceScheme)114 appearance = TerminalAppearanceScheme(value: appearanceScheme)
113 updateGhosttyTheme()115 refreshSurface()
114 }116 }
115 }117 }
116 118
117 var themeConfig: String = "" {119 var themeConfig: String = "" {
118 didSet {120 didSet {
119 updateGhosttyTheme()121 guard oldValue != themeConfig else { return }
122 refreshSurface()
120 }123 }
121 }124 }
122 125
@@ -353,6 +356,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 230 unmodified lines
353 setNeedsLayout()356 setNeedsLayout()
354 }357 }
355 358
359 private func refreshSurface() {
360 resetSurface()
361 createSurfaceIfPossible()
362 }
363
356 private func destroySurface() {364 private func destroySurface() {
357 if let surface {365 if let surface {
358 ghostty_surface_set_write_callback(surface, nil, nil)366 ghostty_surface_set_write_callback(surface, nil, nil)
@@ -525,19 +533,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 166 unmodified lines
525 terminalViewport.backgroundColor = backgroundColorValue533 terminalViewport.backgroundColor = backgroundColorValue
526 }534 }
527 535
528 private func updateGhosttyTheme() {
529 guard let app, let surface else { return }
530 guard let config = ghostty_config_new() else { return }
531 loadThemeConfig(into: config)
532 ghostty_config_finalize(config)
533 ghostty_app_update_config(app, config)
534 ghostty_surface_update_config(surface, config)
535 ghostty_app_set_color_scheme(app, appearance.ghosttyColorScheme)
536 ghostty_surface_set_color_scheme(surface, appearance.ghosttyColorScheme)
537 ghostty_config_free(config)
538 redrawSurface()
539 }
540
541 private func loadThemeConfig(into config: ghostty_config_t) {536 private func loadThemeConfig(into config: ghostty_config_t) {
542 guard let path = writeThemeConfigFile() else { return }537 guard let path = writeThemeConfigFile() else { return }
543 path.withCString { cString in538 path.withCString { cString in
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+17−23
@@ -18,11 +18,10 @@ import { connectionTone } from "../connection/connectionTone"; · 17 unmodified lines
18 18
19import { useRemoteCatalog } from "../../state/use-remote-catalog";19import { useRemoteCatalog } from "../../state/use-remote-catalog";
20import {20import {
21 getEnvironmentClient,
22 useRemoteConnectionStatus,21 useRemoteConnectionStatus,
23 useRemoteEnvironmentState,22 useRemoteEnvironmentState,
24} from "../../state/use-remote-environment-registry";23} from "../../state/use-remote-environment-registry";
25import { terminalSessionManager, useKnownTerminalSessions } from "../../state/use-terminal-session";24import { useKnownTerminalSessions } from "../../state/use-terminal-session";
26import { useSelectedThreadDetail } from "../../state/use-thread-detail";25import { useSelectedThreadDetail } from "../../state/use-thread-detail";
27import { useThreadSelection } from "../../state/use-thread-selection";26import { useThreadSelection } from "../../state/use-thread-selection";
28import { GitActionProgressOverlay } from "./GitActionProgressOverlay";27import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
@@ -31,7 +30,10 @@ import { · 2 unmodified lines
31 nextTerminalId,30 nextTerminalId,
32 resolveProjectScriptTerminalId,31 resolveProjectScriptTerminalId,
33} from "./terminalMenu";32} from "./terminalMenu";
34import { resolvePreferredThreadWorktreePath } from "./terminalLaunchContext";33import {
34 resolvePreferredThreadWorktreePath,
35 stagePendingTerminalLaunch,
36} from "./terminalLaunchContext";
35import { ThreadDetailScreen } from "./ThreadDetailScreen";37import { ThreadDetailScreen } from "./ThreadDetailScreen";
36import { ThreadGitControls } from "./ThreadGitControls";38import { ThreadGitControls } from "./ThreadGitControls";
37import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";39import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
@@ -166,11 +168,6 @@ export function ThreadRouteScreen() { · 128 unmodified lines
166 return;168 return;
167 }169 }
168 170
169 const client = getEnvironmentClient(selectedThread.environmentId);
170 if (!client) {
171 return;
172 }
173
174 const targetTerminalId = resolveProjectScriptTerminalId({171 const targetTerminalId = resolveProjectScriptTerminalId({
175 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),172 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
176 hasRunningTerminal: terminalMenuSessions.some(173 hasRunningTerminal: terminalMenuSessions.some(
@@ -189,23 +186,20 @@ export function ThreadRouteScreen() { · 12 unmodified lines
189 project: { cwd: selectedThreadProject.workspaceRoot },186 project: { cwd: selectedThreadProject.workspaceRoot },
190 worktreePath: preferredWorktreePath,187 worktreePath: preferredWorktreePath,
191 });188 });
192 const snapshot = await client.terminal.open({189 stagePendingTerminalLaunch({
193 threadId: selectedThread.id,190 target: {
194 terminalId: targetTerminalId,191 environmentId: selectedThread.environmentId,
195 cwd,192 threadId: selectedThread.id,
196 worktreePath: preferredWorktreePath,193 terminalId: targetTerminalId,
197 env,194 },
195 launch: {
196 cwd,
197 worktreePath: preferredWorktreePath,
198 env,
199 initialInput: `${script.command}\r`,
200 },
198 });201 });
199 202
200 terminalSessionManager.syncSnapshot(
201 { environmentId: selectedThread.environmentId },
202 snapshot,
203 );
204 await client.terminal.write({
205 threadId: selectedThread.id,
206 terminalId: targetTerminalId,
207 data: `${script.command}\r`,
208 });
209 void router.push(buildThreadTerminalRoutePath(selectedThread, targetTerminalId));203 void router.push(buildThreadTerminalRoutePath(selectedThread, targetTerminalId));
210 },204 },
211 [205 [
apps/mobile/src/features/threads/ThreadTerminalRouteScreen.test.ts+44−2
@@ -12,6 +12,8 @@ describe("resolveTerminalRouteBootstrap", () => { · 11 unmodified lines
12 requestedTerminalId: null,12 requestedTerminalId: null,
13 currentTerminalId: "default",13 currentTerminalId: "default",
14 runningTerminalId: "term-2",14 runningTerminalId: "term-2",
15 currentTerminalStatus: "closed",
16 hasCurrentTerminalHydration: false,
15 }),17 }),
16 ).toEqual({18 ).toEqual({
17 kind: "redirect",19 kind: "redirect",
@@ -19,7 +21,7 @@ describe("resolveTerminalRouteBootstrap", () => { · 1 unmodified line
19 });21 });
20 });22 });
21 23
22 it("hydrates the current running terminal instead of skipping open", () => {24 it("hydrates the current running terminal when client state is not hydrated yet", () => {
23 expect(25 expect(
24 resolveTerminalRouteBootstrap({26 resolveTerminalRouteBootstrap({
25 hasThread: true,27 hasThread: true,
@@ -28,13 +30,15 @@ describe("resolveTerminalRouteBootstrap", () => { · 2 unmodified lines
28 requestedTerminalId: null,30 requestedTerminalId: null,
29 currentTerminalId: "default",31 currentTerminalId: "default",
30 runningTerminalId: "default",32 runningTerminalId: "default",
33 currentTerminalStatus: "running",
34 hasCurrentTerminalHydration: false,
31 }),35 }),
32 ).toEqual({36 ).toEqual({
33 kind: "open",37 kind: "open",
34 });38 });
35 });39 });
36 40
37 it("opens explicit terminal routes once so they replay existing history", () => {41 it("opens explicit terminal routes when the session still needs hydration", () => {
38 expect(42 expect(
39 resolveTerminalRouteBootstrap({43 resolveTerminalRouteBootstrap({
40 hasThread: true,44 hasThread: true,
@@ -43,6 +47,8 @@ describe("resolveTerminalRouteBootstrap", () => { · 2 unmodified lines
43 requestedTerminalId: "term-2",47 requestedTerminalId: "term-2",
44 currentTerminalId: "term-2",48 currentTerminalId: "term-2",
45 runningTerminalId: "term-2",49 runningTerminalId: "term-2",
50 currentTerminalStatus: "running",
51 hasCurrentTerminalHydration: false,
46 }),52 }),
47 ).toEqual({53 ).toEqual({
48 kind: "open",54 kind: "open",
@@ -58,6 +64,42 @@ describe("resolveTerminalRouteBootstrap", () => { · 9 unmodified lines
58 requestedTerminalId: null,64 requestedTerminalId: null,
59 currentTerminalId: "default",65 currentTerminalId: "default",
60 runningTerminalId: "default",66 runningTerminalId: "default",
67 currentTerminalStatus: "running",
68 hasCurrentTerminalHydration: true,
69 }),
70 ).toEqual({
71 kind: "idle",
72 });
73 });
74
75 it("stays idle when the current running terminal is already hydrated in client state", () => {
76 expect(
77 resolveTerminalRouteBootstrap({
78 hasThread: true,
79 hasWorkspaceRoot: true,
80 hasOpened: false,
81 requestedTerminalId: null,
82 currentTerminalId: "default",
83 runningTerminalId: "default",
84 currentTerminalStatus: "running",
85 hasCurrentTerminalHydration: true,
86 }),
87 ).toEqual({
88 kind: "idle",
89 });
90 });
91
92 it("stays idle for explicit running terminal routes that already have hydrated output", () => {
93 expect(
94 resolveTerminalRouteBootstrap({
95 hasThread: true,
96 hasWorkspaceRoot: true,
97 hasOpened: false,
98 requestedTerminalId: "term-2",
99 currentTerminalId: "term-2",
100 runningTerminalId: "term-2",
101 currentTerminalStatus: "running",
102 hasCurrentTerminalHydration: true,
61 }),103 }),
62 ).toEqual({104 ).toEqual({
63 kind: "idle",105 kind: "idle",
apps/mobile/src/features/threads/ThreadTerminalRouteScreen.tsx+262−56
@@ -1,7 +1,7 @@
1import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts";1import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts";
2import { Stack, useLocalSearchParams, useRouter } from "expo-router";2import { Stack, useLocalSearchParams, useRouter } from "expo-router";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { Pressable, ScrollView, Text as RNText, View } from "react-native";4import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
5import { KeyboardStickyView } from "react-native-keyboard-controller";5import { KeyboardStickyView } from "react-native-keyboard-controller";
6import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated";6import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated";
7 7
@@ -21,9 +21,19 @@ import { · 13 unmodified lines
21import { useThreadSelection } from "../../state/use-thread-selection";21import { useThreadSelection } from "../../state/use-thread-selection";
22import { useSelectedThreadDetail } from "../../state/use-thread-detail";22import { useSelectedThreadDetail } from "../../state/use-thread-detail";
23import { TerminalSurface } from "../../native/terminal/NativeTerminalSurface";23import { TerminalSurface } from "../../native/terminal/NativeTerminalSurface";
24import { getPierreTerminalTheme } from "../../native/terminal/terminalTheme";
24import { loadPreferences, savePreferencesPatch } from "../../lib/storage";25import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
26import {
27 getTerminalBufferReplayKey,
28 getTerminalSurfaceReplayBuffer,
29 TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
30} from "./terminalBufferReplay";
25import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap";31import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap";
26import { resolveTerminalOpenLocation } from "./terminalLaunchContext";32import {
33 resolveTerminalOpenLocation,
34 stagePendingTerminalLaunch,
35 takePendingTerminalLaunch,
36} from "./terminalLaunchContext";
27import {37import {
28 basename,38 basename,
29 buildTerminalMenuSessions,39 buildTerminalMenuSessions,
@@ -38,6 +48,12 @@ import { · 8 unmodified lines
38 TERMINAL_FONT_SIZE_STEP,48 TERMINAL_FONT_SIZE_STEP,
39 normalizeTerminalFontSize,49 normalizeTerminalFontSize,
40} from "./terminalPreferences";50} from "./terminalPreferences";
51import {
52 cacheTerminalFontSize,
53 cacheTerminalGridSize,
54 getCachedTerminalFontSize,
55 getCachedTerminalGridSize,
56} from "./terminalUiState";
41 57
42const DEFAULT_TERMINAL_COLS = 80;58const DEFAULT_TERMINAL_COLS = 80;
43const DEFAULT_TERMINAL_ROWS = 24;59const DEFAULT_TERMINAL_ROWS = 24;
@@ -154,9 +170,14 @@ function applyCtrlModifier(input: string): string { · 110 unmodified lines
154 return input;170 return input;
155}171}
156 172
173function withAlpha(hexColor: string, alpha: string): string {
174 return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor;
175}
176
157export function ThreadTerminalRouteScreen() {177export function ThreadTerminalRouteScreen() {
158 const router = useRouter();178 const router = useRouter();
159 const keyboard = useAnimatedKeyboard();179 const keyboard = useAnimatedKeyboard();
180 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
160 const { isLoadingSavedConnection } = useRemoteEnvironmentState();181 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
161 const params = useLocalSearchParams<{182 const params = useLocalSearchParams<{
162 environmentId?: string | string[];183 environmentId?: string | string[];
@@ -166,21 +187,45 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines
166 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =187 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
167 useThreadSelection();188 useThreadSelection();
168 const selectedThreadDetail = useSelectedThreadDetail();189 const selectedThreadDetail = useSelectedThreadDetail();
190 const routeEnvironmentId = firstRouteParam(params.environmentId);
191 const routeThreadId = firstRouteParam(params.threadId);
169 const requestedTerminalId = firstRouteParam(params.terminalId);192 const requestedTerminalId = firstRouteParam(params.terminalId);
193 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;
194 const cachedFontSize = getCachedTerminalFontSize();
195 const cachedRouteGridSize =
196 routeEnvironmentId && routeThreadId
197 ? getCachedTerminalGridSize({
198 environmentId: routeEnvironmentId,
199 threadId: routeThreadId,
200 terminalId,
201 })
202 : null;
170 const knownSessions = useKnownTerminalSessions({203 const knownSessions = useKnownTerminalSessions({
171 environmentId: selectedThread?.environmentId ?? null,204 environmentId: selectedThread?.environmentId ?? null,
172 threadId: selectedThread?.id ?? null,205 threadId: selectedThread?.id ?? null,
173 });206 });
174 const [lastGridSize, setLastGridSize] = useState({207 const [lastGridSize, setLastGridSize] = useState(
175 cols: DEFAULT_TERMINAL_COLS,208 cachedRouteGridSize ?? {
176 rows: DEFAULT_TERMINAL_ROWS,209 cols: DEFAULT_TERMINAL_COLS,
177 });210 rows: DEFAULT_TERMINAL_ROWS,
178 const [pendingModifier, setPendingModifier] = useState<PendingModifier | null>(null);211 },
179 const [fontSize, setFontSize] = useState(DEFAULT_TERMINAL_FONT_SIZE);212 );
213 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
180 const hasOpenedRef = useRef(false);214 const hasOpenedRef = useRef(false);
181 const hasLoadedFontPreferenceRef = useRef(false);215 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
182 216 const lastBufferReplayKeyRef = useRef<string | null>(null);
183 const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;217 const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null);
218 const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState(
219 cachedFontSize !== null,
220 );
221 const [hasMeasuredSurface, setHasMeasuredSurface] = useState(cachedRouteGridSize !== null);
222 const [pendingModifierState, setPendingModifierState] = useState<{
223 readonly terminalId: string;
224 readonly value: PendingModifier | null;
225 }>({
226 terminalId,
227 value: null,
228 });
184 const target = useTerminalSessionTarget({229 const target = useTerminalSessionTarget({
185 environmentId: selectedThread?.environmentId ?? null,230 environmentId: selectedThread?.environmentId ?? null,
186 threadId: selectedThread?.id ?? null,231 threadId: selectedThread?.id ?? null,
@@ -194,6 +239,18 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines
194 : terminalId,239 : terminalId,
195 [selectedThread, terminalId],240 [selectedThread, terminalId],
196 );241 );
242 const bufferReplayKey = useMemo(
243 () => getTerminalBufferReplayKey({ terminalKey, fontSize }),
244 [fontSize, terminalKey],
245 );
246 if (lastBufferReplayKeyRef.current === null) {
247 lastBufferReplayKeyRef.current = bufferReplayKey;
248 }
249 const terminalSurfaceBuffer = getTerminalSurfaceReplayBuffer({
250 buffer: terminal.buffer,
251 replayKey: bufferReplayKey,
252 readyReplayKey: readyBufferReplayKey,
253 });
197 const isRunning = terminal.status === "running" || terminal.status === "starting";254 const isRunning = terminal.status === "running" || terminal.status === "starting";
198 const cwd = terminal.snapshot?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;255 const cwd = terminal.snapshot?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;
199 const hostPlatform = useMemo(256 const hostPlatform = useMemo(
@@ -219,6 +276,9 @@ export function ThreadTerminalRouteScreen() { · 19 unmodified lines
219 }),276 }),
220 [terminal.hasRunningSubprocess, terminal.status],277 [terminal.hasRunningSubprocess, terminal.status],
221 );278 );
279 const terminalTheme = getPierreTerminalTheme(appearanceScheme);
280 const pendingModifier =
281 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
222 const headerTitle = useMemo(() => {282 const headerTitle = useMemo(() => {
223 const topLineParts = [283 const topLineParts = [
224 selectedEnvironmentConnection?.environmentLabel ?? null,284 selectedEnvironmentConnection?.environmentLabel ?? null,
@@ -301,24 +361,59 @@ export function ThreadTerminalRouteScreen() { · 76 unmodified lines
301 return;361 return;
302 }362 }
303 363
304 const launchLocation = resolveTerminalOpenLocation({364 const pendingLaunchTarget = {
305 terminalSnapshot: terminal.snapshot,365 environmentId: selectedThread.environmentId,
306 activeSessionSnapshot: activeKnownSession?.state.snapshot ?? null,
307 workspaceRoot: selectedThreadProject.workspaceRoot,
308 threadShellWorktreePath: selectedThread.worktreePath ?? null,
309 threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
310 });
311
312 const snapshot = await client.terminal.open({
313 threadId: selectedThread.id,366 threadId: selectedThread.id,
314 terminalId,367 terminalId,
315 cwd: launchLocation.cwd,368 };
316 worktreePath: launchLocation.worktreePath,369 const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget);
317 cols: lastGridSize.cols,370
318 rows: lastGridSize.rows,371 try {
319 });372 const launchLocation = pendingLaunch
373 ? {
374 cwd: pendingLaunch.cwd,
375 worktreePath: pendingLaunch.worktreePath,
376 }
377 : resolveTerminalOpenLocation({
378 terminalSnapshot: terminal.snapshot,
379 activeSessionSnapshot: activeKnownSession?.state.snapshot ?? null,
380 workspaceRoot: selectedThreadProject.workspaceRoot,
381 threadShellWorktreePath: selectedThread.worktreePath ?? null,
382 threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null,
383 });
384
385 const snapshot = await client.terminal.open({
386 threadId: selectedThread.id,
387 terminalId,
388 cwd: launchLocation.cwd,
389 worktreePath: launchLocation.worktreePath,
390 cols: lastGridSize.cols,
391 rows: lastGridSize.rows,
392 env: pendingLaunch?.env,
393 });
394
395 terminalSessionManager.syncSnapshot(
396 { environmentId: selectedThread.environmentId },
397 snapshot,
398 );
320 399
321 terminalSessionManager.syncSnapshot({ environmentId: selectedThread.environmentId }, snapshot);400 if (pendingLaunch?.initialInput) {
401 await client.terminal.write({
402 threadId: selectedThread.id,
403 terminalId,
404 data: pendingLaunch.initialInput,
405 });
406 }
407 } catch (error) {
408 if (pendingLaunch) {
409 stagePendingTerminalLaunch({
410 target: pendingLaunchTarget,
411 launch: pendingLaunch,
412 });
413 }
414
415 throw error;
416 }
322 }, [417 }, [
323 lastGridSize.cols,418 lastGridSize.cols,
324 lastGridSize.rows,419 lastGridSize.rows,
@@ -334,6 +429,74 @@ export function ThreadTerminalRouteScreen() { · 9 unmodified lines
334 hasOpenedRef.current = false;429 hasOpenedRef.current = false;
335 }, [terminalKey]);430 }, [terminalKey]);
336 431
432 const clearBufferReplayTimer = useCallback(() => {
433 if (bufferReplayTimerRef.current !== null) {
434 clearTimeout(bufferReplayTimerRef.current);
435 bufferReplayTimerRef.current = null;
436 }
437 }, []);
438
439 const scheduleBufferReplayReady = useCallback(() => {
440 clearBufferReplayTimer();
441 const replayKey = bufferReplayKey;
442 bufferReplayTimerRef.current = setTimeout(() => {
443 bufferReplayTimerRef.current = null;
444 setReadyBufferReplayKey(replayKey);
445 }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS);
446 }, [bufferReplayKey, clearBufferReplayTimer]);
447
448 useEffect(() => {
449 if (lastBufferReplayKeyRef.current === bufferReplayKey) {
450 return;
451 }
452
453 lastBufferReplayKeyRef.current = bufferReplayKey;
454 clearBufferReplayTimer();
455 setReadyBufferReplayKey(null);
456 }, [bufferReplayKey, clearBufferReplayTimer]);
457
458 useEffect(() => clearBufferReplayTimer, [clearBufferReplayTimer]);
459
460 useEffect(() => {
461 if (!routeEnvironmentId || !routeThreadId) {
462 setLastGridSize({
463 cols: DEFAULT_TERMINAL_COLS,
464 rows: DEFAULT_TERMINAL_ROWS,
465 });
466 return;
467 }
468
469 setLastGridSize(
470 getCachedTerminalGridSize({
471 environmentId: routeEnvironmentId,
472 threadId: routeThreadId,
473 terminalId,
474 }) ?? {
475 cols: DEFAULT_TERMINAL_COLS,
476 rows: DEFAULT_TERMINAL_ROWS,
477 },
478 );
479 setHasMeasuredSurface(
480 getCachedTerminalGridSize({
481 environmentId: routeEnvironmentId,
482 threadId: routeThreadId,
483 terminalId,
484 }) !== null,
485 );
486 }, [routeEnvironmentId, routeThreadId, terminalId]);
487
488 useEffect(() => {
489 setHasMeasuredSurface(
490 routeEnvironmentId !== null &&
491 routeThreadId !== null &&
492 getCachedTerminalGridSize({
493 environmentId: routeEnvironmentId,
494 threadId: routeThreadId,
495 terminalId,
496 }) !== null,
497 );
498 }, [fontSize, routeEnvironmentId, routeThreadId, terminalId]);
499
337 useEffect(() => {500 useEffect(() => {
338 let cancelled = false;501 let cancelled = false;
339 502
@@ -343,15 +506,15 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines
343 return;506 return;
344 }507 }
345 508
346 setFontSize(normalizeTerminalFontSize(preferences.terminalFontSize));509 setFontSize(cacheTerminalFontSize(preferences.terminalFontSize));
347 hasLoadedFontPreferenceRef.current = true;510 setHasResolvedFontPreference(true);
348 })511 })
349 .catch(() => {512 .catch(() => {
350 if (cancelled) {513 if (cancelled) {
351 return;514 return;
352 }515 }
353 516
354 hasLoadedFontPreferenceRef.current = true;517 setHasResolvedFontPreference(true);
355 });518 });
356 519
357 return () => {520 return () => {
@@ -360,16 +523,21 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines
360 }, []);523 }, []);
361 524
362 useEffect(() => {525 useEffect(() => {
363 if (!hasLoadedFontPreferenceRef.current) {526 if (!hasResolvedFontPreference) {
364 return;527 return;
365 }528 }
366 529
530 cacheTerminalFontSize(fontSize);
367 void savePreferencesPatch({531 void savePreferencesPatch({
368 terminalFontSize: normalizeTerminalFontSize(fontSize),532 terminalFontSize: normalizeTerminalFontSize(fontSize),
369 });533 });
370 }, [fontSize]);534 }, [fontSize, hasResolvedFontPreference]);
371 535
372 useEffect(() => {536 useEffect(() => {
537 if (!hasResolvedFontPreference || !hasMeasuredSurface) {
538 return;
539 }
540
373 const bootstrapAction = resolveTerminalRouteBootstrap({541 const bootstrapAction = resolveTerminalRouteBootstrap({
374 hasThread: selectedThread !== null,542 hasThread: selectedThread !== null,
375 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),543 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
@@ -377,6 +545,8 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line
377 requestedTerminalId,545 requestedTerminalId,
378 currentTerminalId: terminalId,546 currentTerminalId: terminalId,
379 runningTerminalId: runningSession?.target.terminalId ?? null,547 runningTerminalId: runningSession?.target.terminalId ?? null,
548 currentTerminalStatus: terminal.status,
549 hasCurrentTerminalHydration: terminal.snapshot !== null || terminal.buffer.length > 0,
380 });550 });
381 if (bootstrapAction.kind === "idle" || !selectedThread) {551 if (bootstrapAction.kind === "idle" || !selectedThread) {
382 return;552 return;
@@ -398,13 +568,14 @@ export function ThreadTerminalRouteScreen() { · 15 unmodified lines
398 runningSession,568 runningSession,
399 selectedThread,569 selectedThread,
400 selectedThreadProject?.workspaceRoot,570 selectedThreadProject?.workspaceRoot,
571 terminal.buffer.length,
572 terminal.snapshot,
573 terminal.status,
401 terminalId,574 terminalId,
575 hasMeasuredSurface,
576 hasResolvedFontPreference,
402 ]);577 ]);
403 578
404 useEffect(() => {
405 setPendingModifier(null);
406 }, [terminalId]);
407
408 const writeInput = useCallback(579 const writeInput = useCallback(
409 (data: string) => {580 (data: string) => {
410 if (!selectedThread || !isRunning) {581 if (!selectedThread || !isRunning) {
@@ -432,20 +603,34 @@ export function ThreadTerminalRouteScreen() { · 21 unmodified lines
432 }603 }
433 604
434 if (pendingModifier === "ctrl") {605 if (pendingModifier === "ctrl") {
435 setPendingModifier(null);606 setPendingModifierState({ terminalId, value: null });
436 writeInput(applyCtrlModifier(data));607 writeInput(applyCtrlModifier(data));
437 } else if (pendingModifier === "meta") {608 } else if (pendingModifier === "meta") {
438 setPendingModifier(null);609 setPendingModifierState({ terminalId, value: null });
439 writeInput(`\u001b${data}`);610 writeInput(`\u001b${data}`);
440 } else {611 } else {
441 writeInput(data);612 writeInput(data);
442 }613 }
443 },614 },
444 [pendingModifier, writeInput],615 [pendingModifier, terminalId, writeInput],
445 );616 );
446 617
447 const handleResize = useCallback(618 const handleResize = useCallback(
448 (size: { readonly cols: number; readonly rows: number }) => {619 (size: { readonly cols: number; readonly rows: number }) => {
620 setHasMeasuredSurface(true);
621 if (readyBufferReplayKey !== bufferReplayKey) {
622 scheduleBufferReplayReady();
623 }
624 if (routeEnvironmentId && routeThreadId) {
625 cacheTerminalGridSize(
626 {
627 environmentId: routeEnvironmentId,
628 threadId: routeThreadId,
629 terminalId,
630 },
631 size,
632 );
633 }
449 if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) {634 if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) {
450 return;635 return;
451 }636 }
@@ -467,7 +652,18 @@ export function ThreadTerminalRouteScreen() { · 15 unmodified lines
467 rows: size.rows,652 rows: size.rows,
468 });653 });
469 },654 },
470 [isRunning, lastGridSize.cols, lastGridSize.rows, selectedThread, terminalId],655 [
656 isRunning,
657 lastGridSize.cols,
658 lastGridSize.rows,
659 bufferReplayKey,
660 readyBufferReplayKey,
661 routeEnvironmentId,
662 routeThreadId,
663 scheduleBufferReplayReady,
664 selectedThread,
665 terminalId,
666 ],
471 );667 );
472 668
473 const handleSelectTerminal = useCallback(669 const handleSelectTerminal = useCallback(
@@ -496,7 +692,7 @@ export function ThreadTerminalRouteScreen() { · 22 unmodified lines
496 692
497 const adjustFontSize = useCallback((delta: number) => {693 const adjustFontSize = useCallback((delta: number) => {
498 setTimeout(() => {694 setTimeout(() => {
499 setFontSize((current) => normalizeTerminalFontSize(current + delta));695 setFontSize((current) => cacheTerminalFontSize(current + delta));
500 }, 0);696 }, 0);
501 }, []);697 }, []);
502 698
@@ -511,11 +707,17 @@ export function ThreadTerminalRouteScreen() { · 8 unmodified lines
511 const handleToolbarActionPress = useCallback(707 const handleToolbarActionPress = useCallback(
512 (action: TerminalToolbarAction) => {708 (action: TerminalToolbarAction) => {
513 if (action.kind === "modifier") {709 if (action.kind === "modifier") {
514 setPendingModifier((current) => (current === action.modifier ? null : action.modifier));710 setPendingModifierState((current) => ({
711 terminalId,
712 value:
713 (current.terminalId === terminalId ? current.value : null) === action.modifier
714 ? null
715 : action.modifier,
716 }));
515 return;717 return;
516 }718 }
517 719
518 setPendingModifier(null);720 setPendingModifierState({ terminalId, value: null });
519 if (pendingModifier === "ctrl") {721 if (pendingModifier === "ctrl") {
520 writeInput(applyCtrlModifier(action.data));722 writeInput(applyCtrlModifier(action.data));
521 } else if (pendingModifier === "meta") {723 } else if (pendingModifier === "meta") {
@@ -524,7 +726,7 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines
524 writeInput(action.data);726 writeInput(action.data);
525 }727 }
526 },728 },
527 [pendingModifier, writeInput],729 [pendingModifier, terminalId, writeInput],
528 );730 );
529 731
530 if (!selectedThread) {732 if (!selectedThread) {
@@ -561,8 +763,8 @@ export function ThreadTerminalRouteScreen() { · 30 unmodified lines
561 headerBackButtonDisplayMode: "minimal",763 headerBackButtonDisplayMode: "minimal",
562 headerBackTitle: "",764 headerBackTitle: "",
563 headerShadowVisible: false,765 headerShadowVisible: false,
564 headerStyle: { backgroundColor: "#0a0a0a" },766 headerStyle: { backgroundColor: terminalTheme.background },
565 headerTintColor: "#f5f5f5",767 headerTintColor: terminalTheme.foreground,
566 headerTitleAlign: "center",768 headerTitleAlign: "center",
567 title: "",769 title: "",
568 headerTitle: () => (770 headerTitle: () => (
@@ -576,7 +778,7 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines
576 <RNText778 <RNText
577 numberOfLines={1}779 numberOfLines={1}
578 style={{780 style={{
579 color: "#f5f5f5",781 color: terminalTheme.foreground,
580 fontFamily: "DMSans_700Bold",782 fontFamily: "DMSans_700Bold",
581 fontSize: 13,783 fontSize: 13,
582 lineHeight: 16,784 lineHeight: 16,
@@ -588,7 +790,7 @@ export function ThreadTerminalRouteScreen() { · 5 unmodified lines
588 ellipsizeMode="middle"790 ellipsizeMode="middle"
589 numberOfLines={1}791 numberOfLines={1}
590 style={{792 style={{
591 color: "#8f8f94",793 color: terminalTheme.mutedForeground,
592 fontFamily: "Menlo",794 fontFamily: "Menlo",
593 fontSize: 11,795 fontSize: 11,
594 lineHeight: 14,796 lineHeight: 14,
@@ -658,10 +860,10 @@ export function ThreadTerminalRouteScreen() { · 63 unmodified lines
658 </Stack.Toolbar.Menu>860 </Stack.Toolbar.Menu>
659 </Stack.Toolbar>861 </Stack.Toolbar>
660 862
661 <View style={{ flex: 1, backgroundColor: "#050505" }}>863 <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>
662 <Animated.View style={[{ flex: 1 }, terminalContainerAnimatedStyle]}>864 <Animated.View style={[{ flex: 1 }, terminalContainerAnimatedStyle]}>
663 <TerminalSurface865 <TerminalSurface
664 buffer={terminal.buffer}866 buffer={terminalSurfaceBuffer}
665 fontSize={fontSize}867 fontSize={fontSize}
666 isRunning={isRunning}868 isRunning={isRunning}
667 onInput={handleInput}869 onInput={handleInput}
@@ -674,8 +876,8 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines
674 <KeyboardStickyView style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}>876 <KeyboardStickyView style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}>
675 <View877 <View
676 style={{878 style={{
677 backgroundColor: "#0a0a0a",879 backgroundColor: terminalTheme.background,
678 borderTopColor: "rgba(255,255,255,0.08)",880 borderTopColor: terminalTheme.border,
679 borderTopWidth: 1,881 borderTopWidth: 1,
680 minHeight: TERMINAL_ACCESSORY_HEIGHT,882 minHeight: TERMINAL_ACCESSORY_HEIGHT,
681 paddingBottom: 4,883 paddingBottom: 4,
@@ -698,11 +900,13 @@ export function ThreadTerminalRouteScreen() { · 16 unmodified lines
698 style={({ pressed }) => ({900 style={({ pressed }) => ({
699 alignItems: "center",901 alignItems: "center",
700 backgroundColor: active902 backgroundColor: active
701 ? "rgba(74, 222, 128, 0.18)"903 ? withAlpha(terminalTheme.palette[10] ?? terminalTheme.foreground, "2e")
702 : pressed904 : pressed
703 ? "rgba(255,255,255,0.12)"905 ? withAlpha(terminalTheme.foreground, "1f")
704 : "rgba(255,255,255,0.06)",906 : withAlpha(terminalTheme.foreground, "12"),
705 borderColor: active ? "rgba(74, 222, 128, 0.36)" : "rgba(255,255,255,0.08)",907 borderColor: active
908 ? withAlpha(terminalTheme.palette[10] ?? terminalTheme.foreground, "52")
909 : terminalTheme.border,
706 borderRadius: 12,910 borderRadius: 12,
707 borderWidth: 1,911 borderWidth: 1,
708 justifyContent: "center",912 justifyContent: "center",
@@ -713,7 +917,9 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines
713 >917 >
714 <RNText918 <RNText
715 style={{919 style={{
716 color: active ? "#bbf7d0" : "#f5f5f5",920 color: active
921 ? (terminalTheme.palette[10] ?? terminalTheme.foreground)
922 : terminalTheme.foreground,
717 fontFamily: "DMSans_700Bold",923 fontFamily: "DMSans_700Bold",
718 fontSize: 12,924 fontSize: 12,
719 fontWeight: "700",925 fontWeight: "700",
apps/mobile/src/features/threads/terminalBufferReplay.test.ts+43−0
@@ -0,0 +1,43 @@
1import { describe, expect, it } from "vitest";
2
3import { getTerminalBufferReplayKey, getTerminalSurfaceReplayBuffer } from "./terminalBufferReplay";
4
5describe("terminalBufferReplay", () => {
6 it("keys replay readiness by terminal identity and font metrics", () => {
7 expect(
8 getTerminalBufferReplayKey({
9 terminalKey: "env-1:thread-1:default",
10 fontSize: 10,
11 }),
12 ).toBe("env-1:thread-1:default:10");
13 });
14
15 it("holds back terminal history until the measured surface is ready", () => {
16 const replayKey = getTerminalBufferReplayKey({
17 terminalKey: "env-1:thread-1:default",
18 fontSize: 10,
19 });
20
21 expect(
22 getTerminalSurfaceReplayBuffer({
23 buffer: "fastfetch output",
24 replayKey,
25 readyReplayKey: null,
26 }),
27 ).toBe("");
28 expect(
29 getTerminalSurfaceReplayBuffer({
30 buffer: "fastfetch output",
31 replayKey,
32 readyReplayKey: "env-1:thread-1:default:11",
33 }),
34 ).toBe("");
35 expect(
36 getTerminalSurfaceReplayBuffer({
37 buffer: "fastfetch output",
38 replayKey,
39 readyReplayKey: replayKey,
40 }),
41 ).toBe("fastfetch output");
42 });
43});
apps/mobile/src/features/threads/terminalBufferReplay.ts+16−0
@@ -0,0 +1,16 @@
1export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180;
2
3export function getTerminalBufferReplayKey(input: {
4 readonly terminalKey: string;
5 readonly fontSize: number;
6}): string {
7 return `${input.terminalKey}:${input.fontSize}`;
8}
9
10export function getTerminalSurfaceReplayBuffer(input: {
11 readonly buffer: string;
12 readonly replayKey: string;
13 readonly readyReplayKey: string | null;
14}): string {
15 return input.readyReplayKey === input.replayKey ? input.buffer : "";
16}
apps/mobile/src/features/threads/terminalLaunchContext.test.ts+68−0
@@ -1,8 +1,11 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2 2
3import {3import {
4 peekPendingTerminalLaunch,
4 resolvePreferredThreadWorktreePath,5 resolvePreferredThreadWorktreePath,
5 resolveTerminalOpenLocation,6 resolveTerminalOpenLocation,
7 stagePendingTerminalLaunch,
8 takePendingTerminalLaunch,
6} from "./terminalLaunchContext";9} from "./terminalLaunchContext";
7 10
8describe("resolvePreferredThreadWorktreePath", () => {11describe("resolvePreferredThreadWorktreePath", () => {
@@ -59,3 +62,68 @@ describe("resolveTerminalOpenLocation", () => { · 50 unmodified lines
59 });62 });
60 });63 });
61});64});
65
66describe("pending terminal launches", () => {
67 it("stages and consumes launch details for a specific terminal target", () => {
68 const target = {
69 environmentId: "env-1",
70 threadId: "thread-1",
71 terminalId: "term-2",
72 };
73
74 stagePendingTerminalLaunch({
75 target,
76 launch: {
77 cwd: "/repo/worktrees/feature",
78 worktreePath: "/repo/worktrees/feature",
79 env: { FOO: "bar" },
80 initialInput: "pnpm dev\r",
81 },
82 });
83
84 expect(peekPendingTerminalLaunch(target)).toEqual({
85 cwd: "/repo/worktrees/feature",
86 worktreePath: "/repo/worktrees/feature",
87 env: { FOO: "bar" },
88 initialInput: "pnpm dev\r",
89 });
90 expect(takePendingTerminalLaunch(target)).toEqual({
91 cwd: "/repo/worktrees/feature",
92 worktreePath: "/repo/worktrees/feature",
93 env: { FOO: "bar" },
94 initialInput: "pnpm dev\r",
95 });
96 expect(peekPendingTerminalLaunch(target)).toBeNull();
97 });
98
99 it("keeps pending launches isolated per terminal target", () => {
100 const primaryTarget = {
101 environmentId: "env-1",
102 threadId: "thread-1",
103 terminalId: "term-2",
104 };
105 const otherTarget = {
106 environmentId: "env-1",
107 threadId: "thread-1",
108 terminalId: "term-3",
109 };
110
111 stagePendingTerminalLaunch({
112 target: primaryTarget,
113 launch: {
114 cwd: "/repo/root",
115 worktreePath: null,
116 initialInput: "pnpm i\r",
117 },
118 });
119
120 expect(peekPendingTerminalLaunch(otherTarget)).toBeNull();
121 expect(takePendingTerminalLaunch(otherTarget)).toBeNull();
122 expect(takePendingTerminalLaunch(primaryTarget)).toEqual({
123 cwd: "/repo/root",
124 worktreePath: null,
125 env: undefined,
126 initialInput: "pnpm i\r",
127 });
128 });
129});
apps/mobile/src/features/threads/terminalLaunchContext.ts+49−0
@@ -3,6 +3,55 @@ interface TerminalSnapshotLike { · 2 unmodified lines
3 readonly worktreePath: string | null;3 readonly worktreePath: string | null;
4}4}
5 5
6interface PendingTerminalLaunchTarget {
7 readonly environmentId: string;
8 readonly threadId: string;
9 readonly terminalId: string;
10}
11
12export interface PendingTerminalLaunch {
13 readonly cwd: string;
14 readonly worktreePath: string | null;
15 readonly env?: Record<string, string>;
16 readonly initialInput?: string;
17}
18
19const pendingTerminalLaunches = new Map<string, PendingTerminalLaunch>();
20
21function pendingTerminalLaunchKey(input: PendingTerminalLaunchTarget): string {
22 return `${input.environmentId}:${input.threadId}:${input.terminalId}`;
23}
24
25export function stagePendingTerminalLaunch(input: {
26 readonly target: PendingTerminalLaunchTarget;
27 readonly launch: PendingTerminalLaunch;
28}) {
29 pendingTerminalLaunches.set(pendingTerminalLaunchKey(input.target), {
30 cwd: input.launch.cwd,
31 worktreePath: input.launch.worktreePath,
32 env: input.launch.env ? { ...input.launch.env } : undefined,
33 initialInput: input.launch.initialInput,
34 });
35}
36
37export function peekPendingTerminalLaunch(
38 target: PendingTerminalLaunchTarget,
39): PendingTerminalLaunch | null {
40 return pendingTerminalLaunches.get(pendingTerminalLaunchKey(target)) ?? null;
41}
42
43export function takePendingTerminalLaunch(
44 target: PendingTerminalLaunchTarget,
45): PendingTerminalLaunch | null {
46 const key = pendingTerminalLaunchKey(target);
47 const launch = pendingTerminalLaunches.get(key) ?? null;
48 if (launch) {
49 pendingTerminalLaunches.delete(key);
50 }
51
52 return launch;
53}
54
6export function resolvePreferredThreadWorktreePath(input: {55export function resolvePreferredThreadWorktreePath(input: {
7 readonly threadShellWorktreePath: string | null;56 readonly threadShellWorktreePath: string | null;
8 readonly threadDetailWorktreePath: string | null;57 readonly threadDetailWorktreePath: string | null;
apps/mobile/src/features/threads/terminalRouteBootstrap.ts+9−0
@@ -5,6 +5,8 @@ export function resolveTerminalRouteBootstrap(input: { · 4 unmodified lines
5 readonly requestedTerminalId: string | null;5 readonly requestedTerminalId: string | null;
6 readonly currentTerminalId: string;6 readonly currentTerminalId: string;
7 readonly runningTerminalId: string | null;7 readonly runningTerminalId: string | null;
8 readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed";
9 readonly hasCurrentTerminalHydration: boolean;
8}):10}):
9 | { readonly kind: "idle" }11 | { readonly kind: "idle" }
10 | { readonly kind: "redirect"; readonly terminalId: string }12 | { readonly kind: "redirect"; readonly terminalId: string }
@@ -21,5 +23,12 @@ export function resolveTerminalRouteBootstrap(input: { · 10 unmodified lines
21 return { kind: "redirect", terminalId: input.runningTerminalId };23 return { kind: "redirect", terminalId: input.runningTerminalId };
22 }24 }
23 25
26 if (
27 (input.currentTerminalStatus === "running" || input.currentTerminalStatus === "starting") &&
28 input.hasCurrentTerminalHydration
29 ) {
30 return { kind: "idle" };
31 }
32
24 return { kind: "open" };33 return { kind: "open" };
25}34}
apps/mobile/src/features/threads/terminalUiState.test.ts+52−0
@@ -0,0 +1,52 @@
1import { beforeEach, describe, expect, it } from "vitest";
2
3import {
4 cacheTerminalFontSize,
5 cacheTerminalGridSize,
6 getCachedTerminalFontSize,
7 getCachedTerminalGridSize,
8 resetTerminalUiStateCaches,
9} from "./terminalUiState";
10
11describe("terminalUiState", () => {
12 beforeEach(() => {
13 resetTerminalUiStateCaches();
14 });
15
16 it("caches terminal font size using the shared normalization rules", () => {
17 expect(getCachedTerminalFontSize()).toBeNull();
18 expect(cacheTerminalFontSize(8.5)).toBe(8.5);
19 expect(getCachedTerminalFontSize()).toBe(8.5);
20 expect(cacheTerminalFontSize(100)).toBe(14);
21 expect(getCachedTerminalFontSize()).toBe(14);
22 });
23
24 it("stores terminal grid sizes per terminal target", () => {
25 const primaryTarget = {
26 environmentId: "env-1",
27 threadId: "thread-1",
28 terminalId: "default",
29 };
30 const otherTarget = {
31 environmentId: "env-1",
32 threadId: "thread-1",
33 terminalId: "term-2",
34 };
35
36 expect(getCachedTerminalGridSize(primaryTarget)).toBeNull();
37 expect(
38 cacheTerminalGridSize(primaryTarget, {
39 cols: 107.9,
40 rows: 33.2,
41 }),
42 ).toEqual({
43 cols: 107,
44 rows: 33,
45 });
46 expect(getCachedTerminalGridSize(primaryTarget)).toEqual({
47 cols: 107,
48 rows: 33,
49 });
50 expect(getCachedTerminalGridSize(otherTarget)).toBeNull();
51 });
52});
apps/mobile/src/features/threads/terminalUiState.ts+50−0
@@ -0,0 +1,50 @@
1import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences";
2
3export interface TerminalGridSize {
4 readonly cols: number;
5 readonly rows: number;
6}
7
8export interface TerminalUiStateTarget {
9 readonly environmentId: string;
10 readonly threadId: string;
11 readonly terminalId: string;
12}
13
14const terminalGridSizeCache = new Map<string, TerminalGridSize>();
15let cachedTerminalFontSize: number | null = null;
16
17function terminalUiStateKey(target: TerminalUiStateTarget): string {
18 return `${target.environmentId}:${target.threadId}:${target.terminalId}`;
19}
20
21export function getCachedTerminalFontSize(): number | null {
22 return cachedTerminalFontSize;
23}
24
25export function cacheTerminalFontSize(value: number | null | undefined): number {
26 const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE);
27 cachedTerminalFontSize = normalized;
28 return normalized;
29}
30
31export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null {
32 return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null;
33}
34
35export function cacheTerminalGridSize(
36 target: TerminalUiStateTarget,
37 size: TerminalGridSize,
38): TerminalGridSize {
39 const normalized = {
40 cols: Math.max(1, Math.floor(size.cols)),
41 rows: Math.max(1, Math.floor(size.rows)),
42 };
43 terminalGridSizeCache.set(terminalUiStateKey(target), normalized);
44 return normalized;
45}
46
47export function resetTerminalUiStateCaches() {
48 cachedTerminalFontSize = null;
49 terminalGridSizeCache.clear();
50}
apps/mobile/src/native/terminal/NativeTerminalSurface.tsx+3−3
@@ -14,7 +14,7 @@ import { AppText as Text } from "../../components/AppText"; · 13 unmodified lines
14import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";14import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";
15import {15import {
16 buildGhosttyThemeConfig,16 buildGhosttyThemeConfig,
17 getGitHubTerminalTheme,17 getPierreTerminalTheme,
18 type TerminalTheme,18 type TerminalTheme,
19} from "./terminalTheme";19} from "./terminalTheme";
20 20
@@ -53,7 +53,7 @@ function estimateGridSize(input: { · 32 unmodified lines
53const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {53const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
54 const fontSize = props.fontSize ?? 12;54 const fontSize = props.fontSize ?? 12;
55 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";55 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
56 const theme = props.theme ?? getGitHubTerminalTheme(appearanceScheme);56 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
57 const statusLabel = props.isRunning57 const statusLabel = props.isRunning
58 ? "Native terminal unavailable. Using text fallback."58 ? "Native terminal unavailable. Using text fallback."
59 : "Open terminal to start a shell.";59 : "Open terminal to start a shell.";
@@ -165,7 +165,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 105 unmodified lines
165export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {165export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
166 const fontSize = props.fontSize ?? 12;166 const fontSize = props.fontSize ?? 12;
167 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";167 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
168 const theme = props.theme ?? getGitHubTerminalTheme(appearanceScheme);168 const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
169 const { onInput, onResize } = props;169 const { onInput, onResize } = props;
170 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();170 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
171 const handleNativeInput = useCallback(171 const handleNativeInput = useCallback(
apps/mobile/src/native/terminal/terminalTheme.test.ts+20−20
@@ -1,36 +1,36 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2 2
3import { buildGhosttyThemeConfig, getGitHubTerminalTheme } from "./terminalTheme";3import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme";
4 4
5describe("getGitHubTerminalTheme", () => {5describe("getPierreTerminalTheme", () => {
6 it("returns the GitHub light terminal palette", () => {6 it("returns the Pierre light terminal palette", () => {
7 expect(getGitHubTerminalTheme("light")).toMatchObject({7 expect(getPierreTerminalTheme("light")).toMatchObject({
8 background: "#ffffff",8 background: "#f2f2f7",
9 foreground: "#586069",9 foreground: "#6C6C71",
10 cursorForeground: "#005cc5",10 cursorForeground: "#009fff",
11 cursorBackground: "#d1d5da",11 cursorBackground: "#f2f2f7",
12 });12 });
13 });13 });
14 14
15 it("returns the GitHub dark terminal palette", () => {15 it("returns the Pierre dark terminal palette", () => {
16 expect(getGitHubTerminalTheme("dark")).toMatchObject({16 expect(getPierreTerminalTheme("dark")).toMatchObject({
17 background: "#24292e",17 background: "#0a0a0a",
18 foreground: "#d1d5da",18 foreground: "#adadb1",
19 cursorForeground: "#79b8ff",19 cursorForeground: "#009fff",
20 cursorBackground: "#586069",20 cursorBackground: "#0a0a0a",
21 });21 });
22 });22 });
23});23});
24 24
25describe("buildGhosttyThemeConfig", () => {25describe("buildGhosttyThemeConfig", () => {
26 it("serializes theme colors into a ghostty config file", () => {26 it("serializes theme colors into a ghostty config file", () => {
27 const config = buildGhosttyThemeConfig(getGitHubTerminalTheme("dark"));27 const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark"));
28 28
29 expect(config).toContain("background = #24292e");29 expect(config).toContain("background = #0a0a0a");
30 expect(config).toContain("foreground = #d1d5da");30 expect(config).toContain("foreground = #adadb1");
31 expect(config).toContain("cursor-color = #79b8ff");31 expect(config).toContain("cursor-color = #009fff");
32 expect(config).toContain("palette = 0=#586069");32 expect(config).toContain("palette = 0=#141415");
33 expect(config).toContain("palette = 15=#fafbfc");33 expect(config).toContain("palette = 15=#c6c6c8");
34 expect(config.endsWith("\n")).toBe(true);34 expect(config.endsWith("\n")).toBe(true);
35 });35 });
36});36});
apps/mobile/src/native/terminal/terminalTheme.ts+50−49
@@ -10,63 +10,64 @@ export interface TerminalTheme { · 9 unmodified lines
10 readonly palette: readonly string[];10 readonly palette: readonly string[];
11}11}
12 12
13const GITHUB_LIGHT_THEME: TerminalTheme = {13const PIERRE_LIGHT_THEME: TerminalTheme = {
14 // Sourced from GitHub's official VS Code theme terminal palette.14 // Pierre terminal palette with the app's shared screen background.
15 background: "#ffffff",15 background: "#f2f2f7",
16 foreground: "#586069",16 foreground: "#6C6C71",
17 mutedForeground: "#6a737d",17 mutedForeground: "#8E8E95",
18 border: "rgba(27,31,35,0.12)",18 border: "#eeeeef",
19 cursorForeground: "#005cc5",19 cursorForeground: "#009fff",
20 cursorBackground: "#d1d5da",20 cursorBackground: "#f2f2f7",
21 palette: [21 palette: [
22 "#24292e",22 "#1F1F21",
23 "#d73a49",23 "#ff2e3f",
24 "#28a745",24 "#0dbe4e",
25 "#dbab09",25 "#ffca00",
26 "#0366d6",26 "#009fff",
27 "#5a32a3",27 "#c635e4",
28 "#1b7c83",28 "#08c0ef",
29 "#6a737d",29 "#c6c6c8",
30 "#959da5",30 "#1F1F21",
31 "#cb2431",31 "#ff2e3f",
32 "#22863a",32 "#0dbe4e",
33 "#b08800",33 "#ffca00",
34 "#005cc5",34 "#009fff",
35 "#5a32a3",35 "#c635e4",
36 "#3192aa",36 "#08c0ef",
37 "#d1d5da",37 "#c6c6c8",
38 ],38 ],
39};39};
40 40
41const GITHUB_DARK_THEME: TerminalTheme = {41const PIERRE_DARK_THEME: TerminalTheme = {
42 background: "#24292e",42 // Pierre terminal palette with the app's shared screen background.
43 foreground: "#d1d5da",43 background: "#0a0a0a",
44 mutedForeground: "#959da5",44 foreground: "#adadb1",
45 border: "rgba(240,246,252,0.12)",45 mutedForeground: "#8E8E95",
46 cursorForeground: "#79b8ff",46 border: "#2e2e30",
47 cursorBackground: "#586069",47 cursorForeground: "#009fff",
48 cursorBackground: "#0a0a0a",
48 palette: [49 palette: [
49 "#586069",50 "#141415",
50 "#ea4a5a",51 "#ff2e3f",
51 "#34d058",52 "#0dbe4e",
52 "#ffea7f",53 "#ffca00",
53 "#2188ff",54 "#009fff",
54 "#b392f0",55 "#c635e4",
55 "#39c5cf",56 "#08c0ef",
56 "#d1d5da",57 "#c6c6c8",
57 "#959da5",58 "#141415",
58 "#f97583",59 "#ff2e3f",
59 "#85e89d",60 "#0dbe4e",
60 "#ffea7f",61 "#ffca00",
61 "#79b8ff",62 "#009fff",
62 "#b392f0",63 "#c635e4",
63 "#56d4dd",64 "#08c0ef",
64 "#fafbfc",65 "#c6c6c8",
65 ],66 ],
66};67};
67 68
68export function getGitHubTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme {69export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme {
69 return scheme === "light" ? GITHUB_LIGHT_THEME : GITHUB_DARK_THEME;70 return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME;
70}71}
71 72
72export function buildGhosttyThemeConfig(theme: TerminalTheme): string {73export function buildGhosttyThemeConfig(theme: TerminalTheme): string {