From b8454084e997fc44509f3c19706b0e6c73b7cf15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 06:56:16 +0000 Subject: [PATCH 01/16] test(server): attached terminal streams learn the shared PTY size Reproduces #14463: when a second client attaches with a narrower size, the shared PTY is resized but no attached stream is told the resulting grid. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/terminal/Manager.test.ts | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 413436ea452c5..9704d5c4257d3 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -457,6 +457,49 @@ it.layer( }), ); + // Which client owns the shared PTY size is left open by the issue, so this only asserts + // that every attached renderer is told the grid the PTY actually has. + it.effect("tells every attached stream the shared PTY size when attaches disagree", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const wideEvents: TerminalAttachStreamEvent[] = []; + const narrowEvents: TerminalAttachStreamEvent[] = []; + const stopWide = yield* manager.attachStream(openInput({ cols: 130, rows: 15 }), (event) => + Effect.sync(() => { + wideEvents.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopWide)); + const stopNarrow = yield* manager.attachStream(openInput({ cols: 54, rows: 15 }), (event) => + Effect.sync(() => { + narrowEvents.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopNarrow)); + + const spawned = ptyAdapter.spawnInputs[0]!; + const ptySize = ptyAdapter.processes[0]!.resizeCalls.at(-1) ?? { + cols: spawned.cols, + rows: spawned.rows, + }; + const lastReportedSize = (events: ReadonlyArray) => { + for (const event of events.toReversed()) { + const source: Record = + event.type === "snapshot" ? event.snapshot : event; + if (typeof source.cols === "number" && typeof source.rows === "number") { + return { cols: source.cols, rows: source.rows }; + } + } + return null; + }; + + expect({ + wide: lastReportedSize(wideEvents), + narrow: lastReportedSize(narrowEvents), + }).toEqual({ wide: ptySize, narrow: ptySize }); + }), + ); + it.effect("resumed attaches after close never respawn until the terminal is reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); From ea47c9f0633a25238ebb7e252e4420ffbf33cc18 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 02:49:41 +0000 Subject: [PATCH 02/16] fix(terminal): attached viewers share the smallest PTY grid and draw the server's size Co-Authored-By: Claude Opus 5.5 --- .../modules/t3terminal/T3TerminalModule.kt | 4 + .../expo/modules/t3terminal/T3TerminalView.kt | 26 +- .../t3-terminal/ios/T3TerminalModule.swift | 4 + .../t3-terminal/ios/T3TerminalView.swift | 46 ++-- .../terminal/NativeTerminalSurface.tsx | 11 +- .../terminal/ThreadTerminalRouteScreen.tsx | 1 + .../features/terminal/nativeTerminalModule.ts | 2 + apps/server/src/terminal/Manager.ts | 224 +++++++++++++++--- apps/server/src/ws.ts | 16 +- .../src/components/ThreadTerminalDrawer.tsx | 7 + apps/web/src/terminal/ghostty/surface.ts | 49 +++- .../src/state/terminalSession.test.ts | 24 ++ .../src/state/terminalSession.ts | 29 ++- packages/contracts/src/terminal.ts | 6 + 14 files changed, 386 insertions(+), 63 deletions(-) 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 cf99f02c028dc..de51eda7b7ce7 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 @@ -61,6 +61,10 @@ class T3TerminalModule : Module() { Prop("captureRequest") { view: T3TerminalView, request: Double -> view.captureRequest = request } + + Prop("sharedGrid") { view: T3TerminalView, sharedGrid: List -> + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") OnViewDestroys { view: T3TerminalView -> 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 f98d4b11138e7..bd1b6a724b680 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 @@ -57,6 +57,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var fedBuffer = "" private var cols = 0 private var rows = 0 + private var measuredCols = 0 + private var measuredRows = 0 private var clearingInput = false private var isCleanedUp = false private var backgroundColorValue = Color.parseColor("#24292E") @@ -90,6 +92,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex var appearanceScheme: String = "dark" + // The PTY grid shared by every attached client, or empty before the server reports one. + // The terminal draws this grid so its wrapping and cursor match the PTY; the view's own + // fit still goes out through onResize. + var sharedGrid: List = emptyList() + set(value) { + if (field == value) return + field = value + applyGrid() + } + var themeConfig: String = "" set(value) { field = value @@ -327,6 +339,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx) .toInt() .coerceIn(2, 200) + if (nextCols != measuredCols || nextRows != measuredRows) { + measuredCols = nextCols + measuredRows = nextRows + onResize(mapOf("cols" to measuredCols, "rows" to measuredRows)) + } + applyGrid() + } + + private fun applyGrid() { + if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return + val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 } + val nextCols = shared?.get(0) ?: measuredCols + val nextRows = shared?.get(1) ?: measuredRows if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return cols = nextCols rows = nextRows @@ -343,7 +368,6 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex ) } emitResponse(response) - onResize(mapOf("cols" to cols, "rows" to rows)) feedPendingBuffer() renderSnapshot() } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index a3ac8f622c509..c760ace918c4e 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -58,6 +58,10 @@ public class T3TerminalModule: Module { Prop("captureRequest") { (view: T3TerminalView, request: Double) in view.captureRequest = request } + + Prop("sharedGrid") { (view: T3TerminalView, sharedGrid: [Int]) in + view.sharedGrid = sharedGrid + } Events("onInput", "onResize", "onCapture") } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 4a69ff77f9fad..494c7a52c2ba1 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -257,6 +257,16 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + // The PTY grid shared by every attached client, or empty before the server reports one. + // The surface draws this grid so its wrapping and cursor match the PTY; the view's own + // fit still goes out through onResize. + var sharedGrid: [Int] = [] { + didSet { + guard oldValue != sharedGrid else { return } + resizeSurface() + } + } + var fontSize: CGFloat = 10 { didSet { guard oldValue != fontSize else { return } @@ -636,10 +646,25 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { terminalViewport.contentScaleFactor = scale ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) ghostty_surface_set_size(surface, width, height) + let fitted = ghostty_surface_size(surface) + let fittedCols = max(1, Int(fitted.columns)) + let fittedRows = max(1, Int(fitted.rows)) + // Trimming whole cells keeps ghostty's padding, and the surface can only shrink + // within the view, so a stale larger grid falls back to the fitted one. + var surfaceWidth = width + var surfaceHeight = height + if sharedGrid.count == 2, sharedGrid[0] > 0, sharedGrid[1] > 0 { + surfaceWidth -= UInt32(max(fittedCols - sharedGrid[0], 0)) * fitted.cell_width_px + surfaceHeight -= UInt32(max(fittedRows - sharedGrid[1], 0)) * fitted.cell_height_px + if surfaceWidth != width || surfaceHeight != height { + ghostty_surface_set_size(surface, surfaceWidth, surfaceHeight) + } + } ghostty_surface_set_occlusion(surface, window != nil) - configureIOSurfaceLayers() + configureIOSurfaceLayers( + size: CGSize(width: CGFloat(surfaceWidth) / scale, height: CGFloat(surfaceHeight) / scale)) redrawSurface() - emitGhosttyResize() + emitResize(cols: fittedCols, rows: fittedRows) } private func redrawSurface() { @@ -647,19 +672,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_refresh(surface) ghostty_surface_draw(surface) markIOSurfaceLayersForDisplay() - emitGhosttyResize() - } - - private func emitGhosttyResize() { - guard let surface else { - emitEstimatedResize() - return - } - - let size = ghostty_surface_size(surface) - let cols = max(1, Int(size.columns)) - let rows = max(1, Int(size.rows)) - emitResize(cols: cols, rows: rows) } private func emitEstimatedResize() { @@ -710,8 +722,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_app_keyboard_changed(app) } - private func configureIOSurfaceLayers() { - let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size) + private func configureIOSurfaceLayers(size: CGSize) { + let targetBounds = CGRect(origin: .zero, size: size) CATransaction.begin() CATransaction.setDisableActions(true) terminalViewport.layer.sublayers?.forEach { sublayer in diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index ea354ea14f9c0..54c622fc1db6e 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -1,4 +1,5 @@ -import { memo, useCallback, useEffect, useRef } from "react"; +import type { TerminalGrid } from "@t3tools/client-runtime/state/terminal"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { Pressable, ScrollView, @@ -38,6 +39,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly terminalKey: string; readonly buffer: string | null; readonly fontSize?: number; + readonly grid?: TerminalGrid | null; readonly isRunning: boolean; readonly readOnly?: boolean; readonly autoFocus?: boolean; @@ -196,6 +198,12 @@ const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerm const { onInput, onResize } = props; const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); const hasNativeSurface = Boolean(NativeTerminalSurfaceView); + const gridCols = props.grid?.cols; + const gridRows = props.grid?.rows; + const sharedGrid = useMemo( + () => (gridCols !== undefined && gridRows !== undefined ? [gridCols, gridRows] : []), + [gridCols, gridRows], + ); useEffect(() => { terminalDebugLog("native:surface", { @@ -242,6 +250,7 @@ const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerm terminalKey={props.terminalKey} initialBuffer={props.buffer} fontSize={fontSize} + sharedGrid={sharedGrid} style={{ flex: 1 }} themeConfig={buildGhosttyThemeConfig(theme)} onInput={handleNativeInput} diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 34a1ec4b4fbab..55b0b4d91b06d 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1249,6 +1249,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) readOnly={!canOperateTerminal} buffer={terminal.version === 0 ? null : terminalSurfaceBuffer} fontSize={fontSize} + grid={terminal.grid} isRunning={isRunning} keyboardFocusRequest={keyboardFocusRequest} captureRequest={captureRequest} diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index b7c8bba084cb4..130a9404f288a 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -35,6 +35,8 @@ export interface NativeTerminalSurfaceProps extends ViewProps { readonly terminalKey: string; readonly initialBuffer: string; readonly fontSize: number; + /** `[cols, rows]` of the shared PTY, or empty before the server reports one. */ + readonly sharedGrid?: ReadonlyArray; readonly onInput?: (event: NativeSyntheticEvent) => void; readonly onResize?: (event: NativeSyntheticEvent) => void; } diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index a75f6efccf050..9e68be700cb18 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -165,10 +165,16 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + /** + * Attached viewers share one PTY sized to the smallest of them, so a narrow client + * still sees the whole TUI. Streams and resizes with the same `viewerId` (one client + * connection) count as one viewer; streams without one are each their own viewer. + */ readonly attachStream: ( input: TerminalAttachInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, registerCleanup?: (stop: () => void) => void, + viewerId?: string, ) => Effect.Effect<() => void, TerminalError>; readonly observeStream: ( @@ -185,7 +191,10 @@ export class TerminalManager extends Context.Service< readonly write: (input: TerminalWriteInput) => Effect.Effect; - readonly resize: (input: TerminalResizeInput) => Effect.Effect; + readonly resize: ( + input: TerminalResizeInput, + viewerId?: string, + ) => Effect.Effect; readonly clear: (input: TerminalClearInput) => Effect.Effect; @@ -285,6 +294,17 @@ interface TerminalSessionState { runtimeEnv: Record | null; } +interface TerminalGrid { + readonly cols: number; + readonly rows: number; +} + +/** One client connection attached to a terminal, possibly through several streams. */ +interface TerminalViewer { + streams: number; + grid: TerminalGrid | null; +} + interface PersistHistoryRequest { history: BoundedTerminalHistory; immediate: boolean; @@ -366,6 +386,8 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { label: terminalWireLabel(session), updatedAt: session.updatedAt, sequence: session.eventSequence, + cols: session.cols, + rows: session.rows, }; } @@ -1656,6 +1678,102 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func effect: Effect.Effect, ): Effect.Effect => threadLocks.withLock(threadId, effect); + const terminalViewers = new Map>(); + let anonymousViewerCount = 0; + + const retainViewer = (sessionKey: string, viewerId: string, grid: TerminalGrid | null) => { + let viewers = terminalViewers.get(sessionKey); + if (!viewers) { + viewers = new Map(); + terminalViewers.set(sessionKey, viewers); + } + const viewer = viewers.get(viewerId); + if (viewer) { + viewer.streams += 1; + if (grid) viewer.grid = grid; + } else { + viewers.set(viewerId, { streams: 1, grid }); + } + }; + + /** Returns whether the viewer left, which can grow the shared grid. */ + const releaseViewer = (sessionKey: string, viewerId: string) => { + const viewers = terminalViewers.get(sessionKey); + const viewer = viewers?.get(viewerId); + if (!viewers || !viewer) return false; + viewer.streams -= 1; + if (viewer.streams > 0) return false; + viewers.delete(viewerId); + if (viewers.size === 0) terminalViewers.delete(sessionKey); + return true; + }; + + // The smallest attached viewer bounds each axis, so every viewer can draw the whole grid. + const sharedViewerGrid = (sessionKey: string): TerminalGrid | null => { + let shared: TerminalGrid | null = null; + for (const viewer of terminalViewers.get(sessionKey)?.values() ?? []) { + if (!viewer.grid) continue; + shared = shared + ? { + cols: Math.min(shared.cols, viewer.grid.cols), + rows: Math.min(shared.rows, viewer.grid.rows), + } + : viewer.grid; + } + return shared; + }; + + /** Attached viewers own the size; a caller's own size only applies when none has reported one. */ + const withSharedGrid = < + A extends { readonly cols?: number | undefined; readonly rows?: number | undefined }, + >( + sessionKey: string, + input: A, + ): A => { + const shared = sharedViewerGrid(sessionKey); + return shared ? { ...input, cols: shared.cols, rows: shared.rows } : input; + }; + + // Renderers draw the PTY grid, so every size change reaches them. It rides on activity + // because older clients reject unknown event types. + const resizeSession = Effect.fnUntraced(function* ( + session: TerminalSessionState, + process: PtyAdapter.PtyProcess, + cols: number, + rows: number, + ) { + yield* resizePtyProcess(session, process, cols, rows); + session.cols = cols; + session.rows = rows; + const eventStamp = advanceEventSequence(session); + yield* publishEvent({ + type: "activity", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: eventStamp.sequence, + hasRunningSubprocess: session.hasRunningSubprocess, + label: terminalWireLabel(session), + cols, + rows, + }); + }); + + const applySharedGrid = Effect.fnUntraced(function* (threadId: string, terminalId: string) { + const session = yield* getSession(threadId, terminalId); + if (Option.isNone(session)) return; + const process = session.value.process; + const shared = sharedViewerGrid(toSessionKey(threadId, terminalId)); + if ( + !process || + session.value.status !== "running" || + !shared || + (shared.cols === session.value.cols && shared.rows === session.value.rows) + ) { + return; + } + yield* resizeSession(session.value, process, shared.cols, shared.rows); + }); + const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( process: PtyAdapter.PtyProcess | null, ) { @@ -2715,12 +2833,13 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); const openWithWorkspaceLease = Effect.fn("terminal.openLocked")(function* ( - input: TerminalOpenInput, + requestedInput: TerminalOpenInput, ) { - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); + const terminalId = requestedInput.terminalId; + yield* assertValidCwd(requestedInput.cwd); - const sessionKey = toSessionKey(input.threadId, terminalId); + const sessionKey = toSessionKey(requestedInput.threadId, terminalId); + const input = withSharedGrid(sessionKey, requestedInput); const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { yield* flushPersist(input.threadId, terminalId); @@ -2841,10 +2960,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { - yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows); - liveSession.cols = targetCols; - liveSession.rows = targetRows; - liveSession.updatedAt = yield* nowIso; + yield* resizeSession(liveSession, liveSession.process, targetCols, targetRows); } return snapshot(liveSession); @@ -2862,8 +2978,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); - const openOrAttachForStream = Effect.fnUntraced(function* (input: TerminalAttachInput) { - const terminalId = input.terminalId; + const openOrAttachForStream = Effect.fnUntraced(function* (requestedInput: TerminalAttachInput) { + const terminalId = requestedInput.terminalId; + const input = withSharedGrid(toSessionKey(requestedInput.threadId, terminalId), requestedInput); const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { @@ -2903,11 +3020,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.status === "running" && (session.cols !== targetCols || session.rows !== targetRows) ) { - const process = session.process; - yield* resizePtyProcess(session, process, targetCols, targetRows); - session.cols = targetCols; - session.rows = targetRows; - session.updatedAt = yield* nowIso; + yield* resizeSession(session, session.process, targetCols, targetRows); } return snapshot(session); @@ -3034,7 +3147,47 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func input, listener, registerCleanup, - ) => streamSession(input, openOrAttachForStream(input), listener, registerCleanup); + viewerId, + ) => { + const sessionKey = toSessionKey(input.threadId, input.terminalId); + const viewer = viewerId ?? `stream:${++anonymousViewerCount}`; + let retained = false; + const detach = () => { + if (!retained) return; + retained = false; + if (releaseViewer(sessionKey, viewer)) { + runFork(withThreadLock(input.threadId, applySharedGrid(input.threadId, input.terminalId))); + } + }; + const initial = Effect.suspend(() => { + retained = true; + retainViewer( + sessionKey, + viewer, + input.cols !== undefined && input.rows !== undefined + ? { cols: input.cols, rows: input.rows } + : null, + ); + return openOrAttachForStream(input); + }); + return streamSession( + input, + initial, + listener, + registerCleanup && + ((stop) => + registerCleanup(() => { + stop(); + detach(); + })), + ).pipe( + Effect.map((stop) => () => { + stop(); + detach(); + }), + Effect.onError(() => Effect.sync(detach)), + ); + }; const observeStream: TerminalManager["Service"]["observeStream"] = ( input, @@ -3168,23 +3321,35 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); }); - const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { + const resizeLocked = Effect.fn("terminal.resize")(function* ( + input: TerminalResizeInput, + viewerId: string | undefined, + ) { + const sessionKey = toSessionKey(input.threadId, input.terminalId); + const viewer = viewerId === undefined ? undefined : terminalViewers.get(sessionKey)?.get(viewerId); + if (viewer) { + viewer.grid = { cols: input.cols, rows: input.rows }; + return yield* applySharedGrid(input.threadId, input.terminalId); + } + // A caller that is not attached cannot override the viewers that are. + if (sharedViewerGrid(sessionKey)) return; const session = yield* getSession(input.threadId, input.terminalId); if (Option.isNone(session)) { return; } const process = session.value.process; - if (!process || session.value.status !== "running") { + if ( + !process || + session.value.status !== "running" || + (session.value.cols === input.cols && session.value.rows === input.rows) + ) { return; } - yield* resizePtyProcess(session.value, process, input.cols, input.rows); - session.value.cols = input.cols; - session.value.rows = input.rows; - session.value.updatedAt = yield* nowIso; + yield* resizeSession(session.value, process, input.cols, input.rows); }); - const resize: TerminalManager["Service"]["resize"] = (input) => - withThreadLock(input.threadId, resizeLocked(input)); + const resize: TerminalManager["Service"]["resize"] = (input, viewerId) => + withThreadLock(input.threadId, resizeLocked(input, viewerId)); const clear: TerminalManager["Service"]["clear"] = (input) => withThreadLock( @@ -3209,13 +3374,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); const restartResolved = Effect.fn("Manager.restartResolved")(function* ( - input: TerminalRestartInput, + requestedInput: TerminalRestartInput, ) { yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); + const terminalId = requestedInput.terminalId; + yield* assertValidCwd(requestedInput.cwd); - const sessionKey = toSessionKey(input.threadId, terminalId); + const sessionKey = toSessionKey(requestedInput.threadId, terminalId); + const input = withSharedGrid(sessionKey, requestedInput); const existingSession = yield* getSession(input.threadId, terminalId); let session: TerminalSessionState; if (Option.isNone(existingSession)) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1ba4ff99ff644..e23948385d3e6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1232,6 +1232,10 @@ export const layerWsRpc = ( yield* Effect.context>>(); const orchestrationEngine = yield* Orchestrator.OrchestratorV2; const crypto = yield* Crypto.Crypto; + // RPC client ids are only unique within one socket; terminals share viewers across sockets. + const terminalViewerPrefix = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const terminalViewerId = (metadata: { readonly client: { readonly id: number } }) => + `${terminalViewerPrefix}:${metadata.client.id}`; const serverCommandId = (tag: string) => crypto.randomUUIDv4().pipe( Effect.orDie, @@ -2871,15 +2875,21 @@ export const layerWsRpc = ( [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), - [WS_METHODS.terminalAttach]: (input) => + [WS_METHODS.terminalAttach]: (input, metadata) => terminalOutputStream( (listener, registerCleanup) => - terminalManager.attachStream(input, listener, registerCleanup), + terminalManager.attachStream( + input, + listener, + registerCleanup, + terminalViewerId(metadata), + ), (listener, registerCleanup) => terminalManager.resyncStream(input, listener, registerCleanup), ), [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), - [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), + [WS_METHODS.terminalResize]: (input, metadata) => + terminalManager.resize(input, terminalViewerId(metadata)), [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input), [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 0a12a6535c2d5..6fea8b887e196 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -555,6 +555,8 @@ function TerminalSessionViewport({ }); }); const terminalOutput = terminalSession.output; + const terminalGrid = terminalSession.grid; + const readTerminalGrid = useEffectEvent(() => terminalGrid); const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); @@ -615,6 +617,10 @@ function TerminalSessionViewport({ terminalRef.current?.resendSize(); }, [resizeSessionGeneration]); + useLayoutEffect(() => { + terminalRef.current?.setSharedGrid(terminalGrid); + }, [terminalGrid]); + useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); @@ -667,6 +673,7 @@ function TerminalSessionViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + terminal.setSharedGrid(readTerminalGrid()); terminal.input.readOnly = !hasTerminalWriteAccess(); // Client settings hydrate asynchronously; a font preference that landed // while the surface was loading found terminalRef null, so its setFont diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index d0b2641170a7b..41cc34a8da4ae 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -516,10 +516,14 @@ export class GhosttyTerminalSurface { readonly canvas: HTMLCanvasElement; readonly input: HTMLTextAreaElement; readonly scrollbar: HTMLDivElement; + /** The drawn grid: the shared PTY grid when known, otherwise the measured one. */ cols = 1; rows = 1; private readonly mount: HTMLElement; + private measuredCols = 1; + private measuredRows = 1; + private sharedGrid: { readonly cols: number; readonly rows: number } | null = null; private readonly context: CanvasRenderingContext2D; private readonly core: GhosttyTerminalCore; private readonly options: GhosttyTerminalSurfaceOptions; @@ -868,15 +872,16 @@ export class GhosttyTerminalSurface { this.mountHeight = height; // onResize is the only PTY resize channel, so the first successful fit must // notify even when the measured grid equals the 1x1 construction sentinel. - if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { - this.cols = grid.cols; - this.rows = grid.rows; - this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); + if ( + grid.cols !== this.measuredCols || + grid.rows !== this.measuredRows || + !this.resizeNotified + ) { + this.measuredCols = grid.cols; + this.measuredRows = grid.rows; this.notifyResize(); - this.forceFullRender = true; - this.scrollbarDirty = true; - shouldRender = true; } + if (this.applyGrid()) shouldRender = true; // Rendering synchronously keeps the repaint inside the same frame as the // layout change: ResizeObserver fires before paint, so the browser never // composites the old backing store stretched into the new element box. @@ -884,6 +889,30 @@ export class GhosttyTerminalSurface { return true; } + /** + * Other clients can share this PTY, so the server owns its grid. Drawing that grid + * keeps wrapping and cursor positions in step with the PTY, and the server keeps it + * no larger than this surface's own fit. + */ + setSharedGrid(grid: { readonly cols: number; readonly rows: number } | null): void { + if (this.disposed) return; + if (this.sharedGrid?.cols === grid?.cols && this.sharedGrid?.rows === grid?.rows) return; + this.sharedGrid = grid; + if (this.hasSize && this.applyGrid()) this.requestRender(); + } + + private applyGrid(): boolean { + const cols = this.sharedGrid?.cols ?? this.measuredCols; + const rows = this.sharedGrid?.rows ?? this.measuredRows; + if (cols === this.cols && rows === this.rows) return false; + this.cols = cols; + this.rows = rows; + this.core.resize(cols, rows, this.metrics.width, this.metrics.height); + this.forceFullRender = true; + this.scrollbarDirty = true; + return true; + } + /** * The local grid reflows immediately, but the PTY only hears about settled * dimensions: notifying on every drag step makes the shell reprint its @@ -894,7 +923,7 @@ export class GhosttyTerminalSurface { if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = window.setTimeout(() => { this.resizeNotifyTimer = null; - if (!this.disposed) this.options.onResize(this.cols, this.rows); + if (!this.disposed) this.options.onResize(this.measuredCols, this.measuredRows); }, 150); } @@ -909,7 +938,7 @@ export class GhosttyTerminalSurface { if (this.resizeNotifyTimer === null) return; window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = null; - this.options.onResize(this.cols, this.rows); + this.options.onResize(this.measuredCols, this.measuredRows); }; focus(): void { @@ -1037,7 +1066,7 @@ export class GhosttyTerminalSurface { if (this.resizeNotifyTimer !== null) { window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = null; - this.options.onResize(this.cols, this.rows); + this.options.onResize(this.measuredCols, this.measuredRows); } this.cancelRender(); if (this.compositionSuppressionTimer !== null) { diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index a7c7559af5695..a04504e00ee3b 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -67,6 +67,30 @@ describe("terminal session reducers", () => { }); }); + it("follows the shared PTY grid from snapshots and activity without disturbing output", () => { + const attached = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, cols: 130, rows: 15 }, + }); + const activity = { + type: "activity", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + hasRunningSubprocess: false, + label: "Terminal 1", + } as const; + const narrowed = applyTerminalAttachStreamEvent(attached, { ...activity, cols: 54, rows: 15 }); + // Activity from a server without geometry, or a repeat, must not reset the renderer. + const unchanged = applyTerminalAttachStreamEvent( + applyTerminalAttachStreamEvent(narrowed, activity), + { ...activity, cols: 54, rows: 15 }, + ); + + expect(attached.grid).toEqual({ cols: 130, rows: 15 }); + expect(narrowed).toEqual({ ...attached, grid: { cols: 54, rows: 15 } }); + expect(unchanged).toBe(narrowed); + }); + it("preserves a BOM code point at a new output chunk boundary", () => { const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index eea5514f567d3..f67184ef710b6 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -24,9 +24,17 @@ export { } from "./terminalOutput.ts"; export { DEFAULT_MAX_TERMINAL_BUFFER_BYTES } from "@t3tools/contracts"; +/** The PTY grid every renderer of a shared terminal draws. */ +export interface TerminalGrid { + readonly cols: number; + readonly rows: number; +} + export interface TerminalSessionState { readonly summary: TerminalSummary | null; readonly output: TerminalOutputState; + /** Null when the server predates shared terminal geometry. */ + readonly grid: TerminalGrid | null; readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly hasRunningSubprocess: boolean; @@ -37,6 +45,7 @@ export interface TerminalSessionState { export interface TerminalBufferState { readonly output: TerminalOutputState; + readonly grid: TerminalGrid | null; readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly updatedAt: string | null; @@ -65,6 +74,7 @@ export function selectRunningSubprocessTerminalIds( export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ output: EMPTY_TERMINAL_OUTPUT_STATE, + grid: null, status: "closed", error: null, updatedAt: null, @@ -75,6 +85,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ summary: null, output: EMPTY_TERMINAL_OUTPUT_STATE, + grid: null, status: "closed", error: null, hasRunningSubprocess: false, @@ -103,6 +114,7 @@ function terminalBufferStateFromSnapshot( ): TerminalBufferState { return { output: resetOutput(current.output, snapshot.history, maxBufferBytes), + grid: nextGrid(current.grid, snapshot), status: snapshot.status, error: null, updatedAt: snapshot.updatedAt, @@ -111,6 +123,16 @@ function terminalBufferStateFromSnapshot( }; } +function nextGrid( + current: TerminalGrid | null, + source: { readonly cols?: number | undefined; readonly rows?: number | undefined }, +): TerminalGrid | null { + if (source.cols === undefined || source.rows === undefined) return current; + return current?.cols === source.cols && current.rows === source.rows + ? current + : { cols: source.cols, rows: source.rows }; +} + function latestTimestamp(left: string | null, right: string | null): string | null { if (left === null) return right; if (right === null) return left; @@ -124,6 +146,7 @@ export function combineTerminalSessionState( return { summary, output: buffer.output, + grid: buffer.grid, status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), error: buffer.error, hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, @@ -186,8 +209,10 @@ export function applyTerminalAttachStreamEvent( error: event.message, version: current.version + 1, }; - case "activity": - return current; + case "activity": { + const grid = nextGrid(current.grid, event); + return grid === current.grid ? current : { ...current, grid }; + } } } diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index c00b9300b355f..d918569f7f5f9 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -111,6 +111,9 @@ export const TerminalSessionSnapshot = Schema.Struct({ label: Schema.String.check(Schema.isMaxLength(128)), updatedAt: Schema.String, sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + // The PTY grid every renderer of this terminal should draw; absent from older servers. + cols: Schema.optional(TerminalColsSchema), + rows: Schema.optional(TerminalRowsSchema), }); export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type; @@ -204,6 +207,9 @@ const TerminalActivityEvent = Schema.Struct({ type: Schema.Literal("activity"), hasRunningSubprocess: Schema.Boolean, label: Schema.String.check(Schema.isMaxLength(128)), + // Shared PTY grid changes ride on activity because older clients reject unknown event types. + cols: Schema.optional(TerminalColsSchema), + rows: Schema.optional(TerminalRowsSchema), }); export const TerminalEvent = Schema.Union([ From 329dd82a48cc555bcaeda5d889ac5d460dedbfa0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 02:50:38 +0000 Subject: [PATCH 03/16] test(server): the narrowest viewer detaching gives the PTY back Co-Authored-By: Claude Opus 5.5 --- apps/server/src/terminal/Manager.test.ts | 41 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 4 ++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 9704d5c4257d3..eb48fc5abcd7f 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -500,6 +500,47 @@ it.layer( }), ); + it.effect("gives the PTY back to the remaining viewer when the narrowest one detaches", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const restored = yield* Deferred.make(); + const stopWide = yield* manager.attachStream( + openInput({ cols: 130, rows: 15 }), + (event) => + event.type === "activity" && event.cols === 130 && event.rows === 15 + ? Deferred.succeed(restored, undefined).pipe(Effect.asVoid) + : Effect.void, + undefined, + "wide", + ); + yield* Effect.addFinalizer(() => Effect.sync(stopWide)); + const stopNarrow = yield* manager.attachStream( + openInput({ cols: 54, rows: 15 }), + () => Effect.void, + undefined, + "narrow", + ); + const process = ptyAdapter.processes[0]!; + + yield* manager.resize( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 80, rows: 12 }, + "narrow", + ); + // A resize from a client with no attached stream cannot override attached viewers. + yield* manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 200, + rows: 50, + }); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 80, rows: 12 }); + + stopNarrow(); + yield* Deferred.await(restored); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 130, rows: 15 }); + }), + ); + it.effect("resumed attaches after close never respawn until the terminal is reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9e68be700cb18..bab317d07ab4c 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1745,6 +1745,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* resizePtyProcess(session, process, cols, rows); session.cols = cols; session.rows = rows; + session.updatedAt = yield* nowIso; const eventStamp = advanceEventSequence(session); yield* publishEvent({ type: "activity", @@ -3326,7 +3327,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func viewerId: string | undefined, ) { const sessionKey = toSessionKey(input.threadId, input.terminalId); - const viewer = viewerId === undefined ? undefined : terminalViewers.get(sessionKey)?.get(viewerId); + const viewer = + viewerId === undefined ? undefined : terminalViewers.get(sessionKey)?.get(viewerId); if (viewer) { viewer.grid = { cols: input.cols, rows: input.rows }; return yield* applySharedGrid(input.threadId, input.terminalId); From d63c54af2d1c266bd9758f186819680560f85e0c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 03:04:14 +0000 Subject: [PATCH 04/16] test: add portable shared terminal evidence fixtures --- .../issue-14463/native-fixture.tsx.template | 57 ++++++++++++++++++ .../fixtures/issue-14463/prepare-native.py | 25 ++++++++ scripts/fixtures/issue-14463/seed-web.py | 28 +++++++++ .../fixtures/issue-14463/shared-terminal.py | 60 +++++++++++++++++++ scripts/fixtures/issue-14463/web-seed.json | 9 +++ 5 files changed, 179 insertions(+) create mode 100644 scripts/fixtures/issue-14463/native-fixture.tsx.template create mode 100644 scripts/fixtures/issue-14463/prepare-native.py create mode 100644 scripts/fixtures/issue-14463/seed-web.py create mode 100644 scripts/fixtures/issue-14463/shared-terminal.py create mode 100644 scripts/fixtures/issue-14463/web-seed.json diff --git a/scripts/fixtures/issue-14463/native-fixture.tsx.template b/scripts/fixtures/issue-14463/native-fixture.tsx.template new file mode 100644 index 0000000000000..323e71965e650 --- /dev/null +++ b/scripts/fixtures/issue-14463/native-fixture.tsx.template @@ -0,0 +1,57 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text, Pressable, ScrollView, Keyboard, KeyboardAvoidingView, StatusBar } from 'react-native'; +import { registerRootComponent } from 'expo'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { RegistryContext } from '@effect/atom-react'; +import { AppearancePreferencesProvider, useAppearancePreferences } from './src/features/settings/appearance/AppearancePreferencesProvider'; +import { appAtomRegistry } from './src/state/atom-registry'; +import { TerminalSurface } from './src/features/terminal/NativeTerminalSurface'; +import { getMobileTerminalTheme } from './src/features/terminal/terminalTheme'; +import './global.css'; +const Stack = createNativeStackNavigator(); +const steps = ['null','equal','small','stale','width','height','normal','grow']; +function ansi(cols: number, rows: number, normal: boolean) { + if (normal) return '\x1b[?1049l\x1b[0m\x1b[2J\x1b[H' + Array.from({length:60},(_,i)=>`ROW ${String(i+1).padStart(3,'0')} | ${'0123456789'.repeat(12)}\r\n`).join('') + 'CURSOR> \x1b[?25h\x1b[?12l'; + const width = Math.max(4,cols), height=Math.max(7,rows); + let out='\x1b[?1049h\x1b[0m\x1b[2J\x1b[H'; + for(let row=0;rowsetFit(p=>p.cols===size.cols&&p.rows===size.rows?p:size),[]); + const small={cols:Math.max(20,fit.cols-10),rows:Math.max(7,fit.rows-8)}; + const grid=step==='null'?null:step==='small'||step==='normal'?small:step==='stale'?{cols:fit.cols+20,rows:fit.rows+20}:step==='width'?{cols:small.cols,rows:fit.rows+20}:step==='height'?{cols:fit.cols+20,rows:small.rows}:fit; + const drawn=grid?{cols:Math.min(grid.cols,fit.cols),rows:Math.min(grid.rows,fit.rows)}:fit; + const button=(title:string,onPress:()=>void)=>{title}; + return + + Static fixture; limited integration | __PRODUCT_SHA__ + {step} | fitted {fit.cols}x{fit.rows} shared {grid?`${grid.cols}x${grid.rows}`:'null'} | {font}pt | {readOnly?'read-only':'editable'} + {steps.map(s=>button(s,()=>{setStep(s);setInput('');setCaptured('')}))} + setInput(p=>p+data)}/> + + {button('Theme',()=>setThemeMode(themeAppearance==='light'?'dark':'light'))} + {button('Font',()=>setFont(f=>f===13?18:13))} + {button('Keyboard',()=>setFocus(f=>f+1))} + {button('Dismiss',()=>Keyboard.dismiss())} + {button('Read only',()=>setReadOnly(r=>!r))} + {button('Capture output',()=>setCapture(c=>c+1))} + + {captured?{captured}:null} + ; +} +function App(){return } +registerRootComponent(App); diff --git a/scripts/fixtures/issue-14463/prepare-native.py b/scripts/fixtures/issue-14463/prepare-native.py new file mode 100644 index 0000000000000..c595516cc6ecb --- /dev/null +++ b/scripts/fixtures/issue-14463/prepare-native.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Apply ONLY to a disposable checkout. Temporary index/profile, real native module.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +p=argparse.ArgumentParser() +p.add_argument('checkout',type=Path) +a=p.parse_args() +root=a.checkout.resolve() +if root == Path(__file__).resolve().parents[3]: + raise SystemExit('Use a separate disposable checkout, not fixture source checkout') +sha=subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip() +fixture=Path(__file__).with_name('native-fixture.tsx.template').read_text() +(root/'apps/mobile/index.ts').write_text(fixture.replace('__PRODUCT_SHA__',sha[:12])) +profile=root/'apps/mobile/eas.json' +data=json.loads(profile.read_text()) +data['build']['issue14463-static']={'corepack':True,'distribution':'internal','environment':'preview','env':{'APP_VARIANT':'preview','NODE_OPTIONS':'--max-old-space-size=8192'},'ios':{'simulator':True,'image':'macos-tahoe-26.6-xcode-27.1'},'developmentClient':False} +profile.write_text(json.dumps(data,indent=2)+'\n') +# Include ignored native vendored frameworks when EAS packs the disposable checkout. +ignore=root/'.easignore' +text=(root/'.gitignore').read_text() +ignore.write_text(text+'\n!apps/mobile/modules/t3-terminal/Vendor/\n!apps/mobile/modules/t3-terminal/Vendor/**\n.env*\n**/.env*\n') +print(json.dumps({'productSha':sha,'templateSha256':hashlib.sha256(fixture.encode()).hexdigest(),'profile':'issue14463-static','limitedIntegration':'server arbitration, WS delivery, connection permissions/navigation not exercised'})) diff --git a/scripts/fixtures/issue-14463/seed-web.py b/scripts/fixtures/issue-14463/seed-web.py new file mode 100644 index 0000000000000..031ac570c605b --- /dev/null +++ b/scripts/fixtures/issue-14463/seed-web.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Seed stopped, isolated test homes; never touches live T3 home.""" +import argparse +import json +from pathlib import Path +import socket +import sqlite3 +p=argparse.ArgumentParser() +p.add_argument('home',type=Path) +p.add_argument('--server-port',type=int,required=True) +p.add_argument('--workspace',type=Path,required=True) +a=p.parse_args() +home=a.home.resolve() +if str(home).startswith(str(Path.home()/'.t3')): + raise SystemExit('Refusing live T3 home') +with socket.socket() as s: + if s.connect_ex(('127.0.0.1',a.server_port))==0: + raise SystemExit('Stop target server before seeding') +a.workspace.mkdir(parents=True,exist_ok=True) +(a.workspace/'fixture.txt').write_text('Shared terminal geometry fixture\n') +c=sqlite3.connect(home/'userdata/statev2.sqlite') +fixture=json.loads(Path(__file__).with_name('web-seed.json').read_text()) +with c: + c.execute('INSERT OR REPLACE INTO projection_projects (project_id,title,workspace_root,scripts_json,created_at,updated_at,auto_pull) VALUES (?,?,?,?,?,?,0)',(fixture['projectId'],fixture['title'],str(a.workspace.resolve()),'[]',fixture['clock'],fixture['clock'])) + for t in fixture['threads']: + payload={**t,'projectId':fixture['projectId'],'providerInstanceId':'codex','modelSelection':{'instanceId':'codex','model':'gpt-6-astra'},'runtimeMode':'full-access','interactionMode':'default','branch':None,'worktreePath':None,'pullRequests':[],'activeProviderThreadId':None,'lineage':{'parentThreadId':None,'relationshipToParent':None,'rootThreadId':t['id']},'forkedFrom':None,'createdBy':'system','creationSource':'server','createdAt':fixture['clock'],'updatedAt':fixture['clock'],'archivedAt':None,'settledOverride':None,'settledAt':None,'snoozedUntil':None,'snoozedAt':None,'lastVisitedAt':fixture['clock'],'deletedAt':None} + c.execute('INSERT OR REPLACE INTO orchestration_v2_projection_threads (thread_id,project_id,title,default_provider,runtime_mode,interaction_mode,active_provider_thread_id,created_at,updated_at,archived_at,deleted_at,payload_json,provider_instance_id) VALUES (?,?,?,?,?,?,NULL,?,?,NULL,NULL,?,?)',(t['id'],fixture['projectId'],t['title'],'codex','full-access','default',fixture['clock'],fixture['clock'],json.dumps(payload),'codex')) +print(json.dumps({'home':str(home),'projectId':fixture['projectId'],'threadIds':[t['id'] for t in fixture['threads']],'workspace':str(a.workspace.resolve())})) diff --git a/scripts/fixtures/issue-14463/shared-terminal.py b/scripts/fixtures/issue-14463/shared-terminal.py new file mode 100644 index 0000000000000..a81181060671d --- /dev/null +++ b/scripts/fixtures/issue-14463/shared-terminal.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Deterministic real PTY fixture: --mode tui|normal|burst; q exits TUI.""" +import argparse +import curses +import os +import signal +import sys + +parser = argparse.ArgumentParser() +parser.add_argument('--mode', choices=['tui', 'normal', 'burst'], default='tui') +parser.add_argument('--label', default='Shared terminal') +args = parser.parse_args() + + +def normal(): + sys.stdout.write('\x1b[?1049l\x1b[0m\x1b[2J\x1b[H\x1b[?25h\x1b[?12l') + for row in range(1, 61): + print(f'ROW {row:03d} | ' + '0123456789' * 12, flush=True) + sys.stdout.write('CURSOR> \x1b[?25h\x1b[?12l') + sys.stdout.flush() + for line in sys.stdin: + if line.strip() == 'q': + break + sys.stdout.write('INPUT ' + line + 'CURSOR> ') + sys.stdout.flush() + + +def tui(screen): + curses.start_color() + curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) + screen.bkgd(' ', curses.color_pair(1)) + curses.curs_set(1) + while True: + rows, cols = screen.getmaxyx() + screen.erase() + if rows > 2 and cols > 2: + screen.border('|', '|', '-', '-', '+', '+', '+', '+') + lines = [f'{args.label} {cols} columns x {rows} rows', + 'Every border must be visible', 'Resize either client; q exits', + '0123456789' * 40] + for row, line in enumerate(lines, 1): + if row < rows - 1: + screen.addnstr(row, 1, line, cols - 2) + if rows > 6: + screen.addnstr(rows - 2, 1, 'BOTTOM EDGE', cols - 2) + screen.move(min(5, rows - 2), min(3, cols - 2)) + screen.refresh() + if screen.getch() in (ord('q'), 3): + break + + +if args.mode == 'normal': + normal() +elif args.mode == 'burst': + for row in range(20000): + print(f'BURST {row:05d} ' + '0123456789' * 20) + sys.stdout.flush() + curses.wrapper(tui) +else: + curses.wrapper(tui) diff --git a/scripts/fixtures/issue-14463/web-seed.json b/scripts/fixtures/issue-14463/web-seed.json new file mode 100644 index 0000000000000..db64980db6344 --- /dev/null +++ b/scripts/fixtures/issue-14463/web-seed.json @@ -0,0 +1,9 @@ +{ + "clock": "2026-10-20T12:00:00.000Z", + "projectId": "14463000-0000-4000-8000-000000000001", + "title": "Shared terminal proof", + "threads": [ + { "id": "14463000-0000-4000-8000-000000000002", "title": "Shared terminal geometry" }, + { "id": "14463000-0000-4000-8000-000000000003", "title": "Lifecycle control" } + ] +} From 438e67d814a909c0540f68f068d3fcba36e799e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 03:07:31 +0000 Subject: [PATCH 05/16] test: use fixed RGB colors in terminal fixture --- .../fixtures/issue-14463/shared-terminal.py | 65 ++++++++++++------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/scripts/fixtures/issue-14463/shared-terminal.py b/scripts/fixtures/issue-14463/shared-terminal.py index a81181060671d..beecabd23c7f3 100644 --- a/scripts/fixtures/issue-14463/shared-terminal.py +++ b/scripts/fixtures/issue-14463/shared-terminal.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Deterministic real PTY fixture: --mode tui|normal|burst; q exits TUI.""" import argparse -import curses +import select +import termios +import tty import os import signal import sys @@ -25,28 +27,41 @@ def normal(): sys.stdout.flush() -def tui(screen): - curses.start_color() - curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) - screen.bkgd(' ', curses.color_pair(1)) - curses.curs_set(1) - while True: - rows, cols = screen.getmaxyx() - screen.erase() - if rows > 2 and cols > 2: - screen.border('|', '|', '-', '-', '+', '+', '+', '+') - lines = [f'{args.label} {cols} columns x {rows} rows', - 'Every border must be visible', 'Resize either client; q exits', - '0123456789' * 40] - for row, line in enumerate(lines, 1): - if row < rows - 1: - screen.addnstr(row, 1, line, cols - 2) - if rows > 6: - screen.addnstr(rows - 2, 1, 'BOTTOM EDGE', cols - 2) - screen.move(min(5, rows - 2), min(3, cols - 2)) - screen.refresh() - if screen.getch() in (ord('q'), 3): - break +def tui(): + old = termios.tcgetattr(sys.stdin) + pending = True + def redraw(*_): + nonlocal pending + pending = True + signal.signal(signal.SIGWINCH, redraw) + try: + tty.setraw(sys.stdin) + sys.stdout.write('\x1b[?1049h\x1b[?12l') + while True: + if pending: + pending = False + cols, rows = os.get_terminal_size() + out = '\x1b[38;2;255;255;255m\x1b[48;2;0;45;100m\x1b[2J\x1b[H' + lines = [f'{args.label} {cols} columns x {rows} rows', + 'Every border must be visible', 'Resize either client; q exits', + '0123456789' * 40] + for row in range(rows): + edge = row == 0 or row == rows - 1 + content = '-' * max(0, cols - 2) if edge else ( + 'BOTTOM EDGE' if row == rows - 2 else + lines[row - 1] if 1 <= row <= len(lines) else '') + content = content[:max(0, cols-2)].ljust(max(0, cols-2)) + out += f'\x1b[{row+1};1H' + ('+' if edge else '|') + content + ('+' if edge else '|') + out += f'\x1b[{min(6,rows-1)};{min(4,cols-1)}H\x1b[?25h' + sys.stdout.write(out) + sys.stdout.flush() + if select.select([sys.stdin], [], [], 0.1)[0]: + if os.read(sys.stdin.fileno(), 1) in (b'q', b'\x03'): + break + finally: + sys.stdout.write('\x1b[0m\x1b[?1049l\x1b[?25h') + sys.stdout.flush() + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old) if args.mode == 'normal': @@ -55,6 +70,6 @@ def tui(screen): for row in range(20000): print(f'BURST {row:05d} ' + '0123456789' * 20) sys.stdout.flush() - curses.wrapper(tui) + tui() else: - curses.wrapper(tui) + tui() From a666d58ff7fc3554da3c99b1e67ec5c5e7e125c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 03:09:07 +0000 Subject: [PATCH 06/16] test: add reproducible unmasked evidence pixel diffs --- scripts/fixtures/issue-14463/pixel-diff.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 scripts/fixtures/issue-14463/pixel-diff.py diff --git a/scripts/fixtures/issue-14463/pixel-diff.py b/scripts/fixtures/issue-14463/pixel-diff.py new file mode 100644 index 0000000000000..9cdbe7b6a3a9f --- /dev/null +++ b/scripts/fixtures/issue-14463/pixel-diff.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Unregistered whole-image RGBA diffs. Requires Pillow and numpy.""" +import argparse +import json +from pathlib import Path +from PIL import Image +import numpy as np +p=argparse.ArgumentParser() +p.add_argument('before',type=Path);p.add_argument('after',type=Path);p.add_argument('prefix',type=Path) +a=p.parse_args() +b=Image.open(a.before).convert('RGBA');c=Image.open(a.after).convert('RGBA') +if b.size!=c.size: raise SystemExit(f'Dimensions differ: {b.size} vs {c.size}') +d=np.abs(np.asarray(b).astype(np.int16)-np.asarray(c).astype(np.int16)) +m=d.max(axis=2);changed=m>0;threshold=m>16 +zero=np.zeros((*m.shape,3),dtype=np.uint8) +zero[changed]=np.maximum(64,np.minimum(255,d[...,:3][changed]*16)).astype(np.uint8) +# Alpha-only differences are visible too, as magenta. +zero[(d[...,:3].max(axis=2)==0)&changed]=[255,0,255] +th=np.zeros_like(zero);th[threshold]=[255,32,128] +Image.fromarray(zero).save(str(a.prefix)+'-diff0.png') +Image.fromarray(th).save(str(a.prefix)+'-diffT.png') +result={'before':str(a.before),'after':str(a.after),'dimensions':b.size,'pixels':int(m.size),'changed0':int(changed.sum()),'changedT':int(threshold.sum()),'maxDelta':int(m.max()),'threshold':'max absolute RGBA channel delta >16','amplification':'absolute RGB channel delta x16; nonzero pixels min64; alpha-only magenta','registration':'none; no masks; complete image','colorspace':'8-bit decoded PNG RGBA, no color conversion beyond RGBA decode'} +Path(str(a.prefix)+'-stats.json').write_text(json.dumps(result,indent=2)+'\n') +print(json.dumps(result)) From 75d234b6c1511c3fd2e1dbe95f846393d364c907 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 03:10:50 +0000 Subject: [PATCH 07/16] test: keep native fixture JSX in a TSX module --- scripts/fixtures/issue-14463/prepare-native.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/fixtures/issue-14463/prepare-native.py b/scripts/fixtures/issue-14463/prepare-native.py index c595516cc6ecb..173ce9ac5c4aa 100644 --- a/scripts/fixtures/issue-14463/prepare-native.py +++ b/scripts/fixtures/issue-14463/prepare-native.py @@ -13,7 +13,8 @@ raise SystemExit('Use a separate disposable checkout, not fixture source checkout') sha=subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip() fixture=Path(__file__).with_name('native-fixture.tsx.template').read_text() -(root/'apps/mobile/index.ts').write_text(fixture.replace('__PRODUCT_SHA__',sha[:12])) +(root/'apps/mobile/issue14463-static.tsx').write_text(fixture.replace('__PRODUCT_SHA__',sha[:12])) +(root/'apps/mobile/index.ts').write_text("import './issue14463-static';\n") profile=root/'apps/mobile/eas.json' data=json.loads(profile.read_text()) data['build']['issue14463-static']={'corepack':True,'distribution':'internal','environment':'preview','env':{'APP_VARIANT':'preview','NODE_OPTIONS':'--max-old-space-size=8192'},'ios':{'simulator':True,'image':'macos-tahoe-26.6-xcode-27.1'},'developmentClient':False} From 65a76791c2341cd36da89d60411a51fc460afde0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 03:11:26 +0000 Subject: [PATCH 08/16] fix(terminal): a viewer keeps its PTY size through output recovery terminal.attach overflow recovery swapped the inner subscription, which released the viewer, so a stalled narrow client lost its constraint and its resizes were ignored. The outer attach stream now owns the viewer (TerminalManager.attachOutputStream) until the whole request ends. Web and Android also clamp the drawn shared grid to their measured fit per axis, like iOS, so a lagging or observer-only shared grid never clips. Co-Authored-By: Claude Opus 5.5 --- .../expo/modules/t3terminal/T3TerminalView.kt | 9 +-- apps/server/src/terminal/Manager.test.ts | 47 +++++++++++++++ apps/server/src/terminal/Manager.ts | 58 +++++++++++++++---- apps/server/src/ws.ts | 12 +--- apps/web/src/terminal/ghostty/surface.ts | 11 ++-- 5 files changed, 106 insertions(+), 31 deletions(-) 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 bd1b6a724b680..99595808c356a 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 @@ -93,8 +93,9 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex var appearanceScheme: String = "dark" // The PTY grid shared by every attached client, or empty before the server reports one. - // The terminal draws this grid so its wrapping and cursor match the PTY; the view's own - // fit still goes out through onResize. + // The terminal draws this grid, clamped to the view's own fit (the server's grid lags a + // local resize, and read-only observers never constrain it), so its wrapping and cursor + // match the PTY; the view's own fit still goes out through onResize. var sharedGrid: List = emptyList() set(value) { if (field == value) return @@ -350,8 +351,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private fun applyGrid() { if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 } - val nextCols = shared?.get(0) ?: measuredCols - val nextRows = shared?.get(1) ?: measuredRows + val nextCols = shared?.get(0)?.coerceAtMost(measuredCols) ?: measuredCols + val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return cols = nextCols rows = nextRows diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index b1e09870bccc2..22a82f838d848 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1653,6 +1653,53 @@ it.layer( }).pipe(Effect.scoped), ); + it.effect.each([ + { recovered: true, label: "after recovery" }, + { recovered: false, label: "when cancelled before recovery" }, + ])("keeps a stalled viewer's size until its attach stream ends ($label)", ({ recovered }) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const restored = yield* Deferred.make(); + const stopWide = yield* manager.attachStream( + openInput({ cols: 130, rows: 15 }), + (event) => + event.type === "activity" && event.cols === 130 && event.rows === 15 + ? Deferred.succeed(restored, undefined).pipe(Effect.asVoid) + : Effect.void, + undefined, + "wide", + ); + yield* Effect.addFinalizer(() => Effect.sync(stopWide)); + const process = ptyAdapter.processes[0]!; + + yield* Effect.gen(function* () { + const pull = yield* Stream.toPull( + manager.attachOutputStream(openInput({ cols: 54, rows: 15 }), "narrow"), + ); + expect(yield* pull).toMatchObject([{ type: "snapshot" }]); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 54, rows: 15 }); + // Overflow releases the inner subscription before any recovery pull. + yield* publishPastOutputBudget(manager, process); + yield* manager.resize( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 50, rows: 12 }, + "narrow", + ); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 50, rows: 12 }); + if (!recovered) return; + + expect(yield* pull).toMatchObject([{ type: "snapshot" }]); + yield* manager.resize( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 48, rows: 12 }, + "narrow", + ); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 48, rows: 12 }); + }).pipe(Effect.scoped); + + yield* Deferred.await(restored); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 130, rows: 15 }); + }).pipe(Effect.scoped), + ); + it.effect("does not replay in-flight output already included in an observer snapshot", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 5599739bf20ef..4cb4aaa3635da 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -58,6 +58,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; @@ -75,6 +76,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; +import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; export { @@ -177,6 +179,15 @@ export class TerminalManager extends Context.Service< viewerId?: string, ) => Effect.Effect<() => void, TerminalError>; + /** + * The `terminal.attach` RPC stream. Overflow recovery swaps the inner subscription, so + * `viewerId` and the size it reported stay attached until the whole stream ends. + */ + readonly attachOutputStream: ( + input: TerminalAttachInput, + viewerId: string, + ) => Stream.Stream; + readonly observeStream: ( input: TerminalObserveInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, @@ -3152,26 +3163,35 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); }; - const attachStream: TerminalManager["Service"]["attachStream"] = ( - input, - listener, - registerCleanup, - viewerId, + /** Retains `viewer` and returns its release, which runs at most once. */ + const retainViewerOnce = ( + input: TerminalObserveInput, + viewer: string, + grid: TerminalGrid | null, ) => { const sessionKey = toSessionKey(input.threadId, input.terminalId); - const viewer = viewerId ?? `stream:${++anonymousViewerCount}`; - let retained = false; - const detach = () => { + retainViewer(sessionKey, viewer, grid); + let retained = true; + return () => { if (!retained) return; retained = false; if (releaseViewer(sessionKey, viewer)) { runFork(withThreadLock(input.threadId, applySharedGrid(input.threadId, input.terminalId))); } }; + }; + + const attachStream: TerminalManager["Service"]["attachStream"] = ( + input, + listener, + registerCleanup, + viewerId, + ) => { + const viewer = viewerId ?? `stream:${++anonymousViewerCount}`; + let detach = () => {}; const initial = Effect.suspend(() => { - retained = true; - retainViewer( - sessionKey, + detach = retainViewerOnce( + input, viewer, input.cols !== undefined && input.rows !== undefined ? { cols: input.cols, rows: input.rows } @@ -3224,6 +3244,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func registerCleanup, ); + const attachOutputStream: TerminalManager["Service"]["attachOutputStream"] = (input, viewerId) => + Stream.unwrap( + Effect.acquireRelease( + Effect.sync(() => retainViewerOnce(input, viewerId, null)), + (release) => Effect.sync(release), + ).pipe( + Effect.as( + terminalOutputStream( + (listener, registerCleanup) => attachStream(input, listener, registerCleanup, viewerId), + (listener, registerCleanup) => resyncStream(input, listener, registerCleanup), + ), + ), + ), + ); + const metadataEventFromTerminalEvent = ( event: TerminalEvent, ): Effect.Effect => { @@ -3542,6 +3577,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, attachStream, + attachOutputStream, observeStream, resyncStream, write, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c23a563a80793..9f736ac1a6761 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2927,17 +2927,7 @@ export const layerWsRpc = ( [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), [WS_METHODS.terminalAttach]: (input, metadata) => - terminalOutputStream( - (listener, registerCleanup) => - terminalManager.attachStream( - input, - listener, - registerCleanup, - terminalViewerId(metadata), - ), - (listener, registerCleanup) => - terminalManager.resyncStream(input, listener, registerCleanup), - ), + terminalManager.attachOutputStream(input, terminalViewerId(metadata)), [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), [WS_METHODS.terminalResize]: (input, metadata) => terminalManager.resize(input, terminalViewerId(metadata)), diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 41cc34a8da4ae..a6d69d633a931 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -516,7 +516,7 @@ export class GhosttyTerminalSurface { readonly canvas: HTMLCanvasElement; readonly input: HTMLTextAreaElement; readonly scrollbar: HTMLDivElement; - /** The drawn grid: the shared PTY grid when known, otherwise the measured one. */ + /** The drawn grid: the shared PTY grid, clamped per axis to the measured one. */ cols = 1; rows = 1; @@ -891,8 +891,9 @@ export class GhosttyTerminalSurface { /** * Other clients can share this PTY, so the server owns its grid. Drawing that grid - * keeps wrapping and cursor positions in step with the PTY, and the server keeps it - * no larger than this surface's own fit. + * keeps wrapping and cursor positions in step with the PTY. Each axis is clamped to + * this surface's own fit: the server's grid lags a local resize, and read-only + * observers never constrain it. */ setSharedGrid(grid: { readonly cols: number; readonly rows: number } | null): void { if (this.disposed) return; @@ -902,8 +903,8 @@ export class GhosttyTerminalSurface { } private applyGrid(): boolean { - const cols = this.sharedGrid?.cols ?? this.measuredCols; - const rows = this.sharedGrid?.rows ?? this.measuredRows; + const cols = Math.min(this.sharedGrid?.cols ?? this.measuredCols, this.measuredCols); + const rows = Math.min(this.sharedGrid?.rows ?? this.measuredRows, this.measuredRows); if (cols === this.cols && rows === this.rows) return false; this.cols = cols; this.rows = rows; From 2f6167c225a56db5454de82bc6871f92d12ce16d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:32:46 +0000 Subject: [PATCH 09/16] fix(mobile): iOS terminal feeds new output after the batch's grid lands Expo sets a Fabric props batch in Dictionary order, so a replaced buffer could recreate the surface and feed at the previous shared grid before the same batch's sharedGrid resized it, leaving cursor-addressed output clipped until the next refresh. Apply the buffer in OnViewDidUpdateProps. Co-Authored-By: Claude Opus 5.5 --- .../modules/t3-terminal/ios/T3TerminalModule.swift | 4 ++++ .../modules/t3-terminal/ios/T3TerminalView.swift | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index c760ace918c4e..f9423b8078599 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -63,6 +63,10 @@ public class T3TerminalModule: Module { view.sharedGrid = sharedGrid } Events("onInput", "onResize", "onCapture") + + OnViewDidUpdateProps { (view: T3TerminalView) in + view.applyPendingBuffer() + } } } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 494c7a52c2ba1..77d9ae81b69d9 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -251,11 +251,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" { didSet { - applyRemoteBuffer(initialBuffer) + hasPendingBuffer = true } } + private var hasPendingBuffer = false // The PTY grid shared by every attached client, or empty before the server reports one. // The surface draws this grid so its wrapping and cursor match the PTY; the view's own @@ -575,6 +579,12 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { app = nil } + func applyPendingBuffer() { + guard hasPendingBuffer else { return } + hasPendingBuffer = false + applyRemoteBuffer(initialBuffer) + } + private func applyRemoteBuffer(_ buffer: String) { guard surface != nil else { createSurfaceIfPossible() From ee162767fc5cf0c7a24499e729958c23df91b93e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:47:53 +0000 Subject: [PATCH 10/16] fix(mobile): iOS terminal refresh no longer feeds a pending buffer early A theme, appearance, or font refresh in the same props batch as a replaced buffer recreated the surface and fed the new buffer at the previous grid. That set lastAppliedBuffer, so the deferred apply after the batch saw no change and never replayed it at the batch's grid. Surface creation now leaves a pending buffer to applyPendingBuffer. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 77d9ae81b69d9..a15994192d09e 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -549,7 +549,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) setupWriteCallback() resizeSurface() - feedBuffer(initialBuffer) + // A theme or font refresh in the same batch as a new buffer must not feed it early; + // applyPendingBuffer feeds it into this fresh surface once the batch's grid is set. + if !hasPendingBuffer { + feedBuffer(initialBuffer) + } } private func resetSurface() { From e977cddfe7c0a08eb8a498df70965fb293415261 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 04:53:52 +0000 Subject: [PATCH 11/16] test: add same-batch native fixture scenarios for issue 14463 Add explicit controls that commit buffer, shared grid, and theme, font, or zero bounds in one React batch, plus prefix-append and clear/recover cases. Each scenario writes an alt-screen INPUTWRAP row exactly the grid wide, so output fed at an earlier grid misplaces NEXTROW< visibly. Default fixture controls and layout are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../issue-14463/native-fixture.tsx.template | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/scripts/fixtures/issue-14463/native-fixture.tsx.template b/scripts/fixtures/issue-14463/native-fixture.tsx.template index 323e71965e650..a572ab1b9bce9 100644 --- a/scripts/fixtures/issue-14463/native-fixture.tsx.template +++ b/scripts/fixtures/issue-14463/native-fixture.tsx.template @@ -23,9 +23,15 @@ function ansi(cols: number, rows: number, normal: boolean) { } return out+'\x1b[6;4H\x1b[?25h\x1b[?12l'; } +type Grid={cols:number;rows:number}; +type Scenario={name:string;grid:Grid;buffer:string;zero:boolean}; +function inputWrap(name:string,grid:Grid){const head=`INPUTWRAP ${name} ${grid.cols}x${grid.rows}`.slice(0,grid.cols-1);return `\x1b[5;1H\x1b[2K\x1b[6;1H\x1b[2K\x1b[5;1H${head}${'.'.repeat(grid.cols-1-head.length)}#NEXTROW<\x1b[7;4H`} +function scenarioBuffer(name:string,grid:Grid){return ansi(grid.cols,grid.rows,false)+inputWrap(name,grid)} function Fixture() { const {themeAppearance, themeId,setThemeMode}=useAppearancePreferences(); - const theme=getMobileTerminalTheme(themeId,themeAppearance); + const [scenario,setScenario]=useState(null); const [flipTheme,setFlipTheme]=useState(false); const [mount,setMount]=useState(0); const [seq,setSeq]=useState(0); + const shownAppearance=flipTheme?(themeAppearance==='light'?'dark':'light'):themeAppearance; + const theme=getMobileTerminalTheme(themeId,shownAppearance); const [fit,setFit]=useState({cols:40,rows:24}); const [step,setStep]=useState('null'); const [font,setFont]=useState(13); const [readOnly,setReadOnly]=useState(false); const [focus,setFocus]=useState(0); @@ -35,13 +41,29 @@ function Fixture() { const small={cols:Math.max(20,fit.cols-10),rows:Math.max(7,fit.rows-8)}; const grid=step==='null'?null:step==='small'||step==='normal'?small:step==='stale'?{cols:fit.cols+20,rows:fit.rows+20}:step==='width'?{cols:small.cols,rows:fit.rows+20}:step==='height'?{cols:fit.cols+20,rows:small.rows}:fit; const drawn=grid?{cols:Math.min(grid.cols,fit.cols),rows:Math.min(grid.rows,fit.rows)}:fit; + // Each D action is one handler, so React commits buffer, grid, and theme/font/bounds in one native props batch. + // Grids alternate between two sizes under the fit the next font allows, so no follow-up fit update repaints the surface. + const nextGrid=(nextFont:number):Grid=>{const scale=Math.min(1,font/nextFont);const big={cols:Math.max(24,Math.floor(fit.cols*0.7*scale)),rows:Math.max(10,Math.floor(fit.rows*0.7*scale))};return scenario&&scenario.grid.cols===big.cols?{cols:big.cols-5,rows:big.rows-3}:big}; + const run=(name:string,nextFont=font,zero=false,buffer?:(grid:Grid)=>string)=>{const grid=nextGrid(nextFont);setScenario({name,grid,zero,buffer:buffer?buffer(grid):scenarioBuffer(`${name}#${seq+1}`,grid)});setSeq(seq+1);setInput('');setCaptured('')}; + const scenarios:[string,()=>void][]=[ + ['D2 buffer+grid+theme',()=>{run('D2');setFlipTheme(f=>!f)}], + ['D3 buffer+grid+font',()=>{const next=font===13?18:13;run('D3',next);setFont(next)}], + ['D4 mount zero',()=>{run('D4',font,true);setMount(m=>m+1)}], + ['D4 layout',()=>setScenario(s=>s&&{...s,zero:false})], + ['D5 append+grid',()=>run('D5',font,false,grid=>(scenario?.buffer??'')+inputWrap(`D5#${seq+1}`,grid))], + ['D6 clear',()=>run('D6',font,false,()=>'')], + ['D6 recover',()=>run('D6')], + ['Exit scenario',()=>{setScenario(null);setFlipTheme(false)}], + ]; const button=(title:string,onPress:()=>void)=>{title}; return Static fixture; limited integration | __PRODUCT_SHA__ - {step} | fitted {fit.cols}x{fit.rows} shared {grid?`${grid.cols}x${grid.rows}`:'null'} | {font}pt | {readOnly?'read-only':'editable'} - {steps.map(s=>button(s,()=>{setStep(s);setInput('');setCaptured('')}))} - setInput(p=>p+data)}/> + {scenario?`${scenario.name}${scenario.zero?' zero-bounds':''} | fitted ${fit.cols}x${fit.rows} shared ${scenario.grid.cols}x${scenario.grid.rows} | ${font}pt | ${shownAppearance} | expect # at row 5 right edge, NEXTROW< at row 6 col 1`:<>{step} | fitted {fit.cols}x{fit.rows} shared {grid?`${grid.cols}x${grid.rows}`:'null'} | {font}pt | {readOnly?'read-only':'editable'}} + {steps.map(s=>button(s,()=>{setStep(s);setScenario(null);setFlipTheme(false);setInput('');setCaptured('')}))} + + setInput(p=>p+data)}/> + {button('Theme',()=>setThemeMode(themeAppearance==='light'?'dark':'light'))} {button('Font',()=>setFont(f=>f===13?18:13))} @@ -49,6 +71,7 @@ function Fixture() { {button('Dismiss',()=>Keyboard.dismiss())} {button('Read only',()=>setReadOnly(r=>!r))} {button('Capture output',()=>setCapture(c=>c+1))} + {scenarios.map(([title,action])=>button(title,action))} {captured?{captured}:null} ; From 792b3451ae28eb9ec4a1ce51b597a9ec23887e3d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 05:16:03 +0000 Subject: [PATCH 12/16] fix(mobile): iOS terminal refresh no longer leaves the old surface's frame behind Ghostty adds its IOSurface layer to the viewport on iOS and never removes it when the surface is freed, so a theme or font refresh stacked the new layer over the stale one. Once the new layer shrinks to a smaller shared grid, the old frame shows as a gutter. Remove the viewport's sublayers after freeing the surface. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index a15994192d09e..5480882ac9405 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -581,6 +581,13 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } surface = nil app = nil + // Ghostty adds its IOSurface layer to the viewport on iOS but never removes it on + // free, leaving the last frame on screen under the next surface's layer. The + // viewport has no subviews, so every sublayer belongs to a freed surface. + CATransaction.begin() + CATransaction.setDisableActions(true) + terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + CATransaction.commit() } func applyPendingBuffer() { From 66b795ceb77947eafa46480838b695d191a4d630 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 06:25:50 +0000 Subject: [PATCH 13/16] fix(mobile): Android terminal feeds output after the prop batch's grid lands Expo applies a batch's props in ReadableMap iteration order, so initialBuffer could be parsed at the previous grid before the same batch's sharedGrid resized the terminal. Defer the feed to OnViewDidUpdateProps and keep creation/recreation from feeding early while a buffer is pending, matching the iOS surface. Co-Authored-By: Claude Opus 5.5 --- .../expo/modules/t3terminal/T3TerminalModule.kt | 4 ++++ .../expo/modules/t3terminal/T3TerminalView.kt | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) 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 de51eda7b7ce7..cea2d3e0cfe63 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 @@ -67,6 +67,10 @@ class T3TerminalModule : Module() { } Events("onInput", "onResize", "onCapture") + OnViewDidUpdateProps { view: T3TerminalView -> + view.applyPendingBuffer() + } + OnViewDestroys { view: T3TerminalView -> view.cleanup() } 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 99595808c356a..c1f97f39e8c24 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 @@ -75,12 +75,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex recreateTerminal() } + // Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch: + // output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the + // previous grid. var initialBuffer: String = "" set(value) { if (field == value) return field = value - feedPendingBuffer() + hasPendingBuffer = true } + private var hasPendingBuffer = false var fontSize: Float = 10f set(value) { @@ -369,7 +373,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex ) } emitResponse(response) - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -393,7 +397,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (terminalHandle == 0L) return destroyTerminal() createTerminal() - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -405,6 +409,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalCanvas.resetSelectionState() } + fun applyPendingBuffer() { + if (!hasPendingBuffer) return + hasPendingBuffer = false + feedPendingBuffer() + } + private fun feedPendingBuffer() { if (terminalHandle == 0L || initialBuffer == fedBuffer) return if (!initialBuffer.startsWith(fedBuffer)) { From 5700aa6c18c403eafd4580d8245c3b8c768863aa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 06:40:49 +0000 Subject: [PATCH 14/16] test(server): a repeated attach keeps the viewer's resized grid Co-Authored-By: Claude Opus 5.5 --- apps/server/src/terminal/Manager.test.ts | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 22a82f838d848..96c922805e9af 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -541,6 +541,49 @@ it.layer( }), ); + // Client retries resend the first attach's size, which a later resize may have replaced. + it.effect("keeps a viewer's resized grid when the same viewer attaches again", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const restored = yield* Deferred.make(); + const stopWide = yield* manager.attachStream( + openInput({ cols: 130, rows: 15 }), + (event) => + event.type === "activity" && event.cols === 130 && event.rows === 15 + ? Deferred.succeed(restored, undefined).pipe(Effect.asVoid) + : Effect.void, + undefined, + "wide", + ); + yield* Effect.addFinalizer(() => Effect.sync(stopWide)); + const stopFirst = yield* manager.attachStream( + openInput({ cols: 54, rows: 15 }), + () => Effect.void, + undefined, + "phone", + ); + const process = ptyAdapter.processes[0]!; + yield* manager.resize( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 100, rows: 15 }, + "phone", + ); + + const stopRetry = yield* manager.attachStream( + { ...openInput({ cols: 54, rows: 15 }), resume: true }, + () => Effect.void, + undefined, + "phone", + ); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 100, rows: 15 }); + + stopRetry(); + stopFirst(); + yield* Deferred.await(restored); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 130, rows: 15 }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + it.effect("resumed attaches after close never respawn until the terminal is reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); From 9cb86244731908f47ab3753fbdcab94391a1fb71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 06:41:37 +0000 Subject: [PATCH 15/16] fix(server): repeated terminal attaches keep the viewer's resized grid An attach retry from the same viewer resent the first attach's size and overwrote a newer resize. Only a viewer's first reported size comes from attach now. The issue-14463 web seed script also refuses the live T3 home when it or ~/.t3 is reached through a symlink. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/terminal/Manager.ts | 3 ++- scripts/fixtures/issue-14463/seed-web.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4cb4aaa3635da..0e11878ff281b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1709,7 +1709,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const viewer = viewers.get(viewerId); if (viewer) { viewer.streams += 1; - if (grid) viewer.grid = grid; + // After a viewer's first size, resizes own it; attach retries resend the original size. + viewer.grid ??= grid; } else { viewers.set(viewerId, { streams: 1, grid }); } diff --git a/scripts/fixtures/issue-14463/seed-web.py b/scripts/fixtures/issue-14463/seed-web.py index 031ac570c605b..6577cb0fc04ce 100644 --- a/scripts/fixtures/issue-14463/seed-web.py +++ b/scripts/fixtures/issue-14463/seed-web.py @@ -2,6 +2,7 @@ """Seed stopped, isolated test homes; never touches live T3 home.""" import argparse import json +import os from pathlib import Path import socket import sqlite3 @@ -11,7 +12,10 @@ p.add_argument('--workspace',type=Path,required=True) a=p.parse_args() home=a.home.resolve() -if str(home).startswith(str(Path.home()/'.t3')): +# Compare lexical and resolved forms of both sides, so a symlinked ~/.t3 or test home cannot alias it. +live=Path.home()/'.t3' +lives={Path(os.path.abspath(live)),live.resolve()} +if any(h.is_relative_to(l) for h in (Path(os.path.abspath(a.home)),home) for l in lives): raise SystemExit('Refusing live T3 home') with socket.socket() as s: if s.connect_ex(('127.0.0.1',a.server_port))==0: From c4af00dee94d447d16fafbbd914d7dbb08e3790b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 20:13:09 -0700 Subject: [PATCH 16/16] chore: remove shared terminal PR evidence fixtures --- .../issue-14463/native-fixture.tsx.template | 80 ------------------- scripts/fixtures/issue-14463/pixel-diff.py | 24 ------ .../fixtures/issue-14463/prepare-native.py | 26 ------ scripts/fixtures/issue-14463/seed-web.py | 32 -------- .../fixtures/issue-14463/shared-terminal.py | 75 ----------------- scripts/fixtures/issue-14463/web-seed.json | 9 --- 6 files changed, 246 deletions(-) delete mode 100644 scripts/fixtures/issue-14463/native-fixture.tsx.template delete mode 100644 scripts/fixtures/issue-14463/pixel-diff.py delete mode 100644 scripts/fixtures/issue-14463/prepare-native.py delete mode 100644 scripts/fixtures/issue-14463/seed-web.py delete mode 100644 scripts/fixtures/issue-14463/shared-terminal.py delete mode 100644 scripts/fixtures/issue-14463/web-seed.json diff --git a/scripts/fixtures/issue-14463/native-fixture.tsx.template b/scripts/fixtures/issue-14463/native-fixture.tsx.template deleted file mode 100644 index a572ab1b9bce9..0000000000000 --- a/scripts/fixtures/issue-14463/native-fixture.tsx.template +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { View, Text, Pressable, ScrollView, Keyboard, KeyboardAvoidingView, StatusBar } from 'react-native'; -import { registerRootComponent } from 'expo'; -import { NavigationContainer } from '@react-navigation/native'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { RegistryContext } from '@effect/atom-react'; -import { AppearancePreferencesProvider, useAppearancePreferences } from './src/features/settings/appearance/AppearancePreferencesProvider'; -import { appAtomRegistry } from './src/state/atom-registry'; -import { TerminalSurface } from './src/features/terminal/NativeTerminalSurface'; -import { getMobileTerminalTheme } from './src/features/terminal/terminalTheme'; -import './global.css'; -const Stack = createNativeStackNavigator(); -const steps = ['null','equal','small','stale','width','height','normal','grow']; -function ansi(cols: number, rows: number, normal: boolean) { - if (normal) return '\x1b[?1049l\x1b[0m\x1b[2J\x1b[H' + Array.from({length:60},(_,i)=>`ROW ${String(i+1).padStart(3,'0')} | ${'0123456789'.repeat(12)}\r\n`).join('') + 'CURSOR> \x1b[?25h\x1b[?12l'; - const width = Math.max(4,cols), height=Math.max(7,rows); - let out='\x1b[?1049h\x1b[0m\x1b[2J\x1b[H'; - for(let row=0;row(null); const [flipTheme,setFlipTheme]=useState(false); const [mount,setMount]=useState(0); const [seq,setSeq]=useState(0); - const shownAppearance=flipTheme?(themeAppearance==='light'?'dark':'light'):themeAppearance; - const theme=getMobileTerminalTheme(themeId,shownAppearance); - const [fit,setFit]=useState({cols:40,rows:24}); - const [step,setStep]=useState('null'); const [font,setFont]=useState(13); - const [readOnly,setReadOnly]=useState(false); const [focus,setFocus]=useState(0); - const [capture,setCapture]=useState(0); const [captured,setCaptured]=useState(''); - const [input,setInput]=useState(''); const insets=useSafeAreaInsets(); - const resized=useCallback((size:{cols:number;rows:number})=>setFit(p=>p.cols===size.cols&&p.rows===size.rows?p:size),[]); - const small={cols:Math.max(20,fit.cols-10),rows:Math.max(7,fit.rows-8)}; - const grid=step==='null'?null:step==='small'||step==='normal'?small:step==='stale'?{cols:fit.cols+20,rows:fit.rows+20}:step==='width'?{cols:small.cols,rows:fit.rows+20}:step==='height'?{cols:fit.cols+20,rows:small.rows}:fit; - const drawn=grid?{cols:Math.min(grid.cols,fit.cols),rows:Math.min(grid.rows,fit.rows)}:fit; - // Each D action is one handler, so React commits buffer, grid, and theme/font/bounds in one native props batch. - // Grids alternate between two sizes under the fit the next font allows, so no follow-up fit update repaints the surface. - const nextGrid=(nextFont:number):Grid=>{const scale=Math.min(1,font/nextFont);const big={cols:Math.max(24,Math.floor(fit.cols*0.7*scale)),rows:Math.max(10,Math.floor(fit.rows*0.7*scale))};return scenario&&scenario.grid.cols===big.cols?{cols:big.cols-5,rows:big.rows-3}:big}; - const run=(name:string,nextFont=font,zero=false,buffer?:(grid:Grid)=>string)=>{const grid=nextGrid(nextFont);setScenario({name,grid,zero,buffer:buffer?buffer(grid):scenarioBuffer(`${name}#${seq+1}`,grid)});setSeq(seq+1);setInput('');setCaptured('')}; - const scenarios:[string,()=>void][]=[ - ['D2 buffer+grid+theme',()=>{run('D2');setFlipTheme(f=>!f)}], - ['D3 buffer+grid+font',()=>{const next=font===13?18:13;run('D3',next);setFont(next)}], - ['D4 mount zero',()=>{run('D4',font,true);setMount(m=>m+1)}], - ['D4 layout',()=>setScenario(s=>s&&{...s,zero:false})], - ['D5 append+grid',()=>run('D5',font,false,grid=>(scenario?.buffer??'')+inputWrap(`D5#${seq+1}`,grid))], - ['D6 clear',()=>run('D6',font,false,()=>'')], - ['D6 recover',()=>run('D6')], - ['Exit scenario',()=>{setScenario(null);setFlipTheme(false)}], - ]; - const button=(title:string,onPress:()=>void)=>{title}; - return - - Static fixture; limited integration | __PRODUCT_SHA__ - {scenario?`${scenario.name}${scenario.zero?' zero-bounds':''} | fitted ${fit.cols}x${fit.rows} shared ${scenario.grid.cols}x${scenario.grid.rows} | ${font}pt | ${shownAppearance} | expect # at row 5 right edge, NEXTROW< at row 6 col 1`:<>{step} | fitted {fit.cols}x{fit.rows} shared {grid?`${grid.cols}x${grid.rows}`:'null'} | {font}pt | {readOnly?'read-only':'editable'}} - {steps.map(s=>button(s,()=>{setStep(s);setScenario(null);setFlipTheme(false);setInput('');setCaptured('')}))} - - setInput(p=>p+data)}/> - - - {button('Theme',()=>setThemeMode(themeAppearance==='light'?'dark':'light'))} - {button('Font',()=>setFont(f=>f===13?18:13))} - {button('Keyboard',()=>setFocus(f=>f+1))} - {button('Dismiss',()=>Keyboard.dismiss())} - {button('Read only',()=>setReadOnly(r=>!r))} - {button('Capture output',()=>setCapture(c=>c+1))} - {scenarios.map(([title,action])=>button(title,action))} - - {captured?{captured}:null} - ; -} -function App(){return } -registerRootComponent(App); diff --git a/scripts/fixtures/issue-14463/pixel-diff.py b/scripts/fixtures/issue-14463/pixel-diff.py deleted file mode 100644 index 9cdbe7b6a3a9f..0000000000000 --- a/scripts/fixtures/issue-14463/pixel-diff.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -"""Unregistered whole-image RGBA diffs. Requires Pillow and numpy.""" -import argparse -import json -from pathlib import Path -from PIL import Image -import numpy as np -p=argparse.ArgumentParser() -p.add_argument('before',type=Path);p.add_argument('after',type=Path);p.add_argument('prefix',type=Path) -a=p.parse_args() -b=Image.open(a.before).convert('RGBA');c=Image.open(a.after).convert('RGBA') -if b.size!=c.size: raise SystemExit(f'Dimensions differ: {b.size} vs {c.size}') -d=np.abs(np.asarray(b).astype(np.int16)-np.asarray(c).astype(np.int16)) -m=d.max(axis=2);changed=m>0;threshold=m>16 -zero=np.zeros((*m.shape,3),dtype=np.uint8) -zero[changed]=np.maximum(64,np.minimum(255,d[...,:3][changed]*16)).astype(np.uint8) -# Alpha-only differences are visible too, as magenta. -zero[(d[...,:3].max(axis=2)==0)&changed]=[255,0,255] -th=np.zeros_like(zero);th[threshold]=[255,32,128] -Image.fromarray(zero).save(str(a.prefix)+'-diff0.png') -Image.fromarray(th).save(str(a.prefix)+'-diffT.png') -result={'before':str(a.before),'after':str(a.after),'dimensions':b.size,'pixels':int(m.size),'changed0':int(changed.sum()),'changedT':int(threshold.sum()),'maxDelta':int(m.max()),'threshold':'max absolute RGBA channel delta >16','amplification':'absolute RGB channel delta x16; nonzero pixels min64; alpha-only magenta','registration':'none; no masks; complete image','colorspace':'8-bit decoded PNG RGBA, no color conversion beyond RGBA decode'} -Path(str(a.prefix)+'-stats.json').write_text(json.dumps(result,indent=2)+'\n') -print(json.dumps(result)) diff --git a/scripts/fixtures/issue-14463/prepare-native.py b/scripts/fixtures/issue-14463/prepare-native.py deleted file mode 100644 index 173ce9ac5c4aa..0000000000000 --- a/scripts/fixtures/issue-14463/prepare-native.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -"""Apply ONLY to a disposable checkout. Temporary index/profile, real native module.""" -import argparse -import hashlib -import json -from pathlib import Path -import subprocess -p=argparse.ArgumentParser() -p.add_argument('checkout',type=Path) -a=p.parse_args() -root=a.checkout.resolve() -if root == Path(__file__).resolve().parents[3]: - raise SystemExit('Use a separate disposable checkout, not fixture source checkout') -sha=subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip() -fixture=Path(__file__).with_name('native-fixture.tsx.template').read_text() -(root/'apps/mobile/issue14463-static.tsx').write_text(fixture.replace('__PRODUCT_SHA__',sha[:12])) -(root/'apps/mobile/index.ts').write_text("import './issue14463-static';\n") -profile=root/'apps/mobile/eas.json' -data=json.loads(profile.read_text()) -data['build']['issue14463-static']={'corepack':True,'distribution':'internal','environment':'preview','env':{'APP_VARIANT':'preview','NODE_OPTIONS':'--max-old-space-size=8192'},'ios':{'simulator':True,'image':'macos-tahoe-26.6-xcode-27.1'},'developmentClient':False} -profile.write_text(json.dumps(data,indent=2)+'\n') -# Include ignored native vendored frameworks when EAS packs the disposable checkout. -ignore=root/'.easignore' -text=(root/'.gitignore').read_text() -ignore.write_text(text+'\n!apps/mobile/modules/t3-terminal/Vendor/\n!apps/mobile/modules/t3-terminal/Vendor/**\n.env*\n**/.env*\n') -print(json.dumps({'productSha':sha,'templateSha256':hashlib.sha256(fixture.encode()).hexdigest(),'profile':'issue14463-static','limitedIntegration':'server arbitration, WS delivery, connection permissions/navigation not exercised'})) diff --git a/scripts/fixtures/issue-14463/seed-web.py b/scripts/fixtures/issue-14463/seed-web.py deleted file mode 100644 index 6577cb0fc04ce..0000000000000 --- a/scripts/fixtures/issue-14463/seed-web.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -"""Seed stopped, isolated test homes; never touches live T3 home.""" -import argparse -import json -import os -from pathlib import Path -import socket -import sqlite3 -p=argparse.ArgumentParser() -p.add_argument('home',type=Path) -p.add_argument('--server-port',type=int,required=True) -p.add_argument('--workspace',type=Path,required=True) -a=p.parse_args() -home=a.home.resolve() -# Compare lexical and resolved forms of both sides, so a symlinked ~/.t3 or test home cannot alias it. -live=Path.home()/'.t3' -lives={Path(os.path.abspath(live)),live.resolve()} -if any(h.is_relative_to(l) for h in (Path(os.path.abspath(a.home)),home) for l in lives): - raise SystemExit('Refusing live T3 home') -with socket.socket() as s: - if s.connect_ex(('127.0.0.1',a.server_port))==0: - raise SystemExit('Stop target server before seeding') -a.workspace.mkdir(parents=True,exist_ok=True) -(a.workspace/'fixture.txt').write_text('Shared terminal geometry fixture\n') -c=sqlite3.connect(home/'userdata/statev2.sqlite') -fixture=json.loads(Path(__file__).with_name('web-seed.json').read_text()) -with c: - c.execute('INSERT OR REPLACE INTO projection_projects (project_id,title,workspace_root,scripts_json,created_at,updated_at,auto_pull) VALUES (?,?,?,?,?,?,0)',(fixture['projectId'],fixture['title'],str(a.workspace.resolve()),'[]',fixture['clock'],fixture['clock'])) - for t in fixture['threads']: - payload={**t,'projectId':fixture['projectId'],'providerInstanceId':'codex','modelSelection':{'instanceId':'codex','model':'gpt-6-astra'},'runtimeMode':'full-access','interactionMode':'default','branch':None,'worktreePath':None,'pullRequests':[],'activeProviderThreadId':None,'lineage':{'parentThreadId':None,'relationshipToParent':None,'rootThreadId':t['id']},'forkedFrom':None,'createdBy':'system','creationSource':'server','createdAt':fixture['clock'],'updatedAt':fixture['clock'],'archivedAt':None,'settledOverride':None,'settledAt':None,'snoozedUntil':None,'snoozedAt':None,'lastVisitedAt':fixture['clock'],'deletedAt':None} - c.execute('INSERT OR REPLACE INTO orchestration_v2_projection_threads (thread_id,project_id,title,default_provider,runtime_mode,interaction_mode,active_provider_thread_id,created_at,updated_at,archived_at,deleted_at,payload_json,provider_instance_id) VALUES (?,?,?,?,?,?,NULL,?,?,NULL,NULL,?,?)',(t['id'],fixture['projectId'],t['title'],'codex','full-access','default',fixture['clock'],fixture['clock'],json.dumps(payload),'codex')) -print(json.dumps({'home':str(home),'projectId':fixture['projectId'],'threadIds':[t['id'] for t in fixture['threads']],'workspace':str(a.workspace.resolve())})) diff --git a/scripts/fixtures/issue-14463/shared-terminal.py b/scripts/fixtures/issue-14463/shared-terminal.py deleted file mode 100644 index beecabd23c7f3..0000000000000 --- a/scripts/fixtures/issue-14463/shared-terminal.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -"""Deterministic real PTY fixture: --mode tui|normal|burst; q exits TUI.""" -import argparse -import select -import termios -import tty -import os -import signal -import sys - -parser = argparse.ArgumentParser() -parser.add_argument('--mode', choices=['tui', 'normal', 'burst'], default='tui') -parser.add_argument('--label', default='Shared terminal') -args = parser.parse_args() - - -def normal(): - sys.stdout.write('\x1b[?1049l\x1b[0m\x1b[2J\x1b[H\x1b[?25h\x1b[?12l') - for row in range(1, 61): - print(f'ROW {row:03d} | ' + '0123456789' * 12, flush=True) - sys.stdout.write('CURSOR> \x1b[?25h\x1b[?12l') - sys.stdout.flush() - for line in sys.stdin: - if line.strip() == 'q': - break - sys.stdout.write('INPUT ' + line + 'CURSOR> ') - sys.stdout.flush() - - -def tui(): - old = termios.tcgetattr(sys.stdin) - pending = True - def redraw(*_): - nonlocal pending - pending = True - signal.signal(signal.SIGWINCH, redraw) - try: - tty.setraw(sys.stdin) - sys.stdout.write('\x1b[?1049h\x1b[?12l') - while True: - if pending: - pending = False - cols, rows = os.get_terminal_size() - out = '\x1b[38;2;255;255;255m\x1b[48;2;0;45;100m\x1b[2J\x1b[H' - lines = [f'{args.label} {cols} columns x {rows} rows', - 'Every border must be visible', 'Resize either client; q exits', - '0123456789' * 40] - for row in range(rows): - edge = row == 0 or row == rows - 1 - content = '-' * max(0, cols - 2) if edge else ( - 'BOTTOM EDGE' if row == rows - 2 else - lines[row - 1] if 1 <= row <= len(lines) else '') - content = content[:max(0, cols-2)].ljust(max(0, cols-2)) - out += f'\x1b[{row+1};1H' + ('+' if edge else '|') + content + ('+' if edge else '|') - out += f'\x1b[{min(6,rows-1)};{min(4,cols-1)}H\x1b[?25h' - sys.stdout.write(out) - sys.stdout.flush() - if select.select([sys.stdin], [], [], 0.1)[0]: - if os.read(sys.stdin.fileno(), 1) in (b'q', b'\x03'): - break - finally: - sys.stdout.write('\x1b[0m\x1b[?1049l\x1b[?25h') - sys.stdout.flush() - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old) - - -if args.mode == 'normal': - normal() -elif args.mode == 'burst': - for row in range(20000): - print(f'BURST {row:05d} ' + '0123456789' * 20) - sys.stdout.flush() - tui() -else: - tui() diff --git a/scripts/fixtures/issue-14463/web-seed.json b/scripts/fixtures/issue-14463/web-seed.json deleted file mode 100644 index db64980db6344..0000000000000 --- a/scripts/fixtures/issue-14463/web-seed.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "clock": "2026-10-20T12:00:00.000Z", - "projectId": "14463000-0000-4000-8000-000000000001", - "title": "Shared terminal proof", - "threads": [ - { "id": "14463000-0000-4000-8000-000000000002", "title": "Shared terminal geometry" }, - { "id": "14463000-0000-4000-8000-000000000003", "title": "Lifecycle control" } - ] -}