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 b8c92f2d9332f..703bcdf361b49 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 @@ -66,8 +66,16 @@ 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") + 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 f4de25460d5c6..1deb3a6170350 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 @@ -58,6 +58,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var streamedOutput: TerminalOutputBuffer? = null 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") @@ -74,12 +76,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) { @@ -91,6 +97,17 @@ 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, 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 + field = value + applyGrid() + } + var themeConfig: String = "" set(value) { field = value @@ -327,6 +344,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)?.coerceAtMost(measuredCols) ?: measuredCols + val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return cols = nextCols rows = nextRows @@ -343,8 +373,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex ) } emitResponse(response) - onResize(mapOf("cols" to cols, "rows" to rows)) - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -369,7 +398,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (terminalHandle == 0L) return destroyTerminal() createTerminal() - feedPendingBuffer() + if (!hasPendingBuffer) feedPendingBuffer() renderSnapshot() } @@ -381,6 +410,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex terminalCanvas.resetSelectionState() } + fun applyPendingBuffer() { + if (!hasPendingBuffer) return + hasPendingBuffer = false + feedPendingBuffer() + } + private fun feedPendingBuffer() { if (streamedOutput != null || terminalHandle == 0L || initialBuffer == fedBuffer) return if (!initialBuffer.startsWith(fedBuffer)) { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 2f7445265886c..d68e46a5416b1 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -63,7 +63,15 @@ 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") + + 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 364880aaeb39b..0ae146bb1241a 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -243,9 +243,23 @@ 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 + // fit still goes out through onResize. + var sharedGrid: [Int] = [] { + didSet { + guard oldValue != sharedGrid else { return } + resizeSurface() } } @@ -529,7 +543,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { resizeSurface() if let streamedOutput { feedData(streamedOutput.data) - } else { + } else if !hasPendingBuffer { feedBuffer(initialBuffer) } } @@ -559,6 +573,19 @@ 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() { + guard hasPendingBuffer else { return } + hasPendingBuffer = false + applyRemoteBuffer(initialBuffer) } private func applyRemoteBuffer(_ buffer: String) { @@ -647,10 +674,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() { @@ -658,19 +700,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() { @@ -721,8 +750,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 51c82f29fa6ca..a1957aa1242cf 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -1,5 +1,5 @@ -import type { TerminalOutputState } from "@t3tools/client-runtime/state/terminal"; -import { memo, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; +import type { TerminalGrid, TerminalOutputState } from "@t3tools/client-runtime/state/terminal"; +import { memo, useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; import { Pressable, ScrollView, @@ -47,6 +47,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly buffer: string | null; readonly output: TerminalOutputState | null; readonly fontSize?: number; + readonly grid?: TerminalGrid | null; readonly isRunning: boolean; readonly readOnly?: boolean; readonly autoFocus?: boolean; @@ -204,6 +205,12 @@ const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerm const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance); const { onInput, onResize } = props; const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); + const gridCols = props.grid?.cols; + const gridRows = props.grid?.rows; + const sharedGrid = useMemo( + () => (gridCols !== undefined && gridRows !== undefined ? [gridCols, gridRows] : []), + [gridCols, gridRows], + ); const streaming = supportsNativeTerminalOutput(); const nativeRef = useRef(null); const writerRef = useRef | null>(null); @@ -260,6 +267,7 @@ const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerm terminalKey={props.terminalKey} initialBuffer={streaming ? "" : 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 105e03e3fc0b8..2f6cc0d292a7f 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -939,6 +939,7 @@ function ThreadTerminal(props: { buffer={terminal.version === 0 ? null : terminal.buffer} output={terminal.version === 0 ? null : terminal.output} 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 ba5b6e4f5d382..cb5d79e210965 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -36,6 +36,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.test.ts b/apps/server/src/terminal/Manager.test.ts index 288bedd357e37..e30a4cd3d720d 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -442,6 +442,133 @@ 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("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 }); + }), + ); + + // 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(); @@ -1467,6 +1594,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 ea2059fb2c5d5..4c5229a7bae4f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -57,6 +57,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"; @@ -70,6 +71,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 { @@ -160,12 +162,27 @@ 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>; + /** + * 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, @@ -180,7 +197,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; @@ -273,6 +293,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; @@ -354,6 +385,8 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { label: terminalWireLabel(session), updatedAt: session.updatedAt, sequence: session.eventSequence, + cols: session.cols, + rows: session.rows, }; } @@ -1631,6 +1664,104 @@ 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; + // 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 }); + } + }; + + /** 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; + session.updatedAt = yield* nowIso; + 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, ) { @@ -2686,12 +2817,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); @@ -2806,10 +2938,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); @@ -2827,8 +2956,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)) { @@ -2868,11 +2998,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); @@ -2993,11 +3119,60 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); }; + /** 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); + 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, - ) => streamSession(input, openOrAttachForStream(input), listener, registerCleanup); + viewerId, + ) => { + const viewer = viewerId ?? `stream:${++anonymousViewerCount}`; + let detach = () => {}; + const initial = Effect.suspend(() => { + detach = retainViewerOnce( + input, + 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, @@ -3025,6 +3200,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 => { @@ -3131,23 +3321,36 @@ 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( @@ -3235,6 +3438,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 40ccc02353a91..1e95f3fb06eaa 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1269,6 +1269,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, @@ -2971,15 +2975,11 @@ 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) => - terminalOutputStream( - (listener, registerCleanup) => - terminalManager.attachStream(input, listener, registerCleanup), - (listener, registerCleanup) => - terminalManager.resyncStream(input, listener, registerCleanup), - ), + [WS_METHODS.terminalAttach]: (input, metadata) => + terminalManager.attachOutputStream(input, terminalViewerId(metadata)), [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.terminalClose]: (input) => terminalManager.close(input), [WS_METHODS.terminalObserve]: (input) => diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 7c0a3afa4dd50..295c553827f7c 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -695,6 +695,7 @@ function TerminalSessionViewport({ void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); } const latestSession = readTerminalSession(); + terminal.setSharedGrid(latestSession.grid); const initialOutput = readTerminalOutputUpdate( latestSession.output, INITIAL_TERMINAL_OUTPUT_CURSOR, @@ -720,6 +721,7 @@ function TerminalSessionViewport({ } const previous = previousSession; previousSession = session; + terminal.setSharedGrid(session.grid); synchronizeTerminalStatus(terminal, session.status, session.version); outputCursorRef.current = synchronizeTerminalSession( terminal, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 321bc0b48cc17..cb05acdc5e03d 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -518,10 +518,14 @@ export class GhosttyTerminalSurface { readonly canvas: HTMLCanvasElement; readonly input: HTMLTextAreaElement; readonly scrollbar: HTMLDivElement; + /** The drawn grid: the shared PTY grid, clamped per axis to 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; @@ -866,28 +870,59 @@ export class GhosttyTerminalSurface { } const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING); this.mountHeight = height; - // onResize is the only PTY channel; notify on first fit even when the grid matches its 1x1 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); + // 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.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; // Repaint synchronously before paint so resize never stretches the old backing store. if (shouldRender || this.forceFullRender) this.renderFrame(); return true; } - // Reflow locally during drags; debounce PTY resizes so the shell does not reprint its prompt each step. + /** + * 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. 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; + 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 = 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; + 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 + * prompt mid-drag, which reads as jitter. + */ private notifyResize(): void { this.resizeNotified = true; 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); } @@ -898,7 +933,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 { @@ -1019,7 +1054,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 214df808555e2..717727d8932cb 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -100,6 +100,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; @@ -193,6 +196,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([