diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 1631c7fe68a1..d638a326b3f6 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -30,6 +30,10 @@ class T3TerminalModule : Module() { view.focusRequest = focusRequest } + Prop("readOnly") { view: T3TerminalView, readOnly: Boolean -> + view.readOnly = readOnly + } + Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean -> view.autoFocus = autoFocus } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 88de793a8f7d..11a975f3697e 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -76,6 +76,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } + var readOnly: Boolean = false + set(value) { + field = value + inputView.isEnabled = !value + if (value) { + inputView.clearFocus() + hideKeyboard() + } + } + var autoFocus: Boolean = true set(value) { field = value @@ -214,6 +224,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS inputView.setPadding(0, 0, 0, 0) inputView.setOnEditorActionListener { _, actionId, event -> + if (readOnly) return@setOnEditorActionListener true val isKeyUp = event?.action == KeyEvent.ACTION_UP val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER && @@ -228,6 +239,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } inputView.setOnKeyListener { _, keyCode, event -> + if (readOnly) return@setOnKeyListener true if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { keyCode == KeyEvent.KEYCODE_DEL -> { @@ -249,11 +261,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (readOnly) return if (clearingInput || s == null || count <= 0) return val end = (start + count).coerceAtMost(s.length) - if (start >= end) return - val insertedText = s.subSequence(start, end).toString() - if (insertedText.isNotEmpty()) { + if (start < end) { + val insertedText = s.subSequence(start, end).toString() onInput(mapOf("data" to insertedText)) } } @@ -366,12 +378,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } private fun emitResponse(response: ByteArray) { - if (response.isNotEmpty()) { + if (!readOnly && response.isNotEmpty()) { onInput(mapOf("data" to String(response, Charsets.UTF_8))) } } private fun requestKeyboardFocus() { + if (readOnly) return inputView.requestFocus() val inputMethodManager = context.getSystemService( Context.INPUT_METHOD_SERVICE diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index f68cc6b4a112..fb923f3bf949 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -27,6 +27,10 @@ public class T3TerminalModule: Module { view.focusRequest = focusRequest } + Prop("readOnly") { (view: T3TerminalView, readOnly: Bool) in + view.readOnly = readOnly + } + Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in view.autoFocus = autoFocus } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index f04db4467fdf..6a12dbc552a7 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -248,6 +248,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + var readOnly = false { + didSet { + if readOnly { + inputField.resignFirstResponder() + } + } + } + var autoFocus = true { didSet { guard oldValue != autoFocus else { return } @@ -582,7 +590,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return } DispatchQueue.main.async { - view.onInput(["data": input]) + view.emitInput(input) } }, userdata) } @@ -657,13 +665,13 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } private func requestKeyboardFocus() { - guard window != nil else { return } + guard window != nil, !readOnly else { return } inputField.becomeFirstResponder() textInputModeDidChange() } private func emitInput(_ data: String) { - guard !data.isEmpty else { return } + guard !readOnly, !data.isEmpty else { return } onInput(["data": data]) } diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 37dec1fe4562..b8df5d999584 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -22,6 +22,7 @@ import { type TerminalTheme, } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; +import { useTerminalSurfaceBuffer } from "./useTerminalSurfaceBuffer"; interface TerminalInputEvent { readonly data: string; @@ -34,9 +35,10 @@ interface TerminalResizeEvent { interface TerminalSurfaceProps extends ViewProps { readonly terminalKey: string; - readonly buffer: string; + readonly buffer: string | null; readonly fontSize?: number; readonly isRunning: boolean; + readonly readOnly?: boolean; readonly autoFocus?: boolean; readonly keyboardFocusRequest?: number; readonly theme?: TerminalTheme; @@ -44,6 +46,10 @@ interface TerminalSurfaceProps extends ViewProps { readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void; } +type ReadyTerminalSurfaceProps = Omit & { + readonly buffer: string; +}; + function estimateGridSize(input: { readonly width: number; readonly height: number; @@ -57,14 +63,18 @@ function estimateGridSize(input: { }; } -const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { +const FallbackTerminalSurface = memo(function FallbackTerminalSurface( + props: ReadyTerminalSurfaceProps, +) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const inputRef = useRef(null); const { themeAppearance, themeId } = useAppearancePreferences(); const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance); - const statusLabel = props.isRunning - ? "Native terminal unavailable. Using text fallback." - : "Open terminal to start a shell."; + const statusLabel = props.readOnly + ? "Viewing terminal output." + : props.isRunning + ? "Native terminal unavailable. Using text fallback." + : "Open terminal to start a shell."; const handleLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; @@ -72,14 +82,14 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter }; useEffect(() => { - if ((props.keyboardFocusRequest ?? 0) > 0) { + if (!props.readOnly && (props.keyboardFocusRequest ?? 0) > 0) { inputRef.current?.blur(); const focusFrame = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(focusFrame); } return undefined; - }, [props.keyboardFocusRequest]); + }, [props.keyboardFocusRequest, props.readOnly]); return ( ({ - opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1, + opacity: !props.isRunning || props.readOnly ? 0.35 : pressed ? 0.65 : 1, paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8, @@ -172,6 +182,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter }); export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { + const buffer = useTerminalSurfaceBuffer(props); + return ; +}); + +const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerminalSurfaceProps) { const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize; const { themeAppearance, themeId } = useAppearancePreferences(); const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance); @@ -191,7 +206,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); const handleNativeInput = useCallback( (event: NativeSyntheticEvent) => { - if (!props.isRunning) { + if (!props.isRunning || props.readOnly) { return; } terminalDebugLog("native:onInput", { @@ -199,7 +214,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf }); onInput(event.nativeEvent.data); }, - [onInput, props.isRunning], + [onInput, props.isRunning, props.readOnly], ); const handleNativeResize = useCallback( (event: NativeSyntheticEvent) => { @@ -216,9 +231,10 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf pickRunningTerminalSessionForBootstrap(knownSessions), - [knownSessions], + () => + pickRunningTerminalSessionForBootstrap(knownSessions ?? []) ?? + (canOperateTerminal ? null : (knownSessions?.[0] ?? null)), + [canOperateTerminal, knownSessions], ); const activeKnownSession = useMemo( - () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, + () => knownSessions?.find((session) => session.target.terminalId === terminalId) ?? null, [knownSessions, terminalId], ); + const hasTerminalTarget = requestedTerminalId !== null || activeKnownSession !== null; const launchTarget = useMemo( () => selectedThread @@ -328,13 +351,28 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) terminalId, ], ); + const observingTerminal = + !canOperateTerminal && canReadTerminal && selectedThread !== null && hasTerminalTarget; const terminal = useAttachedTerminalSession({ environmentId: selectedThread?.environmentId ?? null, - terminal: terminalAttachInput, + terminal: canOperateTerminal + ? terminalAttachInput + : observingTerminal + ? { threadId: selectedThread.id, terminalId } + : null, }); const terminalKey = selectedThread ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` : terminalId; + useTerminalGridSync({ + environmentId: selectedThread?.environmentId ?? null, + threadId: selectedThread?.id ?? null, + terminalId, + canOperate: canOperateTerminal, + terminal, + size: lastGridSize, + resize: resizeTerminal, + }); const bufferReplayKey = useMemo( () => getTerminalBufferReplayKey({ terminalKey, fontSize }), [fontSize, terminalKey], @@ -349,64 +387,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); const isRunning = terminal.status === "running" || terminal.status === "starting"; - // When the process ends while this screen is attached (e.g. typing `exit`), - // close the session and leave the screen, mirroring the web drawer's - // onSessionExited flow. Only react to a running -> exited transition - // observed on this screen so already-exited sessions can still be opened - // (they restart on attach). - const runningTerminalKeyRef = useRef(null); - const reopenedStaleTerminalKeyRef = useRef(null); const pendingExitNavigationRef = useRef(null); - // Attach subscriptions are cached with an idle TTL, so revisiting a - // terminal whose session ended while unobserved reuses the stale stream - // without a new attach RPC — the server never respawns anything. Detect - // that (dead status with processed events, never seen running here) and - // issue an explicit open; its snapshot flows into the live subscription. - useEffect(() => { - if (isRunning) { - reopenedStaleTerminalKeyRef.current = null; - return; - } - if ( - terminalAttachInput === null || - !selectedThread || - (terminal.status !== "closed" && terminal.status !== "exited") || - terminal.version === 0 || - runningTerminalKeyRef.current === terminalKey || - reopenedStaleTerminalKeyRef.current === terminalKey - ) { - return; - } - reopenedStaleTerminalKeyRef.current = terminalKey; - void openTerminal({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - terminalId, - cwd: terminalAttachInput.cwd, - worktreePath: terminalAttachInput.worktreePath, - cols: terminalAttachInput.cols, - rows: terminalAttachInput.rows, - ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}), - }, - }).then((result) => { - // Release the guard on failure so a later render can retry the respawn. - if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { - reopenedStaleTerminalKeyRef.current = null; - } - }); - }, [ - isRunning, - openTerminal, - selectedThread, - terminal.status, - terminal.version, - terminalAttachInput, - terminalId, - terminalKey, - ]); - useEffect(() => { terminalDebugLog("surface:props", { terminalKey, @@ -507,7 +489,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) height: state.height, isVisible: state.isVisible, })); - const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; + const isAccessoryVisible = canOperateTerminal && keyboardState.isVisible && !isAccessoryDismissed; const terminalBottomInset = (keyboardState.isVisible ? keyboardState.height : 0) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); @@ -529,7 +511,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const terminalMenuSessions = useMemo>( () => buildTerminalMenuSessions({ - knownSessions, + knownSessions: knownSessions ?? [], workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, currentSession: { terminalId, @@ -612,8 +594,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) useEffect(() => { const initialInput = pendingLaunch?.initialInput; if ( + !canOperateTerminal || !initialInput || !selectedThread || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) || terminal.version === 0 || sentInitialInputKeyRef.current === launchTargetKey ) { @@ -635,6 +619,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) terminal.version, terminalId, writeTerminal, + canOperateTerminal, ]); useEffect(() => { @@ -700,7 +685,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */ const writeInput = useCallback( async (data: string): Promise => { - if (!selectedThread || !isRunning) { + if ( + !selectedThread || + !isRunning || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + ) { return false; } @@ -725,11 +714,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) // Drop delayed clipboard reads whenever the route or attached pty changes. useEffect(() => { - pasteSession.reset(isRunning); + pasteSession.reset(canOperateTerminal && isRunning); return () => { pasteSession.reset(false); }; - }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]); + }, [canOperateTerminal, isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]); const pasteFromClipboard = useCallback(async () => { await pasteSession.paste({ @@ -801,31 +790,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) } setLastGridSize(size); - if (!selectedThread || !isRunning) { - return; - } - - void resizeTerminal({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - terminalId, - cols: size.cols, - rows: size.rows, - }, - }); }, [ - isRunning, lastGridSize.cols, lastGridSize.rows, bufferReplayKey, readyBufferReplayKey, routeEnvironmentId, routeThreadId, - resizeTerminal, scheduleBufferReplayReady, - selectedThread, terminalId, terminalKey, ], @@ -881,59 +854,54 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) } }, [navigation, selectedThread, terminalId, terminalMenuSessions]); - useEffect(() => { - // Detached (hidden surface or environment drop): forget the running - // marker so a reattach takes the stale-reopen path instead of misreading - // the dead snapshot as an exit observed on this screen. A pending exit - // navigation stays armed — it only clears once the session runs again — - // so refocusing a dead screen still leaves it. - if (terminalAttachInput === null) { - runningTerminalKeyRef.current = null; - return; - } - if (isRunning) { - runningTerminalKeyRef.current = terminalKey; - // The session came back (e.g. respawned elsewhere) before the user - // returned; a stale pending exit must not eject a live terminal. - pendingExitNavigationRef.current = null; - return; - } - // The web drawer treats both exited and closed as session end. - const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; - if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { - return; - } - runningTerminalKeyRef.current = null; - // Mark this key handled so the stale-attach effect doesn't respawn the - // session the user just ended. - reopenedStaleTerminalKeyRef.current = terminalKey; - if (selectedThread) { - void closeTerminal({ + useTerminalLifecycle({ + terminalKey, + canOperate: canOperateTerminal, + observing: observingTerminal, + attached: terminalAttachInput !== null && selectedThread !== null, + terminal, + reopen: async () => { + if ( + terminalAttachInput === null || + selectedThread === null || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + ) + return false; + const result = await openTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, terminalId, + cwd: terminalAttachInput.cwd, + worktreePath: terminalAttachInput.worktreePath, + cols: terminalAttachInput.cols, + rows: terminalAttachInput.rows, + ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}), }, }); - } - if (navigation.isFocused()) { - navigateAwayAfterExit(); - return; - } - // An unfocused screen can't navigate; leave when the user returns so - // they never land on the dead session. - pendingExitNavigationRef.current = terminalKey; - }, [ - closeTerminal, - isRunning, - navigateAwayAfterExit, - navigation, - selectedThread, - terminal.status, - terminalAttachInput, - terminalId, - terminalKey, - ]); + return result._tag === "Success"; + }, + onRunning: () => { + pendingExitNavigationRef.current = null; + }, + onExit: () => { + if ( + selectedThread === null || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + ) + return; + void closeTerminal({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id, terminalId }, + }); + if (navigation.isFocused()) { + navigateAwayAfterExit(); + return; + } + // Leave a background terminal screen when it is focused again. + pendingExitNavigationRef.current = terminalKey; + }, + }); useEffect( () => @@ -948,7 +916,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ); const handleOpenNewTerminal = useCallback(() => { - if (!selectedThread) { + if ( + !selectedThread || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + ) { return; } @@ -959,10 +930,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) terminalId: nextOpenTerminalId({ listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), activeRouteTerminalId: terminalId, + ...(knownSessions === null || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) + ? { uniqueSuffix: uuidv4() } + : {}), }), }), ); - }, [navigation, selectedThread, terminalId, terminalMenuSessions]); + }, [knownSessions, navigation, selectedThread, terminalId, terminalMenuSessions]); const handleDecreaseFontSize = useCallback(() => { setTerminalFontSize(stepTerminalFontSize(fontSize, -1)); @@ -1004,11 +979,18 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { id: "terminal-new", title: "Open new terminal", + attributes: { disabled: !canOperateTerminal }, image: "plus", subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, }, ], - [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], + [ + canOperateTerminal, + fontSize, + selectedThreadProject?.workspaceRoot, + terminalId, + terminalMenuSessions, + ], ); const handleAndroidTerminalMenuAction = useCallback( @@ -1034,7 +1016,10 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ); const handleClearTerminal = useCallback(() => { - if (!selectedThread) { + if ( + !selectedThread || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + ) { return; } @@ -1232,6 +1217,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) Open new terminal @@ -1258,12 +1244,36 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) resourceName="terminal" onRetry={handleRetryEnvironment} /> + ) : terminalSession.data === null && terminalSession.error === null ? ( + + ) : !canReadTerminal && !canOperateTerminal ? ( + + ) : !canOperateTerminal && !hasTerminalTarget ? ( + + ) : !canOperateTerminal && terminal.error !== null ? ( + ) : ( <> - ) : !keyboardState.isVisible ? ( + ) : canOperateTerminal && !keyboardState.isVisible ? ( { }); describe("nextOpenTerminalId", () => { + it("allocates separate terminals on repeated visits without readable metadata", () => { + const first = nextOpenTerminalId({ + listedTerminalIds: [], + uniqueSuffix: "783c91cc-a413-47c7-8312-c2a5a1f05e40", + }); + const nextVisit = nextOpenTerminalId({ + listedTerminalIds: [], + uniqueSuffix: "102315fc-ceef-45c4-b978-c4d4947d3c26", + }); + expect(first).not.toBe(DEFAULT_TERMINAL_ID); + expect(nextVisit).not.toBe(first); + }); + it("matches nextTerminalId when not on a terminal route", () => { expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID); expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2"); @@ -208,6 +221,16 @@ describe("previousLiveTerminalId", () => { }); describe("resolveProjectScriptTerminalId", () => { + it("never targets an unseen default terminal when metadata is unavailable", () => { + const terminalId = resolveProjectScriptTerminalId({ + existingTerminalIds: [], + hasRunningTerminal: false, + uniqueSuffix: "783c91cc-a413-47c7-8312-c2a5a1f05e40", + }); + expect(terminalId).not.toBe(DEFAULT_TERMINAL_ID); + expect(getTerminalLabel(terminalId)).toBe("Terminal 1"); + }); + it("reuses the default shell when no terminal is running", () => { expect( resolveProjectScriptTerminalId({ diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 06cb74e9467d..47f3d08144ad 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -70,15 +70,16 @@ export function getTerminalStatusLabel(input: { export function nextOpenTerminalId(input: { readonly listedTerminalIds: ReadonlyArray; readonly activeRouteTerminalId?: string | null; + readonly uniqueSuffix?: string; }): string { const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0); const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null; if (!routeId || listed.includes(routeId)) { - return nextTerminalId(listed); + return nextTerminalId(listed, input.uniqueSuffix); } - return nextTerminalId([...listed, routeId]); + return nextTerminalId([...listed, routeId], input.uniqueSuffix); } export function buildTerminalMenuSessions(input: { @@ -146,12 +147,13 @@ export function previousLiveTerminalId(input: { export function resolveProjectScriptTerminalId(input: { readonly existingTerminalIds: ReadonlyArray; readonly hasRunningTerminal: boolean; + readonly uniqueSuffix?: string; }): string { - if (!input.hasRunningTerminal) { + if (!input.hasRunningTerminal && input.uniqueSuffix === undefined) { return DEFAULT_TERMINAL_ID; } - return nextTerminalId(input.existingTerminalIds); + return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix); } export function projectScriptMenuLabel(script: ProjectScript): string { diff --git a/apps/mobile/src/features/terminal/useTerminalGridSync.test.ts b/apps/mobile/src/features/terminal/useTerminalGridSync.test.ts new file mode 100644 index 000000000000..827516c6e693 --- /dev/null +++ b/apps/mobile/src/features/terminal/useTerminalGridSync.test.ts @@ -0,0 +1,183 @@ +import * as NodeModule from "node:module"; +import { EMPTY_TERMINAL_BUFFER_STATE } from "@t3tools/client-runtime/state/terminal"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { act, createElement, type ReactNode } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useTerminalGridSync } from "./useTerminalGridSync"; + +const access = vi.hoisted(() => ({ environments: new Set() })); +vi.mock("../../state/session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + scope === "terminal:operate" && access.environments.has(environmentId), +})); + +// Mobile already depends on ReactDOM but does not install its browser type declarations. +const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as { + createRoot(container: Element): { + render(children: ReactNode): void; + unmount(): void; + }; +}; + +type Input = Parameters[0]; +let root: ReturnType; +const resize = vi.fn(); + +function GridProbe(input: Input) { + useTerminalGridSync(input); + return null; +} + +function session({ + generation = 1, + version = 1, + status = "running", +}: { + readonly generation?: number; + readonly version?: number; + readonly status?: Input["terminal"]["status"]; +} = {}): Input["terminal"] { + return { output: { ...EMPTY_TERMINAL_BUFFER_STATE.output, generation }, version, status }; +} + +function input(): Input { + return { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + terminalId: "term-1", + canOperate: true, + terminal: session(), + size: { cols: 80, rows: 24 }, + resize, + }; +} + +function render(value: Input) { + return act(() => root.render(createElement(GridProbe, value))); +} + +beforeEach(() => { + resize.mockClear(); + access.environments.clear(); + access.environments.add("environment"); + // The probe renders no DOM, but ReactDOM needs an event target to run real effects. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); + +it("checks the current target grant before dispatching a delayed resize", async () => { + const current = input(); + await render(current); + expect(resize).toHaveBeenCalledOnce(); + + access.environments.clear(); + await render({ ...current, size: { cols: 120, rows: 40 } }); + expect(resize).toHaveBeenCalledOnce(); + + access.environments.add("secondary"); + await render({ ...current, size: { cols: 120, rows: 41 } }); + expect(resize).toHaveBeenCalledOnce(); + + await render({ ...current, environmentId: EnvironmentId.make("secondary") }); + expect(resize).toHaveBeenCalledTimes(2); + expect(resize).toHaveBeenLastCalledWith({ + environmentId: "secondary", + input: { threadId: "thread", terminalId: "term-1", cols: 80, rows: 24 }, + }); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +it("replays an observer's measured grid once its writable attachment snapshot arrives", async () => { + let current = { ...input(), canOperate: false }; + await render(current); + current = { ...current, size: { cols: 120, rows: 40 } }; + await render(current); + expect(resize).not.toHaveBeenCalled(); + + current = { + ...current, + canOperate: true, + terminal: session({ generation: 2, version: 0 }), + }; + await render(current); + expect(resize).not.toHaveBeenCalled(); + + current = { ...current, terminal: session({ generation: 2 }) }; + await render(current); + expect(resize).toHaveBeenCalledExactlyOnceWith({ + environmentId: "environment", + input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 40 }, + }); + + await render({ ...current, terminal: session({ generation: 2, version: 3 }) }); + await render({ ...current, size: { ...current.size } }); + expect(resize).toHaveBeenCalledOnce(); + + await render({ ...current, size: { cols: 120, rows: 41 } }); + expect(resize).toHaveBeenCalledTimes(2); + expect(resize).toHaveBeenLastCalledWith({ + environmentId: "environment", + input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 41 }, + }); +}); + +it("replays an unchanged grid after reconnecting with a new attachment generation", async () => { + const current = { ...input(), size: { cols: 120, rows: 40 } }; + await render(current); + expect(resize).toHaveBeenCalledOnce(); + + await render({ ...current, terminal: session({ generation: 2, version: 0 }) }); + expect(resize).toHaveBeenCalledOnce(); + + await render({ ...current, terminal: session({ generation: 2 }) }); + expect(resize).toHaveBeenCalledTimes(2); + expect(resize).toHaveBeenLastCalledWith({ + environmentId: "environment", + input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 40 }, + }); + + // React may observe the next snapshot without rendering the empty seed first. + await render({ ...current, terminal: session({ generation: 3 }) }); + expect(resize).toHaveBeenCalledTimes(3); +}); + +it.each([ + { name: "permission is revoked", change: { canOperate: false } }, + { name: "the attachment is pending", change: { terminal: session({ version: 0 }) } }, + { name: "the process has exited", change: { terminal: session({ status: "exited" }) } }, +])("retains measurements without resizing while $name", async ({ change }) => { + const current = input(); + await render(current); + expect(resize).toHaveBeenCalledOnce(); + + await render({ ...current, ...change, size: { cols: 120, rows: 40 } }); + await render({ ...current, ...change, size: { cols: 120, rows: 41 } }); + expect(resize).toHaveBeenCalledOnce(); + + await render({ ...current, size: { cols: 120, rows: 41 } }); + expect(resize).toHaveBeenCalledTimes(2); + expect(resize).toHaveBeenLastCalledWith({ + environmentId: "environment", + input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 41 }, + }); +}); diff --git a/apps/mobile/src/features/terminal/useTerminalGridSync.ts b/apps/mobile/src/features/terminal/useTerminalGridSync.ts new file mode 100644 index 000000000000..1ad8922fcbee --- /dev/null +++ b/apps/mobile/src/features/terminal/useTerminalGridSync.ts @@ -0,0 +1,52 @@ +import type { TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; +import { + AuthTerminalOperateScope, + type EnvironmentId, + type TerminalResizeInput, + type ThreadId, +} from "@t3tools/contracts"; +import { useEffect } from "react"; + +import type { TerminalGridSize } from "./terminalUiState"; +import { readEnvironmentScope } from "../../state/session"; + +/** Replay the measured grid when a writable attachment becomes ready or reconnects. */ +export function useTerminalGridSync({ + environmentId, + threadId, + terminalId, + canOperate, + terminal, + size, + resize, +}: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly terminalId: string; + readonly canOperate: boolean; + readonly terminal: Pick; + readonly size: TerminalGridSize; + readonly resize: (target: { + readonly environmentId: EnvironmentId; + readonly input: TerminalResizeInput; + }) => void; +}): void { + const generation = + canOperate && terminal.version > 0 && terminal.status === "running" + ? terminal.output.generation + : null; + + useEffect(() => { + if ( + generation === null || + environmentId === null || + threadId === null || + !readEnvironmentScope(environmentId, AuthTerminalOperateScope) + ) + return; + resize({ + environmentId, + input: { threadId, terminalId, cols: size.cols, rows: size.rows }, + }); + }, [environmentId, generation, resize, size.cols, size.rows, terminalId, threadId]); +} diff --git a/apps/mobile/src/features/terminal/useTerminalLifecycle.test.ts b/apps/mobile/src/features/terminal/useTerminalLifecycle.test.ts new file mode 100644 index 000000000000..c3278a0d205b --- /dev/null +++ b/apps/mobile/src/features/terminal/useTerminalLifecycle.test.ts @@ -0,0 +1,177 @@ +import * as NodeModule from "node:module"; +import { act, createElement, type ReactNode } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useTerminalLifecycle } from "./useTerminalLifecycle"; + +const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as { + createRoot(container: Element): { + render(children: ReactNode): void; + unmount(): void; + }; +}; + +type Input = Parameters[0]; +let root: ReturnType; +const reopen = vi.fn(); +const onRunning = vi.fn(); +const onExit = vi.fn(); + +function LifecycleProbe(input: Input) { + useTerminalLifecycle(input); + return null; +} + +function input(changes: Partial = {}): Input { + return { + terminalKey: "environment:thread:term-1", + canOperate: true, + observing: false, + attached: true, + terminal: { status: "running", version: 1 }, + reopen, + onRunning, + onExit, + ...changes, + }; +} + +function render(value: Input) { + return act(() => root.render(createElement(LifecycleProbe, value))); +} + +beforeEach(() => { + reopen.mockReset().mockResolvedValue(true); + onRunning.mockClear(); + onExit.mockClear(); + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +it.each(["running", "closed", "exited"] as const)( + "waits for the replacement attachment snapshot before handling %s", + async (status) => { + await render(input()); + await render(input({ terminal: { status: "closed", version: 0 } })); + expect(onExit).not.toHaveBeenCalled(); + expect(reopen).not.toHaveBeenCalled(); + + await render(input({ terminal: { status, version: 1 } })); + expect(onExit).toHaveBeenCalledTimes(status === "running" ? 0 : 1); + expect(reopen).not.toHaveBeenCalled(); + }, +); + +it("retains an exited observer's history after gaining operate", async () => { + await render(input({ canOperate: false, terminal: { status: "exited", version: 1 } })); + await render(input({ terminal: { status: "closed", version: 0 } })); + await render(input({ terminal: { status: "exited", version: 1 } })); + expect(reopen).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); +}); + +it.each(["closed", "exited"] as const)( + "keeps a pending observer passive when its first writable snapshot is %s", + async (status) => { + await render( + input({ + canOperate: false, + observing: true, + terminal: { status, version: 0 }, + }), + ); + await render(input({ terminal: { status: "closed", version: 0 } })); + await render(input({ terminal: { status, version: 1 } })); + expect(reopen).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(onRunning).not.toHaveBeenCalled(); + }, +); + +it("reopens an explicit operator visit after permissions finish loading", async () => { + await render( + input({ + canOperate: false, + observing: false, + terminal: { status: "closed", version: 0 }, + }), + ); + await render(input({ terminal: { status: "closed", version: 0 } })); + await render(input({ terminal: { status: "exited", version: 1 } })); + expect(reopen).toHaveBeenCalledOnce(); + expect(onExit).not.toHaveBeenCalled(); +}); + +it("does not respawn a terminal that exited after operate was revoked", async () => { + await render(input()); + await render(input({ canOperate: false })); + await render(input({ canOperate: false, terminal: { status: "exited", version: 2 } })); + await render(input({ terminal: { status: "exited", version: 1 } })); + expect(reopen).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); +}); + +it("remembers revocation before the observer has received a snapshot", async () => { + await render(input()); + await render(input({ canOperate: false, terminal: { status: "closed", version: 0 } })); + await render(input({ terminal: { status: "exited", version: 1 } })); + expect(reopen).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); +}); + +it("cleans up an actual exit after an observed running terminal becomes writable", async () => { + await render(input({ canOperate: false })); + await render(input()); + await render(input({ terminal: { status: "exited", version: 2 } })); + await render(input({ terminal: { status: "closed", version: 3 } })); + expect(onExit).toHaveBeenCalledOnce(); + expect(reopen).not.toHaveBeenCalled(); +}); + +it("reopens an ended terminal on an explicit new route visit", async () => { + await render(input({ canOperate: false, terminal: { status: "exited", version: 1 } })); + await render(input({ terminalKey: "environment:thread:term-2" })); + await render(input({ terminal: { status: "exited", version: 1 } })); + expect(reopen).toHaveBeenCalledOnce(); + + await render(input({ terminal: { status: "exited", version: 2 } })); + expect(reopen).toHaveBeenCalledOnce(); + await render(input({ terminal: { status: "running", version: 3 } })); + await render(input({ terminal: { status: "exited", version: 4 } })); + expect(onExit).toHaveBeenCalledOnce(); +}); + +it("keeps a detached terminal from being mistaken for a live exit", async () => { + await render(input()); + await render(input({ attached: false, terminal: { status: "closed", version: 0 } })); + await render(input({ terminal: { status: "exited", version: 1 } })); + expect(onExit).not.toHaveBeenCalled(); + expect(reopen).toHaveBeenCalledOnce(); +}); + +it("allows a later snapshot to retry an unsuccessful explicit reopen", async () => { + reopen.mockResolvedValueOnce(false); + await render(input({ terminal: { status: "exited", version: 1 } })); + await render(input({ terminal: { status: "exited", version: 2 } })); + expect(reopen).toHaveBeenCalledTimes(2); +}); diff --git a/apps/mobile/src/features/terminal/useTerminalLifecycle.ts b/apps/mobile/src/features/terminal/useTerminalLifecycle.ts new file mode 100644 index 000000000000..cb71cf14cf59 --- /dev/null +++ b/apps/mobile/src/features/terminal/useTerminalLifecycle.ts @@ -0,0 +1,79 @@ +import type { TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; +import { useEffect, useEffectEvent, useRef } from "react"; + +/** Keep observed history passive while preserving explicit terminal visits and live exits. */ +export function useTerminalLifecycle({ + terminalKey, + canOperate, + observing, + attached, + terminal, + reopen, + onRunning, + onExit, +}: { + readonly terminalKey: string; + readonly canOperate: boolean; + readonly observing: boolean; + readonly attached: boolean; + readonly terminal: Pick; + readonly reopen: () => Promise; + readonly onRunning: () => void; + readonly onExit: () => void; +}) { + const lifecycle = useRef({ + key: terminalKey, + wasRunning: false, + observed: false, + reopened: false, + }); + const reopenTerminal = useEffectEvent(reopen); + const handleRunning = useEffectEvent(onRunning); + const handleExit = useEffectEvent(onExit); + + useEffect(() => { + if (lifecycle.current.key !== terminalKey) { + lifecycle.current = { + key: terminalKey, + wasRunning: false, + observed: false, + reopened: false, + }; + } + const current = lifecycle.current; + if (!canOperate) { + // An observe request stays passive before its first snapshot arrives. + if (observing || terminal.version > 0 || current.wasRunning) current.observed = true; + current.wasRunning = false; + return; + } + if (!attached) { + current.wasRunning = false; + return; + } + // An attachment without its first snapshot has no known process status yet. + if (terminal.version === 0) return; + + if (terminal.status === "running" || terminal.status === "starting") { + current.wasRunning = true; + current.reopened = false; + handleRunning(); + return; + } + if (terminal.status !== "closed" && terminal.status !== "exited") return; + + if (current.wasRunning) { + current.wasRunning = false; + current.reopened = true; + handleExit(); + return; + } + // An explicit visit can reopen a cached ended session. Granting a viewer + // operate permission must leave the history they were reading intact. + if (current.observed || current.reopened) return; + current.reopened = true; + void reopenTerminal().then((succeeded) => { + if (!succeeded && lifecycle.current === current) current.reopened = false; + }); + }, [attached, canOperate, observing, terminal.status, terminal.version, terminalKey]); +} diff --git a/apps/mobile/src/features/terminal/useTerminalSurfaceBuffer.test.ts b/apps/mobile/src/features/terminal/useTerminalSurfaceBuffer.test.ts new file mode 100644 index 000000000000..7721d647a4fb --- /dev/null +++ b/apps/mobile/src/features/terminal/useTerminalSurfaceBuffer.test.ts @@ -0,0 +1,100 @@ +import * as NodeModule from "node:module"; +import { act, createElement, useEffect, type ReactNode } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useTerminalSurfaceBuffer } from "./useTerminalSurfaceBuffer"; + +const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as { + createRoot(container: Element): { + render(children: ReactNode): void; + unmount(): void; + }; +}; + +type Input = Parameters[0] & { readonly readOnly: boolean }; +let root: ReturnType; +const displayed = vi.fn<(value: { buffer: string; readOnly: boolean }) => void>(); + +function BufferProbe(input: Input) { + const buffer = useTerminalSurfaceBuffer(input); + useEffect(() => { + displayed({ buffer, readOnly: input.readOnly }); + }, [buffer, input.readOnly]); + return null; +} + +function render(changes: Partial = {}) { + return act(() => + root.render( + createElement(BufferProbe, { + terminalKey: "environment:thread:term-1", + buffer: "host output", + readOnly: false, + ...changes, + }), + ), + ); +} + +beforeEach(() => { + displayed.mockClear(); + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +it("preserves displayed history while permissions change before the next snapshot", async () => { + await render(); + await render({ buffer: null, readOnly: true }); + expect(displayed).toHaveBeenLastCalledWith({ buffer: "host output", readOnly: true }); + + await render({ buffer: "host output\nobserved output", readOnly: true }); + await render({ buffer: null }); + expect(displayed).toHaveBeenLastCalledWith({ + buffer: "host output\nobserved output", + readOnly: false, + }); + expect(displayed.mock.calls.every(([value]) => value.buffer.length > 0)).toBe(true); +}); + +it("still applies an intentional empty buffer", async () => { + await render(); + await render({ buffer: null }); + expect(displayed).toHaveBeenCalledTimes(1); + await render({ buffer: "" }); + expect(displayed).toHaveBeenLastCalledWith({ buffer: "", readOnly: false }); +}); + +it.each([ + "secondary:thread:term-1", + "environment:another-thread:term-1", + "environment:thread:term-2", +])("does not carry history into another terminal target (%s)", async (terminalKey) => { + await render(); + await render({ terminalKey, buffer: null }); + expect(displayed).toHaveBeenLastCalledWith({ buffer: "", readOnly: false }); + await render({ terminalKey, buffer: "new target" }); + expect(displayed).toHaveBeenLastCalledWith({ buffer: "new target", readOnly: false }); +}); + +it("starts empty until its first snapshot arrives", async () => { + await render({ buffer: null }); + expect(displayed).toHaveBeenLastCalledWith({ buffer: "", readOnly: false }); + await render(); + expect(displayed).toHaveBeenLastCalledWith({ buffer: "host output", readOnly: false }); +}); diff --git a/apps/mobile/src/features/terminal/useTerminalSurfaceBuffer.ts b/apps/mobile/src/features/terminal/useTerminalSurfaceBuffer.ts new file mode 100644 index 000000000000..a4fcfd1bf017 --- /dev/null +++ b/apps/mobile/src/features/terminal/useTerminalSurfaceBuffer.ts @@ -0,0 +1,17 @@ +import { useState } from "react"; + +/** A null buffer means the attachment is waiting for its first snapshot. */ +export function useTerminalSurfaceBuffer({ + terminalKey, + buffer, +}: { + readonly terminalKey: string; + readonly buffer: string | null; +}): string { + const [snapshot, setSnapshot] = useState({ terminalKey, buffer: buffer ?? "" }); + const currentBuffer = buffer ?? (snapshot.terminalKey === terminalKey ? snapshot.buffer : ""); + if (snapshot.terminalKey !== terminalKey || snapshot.buffer !== currentBuffer) { + setSnapshot({ terminalKey, buffer: currentBuffer }); + } + return currentBuffer; +} diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 1843d498a44d..c1d34a54d648 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -98,6 +98,7 @@ type ThreadGitControlsProps = ThreadGitMenuProps & { readonly onPress: () => void; }; readonly canOpenTerminal: boolean; + readonly canOperateTerminal: boolean; readonly canOpenFiles: boolean; readonly projectScripts: ReadonlyArray; readonly terminalSessions: ReadonlyArray; @@ -283,6 +284,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit items: [ ...props.projectScripts.map((script) => ({ description: script.command, + disabled: !props.canOperateTerminal, icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const }, label: projectScriptMenuLabel(script), onPress: () => void props.onRunProjectScript(script), @@ -317,6 +319,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit })), { description: "Start another shell for this thread", + disabled: !props.canOperateTerminal, icon: { name: "plus", type: "sfSymbol" }, label: "Open new terminal", onPress: props.onOpenNewTerminal, @@ -402,6 +405,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit model.runQuickAction, props.canOpenFiles, props.canOpenTerminal, + props.canOperateTerminal, props.gitStatus, props.onOpenNewTerminal, props.onOpenTerminal, @@ -457,6 +461,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { void props.onRunProjectScript(script)} subtitle={script.command} > @@ -495,6 +500,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { ))} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index f071f6db641b..f40e1a9a837d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -9,11 +9,12 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import * as Option from "effect/Option"; import { AuthOrchestrationOperateScope, + AuthTerminalOperateScope, + AuthTerminalReadScope, EnvironmentId, ThreadId, type ProjectScript, } from "@t3tools/contracts"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { requestOlderThreadTurns, threadHasOlderTurns, @@ -42,6 +43,8 @@ import { useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; +import { uuidv4 } from "../../lib/uuid"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; @@ -203,6 +206,14 @@ function ThreadRouteContent( selectedThread?.environmentId ?? null, AuthOrchestrationOperateScope, ); + const canReadTerminal = useEnvironmentScope( + selectedThread?.environmentId ?? null, + AuthTerminalReadScope, + ); + const canOperateTerminal = useEnvironmentScope( + selectedThread?.environmentId ?? null, + AuthTerminalOperateScope, + ); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); // "Load earlier turns" header state for windowed (paginated) thread loads. @@ -331,7 +342,7 @@ function ThreadRouteContent( const terminalMenuSessions = useMemo( () => buildTerminalMenuSessions({ - knownSessions: knownTerminalSessions, + knownSessions: knownTerminalSessions ?? [], workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, }), [knownTerminalSessions, selectedThreadProject?.workspaceRoot], @@ -517,7 +528,12 @@ function ThreadRouteContent( hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), }); - if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + if ( + !selectedThread || + !selectedThreadProject?.workspaceRoot || + (!readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) && + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)) + ) { return; } @@ -537,19 +553,33 @@ function ThreadRouteContent( listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), }); - if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + if ( + !selectedThread || + !selectedThreadProject?.workspaceRoot || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + ) { return; } const nextId = nextOpenTerminalId({ listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), + ...(knownTerminalSessions === null || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) + ? { uniqueSuffix: uuidv4() } + : {}), }); void navigation.navigate("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), terminalId: nextId, }); - }, [navigation, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); + }, [ + knownTerminalSessions, + navigation, + selectedThread, + selectedThreadProject?.workspaceRoot, + terminalMenuSessions, + ]); const handleRunProjectScript = useCallback( async (script: ProjectScript) => { @@ -560,16 +590,24 @@ function ThreadRouteContent( hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), }); - if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + if ( + !selectedThread || + !selectedThreadProject?.workspaceRoot || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) + ) { terminalDebugLog("project-script:abort", { scriptId: script.id, - reason: "no-thread-or-workspace", + reason: "no-thread-workspace-or-terminal-access", }); return; } const targetTerminalId = resolveProjectScriptTerminalId({ existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId), + ...(knownTerminalSessions === null || + !readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope) + ? { uniqueSuffix: uuidv4() } + : {}), hasRunningTerminal: terminalMenuSessions.some( (session) => session.status === "running" || session.status === "starting", ), @@ -618,6 +656,7 @@ function ThreadRouteContent( selectedThreadDetailWorktreePath, selectedThreadProject, terminalMenuSessions, + knownTerminalSessions, ], ); const threadGitControlProps = { @@ -636,7 +675,9 @@ function ThreadRouteContent( currentBranch: selectedThread?.branch ?? null, gitStatus: gitStatus.data, gitOperationLabel: gitState.gitOperationLabel, - canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), + canOpenTerminal: + Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal), + canOperateTerminal, canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), projectScripts: selectedThreadProject?.scripts ?? [], terminalSessions: terminalMenuSessions, @@ -708,7 +749,7 @@ function ThreadRouteContent( onPress: handleOpenFilesInspector, }); } - if (selectedThreadProject?.workspaceRoot) { + if (selectedThreadProject?.workspaceRoot && (canReadTerminal || canOperateTerminal)) { actions.push({ accessibilityLabel: "Open terminal", icon: "terminal", @@ -729,6 +770,8 @@ function ThreadRouteContent( } return actions; }, [ + canReadTerminal, + canOperateTerminal, fileInspector.supported, handleOpenFilesInspector, handleOpenTerminal, diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 6be57007a60f..1695d03b6516 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -6,11 +6,18 @@ import { type KnownTerminalSession, type TerminalSessionState, } from "@t3tools/client-runtime/state/terminal"; -import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; +import { + AuthTerminalReadScope, + AuthTerminalOperateScope, + ThreadId, + type EnvironmentId, + type TerminalAttachInput, +} from "@t3tools/contracts"; import { useMemo } from "react"; import { useEnvironmentQuery } from "./query"; import { terminalEnvironment } from "./terminal"; +import { useEnvironmentScope } from "./session"; type LegacyTerminalSessionState = TerminalSessionState & { readonly buffer: string }; const EMPTY_LEGACY_TERMINAL_SESSION_STATE: LegacyTerminalSessionState = { @@ -22,16 +29,25 @@ export function useAttachedTerminalSession(input: { readonly environmentId: EnvironmentId | null; readonly terminal: TerminalAttachInput | null; }): LegacyTerminalSessionState { + const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope); + const canOperate = useEnvironmentScope(input.environmentId, AuthTerminalOperateScope); const attach = useEnvironmentQuery( input.environmentId !== null && input.terminal !== null - ? terminalEnvironment.attach({ - environmentId: input.environmentId, - input: input.terminal, - }) + ? canOperate + ? terminalEnvironment.attach({ + environmentId: input.environmentId, + input: input.terminal, + }) + : canRead + ? terminalEnvironment.observe({ + environmentId: input.environmentId, + input: { threadId: input.terminal.threadId, terminalId: input.terminal.terminalId }, + }) + : null : null, ); const metadata = useEnvironmentQuery( - input.environmentId === null + input.environmentId === null || !canRead ? null : terminalEnvironment.metadata({ environmentId: input.environmentId, @@ -64,9 +80,10 @@ export function useAttachedTerminalSession(input: { export function useKnownTerminalSessions(input: { readonly environmentId: EnvironmentId | null; readonly threadId: ThreadId | null; -}): ReadonlyArray { +}): ReadonlyArray | null { + const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope); const metadata = useEnvironmentQuery( - input.environmentId === null + input.environmentId === null || !canRead ? null : terminalEnvironment.metadata({ environmentId: input.environmentId, @@ -74,10 +91,10 @@ export function useKnownTerminalSessions(input: { }), ); return useMemo(() => { - if (input.environmentId === null) { - return []; + if (input.environmentId === null || metadata.data === null || metadata.error !== null) { + return null; } - return (metadata.data ?? []) + return metadata.data .filter((summary) => input.threadId === null || summary.threadId === input.threadId) .map((summary) => ({ target: { @@ -92,5 +109,5 @@ export function useKnownTerminalSessions(input: { numeric: true, }), ); - }, [input.environmentId, input.threadId, metadata.data]); + }, [input.environmentId, input.threadId, metadata.data, metadata.error]); } diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7c6797d7f790..04da93a9eb1f 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -5,6 +5,8 @@ import { AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, + AuthTerminalReadScope, + AuthTerminalOperateScope, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -88,6 +90,27 @@ describe("RPC authorization scopes", () => { } }); + it("separates passive terminal observation from operations that can change a shell", () => { + for (const method of [ + WS_METHODS.terminalObserve, + WS_METHODS.subscribeTerminalEvents, + WS_METHODS.subscribeTerminalMetadata, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalReadScope); + } + for (const method of [ + WS_METHODS.terminalAttach, + WS_METHODS.terminalOpen, + WS_METHODS.terminalWrite, + WS_METHODS.terminalResize, + WS_METHODS.terminalClear, + WS_METHODS.terminalRestart, + WS_METHODS.terminalClose, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 692f487fd879..35ee72ca2036 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -13,6 +13,7 @@ import { AuthRelayWriteScope, AuthSourceControlWriteScope, AuthTerminalOperateScope, + AuthTerminalReadScope, ORCHESTRATION_WS_METHODS, type AuthEnvironmentScope, WS_METHODS, @@ -129,13 +130,14 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, + [WS_METHODS.terminalObserve]: AuthTerminalReadScope, [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, [WS_METHODS.terminalResize]: AuthTerminalOperateScope, [WS_METHODS.terminalClear]: AuthTerminalOperateScope, [WS_METHODS.terminalRestart]: AuthTerminalOperateScope, [WS_METHODS.terminalClose]: AuthTerminalOperateScope, - [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope, - [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope, + [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope, + [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope, [WS_METHODS.previewOpen]: AuthPreviewOperateScope, [WS_METHODS.previewNavigate]: AuthPreviewOperateScope, [WS_METHODS.previewResize]: AuthPreviewOperateScope, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 6cc399dd4de2..ac634cf76d50 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -57,6 +57,7 @@ const makeTerminalManagerLayer = ( Layer.succeed(TerminalManager.TerminalManager, { ...overrides, attachStream: () => Effect.die(new Error("unused")), + observeStream: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ee8e8196d12a..ede1b86a6ad4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -30,6 +30,7 @@ import { type OrchestrationThreadActivity, type OrchestrationThreadShell, TerminalNotRunningError, + TerminalSessionLookupError, type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, @@ -6266,6 +6267,109 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("terminal observers can read output but cannot start or change a terminal", () => + Effect.gen(function* () { + let observations = 0; + yield* buildAppUnderTest({ + layers: { + terminalManager: { + observeStream: (input, listener) => + Effect.gen(function* () { + observations += 1; + yield* listener({ + type: "snapshot", + snapshot: { + threadId: input.threadId, + terminalId: input.terminalId, + cwd: "/workspace", + worktreePath: null, + status: "exited", + pid: null, + history: "retained output", + exitCode: 0, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-09-04T00:00:00.000Z", + }, + }); + return () => {}; + }), + subscribeMetadata: (listener) => + listener({ type: "snapshot", terminals: [] }).pipe(Effect.as(() => {})), + subscribe: (listener) => + listener({ + type: "output", + threadId: "thread-1", + terminalId: "term-1", + data: "live output", + }).pipe(Effect.as(() => {})), + }, + }, + }); + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: "terminal:read", + }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const input = { threadId: "thread-1", terminalId: "term-1" }; + const event = yield* client[WS_METHODS.terminalObserve](input).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(event.type, "snapshot"); + if (event.type === "snapshot") assert.equal(event.snapshot.history, "retained output"); + const metadata = yield* client[WS_METHODS.subscribeTerminalMetadata]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(metadata.type, "snapshot"); + const output = yield* client[WS_METHODS.subscribeTerminalEvents]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(output.type, "output"); + const errors = [ + yield* client[WS_METHODS.terminalAttach]({ ...input, cwd: "/workspace" }).pipe( + Stream.runHead, + Effect.flip, + ), + yield* client[WS_METHODS.terminalOpen]({ ...input, cwd: "/workspace" }).pipe( + Effect.flip, + ), + yield* client[WS_METHODS.terminalWrite]({ ...input, data: "whoami\r" }).pipe( + Effect.flip, + ), + yield* client[WS_METHODS.terminalResize]({ ...input, cols: 80, rows: 24 }).pipe( + Effect.flip, + ), + yield* client[WS_METHODS.terminalClear](input).pipe(Effect.flip), + yield* client[WS_METHODS.terminalRestart]({ + ...input, + cwd: "/workspace", + cols: 80, + rows: 24, + }).pipe(Effect.flip), + yield* client[WS_METHODS.terminalClose](input).pipe(Effect.flip), + ]; + for (const error of errors) { + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") + assert.equal(error.requiredScope, "terminal:operate"); + } + }), + ), + ); + assert.equal(observations, 1); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("provider setup lets read-only clients observe installation but not change setup", () => Effect.gen(function* () { let installStarts = 0; @@ -11854,6 +11958,32 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const method of [WS_METHODS.terminalObserve, WS_METHODS.terminalAttach]) { + it.effect(`routes websocket rpc ${method} lookup errors`, () => + Effect.gen(function* () { + const input = { threadId: "thread-1", terminalId: "missing-terminal" }; + const terminalError = new TerminalSessionLookupError(input); + yield* buildAppUnderTest({ + layers: { + terminalManager: { + observeStream: () => Effect.fail(terminalError), + attachStream: () => Effect.fail(terminalError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[method](input).pipe(Stream.runHead)).pipe( + Effect.result, + ), + ); + + assertFailure(result, terminalError); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + } + it.effect("routes websocket rpc terminal.write errors", () => Effect.gen(function* () { const terminalError = new TerminalNotRunningError({ diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index e480e11588b0..69662825b4fc 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2414,6 +2414,104 @@ it.layer( }), ); + it.effect("observes terminal history and live output without changing the process", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const opened = yield* manager.open(openInput({ env: { OBSERVER_TEST: "original" } })); + const process = ptyAdapter.processes[0]!; + const historyReceived = yield* Deferred.make(); + const unsubscribeHistory = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(historyReceived, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + process.emitData("existing history\n"); + yield* Deferred.await(historyReceived); + unsubscribeHistory(); + + const observed = yield* Ref.make>([]); + const liveReceived = yield* Deferred.make(); + const unsubscribe = yield* manager.observeStream( + { threadId: opened.threadId, terminalId: opened.terminalId }, + (event) => + Ref.update(observed, (events) => [...events, event]).pipe( + Effect.andThen( + event.type === "output" + ? Deferred.succeed(liveReceived, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + process.emitData("live output\n"); + yield* Deferred.await(liveReceived); + + expect(yield* Ref.get(observed)).toMatchObject([ + { + type: "snapshot", + snapshot: { + cwd: opened.cwd, + worktreePath: opened.worktreePath, + pid: opened.pid, + history: "existing history\n", + }, + }, + { type: "output", data: "live output\n" }, + ]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.env.OBSERVER_TEST).toBe("original"); + expect(process.resizeCalls).toEqual([]); + expect(process.writes).toEqual([]); + expect(process.killSignals).toEqual([]); + }), + ); + + it.effect("observes exited terminals without restarting and rejects missing sessions", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const missingEvents: TerminalAttachStreamEvent[] = []; + const missing = yield* manager + .observeStream({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }, (event) => + Effect.sync(() => { + missingEvents.push(event); + }), + ) + .pipe(Effect.flip); + expect(missing._tag).toBe("TerminalSessionLookupError"); + expect(ptyAdapter.spawnInputs).toEqual([]); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + const exited = yield* Deferred.make(); + const unsubscribeExit = yield* manager.subscribe((event) => + event.type === "exited" + ? Deferred.succeed(exited, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + process.emitExit({ exitCode: 7, signal: 0 }); + yield* Deferred.await(exited); + unsubscribeExit(); + + const events: TerminalAttachStreamEvent[] = []; + const unsubscribe = yield* manager.observeStream( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }, + (event) => + Effect.sync(() => { + events.push(event); + }), + ); + unsubscribe(); + expect(events).toMatchObject([ + { type: "snapshot", snapshot: { status: "exited", exitCode: 7, pid: null } }, + ]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(process.resizeCalls).toEqual([]); + expect(process.writes).toEqual([]); + expect(process.killSignals).toEqual([]); + expect(missingEvents).toEqual([]); + }), + ); + it.effect("buffers attach output delivered during the initial snapshot callback", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index d9bdc6bcd92a..7f88d7c9ee6e 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -27,6 +27,7 @@ import { type TerminalEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, + type TerminalObserveInput, type TerminalResizeInput, type TerminalRestartInput, type TerminalSessionSnapshot, @@ -163,6 +164,12 @@ export class TerminalManager extends Context.Service< listener: (event: TerminalAttachStreamEvent) => Effect.Effect, ) => Effect.Effect<() => void, TerminalError>; + /** Observe an existing session without starting or changing its process. */ + readonly observeStream: ( + input: TerminalObserveInput, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, TerminalError>; + /** * Write input bytes to a terminal session. */ @@ -2650,7 +2657,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }; }); - const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => { + const streamSession = ( + input: TerminalObserveInput, + initial: Effect.Effect, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + ) => { let unsubscribe: (() => void) | null = null; return Effect.gen(function* () { @@ -2671,7 +2682,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return attachEvent ? listener(attachEvent) : Effect.void; }); - const initialSnapshot = yield* openOrAttachForStream(input); + const initialSnapshot = yield* initial; yield* listener({ type: "snapshot", @@ -2707,6 +2718,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); }; + const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => + streamSession(input, openOrAttachForStream(input), listener); + + const observeStream: TerminalManager["Service"]["observeStream"] = (input, listener) => + streamSession( + input, + withThreadLock( + input.threadId, + requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)), + ), + listener, + ); + const metadataEventFromTerminalEvent = ( event: TerminalEvent, ): Effect.Effect => { @@ -2961,6 +2985,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, attachStream, + observeStream, write, resize, clear, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 82af703e2722..d178d9d71c78 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2586,7 +2586,7 @@ const makeWsRpcLayer = ( Effect.acquireRelease( terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), (unsubscribe) => Effect.sync(unsubscribe), - ), + ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))), ), { "rpc.aggregate": "terminal" }, ), @@ -2594,6 +2594,17 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { "rpc.aggregate": "terminal", }), + [WS_METHODS.terminalObserve]: (input) => + observeRpcStream( + WS_METHODS.terminalObserve, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.observeStream(input, (event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.catchCause((cause) => Queue.failCause(queue, cause))), + ), + { "rpc.aggregate": "terminal" }, + ), [WS_METHODS.terminalResize]: (input) => observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 74f88c06e307..26e9a059c7f5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,8 @@ import { AuthSettingsWriteScope, AuthSourceControlWriteScope, AuthPreviewOperateScope, + AuthTerminalReadScope, + AuthTerminalOperateScope, type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, @@ -201,7 +203,7 @@ import { PaperclipIcon, WifiOffIcon, } from "lucide-react"; -import { cn, randomHex } from "~/lib/utils"; +import { cn, randomHex, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule, @@ -821,6 +823,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { + const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const hasTerminalWriteAccess = useCallback( + () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), + [threadRef.environmentId], + ); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -854,7 +861,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const drawerTerminalSessions = useMemo( () => - knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), + knownTerminalSessions?.filter( + (session) => !panelTerminalIds.has(session.target.terminalId), + ) ?? [], [knownTerminalSessions, panelTerminalIds], ); const terminalLabelsById = useMemo(() => { @@ -916,6 +925,17 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ], [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], ); + const allocateTerminalId = useCallback( + () => + nextTerminalId( + allocatableTerminalIds, + knownTerminalSessions === null || + !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) + ? randomUUID() + : undefined, + ), + [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], + ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore( @@ -982,10 +1002,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const splitTerminal = useCallback(() => { - if (!cwd) { + if (!hasTerminalWriteAccess() || !cwd) { return; } - const terminalId = nextTerminalId(allocatableTerminalIds); + const terminalId = allocateTerminalId(); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -999,7 +1019,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ - allocatableTerminalIds, + allocateTerminalId, bumpFocusRequestId, cwd, effectiveWorktreePath, @@ -1008,12 +1028,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId, threadRef, openTerminal, + hasTerminalWriteAccess, ]); const splitTerminalVertical = useCallback(() => { - if (!cwd) { + if (!hasTerminalWriteAccess() || !cwd) { return; } - const terminalId = nextTerminalId(allocatableTerminalIds); + const terminalId = allocateTerminalId(); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -1027,11 +1048,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ - allocatableTerminalIds, + allocateTerminalId, bumpFocusRequestId, cwd, effectiveWorktreePath, openTerminal, + hasTerminalWriteAccess, runtimeEnv, storeSplitTerminalVertical, threadId, @@ -1039,10 +1061,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ]); const createNewTerminal = useCallback(() => { - if (!cwd) { + if (!hasTerminalWriteAccess() || !cwd) { return; } - const terminalId = nextTerminalId(allocatableTerminalIds); + const terminalId = allocateTerminalId(); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -1059,12 +1081,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, - allocatableTerminalIds, + allocateTerminalId, runtimeEnv, storeNewTerminal, threadId, threadRef, openTerminal, + hasTerminalWriteAccess, ]); const activateTerminal = useCallback( @@ -1077,6 +1100,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const closeTerminal = useCallback( (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; const fallbackExitWrite = () => writeTerminal({ environmentId: threadRef.environmentId, @@ -1092,7 +1116,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra deleteHistory: true, }, }); - if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { + if ( + closeResult._tag === "Failure" && + !isAtomCommandInterrupted(closeResult) && + hasTerminalWriteAccess() + ) { await fallbackExitWrite(); } })(); @@ -1106,6 +1134,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId, threadRef, closeTerminalMutation, + hasTerminalWriteAccess, writeTerminal, ], ); @@ -1144,7 +1173,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra visible={visible} height={terminalUiState.terminalHeight} // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. - terminalIds={terminalUiState.terminalIds} + terminalIds={canOperateTerminal ? terminalUiState.terminalIds : serverOrderedTerminalIds} activeTerminalId={terminalUiState.activeTerminalId} terminalGroups={terminalUiState.terminalGroups} activeTerminalGroupId={terminalUiState.activeTerminalGroupId} @@ -1220,7 +1249,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane }); const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const activeSummary = - knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) + knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; const worktreePath = launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; @@ -1250,7 +1279,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane const labels = new Map(); for (const terminalId of surface.terminalIds) { const summary = - knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state .summary ?? null; labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); } @@ -1267,7 +1296,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane >(); for (const terminalId of surface.terminalIds) { const summary = - knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state .summary ?? null; const terminalWorktreePath = launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; @@ -1379,6 +1408,12 @@ export default function ChatView(props: ChatViewProps) { forceExpandedMobileComposer = false, } = props; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); + const hasTerminalWriteAccess = useCallback( + () => readEnvironmentScope(environmentId, AuthTerminalOperateScope), + [environmentId], + ); + const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope); const draftId = routeKind === "draft" ? props.draftId : null; const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; @@ -1806,7 +1841,7 @@ export default function ChatView(props: ChatViewProps) { threadId: activeThreadId, }); const activeThreadKnownSessions = useMemo(() => { - if (activeThreadId === null) { + if (activeThreadId === null || activeThreadKnownSessionsRaw === null) { return []; } return activeThreadKnownSessionsRaw.filter( @@ -1888,6 +1923,17 @@ export default function ChatView(props: ChatViewProps) { () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], [activeKnownTerminalIds, panelTerminalIds], ); + const canReuseTerminal = activeThreadKnownSessionsRaw !== null; + const allocateTerminalId = useCallback( + () => + nextTerminalId( + allocatableActiveTerminalIds, + canReuseTerminal && readEnvironmentScope(environmentId, AuthTerminalReadScope) + ? undefined + : randomUUID(), + ), + [allocatableActiveTerminalIds, canReuseTerminal, environmentId], + ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = @@ -3271,7 +3317,13 @@ export default function ChatView(props: ChatViewProps) { const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; - if (nextOpen && terminalUiState.terminalIds.length === 0) { + if ( + nextOpen && + !readEnvironmentScope(environmentId, AuthTerminalReadScope) && + !hasTerminalWriteAccess() + ) + return; + if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { if (!activeThreadId || !activeProject) { return; } @@ -3279,7 +3331,7 @@ export default function ChatView(props: ChatViewProps) { if (!cwdForOpen) { return; } - const terminalId = nextTerminalId(allocatableActiveTerminalIds); + const terminalId = allocateTerminalId(); storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); void openTerminal({ environmentId, @@ -3302,10 +3354,11 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activeThreadRef, activeThreadWorktreePath, - allocatableActiveTerminalIds, + allocateTerminalId, environmentId, gitCwd, openTerminal, + hasTerminalWriteAccess, setTerminalOpen, storeEnsureTerminal, terminalUiState.terminalIds.length, @@ -3313,14 +3366,20 @@ export default function ChatView(props: ChatViewProps) { ]); const splitTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + if ( + !hasTerminalWriteAccess() || + !activeThreadRef || + hasReachedSplitLimit || + !activeThreadId || + !activeProject + ) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; if (!cwdForOpen) { return; } - const terminalId = nextTerminalId(allocatableActiveTerminalIds); + const terminalId = allocateTerminalId(); if (direction === "vertical") { storeSplitTerminalVertical(activeThreadRef, terminalId); } else { @@ -3344,9 +3403,10 @@ export default function ChatView(props: ChatViewProps) { [ activeProject, activeThreadId, - allocatableActiveTerminalIds, + allocateTerminalId, activeThreadRef, openTerminal, + hasTerminalWriteAccess, activeThreadWorktreePath, environmentId, gitCwd, @@ -3356,14 +3416,14 @@ export default function ChatView(props: ChatViewProps) { ], ); const createNewTerminal = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) { + if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; if (!cwdForOpen) { return; } - const terminalId = nextTerminalId(allocatableActiveTerminalIds); + const terminalId = allocateTerminalId(); storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ @@ -3382,9 +3442,10 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, activeThreadId, - allocatableActiveTerminalIds, + allocateTerminalId, activeThreadRef, openTerminal, + hasTerminalWriteAccess, activeThreadWorktreePath, environmentId, gitCwd, @@ -3392,7 +3453,7 @@ export default function ChatView(props: ChatViewProps) { ]); const closeTerminal = useCallback( (terminalId: string) => { - if (!activeThreadId || !activeThreadRef) return; + if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return; const fallbackExitWrite = () => writeTerminal({ environmentId, @@ -3407,7 +3468,11 @@ export default function ChatView(props: ChatViewProps) { deleteHistory: true, }, }); - if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { + if ( + closeResult._tag === "Failure" && + !isAtomCommandInterrupted(closeResult) && + hasTerminalWriteAccess() + ) { await fallbackExitWrite(); } })(); @@ -3418,6 +3483,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activeThreadRef, closeTerminalMutation, + hasTerminalWriteAccess, environmentId, storeCloseTerminal, writeTerminal, @@ -3434,7 +3500,7 @@ export default function ChatView(props: ChatViewProps) { rememberAsLastInvoked?: boolean; }, ) => { - if (!activeThreadId || !activeProject || !activeThread) return; + if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; @@ -3446,7 +3512,10 @@ export default function ChatView(props: ChatViewProps) { terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = wantsNewTerminal; + const shouldCreateNewTerminal = + wantsNewTerminal || + !canReuseTerminal || + !readEnvironmentScope(environmentId, AuthTerminalReadScope); const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; setTerminalUiLaunchContext({ @@ -3467,9 +3536,7 @@ export default function ChatView(props: ChatViewProps) { worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); - const targetTerminalId = shouldCreateNewTerminal - ? nextTerminalId(allocatableActiveTerminalIds) - : baseTerminalId; + const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId; const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { threadId: activeThreadId, @@ -3506,6 +3573,7 @@ export default function ChatView(props: ChatViewProps) { return; } + if (!hasTerminalWriteAccess()) return; const writeResult = await writeTerminal({ environmentId, input: { @@ -3535,8 +3603,10 @@ export default function ChatView(props: ChatViewProps) { setLastInvokedScriptByProjectId, environmentId, openTerminal, + hasTerminalWriteAccess, activeKnownTerminalIds, - allocatableActiveTerminalIds, + canReuseTerminal, + allocateTerminalId, runningTerminalIds, terminalUiState.activeTerminalId, writeTerminal, @@ -4131,9 +4201,9 @@ export default function ChatView(props: ChatViewProps) { } }, [activeThreadRef]); const addTerminalSurface = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) return; + if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; - const terminalId = nextTerminalId(allocatableActiveTerminalIds); + const terminalId = allocateTerminalId(); useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ @@ -4154,13 +4224,15 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activeThreadRef, activeThreadWorktreePath, - allocatableActiveTerminalIds, + allocateTerminalId, gitCwd, openTerminal, + hasTerminalWriteAccess, ]); const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { if ( + !hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject || @@ -4169,7 +4241,7 @@ export default function ChatView(props: ChatViewProps) { ) { return; } - const terminalId = nextTerminalId(allocatableActiveTerminalIds); + const terminalId = allocateTerminalId(); const cwd = gitCwd ?? activeProject.workspaceRoot; useRightPanelStore .getState() @@ -4195,9 +4267,10 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activeThreadRef, activeThreadWorktreePath, - allocatableActiveTerminalIds, + allocateTerminalId, gitCwd, openTerminal, + hasTerminalWriteAccess, ], ); const splitPanelTerminalVertical = useCallback(() => { @@ -4215,7 +4288,12 @@ export default function ChatView(props: ChatViewProps) { ); const closePanelTerminal = useCallback( (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + if ( + !hasTerminalWriteAccess() || + !activeThreadRef || + activeRightPanelSurface?.kind !== "terminal" + ) + return; void closeTerminalMutation({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, @@ -4226,25 +4304,37 @@ export default function ChatView(props: ChatViewProps) { .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], + [ + hasTerminalWriteAccess, + activeRightPanelSurface, + activeThreadRef, + closeTerminalMutation, + storeCloseTerminal, + ], ); const requestCloseTerminal = useCallback( (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed) closeTerminal(terminalId); + if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { + closeTerminal(terminalId); + } }); }, - [activeTerminalLabelsById, closeTerminal], + [hasTerminalWriteAccess, activeTerminalLabelsById, closeTerminal, environmentId], ); const requestClosePanelTerminal = useCallback( (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed) closePanelTerminal(terminalId); + if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { + closePanelTerminal(terminalId); + } }); }, - [activeTerminalLabelsById, closePanelTerminal], + [hasTerminalWriteAccess, activeTerminalLabelsById, closePanelTerminal, environmentId], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { @@ -4288,7 +4378,10 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, }); } - if (surface.kind === "terminal") { + if ( + surface.kind === "terminal" && + readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope) + ) { for (const terminalId of surface.terminalIds) { storeCloseTerminal(activeThreadRef, terminalId); void closeTerminalMutation({ @@ -4359,7 +4452,10 @@ export default function ChatView(props: ChatViewProps) { closeAfterAgentBrowserConfirmation([surface], finishClose); return; } - if (surface.kind !== "terminal") { + if ( + surface.kind !== "terminal" || + !readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope) + ) { finishClose(); return; } @@ -4372,7 +4468,9 @@ export default function ChatView(props: ChatViewProps) { (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId), ); void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => { - if (confirmed) finishClose(); + if (confirmed) { + finishClose(); + } }); }, [ @@ -5969,6 +6067,7 @@ export default function ChatView(props: ChatViewProps) { } if (command === "terminal.toggle") { + if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return; event.preventDefault(); event.stopPropagation(); toggleTerminalVisibility(); @@ -6002,6 +6101,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); + if (!canOperateTerminal) return; if (terminalFocusOwner === "right-panel") { splitPanelTerminal(); return; @@ -6016,6 +6116,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.splitVertical") { event.preventDefault(); event.stopPropagation(); + if (!canOperateTerminal) return; if (terminalFocusOwner === "right-panel") { splitPanelTerminal("vertical"); return; @@ -6030,6 +6131,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); + if (!canOperateTerminal) return; if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); return; @@ -6042,6 +6144,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); + if (!canOperateTerminal) return; if (terminalFocusOwner === "right-panel") { addTerminalSurface(); return; @@ -6068,7 +6171,7 @@ export default function ChatView(props: ChatViewProps) { } const scriptId = projectScriptIdFromCommand(command); - if (!scriptId || !activeProject) return; + if (!scriptId || !activeProject || !canOperateTerminal) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); if (!script) return; event.preventDefault(); @@ -6080,6 +6183,8 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, activeRightPanelSurface, + canReadTerminal, + canOperateTerminal, addTerminalSurface, activeThreadRef, activeThreadPinned, @@ -7666,7 +7771,10 @@ export default function ChatView(props: ChatViewProps) { const panelToggleControls = ( ; preferredScriptId?: string | null; - onRunScript: (script: ProjectScript) => void; + onRunScript?: ((script: ProjectScript) => void) | undefined; onAddScript: (input: NewProjectScriptInput) => Promise; onUpdateScript: ( scriptId: string, @@ -170,12 +170,13 @@ export default function ProjectScriptsControl({ + )} + + + {!onRunScript && ( + openEditDialog(script)} > - - - - + + Edit {script.name} + + )} + ); })} {importMenuItems} diff --git a/apps/web/src/components/ThreadTerminalDrawer.permissions.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.permissions.test.tsx new file mode 100644 index 000000000000..895586a1df9a --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDrawer.permissions.test.tsx @@ -0,0 +1,153 @@ +import { + AuthTerminalOperateScope, + EnvironmentId, + ThreadId, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { nextTerminalAttachSeedState } from "@t3tools/client-runtime/state/terminal"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import type { + GhosttyTerminalSurface, + GhosttyTerminalSurfaceOptions, +} from "~/terminal/ghostty/surface"; + +const state = vi.hoisted(() => ({ + allowed: true, + listeners: new Set<() => void>(), + resize: vi.fn(), + otherCommand: vi.fn(), + createSurface: + vi.fn< + ( + mount: HTMLElement, + options: GhosttyTerminalSurfaceOptions, + ) => Promise + >(), +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../hooks/useSettings", () => ({ + getClientSettings: () => DEFAULT_CLIENT_SETTINGS, + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), +})); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => state.otherCommand })); +vi.mock("../localApi", () => ({ readLocalApi: () => null })); +vi.mock("../state/server", () => ({ + serverEnvironment: { configValueAtom: () => "config" }, +})); +vi.mock("../state/preview", () => ({ previewEnvironment: { open: "open" } })); +vi.mock("../state/terminal", () => ({ terminalEnvironment: { resize: "resize", write: "write" } })); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: (command: string) => (command === "resize" ? state.resize : state.otherCommand), +})); +vi.mock("../state/terminalSessions", () => ({ + useAttachedTerminalSession: () => session, +})); +vi.mock("~/terminal/ghostty/surface", () => ({ + GhosttyTerminalSurface: { create: state.createSurface }, +})); +vi.mock("../state/session", async () => { + const { useSyncExternalStore } = await import("react"); + const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => + id !== null && + (scope !== AuthTerminalOperateScope || id !== threadRef.environmentId || state.allowed); + return { + readEnvironmentScope, + useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) => + useSyncExternalStore( + (listener) => { + state.listeners.add(listener); + return () => state.listeners.delete(listener); + }, + () => readEnvironmentScope(id, scope), + ), + }; +}); + +import { TerminalViewport } from "./ThreadTerminalDrawer"; + +const threadRef = { + environmentId: EnvironmentId.make("secondary-terminal"), + threadId: ThreadId.make("thread"), +}; +const session = { ...nextTerminalAttachSeedState(), status: "running" as const, version: 1 }; +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + state.allowed = true; + state.listeners.clear(); + state.resize.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.otherCommand.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + // A surface can report its initial grid while asynchronous WASM setup is + // pending. Keep that unrelated setup pending while exercising its callback. + state.createSurface.mockReset().mockReturnValue(new Promise(() => undefined)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { + body: {}, + documentElement: { classList: { contains: () => false } }, + querySelector: () => null, + createElement: () => ({ getContext: () => null }), + }); + vi.stubGlobal("getComputedStyle", () => ({ + colorScheme: "light", + backgroundColor: "", + color: "", + getPropertyValue: () => "", + })); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it("rechecks the target terminal grant when a retained surface callback reports a resize", async () => { + await act(async () => { + renderer = create( + , + { createNodeMock: () => ({ closest: () => null, contains: () => false }) }, + ); + }); + expect(state.createSurface).toHaveBeenCalledOnce(); + const onResize = state.createSurface.mock.calls[0]![1].onResize; + if (!onResize) throw new Error("The terminal did not register its resize callback."); + + onResize(80, 24); + expect(state.resize).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 80, rows: 24 }, + }); + + // The connection updates before React commits its external-store update. + // Other environments retain access; only this terminal's target is revoked. + state.allowed = false; + onResize(120, 40); + expect(state.resize).toHaveBeenCalledTimes(1); + + state.allowed = true; + onResize(100, 30); + expect(state.resize).toHaveBeenCalledTimes(2); + expect(state.resize).toHaveBeenLastCalledWith({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 100, rows: 30 }, + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 1624a739bb1a..33966b96890b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,9 +1,17 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + applyTerminalAttachStreamEvent, + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, + INITIAL_TERMINAL_OUTPUT_CURSOR, + nextTerminalAttachSeedState, +} from "@t3tools/client-runtime/state/terminal"; import { shouldClearTerminalSelectionAction, shouldHandleTerminalExit, terminalContextMenuItems, + synchronizeTerminalOutput, terminalSelectionLineRange, terminalSelectionMenuItems, terminalThemeFromApp, @@ -138,8 +146,63 @@ describe("terminal selection actions", () => { }); it("handles an exit that lands while the terminal surface is still loading", () => { - expect(shouldHandleTerminalExit("exited", "running", false)).toBe(true); - expect(shouldHandleTerminalExit("exited", "exited", false)).toBe(false); - expect(shouldHandleTerminalExit("closed", "running", true)).toBe(false); + expect(shouldHandleTerminalExit("exited", "running", false, 1)).toBe(true); + expect(shouldHandleTerminalExit("exited", "exited", false, 1)).toBe(false); + expect(shouldHandleTerminalExit("closed", "running", true, 1)).toBe(false); + }); + + it.each(["closed", "exited"] as const)("ignores an unsynchronized %s seed", (status) => { + expect(shouldHandleTerminalExit(status, "running", false, 0)).toBe(false); + expect(shouldHandleTerminalExit(status, "running", false, 1)).toBe(true); + }); +}); + +it("retains visible output and selection until a replacement subscription receives its snapshot", () => { + const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() }; + const snapshot = { + threadId: "thread", + terminalId: "term-1", + cwd: "/repo", + worktreePath: null, + status: "running" as const, + pid: 123, + history: "host output", + exitCode: null, + exitSignal: null, + label: "Terminal 1", + updatedAt: "2026-09-05T00:00:00.000Z", + }; + const attached = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot, + }); + const cursor = synchronizeTerminalOutput(terminal, attached, INITIAL_TERMINAL_OUTPUT_CURSOR); + expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("host output"); + terminal.resetAndWrite.mockClear(); + terminal.clearSelection.mockClear(); + + const pending = combineTerminalSessionState( + { ...snapshot, hasRunningSubprocess: false }, + EMPTY_TERMINAL_BUFFER_STATE, + ); + expect(pending.status).toBe("running"); + expect(synchronizeTerminalOutput(terminal, pending, cursor)).toBe(cursor); + expect(terminal.resetAndWrite).not.toHaveBeenCalled(); + expect(terminal.write).not.toHaveBeenCalled(); + expect(terminal.clearSelection).not.toHaveBeenCalled(); + + const observed = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot: { ...snapshot, history: "host output\nobserved output" }, + }); + const observedCursor = synchronizeTerminalOutput(terminal, observed, cursor); + expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("host output\nobserved output"); + + const cleared = applyTerminalAttachStreamEvent(observed, { + type: "cleared", + threadId: "thread", + terminalId: "term-1", }); + synchronizeTerminalOutput(terminal, cleared, observedCursor); + expect(terminal.resetAndWrite).toHaveBeenLastCalledWith(""); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1702a7fb5a0b..b8b9d94a0235 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,8 @@ import { } from "lucide-react"; import { AuthPreviewOperateScope, + AuthOrchestrationOperateScope, + AuthTerminalOperateScope, type ContextMenuItem, type ProviderInstanceId, type ResolvedKeybindingsConfig, @@ -83,6 +85,7 @@ import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope } from "../state/session"; import { terminalEnvironment } from "../state/terminal"; +import { useEnvironmentScope } from "../state/session"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; @@ -121,6 +124,18 @@ export function writeTerminalOutputUpdate( } } +export function synchronizeTerminalOutput( + terminal: Pick, + session: Pick, + cursor: TerminalOutputCursor, +): TerminalOutputCursor { + if (session.version === 0) return cursor; + const update = readTerminalOutputUpdate(session.output, cursor); + writeTerminalOutputUpdate(terminal, update); + terminal.clearSelection(); + return update.cursor; +} + function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { if (typeof document === "undefined") return fallback; @@ -272,6 +287,7 @@ export function terminalSelectionMenuItems(options?: { export function terminalContextMenuItems(options: { hasSelection: boolean; canAddToChat?: boolean; + readOnly?: boolean; }): ContextMenuItem[] { const { hasSelection, canAddToChat = true } = options; return [ @@ -279,7 +295,7 @@ export function terminalContextMenuItems(options: { ...item, disabled: !hasSelection, })), - { id: "paste", label: "Paste" }, + { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) }, ]; } @@ -302,9 +318,13 @@ export function shouldHandleTerminalExit( current: TerminalSessionState["status"], synchronized: TerminalSessionState["status"], alreadyHandled: boolean, + version: number, ): boolean { return ( - (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled + version > 0 && + (current === "closed" || current === "exited") && + current !== synchronized && + !alreadyHandled ); } @@ -357,6 +377,15 @@ export function TerminalViewport({ const terminalRef = useRef(null); const visibleRef = useRef(visible); const environmentId = threadRef.environmentId; + const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); + const hasTerminalWriteAccess = useEffectEvent(() => + readEnvironmentScope(environmentId, AuthTerminalOperateScope), + ); + const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const canActivateTerminalLink = useEffectEvent( + (text: string) => + isTerminalUrl(text) || readEnvironmentScope(environmentId, AuthOrchestrationOperateScope), + ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, @@ -381,7 +410,7 @@ export function TerminalViewport({ const keybindingsRef = useRef(keybindings); const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); const handleSessionExited = useEffectEvent(() => { - onSessionExited(); + if (hasTerminalWriteAccess()) onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { onAddTerminalContext?.(selection); @@ -414,29 +443,35 @@ export function TerminalViewport({ ...(providerInstanceId ? { providerInstanceId } : {}), }, }); + const canResizeTerminal = + canOperateTerminal && terminalSession.version > 0 && terminalSession.status === "running"; + const resizeSessionGeneration = canResizeTerminal ? terminalSession.output.generation : null; const writeTerminal = useEffectEvent((data: string) => runTerminalWrite({ environmentId, input: { threadId, terminalId, data }, }), ); - const resizeTerminal = useEffectEvent((cols: number, rows: number) => - runTerminalResize({ + const resizeTerminal = useEffectEvent((cols: number, rows: number) => { + if (!canResizeTerminal || !hasTerminalWriteAccess()) return; + return runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, - }), - ); + }); + }); const terminalOutput = terminalSession.output; const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const synchronizedStatusRef = useRef("closed"); const synchronizeTerminalStatus = useEffectEvent( - (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => { + (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"], version: number) => { const synchronized = synchronizedStatusRef.current; - if (status === "running") { + if (version > 0 && status === "running") { hasHandledExitRef.current = false; - } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) { + } else if ( + shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current, version) + ) { hasHandledExitRef.current = true; writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited"); window.setTimeout(() => { @@ -445,7 +480,7 @@ export function TerminalViewport({ } }, 0); } - synchronizedStatusRef.current = status; + if (version > 0) synchronizedStatusRef.current = status; }, ); const terminalVersion = terminalSession.version; @@ -467,6 +502,22 @@ export function TerminalViewport({ keybindingsRef.current = keybindings; }, [keybindings]); + useLayoutEffect(() => { + if (terminalRef.current) terminalRef.current.input.readOnly = !canOperateTerminal; + }, [canOperateTerminal]); + + // A grant can change while the pointer remains over a link. + useEffect(() => { + terminalRef.current?.refreshLinkActivation(); + }, [canOpenHostEditor]); + + useEffect(() => { + if (resizeSessionGeneration === null) return; + // The first fit can finish before authorization or the attach snapshot. + // Replay its grid once this writable session exists, including reconnects. + terminalRef.current?.resendSize(); + }, [resizeSessionGeneration]); + useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); @@ -503,6 +554,7 @@ export function TerminalViewport({ onSelectionChange: () => handleSelectionChange(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), + canActivateLink: (text) => canActivateTerminalLink(text), // The surface listens from construction, so a right-click can land // while `create` is still awaiting WASM — before the handler below it // exists. The ref is only assigned once that setup has run. @@ -521,6 +573,7 @@ export function TerminalViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + terminal.input.readOnly = !hasTerminalWriteAccess(); // Client settings hydrate asynchronously; a font preference that landed // while the surface was loading found terminalRef null, so its setFont // was dropped. Re-apply whatever is current once the terminal exists. @@ -544,9 +597,13 @@ export function TerminalViewport({ // (A session that is "closed" at mount is indistinguishable from one that // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; - synchronizeTerminalStatus(terminal, latestSession.status); + synchronizeTerminalStatus(terminal, latestSession.status, latestSession.version); // Startup may finish after the user has returned to the composer. - if (visibleRef.current && mount.contains(document.activeElement)) { + if ( + hasTerminalWriteAccess() && + visibleRef.current && + mount.contains(document.activeElement) + ) { terminal.focus(); } @@ -634,6 +691,7 @@ export function TerminalViewport({ }; const pasteFromClipboard = async (requestId: number) => { + if (!hasTerminalWriteAccess()) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { @@ -667,6 +725,7 @@ export function TerminalViewport({ terminalContextMenuItems({ hasSelection: selectionAction !== null, canAddToChat: canAddSelectionToChat(), + readOnly: !hasTerminalWriteAccess(), }), { x: event.clientX, y: event.clientY }, ); @@ -732,6 +791,7 @@ export function TerminalViewport({ }; const sendTerminalInput = async (data: string, fallbackError: string) => { + if (!hasTerminalWriteAccess()) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; const result = await writeTerminal(data); @@ -784,7 +844,7 @@ export function TerminalViewport({ } function handleLinkActivate(text: string, event: MouseEvent): void { - if (!isTerminalLinkActivation(event)) return; + if (!isTerminalLinkActivation(event) || !canActivateTerminalLink(text)) return; const latestTerminal = terminalRef.current; if (!latestTerminal) return; if (isTerminalUrl(text)) { @@ -835,6 +895,7 @@ export function TerminalViewport({ } function handleData(data: string): void { + if (!hasTerminalWriteAccess()) return; void (async () => { const result = await writeTerminal(data); if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; @@ -924,7 +985,9 @@ export function TerminalViewport({ cancelled = true; const hadFocus = mount.contains(document.activeElement); teardown?.(); - if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); + if (hasTerminalWriteAccess() && hadFocus && mount.isConnected) { + mount.focus({ preventScroll: true }); + } }; }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); @@ -942,15 +1005,12 @@ export function TerminalViewport({ } const previous = previousSessionRef.current; - synchronizeTerminalStatus(terminal, current.status); + synchronizeTerminalStatus(terminal, current.status, current.version); if (current.version === previous.version && current.output === previous.output) { return; } - const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current); - writeTerminalOutputUpdate(terminal, outputUpdate); - outputCursorRef.current = outputUpdate.cursor; - terminal.clearSelection(); + outputCursorRef.current = synchronizeTerminalOutput(terminal, current, outputCursorRef.current); if (current.error !== null && current.error !== previous.error) { writeSystemMessage(terminal, current.error); @@ -960,11 +1020,11 @@ export function TerminalViewport({ }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); useEffect(() => { - if (!autoFocus || !visible) return; + if (!autoFocus || !canOperateTerminal || !visible) return; // Claim focus when requested, then hand it to the terminal once ready only // if the user has not focused something else in the meantime. (terminalRef.current ?? containerRef.current)?.focus(); - }, [autoFocus, focusRequestId, visible]); + }, [autoFocus, canOperateTerminal, focusRequestId, visible]); useEffect(() => { const terminal = terminalRef.current; @@ -1029,14 +1089,29 @@ interface TerminalActionButtonProps { className: string; onClick: () => void; children: ReactNode; + disabled?: boolean; } -function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { +function TerminalActionButton({ + label, + className, + onClick, + children, + disabled, +}: TerminalActionButtonProps) { return ( } + render={ + @@ -1449,6 +1534,7 @@ export default function ThreadTerminalDrawer({
confirmCloseTerminal(resolvedActiveTerminalId)} label={closeTerminalActionLabel} @@ -1594,6 +1683,7 @@ export default function ThreadTerminalDrawer({
confirmCloseTerminal(resolvedActiveTerminalId)} label={closeTerminalActionLabel} @@ -1689,13 +1782,17 @@ export default function ThreadTerminalDrawer({ : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - confirmCloseTerminal(terminalId)} - tooltip={closeTerminalLabel} - > + {canOperateTerminal ? ( + confirmCloseTerminal(terminalId)} + tooltip={closeTerminalLabel} + > + + + ) : ( - + )} ) : null} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 6df6993fa7f6..9a8047d9780d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -28,6 +28,7 @@ import { AuthFilesystemWriteScope, AuthStandardClientScopes, AuthTerminalOperateScope, + AuthTerminalReadScope, type AuthClientSession, type AuthEnvironmentScope, type AuthGrantScope, @@ -230,6 +231,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ title: "View diagnostics and usage", description: "Read process diagnostics, resource history, and usage totals.", }, + { + scope: AuthTerminalReadScope, + title: "View terminals", + description: "Read existing terminal output and status.", + }, { scope: AuthTerminalOperateScope, title: "Use terminals", @@ -1192,6 +1198,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio AuthOrchestrationReadScope, AuthFilesystemReadScope, AuthDiagnosticsReadScope, + AuthTerminalReadScope, ].filter((scope) => delegatableScopes.includes(scope)), ) } diff --git a/apps/web/src/state/terminalSessionAvailability.test.ts b/apps/web/src/state/terminalSessionAvailability.test.ts new file mode 100644 index 000000000000..1e054680f3ca --- /dev/null +++ b/apps/web/src/state/terminalSessionAvailability.test.ts @@ -0,0 +1,104 @@ +import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts"; +import { act, createElement, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useKnownTerminalSessions } from "./terminalSessions"; + +const state = vi.hoisted(() => ({ + canRead: true, + data: null as ReadonlyArray | null, + error: null as string | null, +})); + +vi.mock("./session", () => ({ useEnvironmentScope: () => state.canRead })); +vi.mock("./terminal", () => ({ terminalEnvironment: { metadata: () => ({}) } })); +vi.mock("./query", () => ({ + useEnvironmentQuery: (atom: unknown) => + atom === null ? { data: null, error: null } : { data: state.data, error: state.error }, +})); + +let root: Root; +let current: ReturnType; +const target = { + environmentId: EnvironmentId.make("secondary"), + threadId: ThreadId.make("thread"), +}; +const terminal: TerminalSummary = { + threadId: "thread", + terminalId: "term-1", + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: true, + label: "Busy process", + updatedAt: "2026-09-05T00:00:00.000Z", +}; + +function Probe() { + const sessions = useKnownTerminalSessions(target); + useEffect(() => { + current = sessions; + }, [sessions]); + return null; +} + +function render() { + return act(() => root.render(createElement(Probe))); +} + +beforeEach(() => { + state.canRead = true; + state.data = null; + state.error = null; + const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +it("distinguishes missing metadata from an authoritative empty list across scope changes", async () => { + await render(); + expect(current).toBeNull(); + + state.data = [terminal]; + await render(); + expect(current?.[0]).toMatchObject({ + target: { environmentId: "secondary", terminalId: "term-1" }, + state: { hasRunningSubprocess: true }, + }); + + state.canRead = false; + await render(); + expect(current).toBeNull(); + + state.canRead = true; + state.data = []; + await render(); + expect(current).toEqual([]); +}); + +it("does not reuse cached terminal metadata after a subscription failure", async () => { + state.data = [terminal]; + await render(); + state.error = "Access denied"; + await render(); + expect(current).toBeNull(); +}); diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts index 5e3a397826de..326b9f8db2a7 100644 --- a/apps/web/src/state/terminalSessions.ts +++ b/apps/web/src/state/terminalSessions.ts @@ -8,6 +8,8 @@ import { } from "@t3tools/client-runtime/state/terminal"; import { ThreadId, + AuthTerminalReadScope, + AuthTerminalOperateScope, type EnvironmentId, type TerminalAttachInput, type TerminalSummary, @@ -16,6 +18,7 @@ import { useMemo } from "react"; import { useEnvironmentQuery } from "./query"; import { terminalEnvironment } from "./terminal"; +import { useEnvironmentScope } from "./session"; const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze>([]); @@ -125,16 +128,25 @@ export function useAttachedTerminalSession(input: { readonly environmentId: EnvironmentId | null; readonly terminal: TerminalAttachInput | null; }): TerminalSessionState { + const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope); + const canOperate = useEnvironmentScope(input.environmentId, AuthTerminalOperateScope); const attach = useEnvironmentQuery( input.environmentId !== null && input.terminal !== null - ? terminalEnvironment.attach({ - environmentId: input.environmentId, - input: input.terminal, - }) + ? canOperate + ? terminalEnvironment.attach({ + environmentId: input.environmentId, + input: input.terminal, + }) + : canRead + ? terminalEnvironment.observe({ + environmentId: input.environmentId, + input: { threadId: input.terminal.threadId, terminalId: input.terminal.terminalId }, + }) + : null : null, ); const metadata = useEnvironmentQuery( - input.environmentId === null + input.environmentId === null || !canRead ? null : terminalEnvironment.metadata({ environmentId: input.environmentId, @@ -160,9 +172,10 @@ export function useAttachedTerminalSession(input: { export function useKnownTerminalSessions(input: { readonly environmentId: EnvironmentId | null; readonly threadId: ThreadId | null; -}): ReadonlyArray { +}): ReadonlyArray | null { + const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope); const metadata = useEnvironmentQuery( - input.environmentId === null + input.environmentId === null || !canRead ? null : terminalEnvironment.metadata({ environmentId: input.environmentId, @@ -170,8 +183,11 @@ export function useKnownTerminalSessions(input: { }), ); return useMemo( - () => selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId), - [input.environmentId, input.threadId, metadata.data], + () => + metadata.data === null || metadata.error !== null + ? null + : selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId), + [input.environmentId, input.threadId, metadata.data, metadata.error], ); } @@ -180,5 +196,5 @@ export function useThreadRunningTerminalIds(input: { readonly threadId: ThreadId | null; }): ReadonlyArray { const sessions = useKnownTerminalSessions(input); - return useMemo(() => selectRunningSubprocessTerminalIds(sessions), [sessions]); + return useMemo(() => selectRunningSubprocessTerminalIds(sessions ?? []), [sessions]); } diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ee3240b41b08..006d78aaadb0 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -168,7 +168,12 @@ describe("GhosttyTerminalSurface visibility", () => { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number) { + pointer( + type: string, + clientX: number, + buttons: number, + modifiers: Partial> = {}, + ) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, @@ -176,6 +181,7 @@ describe("GhosttyTerminalSurface visibility", () => { pointerId: 1, button: 0, buttons, + ...modifiers, }), ); }, @@ -210,6 +216,108 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + it.each([ + { platform: "Linux x86_64", modifiers: { ctrlKey: true, metaKey: false } }, + { platform: "MacIntel", modifiers: { ctrlKey: false, metaKey: true } }, + ])( + "gates path links and preserves selection and URLs ($platform)", + async ({ platform, modifiers }) => { + vi.stubGlobal("navigator", { platform }); + const harness = createHarness(); + let canOpenPaths = false; + const openLink = vi.fn(); + const surface = await harness.create({ + canActivateLink: (text) => text.startsWith("https://") || canOpenPaths, + onLinkActivate: openLink, + }); + surface.write("/repo/file.ts"); + harness.flushFrame(); + harness.pointer("pointermove", 5, 0, modifiers); + expect(surface.canvas.style.cursor).toBe(""); + harness.pointer("pointerdown", 5, 1, modifiers); + harness.pointer("pointerup", 5, 0, modifiers); + expect(openLink).not.toHaveBeenCalled(); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("/repo"); + + harness.pointer("pointermove", 5, 0, modifiers); + canOpenPaths = true; + surface.refreshLinkActivation(); + expect(surface.canvas.style.cursor).toBe("pointer"); + harness.pointer("pointerdown", 5, 1, modifiers); + harness.pointer("pointerup", 5, 0, modifiers); + expect(openLink).toHaveBeenCalledExactlyOnceWith("/repo/file.ts", expect.any(Event)); + + harness.pointer("pointerdown", 5, 1, modifiers); + canOpenPaths = false; + surface.refreshLinkActivation(); + expect(surface.canvas.style.cursor).toBe(""); + harness.pointer("pointerup", 5, 0, modifiers); + expect(openLink).toHaveBeenCalledOnce(); + + surface.resetAndWrite("https://t3.codes"); + harness.flushFrame(); + harness.pointer("pointermove", 5, 0, modifiers); + expect(surface.canvas.style.cursor).toBe("pointer"); + harness.pointer("pointerdown", 5, 1, modifiers); + harness.pointer("pointerup", 5, 0, modifiers); + expect(openLink).toHaveBeenLastCalledWith("https://t3.codes", expect.any(Event)); + }, + ); + + it("resends an unchanged grid when authorization and attachment become ready", async () => { + const harness = createHarness(); + let canOperate = false; + let attached = false; + const resizePty = vi.fn<(cols: number, rows: number) => void>(); + const surface = await harness.create({ + onResize: (cols, rows) => { + if (canOperate && attached) resizePty(cols, rows); + }, + }); + vi.advanceTimersByTime(150); + expect(resizePty).not.toHaveBeenCalled(); + + canOperate = true; + surface.resendSize(); + vi.advanceTimersByTime(150); + expect(resizePty).not.toHaveBeenCalled(); + + attached = true; + surface.resendSize(); + vi.advanceTimersByTime(150); + expect(resizePty).toHaveBeenCalledExactlyOnceWith(20, 6); + surface.fit(); + vi.advanceTimersByTime(150); + expect(resizePty).toHaveBeenCalledOnce(); + + canOperate = false; + harness.mount.clientWidth = 248; + surface.fit(); + vi.advanceTimersByTime(150); + expect(resizePty).toHaveBeenCalledOnce(); + }); + + it("replays the current grid on reveal when a hidden host becomes ready", async () => { + const harness = createHarness(); + const onResize = vi.fn<(cols: number, rows: number) => void>(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + onResize.mockClear(); + + surface.setVisible(false); + surface.resendSize(); + vi.advanceTimersByTime(150); + expect(onResize).not.toHaveBeenCalled(); + + surface.setVisible(true); + vi.advanceTimersByTime(150); + expect(onResize).toHaveBeenCalledExactlyOnceWith(20, 6); + }); + it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { const harness = createHarness(); const surface = await harness.create(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 29aaac6f6abd..2cafeb5e4374 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -527,6 +527,7 @@ export interface GhosttyTerminalSurfaceOptions { readonly onSelectionChange: () => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; + readonly canActivateLink?: (text: string) => boolean; /** * A right-click the running application did not claim through mouse * reporting. The host owns the menu, so it also owns preventing the browser @@ -768,6 +769,12 @@ export class GhosttyTerminalSurface { this.requestRender(); } + /** Re-evaluate link feedback after the host's available actions change. */ + refreshLinkActivation(): void { + if (this.disposed) return; + this.refreshHoveredLink(); + } + async setFont(font: GhosttyTerminalFont): Promise { if (this.disposed) return; const fontSize = terminalFontSize(font.size); @@ -830,6 +837,12 @@ export class GhosttyTerminalSurface { this.applyFontMetrics(); }; + /** Replay the measured grid after the host becomes ready to resize its PTY. */ + resendSize(): void { + this.resizeNotified = false; + this.fit(); + } + fit(): boolean { if (this.disposed || !this.visible) return false; const width = this.mount.clientWidth; @@ -1834,6 +1847,11 @@ export class GhosttyTerminalSurface { } private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { + const link = this.findLinkAt(clientX, clientY); + return link && this.options.canActivateLink?.(link.text) !== false ? link : null; + } + + private findLinkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { if (!this.snapshot) return null; const cell = terminalGridCellAt({ bounds: this.canvas.getBoundingClientRect(), diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 175d633e242f..8042c576bcb0 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -55,7 +55,8 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus - | typeof WS_METHODS.terminalAttach; + | typeof WS_METHODS.terminalAttach + | typeof WS_METHODS.terminalObserve; export type EnvironmentStreamCommandRpcTag = | typeof WS_METHODS.cloudInstallRelayClient diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 3bc2bca78f0f..22b27cda99c0 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -37,6 +37,15 @@ export function createTerminalEnvironmentAtoms( }) => JSON.stringify([environmentId, input.threadId, input.terminalId ?? null]); const lifecycleConcurrency = { mode: "serial" as const, key: terminalThreadKey }; return { + observe: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:observe", + subscribe: (input: EnvironmentRpcInput) => + Stream.suspend(() => + subscribe(WS_METHODS.terminalObserve, input).pipe( + Stream.scan(nextTerminalAttachSeedState(), applyTerminalAttachStreamEvent), + ), + ), + }), attach: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:terminal:attach", subscribe: (input: EnvironmentRpcInput) => diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 2b481a681e94..2135a85e6a9c 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -85,6 +85,7 @@ export const AuthProvidersManageScope = "providers:manage" as const; export const AuthEnvironmentMaintainScope = "environment:maintain" as const; export const AuthPreviewOperateScope = "preview:operate" as const; export const AuthDiagnosticsReadScope = "diagnostics:read" as const; +export const AuthTerminalReadScope = "terminal:read" as const; export const AuthTerminalOperateScope = "terminal:operate" as const; export const AuthSourceControlWriteScope = "source-control:write" as const; export const AuthFilesystemReadScope = "filesystem:read" as const; @@ -103,6 +104,7 @@ export const AuthEnvironmentScope = Schema.Literals([ AuthEnvironmentMaintainScope, AuthPreviewOperateScope, AuthDiagnosticsReadScope, + AuthTerminalReadScope, AuthTerminalOperateScope, AuthFilesystemReadScope, AuthFilesystemWriteScope, @@ -132,6 +134,7 @@ export const AuthStandardClientScopes = [ AuthEnvironmentMaintainScope, AuthPreviewOperateScope, AuthDiagnosticsReadScope, + AuthTerminalReadScope, AuthTerminalOperateScope, AuthSourceControlWriteScope, AuthFilesystemReadScope, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 2f8e4b1fb43e..d51498cb0e5e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -38,6 +38,7 @@ import type { } from "./project.ts"; import type { TerminalAttachInput, + TerminalObserveInput, TerminalAttachStreamEvent, TerminalClearInput, TerminalCloseInput, @@ -1321,6 +1322,13 @@ export interface EnvironmentApi { onResubscribe?: () => void; }, ) => () => void; + observe: ( + input: typeof TerminalObserveInput.Encoded, + callback: (event: TerminalAttachStreamEvent) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; write: (input: typeof TerminalWriteInput.Encoded) => Promise; resize: (input: typeof TerminalResizeInput.Encoded) => Promise; clear: (input: typeof TerminalClearInput.Encoded) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 4e8ae2e54134..8f72dfd193b4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -152,6 +152,7 @@ import { } from "./project.ts"; import { TerminalAttachInput, + TerminalObserveInput, TerminalAttachStreamEvent, TerminalClearInput, TerminalCloseInput, @@ -290,6 +291,7 @@ export const WS_METHODS = { // Terminal methods terminalOpen: "terminal.open", terminalAttach: "terminal.attach", + terminalObserve: "terminal.observe", terminalWrite: "terminal.write", terminalResize: "terminal.resize", terminalClear: "terminal.clear", @@ -976,6 +978,13 @@ export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { stream: true, }); +export const WsTerminalObserveRpc = Rpc.make(WS_METHODS.terminalObserve, { + payload: TerminalObserveInput, + success: TerminalAttachStreamEvent, + error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), + stream: true, +}); + export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { payload: TerminalWriteInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), @@ -1291,6 +1300,7 @@ export const WsRpcGroup = RpcGroup.make( WsReviewGetDiffFileContentsRpc, WsTerminalOpenRpc, WsTerminalAttachRpc, + WsTerminalObserveRpc, WsTerminalWriteRpc, WsTerminalResizeRpc, WsTerminalClearRpc, diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 36e3d339f521..8165d6b913f3 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -60,6 +60,9 @@ export const TerminalAttachInput = Schema.Struct({ }); export type TerminalAttachInput = typeof TerminalAttachInput.Type; +export const TerminalObserveInput = TerminalSessionInput; +export type TerminalObserveInput = Schema.Codec.Encoded; + export const TerminalWriteInput = Schema.Struct({ ...TerminalSessionInput.fields, data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), diff --git a/packages/shared/src/terminalLabels.test.ts b/packages/shared/src/terminalLabels.test.ts index b8a146b0cc2e..2b2d73418b1f 100644 --- a/packages/shared/src/terminalLabels.test.ts +++ b/packages/shared/src/terminalLabels.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import type { TerminalSummary } from "@t3tools/contracts"; -import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts"; +import { DEFAULT_TERMINAL_ID, TerminalOpenInput } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts"; +const decodeTerminalOpen = Schema.decodeUnknownSync(TerminalOpenInput); + describe("getTerminalLabel", () => { it("uses the numeric suffix for term-* ids", () => { expect(getTerminalLabel(DEFAULT_TERMINAL_ID)).toBe("Terminal 1"); @@ -16,6 +19,10 @@ describe("getTerminalLabel", () => { it("falls back to the raw id for unknown shapes", () => { expect(getTerminalLabel("custom-session")).toBe("custom-session"); }); + + it("keeps a numbered label for terminals allocated without host metadata", () => { + expect(getTerminalLabel("term-2-783c91cc-a413-47c7-8312-c2a5a1f05e40")).toBe("Terminal 2"); + }); }); describe("resolveTerminalSessionLabel", () => { @@ -50,4 +57,20 @@ describe("nextTerminalId", () => { expect(nextTerminalId(["", " ", DEFAULT_TERMINAL_ID])).toBe("term-2"); expect(nextTerminalId(["", " "])).toBe("term-1"); }); + + it("avoids unseen sessions when metadata is unavailable and resumes normal numbering", () => { + const first = nextTerminalId([], "783c91cc-a413-47c7-8312-c2a5a1f05e40"); + const second = nextTerminalId([first], "102315fc-ceef-45c4-b978-c4d4947d3c26"); + expect(first).not.toBe(DEFAULT_TERMINAL_ID); + expect(second).not.toBe(first); + expect(getTerminalLabel(second)).toBe("Terminal 2"); + expect(nextTerminalId([first, second])).toBe("term-3"); + expect( + decodeTerminalOpen({ + threadId: "thread", + terminalId: second, + cwd: "/workspace", + }), + ).toMatchObject({ terminalId: second }); + }); }); diff --git a/packages/shared/src/terminalLabels.ts b/packages/shared/src/terminalLabels.ts index 1cdae101430c..9873c32c4cb2 100644 --- a/packages/shared/src/terminalLabels.ts +++ b/packages/shared/src/terminalLabels.ts @@ -1,8 +1,14 @@ import type { TerminalSummary } from "@t3tools/contracts"; +function terminalNumber(terminalId: string): string | undefined { + return /^term(?:inal)?-(\d+)(?:-[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12})?$/i.exec( + terminalId, + )?.[1]; +} + /** Human-readable label for a terminal tab; matches mobile and web sidebars. */ export function getTerminalLabel(terminalId: string): string { - const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1]; + const numericSuffix = terminalNumber(terminalId); if (numericSuffix) { return `Terminal ${numericSuffix}`; } @@ -26,15 +32,18 @@ export function resolveTerminalSessionLabel( * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly * on every `terminal.open` / `terminal.attach` call — the server never allocates. * - * Returns the lowest unused `term-N` id (starting at `term-1`), skipping any ids already in - * `existingTerminalIds`. + * Returns the lowest unused `term-N` number. When metadata is unavailable, + * callers append a UUID so an unseen host session cannot be reused accidentally. */ -export function nextTerminalId(existingTerminalIds: ReadonlyArray): string { - const usedIds = new Set(existingTerminalIds.filter((id) => id.trim().length > 0)); +export function nextTerminalId( + existingTerminalIds: ReadonlyArray, + uniqueSuffix?: string, +): string { + const usedNumbers = new Set(existingTerminalIds.map(terminalNumber)); let nextIndex = 1; - while (usedIds.has(`term-${nextIndex}`)) { + while (usedNumbers.has(String(nextIndex))) { nextIndex += 1; } - return `term-${nextIndex}`; + return `term-${nextIndex}${uniqueSuffix === undefined ? "" : `-${uniqueSuffix}`}`; }