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
4747 }
4848 }
4949
50− var fontSize: Float = 12f
50+ var fontSize: Float = 10f
5151 set(value) {
5252 field = value
5353 textView.textSize = value
54+ inputView.textSize = max(value, 13f)
5455 emitResize()
5556 }
5657
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+12−17
@@ -100,23 +100,26 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 99 unmodified lines
100100 }
101101 }
102102
103− var fontSize: CGFloat = 12 {
103+ var fontSize: CGFloat = 10 {
104104 didSet {
105+ guard oldValue != fontSize else { return }
105106 inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
106− resetSurface()
107+ refreshSurface()
107108 }
108109 }
109110
110111 var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
111112 didSet {
113+ guard oldValue != appearanceScheme else { return }
112114 appearance = TerminalAppearanceScheme(value: appearanceScheme)
113− updateGhosttyTheme()
115+ refreshSurface()
114116 }
115117 }
116118
117119 var themeConfig: String = "" {
118120 didSet {
119− updateGhosttyTheme()
121+ guard oldValue != themeConfig else { return }
122+ refreshSurface()
120123 }
121124 }
122125
@@ -353,6 +356,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 230 unmodified lines
353356 setNeedsLayout()
354357 }
355358
359+ private func refreshSurface() {
360+ resetSurface()
361+ createSurfaceIfPossible()
362+ }
363+
356364 private func destroySurface() {
357365 if let surface {
358366 ghostty_surface_set_write_callback(surface, nil, nil)
@@ -525,19 +533,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 166 unmodified lines
525533 terminalViewport.backgroundColor = backgroundColorValue
526534 }
527535
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−
541536 private func loadThemeConfig(into config: ghostty_config_t) {
542537 guard let path = writeThemeConfigFile() else { return }
543538 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
1818
1919 import { useRemoteCatalog } from "../../state/use-remote-catalog";
2020 import {
21− getEnvironmentClient,
2221 useRemoteConnectionStatus,
2322 useRemoteEnvironmentState,
2423 } from "../../state/use-remote-environment-registry";
25−import { terminalSessionManager, useKnownTerminalSessions } from "../../state/use-terminal-session";
24+import { useKnownTerminalSessions } from "../../state/use-terminal-session";
2625 import { useSelectedThreadDetail } from "../../state/use-thread-detail";
2726 import { useThreadSelection } from "../../state/use-thread-selection";
2827 import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
@@ -31,7 +30,10 @@ import { · 2 unmodified lines
3130 nextTerminalId,
3231 resolveProjectScriptTerminalId,
3332 } from "./terminalMenu";
34−import { resolvePreferredThreadWorktreePath } from "./terminalLaunchContext";
33+import {
34+ resolvePreferredThreadWorktreePath,
35+ stagePendingTerminalLaunch,
36+} from "./terminalLaunchContext";
3537 import { ThreadDetailScreen } from "./ThreadDetailScreen";
3638 import { ThreadGitControls } from "./ThreadGitControls";
3739 import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
@@ -166,11 +168,6 @@ export function ThreadRouteScreen() { · 128 unmodified lines
166168 return;
167169 }
168170
169− const client = getEnvironmentClient(selectedThread.environmentId);
170− if (!client) {
171− return;
172− }
173−
174171 const targetTerminalId = resolveProjectScriptTerminalId({
175172 existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
176173 hasRunningTerminal: terminalMenuSessions.some(
@@ -189,23 +186,20 @@ export function ThreadRouteScreen() { · 12 unmodified lines
189186 project: { cwd: selectedThreadProject.workspaceRoot },
190187 worktreePath: preferredWorktreePath,
191188 });
192− const snapshot = await client.terminal.open({
193− threadId: selectedThread.id,
194− terminalId: targetTerminalId,
195− cwd,
196− worktreePath: preferredWorktreePath,
197− env,
189+ stagePendingTerminalLaunch({
190+ target: {
191+ environmentId: selectedThread.environmentId,
192+ threadId: selectedThread.id,
193+ terminalId: targetTerminalId,
194+ },
195+ launch: {
196+ cwd,
197+ worktreePath: preferredWorktreePath,
198+ env,
199+ initialInput: `${script.command}\r`,
200+ },
198201 });
199202
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− });
209203 void router.push(buildThreadTerminalRoutePath(selectedThread, targetTerminalId));
210204 },
211205 [
apps/mobile/src/features/threads/ThreadTerminalRouteScreen.test.ts+44−2
@@ -12,6 +12,8 @@ describe("resolveTerminalRouteBootstrap", () => { · 11 unmodified lines
1212 requestedTerminalId: null,
1313 currentTerminalId: "default",
1414 runningTerminalId: "term-2",
15+ currentTerminalStatus: "closed",
16+ hasCurrentTerminalHydration: false,
1517 }),
1618 ).toEqual({
1719 kind: "redirect",
@@ -19,7 +21,7 @@ describe("resolveTerminalRouteBootstrap", () => { · 1 unmodified line
1921 });
2022 });
2123
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", () => {
2325 expect(
2426 resolveTerminalRouteBootstrap({
2527 hasThread: true,
@@ -28,13 +30,15 @@ describe("resolveTerminalRouteBootstrap", () => { · 2 unmodified lines
2830 requestedTerminalId: null,
2931 currentTerminalId: "default",
3032 runningTerminalId: "default",
33+ currentTerminalStatus: "running",
34+ hasCurrentTerminalHydration: false,
3135 }),
3236 ).toEqual({
3337 kind: "open",
3438 });
3539 });
3640
37− it("opens explicit terminal routes once so they replay existing history", () => {
41+ it("opens explicit terminal routes when the session still needs hydration", () => {
3842 expect(
3943 resolveTerminalRouteBootstrap({
4044 hasThread: true,
@@ -43,6 +47,8 @@ describe("resolveTerminalRouteBootstrap", () => { · 2 unmodified lines
4347 requestedTerminalId: "term-2",
4448 currentTerminalId: "term-2",
4549 runningTerminalId: "term-2",
50+ currentTerminalStatus: "running",
51+ hasCurrentTerminalHydration: false,
4652 }),
4753 ).toEqual({
4854 kind: "open",
@@ -58,6 +64,42 @@ describe("resolveTerminalRouteBootstrap", () => { · 9 unmodified lines
5864 requestedTerminalId: null,
5965 currentTerminalId: "default",
6066 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,
61103 }),
62104 ).toEqual({
63105 kind: "idle",
apps/mobile/src/features/threads/ThreadTerminalRouteScreen.tsx+262−56
@@ -1,7 +1,7 @@
11 import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts";
22 import { Stack, useLocalSearchParams, useRouter } from "expo-router";
33 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4−import { Pressable, ScrollView, Text as RNText, View } from "react-native";
4+import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
55 import { KeyboardStickyView } from "react-native-keyboard-controller";
66 import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated";
77
@@ -21,9 +21,19 @@ import { · 13 unmodified lines
2121 import { useThreadSelection } from "../../state/use-thread-selection";
2222 import { useSelectedThreadDetail } from "../../state/use-thread-detail";
2323 import { TerminalSurface } from "../../native/terminal/NativeTerminalSurface";
24+import { getPierreTerminalTheme } from "../../native/terminal/terminalTheme";
2425 import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
26+import {
27+ getTerminalBufferReplayKey,
28+ getTerminalSurfaceReplayBuffer,
29+ TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS,
30+} from "./terminalBufferReplay";
2531 import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap";
26−import { resolveTerminalOpenLocation } from "./terminalLaunchContext";
32+import {
33+ resolveTerminalOpenLocation,
34+ stagePendingTerminalLaunch,
35+ takePendingTerminalLaunch,
36+} from "./terminalLaunchContext";
2737 import {
2838 basename,
2939 buildTerminalMenuSessions,
@@ -38,6 +48,12 @@ import { · 8 unmodified lines
3848 TERMINAL_FONT_SIZE_STEP,
3949 normalizeTerminalFontSize,
4050 } from "./terminalPreferences";
51+import {
52+ cacheTerminalFontSize,
53+ cacheTerminalGridSize,
54+ getCachedTerminalFontSize,
55+ getCachedTerminalGridSize,
56+} from "./terminalUiState";
4157
4258 const DEFAULT_TERMINAL_COLS = 80;
4359 const DEFAULT_TERMINAL_ROWS = 24;
@@ -154,9 +170,14 @@ function applyCtrlModifier(input: string): string { · 110 unmodified lines
154170 return input;
155171 }
156172
173+function withAlpha(hexColor: string, alpha: string): string {
174+ return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor;
175+}
176+
157177 export function ThreadTerminalRouteScreen() {
158178 const router = useRouter();
159179 const keyboard = useAnimatedKeyboard();
180+ const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
160181 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
161182 const params = useLocalSearchParams<{
162183 environmentId?: string | string[];
@@ -166,21 +187,45 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines
166187 const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
167188 useThreadSelection();
168189 const selectedThreadDetail = useSelectedThreadDetail();
190+ const routeEnvironmentId = firstRouteParam(params.environmentId);
191+ const routeThreadId = firstRouteParam(params.threadId);
169192 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;
170203 const knownSessions = useKnownTerminalSessions({
171204 environmentId: selectedThread?.environmentId ?? null,
172205 threadId: selectedThread?.id ?? null,
173206 });
174− const [lastGridSize, setLastGridSize] = useState({
175− cols: DEFAULT_TERMINAL_COLS,
176− rows: DEFAULT_TERMINAL_ROWS,
177− });
178− const [pendingModifier, setPendingModifier] = useState<PendingModifier | null>(null);
179− const [fontSize, setFontSize] = useState(DEFAULT_TERMINAL_FONT_SIZE);
207+ const [lastGridSize, setLastGridSize] = useState(
208+ cachedRouteGridSize ?? {
209+ cols: DEFAULT_TERMINAL_COLS,
210+ rows: DEFAULT_TERMINAL_ROWS,
211+ },
212+ );
213+ const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
180214 const hasOpenedRef = useRef(false);
181− const hasLoadedFontPreferenceRef = useRef(false);
182−
183− const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID;
215+ const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
216+ const lastBufferReplayKeyRef = useRef<string | null>(null);
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+ });
184229 const target = useTerminalSessionTarget({
185230 environmentId: selectedThread?.environmentId ?? null,
186231 threadId: selectedThread?.id ?? null,
@@ -194,6 +239,18 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines
194239 : terminalId,
195240 [selectedThread, terminalId],
196241 );
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+ });
197254 const isRunning = terminal.status === "running" || terminal.status === "starting";
198255 const cwd = terminal.snapshot?.cwd ?? selectedThreadProject?.workspaceRoot ?? null;
199256 const hostPlatform = useMemo(
@@ -219,6 +276,9 @@ export function ThreadTerminalRouteScreen() { · 19 unmodified lines
219276 }),
220277 [terminal.hasRunningSubprocess, terminal.status],
221278 );
279+ const terminalTheme = getPierreTerminalTheme(appearanceScheme);
280+ const pendingModifier =
281+ pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
222282 const headerTitle = useMemo(() => {
223283 const topLineParts = [
224284 selectedEnvironmentConnection?.environmentLabel ?? null,
@@ -301,24 +361,59 @@ export function ThreadTerminalRouteScreen() { · 76 unmodified lines
301361 return;
302362 }
303363
304− const launchLocation = resolveTerminalOpenLocation({
305− terminalSnapshot: terminal.snapshot,
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({
364+ const pendingLaunchTarget = {
365+ environmentId: selectedThread.environmentId,
313366 threadId: selectedThread.id,
314367 terminalId,
315− cwd: launchLocation.cwd,
316− worktreePath: launchLocation.worktreePath,
317− cols: lastGridSize.cols,
318− rows: lastGridSize.rows,
319− });
368+ };
369+ const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget);
370+
371+ try {
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+ );
320399
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+ }
322417 }, [
323418 lastGridSize.cols,
324419 lastGridSize.rows,
@@ -334,6 +429,74 @@ export function ThreadTerminalRouteScreen() { · 9 unmodified lines
334429 hasOpenedRef.current = false;
335430 }, [terminalKey]);
336431
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+
337500 useEffect(() => {
338501 let cancelled = false;
339502
@@ -343,15 +506,15 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines
343506 return;
344507 }
345508
346− setFontSize(normalizeTerminalFontSize(preferences.terminalFontSize));
347− hasLoadedFontPreferenceRef.current = true;
509+ setFontSize(cacheTerminalFontSize(preferences.terminalFontSize));
510+ setHasResolvedFontPreference(true);
348511 })
349512 .catch(() => {
350513 if (cancelled) {
351514 return;
352515 }
353516
354− hasLoadedFontPreferenceRef.current = true;
517+ setHasResolvedFontPreference(true);
355518 });
356519
357520 return () => {
@@ -360,16 +523,21 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines
360523 }, []);
361524
362525 useEffect(() => {
363− if (!hasLoadedFontPreferenceRef.current) {
526+ if (!hasResolvedFontPreference) {
364527 return;
365528 }
366529
530+ cacheTerminalFontSize(fontSize);
367531 void savePreferencesPatch({
368532 terminalFontSize: normalizeTerminalFontSize(fontSize),
369533 });
370− }, [fontSize]);
534+ }, [fontSize, hasResolvedFontPreference]);
371535
372536 useEffect(() => {
537+ if (!hasResolvedFontPreference || !hasMeasuredSurface) {
538+ return;
539+ }
540+
373541 const bootstrapAction = resolveTerminalRouteBootstrap({
374542 hasThread: selectedThread !== null,
375543 hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot),
@@ -377,6 +545,8 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line
377545 requestedTerminalId,
378546 currentTerminalId: terminalId,
379547 runningTerminalId: runningSession?.target.terminalId ?? null,
548+ currentTerminalStatus: terminal.status,
549+ hasCurrentTerminalHydration: terminal.snapshot !== null || terminal.buffer.length > 0,
380550 });
381551 if (bootstrapAction.kind === "idle" || !selectedThread) {
382552 return;
@@ -398,13 +568,14 @@ export function ThreadTerminalRouteScreen() { · 15 unmodified lines
398568 runningSession,
399569 selectedThread,
400570 selectedThreadProject?.workspaceRoot,
571+ terminal.buffer.length,
572+ terminal.snapshot,
573+ terminal.status,
401574 terminalId,
575+ hasMeasuredSurface,
576+ hasResolvedFontPreference,
402577 ]);
403578
404− useEffect(() => {
405− setPendingModifier(null);
406− }, [terminalId]);
407−
408579 const writeInput = useCallback(
409580 (data: string) => {
410581 if (!selectedThread || !isRunning) {
@@ -432,20 +603,34 @@ export function ThreadTerminalRouteScreen() { · 21 unmodified lines
432603 }
433604
434605 if (pendingModifier === "ctrl") {
435− setPendingModifier(null);
606+ setPendingModifierState({ terminalId, value: null });
436607 writeInput(applyCtrlModifier(data));
437608 } else if (pendingModifier === "meta") {
438− setPendingModifier(null);
609+ setPendingModifierState({ terminalId, value: null });
439610 writeInput(`\u001b${data}`);
440611 } else {
441612 writeInput(data);
442613 }
443614 },
444− [pendingModifier, writeInput],
615+ [pendingModifier, terminalId, writeInput],
445616 );
446617
447618 const handleResize = useCallback(
448619 (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+ }
449634 if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) {
450635 return;
451636 }
@@ -467,7 +652,18 @@ export function ThreadTerminalRouteScreen() { · 15 unmodified lines
467652 rows: size.rows,
468653 });
469654 },
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+ ],
471667 );
472668
473669 const handleSelectTerminal = useCallback(
@@ -496,7 +692,7 @@ export function ThreadTerminalRouteScreen() { · 22 unmodified lines
496692
497693 const adjustFontSize = useCallback((delta: number) => {
498694 setTimeout(() => {
499− setFontSize((current) => normalizeTerminalFontSize(current + delta));
695+ setFontSize((current) => cacheTerminalFontSize(current + delta));
500696 }, 0);
501697 }, []);
502698
@@ -511,11 +707,17 @@ export function ThreadTerminalRouteScreen() { · 8 unmodified lines
511707 const handleToolbarActionPress = useCallback(
512708 (action: TerminalToolbarAction) => {
513709 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+ }));
515717 return;
516718 }
517719
518− setPendingModifier(null);
720+ setPendingModifierState({ terminalId, value: null });
519721 if (pendingModifier === "ctrl") {
520722 writeInput(applyCtrlModifier(action.data));
521723 } else if (pendingModifier === "meta") {
@@ -524,7 +726,7 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines
524726 writeInput(action.data);
525727 }
526728 },
527− [pendingModifier, writeInput],
729+ [pendingModifier, terminalId, writeInput],
528730 );
529731
530732 if (!selectedThread) {
@@ -561,8 +763,8 @@ export function ThreadTerminalRouteScreen() { · 30 unmodified lines
561763 headerBackButtonDisplayMode: "minimal",
562764 headerBackTitle: "",
563765 headerShadowVisible: false,
564− headerStyle: { backgroundColor: "#0a0a0a" },
565− headerTintColor: "#f5f5f5",
766+ headerStyle: { backgroundColor: terminalTheme.background },
767+ headerTintColor: terminalTheme.foreground,
566768 headerTitleAlign: "center",
567769 title: "",
568770 headerTitle: () => (
@@ -576,7 +778,7 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines
576778 <RNText
577779 numberOfLines={1}
578780 style={{
579− color: "#f5f5f5",
781+ color: terminalTheme.foreground,
580782 fontFamily: "DMSans_700Bold",
581783 fontSize: 13,
582784 lineHeight: 16,
@@ -588,7 +790,7 @@ export function ThreadTerminalRouteScreen() { · 5 unmodified lines
588790 ellipsizeMode="middle"
589791 numberOfLines={1}
590792 style={{
591− color: "#8f8f94",
793+ color: terminalTheme.mutedForeground,
592794 fontFamily: "Menlo",
593795 fontSize: 11,
594796 lineHeight: 14,
@@ -658,10 +860,10 @@ export function ThreadTerminalRouteScreen() { · 63 unmodified lines
658860 </Stack.Toolbar.Menu>
659861 </Stack.Toolbar>
660862
661− <View style={{ flex: 1, backgroundColor: "#050505" }}>
863+ <View style={{ flex: 1, backgroundColor: terminalTheme.background }}>
662864 <Animated.View style={[{ flex: 1 }, terminalContainerAnimatedStyle]}>
663865 <TerminalSurface
664− buffer={terminal.buffer}
866+ buffer={terminalSurfaceBuffer}
665867 fontSize={fontSize}
666868 isRunning={isRunning}
667869 onInput={handleInput}
@@ -674,8 +876,8 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines
674876 <KeyboardStickyView style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}>
675877 <View
676878 style={{
677− backgroundColor: "#0a0a0a",
678− borderTopColor: "rgba(255,255,255,0.08)",
879+ backgroundColor: terminalTheme.background,
880+ borderTopColor: terminalTheme.border,
679881 borderTopWidth: 1,
680882 minHeight: TERMINAL_ACCESSORY_HEIGHT,
681883 paddingBottom: 4,
@@ -698,11 +900,13 @@ export function ThreadTerminalRouteScreen() { · 16 unmodified lines
698900 style={({ pressed }) => ({
699901 alignItems: "center",
700902 backgroundColor: active
701− ? "rgba(74, 222, 128, 0.18)"
903+ ? withAlpha(terminalTheme.palette[10] ?? terminalTheme.foreground, "2e")
702904 : pressed
703− ? "rgba(255,255,255,0.12)"
704− : "rgba(255,255,255,0.06)",
705− borderColor: active ? "rgba(74, 222, 128, 0.36)" : "rgba(255,255,255,0.08)",
905+ ? withAlpha(terminalTheme.foreground, "1f")
906+ : withAlpha(terminalTheme.foreground, "12"),
907+ borderColor: active
908+ ? withAlpha(terminalTheme.palette[10] ?? terminalTheme.foreground, "52")
909+ : terminalTheme.border,
706910 borderRadius: 12,
707911 borderWidth: 1,
708912 justifyContent: "center",
@@ -713,7 +917,9 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines
713917 >
714918 <RNText
715919 style={{
716− color: active ? "#bbf7d0" : "#f5f5f5",
920+ color: active
921+ ? (terminalTheme.palette[10] ?? terminalTheme.foreground)
922+ : terminalTheme.foreground,
717923 fontFamily: "DMSans_700Bold",
718924 fontSize: 12,
719925 fontWeight: "700",
apps/mobile/src/features/threads/terminalBufferReplay.test.ts+43−0
@@ -0,0 +1,43 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { getTerminalBufferReplayKey, getTerminalSurfaceReplayBuffer } from "./terminalBufferReplay";
4+
5+describe("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 @@
1+export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180;
2+
3+export function getTerminalBufferReplayKey(input: {
4+ readonly terminalKey: string;
5+ readonly fontSize: number;
6+}): string {
7+ return `${input.terminalKey}:${input.fontSize}`;
8+}
9+
10+export 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 @@
11 import { describe, expect, it } from "vitest";
22
33 import {
4+ peekPendingTerminalLaunch,
45 resolvePreferredThreadWorktreePath,
56 resolveTerminalOpenLocation,
7+ stagePendingTerminalLaunch,
8+ takePendingTerminalLaunch,
69 } from "./terminalLaunchContext";
710
811 describe("resolvePreferredThreadWorktreePath", () => {
@@ -59,3 +62,68 @@ describe("resolveTerminalOpenLocation", () => { · 50 unmodified lines
5962 });
6063 });
6164 });
65+
66+describe("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
33 readonly worktreePath: string | null;
44 }
55
6+interface PendingTerminalLaunchTarget {
7+ readonly environmentId: string;
8+ readonly threadId: string;
9+ readonly terminalId: string;
10+}
11+
12+export interface PendingTerminalLaunch {
13+ readonly cwd: string;
14+ readonly worktreePath: string | null;
15+ readonly env?: Record<string, string>;
16+ readonly initialInput?: string;
17+}
18+
19+const pendingTerminalLaunches = new Map<string, PendingTerminalLaunch>();
20+
21+function pendingTerminalLaunchKey(input: PendingTerminalLaunchTarget): string {
22+ return `${input.environmentId}:${input.threadId}:${input.terminalId}`;
23+}
24+
25+export 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+
37+export function peekPendingTerminalLaunch(
38+ target: PendingTerminalLaunchTarget,
39+): PendingTerminalLaunch | null {
40+ return pendingTerminalLaunches.get(pendingTerminalLaunchKey(target)) ?? null;
41+}
42+
43+export 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+
655 export function resolvePreferredThreadWorktreePath(input: {
756 readonly threadShellWorktreePath: string | null;
857 readonly threadDetailWorktreePath: string | null;
apps/mobile/src/features/threads/terminalRouteBootstrap.ts+9−0
@@ -5,6 +5,8 @@ export function resolveTerminalRouteBootstrap(input: { · 4 unmodified lines
55 readonly requestedTerminalId: string | null;
66 readonly currentTerminalId: string;
77 readonly runningTerminalId: string | null;
8+ readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed";
9+ readonly hasCurrentTerminalHydration: boolean;
810 }):
911 | { readonly kind: "idle" }
1012 | { readonly kind: "redirect"; readonly terminalId: string }
@@ -21,5 +23,12 @@ export function resolveTerminalRouteBootstrap(input: { · 10 unmodified lines
2123 return { kind: "redirect", terminalId: input.runningTerminalId };
2224 }
2325
26+ if (
27+ (input.currentTerminalStatus === "running" || input.currentTerminalStatus === "starting") &&
28+ input.hasCurrentTerminalHydration
29+ ) {
30+ return { kind: "idle" };
31+ }
32+
2433 return { kind: "open" };
2534 }
apps/mobile/src/features/threads/terminalUiState.test.ts+52−0
@@ -0,0 +1,52 @@
1+import { beforeEach, describe, expect, it } from "vitest";
2+
3+import {
4+ cacheTerminalFontSize,
5+ cacheTerminalGridSize,
6+ getCachedTerminalFontSize,
7+ getCachedTerminalGridSize,
8+ resetTerminalUiStateCaches,
9+} from "./terminalUiState";
10+
11+describe("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 @@
1+import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences";
2+
3+export interface TerminalGridSize {
4+ readonly cols: number;
5+ readonly rows: number;
6+}
7+
8+export interface TerminalUiStateTarget {
9+ readonly environmentId: string;
10+ readonly threadId: string;
11+ readonly terminalId: string;
12+}
13+
14+const terminalGridSizeCache = new Map<string, TerminalGridSize>();
15+let cachedTerminalFontSize: number | null = null;
16+
17+function terminalUiStateKey(target: TerminalUiStateTarget): string {
18+ return `${target.environmentId}:${target.threadId}:${target.terminalId}`;
19+}
20+
21+export function getCachedTerminalFontSize(): number | null {
22+ return cachedTerminalFontSize;
23+}
24+
25+export function cacheTerminalFontSize(value: number | null | undefined): number {
26+ const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE);
27+ cachedTerminalFontSize = normalized;
28+ return normalized;
29+}
30+
31+export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null {
32+ return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null;
33+}
34+
35+export 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+
47+export 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
1414 import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";
1515 import {
1616 buildGhosttyThemeConfig,
17− getGitHubTerminalTheme,
17+ getPierreTerminalTheme,
1818 type TerminalTheme,
1919 } from "./terminalTheme";
2020
@@ -53,7 +53,7 @@ function estimateGridSize(input: { · 32 unmodified lines
5353 const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
5454 const fontSize = props.fontSize ?? 12;
5555 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
56− const theme = props.theme ?? getGitHubTerminalTheme(appearanceScheme);
56+ const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
5757 const statusLabel = props.isRunning
5858 ? "Native terminal unavailable. Using text fallback."
5959 : "Open terminal to start a shell.";
@@ -165,7 +165,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 105 unmodified lines
165165 export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
166166 const fontSize = props.fontSize ?? 12;
167167 const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";
168− const theme = props.theme ?? getGitHubTerminalTheme(appearanceScheme);
168+ const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);
169169 const { onInput, onResize } = props;
170170 const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
171171 const handleNativeInput = useCallback(
apps/mobile/src/native/terminal/terminalTheme.test.ts+20−20
@@ -1,36 +1,36 @@
11 import { describe, expect, it } from "vitest";
22
3−import { buildGhosttyThemeConfig, getGitHubTerminalTheme } from "./terminalTheme";
3+import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme";
44
5−describe("getGitHubTerminalTheme", () => {
6− it("returns the GitHub light terminal palette", () => {
7− expect(getGitHubTerminalTheme("light")).toMatchObject({
8− background: "#ffffff",
9− foreground: "#586069",
10− cursorForeground: "#005cc5",
11− cursorBackground: "#d1d5da",
5+describe("getPierreTerminalTheme", () => {
6+ it("returns the Pierre light terminal palette", () => {
7+ expect(getPierreTerminalTheme("light")).toMatchObject({
8+ background: "#f2f2f7",
9+ foreground: "#6C6C71",
10+ cursorForeground: "#009fff",
11+ cursorBackground: "#f2f2f7",
1212 });
1313 });
1414
15− it("returns the GitHub dark terminal palette", () => {
16− expect(getGitHubTerminalTheme("dark")).toMatchObject({
17− background: "#24292e",
18− foreground: "#d1d5da",
19− cursorForeground: "#79b8ff",
20− cursorBackground: "#586069",
15+ it("returns the Pierre dark terminal palette", () => {
16+ expect(getPierreTerminalTheme("dark")).toMatchObject({
17+ background: "#0a0a0a",
18+ foreground: "#adadb1",
19+ cursorForeground: "#009fff",
20+ cursorBackground: "#0a0a0a",
2121 });
2222 });
2323 });
2424
2525 describe("buildGhosttyThemeConfig", () => {
2626 it("serializes theme colors into a ghostty config file", () => {
27− const config = buildGhosttyThemeConfig(getGitHubTerminalTheme("dark"));
27+ const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark"));
2828
29− expect(config).toContain("background = #24292e");
30− expect(config).toContain("foreground = #d1d5da");
31− expect(config).toContain("cursor-color = #79b8ff");
32− expect(config).toContain("palette = 0=#586069");
33− expect(config).toContain("palette = 15=#fafbfc");
29+ expect(config).toContain("background = #0a0a0a");
30+ expect(config).toContain("foreground = #adadb1");
31+ expect(config).toContain("cursor-color = #009fff");
32+ expect(config).toContain("palette = 0=#141415");
33+ expect(config).toContain("palette = 15=#c6c6c8");
3434 expect(config.endsWith("\n")).toBe(true);
3535 });
3636 });
apps/mobile/src/native/terminal/terminalTheme.ts+50−49
@@ -10,63 +10,64 @@ export interface TerminalTheme { · 9 unmodified lines
1010 readonly palette: readonly string[];
1111 }
1212
13−const GITHUB_LIGHT_THEME: TerminalTheme = {
14− // Sourced from GitHub's official VS Code theme terminal palette.
15− background: "#ffffff",
16− foreground: "#586069",
17− mutedForeground: "#6a737d",
18− border: "rgba(27,31,35,0.12)",
19− cursorForeground: "#005cc5",
20− cursorBackground: "#d1d5da",
13+const PIERRE_LIGHT_THEME: TerminalTheme = {
14+ // Pierre terminal palette with the app's shared screen background.
15+ background: "#f2f2f7",
16+ foreground: "#6C6C71",
17+ mutedForeground: "#8E8E95",
18+ border: "#eeeeef",
19+ cursorForeground: "#009fff",
20+ cursorBackground: "#f2f2f7",
2121 palette: [
22− "#24292e",
23− "#d73a49",
24− "#28a745",
25− "#dbab09",
26− "#0366d6",
27− "#5a32a3",
28− "#1b7c83",
29− "#6a737d",
30− "#959da5",
31− "#cb2431",
32− "#22863a",
33− "#b08800",
34− "#005cc5",
35− "#5a32a3",
36− "#3192aa",
37− "#d1d5da",
22+ "#1F1F21",
23+ "#ff2e3f",
24+ "#0dbe4e",
25+ "#ffca00",
26+ "#009fff",
27+ "#c635e4",
28+ "#08c0ef",
29+ "#c6c6c8",
30+ "#1F1F21",
31+ "#ff2e3f",
32+ "#0dbe4e",
33+ "#ffca00",
34+ "#009fff",
35+ "#c635e4",
36+ "#08c0ef",
37+ "#c6c6c8",
3838 ],
3939 };
4040
41−const GITHUB_DARK_THEME: TerminalTheme = {
42− background: "#24292e",
43− foreground: "#d1d5da",
44− mutedForeground: "#959da5",
45− border: "rgba(240,246,252,0.12)",
46− cursorForeground: "#79b8ff",
47− cursorBackground: "#586069",
41+const PIERRE_DARK_THEME: TerminalTheme = {
42+ // Pierre terminal palette with the app's shared screen background.
43+ background: "#0a0a0a",
44+ foreground: "#adadb1",
45+ mutedForeground: "#8E8E95",
46+ border: "#2e2e30",
47+ cursorForeground: "#009fff",
48+ cursorBackground: "#0a0a0a",
4849 palette: [
49− "#586069",
50− "#ea4a5a",
51− "#34d058",
52− "#ffea7f",
53− "#2188ff",
54− "#b392f0",
55− "#39c5cf",
56− "#d1d5da",
57− "#959da5",
58− "#f97583",
59− "#85e89d",
60− "#ffea7f",
61− "#79b8ff",
62− "#b392f0",
63− "#56d4dd",
64− "#fafbfc",
50+ "#141415",
51+ "#ff2e3f",
52+ "#0dbe4e",
53+ "#ffca00",
54+ "#009fff",
55+ "#c635e4",
56+ "#08c0ef",
57+ "#c6c6c8",
58+ "#141415",
59+ "#ff2e3f",
60+ "#0dbe4e",
61+ "#ffca00",
62+ "#009fff",
63+ "#c635e4",
64+ "#08c0ef",
65+ "#c6c6c8",
6566 ],
6667 };
6768
68−export function getGitHubTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme {
69− return scheme === "light" ? GITHUB_LIGHT_THEME : GITHUB_DARK_THEME;
69+export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme {
70+ return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME;
7071 }
7172
7273 export function buildGhosttyThemeConfig(theme: TerminalTheme): string {