- Carry pending launch context into terminal open - Delay buffer replay until surface hydration is stable - Co-authored-by: codex <codex@users.noreply.github.com>
Unified · Split
| @@ -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 = 12f | |
| 50 | + var fontSize: Float = 10f | |
| 51 | 51 | set(value) { |
| 52 | 52 | field = value |
| 53 | 53 | textView.textSize = value |
| 54 | + inputView.textSize = max(value, 13f) | |
| 54 | 55 | emitResize() |
| 55 | 56 | } |
| 56 | 57 | |
| @@ -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 | 104 | didSet { |
| 105 | + guard oldValue != fontSize else { return } | |
| 105 | 106 | inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) |
| 106 | − resetSurface() | |
| 107 | + refreshSurface() | |
| 107 | 108 | } |
| 108 | 109 | } |
| 109 | 110 | |
| 110 | 111 | var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { |
| 111 | 112 | didSet { |
| 113 | + guard oldValue != appearanceScheme else { return } | |
| 112 | 114 | appearance = TerminalAppearanceScheme(value: appearanceScheme) |
| 113 | − updateGhosttyTheme() | |
| 115 | + refreshSurface() | |
| 114 | 116 | } |
| 115 | 117 | } |
| 116 | 118 | |
| 117 | 119 | var themeConfig: String = "" { |
| 118 | 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 | 356 | setNeedsLayout() |
| 354 | 357 | } |
| 355 | 358 | |
| 359 | + private func refreshSurface() { | |
| 360 | + resetSurface() | |
| 361 | + createSurfaceIfPossible() | |
| 362 | + } | |
| 363 | + | |
| 356 | 364 | private func destroySurface() { |
| 357 | 365 | if let surface { |
| 358 | 366 | ghostty_surface_set_write_callback(surface, nil, nil) |
| @@ -525,19 +533,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 166 unmodified lines | ||
| 525 | 533 | 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 | 536 | private func loadThemeConfig(into config: ghostty_config_t) { |
| 542 | 537 | guard let path = writeThemeConfigFile() else { return } |
| 543 | 538 | path.withCString { cString in |
| @@ -18,11 +18,10 @@ import { connectionTone } from "../connection/connectionTone"; · 17 unmodified lines | ||
| 18 | 18 | |
| 19 | 19 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 20 | 20 | import { |
| 21 | − getEnvironmentClient, | |
| 22 | 21 | useRemoteConnectionStatus, |
| 23 | 22 | useRemoteEnvironmentState, |
| 24 | 23 | } from "../../state/use-remote-environment-registry"; |
| 25 | −import { terminalSessionManager, useKnownTerminalSessions } from "../../state/use-terminal-session"; | |
| 24 | +import { useKnownTerminalSessions } from "../../state/use-terminal-session"; | |
| 26 | 25 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 27 | 26 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 28 | 27 | import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; |
| @@ -31,7 +30,10 @@ import { · 2 unmodified lines | ||
| 31 | 30 | nextTerminalId, |
| 32 | 31 | resolveProjectScriptTerminalId, |
| 33 | 32 | } from "./terminalMenu"; |
| 34 | −import { resolvePreferredThreadWorktreePath } from "./terminalLaunchContext"; | |
| 33 | +import { | |
| 34 | + resolvePreferredThreadWorktreePath, | |
| 35 | + stagePendingTerminalLaunch, | |
| 36 | +} from "./terminalLaunchContext"; | |
| 35 | 37 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; |
| 36 | 38 | import { ThreadGitControls } from "./ThreadGitControls"; |
| 37 | 39 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; |
| @@ -166,11 +168,6 @@ export function ThreadRouteScreen() { · 128 unmodified lines | ||
| 166 | 168 | return; |
| 167 | 169 | } |
| 168 | 170 | |
| 169 | − const client = getEnvironmentClient(selectedThread.environmentId); | |
| 170 | − if (!client) { | |
| 171 | − return; | |
| 172 | − } | |
| 173 | − | |
| 174 | 171 | const targetTerminalId = resolveProjectScriptTerminalId({ |
| 175 | 172 | existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId), |
| 176 | 173 | hasRunningTerminal: terminalMenuSessions.some( |
| @@ -189,23 +186,20 @@ export function ThreadRouteScreen() { · 12 unmodified lines | ||
| 189 | 186 | project: { cwd: selectedThreadProject.workspaceRoot }, |
| 190 | 187 | worktreePath: preferredWorktreePath, |
| 191 | 188 | }); |
| 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 | + }, | |
| 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 | 203 | void router.push(buildThreadTerminalRoutePath(selectedThread, targetTerminalId)); |
| 210 | 204 | }, |
| 211 | 205 | [ |
| @@ -12,6 +12,8 @@ describe("resolveTerminalRouteBootstrap", () => { · 11 unmodified lines | ||
| 12 | 12 | requestedTerminalId: null, |
| 13 | 13 | currentTerminalId: "default", |
| 14 | 14 | runningTerminalId: "term-2", |
| 15 | + currentTerminalStatus: "closed", | |
| 16 | + hasCurrentTerminalHydration: false, | |
| 15 | 17 | }), |
| 16 | 18 | ).toEqual({ |
| 17 | 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 | 25 | expect( |
| 24 | 26 | resolveTerminalRouteBootstrap({ |
| 25 | 27 | hasThread: true, |
| @@ -28,13 +30,15 @@ describe("resolveTerminalRouteBootstrap", () => { · 2 unmodified lines | ||
| 28 | 30 | requestedTerminalId: null, |
| 29 | 31 | currentTerminalId: "default", |
| 30 | 32 | runningTerminalId: "default", |
| 33 | + currentTerminalStatus: "running", | |
| 34 | + hasCurrentTerminalHydration: false, | |
| 31 | 35 | }), |
| 32 | 36 | ).toEqual({ |
| 33 | 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 | 42 | expect( |
| 39 | 43 | resolveTerminalRouteBootstrap({ |
| 40 | 44 | hasThread: true, |
| @@ -43,6 +47,8 @@ describe("resolveTerminalRouteBootstrap", () => { · 2 unmodified lines | ||
| 43 | 47 | requestedTerminalId: "term-2", |
| 44 | 48 | currentTerminalId: "term-2", |
| 45 | 49 | runningTerminalId: "term-2", |
| 50 | + currentTerminalStatus: "running", | |
| 51 | + hasCurrentTerminalHydration: false, | |
| 46 | 52 | }), |
| 47 | 53 | ).toEqual({ |
| 48 | 54 | kind: "open", |
| @@ -58,6 +64,42 @@ describe("resolveTerminalRouteBootstrap", () => { · 9 unmodified lines | ||
| 58 | 64 | requestedTerminalId: null, |
| 59 | 65 | currentTerminalId: "default", |
| 60 | 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 | 104 | ).toEqual({ |
| 63 | 105 | kind: "idle", |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts"; |
| 2 | 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 3 | 3 | 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"; | |
| 5 | 5 | import { KeyboardStickyView } from "react-native-keyboard-controller"; |
| 6 | 6 | import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated"; |
| 7 | 7 | |
| @@ -21,9 +21,19 @@ import { · 13 unmodified lines | ||
| 21 | 21 | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 22 | 22 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; |
| 23 | 23 | import { TerminalSurface } from "../../native/terminal/NativeTerminalSurface"; |
| 24 | +import { getPierreTerminalTheme } from "../../native/terminal/terminalTheme"; | |
| 24 | 25 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; |
| 26 | +import { | |
| 27 | + getTerminalBufferReplayKey, | |
| 28 | + getTerminalSurfaceReplayBuffer, | |
| 29 | + TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, | |
| 30 | +} from "./terminalBufferReplay"; | |
| 25 | 31 | import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; |
| 26 | −import { resolveTerminalOpenLocation } from "./terminalLaunchContext"; | |
| 32 | +import { | |
| 33 | + resolveTerminalOpenLocation, | |
| 34 | + stagePendingTerminalLaunch, | |
| 35 | + takePendingTerminalLaunch, | |
| 36 | +} from "./terminalLaunchContext"; | |
| 27 | 37 | import { |
| 28 | 38 | basename, |
| 29 | 39 | buildTerminalMenuSessions, |
| @@ -38,6 +48,12 @@ import { · 8 unmodified lines | ||
| 38 | 48 | TERMINAL_FONT_SIZE_STEP, |
| 39 | 49 | normalizeTerminalFontSize, |
| 40 | 50 | } from "./terminalPreferences"; |
| 51 | +import { | |
| 52 | + cacheTerminalFontSize, | |
| 53 | + cacheTerminalGridSize, | |
| 54 | + getCachedTerminalFontSize, | |
| 55 | + getCachedTerminalGridSize, | |
| 56 | +} from "./terminalUiState"; | |
| 41 | 57 | |
| 42 | 58 | const DEFAULT_TERMINAL_COLS = 80; |
| 43 | 59 | const DEFAULT_TERMINAL_ROWS = 24; |
| @@ -154,9 +170,14 @@ function applyCtrlModifier(input: string): string { · 110 unmodified lines | ||
| 154 | 170 | return input; |
| 155 | 171 | } |
| 156 | 172 | |
| 173 | +function withAlpha(hexColor: string, alpha: string): string { | |
| 174 | + return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor; | |
| 175 | +} | |
| 176 | + | |
| 157 | 177 | export function ThreadTerminalRouteScreen() { |
| 158 | 178 | const router = useRouter(); |
| 159 | 179 | const keyboard = useAnimatedKeyboard(); |
| 180 | + const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | |
| 160 | 181 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); |
| 161 | 182 | const params = useLocalSearchParams<{ |
| 162 | 183 | environmentId?: string | string[]; |
| @@ -166,21 +187,45 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines | ||
| 166 | 187 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = |
| 167 | 188 | useThreadSelection(); |
| 168 | 189 | const selectedThreadDetail = useSelectedThreadDetail(); |
| 190 | + const routeEnvironmentId = firstRouteParam(params.environmentId); | |
| 191 | + const routeThreadId = firstRouteParam(params.threadId); | |
| 169 | 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 | 203 | const knownSessions = useKnownTerminalSessions({ |
| 171 | 204 | environmentId: selectedThread?.environmentId ?? null, |
| 172 | 205 | threadId: selectedThread?.id ?? null, |
| 173 | 206 | }); |
| 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); | |
| 180 | 214 | 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 | + }); | |
| 184 | 229 | const target = useTerminalSessionTarget({ |
| 185 | 230 | environmentId: selectedThread?.environmentId ?? null, |
| 186 | 231 | threadId: selectedThread?.id ?? null, |
| @@ -194,6 +239,18 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines | ||
| 194 | 239 | : terminalId, |
| 195 | 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 | 254 | const isRunning = terminal.status === "running" || terminal.status === "starting"; |
| 198 | 255 | const cwd = terminal.snapshot?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; |
| 199 | 256 | const hostPlatform = useMemo( |
| @@ -219,6 +276,9 @@ export function ThreadTerminalRouteScreen() { · 19 unmodified lines | ||
| 219 | 276 | }), |
| 220 | 277 | [terminal.hasRunningSubprocess, terminal.status], |
| 221 | 278 | ); |
| 279 | + const terminalTheme = getPierreTerminalTheme(appearanceScheme); | |
| 280 | + const pendingModifier = | |
| 281 | + pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; | |
| 222 | 282 | const headerTitle = useMemo(() => { |
| 223 | 283 | const topLineParts = [ |
| 224 | 284 | selectedEnvironmentConnection?.environmentLabel ?? null, |
| @@ -301,24 +361,59 @@ export function ThreadTerminalRouteScreen() { · 76 unmodified lines | ||
| 301 | 361 | return; |
| 302 | 362 | } |
| 303 | 363 | |
| 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, | |
| 313 | 366 | threadId: selectedThread.id, |
| 314 | 367 | 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 | + ); | |
| 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 | 418 | lastGridSize.cols, |
| 324 | 419 | lastGridSize.rows, |
| @@ -334,6 +429,74 @@ export function ThreadTerminalRouteScreen() { · 9 unmodified lines | ||
| 334 | 429 | hasOpenedRef.current = false; |
| 335 | 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 | 500 | useEffect(() => { |
| 338 | 501 | let cancelled = false; |
| 339 | 502 | |
| @@ -343,15 +506,15 @@ export function ThreadTerminalRouteScreen() { · 3 unmodified lines | ||
| 343 | 506 | return; |
| 344 | 507 | } |
| 345 | 508 | |
| 346 | − setFontSize(normalizeTerminalFontSize(preferences.terminalFontSize)); | |
| 347 | − hasLoadedFontPreferenceRef.current = true; | |
| 509 | + setFontSize(cacheTerminalFontSize(preferences.terminalFontSize)); | |
| 510 | + setHasResolvedFontPreference(true); | |
| 348 | 511 | }) |
| 349 | 512 | .catch(() => { |
| 350 | 513 | if (cancelled) { |
| 351 | 514 | return; |
| 352 | 515 | } |
| 353 | 516 | |
| 354 | − hasLoadedFontPreferenceRef.current = true; | |
| 517 | + setHasResolvedFontPreference(true); | |
| 355 | 518 | }); |
| 356 | 519 | |
| 357 | 520 | return () => { |
| @@ -360,16 +523,21 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines | ||
| 360 | 523 | }, []); |
| 361 | 524 | |
| 362 | 525 | useEffect(() => { |
| 363 | − if (!hasLoadedFontPreferenceRef.current) { | |
| 526 | + if (!hasResolvedFontPreference) { | |
| 364 | 527 | return; |
| 365 | 528 | } |
| 366 | 529 | |
| 530 | + cacheTerminalFontSize(fontSize); | |
| 367 | 531 | void savePreferencesPatch({ |
| 368 | 532 | terminalFontSize: normalizeTerminalFontSize(fontSize), |
| 369 | 533 | }); |
| 370 | − }, [fontSize]); | |
| 534 | + }, [fontSize, hasResolvedFontPreference]); | |
| 371 | 535 | |
| 372 | 536 | useEffect(() => { |
| 537 | + if (!hasResolvedFontPreference || !hasMeasuredSurface) { | |
| 538 | + return; | |
| 539 | + } | |
| 540 | + | |
| 373 | 541 | const bootstrapAction = resolveTerminalRouteBootstrap({ |
| 374 | 542 | hasThread: selectedThread !== null, |
| 375 | 543 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), |
| @@ -377,6 +545,8 @@ export function ThreadTerminalRouteScreen() { · 1 unmodified line | ||
| 377 | 545 | requestedTerminalId, |
| 378 | 546 | currentTerminalId: terminalId, |
| 379 | 547 | runningTerminalId: runningSession?.target.terminalId ?? null, |
| 548 | + currentTerminalStatus: terminal.status, | |
| 549 | + hasCurrentTerminalHydration: terminal.snapshot !== null || terminal.buffer.length > 0, | |
| 380 | 550 | }); |
| 381 | 551 | if (bootstrapAction.kind === "idle" || !selectedThread) { |
| 382 | 552 | return; |
| @@ -398,13 +568,14 @@ export function ThreadTerminalRouteScreen() { · 15 unmodified lines | ||
| 398 | 568 | runningSession, |
| 399 | 569 | selectedThread, |
| 400 | 570 | selectedThreadProject?.workspaceRoot, |
| 571 | + terminal.buffer.length, | |
| 572 | + terminal.snapshot, | |
| 573 | + terminal.status, | |
| 401 | 574 | terminalId, |
| 575 | + hasMeasuredSurface, | |
| 576 | + hasResolvedFontPreference, | |
| 402 | 577 | ]); |
| 403 | 578 | |
| 404 | − useEffect(() => { | |
| 405 | − setPendingModifier(null); | |
| 406 | − }, [terminalId]); | |
| 407 | − | |
| 408 | 579 | const writeInput = useCallback( |
| 409 | 580 | (data: string) => { |
| 410 | 581 | if (!selectedThread || !isRunning) { |
| @@ -432,20 +603,34 @@ export function ThreadTerminalRouteScreen() { · 21 unmodified lines | ||
| 432 | 603 | } |
| 433 | 604 | |
| 434 | 605 | if (pendingModifier === "ctrl") { |
| 435 | − setPendingModifier(null); | |
| 606 | + setPendingModifierState({ terminalId, value: null }); | |
| 436 | 607 | writeInput(applyCtrlModifier(data)); |
| 437 | 608 | } else if (pendingModifier === "meta") { |
| 438 | − setPendingModifier(null); | |
| 609 | + setPendingModifierState({ terminalId, value: null }); | |
| 439 | 610 | writeInput(`\u001b${data}`); |
| 440 | 611 | } else { |
| 441 | 612 | writeInput(data); |
| 442 | 613 | } |
| 443 | 614 | }, |
| 444 | − [pendingModifier, writeInput], | |
| 615 | + [pendingModifier, terminalId, writeInput], | |
| 445 | 616 | ); |
| 446 | 617 | |
| 447 | 618 | const handleResize = useCallback( |
| 448 | 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 | 634 | if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { |
| 450 | 635 | return; |
| 451 | 636 | } |
| @@ -467,7 +652,18 @@ export function ThreadTerminalRouteScreen() { · 15 unmodified lines | ||
| 467 | 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 | 669 | const handleSelectTerminal = useCallback( |
| @@ -496,7 +692,7 @@ export function ThreadTerminalRouteScreen() { · 22 unmodified lines | ||
| 496 | 692 | |
| 497 | 693 | const adjustFontSize = useCallback((delta: number) => { |
| 498 | 694 | setTimeout(() => { |
| 499 | − setFontSize((current) => normalizeTerminalFontSize(current + delta)); | |
| 695 | + setFontSize((current) => cacheTerminalFontSize(current + delta)); | |
| 500 | 696 | }, 0); |
| 501 | 697 | }, []); |
| 502 | 698 | |
| @@ -511,11 +707,17 @@ export function ThreadTerminalRouteScreen() { · 8 unmodified lines | ||
| 511 | 707 | const handleToolbarActionPress = useCallback( |
| 512 | 708 | (action: TerminalToolbarAction) => { |
| 513 | 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 | 717 | return; |
| 516 | 718 | } |
| 517 | 719 | |
| 518 | − setPendingModifier(null); | |
| 720 | + setPendingModifierState({ terminalId, value: null }); | |
| 519 | 721 | if (pendingModifier === "ctrl") { |
| 520 | 722 | writeInput(applyCtrlModifier(action.data)); |
| 521 | 723 | } else if (pendingModifier === "meta") { |
| @@ -524,7 +726,7 @@ export function ThreadTerminalRouteScreen() { · 2 unmodified lines | ||
| 524 | 726 | writeInput(action.data); |
| 525 | 727 | } |
| 526 | 728 | }, |
| 527 | − [pendingModifier, writeInput], | |
| 729 | + [pendingModifier, terminalId, writeInput], | |
| 528 | 730 | ); |
| 529 | 731 | |
| 530 | 732 | if (!selectedThread) { |
| @@ -561,8 +763,8 @@ export function ThreadTerminalRouteScreen() { · 30 unmodified lines | ||
| 561 | 763 | headerBackButtonDisplayMode: "minimal", |
| 562 | 764 | headerBackTitle: "", |
| 563 | 765 | headerShadowVisible: false, |
| 564 | − headerStyle: { backgroundColor: "#0a0a0a" }, | |
| 565 | − headerTintColor: "#f5f5f5", | |
| 766 | + headerStyle: { backgroundColor: terminalTheme.background }, | |
| 767 | + headerTintColor: terminalTheme.foreground, | |
| 566 | 768 | headerTitleAlign: "center", |
| 567 | 769 | title: "", |
| 568 | 770 | headerTitle: () => ( |
| @@ -576,7 +778,7 @@ export function ThreadTerminalRouteScreen() { · 7 unmodified lines | ||
| 576 | 778 | <RNText |
| 577 | 779 | numberOfLines={1} |
| 578 | 780 | style={{ |
| 579 | − color: "#f5f5f5", | |
| 781 | + color: terminalTheme.foreground, | |
| 580 | 782 | fontFamily: "DMSans_700Bold", |
| 581 | 783 | fontSize: 13, |
| 582 | 784 | lineHeight: 16, |
| @@ -588,7 +790,7 @@ export function ThreadTerminalRouteScreen() { · 5 unmodified lines | ||
| 588 | 790 | ellipsizeMode="middle" |
| 589 | 791 | numberOfLines={1} |
| 590 | 792 | style={{ |
| 591 | − color: "#8f8f94", | |
| 793 | + color: terminalTheme.mutedForeground, | |
| 592 | 794 | fontFamily: "Menlo", |
| 593 | 795 | fontSize: 11, |
| 594 | 796 | lineHeight: 14, |
| @@ -658,10 +860,10 @@ export function ThreadTerminalRouteScreen() { · 63 unmodified lines | ||
| 658 | 860 | </Stack.Toolbar.Menu> |
| 659 | 861 | </Stack.Toolbar> |
| 660 | 862 | |
| 661 | − <View style={{ flex: 1, backgroundColor: "#050505" }}> | |
| 863 | + <View style={{ flex: 1, backgroundColor: terminalTheme.background }}> | |
| 662 | 864 | <Animated.View style={[{ flex: 1 }, terminalContainerAnimatedStyle]}> |
| 663 | 865 | <TerminalSurface |
| 664 | − buffer={terminal.buffer} | |
| 866 | + buffer={terminalSurfaceBuffer} | |
| 665 | 867 | fontSize={fontSize} |
| 666 | 868 | isRunning={isRunning} |
| 667 | 869 | onInput={handleInput} |
| @@ -674,8 +876,8 @@ export function ThreadTerminalRouteScreen() { · 6 unmodified lines | ||
| 674 | 876 | <KeyboardStickyView style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}> |
| 675 | 877 | <View |
| 676 | 878 | style={{ |
| 677 | − backgroundColor: "#0a0a0a", | |
| 678 | − borderTopColor: "rgba(255,255,255,0.08)", | |
| 879 | + backgroundColor: terminalTheme.background, | |
| 880 | + borderTopColor: terminalTheme.border, | |
| 679 | 881 | borderTopWidth: 1, |
| 680 | 882 | minHeight: TERMINAL_ACCESSORY_HEIGHT, |
| 681 | 883 | paddingBottom: 4, |
| @@ -698,11 +900,13 @@ export function ThreadTerminalRouteScreen() { · 16 unmodified lines | ||
| 698 | 900 | style={({ pressed }) => ({ |
| 699 | 901 | alignItems: "center", |
| 700 | 902 | backgroundColor: active |
| 701 | − ? "rgba(74, 222, 128, 0.18)" | |
| 903 | + ? withAlpha(terminalTheme.palette[10] ?? terminalTheme.foreground, "2e") | |
| 702 | 904 | : 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, | |
| 706 | 910 | borderRadius: 12, |
| 707 | 911 | borderWidth: 1, |
| 708 | 912 | justifyContent: "center", |
| @@ -713,7 +917,9 @@ export function ThreadTerminalRouteScreen() { · 4 unmodified lines | ||
| 713 | 917 | > |
| 714 | 918 | <RNText |
| 715 | 919 | style={{ |
| 716 | − color: active ? "#bbf7d0" : "#f5f5f5", | |
| 920 | + color: active | |
| 921 | + ? (terminalTheme.palette[10] ?? terminalTheme.foreground) | |
| 922 | + : terminalTheme.foreground, | |
| 717 | 923 | fontFamily: "DMSans_700Bold", |
| 718 | 924 | fontSize: 12, |
| 719 | 925 | fontWeight: "700", |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | + peekPendingTerminalLaunch, | |
| 4 | 5 | resolvePreferredThreadWorktreePath, |
| 5 | 6 | resolveTerminalOpenLocation, |
| 7 | + stagePendingTerminalLaunch, | |
| 8 | + takePendingTerminalLaunch, | |
| 6 | 9 | } from "./terminalLaunchContext"; |
| 7 | 10 | |
| 8 | 11 | describe("resolvePreferredThreadWorktreePath", () => { |
| @@ -59,3 +62,68 @@ describe("resolveTerminalOpenLocation", () => { · 50 unmodified lines | ||
| 59 | 62 | }); |
| 60 | 63 | }); |
| 61 | 64 | }); |
| 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 | +}); | |
| @@ -3,6 +3,55 @@ interface TerminalSnapshotLike { · 2 unmodified lines | ||
| 3 | 3 | readonly worktreePath: string | null; |
| 4 | 4 | } |
| 5 | 5 | |
| 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 | + | |
| 6 | 55 | export function resolvePreferredThreadWorktreePath(input: { |
| 7 | 56 | readonly threadShellWorktreePath: string | null; |
| 8 | 57 | readonly threadDetailWorktreePath: string | null; |
| @@ -5,6 +5,8 @@ export function resolveTerminalRouteBootstrap(input: { · 4 unmodified lines | ||
| 5 | 5 | readonly requestedTerminalId: string | null; |
| 6 | 6 | readonly currentTerminalId: string; |
| 7 | 7 | readonly runningTerminalId: string | null; |
| 8 | + readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed"; | |
| 9 | + readonly hasCurrentTerminalHydration: boolean; | |
| 8 | 10 | }): |
| 9 | 11 | | { readonly kind: "idle" } |
| 10 | 12 | | { readonly kind: "redirect"; readonly terminalId: string } |
| @@ -21,5 +23,12 @@ export function resolveTerminalRouteBootstrap(input: { · 10 unmodified lines | ||
| 21 | 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 | 33 | return { kind: "open" }; |
| 25 | 34 | } |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -14,7 +14,7 @@ import { AppText as Text } from "../../components/AppText"; · 13 unmodified lines | ||
| 14 | 14 | import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule"; |
| 15 | 15 | import { |
| 16 | 16 | buildGhosttyThemeConfig, |
| 17 | − getGitHubTerminalTheme, | |
| 17 | + getPierreTerminalTheme, | |
| 18 | 18 | type TerminalTheme, |
| 19 | 19 | } from "./terminalTheme"; |
| 20 | 20 | |
| @@ -53,7 +53,7 @@ function estimateGridSize(input: { · 32 unmodified lines | ||
| 53 | 53 | const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { |
| 54 | 54 | const fontSize = props.fontSize ?? 12; |
| 55 | 55 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; |
| 56 | − const theme = props.theme ?? getGitHubTerminalTheme(appearanceScheme); | |
| 56 | + const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); | |
| 57 | 57 | const statusLabel = props.isRunning |
| 58 | 58 | ? "Native terminal unavailable. Using text fallback." |
| 59 | 59 | : "Open terminal to start a shell."; |
| @@ -165,7 +165,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter · 105 unmodified lines | ||
| 165 | 165 | export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { |
| 166 | 166 | const fontSize = props.fontSize ?? 12; |
| 167 | 167 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; |
| 168 | − const theme = props.theme ?? getGitHubTerminalTheme(appearanceScheme); | |
| 168 | + const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); | |
| 169 | 169 | const { onInput, onResize } = props; |
| 170 | 170 | const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); |
| 171 | 171 | const handleNativeInput = useCallback( |
| @@ -1,36 +1,36 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | −import { buildGhosttyThemeConfig, getGitHubTerminalTheme } from "./terminalTheme"; | |
| 3 | +import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme"; | |
| 4 | 4 | |
| 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", | |
| 12 | 12 | }); |
| 13 | 13 | }); |
| 14 | 14 | |
| 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", | |
| 21 | 21 | }); |
| 22 | 22 | }); |
| 23 | 23 | }); |
| 24 | 24 | |
| 25 | 25 | describe("buildGhosttyThemeConfig", () => { |
| 26 | 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"); | |
| 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"); | |
| 34 | 34 | expect(config.endsWith("\n")).toBe(true); |
| 35 | 35 | }); |
| 36 | 36 | }); |
| @@ -10,63 +10,64 @@ export interface TerminalTheme { · 9 unmodified lines | ||
| 10 | 10 | readonly palette: readonly string[]; |
| 11 | 11 | } |
| 12 | 12 | |
| 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", | |
| 21 | 21 | 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", | |
| 38 | 38 | ], |
| 39 | 39 | }; |
| 40 | 40 | |
| 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", | |
| 48 | 49 | 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", | |
| 65 | 66 | ], |
| 66 | 67 | }; |
| 67 | 68 | |
| 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; | |
| 70 | 71 | } |
| 71 | 72 | |
| 72 | 73 | export function buildGhosttyThemeConfig(theme: TerminalTheme): string { |