From 2f03ce8eadd56a71ecaae079ee7b5979c682128f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:59:31 +0200 Subject: [PATCH 1/9] perf(terminal): stream output and history Rebase terminal streaming onto current main, retaining offset-based client output, bounded history reads, visibility controls, and provider-aware terminal launches. Capture extended replay before the attach repaint and restore the PTY size if attach is interrupted. Co-Authored-By: Claude Fable 5.1 --- .../android/src/main/cpp/t3_terminal_jni.cpp | 7 +- .../modules/t3terminal/T3TerminalModule.kt | 13 + .../expo/modules/t3terminal/T3TerminalView.kt | 117 +- .../modules/t3terminal/TerminalCanvasView.kt | 8 + .../t3-terminal/ios/T3TerminalModule.swift | 13 + .../t3-terminal/ios/T3TerminalView.swift | 142 +- .../terminal/NativeTerminalSurface.tsx | 177 ++- .../terminal/ThreadTerminalRouteScreen.tsx | 63 +- .../features/terminal/nativeTerminalModule.ts | 31 +- .../terminal/terminalBufferReplay.test.ts | 19 +- .../features/terminal/terminalBufferReplay.ts | 20 +- .../features/terminal/terminalMenu.test.ts | 6 +- .../project/ProjectSetupScriptRunner.test.ts | 1 + apps/server/src/terminal/Manager.test.ts | 1240 ++++++++++++--- apps/server/src/terminal/Manager.ts | 1362 +++++++++++++---- apps/server/src/terminal/NodePtyAdapter.ts | 8 + apps/server/src/terminal/PtyAdapter.ts | 4 + apps/server/src/ws.ts | 53 +- .../components/ThreadTerminalDrawer.test.ts | 28 +- .../src/components/ThreadTerminalDrawer.tsx | 245 ++- apps/web/src/index.css | 14 + apps/web/src/terminal-links.test.ts | 23 + apps/web/src/terminal-links.ts | 4 +- apps/web/src/terminal/ghostty/core.ts | 81 +- .../web/src/terminal/ghostty/renderer.test.ts | 224 ++- apps/web/src/terminal/ghostty/renderer.ts | 185 ++- .../src/terminal/ghostty/runtimeAbi.test.ts | 69 +- apps/web/src/terminal/ghostty/surface.test.ts | 56 +- apps/web/src/terminal/ghostty/surface.ts | 505 +++++- packages/client-runtime/src/state/terminal.ts | 15 +- .../src/state/terminalOutput.ts | 70 +- .../src/state/terminalSession.test.ts | 192 +++ .../src/state/terminalSession.ts | 76 +- packages/contracts/src/terminal.test.ts | 49 + packages/contracts/src/terminal.ts | 26 + packages/shared/package.json | 4 + packages/shared/src/utf8.ts | 47 + 37 files changed, 4452 insertions(+), 745 deletions(-) create mode 100644 packages/shared/src/utf8.ts diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp index 95760e8ead92..7093b364f4ed 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp @@ -12,7 +12,10 @@ namespace { constexpr uint32_t kSnapshotMagic = 0x54563354; // "T3VT" in little endian. constexpr uint16_t kSnapshotVersion = 1; -constexpr size_t kMaxScrollbackRows = 10000; +// libghostty-vt applies max_scrollback to internal cell storage even though +// older headers describe it as a line count. Text expands once cells carry +// terminal state, so leave enough room for the client's 4 MB replay. +constexpr size_t kMaxScrollbackBytes = 64 * 1024 * 1024; enum CellFlag : uint16_t { kBold = 1 << 0, @@ -205,7 +208,7 @@ Java_expo_modules_t3terminal_GhosttyBridge_nativeCreate( GhosttyTerminalOptions options = { .cols = static_cast(std::clamp(cols, 1, 65535)), .rows = static_cast(std::clamp(rows, 1, 65535)), - .max_scrollback = kMaxScrollbackRows, + .max_scrollback = kMaxScrollbackBytes, }; if (ghostty_terminal_new(nullptr, &session->terminal, options) != GHOSTTY_SUCCESS || ghostty_render_state_new(nullptr, &session->render_state) != GHOSTTY_SUCCESS || diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 1631c7fe68a1..fcb2af8eff09 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 @@ -11,6 +11,7 @@ class T3TerminalModule : Module() { // logs so a stale native binary is distinguishable from a broken key pipeline. Constants( "hardwareKeyRevision" to 2, + "streamingRevision" to 2, ) View(T3TerminalView::class) { @@ -56,6 +57,18 @@ class T3TerminalModule : Module() { Events("onInput", "onResize") + AsyncFunction("write") { view: T3TerminalView, data: String -> + view.writeRemoteData(data) + } + + AsyncFunction("writeReplay") { view: T3TerminalView, data: String -> + view.writeReplayRemoteData(data) + } + + AsyncFunction("reset") { view: T3TerminalView, data: String -> + view.resetRemoteData(data) + } + 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 88de793a8f7d..efff1aea6175 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 @@ -15,9 +15,16 @@ import android.widget.FrameLayout import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView +import java.util.ArrayDeque import kotlin.math.max class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + private companion object { + const val MAX_PENDING_REMOTE_DATA_BYTES = 8 * 1024 * 1024 + } + + private data class PendingRemoteData(val data: ByteArray, val replay: Boolean) + private val container = FrameLayout(context) private val terminalCanvas = TerminalCanvasView(context) private val inputView = EditText(context) @@ -25,6 +32,9 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private val onResize by EventDispatcher() private var terminalHandle = 0L private var fedBuffer = "" + private var bufferedOutput = "" + private val pendingRemoteData = ArrayDeque() + private var pendingRemoteDataBytes = 0 private var cols = 0 private var rows = 0 private var clearingInput = false @@ -34,6 +44,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex private var mutedForegroundColorValue = Color.parseColor("#959DA5") private var cursorColorValue = Color.parseColor("#009FFF") private var paletteColors = IntArray(0) + private var renderSnapshotScheduled = false var terminalKey: String = "" set(value) { @@ -43,13 +54,47 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex recreateTerminal() } - var initialBuffer: String = "" + var initialBuffer: String + get() = bufferedOutput set(value) { - if (field == value) return - field = value + if (bufferedOutput == value) return + bufferedOutput = value feedPendingBuffer() } + fun writeRemoteData(data: String) { + writeRemoteData(data, replay = false) + } + + fun writeReplayRemoteData(data: String) { + writeRemoteData(data, replay = true) + } + + private fun writeRemoteData(data: String, replay: Boolean) { + if (data.isEmpty()) return + if (terminalHandle == 0L) { + appendPendingRemoteData(data, replay) + return + } + val response = GhosttyBridge.nativeFeed(terminalHandle, data.toByteArray(Charsets.UTF_8)) + if (!replay) emitResponse(response) + if (terminalCanvas.hasActiveSelection()) { + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } + scheduleRenderSnapshot() + } + + fun resetRemoteData(data: String) { + destroyTerminal() + bufferedOutput = data + pendingRemoteData.clear() + pendingRemoteDataBytes = 0 + if (data.isEmpty()) terminalCanvas.clearFrame() + createTerminal() + feedPendingBuffer() + } + var fontSize: Float = 10f set(value) { field = value @@ -339,25 +384,56 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } private fun feedPendingBuffer() { - if (terminalHandle == 0L || initialBuffer == fedBuffer) return - if (!initialBuffer.startsWith(fedBuffer)) { - recreateTerminal() - if (terminalHandle == 0L) return + if (terminalHandle == 0L) return + if (initialBuffer != fedBuffer) { + if (!initialBuffer.startsWith(fedBuffer)) { + recreateTerminal() + return + } + val suffix = initialBuffer.substring(fedBuffer.length) + if (suffix.isNotEmpty()) { + // Retained history is renderer input, not live PTY output. Discard any + // terminal query replies it generates instead of forwarding them to the shell. + GhosttyBridge.nativeFeed(terminalHandle, suffix.toByteArray(Charsets.UTF_8)) + // New output invalidates an active selection (matches the web drawer); + // otherwise the copy toolbar drifts out of sync with the grid. + if (terminalCanvas.hasActiveSelection()) { + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } + } + fedBuffer = initialBuffer } - val suffix = initialBuffer.substring(fedBuffer.length) - if (suffix.isNotEmpty()) { - emitResponse(GhosttyBridge.nativeFeed(terminalHandle, suffix.toByteArray(Charsets.UTF_8))) - // New output invalidates an active selection (matches the web drawer); - // otherwise the copy toolbar drifts out of sync with the grid. - if (terminalCanvas.hasActiveSelection()) { - GhosttyBridge.nativeClearSelection(terminalHandle) - terminalCanvas.resetSelectionState() + if (pendingRemoteData.isNotEmpty()) { + while (pendingRemoteData.isNotEmpty()) { + val chunk = pendingRemoteData.removeFirst() + val response = GhosttyBridge.nativeFeed(terminalHandle, chunk.data) + if (!chunk.replay) emitResponse(response) } } - fedBuffer = initialBuffer + pendingRemoteDataBytes = 0 renderSnapshot() } + private fun appendPendingRemoteData(data: String, replay: Boolean) { + val encoded = data.toByteArray(Charsets.UTF_8) + if (encoded.size > MAX_PENDING_REMOTE_DATA_BYTES) { + var start = encoded.size - MAX_PENDING_REMOTE_DATA_BYTES + while (start < encoded.size && (encoded[start].toInt() and 0xC0) == 0x80) start += 1 + val suffix = encoded.copyOfRange(start, encoded.size) + pendingRemoteData.clear() + pendingRemoteData.addLast(PendingRemoteData(suffix, replay)) + pendingRemoteDataBytes = suffix.size + return + } + + pendingRemoteData.addLast(PendingRemoteData(encoded, replay)) + pendingRemoteDataBytes += encoded.size + while (pendingRemoteDataBytes > MAX_PENDING_REMOTE_DATA_BYTES) { + pendingRemoteDataBytes -= pendingRemoteData.removeFirst().data.size + } + } + private fun renderSnapshot() { if (terminalHandle == 0L) return TerminalFrame.decode( @@ -365,6 +441,15 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex )?.let(terminalCanvas::setFrame) } + private fun scheduleRenderSnapshot() { + if (renderSnapshotScheduled) return + renderSnapshotScheduled = true + postOnAnimation { + renderSnapshotScheduled = false + renderSnapshot() + } + } + private fun emitResponse(response: ByteArray) { if (response.isNotEmpty()) { onInput(mapOf("data" to String(response, Charsets.UTF_8))) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt index f713bdb4ff00..e641c0cb537b 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt @@ -181,6 +181,14 @@ internal class TerminalCanvasView(context: Context) : View(context) { invalidate() } + fun clearFrame() { + frame = null + cursorOn = true + removeCallbacks(cursorBlink) + resetSelectionState() + invalidate() + } + fun resetSelectionState() { selectionActive = false dragSelecting = false diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index f68cc6b4a112..cfc61fb6b592 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -8,6 +8,7 @@ public class T3TerminalModule: Module { // logs so a stale native binary is distinguishable from a broken key pipeline. Constants([ "hardwareKeyRevision": 3, + "streamingRevision": 2, ]) View(T3TerminalView.self) { @@ -52,6 +53,18 @@ public class T3TerminalModule: Module { } Events("onInput", "onResize") + + AsyncFunction("write") { (view: T3TerminalView, data: String) in + view.writeRemoteData(data) + } + + AsyncFunction("writeReplay") { (view: T3TerminalView, data: String) in + view.writeReplayRemoteData(data) + } + + AsyncFunction("reset") { (view: T3TerminalView, data: String) in + view.resetRemoteData(data) + } } } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index f04db4467fdf..3f1e5c8f9279 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -196,6 +196,13 @@ private extension UIColor { public final class T3TerminalView: ExpoView, UITextFieldDelegate { private static let minimumVerticalScrollStepPoints: CGFloat = 18 private static let verticalScrollStepMultiplier: CGFloat = 1.15 + private static let maxScrollbackBytes = 64 * 1024 * 1024 + private static let maxPendingRemoteDataBytes = 8 * 1024 * 1024 + + private struct PendingRemoteData { + let data: Data + let replay: Bool + } private let terminalViewport = UIView() private let inputField = TerminalInputField() @@ -204,12 +211,18 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private var lastViewportSize: CGSize = .zero private var lastContentScale: CGFloat = 0 private var lastReportedGrid: (cols: Int, rows: Int)? + private var bufferedOutput = "" + private var pendingRemoteData: [PendingRemoteData?] = [] + private var pendingRemoteDataHead = 0 + private var pendingRemoteDataBytes = 0 private var lastAppliedBuffer = "" + private var redrawDisplayLink: CADisplayLink? private var pendingVerticalScrollPoints: CGFloat = 0 private var app: ghostty_app_t? private var surface: ghostty_surface_t? private var isCreatingSurface = false private var surfaceCreationFailed = false + private var suppressInput = false private var appearance = TerminalAppearanceScheme.dark private var backgroundColorValue = UIColor(hexString: "#24292e") @@ -225,10 +238,45 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } - var initialBuffer: String = "" { - didSet { - applyRemoteBuffer(initialBuffer) + var initialBuffer: String { + get { bufferedOutput } + set { + guard bufferedOutput != newValue else { return } + bufferedOutput = newValue + applyRemoteBuffer(bufferedOutput) + } + } + + func writeRemoteData(_ data: String) { + writeRemoteData(data, replay: false) + } + + func writeReplayRemoteData(_ data: String) { + writeRemoteData(data, replay: true) + } + + private func writeRemoteData(_ data: String, replay: Bool) { + guard !data.isEmpty else { return } + if surface == nil { + appendPendingRemoteData(data, replay: replay) + createSurfaceIfPossible() + return } + if replay { + feedReplayData(Data(data.utf8), redraw: false) + } else { + feedData(Data(data.utf8), redraw: false) + } + scheduleRedraw() + } + + func resetRemoteData(_ data: String) { + resetSurface() + bufferedOutput = data + pendingRemoteData = [] + pendingRemoteDataHead = 0 + pendingRemoteDataBytes = 0 + createSurfaceIfPossible() } var fontSize: CGFloat = 10 { @@ -500,6 +548,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { setupWriteCallback() resizeSurface() feedBuffer(initialBuffer) + feedPendingRemoteData() } private func resetSurface() { @@ -518,6 +567,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } private func destroySurface() { + redrawDisplayLink?.invalidate() + redrawDisplayLink = nil if let surface { ghostty_surface_set_write_callback(surface, nil, nil) ghostty_surface_free(surface) @@ -536,14 +587,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } if buffer.isEmpty { - feedData(Data("\u{1B}[3J\u{1B}[H\u{1B}[2J".utf8)) + feedReplayData(Data("\u{1B}[3J\u{1B}[H\u{1B}[2J".utf8)) lastAppliedBuffer = "" return } if buffer.hasPrefix(lastAppliedBuffer) { let suffix = String(buffer.dropFirst(lastAppliedBuffer.count)) - feedData(Data(suffix.utf8)) + feedReplayData(Data(suffix.utf8)) lastAppliedBuffer = buffer return } @@ -554,11 +605,66 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private func feedBuffer(_ buffer: String) { guard !buffer.isEmpty else { return } - feedData(Data(buffer.utf8)) + feedReplayData(Data(buffer.utf8)) lastAppliedBuffer = buffer } - private func feedData(_ data: Data) { + private func feedReplayData(_ data: Data) { + feedReplayData(data, redraw: true) + } + + private func feedReplayData(_ data: Data, redraw: Bool) { + suppressInput = true + defer { suppressInput = false } + feedData(data, redraw: redraw) + } + + private func feedPendingRemoteData() { + guard pendingRemoteDataHead < pendingRemoteData.count else { return } + for case let chunk? in pendingRemoteData[pendingRemoteDataHead...] { + if chunk.replay { + feedReplayData(chunk.data, redraw: false) + } else { + feedData(chunk.data, redraw: false) + } + } + pendingRemoteData = [] + pendingRemoteDataHead = 0 + pendingRemoteDataBytes = 0 + redrawSurface() + } + + private func appendPendingRemoteData(_ data: String, replay: Bool) { + let encoded = Data(data.utf8) + if encoded.count > Self.maxPendingRemoteDataBytes { + var start = encoded.count - Self.maxPendingRemoteDataBytes + while start < encoded.count, encoded[start] & 0xC0 == 0x80 { + start += 1 + } + let suffix = Data(encoded[start...]) + pendingRemoteData = [PendingRemoteData(data: suffix, replay: replay)] + pendingRemoteDataHead = 0 + pendingRemoteDataBytes = suffix.count + return + } + + pendingRemoteData.append(PendingRemoteData(data: encoded, replay: replay)) + pendingRemoteDataBytes += encoded.count + while pendingRemoteDataBytes > Self.maxPendingRemoteDataBytes, + pendingRemoteDataHead < pendingRemoteData.count, + let oldest = pendingRemoteData[pendingRemoteDataHead] { + pendingRemoteData[pendingRemoteDataHead] = nil + pendingRemoteDataHead += 1 + pendingRemoteDataBytes -= oldest.data.count + } + if pendingRemoteDataHead >= 1_024, + pendingRemoteDataHead * 2 >= pendingRemoteData.count { + pendingRemoteData = Array(pendingRemoteData[pendingRemoteDataHead...]) + pendingRemoteDataHead = 0 + } + } + + private func feedData(_ data: Data, redraw: Bool = true) { guard let surface, !data.isEmpty else { return } data.withUnsafeBytes { buffer in @@ -568,6 +674,22 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ghostty_surface_feed_data(surface, pointer, buffer.count) } + if redraw { + redrawSurface() + } + } + + private func scheduleRedraw() { + guard redrawDisplayLink == nil else { return } + let displayLink = CADisplayLink(target: self, selector: #selector(handleScheduledRedraw)) + redrawDisplayLink = displayLink + displayLink.add(to: .main, forMode: .common) + } + + @objc + private func handleScheduledRedraw() { + redrawDisplayLink?.invalidate() + redrawDisplayLink = nil redrawSurface() } @@ -580,6 +702,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { let view = Unmanaged.fromOpaque(userdata).takeUnretainedValue() let bytes = Data(bytes: data, count: len) guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return } + guard !view.suppressInput else { return } DispatchQueue.main.async { view.onInput(["data": input]) @@ -703,8 +826,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } private func writeThemeConfigFile() -> String? { - guard !themeConfig.isEmpty else { return nil } - let configContents = themeConfig + // Ghostty budgets its internal cell storage rather than raw replay bytes, + // so the configured limit must account for the expansion of terminal text. + let configContents = "scrollback-limit = \(Self.maxScrollbackBytes)\n\(themeConfig)" let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty") diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 37dec1fe4562..7634a882cb44 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -1,4 +1,11 @@ -import { memo, useCallback, useEffect, useRef } from "react"; +import { + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, + terminalOutputText, + type TerminalOutputCursor, + type TerminalOutputState, +} from "@t3tools/client-runtime/state/terminal"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { Pressable, ScrollView, @@ -14,7 +21,10 @@ import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { getNativeTerminalHardwareKeyRevision, + getNativeTerminalStreamingRevision, + NATIVE_TERMINAL_STREAMING_REVISION, resolveNativeTerminalSurfaceView, + type NativeTerminalSurfaceHandle, } from "./nativeTerminalModule"; import { buildGhosttyThemeConfig, @@ -32,9 +42,27 @@ interface TerminalResizeEvent { readonly rows: number; } +const NATIVE_COMMAND_RETRY_FRAMES = 8; + +function nextAnimationFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function isPendingNativeViewRegistration(error: unknown): boolean { + return ( + error instanceof Error && + (error.message.includes("Unable to find the 'T3Terminal' view") || + (error.message.includes("Unable to find the class") && + error.message.includes("T3TerminalView view with tag"))) + ); +} + interface TerminalSurfaceProps extends ViewProps { readonly terminalKey: string; - readonly buffer: string; + readonly output: TerminalOutputState; + readonly replayPaused?: boolean; + /** True while the server has opened a replay it has not completed yet. */ + readonly replayPending?: boolean; readonly fontSize?: number; readonly isRunning: boolean; readonly autoFocus?: boolean; @@ -65,6 +93,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter const statusLabel = props.isRunning ? "Native terminal unavailable. Using text fallback." : "Open terminal to start a shell."; + const buffer = props.replayPaused ? "" : terminalOutputText(props.output); const handleLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; @@ -117,7 +146,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter lineHeight: Math.round(fontSize * 1.35), }} > - {props.buffer || "$ "} + {buffer || "$ "} @@ -178,6 +207,23 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf const { onInput, onResize } = props; const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); const hasNativeSurface = Boolean(NativeTerminalSurfaceView); + const streamingRevision = getNativeTerminalStreamingRevision(); + const supportsStreaming = + streamingRevision !== null && streamingRevision >= NATIVE_TERMINAL_STREAMING_REVISION; + const themeConfig = buildGhosttyThemeConfig(theme); + const nativeRef = useRef(null); + const nativeCommandQueueRef = useRef(Promise.resolve()); + const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); + const streamIdentityRef = useRef(""); + const deferredEmptyResetRef = useRef(false); + const surfaceIdentity = `${props.terminalKey}:${fontSize}:${themeAppearance}:${themeConfig}`; + // A failed native command rebuilds the surface once from the retained + // snapshot; without that, an idle terminal would stay stale until new output. + const [nativeRecoveryVersion, setNativeRecoveryVersion] = useState(0); + const recoveredSurfaceIdentityRef = useRef(null); + const resetIdentity = `${surfaceIdentity}:${nativeRecoveryVersion}`; + const legacyBuffer = + supportsStreaming || props.replayPaused ? "" : terminalOutputText(props.output); useEffect(() => { terminalDebugLog("native:surface", { @@ -185,10 +231,126 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf native: hasNativeSurface, // null = installed binary predates native hardware-key handling (rebuild needed). hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(), - bufferLen: props.buffer.length, + retainedBytes: props.output.retainedBytes, isRunning: props.isRunning, + streamingRevision, }); - }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); + }, [ + hasNativeSurface, + props.isRunning, + props.output.retainedBytes, + props.terminalKey, + streamingRevision, + ]); + useEffect( + () => () => { + streamIdentityRef.current = ""; + }, + [], + ); + useEffect(() => { + if (!supportsStreaming) return; + const streamIdentity = props.replayPaused ? `${resetIdentity}:paused` : resetIdentity; + const forceReset = streamIdentityRef.current !== streamIdentity; + const update = + forceReset || props.replayPaused + ? { + type: "reset" as const, + data: props.replayPaused ? "" : terminalOutputText(props.output), + cursor: { + resetVersion: props.output.resetVersion, + generation: props.output.generation, + offset: props.output.nextOffset, + }, + } + : readTerminalOutputUpdate(props.output, outputCursorRef.current); + streamIdentityRef.current = streamIdentity; + outputCursorRef.current = update.cursor; + if (!forceReset && props.replayPaused) return; + let commands: Array<{ type: "reset" | "write" | "writeReplay"; data: string }> = + update.type === "reset" + ? [{ type: "reset", data: update.data }] + : update.type === "append" + ? update.segments.map((segment) => ({ + type: segment.delivery === "replay" ? ("writeReplay" as const) : ("write" as const), + data: segment.data, + })) + : []; + if ( + !forceReset && + props.replayPending === true && + update.type === "reset" && + update.data.length === 0 + ) { + // A replay in flight opens with an empty snapshot. Keep the last frame + // on screen and fold the reset into the first replay chunk instead of + // blanking the terminal while history streams in. + deferredEmptyResetRef.current = true; + commands = []; + } else if (deferredEmptyResetRef.current) { + const first = commands[0]; + if (first !== undefined) { + deferredEmptyResetRef.current = false; + if (first.type === "writeReplay") { + commands = [{ type: "reset" as const, data: first.data }, ...commands.slice(1)]; + } else if (first.type === "write") { + commands = [{ type: "reset" as const, data: "" }, ...commands]; + } + // A reset command already supersedes the deferred one. + } else if (props.replayPending !== true) { + // The replay finished without producing content: the terminal really + // is empty now, so apply the reset that was held back. + deferredEmptyResetRef.current = false; + commands = [{ type: "reset" as const, data: terminalOutputText(props.output) }]; + } + } + if (commands.length === 0) return; + nativeCommandQueueRef.current = nativeCommandQueueRef.current + .then(async () => { + for (const pending of commands) { + for (let attempt = 0; attempt <= NATIVE_COMMAND_RETRY_FRAMES; attempt += 1) { + if (streamIdentityRef.current !== streamIdentity) return; + const handle = nativeRef.current; + const command = handle?.[pending.type]; + if (!command) { + if (attempt < NATIVE_COMMAND_RETRY_FRAMES) { + await nextAnimationFrame(); + continue; + } + throw new Error(`Native terminal does not support ${pending.type}`); + } + + try { + await command.call(handle, pending.data); + break; + } catch (error) { + if (attempt < NATIVE_COMMAND_RETRY_FRAMES && isPendingNativeViewRegistration(error)) { + await nextAnimationFrame(); + continue; + } + throw error; + } + } + } + }) + .catch((error: unknown) => { + console.error("Failed to update native terminal output", error); + if (streamIdentityRef.current !== streamIdentity) return; + // The next output update rebuilds the native surface from the retained + // snapshot instead of continuing after a missing command. + streamIdentityRef.current = ""; + if (recoveredSurfaceIdentityRef.current === surfaceIdentity) return; + recoveredSurfaceIdentityRef.current = surfaceIdentity; + setNativeRecoveryVersion((version) => version + 1); + }); + }, [ + props.output, + props.replayPaused, + props.replayPending, + resetIdentity, + supportsStreaming, + surfaceIdentity, + ]); const handleNativeInput = useCallback( (event: NativeSyntheticEvent) => { if (!props.isRunning) { @@ -215,6 +377,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf return ( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index bca54694bd7b..8ed7b5a42123 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,5 +1,13 @@ -import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; +import { + DEFAULT_TERMINAL_ID, + EXTENDED_TERMINAL_REPLAY_BYTES, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; +import { + terminalOutputText, + type KnownTerminalSession, +} from "@t3tools/client-runtime/state/terminal"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; @@ -47,11 +55,12 @@ import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { TerminalSurface } from "./NativeTerminalSurface"; +import { supportsNativeReplayStreaming } from "./nativeTerminalModule"; import { getMobileTerminalTheme } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; import { getTerminalBufferReplayKey, - getTerminalSurfaceReplayBuffer, + isTerminalBufferReplayPaused, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, } from "./terminalBufferReplay"; import { @@ -311,6 +320,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) worktreePath: launchLocation.worktreePath, cols: initialAttachGridSize.cols, rows: initialAttachGridSize.rows, + ...(supportsNativeReplayStreaming() + ? { replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES } + : {}), ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}), ...(pendingLaunch ? { restartIfNotRunning: true } : {}), } @@ -342,8 +354,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) if (lastBufferReplayKeyRef.current === null) { lastBufferReplayKeyRef.current = bufferReplayKey; } - const terminalSurfaceBuffer = getTerminalSurfaceReplayBuffer({ - buffer: terminal.buffer, + const terminalReplayPaused = isTerminalBufferReplayPaused({ replayKey: bufferReplayKey, readyReplayKey: readyBufferReplayKey, }); @@ -358,11 +369,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const reopenedStaleTerminalKeyRef = useRef(null); const pendingExitNavigationRef = useRef(null); - // Attach subscriptions are cached with an idle TTL, so revisiting a - // terminal whose session ended while unobserved reuses the stale stream - // without a new attach RPC — the server never respawns anything. Detect - // that (dead status with processed events, never seen running here) and - // issue an explicit open; its snapshot flows into the live subscription. + // Attaching to an exited session preserves its final history. This route is + // an interactive shell, so explicitly reopen that session after its + // snapshot arrives unless this screen observed the exit itself. useEffect(() => { if (isRunning) { reopenedStaleTerminalKeyRef.current = null; @@ -410,8 +419,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) useEffect(() => { terminalDebugLog("surface:props", { terminalKey, - atomBufferLen: terminal.buffer.length, - surfaceBufferLen: terminalSurfaceBuffer.length, + retainedBytes: terminal.output.retainedBytes, + retainedChunks: terminal.output.chunks.length, + replayPaused: terminalReplayPaused, replayKey: bufferReplayKey, readyReplayKey: readyBufferReplayKey, status: terminal.status, @@ -420,11 +430,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }, [ bufferReplayKey, readyBufferReplayKey, - terminal.buffer.length, + terminal.output.chunks.length, + terminal.output.retainedBytes, terminal.status, terminal.version, terminalKey, - terminalSurfaceBuffer.length, + terminalReplayPaused, ]); useEffect(() => { @@ -433,11 +444,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) status: terminal.status, error: terminal.error, summary: terminal.summary?.cwd ?? null, - bufferLen: terminal.buffer.length, + retainedBytes: terminal.output.retainedBytes, version: terminal.version, }); }, [ - terminal.buffer.length, + terminal.output.retainedBytes, terminal.error, terminal.status, terminal.summary?.cwd, @@ -446,16 +457,16 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ]); useEffect(() => { - if (terminal.buffer.length === 0 || firstNonEmptyBufferLoggedRef.current) { + if (terminal.output.retainedBytes === 0 || firstNonEmptyBufferLoggedRef.current) { return; } firstNonEmptyBufferLoggedRef.current = true; terminalDebugLog("session:first-nonempty-buffer", { terminalKey, - length: terminal.buffer.length, - preview: terminal.buffer.slice(0, 160), + length: terminal.output.retainedBytes, + preview: terminalOutputText(terminal.output).slice(0, 160), }); - }, [terminal.buffer, terminal.buffer.length, terminalKey]); + }, [terminal.output, terminalKey]); const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; const serverConfigs = useServerConfigs(); const hostOs = @@ -671,7 +682,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) lastBufferReplayKeyRef.current = bufferReplayKey; clearBufferReplayTimer(); setReadyBufferReplayKey(null); - }, [bufferReplayKey, clearBufferReplayTimer]); + scheduleBufferReplayReady(); + }, [bufferReplayKey, clearBufferReplayTimer, scheduleBufferReplayReady]); useEffect(() => clearBufferReplayTimer, [clearBufferReplayTimer]); @@ -1263,12 +1275,19 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) terminal.replayCompleteVersion + } style={{ flex: 1 }} terminalKey={terminalKey} theme={terminalTheme} diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index f6a74595c80e..2eae75c65e0b 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -1,10 +1,12 @@ -import type { ComponentType } from "react"; +import type { ComponentType, Ref } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView, requireOptionalNativeModule } from "expo"; import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; const NATIVE_TERMINAL_MODULE_NAME = "T3TerminalSurface"; +export const NATIVE_TERMINAL_STREAMING_REVISION = 1; +export const NATIVE_TERMINAL_REPLAY_STREAMING_REVISION = 2; interface ExpoGlobalWithViewConfig { readonly expo?: { @@ -22,6 +24,7 @@ interface TerminalResizeEvent { } export interface NativeTerminalSurfaceProps extends ViewProps { + readonly ref?: Ref; readonly appearanceScheme?: "light" | "dark"; readonly autoFocus?: boolean; readonly focusRequest?: number; @@ -36,6 +39,12 @@ export interface NativeTerminalSurfaceProps extends ViewProps { readonly onResize?: (event: NativeSyntheticEvent) => void; } +export interface NativeTerminalSurfaceHandle { + readonly write?: (data: string) => Promise; + readonly writeReplay?: (data: string) => Promise; + readonly reset?: (data: string) => Promise; +} + let cachedNativeTerminalSurfaceView: ComponentType | undefined; let nativeTerminalSurfaceViewResolutionFailed = false; @@ -95,6 +104,26 @@ export function getNativeTerminalHardwareKeyRevision(): number | null { } } +/** Revision 2 adds replay-safe append commands to the revision 1 streaming API. */ +export function getNativeTerminalStreamingRevision(): number | null { + try { + if (typeof requireOptionalNativeModule !== "function") { + return null; + } + const module = requireOptionalNativeModule<{ readonly streamingRevision?: number }>( + NATIVE_TERMINAL_MODULE_NAME, + ); + return module?.streamingRevision ?? null; + } catch { + return null; + } +} + export function hasNativeTerminalSurface() { return resolveNativeTerminalSurfaceView() !== null; } + +/** Whether the installed native binary can render a streamed extended replay. */ +export function supportsNativeReplayStreaming(): boolean { + return (getNativeTerminalStreamingRevision() ?? 0) >= NATIVE_TERMINAL_REPLAY_STREAMING_REVISION; +} diff --git a/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts b/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts index 88323719af00..b9c09ba700cf 100644 --- a/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts +++ b/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { getTerminalBufferReplayKey, getTerminalSurfaceReplayBuffer } from "./terminalBufferReplay"; +import { getTerminalBufferReplayKey, isTerminalBufferReplayPaused } from "./terminalBufferReplay"; describe("terminalBufferReplay", () => { it("keys replay readiness by terminal identity and font metrics", () => { @@ -12,32 +12,29 @@ describe("terminalBufferReplay", () => { ).toBe("env-1:thread-1:default:10"); }); - it("shows terminal history while replay key is unset (initial mount / after key change)", () => { + it("pauses replay only while an older font layout is still ready", () => { const replayKey = getTerminalBufferReplayKey({ terminalKey: "env-1:thread-1:default", fontSize: 10, }); expect( - getTerminalSurfaceReplayBuffer({ - buffer: "fastfetch output", + isTerminalBufferReplayPaused({ replayKey, readyReplayKey: null, }), - ).toBe("fastfetch output"); + ).toBe(false); expect( - getTerminalSurfaceReplayBuffer({ - buffer: "fastfetch output", + isTerminalBufferReplayPaused({ replayKey, readyReplayKey: "env-1:thread-1:default:11", }), - ).toBe(""); + ).toBe(true); expect( - getTerminalSurfaceReplayBuffer({ - buffer: "fastfetch output", + isTerminalBufferReplayPaused({ replayKey, readyReplayKey: replayKey, }), - ).toBe("fastfetch output"); + ).toBe(false); }); }); diff --git a/apps/mobile/src/features/terminal/terminalBufferReplay.ts b/apps/mobile/src/features/terminal/terminalBufferReplay.ts index edbdeb37f3aa..043ee991626c 100644 --- a/apps/mobile/src/features/terminal/terminalBufferReplay.ts +++ b/apps/mobile/src/features/terminal/terminalBufferReplay.ts @@ -1,5 +1,3 @@ -import { terminalDebugLog } from "./terminalDebugLog"; - export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180; export function getTerminalBufferReplayKey(input: { @@ -9,21 +7,9 @@ export function getTerminalBufferReplayKey(input: { return `${input.terminalKey}:${input.fontSize}`; } -export function getTerminalSurfaceReplayBuffer(input: { - readonly buffer: string; +export function isTerminalBufferReplayPaused(input: { readonly replayKey: string; readonly readyReplayKey: string | null; -}): string { - // Pass live buffer whenever ready key is unset or matches. Only return "" when ready key is - // stale vs current replay key (e.g. mid font-size transition). - if (input.readyReplayKey !== null && input.readyReplayKey !== input.replayKey) { - terminalDebugLog("replay:stale-key-hiding-buffer", { - replayKey: input.replayKey, - readyReplayKey: input.readyReplayKey, - bufferLen: input.buffer.length, - }); - return ""; - } - - return input.buffer; +}): boolean { + return input.readyReplayKey !== null && input.readyReplayKey !== input.replayKey; } diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 2f8ce1377720..7ae2fbb1595e 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { - EMPTY_TERMINAL_BUFFER_STATE, + EMPTY_TERMINAL_SESSION_STATE, type KnownTerminalSession, } from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -58,11 +58,13 @@ function makeKnownSession(input: { updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", } : null, - output: EMPTY_TERMINAL_BUFFER_STATE.output, + output: EMPTY_TERMINAL_SESSION_STATE.output, status: input.status, error: null, hasRunningSubprocess: false, updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", + replayStartVersion: 0, + replayCompleteVersion: 0, version: 1, lifecycleVersion: 1, }, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 3a8c3ad71e69..6e8013e9cbd5 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -59,6 +59,7 @@ const makeTerminalManagerLayer = ( Layer.succeed(TerminalManager.TerminalManager, { ...overrides, attachStream: () => Effect.die(new Error("unused")), + readSnapshot: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index f631992e7ae3..3bf1dbc4f0a1 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2,6 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, + DEFAULT_TERMINAL_REPLAY_BYTES, + EXTENDED_TERMINAL_REPLAY_BYTES, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, @@ -53,8 +55,13 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { readonly pid: number; writeFailure: unknown | undefined; resizeFailure: unknown | undefined; + killObserver: ((signal: string | undefined) => void) | undefined; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + private readonly pausedData: string[] = []; + pauseCalls = 0; + resumeCalls = 0; + outputPaused = false; killed = false; constructor(pid: number) { @@ -78,6 +85,24 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { kill(signal?: string): void { this.killed = true; this.killSignals.push(signal); + this.killObserver?.(signal); + } + + pauseOutput(): void { + if (this.outputPaused) return; + this.outputPaused = true; + this.pauseCalls += 1; + } + + resumeOutput(): void { + if (!this.outputPaused) return; + this.outputPaused = false; + this.resumeCalls += 1; + while (!this.outputPaused) { + const data = this.pausedData.shift(); + if (data === undefined) break; + this.notifyData(data); + } } onData(callback: (data: string) => void): () => void { @@ -95,6 +120,14 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } emitData(data: string): void { + if (this.outputPaused) { + this.pausedData.push(data); + return; + } + this.notifyData(data); + } + + private notifyData(data: string): void { for (const listener of this.dataListeners) { listener(data); } @@ -214,6 +247,13 @@ const multiTerminalHistoryLogPath = ( ); interface CreateManagerOptions { + historyTargetBytes?: number; + historyMaxBytes?: number; + replayHistoryTargetBytes?: number; + replayHistoryMaxBytes?: number; + outputBatchWindowMs?: number; + outputBatchMaxBytes?: number; + pendingProcessEventMaxBytes?: number; shellResolver?: () => string; env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ @@ -228,11 +268,11 @@ interface CreateManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; - historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; resolveProviderInstanceEnvironment?: Parameters< typeof TerminalManager.makeWithOptions >[0]["resolveProviderInstanceEnvironment"]; + managerScope?: Scope.Scope; } interface ManagerFixture { @@ -244,7 +284,6 @@ interface ManagerFixture { } const createManager = ( - historyLineLimit = 5, options: CreateManagerOptions = {}, ): Effect.Effect< ManagerFixture, @@ -258,13 +297,30 @@ const createManager = ( const logsDir = join(baseDir, "userdata", "logs", "terminals"); const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); - const manager = yield* TerminalManager.makeWithOptions({ + const managerEffect = TerminalManager.makeWithOptions({ logsDir, - historyLineLimit, - ptyAdapter, - ...(options.historyByteLimit !== undefined - ? { historyByteLimit: options.historyByteLimit } + ...(options.historyTargetBytes !== undefined + ? { historyTargetBytes: options.historyTargetBytes } + : {}), + ...(options.historyMaxBytes !== undefined + ? { historyMaxBytes: options.historyMaxBytes } + : {}), + ...(options.replayHistoryTargetBytes !== undefined + ? { replayHistoryTargetBytes: options.replayHistoryTargetBytes } + : {}), + ...(options.replayHistoryMaxBytes !== undefined + ? { replayHistoryMaxBytes: options.replayHistoryMaxBytes } : {}), + ...(options.outputBatchWindowMs !== undefined + ? { outputBatchWindowMs: options.outputBatchWindowMs } + : {}), + ...(options.outputBatchMaxBytes !== undefined + ? { outputBatchMaxBytes: options.outputBatchMaxBytes } + : {}), + ...(options.pendingProcessEventMaxBytes !== undefined + ? { pendingProcessEventMaxBytes: options.pendingProcessEventMaxBytes } + : {}), + ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined @@ -282,6 +338,9 @@ const createManager = ( ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } : {}), }); + const manager = yield* options.managerScope === undefined + ? managerEffect + : managerEffect.pipe(Effect.provideService(Scope.Scope, options.managerScope)); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => Ref.update(eventsRef, (events) => [...events, event]), @@ -302,105 +361,6 @@ const createManager = ( const withHostPlatform = (platform: NodeJS.Platform) => Layer.succeed(HostProcessPlatform, platform); -// Apply the existing line policy, then find the longest code-point-aligned byte tail. -function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): string { - const terminated = text.endsWith("\n"); - const lines = text.split("\n"); - if (terminated) lines.pop(); - const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n"); - const capped = terminated ? `${retained}\n` : retained; - if (Buffer.byteLength(capped) <= maxBytes) return capped; - const points = Array.from(capped); - let start = points.length; - let bytes = 0; - while (start > 0) { - const next = Buffer.byteLength(points[start - 1]!); - if (bytes + next > maxBytes) break; - bytes += next; - start -= 1; - } - return points.slice(start).join(""); -} - -it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequences, and clear", () => { - let randomSeed = 0x20260904; - const fragments = [ - "", - "a", - "\n", - "\n\n", - "\r", - "\r\n", - "café", - "名", - "🚀", - "\u001b[31m", - "\u001b[0m", - "\u001b]8;;url\u0007", - "\ud83d", - "\ude80", - ]; - const nextFragment = () => { - randomSeed = (Math.imul(randomSeed, 1_664_525) + 1_013_904_223) >>> 0; - return fragments[randomSeed % fragments.length]!; - }; - - for (const maxBytes of [0, 3, 8, 64, Infinity]) { - for (const maxLines of [0, 1, 3, 5, 5_000]) { - let expected = retainedHistory("before\ninitial\n", maxLines, maxBytes); - const history = new TerminalManager.BoundedTerminalHistory( - maxLines, - "before\ninitial\n", - maxBytes, - ); - expect(history.value()).toBe(expected); - - for (let step = 0; step < 300; step += 1) { - if (step % 73 === 0) { - history.clear(); - expected = ""; - expect(history.value()).toBe(expected); - } - const chunk = nextFragment() + nextFragment(); - history.append(chunk); - expected = retainedHistory(expected + chunk, maxLines, maxBytes); - expect(history.value()).toBe(expected); - } - } - } -}); - -it("bounds long partial lines and joins surrogate pairs across chunk boundaries", () => { - const maxBytes = 65_539; - let expected = ""; - const history = new TerminalManager.BoundedTerminalHistory(5_000, "", maxBytes); - const writes = [ - "a".repeat(16_383) + "😀" + "b".repeat(70_000), - "\r" + "c".repeat(70_000) + "\ud83d", - "\ude80" + "d".repeat(100), - "\uFEFF" + "名".repeat(30_000), - ]; - for (const text of writes) { - history.append(text); - expected = retainedHistory(expected + text, 5_000, maxBytes); - expect(history.value()).toBe(expected); - expect(Buffer.byteLength(history.value())).toBeLessThanOrEqual(maxBytes); - } -}); - -it("preserves retained lines as older storage is compacted", () => { - for (const maxLines of [3, 5_000]) { - let expected = ""; - const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected); - for (let batch = 0; batch < 40; batch += 1) { - const chunk = Array.from({ length: 300 }, (_, line) => `${batch}:${line}\n`).join(""); - history.append(chunk); - expected = retainedHistory(expected + chunk, maxLines); - expect(history.value()).toBe(expected); - } - } -}); - it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, @@ -448,15 +408,34 @@ it.layer( }), ); - it.effect("keeps attach streams live when a terminal id is closed and reopened", () => + it.effect("omits replay markers for clients that did not request replayBytes", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); + // Released clients decode the attach stream against a union without the + // replay marker events; sending them would fail the whole stream there. + const { manager } = yield* createManager(); + yield* manager.open(openInput()); + const attachEvents = yield* Ref.make>([]); const unsubscribe = yield* manager.attachStream(openInput(), (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const events = yield* Ref.get(attachEvents); + expect(events.map((event) => event.type)).toEqual(["snapshot"]); + }), + ); + + it.effect("keeps attach streams live when a terminal id is closed and reopened", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + { ...openInput(), replayBytes: DEFAULT_TERMINAL_REPLAY_BYTES }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* manager.close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, @@ -465,7 +444,13 @@ it.layer( yield* manager.open(openInput()); const events = yield* Ref.get(attachEvents); - expect(events.map((event) => event.type)).toEqual(["snapshot", "closed", "snapshot"]); + expect(events.map((event) => event.type)).toEqual([ + "replay-start", + "snapshot", + "replay-complete", + "closed", + "snapshot", + ]); expect( events.filter((event) => event.type === "snapshot").map((event) => event.snapshot.status), ).toEqual(["running", "running"]); @@ -569,6 +554,25 @@ it.layer( fs.writeFileString(filePath, contents), ); + interface RecordedHistoryWrite { + readonly contents: string; + readonly flag: FileSystem.OpenFlag | undefined; + } + + const recordHistoryWrites = ( + fileSystem: FileSystem.FileSystem, + writes: Array, + ): FileSystem.FileSystem => + FileSystem.FileSystem.of({ + ...fileSystem, + writeFileString: (filePath, contents, options) => + Effect.sync(() => { + if (filePath.endsWith(".log")) { + writes.push({ contents, flag: options?.flag }); + } + }).pipe(Effect.andThen(fileSystem.writeFileString(filePath, contents, options))), + }); + it.effect("reports a missing cwd without an artificial cause", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -630,7 +634,7 @@ it.layer( it.effect("supports asynchronous PTY spawn effects", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ ptyAdapter: new FakePtyAdapter("async"), }); @@ -745,6 +749,25 @@ it.layer( }), ); + it.effect("ignores duplicate resize requests", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput({ cols: 120, rows: 30 })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + yield* manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 120, + rows: 30, + }); + + expect(process.resizeCalls).toEqual([]); + }), + ); + it.effect("resizes running terminal on open when a different size is requested", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -1017,7 +1040,7 @@ it.layer( readonly childCommand: string | null; readonly processIds: ReadonlyArray; } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - const { manager, getEvents } = yield* createManager(5, { + const { manager, getEvents } = yield* createManager({ subprocessInspector: () => Effect.succeed(inspect), subprocessPollIntervalMs: 20, }); @@ -1056,7 +1079,7 @@ it.layer( it.effect("does not invoke subprocess polling until a terminal session is running", () => Effect.gen(function* () { let checks = 0; - const { manager } = yield* createManager(5, { + const { manager } = yield* createManager({ subprocessInspector: () => { checks += 1; return Effect.succeed({ @@ -1104,7 +1127,7 @@ it.layer( }), }; - const { manager, getEvents } = yield* createManager(5, { + const { manager, getEvents } = yield* createManager({ subprocessPollIntervalMs: 20, }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), @@ -1165,7 +1188,7 @@ it.layer( }), }; - const { manager, getEvents } = yield* createManager(5, { + const { manager, getEvents } = yield* createManager({ subprocessPollIntervalMs: 20, }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), @@ -1208,7 +1231,7 @@ it.layer( it.effect("uses process snapshots from the resource monitor", () => Effect.gen(function* () { let snapshotCalls = 0; - const { manager, getEvents } = yield* createManager(5, { + const { manager, getEvents } = yield* createManager({ subprocessPollIntervalMs: 20, processTable: Effect.sync(() => { snapshotCalls += 1; @@ -1252,7 +1275,7 @@ it.layer( ), }; - const { manager, getEvents } = yield* createManager(5, { + const { manager, getEvents } = yield* createManager({ subprocessPollIntervalMs: 20, processTable: Effect.fail("sidecar unavailable").pipe( Effect.mapError((cause) => cause as never), @@ -1288,45 +1311,640 @@ it.layer( }), ); - it.effect("caps persisted history to configured line limit", () => + it.effect("appends normal terminal output without rewriting history", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(3); + const fileSystem = yield* FileSystem.FileSystem; + const writes: Array = []; + const { manager, ptyAdapter, logsDir } = yield* createManager().pipe( + Effect.provideService(FileSystem.FileSystem, recordHistoryWrites(fileSystem, writes)), + ); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; - process.emitData("line1\nline2\nline3\nline4\n"); + const output = Array.from({ length: 100 }, (_, index) => `redraw ${index}\r`).join(""); + for (let index = 0; index < 100; index += 1) { + process.emitData(`redraw ${index}\r`); + } yield* manager.close({ threadId: "thread-1" }); - const reopened = yield* manager.open(openInput()); - const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); - expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); + expect(writes.length).toBeLessThan(100); + expect(writes.every((write) => write.flag === "a")).toBe(true); + expect(writes.map((write) => write.contents).join("")).toBe(output); + expect(yield* historyLogPath(logsDir).pipe(Effect.flatMap(readFileString))).toBe(output); }), ); - it.effect("caps incrementally appended history without losing partial or empty lines", () => + it.effect("uses truncation only for clear and restart resets", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(3); + const fileSystem = yield* FileSystem.FileSystem; + const writes: Array = []; + const { manager, ptyAdapter, logsDir } = yield* createManager().pipe( + Effect.provideService(FileSystem.FileSystem, recordHistoryWrites(fileSystem, writes)), + ); + yield* manager.open(openInput()); + const firstProcess = ptyAdapter.processes[0]; + expect(firstProcess).toBeDefined(); + if (!firstProcess) return; + + firstProcess.emitData("before clear\r"); + yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); + firstProcess.emitData("before restart\r"); + yield* manager.restart(restartInput()); + + expect(writes.filter((write) => write.flag === "w")).toEqual([ + { contents: "", flag: "w" }, + { contents: "", flag: "w" }, + ]); + expect(yield* historyLogPath(logsDir).pipe(Effect.flatMap(readFileString))).toBe(""); + }), + ); + + it.effect("compacts carriage-return history at the configured byte limit", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager({ + historyTargetBytes: 12, + historyMaxBytes: 24, + }); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; - process.emitData("line1\n"); - process.emitData("\n"); - process.emitData("line3"); - process.emitData("-continued\nline4"); + process.emitData("old-one\r"); + process.emitData("old-two\r"); + process.emitData("new-one\rnew-two\r"); yield* manager.close({ threadId: "thread-1" }); - const reopened = yield* manager.open(openInput()); - expect(reopened.history).toBe("\nline3-continued\nline4"); + const persisted = yield* historyLogPath(logsDir).pipe(Effect.flatMap(readFileString)); + expect(persisted).toBe("new-two\r"); + expect(Buffer.byteLength(persisted)).toBeLessThanOrEqual(12); + }), + ); + + it.effect("compacts oversized existing history on open", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager({ + historyTargetBytes: 12, + historyMaxBytes: 24, + }); + const filePath = yield* historyLogPath(logsDir); + yield* writeFileString(filePath, "old-one\rold-two\rnew-one\rnew-two\r"); + + const opened = yield* manager.open(openInput()); + + expect(opened.history).toBe("new-two\r"); + expect(yield* readFileString(filePath)).toBe("new-two\r"); + }), + ); + + it.effect("does not start compacted history inside a terminal control sequence", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager({ + historyTargetBytes: 8, + historyMaxBytes: 12, + }); + const filePath = yield* historyLogPath(logsDir); + yield* writeFileString(filePath, "123456\u001b[31mhello"); + + const opened = yield* manager.open(openInput()); + + expect(opened.history).toBe("hello\r\n"); + expect(yield* readFileString(filePath)).toBe("hello\r\n"); + }), + ); + + it.effect("starts a reopened session on a fresh line after a mid-line history tail", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager(); + const filePath = yield* historyLogPath(logsDir); + yield* writeFileString(filePath, "user@host:~$ "); + + const opened = yield* manager.open(openInput()); + + expect(opened.history).toBe("user@host:~$ \r\n"); + }), + ); + + it.effect("neutralizes modes left dangling by a session that died mid-app", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager(); + const filePath = yield* historyLogPath(logsDir); + // The previous process died inside a full-screen app: alternate screen + // entered, cursor hidden, mouse tracking on, and no exit sequences. + yield* writeFileString(filePath, "\u001b[?1049h\u001b[?25l\u001b[?1002happ-frame"); + + const opened = yield* manager.open(openInput()); + + expect(opened.history).toBe( + "\u001b[?1049h\u001b[?25l\u001b[?1002happ-frame\u001b[?1049l\u001b[?25h\u001b[?1002l\r\n", + ); + expect(yield* readFileString(filePath)).toBe(opened.history); + }), + ); + + it.effect("keeps durable history larger than snapshots sent to clients", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager({ + historyTargetBytes: 64, + historyMaxBytes: 96, + replayHistoryTargetBytes: 12, + replayHistoryMaxBytes: 24, + }); + const filePath = yield* historyLogPath(logsDir); + const durableHistory = "old-one\rold-two\rnew-one\rnew-two\r"; + yield* writeFileString(filePath, durableHistory); + + const opened = yield* manager.open(openInput()); + const resynced = yield* manager.readSnapshot({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + + expect(opened.history).toBe("new-two\r"); + expect(Option.getOrThrow(resynced).history).toBe("new-two\r"); + expect(yield* readFileString(filePath)).toBe(durableHistory); + }), + ); + + it.effect("re-establishes sticky DEC modes that aged out of the bounded replay", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager({ + replayHistoryTargetBytes: 32, + replayHistoryMaxBytes: 64, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + // A btop-style takeover whose mode switches scroll out of the retained + // replay tail long before the app exits. + process.emitData("\u001b[?1049h\u001b[?25l\u001b[?1002h\u001b[?1006h"); + process.emitData(`${"x".repeat(256)}end-one`); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("end-one")), + ), + "1200 millis", + ); + + const resynced = yield* manager.readSnapshot({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + const history = Option.getOrThrow(resynced).history; + expect(history.startsWith("\u001b[?1049h\u001b[?25l\u001b[?1002h\u001b[?1006h")).toBe(true); + expect(history).toContain("end-one"); + + // Once the app restores the modes inside the retained window, the tail + // itself is authoritative and no prefix is prepended. + process.emitData("\u001b[?1049l\u001b[?25h\u001b[?1002l\u001b[?1006l end-two"); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("end-two")), + ), + "1200 millis", + ); + + const restored = yield* manager.readSnapshot({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + expect(Option.getOrThrow(restored).history).not.toContain("\u001b[?1049h"); + }), + ); + + it.effect("treats the alternate-screen modes as one state and honors full resets", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager({ + replayHistoryTargetBytes: 32, + replayHistoryMaxBytes: 64, + }); + yield* manager.open(openInput()); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + // Entering via one alternate-screen mode and leaving via another must + // not leave a sibling recorded as active. + ptyProcess.emitData("\u001b[?47h\u001b[?1049h\u001b[?1049l"); + // A full reset restores power-on defaults for everything else too. + ptyProcess.emitData("\u001b[?1002h\u001bc"); + ptyProcess.emitData(`${"x".repeat(256)}aged-out`); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("aged-out")), + ), + "1200 millis", + ); + + const resynced = yield* manager.readSnapshot({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + const history = Option.getOrThrow(resynced).history; + expect(history).not.toContain("\u001b[?47h"); + expect(history).not.toContain("\u001b[?1049h"); + expect(history).not.toContain("\u001b[?1002h"); + }), + ); + + it.effect("restores the mode state at the tail start when the app relaunched inside it", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager({ + replayHistoryTargetBytes: 32, + replayHistoryMaxBytes: 64, + }); + yield* manager.open(openInput()); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + // The first app's entry ages out of the tail; its frames, exit, and the + // second app's entry stay. Replaying the tail on the primary screen + // would paint the first app's frames there for the shell to inherit. + ptyProcess.emitData(`\u001b[?1049h${"a".repeat(30)}`); + ptyProcess.emitData("a".repeat(30)); + ptyProcess.emitData(`\u001b[?1049l\u001b[?1049h${"b".repeat(6)}`); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("bbbbbb")), + ), + "1200 millis", + ); + + const resynced = yield* manager.readSnapshot({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + const history = Option.getOrThrow(resynced).history; + expect(history.startsWith("\u001b[?1049ha")).toBe(true); + expect(history.endsWith("\u001b[?1049l\u001b[?1049hbbbbbb")).toBe(true); + }), + ); + + it.effect("keeps tracked modes through a DECSTR soft reset", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager({ + replayHistoryTargetBytes: 32, + replayHistoryMaxBytes: 64, + }); + yield* manager.open(openInput()); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + // libghostty-vt leaves these modes untouched on `CSI !p`, so the + // renderer is still in the alternate screen with mouse tracking on. + ptyProcess.emitData("\u001b[?1049h\u001b[?1002h\u001b[!p"); + ptyProcess.emitData(`${"x".repeat(256)}aged-out`); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("aged-out")), + ), + "1200 millis", + ); + + const resynced = yield* manager.readSnapshot({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + expect(Option.getOrThrow(resynced).history.startsWith("\u001b[?1049h\u001b[?1002h")).toBe( + true, + ); + }), + ); + + it.effect("neutralizes dangling modes when the process dies without restoring them", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + yield* manager.open(openInput()); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + ptyProcess.emitData("\u001b[?1049h\u001b[?25lapp-frame"); + ptyProcess.emitExit({ exitCode: 137, signal: 9 }); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); + + const snapshot = yield* manager.readSnapshot({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + const history = Option.getOrThrow(snapshot).history; + // The frozen final frame replays, then the resets bring the renderer + // back to a sane primary screen for the exit notice and later reopen. + const frameIndex = history.indexOf("app-frame"); + expect(frameIndex).toBeGreaterThanOrEqual(0); + const tail = history.slice(frameIndex); + expect(tail).toContain("\u001b[?1049l"); + expect(tail).toContain("\u001b[?25h"); + expect(history.startsWith("\u001b[?1049h")).toBe(true); + }), + ); + + it.effect("wiggles the PTY size on attach only while the alternate screen is active", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + yield* manager.open(openInput({ cols: 120, rows: 40 })); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + // A shell at its prompt must not receive a repaint-inducing resize. + const attachEvents = yield* Ref.make>([]); + const shellUnsubscribe = yield* manager.attachStream( + openInput({ cols: 120, rows: 40 }), + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + shellUnsubscribe(); + expect(ptyProcess.resizeCalls).toHaveLength(0); + + // A full-screen app only repaints dirty cells; attach must ask it to + // repaint via SIGWINCH because the replay cannot rebuild its screen. + ptyProcess.emitData("\u001b[?1049happ-frame"); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("app-frame")), + ), + "1200 millis", + ); + const appUnsubscribe = yield* manager.attachStream( + openInput({ cols: 120, rows: 40 }), + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + appUnsubscribe(); + expect(ptyProcess.resizeCalls).toEqual([ + { cols: 119, rows: 40 }, + { cols: 120, rows: 40 }, + ]); + }), + ); + + it.effect("delivers output produced by the attach repaint once, after extended replay", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager({ outputBatchWindowMs: 0 }); + yield* manager.open(openInput({ cols: 120, rows: 40 })); + const process = ptyAdapter.processes[0]!; + process.emitData("\u001b[?1049hbefore-repaint"); + const repaintProcessed = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" && event.data === "attach-repaint" + ? Deferred.succeed(repaintProcessed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const resize = process.resize.bind(process); + process.resize = (cols, rows) => { + resize(cols, rows); + if (cols === 119) process.emitData("attach-repaint"); + }; + const events: Array<{ event: TerminalAttachStreamEvent; delivery: string | undefined }> = []; + const attach = yield* manager + .attachStream( + { ...openInput({ cols: 120, rows: 40 }), replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES }, + (event, delivery) => + Effect.sync(() => { + events.push({ event, delivery }); + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(repaintProcessed); + const stop = yield* Fiber.join(attach); + stop(); + const completion = events.findIndex(({ event }) => event.type === "replay-complete"); + expect(completion).toBeGreaterThan(0); + const text = (items: typeof events) => + items.map(({ event }) => (event.type === "output" ? event.data : "")).join(""); + expect(text(events.slice(0, completion))).not.toContain("attach-repaint"); + expect(text(events.slice(completion + 1))).toBe("attach-repaint"); + }), + ); + + it.effect("restores the PTY size when attach is cancelled during its repaint hold", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput({ cols: 120, rows: 40 })); + const process = ptyAdapter.processes[0]!; + process.emitData("\u001b[?1049happ-frame"); + const resized = yield* Deferred.make(); + const resize = process.resize.bind(process); + process.resize = (cols, rows) => { + resize(cols, rows); + if (cols === 119) Deferred.doneUnsafe(resized, Effect.void); + }; + const attach = yield* manager + .attachStream(openInput({ cols: 120, rows: 40 }), () => Effect.void) + .pipe(Effect.forkScoped); + yield* Deferred.await(resized); + yield* Fiber.interrupt(attach); + expect(process.resizeCalls).toEqual([ + { cols: 119, rows: 40 }, + { cols: 120, rows: 40 }, + ]); + }), + ); + + it.effect("drops mouse reports once the application stops tracking the mouse", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + yield* manager.open(openInput()); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + ptyProcess.emitData("\u001b[?1002h\u001b[?1006h"); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("1002h")), + ), + "1200 millis", + ); + yield* manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "\u001b[<0;10;5M", + }); + expect(ptyProcess.writes).toEqual(["\u001b[<0;10;5M"]); + + // The release raced the application's exit: it disabled tracking before + // the report arrived, so forwarding it would type junk into the shell. + ptyProcess.emitData("\u001b[?1002l\u001b[?1006l"); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("1002l")), + ), + "1200 millis", + ); + yield* manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "\u001b[<0;10;5m", + }); + yield* manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "\u001b[M#!!", + }); + expect(ptyProcess.writes).toEqual(["\u001b[<0;10;5M"]); + + yield* manager.write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "ls\r", + }); + expect(ptyProcess.writes).toEqual(["\u001b[<0;10;5M", "ls\r"]); + }), + ); + + it.effect("drops a release that races the application's exit through its hold window", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + yield* manager.open(openInput()); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + ptyProcess.emitData("\u001b[?1002h\u001b[?1006h"); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("1002h")), + ), + "1200 millis", + ); + + // The press told the application to quit; its restore sequences arrive + // while the release is still inside its hold window. + const release = yield* manager + .write({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + data: "\u001b[<0;10;5m", + }) + .pipe(Effect.forkScoped); + ptyProcess.emitData("\u001b[?1002l\u001b[?1006l"); + yield* Fiber.join(release); + + expect(ptyProcess.writes).toEqual([]); + }), + ); + + it.effect("delivers a write queued behind a held release to the restarted process", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + yield* manager.open(openInput()); + const first = ptyAdapter.processes[0]; + expect(first).toBeDefined(); + if (!first) return; + + first.emitData("\u001b[?1002h\u001b[?1006h"); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data.includes("1002h")), + ), + "1200 millis", + ); + + // The typed input queues behind the release's hold window; the restart + // replaces the process before either write reaches the PTY. + const release = yield* manager + .write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "\u001b[<0;10;5m" }) + .pipe(Effect.forkScoped); + const typed = yield* manager + .write({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, data: "ls\r" }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* manager.restart(restartInput()); + yield* Fiber.join(release); + yield* Fiber.join(typed); + + const second = ptyAdapter.processes[1]; + expect(second).toBeDefined(); + expect(first.writes).toEqual([]); + expect(second?.writes).toEqual(["ls\r"]); + }), + ); + + it.effect("recovers a partially written append with bounded history", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const failedAppend = yield* Deferred.make(); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + pathOrDescriptor: "terminal-history", + }); + let shouldFailAppend = true; + const recoveringFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + writeFileString: (filePath, contents, options) => { + if ( + filePath.endsWith(".log") && + options?.flag === "a" && + contents.length > 0 && + shouldFailAppend + ) { + shouldFailAppend = false; + return fileSystem + .writeFileString(filePath, contents.slice(0, 4), options) + .pipe( + Effect.andThen(Deferred.succeed(failedAppend, undefined)), + Effect.andThen(Effect.fail(cause)), + ); + } + return fileSystem.writeFileString(filePath, contents, options); + }, + }); + const { manager, ptyAdapter, logsDir } = yield* createManager().pipe( + Effect.provideService(FileSystem.FileSystem, recoveringFileSystem), + ); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("survives recovery\r"); + yield* Deferred.await(failedAppend); + yield* manager.close({ threadId: "thread-1" }); + + expect(yield* historyLogPath(logsDir).pipe(Effect.flatMap(readFileString))).toBe( + "survives recovery\r", + ); + }), + ); + + it.effect("preserves Unicode split across terminal output chunks", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before \ud83d"); + process.emitData("\ude00 after\r"); + yield* manager.close({ threadId: "thread-1" }); + + expect(yield* historyLogPath(logsDir).pipe(Effect.flatMap(readFileString))).toBe( + "before 😀 after\r", + ); }), ); it.effect("bounds persisted and attached history without truncating live output", () => Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(5, { historyByteLimit: 10 }); + const { manager, ptyAdapter, logsDir } = yield* createManager({ + historyTargetBytes: 10, + historyMaxBytes: 10, + replayHistoryTargetBytes: 10, + replayHistoryMaxBytes: 10, + }); const attachEvents = yield* Ref.make>([]); const unsubscribe = yield* manager.attachStream(openInput(), (event) => Ref.update(attachEvents, (events) => [...events, event]), @@ -1336,15 +1954,18 @@ it.layer( const process = ptyAdapter.processes[0]!; for (const text of writes) process.emitData(text); yield* manager.close({ threadId: "thread-1" }); - expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("aa😀\rEND"); + expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("END"); const reopened = yield* manager.open(openInput()); const events = yield* Ref.get(attachEvents); - expect(events.filter((event) => event.type === "output").map((event) => event.data)).toEqual( - writes, - ); + expect( + events + .filter((event) => event.type === "output") + .map((event) => event.data) + .join(""), + ).toEqual(writes.join("")); const snapshot = events.filter((event) => event.type === "snapshot").at(-1)?.snapshot; - expect(snapshot?.history).toBe("aa😀\rEND"); + expect(snapshot?.history).toBe("END\r\n"); expect(snapshot?.sequence).toBe(reopened.sequence); }), ); @@ -1385,21 +2006,22 @@ it.layer( }); }), }); - const { manager, logsDir } = yield* createManager(5, { historyByteLimit: 15 }).pipe( - Effect.provideService(FileSystem.FileSystem, trackedFileSystem), - ); + const { manager, logsDir } = yield* createManager({ + historyTargetBytes: 15, + historyMaxBytes: 15, + }).pipe(Effect.provideService(FileSystem.FileSystem, trackedFileSystem)); const nextPath = yield* historyLogPath(logsDir); sourcePath = source === "current" ? nextPath : path.join(logsDir, "thread-1.log"); yield* fs.writeFileString(sourcePath, "old".repeat(32_768) + "😀\uFEFFnewest\ré"); const snapshot = yield* manager.open(openInput()); - expect(snapshot.history).toBe("\uFEFFnewest\ré"); + expect(snapshot.history).toBe("\uFEFFnewest\ré\r\n"); expect(readRequests).toEqual([15, 10, 5]); expect(closedReads).toBe(1); - expect(Buffer.from(yield* fs.readFile(nextPath)).toString()).toBe("\uFEFFnewest\ré"); + expect(Buffer.from(yield* fs.readFile(nextPath)).toString()).toBe("\uFEFFnewest\ré\r\n"); if (source === "legacy") expect(yield* fs.exists(sourcePath)).toBe(false); yield* manager.close({ threadId: "thread-1" }); - expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré"); + expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré\r\n"); }), ); } @@ -1621,7 +2243,7 @@ it.layer( it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); + const { manager, ptyAdapter } = yield* createManager({ processKillGraceMs: 10 }); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); @@ -1654,7 +2276,7 @@ it.layer( it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { + const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager({ maxRetainedInactiveSessions: 1, }); @@ -1717,7 +2339,7 @@ it.layer( const platform = yield* HostProcessPlatform; const missingShell = platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ shellResolver: () => missingShell, }); ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); @@ -1751,7 +2373,7 @@ it.layer( it.effect("prefers PowerShell over ComSpec for Windows terminals", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", PATH: "C:\\Windows\\System32", @@ -1773,7 +2395,7 @@ it.layer( it.effect("falls back to built-in PowerShell by absolute path on Windows", () => Effect.gen(function* () { const ptyAdapter = new FakePtyAdapter(); - const { manager } = yield* createManager(5, { + const { manager } = yield* createManager({ ptyAdapter, shellResolver: () => "C:\\missing\\custom-shell.exe", env: { @@ -1811,7 +2433,7 @@ it.layer( ["24bit", "custom", "custom"], ] as const) { const env = Object.freeze({ COLORTERM: parentColor }); - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ shellResolver: () => "/bin/sh", env, }).pipe(Effect.provide(withHostPlatform(platform))); @@ -1826,7 +2448,7 @@ it.layer( it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ env: { PORT: "5173", T3CODE_PORT: "3773", @@ -1850,7 +2472,7 @@ it.layer( it.effect("expands provider home paths passed to setup terminals", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5); + const { manager, ptyAdapter } = yield* createManager(); yield* manager.open({ ...openInput(), @@ -1871,7 +2493,7 @@ it.layer( it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ env: { APPIMAGE: "/home/user/T3-Code.AppImage", APPDIR: appDir, @@ -1912,7 +2534,7 @@ it.layer( it.effect("leaves the environment untouched when not launched from an AppImage", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ env: { PATH: "/usr/local/bin:/usr/bin:/bin", LD_LIBRARY_PATH: "/home/user/.local/lib", @@ -1957,7 +2579,7 @@ it.layer( it.effect("resolves a provider instance environment before spawning", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("codex_work"); - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ env: { T3CODE_SECRET: "server-only" }, resolveProviderInstanceEnvironment: (requestedId, env) => Effect.succeed({ @@ -1983,7 +2605,7 @@ it.layer( it.effect("fails closed when a provider instance is missing", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("deleted_instance"); - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ resolveProviderInstanceEnvironment: (requestedId) => Effect.fail( new TerminalProviderInstanceNotFoundError({ @@ -2182,7 +2804,7 @@ it.layer( Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("codex_work"); let providerSecret = "first-secret"; - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ resolveProviderInstanceEnvironment: () => Effect.succeed({ PROVIDER_SECRET: providerSecret }), }); @@ -2202,8 +2824,11 @@ it.layer( const serverSettings = yield* ServerSettings.ServerSettingsService; const path = yield* Path.Path; const providerInstanceId = ProviderInstanceId.make("codex_restart"); - const { manager, ptyAdapter, logsDir } = yield* createManager(2, { - historyByteLimit: 8, + const { manager, ptyAdapter, logsDir } = yield* createManager({ + historyTargetBytes: 8, + historyMaxBytes: 8, + replayHistoryTargetBytes: 8, + replayHistoryMaxBytes: 8, resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => TerminalManager.resolveProviderInstanceTerminalEnvironment({ serverSettings, @@ -2280,7 +2905,7 @@ it.layer( Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("codex_work"); let providerAvailable = true; - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ resolveProviderInstanceEnvironment: (requestedId) => providerAvailable ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) @@ -2309,7 +2934,7 @@ it.layer( it.effect("fails closed when attaching would create a missing provider terminal", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("deleted_instance"); - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ resolveProviderInstanceEnvironment: (requestedId) => Effect.fail( new TerminalProviderInstanceNotFoundError({ @@ -2333,7 +2958,7 @@ it.layer( it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ shellResolver: () => "/bin/zsh", }); yield* manager.open(openInput()); @@ -2347,7 +2972,7 @@ it.layer( it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { + const { manager, ptyAdapter, getEvents } = yield* createManager({ ptyAdapter: new FakePtyAdapter("async"), }); @@ -2369,7 +2994,7 @@ it.layer( it.effect("pushes PTY callbacks to direct event subscribers", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ ptyAdapter: new FakePtyAdapter("async"), }); const subscriberEvents = yield* Ref.make>([]); @@ -2473,12 +3098,13 @@ it.layer( "streams attach snapshots followed by live events without duplicate start snapshots", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ ptyAdapter: new FakePtyAdapter("async"), }); const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Ref.update(attachEvents, (events) => [...events, event]), + const unsubscribe = yield* manager.attachStream( + { ...openInput(), replayBytes: DEFAULT_TERMINAL_REPLAY_BYTES }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); @@ -2487,6 +3113,11 @@ it.layer( if (!process) return; expect(yield* Ref.get(attachEvents)).toMatchObject([ + { + type: "replay-start", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }, { type: "snapshot", snapshot: { @@ -2494,6 +3125,11 @@ it.layer( terminalId: DEFAULT_TERMINAL_ID, }, }, + { + type: "replay-complete", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }, ]); process.emitData("hello from attach\n"); @@ -2510,9 +3146,85 @@ it.layer( }), ); + it.effect("streams extended persisted history before live terminal output", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager(); + const history = Array.from( + { length: 12_000 }, + (_, index) => `${String(index).padStart(5, "0")} ${"x".repeat(64)}\n`, + ).join(""); + yield* historyLogPath(logsDir).pipe( + Effect.flatMap((filePath) => writeFileString(filePath, history)), + ); + + const deliveries: Array<{ + readonly event: TerminalAttachStreamEvent; + readonly delivery: "replay" | "live"; + }> = []; + const unsubscribe = yield* manager.attachStream( + { ...openInput(), replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES }, + (event, delivery) => Effect.sync(() => deliveries.push({ event, delivery })), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + expect(deliveries[0]).toMatchObject({ + event: { type: "replay-start" }, + delivery: "replay", + }); + expect(deliveries[1]).toMatchObject({ + event: { type: "snapshot", snapshot: { history: "" } }, + delivery: "replay", + }); + const replayEvents = deliveries + .map(({ event }) => event) + .filter((event) => event.type === "output"); + expect(replayEvents.length).toBeGreaterThan(1); + expect(replayEvents.every((event) => Buffer.byteLength(event.data) <= 64 * 1024)).toBe(true); + expect(replayEvents.map((event) => event.data).join("")).toBe(history); + expect(deliveries.at(-1)?.event.type).toBe("replay-complete"); + expect(deliveries.every(({ delivery }) => delivery === "replay")).toBe(true); + }), + ); + + it.effect("cancels extended history replay when its attach scope closes", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); + const history = "history line\n".repeat(20_000); + yield* historyLogPath(logsDir).pipe( + Effect.flatMap((filePath) => writeFileString(filePath, history)), + ); + const replayStarted = yield* Deferred.make(); + const replayChunks = yield* Ref.make(0); + const attachFiber = yield* manager + .attachStream({ ...openInput(), replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES }, (event) => { + if (event.type !== "output") return Effect.void; + return Ref.update(replayChunks, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(replayStarted, undefined)), + Effect.andThen(Effect.never), + ); + }) + .pipe(Effect.forkScoped); + + yield* Deferred.await(replayStarted); + yield* Fiber.interrupt(attachFiber); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + process.emitData("after cancel\n"); + process.emitExit({ exitCode: 0, signal: 0 }); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); + + expect(yield* Ref.get(replayChunks)).toBe(1); + }), + ); + it.effect("buffers attach output delivered during the initial snapshot callback", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter } = yield* createManager({ ptyAdapter: new FakePtyAdapter("async"), }); yield* manager.open(openInput()); @@ -2522,14 +3234,16 @@ it.layer( if (!process) return; const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Effect.gen(function* () { - yield* Ref.update(attachEvents, (events) => [...events, event]); - if (event.type === "snapshot") { - yield* Effect.sync(() => process.emitData("during snapshot\n")); - yield* Effect.yieldNow; - } - }), + const unsubscribe = yield* manager.attachStream( + { ...openInput(), replayBytes: DEFAULT_TERMINAL_REPLAY_BYTES }, + (event) => + Effect.gen(function* () { + yield* Ref.update(attachEvents, (events) => [...events, event]); + if (event.type === "snapshot") { + yield* Effect.sync(() => process.emitData("during snapshot\n")); + yield* Effect.yieldNow; + } + }), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); @@ -2541,15 +3255,55 @@ it.layer( ); expect(yield* Ref.get(attachEvents)).toMatchObject([ + { type: "replay-start" }, { type: "snapshot" }, + { type: "replay-complete" }, { type: "output", data: "during snapshot\n" }, ]); }), ); + it.effect("does not duplicate pending output across extended replay and live events", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager({ + ptyAdapter: new FakePtyAdapter("async"), + }); + yield* manager.open(openInput()); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("pending\n"); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + { ...openInput(), replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* waitFor( + Effect.map(getEvents, (events) => + events.some((event) => event.type === "output" && event.data === "pending\n"), + ), + "1200 millis", + ); + + const replayedText = (yield* Ref.get(attachEvents)) + .map((event) => { + if (event.type === "snapshot") return event.snapshot.history; + if (event.type === "output") return event.data; + return ""; + }) + .join(""); + expect(replayedText.split("pending\n")).toHaveLength(2); + }), + ); + it.effect("preserves queued PTY output ordering through exit callbacks", () => Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { + const { manager, ptyAdapter, getEvents } = yield* createManager({ ptyAdapter: new FakePtyAdapter("async"), }); @@ -2567,7 +3321,7 @@ it.layer( const relevant = events.filter( (event) => event.type === "output" || event.type === "exited", ); - return relevant.length >= 3; + return relevant.length >= 2; }), "1200 millis", ); @@ -2576,9 +3330,8 @@ it.layer( (event) => event.type === "output" || event.type === "exited", ); expect(relevant).toEqual([ - expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), - expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), - expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), + expect.objectContaining({ type: "output", data: "first\nsecond\n", sequence: 2 }), + expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 3 }), ]); const attachEvents = yield* Ref.make>([]); @@ -2594,26 +3347,145 @@ it.layer( const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); expect(snapshot).toBeDefined(); if (!snapshot || snapshot.type !== "snapshot") return; - expect(snapshot.snapshot.sequence).toBe(4); + expect(snapshot.snapshot.sequence).toBe(3); + }), + ); + + it.effect("coalesces a 128 KB PTY burst into two bounded output events", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager({ + ptyAdapter: new FakePtyAdapter("async"), + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const chunk = "x".repeat(1_024); + for (let index = 0; index < 64; index += 1) { + process.emitData(chunk); + } + const oversizedChunk = chunk.repeat(64); + process.emitData(oversizedChunk); + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); + + const outputEvents = (yield* getEvents).filter((event) => event.type === "output"); + expect(outputEvents).toHaveLength(2); + expect(outputEvents.every((event) => Buffer.byteLength(event.data) <= 64 * 1024)).toBe(true); + expect(outputEvents.map((event) => event.data).join("")).toBe( + `${chunk.repeat(64)}${oversizedChunk}`, + ); + }), + ); + + it.effect("pauses PTY output while the bounded event backlog drains", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager({ + ptyAdapter: new FakePtyAdapter("async"), + outputBatchMaxBytes: 4, + pendingProcessEventMaxBytes: 8, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("aaaa"); + process.emitData("bbbb"); + + yield* waitFor( + Effect.map( + getEvents, + (events) => + events + .filter((event) => event.type === "output") + .map((event) => event.data) + .join("") === "aaaabbbb", + ), + "1200 millis", + ); + + expect(process.pauseCalls).toBeGreaterThanOrEqual(1); + expect(process.resumeCalls).toBe(process.pauseCalls); + expect(process.outputPaused).toBe(false); + + process.emitExit({ exitCode: 0, signal: 0 }); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); }), ); - it.effect("scoped runtime shutdown stops active terminals cleanly", () => + it.effect("preserves a Unicode scalar split across PTY callbacks", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents, logsDir } = yield* createManager({ + ptyAdapter: new FakePtyAdapter("async"), + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("\ud83d"); + process.emitData("\ude42"); + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); + + const output = (yield* getEvents) + .filter((event) => event.type === "output") + .map((event) => event.data) + .join(""); + expect(output).toBe("🙂"); + expect(yield* historyLogPath(logsDir).pipe(Effect.flatMap(readFileString))).toBe("🙂"); + }), + ); + + it.effect("scoped runtime shutdown flushes history and stops active terminals", () => Effect.gen(function* () { const scope = yield* Scope.make("sequential"); - const { manager, ptyAdapter } = yield* createManager(5, { + const { manager, ptyAdapter, logsDir } = yield* createManager({ processKillGraceMs: 10, - }).pipe(Effect.provideService(Scope.Scope, scope)); + managerScope: scope, + }); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; + const sigtermSent = yield* Effect.callback((resume) => { + process.killObserver = (signal) => { + if (signal === "SIGTERM") { + resume(Effect.void); + } + }; + }).pipe(Effect.forkScoped); + const output = `${"x".repeat(64 * 1024)}\ud83d`; + process.emitData(output); const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + yield* Fiber.join(sigtermSent); yield* TestClock.adjust("10 millis"); yield* Fiber.join(closeScope); + const persisted = yield* historyLogPath(logsDir).pipe(Effect.flatMap(readFileString)); + expect({ + byteLength: Buffer.byteLength(persisted), + codePoints: Array.from(persisted.slice(-4), (character) => character.codePointAt(0)), + length: persisted.length, + }).toEqual({ + byteLength: 64 * 1024 + 3, + codePoints: [120, 120, 120, 65_533], + length: 64 * 1024 + 1, + }); assert.equal(process.killSignals[0], "SIGTERM"); expect(process.killSignals).toContain("SIGKILL"); }).pipe(Effect.provide(TestClock.layer())), diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 174ed4206afc..3898965a6032 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -8,6 +8,7 @@ */ import { DEFAULT_TERMINAL_ID, + DEFAULT_TERMINAL_REPLAY_BYTES, TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, @@ -41,6 +42,7 @@ import { import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { splitStringByUtf8Bytes } from "@t3tools/shared/utf8"; import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -89,10 +91,27 @@ export { TerminalWriteError, }; -const DEFAULT_HISTORY_LINE_LIMIT = 5_000; -const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024; -const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; +const DEFAULT_HISTORY_TARGET_BYTES = 8 * 1024 * 1024; +const DEFAULT_HISTORY_MAX_BYTES = 12 * 1024 * 1024; +const DEFAULT_REPLAY_HISTORY_TARGET_BYTES = 48 * 1024; +const DEFAULT_REPLAY_HISTORY_MAX_BYTES = DEFAULT_TERMINAL_REPLAY_BYTES; +const DEFAULT_OUTPUT_BATCH_WINDOW_MS = 8; +// Full-screen terminal apps commonly emit 20-40 KB synchronized updates. Keep +// typical frames in one event while retaining a bounded live-subscriber queue. +const DEFAULT_OUTPUT_BATCH_MAX_BYTES = 64 * 1024; +// Bound output accepted ahead of the event drain. Node PTYs are paused before +// this fills; adapters without producer flow control drop only overflow bytes +// rather than allowing an unbounded server heap queue. +const DEFAULT_PENDING_PROCESS_EVENT_MAX_BYTES = 4 * 1024 * 1024; +const DEFAULT_HISTORY_STREAM_CHUNK_BYTES = 64 * 1024; +// Events published while an attach is still replaying buffer until the replay +// finishes. The budget must comfortably cover live output produced during a +// multi-second extended replay over a slow link; overflowing it degrades the +// subscriber to a bounded resync snapshot, which discards streamed scrollback. +const DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT = 1_024; +const DEFAULT_ATTACH_BUFFERED_MAX_BYTES = 4 * 1024 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; +const DEFAULT_PERSIST_CHUNK_BYTES = 64 * 1024; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; @@ -163,9 +182,17 @@ export class TerminalManager extends Context.Service< */ readonly attachStream: ( input: TerminalAttachInput, - listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + listener: ( + event: TerminalAttachStreamEvent, + delivery: "replay" | "live", + ) => Effect.Effect, ) => Effect.Effect<() => void, TerminalError>; + /** Read the current bounded snapshot for a slow-subscriber resync. */ + readonly readSnapshot: ( + input: TerminalClearInput, + ) => Effect.Effect>; + /** * Write input bytes to a terminal session. */ @@ -258,18 +285,33 @@ export interface TerminalStartInput extends TerminalOpenInput { rows: number; } -interface TerminalSessionState { +export interface TerminalSessionState { threadId: string; terminalId: string; cwd: string; worktreePath: string | null; status: TerminalSessionStatus; pid: number | null; - history: BoundedTerminalHistory; + history: string; + historyBytes: number; + persistenceHistory: string; + persistenceHistoryBytes: number; pendingHistoryControlSequence: string; + /** Last observed state of replayable DEC private modes for the live process. */ + trackedDecModes: Map; + /** Mode state at the first byte of `history`, advanced as caps drop its prefix. */ + historyStartDecModes: Map; + /** Mode state at the first byte of `persistenceHistory`. */ + persistenceStartDecModes: Map; + pendingOutputHighSurrogate: string; pendingProcessEvents: Array; pendingProcessEventIndex: number; - processEventDrainRunning: boolean; + pendingProcessEventBytes: number; + processOutputPaused: boolean; + processEventDrainPid: number | null; + processEventDrainSemaphore: Semaphore.Semaphore; + /** Serializes PTY input so a held mouse release cannot be overtaken. */ + writeSemaphore: Semaphore.Semaphore; exitCode: number | null; exitSignal: number | null; updatedAt: string; @@ -285,13 +327,19 @@ interface TerminalSessionState { runtimeEnv: Record | null; } -interface PersistHistoryRequest { - history: BoundedTerminalHistory; +interface HistoryWrite { + contents: string; + mode: "append" | "truncate"; +} + +interface PersistHistoryRequest extends HistoryWrite { + authoritativeHistory: string; + contentsBytes: number; immediate: boolean; } type PendingProcessEvent = - | { type: "output"; data: string } + | { type: "output"; data: string; dataBytes: number } | { type: "exit"; event: PtyAdapter.PtyExitEvent }; type DrainProcessEventAction = @@ -301,8 +349,9 @@ type DrainProcessEventAction = threadId: string; terminalId: string; sequence: number; - history: BoundedTerminalHistory | null; data: string; + historyWrite: HistoryWrite | null; + authoritativeHistory: string; } | { type: "exit"; @@ -312,6 +361,13 @@ type DrainProcessEventAction = sequence: number; exitCode: number | null; exitSignal: number | null; + /** Neutralizing resets for modes the dead process left dangling. */ + modeResetData: string; + modeReset: { + readonly sequence: number; + readonly historyWrite: HistoryWrite | null; + readonly authoritativeHistory: string; + } | null; }; interface TerminalManagerState { @@ -360,7 +416,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { worktreePath: session.worktreePath, status: session.status, pid: session.pid, - history: session.history.value(), + history: `${decModeReplayPrefix(session.historyStartDecModes)}${session.history}`, exitCode: session.exitCode, exitSignal: session.exitSignal, label: terminalWireLabel(session), @@ -447,24 +503,126 @@ function cleanupProcessHandles(session: TerminalSessionState): void { session.unsubscribeExit = null; } +/** + * Drop all queued PTY events and flow-control state. Only safe when the + * producing process is gone or being replaced: it clears the paused flag + * without resuming, which would wedge a still-attached paused PTY. + */ +function resetPendingProcessQueue(session: TerminalSessionState): void { + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.pendingProcessEventBytes = 0; + session.processOutputPaused = false; + session.processEventDrainPid = null; +} + +function splitCompleteOutput( + pendingHighSurrogate: string, + data: string, + flushTrailingHighSurrogate = false, +): { readonly data: string; readonly pendingHighSurrogate: string } { + const combined = `${pendingHighSurrogate}${data}`; + const finalCodeUnit = combined.charCodeAt(combined.length - 1); + if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) { + if (!flushTrailingHighSurrogate) { + return { + data: combined.slice(0, -1), + pendingHighSurrogate: combined.slice(-1), + }; + } + return { data: `${combined.slice(0, -1)}\ufffd`, pendingHighSurrogate: "" }; + } + return { data: combined, pendingHighSurrogate: "" }; +} + function enqueueProcessEvent( session: TerminalSessionState, expectedPid: number, event: PendingProcessEvent, + outputBatchMaxBytes: number, + pendingProcessEventMaxBytes: number, ): boolean { if (!session.process || session.status !== "running" || session.pid !== expectedPid) { return false; } - session.pendingProcessEvents.push(event); - if (session.processEventDrainRunning) { + if ( + event.type === "output" && + session.pendingProcessEventBytes + event.dataBytes > pendingProcessEventMaxBytes + ) { + if (!session.processOutputPaused) { + session.processOutputPaused = true; + try { + session.process.pauseOutput?.(); + } catch { + // The byte ceiling remains authoritative if adapter flow control fails. + } + } + return false; + } + + const lastPending = session.pendingProcessEvents.at(-1); + if ( + event.type === "output" && + lastPending?.type === "output" && + lastPending.dataBytes + event.dataBytes <= outputBatchMaxBytes + ) { + session.pendingProcessEvents[session.pendingProcessEvents.length - 1] = { + type: "output", + data: `${lastPending.data}${event.data}`, + dataBytes: lastPending.dataBytes + event.dataBytes, + }; + } else { + session.pendingProcessEvents.push(event); + } + if (event.type === "output") { + session.pendingProcessEventBytes += event.dataBytes; + const pauseAtBytes = Math.max( + outputBatchMaxBytes, + pendingProcessEventMaxBytes - outputBatchMaxBytes, + ); + if (!session.processOutputPaused && session.pendingProcessEventBytes >= pauseAtBytes) { + session.processOutputPaused = true; + try { + session.process.pauseOutput?.(); + } catch { + // The hard byte ceiling above still protects adapters whose optional + // producer-level flow control fails at runtime. + } + } + } + if (session.processEventDrainPid === expectedPid) { return false; } - session.processEventDrainRunning = true; + session.processEventDrainPid = expectedPid; return true; } +function resumeProcessOutput( + session: TerminalSessionState, + expectedPid: number, + pendingProcessEventResumeBytes: number, + force = false, +): void { + if ( + !session.processOutputPaused || + session.pid !== expectedPid || + !session.process || + session.status !== "running" || + (!force && session.pendingProcessEventBytes > pendingProcessEventResumeBytes) + ) { + return; + } + + session.processOutputPaused = false; + try { + session.process.resumeOutput?.(); + } catch { + // A failed optional resume cannot leave the manager queue marked paused. + } +} + function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { if (platform === "win32") { return "pwsh.exe"; @@ -810,188 +968,90 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps }, ); -interface TerminalHistoryChunk { - data: string; - byteLength: number; - lineBreaks: number; -} - -export class BoundedTerminalHistory { - private readonly maxLines: number; - private readonly maxBytes: number; - private chunks: Array = []; - private start = 0; - private byteLength = 0; - private lineBreaks = 0; - // Reading the old string's tail on each append can force chunk concatenation. - private lastCodeUnit: number | undefined; - private cachedValue: string | null = ""; - - constructor(maxLines: number, initial: string, maxBytes = DEFAULT_HISTORY_BYTE_LIMIT) { - this.maxLines = maxLines; - this.maxBytes = maxBytes; - this.append(initial); - } +function capHistoryByBytes(history: string, targetBytes: number): string { + const encoded = Buffer.from(history); + if (encoded.byteLength <= targetBytes) return history; - append(text: string): void { - if (text.length === 0) return; - this.cachedValue = null; - if (this.maxBytes <= 0 || this.maxLines <= 0) { - this.clear(); - // Preserve the existing zero-line limit's trailing newline behavior. - if (this.maxBytes > 0 && text.endsWith("\n")) this.appendChunk("\n"); - return; - } - - let offset = 0; - const previous = this.chunks.at(-1); - const lastCode = this.lastCodeUnit; - const firstCode = text.charCodeAt(0); - if ( - previous && - lastCode !== undefined && - lastCode >= 0xd800 && - lastCode <= 0xdbff && - firstCode >= 0xdc00 && - firstCode <= 0xdfff - ) { - // Joining a split surrogate changes its UTF-8 size from 3 to 4 bytes. - previous.data += text[0]; - previous.byteLength += 1; - this.byteLength += 1; - this.lastCodeUnit = firstCode; - offset = 1; - this.trim(); - } - - while (offset < text.length) { - let end = Math.min(offset + MAX_HISTORY_CHUNK_LENGTH, text.length); - const before = text.charCodeAt(end - 1); - const after = text.charCodeAt(end); - if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) { - end -= 1; - } - const data = text.slice(offset, end); - // Detach small chunks from large input strings so evicted prefixes can be collected. - this.appendChunk( - text.length > MAX_HISTORY_CHUNK_LENGTH - ? Buffer.from(data, "utf16le").toString("utf16le") - : data, - ); - this.trim(); - offset = end; - } + let start = encoded.byteLength - targetBytes; + while (start < encoded.byteLength && ((encoded[start] ?? 0) & 0xc0) === 0x80) { + start += 1; } - private appendChunk(data: string): void { - const byteLength = Buffer.byteLength(data); - let lineBreaks = 0; - for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) { - lineBreaks += 1; - } - const previous = this.chunks.at(-1); - if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) { - previous.data += data; - previous.byteLength += byteLength; - previous.lineBreaks += lineBreaks; - } else { - this.chunks.push({ data, byteLength, lineBreaks }); - } - this.byteLength += byteLength; - this.lineBreaks += lineBreaks; - this.lastCodeUnit = data.charCodeAt(data.length - 1); - this.cachedValue = null; - } - - private discardChunk(): void { - const first = this.chunks[this.start]!; - this.byteLength -= first.byteLength; - this.lineBreaks -= first.lineBreaks; - this.chunks[this.start++] = undefined; + const decodedPrefixLength = encoded.subarray(0, start).toString().length; + const safeStart = alignHistoryStartToControlBoundary(history, decodedPrefixLength); + const suffix = history.slice(safeStart); + const previousByte = start > 0 ? encoded[start - 1] : undefined; + if ( + safeStart === decodedPrefixLength && + (previousByte === 0x0a || (previousByte === 0x0d && encoded[start] !== 0x0a)) + ) { + return suffix; } - private trimChunk(offset: number, byteLength: number, lineBreaks: number): void { - const first = this.chunks[this.start]!; - if (offset === first.data.length) { - this.discardChunk(); - return; - } - first.data = first.data.slice(offset); - first.byteLength -= byteLength; - first.lineBreaks -= lineBreaks; - this.byteLength -= byteLength; - this.lineBreaks -= lineBreaks; - } + const newlineIndex = suffix.indexOf("\n"); + const carriageReturnIndex = suffix.indexOf("\r"); + const boundaryIndex = + newlineIndex === -1 + ? carriageReturnIndex + : carriageReturnIndex === -1 + ? newlineIndex + : Math.min(newlineIndex, carriageReturnIndex); + if (boundaryIndex === -1) return suffix; + + const boundaryLength = + suffix[boundaryIndex] === "\r" && suffix[boundaryIndex + 1] === "\n" ? 2 : 1; + if (boundaryIndex + boundaryLength === suffix.length) return suffix; + return suffix.slice(boundaryIndex + boundaryLength); +} - private trim(): void { - const trailingNewline = this.lastCodeUnit === 10; - let linesToDrop = this.lineBreaks + (trailingNewline ? 0 : 1) - this.maxLines; - while (linesToDrop > 0) { - const first = this.chunks[this.start]!; - if (first.lineBreaks < linesToDrop) { - linesToDrop -= first.lineBreaks; - this.discardChunk(); - continue; - } - let offset = 0; - for (let line = 0; line < linesToDrop; line += 1) { - offset = first.data.indexOf("\n", offset) + 1; - } - this.trimChunk(offset, Buffer.byteLength(first.data.slice(0, offset)), linesToDrop); - linesToDrop = 0; - } +function terminalControlSequenceEndIndex(input: string, start: number): number | null { + const codePoint = input.charCodeAt(start); + const isEscape = codePoint === 0x1b; + const nextCodePoint = input.charCodeAt(start + 1); - while (this.byteLength > this.maxBytes) { - const first = this.chunks[this.start]!; - const bytesToDrop = this.byteLength - this.maxBytes; - if (first.byteLength <= bytesToDrop) { - this.discardChunk(); - continue; - } - if (first.byteLength === first.data.length && first.lineBreaks === 0) { - // ASCII without newlines needs no scan to find the byte cutoff. - this.trimChunk(bytesToDrop, bytesToDrop, 0); - continue; - } - let offset = 0; - let bytes = 0; - let lineBreaks = 0; - // Scan only the discarded prefix of one small chunk, never all history. - while (bytes < bytesToDrop) { - const codePoint = first.data.codePointAt(offset)!; - bytes += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; - offset += codePoint <= 0xffff ? 1 : 2; - if (codePoint === 10) lineBreaks += 1; - } - this.trimChunk(offset, bytes, lineBreaks); + if (isEscape && Number.isNaN(nextCodePoint)) return input.length; + if (isEscape && nextCodePoint === 0x5b) { + for (let cursor = start + 2; cursor < input.length; cursor += 1) { + if (isCsiFinalByte(input.charCodeAt(cursor))) return cursor + 1; } - if ( - this.start === this.chunks.length || - (this.start > 2_048 && this.start * 2 >= this.chunks.length) - ) { - this.chunks = this.chunks.slice(this.start); - this.start = 0; - if (this.chunks.length === 0) this.lastCodeUnit = undefined; + return input.length; + } + if (codePoint === 0x9b) { + for (let cursor = start + 1; cursor < input.length; cursor += 1) { + if (isCsiFinalByte(input.charCodeAt(cursor))) return cursor + 1; } + return input.length; } - clear(): void { - this.chunks = []; - this.start = 0; - this.byteLength = 0; - this.lineBreaks = 0; - this.lastCodeUnit = undefined; - this.cachedValue = ""; + const isEscString = + isEscape && + (nextCodePoint === 0x5d || + nextCodePoint === 0x50 || + nextCodePoint === 0x5e || + nextCodePoint === 0x5f); + const isC1String = + codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f; + if (isEscString || isC1String) { + return findStringTerminatorIndex(input, start + (isEscape ? 2 : 1)) ?? input.length; + } + if (isEscape) { + return findEscapeSequenceEndIndex(input, start + 1) ?? input.length; } + return null; +} - value(): string { - if (this.cachedValue !== null) return this.cachedValue; - this.cachedValue = this.chunks - .slice(this.start) - .map((chunk) => chunk!.data) - .join(""); - return this.cachedValue; +function alignHistoryStartToControlBoundary(history: string, requestedStart: number): number { + let index = 0; + while (index < requestedStart) { + const sequenceEnd = terminalControlSequenceEndIndex(history, index); + if (sequenceEnd === null) { + index += 1; + continue; + } + if (sequenceEnd > requestedStart) return sequenceEnd; + index = sequenceEnd; } + return requestedStart; } function isCsiFinalByte(codePoint: number): boolean { @@ -1033,6 +1093,162 @@ function shouldStripDcsSequence(content: string): boolean { return /^[01]?[$+][qr]/.test(content); } +// DEC private modes that shape how a replayed history tail renders or behaves. +// Values are power-on defaults; only deviations have to be re-established when +// the sequence that set them has aged out of the bounded replay window. +// Frame-scoped modes such as synchronized output (2026) stay excluded: they +// must never outlive the frame that opened them. +const REPLAYABLE_DEC_MODE_DEFAULTS = new Map([ + [1, false], // application cursor keys + [6, false], // origin mode + [7, true], // autowrap + [9, false], // X10 mouse reporting + [25, true], // cursor visible + [47, false], // legacy alternate screen + [1000, false], // mouse press/release tracking + [1002, false], // mouse button-event tracking + [1003, false], // mouse any-event tracking + [1004, false], // focus reporting + [1005, false], // UTF-8 mouse encoding + [1006, false], // SGR mouse encoding + [1015, false], // urxvt mouse encoding + [1047, false], // alternate screen buffer + [1049, false], // alternate screen with cursor save + [2004, false], // bracketed paste +]); + +// The three alternate-screen modes toggle one underlying screen: entering via +// one and leaving via another must not leave a sibling recorded as active. +const ALTERNATE_SCREEN_DEC_MODES = [47, 1047, 1049]; + +// Mode sets plus the full reset (RIS `ESC c`) that restores power-on defaults +// without individual mode resets. DECSTR (`CSI !p`) is deliberately not one: +// the vendored libghostty-vt leaves every mode listed above untouched on a +// soft reset, so treating it as a reset would desynchronize the tracked state +// from what the renderer shows. +// eslint-disable-next-line no-control-regex -- matches DEC private mode and RIS sequences. +const DEC_MODE_SET_PATTERN = /(?:\u001b\[|\u009b)\?([0-9;]+)([hl])|\u001b(c)/gu; + +function forEachDecModeSet( + text: string, + visit: (mode: number, enabled: boolean) => void, + onTerminalReset?: () => void, +): void { + for (const match of text.matchAll(DEC_MODE_SET_PATTERN)) { + if (match[3] !== undefined) { + onTerminalReset?.(); + continue; + } + const enabled = match[2] === "h"; + for (const parameter of (match[1] ?? "").split(";")) { + const mode = Number.parseInt(parameter, 10); + if (REPLAYABLE_DEC_MODE_DEFAULTS.has(mode)) visit(mode, enabled); + } + } +} + +function updateTrackedDecModes(modes: Map, chunk: string): void { + forEachDecModeSet( + chunk, + (mode, enabled) => { + if (ALTERNATE_SCREEN_DEC_MODES.includes(mode)) { + for (const alias of ALTERNATE_SCREEN_DEC_MODES) modes.delete(alias); + } + modes.set(mode, enabled); + }, + () => modes.clear(), + ); +} + +// A write consisting purely of SGR or X10 mouse reports. Clients only send +// these as standalone writes, so mixed input such as a paste never matches. +// eslint-disable-next-line no-control-regex -- matches ESC[ mouse report sequences. +const MOUSE_REPORT_WRITE_PATTERN = /^(?:\u001b\[<\d+;\d+;\d+[mM]|\u001b\[M[^]{3})+$/; + +function isMouseTrackingActive(modes: Map): boolean { + return ( + modes.get(9) === true || + modes.get(1000) === true || + modes.get(1002) === true || + modes.get(1003) === true + ); +} + +// How long a release-only mouse write waits for an exit-in-progress to +// disable tracking. A deliberate timing allowance for a physical race: the +// press may have told the application to quit, and its restore sequences race +// this very release. +const DEFAULT_MOUSE_RELEASE_HOLD_MS = 50; + +// How long an attach holds the off-by-one PTY size before restoring it. +// ncurses only reports KEY_RESIZE when the size it reads differs from the +// one it has, so two immediate resizes collapse into a no-op and the app +// never repaints. A deliberate timing allowance: the app must observe the +// intermediate size before the restore. +const DEFAULT_ATTACH_REPAINT_HOLD_MS = 100; + +// SGR releases (`<...m`) and X10 releases (`ESC [ M` with button bits 3, any +// modifier combination), which clients emit when SGR encoding is not enabled. +// eslint-disable-next-line no-control-regex -- matches mouse release sequences. +const MOUSE_RELEASE_WRITE_PATTERN = /^(?:\u001b\[<\d+;\d+;\d+m|\u001b\[M[#'+/37;?][^]{2})+$/; + +/** + * Sequences restoring every tracked mode the given history leaves deviating + * from its default. A process that died mid-app (server restart, crash) leaves + * a dangling alternate-screen or mouse mode in its history; replaying it would + * put the renderer into a state the freshly spawned process is not in. + */ +function decModeResetForModes(modes: Map): string { + const deviations = [...modes].filter(([mode, enabled]) => { + const fallback = REPLAYABLE_DEC_MODE_DEFAULTS.get(mode); + return fallback !== undefined && enabled !== fallback; + }); + // Leave the alternate screen before the remaining resets: exiting it + // restores saved cursor state, which must not undo a cursor-show reset. + deviations.sort( + ([left], [right]) => + Number(!ALTERNATE_SCREEN_DEC_MODES.includes(left)) - + Number(!ALTERNATE_SCREEN_DEC_MODES.includes(right)), + ); + return deviations + .map(([mode]) => `\u001b[?${mode}${REPLAYABLE_DEC_MODE_DEFAULTS.get(mode) ? "h" : "l"}`) + .join(""); +} + +function decModeResetSuffix(history: string): string { + const modes = new Map(); + updateTrackedDecModes(modes, history); + return decModeResetForModes(modes); +} + +/** + * Sequences that put the renderer into the mode state the retained tail + * starts in. A full-screen app's alternate-screen, cursor, and mouse mode + * switches age out of the bounded history long before the app exits; without + * this prefix a reattach rebuilds the app's cells on the primary screen with + * the host theme. The tail may itself leave and re-enter a mode (an app that + * exited and relaunched), so only the state at its first byte is authoritative. + */ +function decModeReplayPrefix(modes: Map): string { + let prefix = ""; + for (const [mode, enabled] of modes) { + if (enabled !== REPLAYABLE_DEC_MODE_DEFAULTS.get(mode)) { + prefix += `\u001b[?${mode}${enabled ? "h" : "l"}`; + } + } + return prefix; +} + +/** Advance a tail-start mode state past the prefix a cap dropped to keep `kept` of `full`. */ +function advanceDecModesPastDroppedPrefix( + modes: Map, + full: string, + kept: string, +): void { + if (kept.length === full.length) return; + updateTrackedDecModes(modes, full.slice(0, full.length - kept.length)); +} + function shouldStripOscSequence(content: string): boolean { return /^(10|11|12);(?:\?|rgb:)/.test(content); } @@ -1189,6 +1405,10 @@ function sanitizeTerminalHistoryChunk( continue; } + if (codePoint >= 0xd800 && codePoint <= 0xdbff && index + 1 === input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + append(input[index] ?? ""); index += 1; } @@ -1313,8 +1533,13 @@ function normalizedRuntimeEnv( interface TerminalManagerOptions { logsDir: string; - historyLineLimit?: number; - historyByteLimit?: number; + historyTargetBytes?: number; + historyMaxBytes?: number; + replayHistoryTargetBytes?: number; + replayHistoryMaxBytes?: number; + outputBatchWindowMs?: number; + outputBatchMaxBytes?: number; + pendingProcessEventMaxBytes?: number; ptyAdapter: PtyAdapter.PtyAdapter["Service"]; shellResolver?: () => string; env?: NodeJS.ProcessEnv; @@ -1424,10 +1649,22 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); - const logsDir = options.logsDir; - const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT; + const historyTargetBytes = options.historyTargetBytes ?? DEFAULT_HISTORY_TARGET_BYTES; + const historyMaxBytes = options.historyMaxBytes ?? DEFAULT_HISTORY_MAX_BYTES; + const replayHistoryTargetBytes = + options.replayHistoryTargetBytes ?? DEFAULT_REPLAY_HISTORY_TARGET_BYTES; + const replayHistoryMaxBytes = options.replayHistoryMaxBytes ?? DEFAULT_REPLAY_HISTORY_MAX_BYTES; + const outputBatchWindowMs = options.outputBatchWindowMs ?? DEFAULT_OUTPUT_BATCH_WINDOW_MS; + const outputBatchMaxBytes = Math.max( + 1, + options.outputBatchMaxBytes ?? DEFAULT_OUTPUT_BATCH_MAX_BYTES, + ); + const pendingProcessEventMaxBytes = Math.max( + outputBatchMaxBytes, + options.pendingProcessEventMaxBytes ?? DEFAULT_PENDING_PROCESS_EVENT_MAX_BYTES, + ); + const pendingProcessEventResumeBytes = Math.floor(pendingProcessEventMaxBytes / 2); const platform = yield* HostProcessPlatform; // Terminals must inherit the user's full environment (minus the blocklist // applied in createTerminalSpawnEnv) — an allowlist here silently strips @@ -1538,6 +1775,73 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } }); + const applyHistoryOutput = ( + session: TerminalSessionState, + data: string, + ): { visibleText: string; write: HistoryWrite | null } => { + const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, data); + session.pendingHistoryControlSequence = sanitized.pendingControlSequence; + if (sanitized.visibleText.length === 0) { + return { visibleText: "", write: null }; + } + updateTrackedDecModes(session.trackedDecModes, sanitized.visibleText); + + const visibleBytes = Buffer.byteLength(sanitized.visibleText); + const nextHistory = `${session.persistenceHistory}${sanitized.visibleText}`; + if (session.persistenceHistoryBytes + visibleBytes <= historyMaxBytes) { + session.persistenceHistory = nextHistory; + session.persistenceHistoryBytes += visibleBytes; + return { + visibleText: sanitized.visibleText, + write: { contents: sanitized.visibleText, mode: "append" }, + }; + } + + const capped = capHistoryByBytes(nextHistory, historyTargetBytes); + advanceDecModesPastDroppedPrefix(session.persistenceStartDecModes, nextHistory, capped); + session.persistenceHistory = capped; + session.persistenceHistoryBytes = Buffer.byteLength(capped); + return { + visibleText: sanitized.visibleText, + write: { contents: session.persistenceHistory, mode: "truncate" }, + }; + }; + + const enqueueOutputData = ( + session: TerminalSessionState, + expectedPid: number, + data: string, + flushTrailingHighSurrogate = false, + ): boolean => { + const complete = splitCompleteOutput( + session.pendingOutputHighSurrogate, + data, + flushTrailingHighSurrogate, + ); + session.pendingOutputHighSurrogate = complete.pendingHighSurrogate; + + let shouldStartDrain = false; + for (const chunk of splitStringByUtf8Bytes(complete.data, outputBatchMaxBytes)) { + if ( + chunk.byteLength > 0 && + enqueueProcessEvent( + session, + expectedPid, + { + type: "output", + data: chunk.data, + dataBytes: chunk.byteLength, + }, + outputBatchMaxBytes, + pendingProcessEventMaxBytes, + ) + ) { + shouldStartDrain = true; + } + } + return shouldStartDrain; + }; + const historyPath = (threadId: string, terminalId: string) => { const threadPart = toSafeThreadId(threadId); if (terminalId === DEFAULT_TERMINAL_ID) { @@ -1690,10 +1994,22 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func never, never >({ - merge: (current, next) => ({ - history: next.history, - immediate: current.immediate || next.immediate, - }), + merge: (current, next) => { + if (next.mode === "truncate") { + return next; + } + + const contents = `${current.contents}${next.contents}`; + const contentsBytes = current.contentsBytes + next.contentsBytes; + return { + authoritativeHistory: next.authoritativeHistory, + contents, + contentsBytes, + mode: current.mode, + immediate: + current.immediate || next.immediate || contentsBytes >= DEFAULT_PERSIST_CHUNK_BYTES, + }; + }, process: Effect.fn("terminal.persistHistoryWorker")(function* (sessionKey, request) { if (!request.immediate) { yield* Effect.sleep(DEFAULT_PERSIST_DEBOUNCE_MS); @@ -1704,16 +2020,34 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return; } + const nextPath = historyPath(threadId, terminalId); yield* fileSystem - .writeFileString(historyPath(threadId, terminalId), request.history.value()) + .writeFileString(nextPath, request.contents, { + flag: request.mode === "append" ? "a" : "w", + }) .pipe( - Effect.catch((error) => - Effect.logWarning("failed to persist terminal history", { - threadId, - terminalId, - error, - }), - ), + Effect.catch((error) => { + if (request.mode === "truncate") { + return Effect.logWarning("failed to persist terminal history", { + threadId, + terminalId, + error, + }); + } + + return fileSystem + .writeFileString(nextPath, request.authoritativeHistory, { flag: "w" }) + .pipe( + Effect.catch((fallbackError) => + Effect.logWarning("failed to recover terminal history append", { + threadId, + terminalId, + error, + fallbackError, + }), + ), + ); + }), ); }), }); @@ -1721,11 +2055,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const queuePersist = Effect.fn("terminal.queuePersist")(function* ( threadId: string, terminalId: string, - history: BoundedTerminalHistory, + write: HistoryWrite, + authoritativeHistory: string, ) { + const contentsBytes = Buffer.byteLength(write.contents); yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, - immediate: false, + ...write, + authoritativeHistory, + contentsBytes, + immediate: contentsBytes >= DEFAULT_PERSIST_CHUNK_BYTES, }); }); @@ -1739,19 +2077,39 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const persistHistory = Effect.fn("terminal.persistHistory")(function* ( threadId: string, terminalId: string, - history: BoundedTerminalHistory, + history: string, ) { yield* persistWorker.enqueue(toSessionKey(threadId, terminalId), { - history, + authoritativeHistory: history, + contents: history, + contentsBytes: Buffer.byteLength(history), + mode: "truncate", immediate: true, }); yield* flushPersist(threadId, terminalId); }); + // A process that died mid-app leaves dangling alternate-screen, cursor, or + // mouse modes in its history. Restore the defaults the new process actually + // starts from, then start it on a fresh line so two prompts cannot + // concatenate side by side (a trailing carriage return already returns the + // cursor to column 0). Applied to both the current log and legacy migration, + // and persisted with the same write so file and memory stay byte-identical. + const normalizeLoadedHistory = (raw: string, truncated = false): string => { + const bounded = + truncated || Buffer.byteLength(raw) > historyMaxBytes + ? capHistoryByBytes(raw, historyTargetBytes) + : raw; + const neutralized = `${bounded}${decModeResetSuffix(bounded)}`; + return neutralized.length > 0 && !neutralized.endsWith("\n") && !neutralized.endsWith("\r") + ? `${neutralized}\r\n` + : neutralized; + }; + const readHistoryTail = Effect.fn("terminal.readHistoryTail")(function* (filePath: string) { const file = yield* fileSystem.open(filePath, { flag: "r" }); const info = yield* file.stat; - const limit = BigInt(historyByteLimit); + const limit = BigInt(historyMaxBytes); const offset = info.size > limit ? info.size - limit : 0n; yield* file.seek(offset, "start"); const bytes = new Uint8Array(Number(info.size - offset)); @@ -1792,8 +2150,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func (cause) => new TerminalHistoryError({ operation: "read", threadId, terminalId, cause }), ), ); - const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); - const capped = history.value(); + const capped = normalizeLoadedHistory(raw, truncated); if (truncated || capped !== raw) { yield* fileSystem .writeFileString(nextPath, capped) @@ -1804,11 +2161,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); } - return history; + return capped; } if (terminalId !== DEFAULT_TERMINAL_ID) { - return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); + return ""; } const legacyPath = legacyHistoryPath(threadId); @@ -1822,17 +2179,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), )) ) { - return new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit); + return ""; } - const { history: raw } = yield* readHistoryTail(legacyPath).pipe( + const { history: raw, truncated } = yield* readHistoryTail(legacyPath).pipe( Effect.scoped, Effect.mapError( (cause) => new TerminalHistoryError({ operation: "migrate", threadId, terminalId, cause }), ), ); - const history = new BoundedTerminalHistory(historyLineLimit, raw, historyByteLimit); - const capped = history.value(); + const capped = normalizeLoadedHistory(raw, truncated); yield* fileSystem .writeFileString(nextPath, capped) .pipe( @@ -1849,7 +2205,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ), ); - return history; + return capped; }); const deleteHistory = Effect.fn("terminal.deleteHistory")(function* ( @@ -1984,16 +2340,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }, ); - const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( + const drainProcessEventsUnlocked = Effect.fn("terminal.drainProcessEventsUnlocked")(function* ( session: TerminalSessionState, expectedPid: number, ) { while (true) { const action: DrainProcessEventAction = yield* Effect.sync(() => { + if (session.processEventDrainPid !== expectedPid) { + return { type: "idle" } as const; + } if (session.pid !== expectedPid || !session.process || session.status !== "running") { - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; + resetPendingProcessQueue(session); return { type: "idle" } as const; } @@ -2001,24 +2358,45 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (!nextEvent) { session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; + session.pendingProcessEventBytes = 0; + session.processEventDrainPid = null; return { type: "idle" } as const; } session.pendingProcessEventIndex += 1; + if (nextEvent.type === "output") { + session.pendingProcessEventBytes = Math.max( + 0, + session.pendingProcessEventBytes - nextEvent.dataBytes, + ); + } if (session.pendingProcessEventIndex >= session.pendingProcessEvents.length) { session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; + } else if ( + session.pendingProcessEventIndex >= 64 && + session.pendingProcessEventIndex * 2 >= session.pendingProcessEvents.length + ) { + session.pendingProcessEvents = session.pendingProcessEvents.slice( + session.pendingProcessEventIndex, + ); + session.pendingProcessEventIndex = 0; } if (nextEvent.type === "output") { - const sanitized = sanitizeTerminalHistoryChunk( - session.pendingHistoryControlSequence, - nextEvent.data, - ); - session.pendingHistoryControlSequence = sanitized.pendingControlSequence; - if (sanitized.visibleText.length > 0) { - session.history.append(sanitized.visibleText); + const historyOutput = applyHistoryOutput(session, nextEvent.data); + if (historyOutput.visibleText.length > 0) { + const visibleBytes = Buffer.byteLength(historyOutput.visibleText); + const nextHistory = `${session.history}${historyOutput.visibleText}`; + if (session.historyBytes + visibleBytes <= replayHistoryMaxBytes) { + session.history = nextHistory; + session.historyBytes += visibleBytes; + } else { + const capped = capHistoryByBytes(nextHistory, replayHistoryTargetBytes); + advanceDecModesPastDroppedPrefix(session.historyStartDecModes, nextHistory, capped); + session.history = capped; + session.historyBytes = Buffer.byteLength(capped); + } } const eventStamp = advanceEventSequence(session); @@ -2027,12 +2405,36 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: session.threadId, terminalId: session.terminalId, sequence: eventStamp.sequence, - history: sanitized.visibleText.length > 0 ? session.history : null, data: nextEvent.data, + historyWrite: historyOutput.write, + authoritativeHistory: session.persistenceHistory, } as const; } const process = session.process; + // A process that died without restoring its modes (kill -9 mid-app) + // leaves the terminal in the alternate screen with the cursor hidden. + // Neutralize like a loaded history so the exit notice and any later + // attach render on a sane primary screen, and persist the same bytes. + const exitModeReset = decModeResetForModes(session.trackedDecModes); + let modeReset: { + readonly sequence: number; + readonly historyWrite: HistoryWrite | null; + readonly authoritativeHistory: string; + } | null = null; + if (exitModeReset.length > 0) { + const historyOutput = applyHistoryOutput(session, exitModeReset); + if (historyOutput.visibleText.length > 0) { + session.history = `${session.history}${historyOutput.visibleText}`; + session.historyBytes += Buffer.byteLength(historyOutput.visibleText); + } + session.trackedDecModes = new Map(); + modeReset = { + sequence: advanceEventSequence(session).sequence, + historyWrite: historyOutput.write, + authoritativeHistory: session.persistenceHistory, + }; + } cleanupProcessHandles(session); session.process = null; session.pid = null; @@ -2040,9 +2442,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.childCommandLabel = null; session.status = "exited"; session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; + session.pendingOutputHighSurrogate = ""; + resetPendingProcessQueue(session); session.exitCode = Number.isInteger(nextEvent.event.exitCode) ? nextEvent.event.exitCode : null; @@ -2054,6 +2455,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return { type: "exit", process, + modeReset, + modeResetData: exitModeReset, threadId: session.threadId, terminalId: session.terminalId, sequence: eventStamp.sequence, @@ -2067,10 +2470,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (action.type === "output") { - if (action.history !== null) { - yield* queuePersist(action.threadId, action.terminalId, action.history); + if (action.historyWrite !== null) { + yield* queuePersist( + action.threadId, + action.terminalId, + action.historyWrite, + action.authoritativeHistory, + ); } - yield* publishEvent({ type: "output", threadId: action.threadId, @@ -2078,14 +2485,33 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func sequence: action.sequence, data: action.data, }); + resumeProcessOutput(session, expectedPid, pendingProcessEventResumeBytes); continue; } + if (action.modeReset !== null) { + if (action.modeReset.historyWrite !== null) { + yield* queuePersist( + action.threadId, + action.terminalId, + action.modeReset.historyWrite, + action.modeReset.authoritativeHistory, + ); + } + yield* publishEvent({ + type: "output", + threadId: action.threadId, + terminalId: action.terminalId, + sequence: action.modeReset.sequence, + data: action.modeResetData, + }); + } yield* clearKillFiber(action.process); yield* unregisterTerminal({ threadId: action.threadId, terminalId: action.terminalId, }); + yield* flushPersist(action.threadId, action.terminalId); yield* publishEvent({ type: "exited", threadId: action.threadId, @@ -2099,7 +2525,26 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } }); + const drainProcessEvents = Effect.fn("terminal.drainProcessEvents")(function* ( + session: TerminalSessionState, + expectedPid: number, + ) { + yield* session.processEventDrainSemaphore.withPermit( + drainProcessEventsUnlocked(session, expectedPid), + ); + }); + const stopProcess = Effect.fn("terminal.stopProcess")(function* (session: TerminalSessionState) { + // A lifecycle command is an ordering barrier. Drain bytes already accepted + // from the PTY before clearing its handlers or state so history and live + // events cannot diverge at close/restart boundaries. + if (session.process && session.pid !== null && session.pendingOutputHighSurrogate.length > 0) { + enqueueOutputData(session, session.pid, "", true); + } + if (session.processEventDrainPid !== null) { + yield* drainProcessEvents(session, session.processEventDrainPid); + } + const process = session.process; if (!process) return; @@ -2112,9 +2557,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.childCommandLabel = null; session.status = "exited"; session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; + session.pendingOutputHighSurrogate = ""; + resetPendingProcessQueue(session); session.updatedAt = updatedAt; return [undefined, state] as const; }); @@ -2207,9 +2651,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.exitSignal = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; + resetPendingProcessQueue(session); + session.pendingOutputHighSurrogate = ""; + // The mode state belongs to the process being replaced. + session.trackedDecModes = new Map(); session.updatedAt = startingAt; return [undefined, state] as const; }); @@ -2229,16 +2674,35 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const processPid = ptyProcess.pid; const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { + if (!session.process || session.status !== "running" || session.pid !== processPid) { return; } - runFork(drainProcessEvents(session, processPid)); + + const shouldStartDrain = enqueueOutputData(session, processPid, data); + if (!shouldStartDrain) { + return; + } + runFork( + Effect.sleep(outputBatchWindowMs).pipe( + Effect.andThen(drainProcessEvents(session, processPid)), + ), + ); }); const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { + const shouldStartDrain = enqueueOutputData(session, processPid, "", true); + if ( + enqueueProcessEvent( + session, + processPid, + { type: "exit", event }, + outputBatchMaxBytes, + pendingProcessEventMaxBytes, + ) + ) { + runFork(drainProcessEvents(session, processPid)); return; } - runFork(drainProcessEvents(session, processPid)); + if (shouldStartDrain) runFork(drainProcessEvents(session, processPid)); }); let eventStamp: ReturnType = { @@ -2284,9 +2748,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.process = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; + resetPendingProcessQueue(session); advanceEventSequence(session); return [undefined, state] as const; }); @@ -2326,7 +2788,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (Option.isSome(session)) { yield* stopProcess(session.value); yield* unregisterTerminal({ threadId, terminalId }); - yield* persistHistory(threadId, terminalId, session.value.history); } yield* flushPersist(threadId, terminalId); @@ -2503,7 +2964,18 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const cleanupSession = Effect.fn("terminal.cleanupSession")(function* ( session: TerminalSessionState, ) { + if ( + session.process && + session.pid !== null && + session.pendingOutputHighSurrogate.length > 0 + ) { + enqueueOutputData(session, session.pid, "", true); + } + if (session.processEventDrainPid !== null) { + yield* drainProcessEvents(session, session.processEventDrainPid); + } cleanupProcessHandles(session); + yield* flushPersist(session.threadId, session.terminalId); if (!session.process) return; yield* clearKillFiber(session.process); yield* runKillEscalation(session.process, session.threadId, session.terminalId); @@ -2524,7 +2996,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { yield* flushPersist(input.threadId, terminalId); - const history = yield* readHistory(input.threadId, terminalId); + const persistenceHistory = yield* readHistory(input.threadId, terminalId); + const history = + Buffer.byteLength(persistenceHistory) > replayHistoryMaxBytes + ? capHistoryByBytes(persistenceHistory, replayHistoryTargetBytes) + : persistenceHistory; + // Loaded history was neutralized to end at the defaults; its start is + // taken as the defaults too, and the replay tail's start follows from + // whatever the cap dropped in between. + const historyStartDecModes = new Map(); + advanceDecModesPastDroppedPrefix(historyStartDecModes, persistenceHistory, history); const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { @@ -2535,10 +3016,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func status: "starting", pid: null, history, + historyBytes: Buffer.byteLength(history), + persistenceHistory, + persistenceHistoryBytes: Buffer.byteLength(persistenceHistory), pendingHistoryControlSequence: "", + trackedDecModes: new Map(), + historyStartDecModes, + persistenceStartDecModes: new Map(), + pendingOutputHighSurrogate: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, - processEventDrainRunning: false, + pendingProcessEventBytes: 0, + processOutputPaused: false, + processEventDrainPid: null, + processEventDrainSemaphore: yield* Semaphore.make(1), + writeSemaphore: yield* Semaphore.make(1), exitCode: null, exitSignal: null, updatedAt: yield* nowIso, @@ -2595,20 +3087,28 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.cwd = input.cwd; liveSession.worktreePath = nextWorktreePath; liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.history.clear(); + liveSession.history = ""; + liveSession.historyBytes = 0; + liveSession.persistenceHistory = ""; + liveSession.persistenceHistoryBytes = 0; + liveSession.historyStartDecModes = new Map(); + liveSession.persistenceStartDecModes = new Map(); liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; + liveSession.pendingOutputHighSurrogate = ""; + resetPendingProcessQueue(liveSession); yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; liveSession.worktreePath = nextWorktreePath; - liveSession.history.clear(); + liveSession.history = ""; + liveSession.historyBytes = 0; + liveSession.persistenceHistory = ""; + liveSession.persistenceHistoryBytes = 0; + liveSession.historyStartDecModes = new Map(); + liveSession.persistenceStartDecModes = new Map(); liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; + liveSession.pendingOutputHighSurrogate = ""; + resetPendingProcessQueue(liveSession); yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); } @@ -2651,6 +3151,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.gen(function* () { const terminalId = input.terminalId; const existing = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + let resizedDuringAttach = false; if (Option.isNone(existing)) { if (!input.cwd) { @@ -2660,40 +3162,107 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - const resolvedInput = yield* resolveLaunchInputEnvironment({ + yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, - }); - return yield* openLocked(resolvedInput); + }).pipe(Effect.flatMap(openLocked)); + session = yield* requireSession(input.threadId, terminalId); + } else { + session = existing.value; + const targetCols = input.cols ?? session.cols; + const targetRows = input.rows ?? session.rows; + + if (!session.process && input.cwd && input.restartIfNotRunning === true) { + yield* resolveLaunchInputEnvironment({ + ...input, + terminalId, + cwd: input.cwd, + }).pipe(Effect.flatMap(openLocked)); + session = yield* requireSession(input.threadId, terminalId); + } else if ( + session.process && + 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; + resizedDuringAttach = true; + } } - const session = existing.value; - const targetCols = input.cols ?? session.cols; - const targetRows = input.rows ?? session.rows; - - if (!session.process && input.cwd && input.restartIfNotRunning === true) { - const resolvedInput = yield* resolveLaunchInputEnvironment({ - ...input, - terminalId, - cwd: input.cwd, - }); - return yield* openLocked(resolvedInput); + // Flush the short output batch before capturing history so the replay + // snapshot and its sequence describe the same point in the PTY stream. + if (session.processEventDrainPid !== null) { + yield* drainProcessEvents(session, session.processEventDrainPid); } + const initialSnapshot = snapshot(session); + // Capture history and its sequence before yielding to the repaint. Bytes + // produced during the hold belong only to the buffered live suffix. + const bootstrap = (() => { + const requestedReplayBytes = input.replayBytes ?? DEFAULT_TERMINAL_REPLAY_BYTES; + if (requestedReplayBytes <= DEFAULT_TERMINAL_REPLAY_BYTES) { + return { snapshot: initialSnapshot, replayHistory: null } as const; + } + + const replayHistory = + session.persistenceHistoryBytes > requestedReplayBytes + ? capHistoryByBytes(session.persistenceHistory, requestedReplayBytes) + : session.persistenceHistory; + const replayStartDecModes = new Map(session.persistenceStartDecModes); + advanceDecModesPastDroppedPrefix( + replayStartDecModes, + session.persistenceHistory, + replayHistory, + ); + return { + snapshot: { ...initialSnapshot, history: "" }, + replayHistory: `${decModeReplayPrefix(replayStartDecModes)}${replayHistory}`, + } as const; + })(); + + // A full-screen app repaints only dirty cells, so the capped replay + // cannot reconstruct its whole screen. Wiggle the PTY size so the + // SIGWINCH makes the app repaint everything; its output lands after + // the replay as ordinary live events. Shells never sit in the + // alternate screen, so attach still cannot redraw a shell prompt. A + // real size change above already delivered the same repaint signal. + // The intermediate size is held long enough for the app to read it. + const altScreenActive = + session.trackedDecModes.get(1049) === true || + session.trackedDecModes.get(1047) === true || + session.trackedDecModes.get(47) === true; if ( + altScreenActive && + !resizedDuringAttach && session.process && - session.status === "running" && - (session.cols !== targetCols || session.rows !== targetRows) + session.status === "running" ) { const process = session.process; - yield* resizePtyProcess(session, process, targetCols, targetRows); - session.cols = targetCols; - session.rows = targetRows; - session.updatedAt = yield* nowIso; + const wiggleCols = session.cols > 1 ? session.cols - 1 : session.cols + 1; + yield* Effect.acquireUseRelease( + resizePtyProcess(session, process, wiggleCols, session.rows), + () => Effect.sleep(DEFAULT_ATTACH_REPAINT_HOLD_MS), + () => + Effect.suspend(() => + session.process === process && session.status === "running" + ? resizePtyProcess(session, process, session.cols, session.rows).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to restore terminal size after attach", { + error, + }), + ), + Effect.ignore, + ) + : Effect.void, + ), + ); } - - return snapshot(session); + return bootstrap; }), ); @@ -2727,12 +3296,20 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }; }); + const readSnapshot: TerminalManager["Service"]["readSnapshot"] = (input) => + getSession(input.threadId, input.terminalId).pipe(Effect.map(Option.map(snapshot))); + const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => { let unsubscribe: (() => void) | null = null; return Effect.gen(function* () { - const bufferedEvents: TerminalEvent[] = []; + const bufferedEvents: Array<{ event: TerminalEvent; bytes: number }> = []; + let bufferedEventBytes = 0; + let bufferedOverflow = false; let deliverLive = false; + // Old clients decode the attach stream against a union without the + // replay markers. Sending replayBytes proves the client understands them. + const emitReplayMarkers = input.replayBytes !== undefined; unsubscribe = yield* subscribe((event) => { if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { @@ -2740,33 +3317,125 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (!deliverLive) { - bufferedEvents.push(event); + const eventBytes = event.type === "output" ? Buffer.byteLength(event.data) : 0; + if ( + bufferedEvents.length >= DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT || + bufferedEventBytes + eventBytes > DEFAULT_ATTACH_BUFFERED_MAX_BYTES + ) { + bufferedEvents.splice(0); + bufferedEventBytes = 0; + bufferedOverflow = true; + } + bufferedEvents.push({ event, bytes: eventBytes }); + bufferedEventBytes += eventBytes; return Effect.void; } const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent) : Effect.void; + return attachEvent ? listener(attachEvent, "live") : Effect.void; }); - const initialSnapshot = yield* openOrAttachForStream(input); + const bootstrap = yield* openOrAttachForStream(input); + let synchronizedSnapshot = bootstrap.snapshot; + + if (emitReplayMarkers) { + yield* listener( + { + type: "replay-start", + threadId: input.threadId, + terminalId: input.terminalId, + ...(typeof bootstrap.snapshot.sequence === "number" + ? { sequence: bootstrap.snapshot.sequence } + : {}), + }, + "replay", + ); + } + + yield* listener( + { + type: "snapshot", + snapshot: bootstrap.snapshot, + }, + "replay", + ); - yield* listener({ - type: "snapshot", - snapshot: initialSnapshot, - }); + if (bootstrap.replayHistory !== null && bootstrap.replayHistory.length > 0) { + for (const { data } of splitStringByUtf8Bytes( + bootstrap.replayHistory, + DEFAULT_HISTORY_STREAM_CHUNK_BYTES, + )) { + yield* listener( + { + type: "output", + threadId: input.threadId, + terminalId: input.terminalId, + ...(typeof bootstrap.snapshot.sequence === "number" + ? { sequence: bootstrap.snapshot.sequence } + : {}), + data, + }, + "replay", + ); + } + } - for (const event of bufferedEvents) { - if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { + if (emitReplayMarkers) { + yield* listener( + { + type: "replay-complete", + threadId: input.threadId, + terminalId: input.terminalId, + ...(typeof bootstrap.snapshot.sequence === "number" + ? { sequence: bootstrap.snapshot.sequence } + : {}), + }, + "replay", + ); + } + + let overflowResyncCount = 0; + while (true) { + if (bufferedOverflow) { + bufferedOverflow = false; + overflowResyncCount += 1; + if (overflowResyncCount > 3) { + // A consumer this far behind keeps overflowing while the resync + // itself is being delivered. Go live anyway; the transport's own + // overflow path resynchronizes it from the latest snapshot. + bufferedEvents.splice(0); + bufferedEventBytes = 0; + deliverLive = true; + break; + } + const latest = yield* readSnapshot(input); + if (Option.isSome(latest)) { + synchronizedSnapshot = latest.value; + yield* listener( + { + type: "snapshot", + snapshot: latest.value, + }, + "replay", + ); + } continue; } - const attachEvent = terminalEventToAttachEvent(event); + const buffered = bufferedEvents.shift(); + if (!buffered) { + deliverLive = true; + break; + } + bufferedEventBytes -= buffered.bytes; + if (isDuplicateAttachSnapshotEvent(buffered.event, synchronizedSnapshot)) continue; + + const attachEvent = terminalEventToAttachEvent(buffered.event); if (attachEvent) { - yield* listener(attachEvent); + yield* listener(attachEvent, "replay"); } } - deliverLive = true; return () => { unsubscribe?.(); unsubscribe = null; @@ -2877,16 +3546,58 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } - yield* Effect.try({ - try: () => process.write(input.data), - catch: (cause) => - new TerminalWriteError({ - threadId: input.threadId, - terminalId, - terminalPid: process.pid, - cause, - }), - }); + // The permit serializes PTY input per session: writes queued behind a held + // mouse release wait for it instead of overtaking it. + yield* session.writeSemaphore.withPermit( + Effect.gen(function* () { + if (MOUSE_REPORT_WRITE_PATTERN.test(input.data)) { + // The client's view of the mouse tracking modes lags the PTY by a + // full round-trip, so a click's release can arrive after the + // application stopped listening and would be typed into the shell + // as junk. Flush pending output, then drop the report unless + // tracking is still on. + if (MOUSE_RELEASE_WRITE_PATTERN.test(input.data)) { + // Apps such as btop act on the press and can take 100 ms+ to emit + // their restore sequences while exiting. A release forwarded in + // that window is never read and the tty queue hands it to the + // next shell. Hold releases briefly so an exit in progress can + // disable tracking first; presses and motions stay immediate to + // keep drags responsive. + yield* Effect.sleep(DEFAULT_MOUSE_RELEASE_HOLD_MS); + } + if (session.processEventDrainPid !== null) { + yield* drainProcessEvents(session, session.processEventDrainPid); + } + if ( + !isMouseTrackingActive(session.trackedDecModes) || + session.status !== "running" || + session.process !== process + ) { + return; + } + } + // A restart can replace the process while this write waited for the + // permit. Deliver to the session's current process, never a stopped one. + const liveProcess = session.process; + if (!liveProcess || session.status !== "running") { + if (session.status === "exited") return; + return yield* new TerminalNotRunningError({ + threadId: input.threadId, + terminalId, + }); + } + yield* Effect.try({ + try: () => liveProcess.write(input.data), + catch: (cause) => + new TerminalWriteError({ + threadId: input.threadId, + terminalId, + terminalPid: liveProcess.pid, + cause, + }), + }); + }), + ); }); const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { @@ -2899,6 +3610,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (!process || session.value.status !== "running") { return; } + if (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; @@ -2914,11 +3628,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.gen(function* () { const terminalId = input.terminalId; const session = yield* requireSession(input.threadId, terminalId); - session.history.clear(); + if (session.processEventDrainPid !== null) { + yield* drainProcessEvents(session, session.processEventDrainPid); + } + session.history = ""; + session.historyBytes = 0; + session.persistenceHistory = ""; + session.persistenceHistoryBytes = 0; + session.historyStartDecModes = new Map(); + session.persistenceStartDecModes = new Map(); session.pendingHistoryControlSequence = ""; + session.pendingOutputHighSurrogate = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; + session.pendingProcessEventBytes = 0; + session.processOutputPaused = false; const eventStamp = advanceEventSequence(session); yield* persistHistory(input.threadId, terminalId, session.history); yield* publishEvent({ @@ -2949,11 +3673,22 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func worktreePath: input.worktreePath ?? null, status: "starting", pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + history: "", + historyBytes: 0, + persistenceHistory: "", + persistenceHistoryBytes: 0, pendingHistoryControlSequence: "", + trackedDecModes: new Map(), + historyStartDecModes: new Map(), + persistenceStartDecModes: new Map(), + pendingOutputHighSurrogate: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, - processEventDrainRunning: false, + pendingProcessEventBytes: 0, + processOutputPaused: false, + processEventDrainPid: null, + processEventDrainSemaphore: yield* Semaphore.make(1), + writeSemaphore: yield* Semaphore.make(1), exitCode: null, exitSignal: null, updatedAt: yield* nowIso, @@ -2985,11 +3720,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const cols = input.cols ?? session.cols; const rows = input.rows ?? session.rows; - session.history.clear(); + session.history = ""; + session.historyBytes = 0; + session.persistenceHistory = ""; + session.persistenceHistoryBytes = 0; + session.historyStartDecModes = new Map(); + session.persistenceStartDecModes = new Map(); session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; + session.pendingOutputHighSurrogate = ""; + resetPendingProcessQueue(session); yield* persistHistory(input.threadId, terminalId, session.history); yield* startSession( session, @@ -3038,6 +3777,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, attachStream, + readSnapshot, write, resize, clear, diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 41f177e47586..dc804f0a4d3c 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -90,6 +90,14 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { this.process.kill(signal); } + pauseOutput(): void { + this.process.pause(); + } + + resumeOutput(): void { + this.process.resume(); + } + onData(callback: (data: string) => void): () => void { const disposable = this.process.onData(callback); return () => { diff --git a/apps/server/src/terminal/PtyAdapter.ts b/apps/server/src/terminal/PtyAdapter.ts index c56dfa93efe6..95bd6c6fee10 100644 --- a/apps/server/src/terminal/PtyAdapter.ts +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -39,6 +39,10 @@ export interface PtyProcess { write(data: string): void; resize(cols: number, rows: number): void; kill(signal?: string): void; + /** Pause PTY output at the producer when the adapter supports flow control. */ + pauseOutput?(): void; + /** Resume PTY output after the manager has drained its bounded backlog. */ + resumeOutput?(): void; onData(callback: (data: string) => void): () => void; onExit(callback: (event: PtyExitEvent) => void): () => void; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..a6712b51df9f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -160,6 +160,7 @@ const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchComma const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); +const TERMINAL_ATTACH_BUFFERED_EVENT_LIMIT = 32; const resolveDiscoveryForConfig = ( discovery: Effect.Effect, @@ -2585,11 +2586,53 @@ const makeWsRpcLayer = ( [WS_METHODS.terminalAttach]: (input) => observeRpcStream( WS_METHODS.terminalAttach, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Stream.callback( + (queue) => + Effect.acquireRelease( + terminalManager.attachStream(input, (event, delivery): Effect.Effect => + Effect.gen(function* () { + if (delivery === "replay") { + yield* Queue.offer(queue, event); + return; + } + if (Queue.offerUnsafe(queue, event)) return; + + yield* Queue.clear(queue); + // The clear may have wiped queued replay events, + // including the replay-complete marker. Re-emit it so + // the client never stays latched in replay mode. Only + // clients that sent replayBytes decode the marker. + if (input.replayBytes !== undefined) { + yield* Queue.offer(queue, { + type: "replay-complete" as const, + threadId: input.threadId, + terminalId: input.terminalId, + }); + } + if (event.type === "closed") { + yield* Queue.offer(queue, event); + return; + } + + const latest = yield* terminalManager.readSnapshot(input); + yield* Queue.offer( + queue, + Option.match(latest, { + onNone: () => event, + onSome: (snapshot) => ({ type: "snapshot" as const, snapshot }), + }), + ); + if (Option.isSome(latest) && event.type === "error") { + yield* Queue.offer(queue, event); + } + }).pipe(Effect.ignore), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ), + { + bufferSize: TERMINAL_ATTACH_BUFFERED_EVENT_LIMIT, + strategy: "suspend", + }, ), { "rpc.aggregate": "terminal" }, ), diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 1624a739bb1a..bec7cd474db5 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -7,6 +7,7 @@ import { terminalSelectionLineRange, terminalSelectionMenuItems, terminalThemeFromApp, + writeTerminalOutputSegments, } from "./ThreadTerminalDrawer"; describe("terminal selection menus", () => { @@ -35,7 +36,7 @@ describe("terminalThemeFromApp", () => { it("uses terminal colors inherited by the mount instead of a light document theme", () => { const root = { classList: { contains: () => false } }; const body = {}; - const drawer = {}; + const drawer = { append() {} }; let canvasColor = "#000"; const colors: Record = { "#000": [0, 0, 0, 255], @@ -49,6 +50,8 @@ describe("terminalThemeFromApp", () => { body, querySelector: () => drawer, createElement: () => ({ + style: {}, + remove() {}, width: 0, height: 0, getContext: () => ({ @@ -143,3 +146,26 @@ describe("terminal selection actions", () => { expect(shouldHandleTerminalExit("closed", "running", true)).toBe(false); }); }); + +describe("writeTerminalOutputSegments", () => { + it("closes a streamed replay before writing live terminal output", () => { + const actions: string[] = []; + const result = writeTerminalOutputSegments({ + terminal: { + beginStreamingReplay: (data) => actions.push(`begin:${data}`), + appendStreamingReplay: (data) => actions.push(`append:${data}`), + completeStreamingReplay: () => actions.push("complete"), + write: (data) => actions.push(`write:${data}`), + }, + segments: [ + { data: "history", delivery: "replay" }, + { data: "\u001b[5n", delivery: "live" }, + ], + replayState: "waiting", + onReplayComplete: () => actions.push("restore-scroll"), + }); + + expect(actions).toEqual(["begin:history", "complete", "restore-scroll", "write:\u001b[5n"]); + expect(result).toEqual({ replayState: "idle", didWrite: true }); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9ddf9225bdf..2d78e12d20bc 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -6,6 +6,7 @@ import { import { INITIAL_TERMINAL_OUTPUT_CURSOR, readTerminalOutputUpdate, + terminalOutputText, type TerminalOutputCursor, type TerminalOutputUpdate, type TerminalSessionState, @@ -19,6 +20,8 @@ import { Trash2, } from "lucide-react"; import { + DEFAULT_TERMINAL_REPLAY_BYTES, + EXTENDED_TERMINAL_REPLAY_BYTES, type ContextMenuItem, type ProviderInstanceId, type ResolvedKeybindingsConfig, @@ -119,6 +122,44 @@ export function writeTerminalOutputUpdate( } } +type TerminalReplayRendererState = "idle" | "waiting" | "replaying"; + +/** Preserve replay/live ordering when React reduces both deliveries before a render. */ +export function writeTerminalOutputSegments(options: { + terminal: Pick< + GhosttyTerminalSurface, + "appendStreamingReplay" | "beginStreamingReplay" | "completeStreamingReplay" | "write" + >; + segments: Extract["segments"]; + replayState: TerminalReplayRendererState; + onReplayComplete: () => void; +}): { replayState: TerminalReplayRendererState; didWrite: boolean } { + let replayState = options.replayState; + let didWrite = false; + + for (const segment of options.segments) { + if (segment.data.length === 0) continue; + didWrite = true; + if (segment.delivery === "replay" && replayState !== "idle") { + if (replayState === "waiting") { + options.terminal.beginStreamingReplay(segment.data); + replayState = "replaying"; + } else { + options.terminal.appendStreamingReplay(segment.data); + } + continue; + } + if (segment.delivery === "live" && replayState !== "idle") { + if (replayState === "replaying") options.terminal.completeStreamingReplay(); + replayState = "idle"; + options.onReplayComplete(); + } + options.terminal.write(segment.data); + } + + return { replayState, didWrite }; +} + function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { if (typeof document === "undefined") return fallback; @@ -219,20 +260,51 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty "--terminal-selection-background", isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", ); + const colorProbe = document.createElement("span"); + colorProbe.ariaHidden = "true"; + colorProbe.style.cssText = "position:fixed;width:0;height:0;overflow:hidden;pointer-events:none"; + drawerSurface.append(colorProbe); + const readResolvedThemeColor = (variable: string, fallback: string) => { + colorProbe.style.color = `var(${variable}, ${fallback})`; + return normalizeComputedColor(getComputedStyle(colorProbe).color, fallback); + }; + const alternateBackground = readResolvedThemeColor( + "--terminal-alt-screen-background", + terminalBackground, + ); + const alternateForeground = readResolvedThemeColor( + "--terminal-alt-screen-foreground", + terminalForeground, + ); + const alternateCursor = readResolvedThemeColor("--terminal-alt-screen-cursor", terminalCursor); + const alternateSelection = readResolvedThemeColor( + "--terminal-alt-screen-selection-background", + terminalSelection, + ); + colorProbe.remove(); + const backgroundColor = parseTerminalColor( + terminalBackground, + isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, + ); + const foregroundColor = parseTerminalColor( + terminalForeground, + isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, + ); + const cursorColor = parseTerminalColor( + terminalCursor, + isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, + ); return { - background: parseTerminalColor( - terminalBackground, - isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, - ), - foreground: parseTerminalColor( - terminalForeground, - isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, - ), - cursor: parseTerminalColor( - terminalCursor, - isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, - ), + background: backgroundColor, + foreground: foregroundColor, + cursor: cursorColor, selectionBackground: terminalSelection, + alternateScreen: { + background: parseTerminalColor(alternateBackground, backgroundColor), + foreground: parseTerminalColor(alternateForeground, foregroundColor), + cursor: parseTerminalColor(alternateCursor, cursorColor), + selectionBackground: alternateSelection, + }, }; } @@ -401,6 +473,38 @@ export function TerminalViewport({ }), ); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); + const pendingScrollbackReplayIdentityRef = useRef(null); + const scrollbackReplayRendererStateRef = useRef("idle"); + const terminalAttachIdentity = useMemo( + () => + JSON.stringify([ + environmentId, + threadId, + terminalId, + cwd, + worktreePath ?? null, + runtimeEnvKey, + ]), + [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath], + ); + const [extendedReplayIdentity, setExtendedReplayIdentity] = useState(null); + const replayBytes = + extendedReplayIdentity === terminalAttachIdentity + ? EXTENDED_TERMINAL_REPLAY_BYTES + : DEFAULT_TERMINAL_REPLAY_BYTES; + const requestExtendedReplay = useEffectEvent(() => { + if (extendedReplayIdentity === terminalAttachIdentity) return; + pendingScrollbackReplayIdentityRef.current = terminalAttachIdentity; + scrollbackReplayRendererStateRef.current = "waiting"; + setExtendedReplayIdentity(terminalAttachIdentity); + }); + useEffect(() => { + setExtendedReplayIdentity(null); + if (pendingScrollbackReplayIdentityRef.current !== terminalAttachIdentity) { + pendingScrollbackReplayIdentityRef.current = null; + scrollbackReplayRendererStateRef.current = "idle"; + } + }, [terminalAttachIdentity]); const terminalSession = useAttachedTerminalSession({ environmentId, terminal: { @@ -410,6 +514,7 @@ export function TerminalViewport({ ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), ...(providerInstanceId ? { providerInstanceId } : {}), + replayBytes, }, }); const writeTerminal = useEffectEvent((data: string) => @@ -428,6 +533,8 @@ export function TerminalViewport({ const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); + const terminalSubscriptionIdentity = `${terminalAttachIdentity}:${replayBytes}`; + const outputSubscriptionIdentityRef = useRef(terminalSubscriptionIdentity); const synchronizedStatusRef = useRef("closed"); const synchronizeTerminalStatus = useEffectEvent( (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => { @@ -447,10 +554,14 @@ export function TerminalViewport({ }, ); const terminalVersion = terminalSession.version; + const terminalReplayStartVersion = terminalSession.replayStartVersion; + const terminalReplayCompleteVersion = terminalSession.replayCompleteVersion; const previousSessionRef = useRef({ output: terminalOutput, error: terminalError, status: terminalStatus, + replayStartVersion: terminalReplayStartVersion, + replayCompleteVersion: terminalReplayCompleteVersion, version: terminalVersion, }); const latestSessionRef = useRef(previousSessionRef.current); @@ -458,6 +569,8 @@ export function TerminalViewport({ output: terminalOutput, error: terminalError, status: terminalStatus, + replayStartVersion: terminalReplayStartVersion, + replayCompleteVersion: terminalReplayCompleteVersion, version: terminalVersion, }; @@ -499,6 +612,7 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), + onScrollbackTop: () => requestExtendedReplay(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), // The surface listens from construction, so a right-click can land @@ -920,14 +1034,32 @@ export function TerminalViewport({ teardown?.(); if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); }; - }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); + // autoFocus is intentionally omitted; + // it is only read at mount time and must not trigger terminal teardown/recreation. + }, [ + cwd, + environmentId, + runtimeEnvKey, + terminalAttachIdentity, + terminalId, + threadId, + worktreePath, + ]); useEffect(() => { const terminal = terminalRef.current; + const subscriptionChanged = + outputSubscriptionIdentityRef.current !== terminalSubscriptionIdentity; + if (subscriptionChanged) { + outputSubscriptionIdentityRef.current = terminalSubscriptionIdentity; + outputCursorRef.current = INITIAL_TERMINAL_OUTPUT_CURSOR; + } const current = { output: terminalOutput, error: terminalError, status: terminalStatus, + replayStartVersion: terminalReplayStartVersion, + replayCompleteVersion: terminalReplayCompleteVersion, version: terminalVersion, }; if (!terminal) { @@ -937,21 +1069,100 @@ export function TerminalViewport({ const previous = previousSessionRef.current; synchronizeTerminalStatus(terminal, current.status); - if (current.version === previous.version && current.output === previous.output) { + const replayBoundaryChanged = + current.replayStartVersion !== previous.replayStartVersion || + current.replayCompleteVersion !== previous.replayCompleteVersion; + if ( + !subscriptionChanged && + current.version === previous.version && + current.output === previous.output && + !replayBoundaryChanged + ) { return; } const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current); - writeTerminalOutputUpdate(terminal, outputUpdate); outputCursorRef.current = outputUpdate.cursor; - terminal.clearSelection(); + if ( + replayBytes === EXTENDED_TERMINAL_REPLAY_BYTES && + current.replayStartVersion !== previous.replayStartVersion + ) { + scrollbackReplayRendererStateRef.current = "waiting"; + } + const scrollbackReplayPending = + pendingScrollbackReplayIdentityRef.current === terminalAttachIdentity; + const streamingReplay = scrollbackReplayRendererStateRef.current !== "idle"; + const completePendingScrollbackReplay = () => { + if (pendingScrollbackReplayIdentityRef.current !== terminalAttachIdentity) return; + pendingScrollbackReplayIdentityRef.current = null; + terminal.scrollToTopAfterWrites(); + }; + let didWriteOutput = false; + if (outputUpdate.type === "append") { + const result = writeTerminalOutputSegments({ + terminal, + segments: outputUpdate.segments, + replayState: scrollbackReplayRendererStateRef.current, + onReplayComplete: completePendingScrollbackReplay, + }); + scrollbackReplayRendererStateRef.current = result.replayState; + didWriteOutput = result.didWrite; + } else if (outputUpdate.type === "reset") { + if (outputUpdate.data.length === 0 && current.version === 0) { + // A restarted attach stream emits its pristine seed state before the + // server replies. Keep the current screen until real content arrives; + // the cursor above already adopted the new stream's epoch. + } else if (streamingReplay && outputUpdate.data.length === 0) { + // The extended attach begins with an empty snapshot. Keep the current + // screen visible until its first retained-history chunk arrives. + scrollbackReplayRendererStateRef.current = "waiting"; + } else if (streamingReplay) { + terminal.beginStreamingReplay(outputUpdate.data); + scrollbackReplayRendererStateRef.current = "replaying"; + didWriteOutput = true; + } else { + terminal.resetAndWrite(outputUpdate.data); + didWriteOutput = true; + } + } + if (didWriteOutput) terminal.clearSelection(); + + if ( + current.replayCompleteVersion > 0 && + // Only an actual completion may finish a pending extended replay: live + // output from the outgoing subscription arrives with these versions + // already balanced and must not clear the request early. + current.replayCompleteVersion !== previous.replayCompleteVersion && + current.replayCompleteVersion >= current.replayStartVersion && + current.version !== previous.version && + scrollbackReplayRendererStateRef.current !== "idle" + ) { + if (scrollbackReplayRendererStateRef.current === "waiting") { + terminal.beginStreamingReplay(terminalOutputText(current.output)); + terminal.clearSelection(); + } + terminal.completeStreamingReplay(); + scrollbackReplayRendererStateRef.current = "idle"; + if (scrollbackReplayPending) completePendingScrollbackReplay(); + } if (current.error !== null && current.error !== previous.error) { writeSystemMessage(terminal, current.error); } previousSessionRef.current = current; - }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); + }, [ + autoFocus, + terminalError, + terminalOutput, + terminalReplayCompleteVersion, + terminalReplayStartVersion, + terminalStatus, + terminalAttachIdentity, + terminalSubscriptionIdentity, + terminalVersion, + replayBytes, + ]); useEffect(() => { if (!autoFocus || !visible) return; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1d7975d24677..2ae0e39ceadb 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1032,6 +1032,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --terminal-foreground: var(--foreground); --terminal-cursor: rgb(38 56 78); --terminal-selection-background: rgb(37 63 99 / 20%); + /* Full-screen TUIs commonly reset cells to terminal defaults. Derive a dark + companion from the active terminal palette so custom themes keep control. */ + --terminal-alt-screen-background: color-mix(in srgb, var(--terminal-background) 5%, rgb(0 0 0)); + --terminal-alt-screen-foreground: color-mix( + in srgb, + var(--terminal-foreground) 5%, + rgb(255 255 255) + ); + --terminal-alt-screen-cursor: color-mix(in srgb, var(--terminal-cursor) 75%, rgb(255 255 255)); + --terminal-alt-screen-selection-background: color-mix( + in srgb, + var(--terminal-alt-screen-foreground) 25%, + transparent + ); @variant dark { color-scheme: dark; diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 3c466378ba8b..61f67c0824f8 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -36,6 +36,29 @@ describe("extractTerminalLinks", () => { ]); }); + it("finds a bare file name when a source position makes it unambiguous", () => { + expect(extractTerminalLinks("failed at surface.ts:42:7")).toEqual([ + { + kind: "path", + text: "surface.ts:42:7", + start: 10, + end: 25, + }, + ]); + }); + + it("does not treat host ports as bare file positions", () => { + expect(extractTerminalLinks("listening on api.example.com:8080 and 127.0.0.1:3000")).toEqual( + [], + ); + }); + + it("does not treat numeric host-to-host port mappings as file positions", () => { + expect(extractTerminalLinks("docker run -p 127.0.0.1:5432:5432 -p 0.0.0.0:8080:8080")).toEqual( + [], + ); + }); + it("classifies uppercase schemes as URLs at activation time too", () => { expect(isTerminalUrl("HTTPS://example.com/docs")).toBe(true); expect(isTerminalUrl("Http://example.com")).toBe(true); diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 59e2082a7359..7d9c97dec74f 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -32,8 +32,10 @@ export interface WrappedTerminalLinkLine { } const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu; +// The bare-filename alternative requires a letter-initial final segment so +// numeric hosts with two ports (`docker -p 127.0.0.1:5432:5432`) stay unlinked. const FILE_PATH_PATTERN = - /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; + /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}|[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*\.[A-Za-z][A-Za-z0-9_-]*(?::\d+){2}/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; function trimClosingDelimiters(value: string): string { diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index d01e20529d45..b0afd863002e 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -9,7 +9,10 @@ import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime"; const GHOSTTY_SUCCESS = 0; const GHOSTTY_OUT_OF_SPACE = -3; -const MAX_SCROLLBACK_ROWS = 10_000; +// Despite the older libghostty-vt header calling this a line count, Ghostty's +// screen implementation applies the value to its internal cell storage. A +// 4 MB text replay expands substantially once every cell has terminal state. +const MAX_SCROLLBACK_BYTES = 64 * 1024 * 1024; // wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the // libghostty-vt revision pinned alongside this module. const SELECTION_FORMAT_OPTIONS_SIZE = 16; @@ -64,7 +67,7 @@ export interface GhosttyColor { readonly b: number; } -export interface GhosttyTheme { +export interface GhosttyScreenTheme { readonly foreground: GhosttyColor; readonly background: GhosttyColor; readonly cursor: GhosttyColor; @@ -72,6 +75,11 @@ export interface GhosttyTheme { readonly selectionBackground?: string; } +export interface GhosttyTheme extends GhosttyScreenTheme { + /** Theme-owned defaults used while the standard alternate screen is active. */ + readonly alternateScreen?: GhosttyScreenTheme; +} + export interface GhosttyCell { readonly text: string; readonly wide: number; @@ -206,6 +214,7 @@ export class GhosttyTerminalCore { private mouseEvent = 0; private ptyWriterId = 0; private ptyWriter: ((data: string) => void) | null = null; + private replayActive = false; private scratch = 0; private graphemes = 0; private graphemeCapacity = 0; @@ -252,7 +261,12 @@ export class GhosttyTerminalCore { const options = this.runtime.alloc(optionsSize); this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols); this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows); - this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS); + this.runtime.setField( + options, + "GhosttyTerminalOptions", + "max_scrollback", + MAX_SCROLLBACK_BYTES, + ); this.terminalSlot = this.runtime.allocOpaque(); const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options); this.runtime.free(options, optionsSize); @@ -326,24 +340,43 @@ export class GhosttyTerminalCore { } resetAndWrite(data: string): void { + this.beginReplay(); + try { + this.writeReplay(data); + } finally { + this.endReplay(); + } + } + + /** Reset for ordered history replay while suppressing replay-generated PTY replies. */ + beginReplay(): void { this.ensureActive(); + if (this.replayActive) this.endReplay(); this.runtime.call("ghostty_terminal_reset", this.terminal); // RIS returns the cursor to Ghostty's built-in steady default, so the // embedder default has to be applied again before the replay runs. this.applyDefaultCursorBlink(); this.rows = []; - if (data.length === 0) return; - const writer = this.ptyWriter; if (this.ptyWriterId !== 0) { this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); this.ptyWriterId = 0; } - try { - this.write(data); - } finally { - if (writer !== null && !this.disposed) { - this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer); - } + this.replayActive = true; + } + + writeReplay(data: string): void { + this.ensureActive(); + if (!this.replayActive) { + throw new Error("Ghostty replay is not active"); + } + this.write(data); + } + + endReplay(): void { + if (!this.replayActive) return; + this.replayActive = false; + if (this.ptyWriter !== null && !this.disposed) { + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, this.ptyWriter); } } @@ -388,6 +421,14 @@ export class GhosttyTerminalCore { this.runtime.call("ghostty_terminal_set", this.terminal, option, color); } this.runtime.free(color, 3); + // Theme changes can coincide with a primary/alternate screen swap. Force + // the next snapshot to refresh every cached row so colors from the screen + // we just left cannot survive under the new defaults. + this.runtime.view(this.scratch, 4).setUint32(0, 2, true); + this.assertSuccess( + "ghostty_render_state_set(theme dirty)", + this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch), + ); } scroll(deltaRows: number): void { @@ -401,6 +442,15 @@ export class GhosttyTerminalCore { this.runtime.free(scroll, layout.size); } + scrollToTop(): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 0); + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + scrollToBottom(): void { this.ensureActive(); const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); @@ -454,6 +504,15 @@ export class GhosttyTerminalCore { ); } + isSynchronizedOutput(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2026, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + isAlternateScreen(): boolean { this.ensureActive(); this.runtime.bytes(this.scratch, 4).fill(0); diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts index 5f5c41c8fecb..58b28b7654db 100644 --- a/apps/web/src/terminal/ghostty/renderer.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -50,7 +50,7 @@ describe("measureGhosttyCell", () => { } as unknown as CanvasRenderingContext2D; expect(measureGhosttyCell(context, 12, "monospace")).toEqual({ - width: 7.2, + width: 7, height: 16, baseline: 11, }); @@ -71,6 +71,108 @@ describe("ghosttyTextRunEnd", () => { }); describe("renderGhosttySnapshot", () => { + it("remaps terminal defaults without overriding explicit application colors", () => { + const fillRectCalls: Array<{ args: number[]; style: string }> = []; + const fillTextCalls: Array<{ args: unknown[]; style: string }> = []; + let fillStyle = ""; + const context = { + canvas: { width: 200, height: 40 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (...args: number[]) => fillRectCalls.push({ args, style: fillStyle }), + fillText: (...args: unknown[]) => fillTextCalls.push({ args, style: fillStyle }), + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + get fillStyle() { + return fillStyle; + }, + set fillStyle(value: string | CanvasGradient | CanvasPattern) { + fillStyle = String(value); + }, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const defaultCell = cell("a"); + const inverseCell = { + ...cell("i"), + foreground: defaultCell.background, + background: defaultCell.foreground, + }; + const applicationCell = { + ...cell("b"), + foreground: { r: 10, g: 20, b: 30 }, + background: { r: 40, g: 50, b: 60 }, + }; + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 1, + foreground: defaultCell.foreground, + background: defaultCell.background, + cursor: { r: 9, g: 8, b: 7 }, + cursorX: 0, + cursorY: 0, + cursorVisible: true, + cursorBlinking: false, + cursorStyle: 0, + dirtyRows: new Set([0]), + rowData: [ + { + cells: [defaultCell, inverseCell, applicationCell], + text: "aib", + isWrapContinuation: false, + wrapsToNext: false, + }, + ], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 10, height: 20, baseline: 15 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: true, + cursorOn: true, + defaultThemeOverride: { + source: { + background: defaultCell.background, + foreground: defaultCell.foreground, + cursor: defaultCell.foreground, + }, + target: { + background: { r: 1, g: 2, b: 3 }, + foreground: { r: 250, g: 251, b: 252 }, + cursor: { r: 200, g: 201, b: 202 }, + }, + }, + }); + + expect(fillRectCalls).toContainEqual({ + args: [0, 0, 200, 40], + style: "rgb(1, 2, 3)", + }); + expect(fillRectCalls).toContainEqual({ + args: [14, 4, 10, 20], + style: "rgb(250, 251, 252)", + }); + expect(fillRectCalls).toContainEqual({ + args: [24, 4, 10, 20], + style: "rgb(40, 50, 60)", + }); + expect(fillRectCalls).toContainEqual({ + args: [4, 4, 2, 20], + style: "rgb(9, 8, 7)", + }); + expect(fillTextCalls).toEqual([ + { args: ["a", 4, 19, 10], style: "rgb(250, 251, 252)" }, + { args: ["i", 14, 19, 10], style: "rgb(1, 2, 3)" }, + { args: ["b", 24, 19, 10], style: "rgb(10, 20, 30)" }, + ]); + }); + it("underlines every cell in a hovered wrapped link", () => { const fillRectCalls: number[][] = []; const context = { @@ -127,6 +229,126 @@ describe("renderGhosttySnapshot", () => { ]); }); + it("draws solid block elements to exact cell edges", () => { + const fillRectCalls: Array<{ args: number[]; style: string }> = []; + let fillStyle = ""; + const context = { + canvas: { width: 100, height: 40 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (...args: number[]) => fillRectCalls.push({ args, style: fillStyle }), + fillText: () => {}, + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + get fillStyle() { + return fillStyle; + }, + set fillStyle(value: string | CanvasGradient | CanvasPattern) { + fillStyle = String(value); + }, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 1, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: -1, + cursorY: -1, + cursorVisible: false, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [ + { + cells: [cell("▀"), cell("▄"), cell("█")], + text: "▀▄█", + isWrapContinuation: false, + wrapsToNext: false, + }, + ], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 10, height: 20, baseline: 15 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: false, + }); + + expect(fillRectCalls).toContainEqual({ args: [4, 4, 10, 10], style: "rgb(255, 255, 255)" }); + expect(fillRectCalls).toContainEqual({ + args: [14, 14, 10, 10], + style: "rgb(255, 255, 255)", + }); + expect(fillRectCalls).toContainEqual({ + args: [24, 4, 10, 20], + style: "rgb(255, 255, 255)", + }); + }); + + it("keeps one-eighth block edges at least one pixel wide in narrow cells", () => { + const fillRectCalls: number[][] = []; + const context = { + canvas: { width: 100, height: 40 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (...args: number[]) => fillRectCalls.push(args), + fillText: () => {}, + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string | CanvasGradient | CanvasPattern) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 2, + rows: 1, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: -1, + cursorY: -1, + cursorVisible: false, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [ + { + cells: [cell("▏"), cell("▕")], + text: "▏▕", + isWrapContinuation: false, + wrapsToNext: false, + }, + ], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 4, height: 20, baseline: 15 }, + fontSize: 7, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: false, + }); + + // Rounding 7/8 of a 4px cell would collapse the right bar to nothing. + expect(fillRectCalls).toContainEqual([4, 4, 1, 20]); + expect(fillRectCalls).toContainEqual([11, 4, 1, 20]); + }); + it("constrains text runs and cursor glyphs to their terminal cells", () => { const fillTextCalls: unknown[][] = []; const context = { diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 9d47718464ea..78d5c8a8b6cb 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -3,6 +3,7 @@ import { ghosttyColorsEqual, type GhosttyCell, type GhosttyColor, + type GhosttyScreenTheme, type GhosttySnapshot, } from "./core"; @@ -17,6 +18,8 @@ export interface GhosttyCellRange { readonly end: { readonly x: number; readonly y: number }; } +type TerminalBlockRect = readonly [x: number, y: number, width: number, height: number]; + const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; function cssColor(color: GhosttyColor): string { @@ -60,6 +63,98 @@ function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): s return `${style} ${weight} ${fontSize}px ${fontFamily}`; } +/** Solid Unicode block elements render as cell geometry, without font side-bearing seams. */ +function terminalBlockRects(text: string): readonly TerminalBlockRect[] | null { + const lower = (eighths: number): readonly TerminalBlockRect[] => [ + [0, 1 - eighths / 8, 1, eighths / 8], + ]; + const left = (eighths: number): readonly TerminalBlockRect[] => [[0, 0, eighths / 8, 1]]; + switch (text) { + case "▀": + return [[0, 0, 1, 0.5]]; + case "▁": + case "▂": + case "▃": + case "▄": + case "▅": + case "▆": + case "▇": + return lower(text.codePointAt(0)! - 0x2580); + case "█": + return [[0, 0, 1, 1]]; + case "▉": + case "▊": + case "▋": + case "▌": + case "▍": + case "▎": + case "▏": + return left(0x2590 - text.codePointAt(0)!); + case "▐": + return [[0.5, 0, 0.5, 1]]; + case "▔": + return [[0, 0, 1, 0.125]]; + case "▕": + return [[0.875, 0, 0.125, 1]]; + case "▖": + return [[0, 0.5, 0.5, 0.5]]; + case "▗": + return [[0.5, 0.5, 0.5, 0.5]]; + case "▘": + return [[0, 0, 0.5, 0.5]]; + case "▙": + return [ + [0, 0, 0.5, 1], + [0.5, 0.5, 0.5, 0.5], + ]; + case "▚": + return [ + [0, 0, 0.5, 0.5], + [0.5, 0.5, 0.5, 0.5], + ]; + case "▛": + return [ + [0, 0, 1, 0.5], + [0, 0.5, 0.5, 0.5], + ]; + case "▜": + return [ + [0, 0, 1, 0.5], + [0.5, 0.5, 0.5, 0.5], + ]; + case "▝": + return [[0.5, 0, 0.5, 0.5]]; + case "▞": + return [ + [0.5, 0, 0.5, 0.5], + [0, 0.5, 0.5, 0.5], + ]; + case "▟": + return [ + [0.5, 0, 0.5, 1], + [0, 0.5, 0.5, 0.5], + ]; + default: + return null; + } +} + +/** Rounds a block fraction to whole pixels, keeping thin edges at least one pixel wide inside the cell. */ +function blockPixelSpan( + origin: number, + start: number, + length: number, + size: number, +): readonly [from: number, to: number] { + let from = Math.round(start * size); + let to = Math.round((start + length) * size); + if (to <= from) { + if (to >= size) from = to - 1; + else to = from + 1; + } + return [origin + from, origin + to]; +} + export function measureGhosttyCell( context: CanvasRenderingContext2D, fontSize: number, @@ -73,7 +168,10 @@ export function measureGhosttyCell( const glyphHeight = ascent + descent; const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight)); return { - width: Math.max(1, widthMeasurement.width), + // libghostty's cell and mouse APIs use integer logical pixels. Flooring + // also makes CanvasRenderingContext2D condense text into the same grid, + // keeping glyphs, backgrounds, and mouse hit targets aligned. + width: Math.max(1, Math.floor(widthMeasurement.width)), height, baseline: Math.round((height - glyphHeight) / 2 + ascent), }; @@ -103,6 +201,11 @@ export function renderGhosttySnapshot(options: { readonly previousCursorY?: number | null; readonly focused?: boolean; readonly selectionBackground?: string; + /** Remap only terminal-default colors; explicit ANSI application colors win. */ + readonly defaultThemeOverride?: { + readonly source: GhosttyScreenTheme; + readonly target: GhosttyScreenTheme; + }; readonly hoveredLinkRange?: GhosttyCellRange | null; /** Vertical origin of row 0; defaults to the horizontal padding. */ readonly originY?: number; @@ -120,6 +223,37 @@ export function renderGhosttySnapshot(options: { } = options; const focused = options.focused ?? true; const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; + const themeOverride = options.defaultThemeOverride; + const defaultBackground = themeOverride?.target.background ?? snapshot.background; + const defaultForeground = themeOverride?.target.foreground ?? snapshot.foreground; + const resolveDefaultColor = ( + color: GhosttyColor, + sourceDefault: GhosttyColor, + sourceInverse: GhosttyColor, + targetDefault: GhosttyColor, + targetInverse: GhosttyColor, + ) => { + if (!themeOverride) return color; + if (ghosttyColorsEqual(color, sourceDefault)) return targetDefault; + if (ghosttyColorsEqual(color, sourceInverse)) return targetInverse; + return color; + }; + const resolveBackground = (color: GhosttyColor) => + resolveDefaultColor( + color, + themeOverride?.source.background ?? snapshot.background, + themeOverride?.source.foreground ?? snapshot.foreground, + defaultBackground, + defaultForeground, + ); + const resolveForeground = (color: GhosttyColor) => + resolveDefaultColor( + color, + themeOverride?.source.foreground ?? snapshot.foreground, + themeOverride?.source.background ?? snapshot.background, + defaultForeground, + defaultBackground, + ); const hoveredLinkRange = options.hoveredLinkRange ?? null; const originY = options.originY ?? padding; const rowsToDraw = forceFull @@ -140,7 +274,7 @@ export function renderGhosttySnapshot(options: { if (forceFull) { context.save(); context.resetTransform(); - context.fillStyle = cssColor(snapshot.background); + context.fillStyle = cssColor(defaultBackground); context.fillRect(0, 0, context.canvas.width, context.canvas.height); context.restore(); } @@ -151,30 +285,31 @@ export function renderGhosttySnapshot(options: { if (!row) continue; const top = originY + rowIndex * metrics.height; - context.fillStyle = cssColor(snapshot.background); + context.fillStyle = cssColor(defaultBackground); context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height); let backgroundStart = 0; while (backgroundStart < row.cells.length) { const first = row.cells[backgroundStart]; if (!first) break; + const firstBackground = resolveBackground(first.background); let backgroundEnd = backgroundStart + 1; while (backgroundEnd < row.cells.length) { const next = row.cells[backgroundEnd]; if ( !next || next.selected !== first.selected || - !ghosttyColorsEqual(next.background, first.background) + !ghosttyColorsEqual(resolveBackground(next.background), firstBackground) ) { break; } backgroundEnd += 1; } - if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) { + if (first.selected || !ghosttyColorsEqual(firstBackground, defaultBackground)) { const left = padding + backgroundStart * metrics.width; const width = (backgroundEnd - backgroundStart) * metrics.width; - if (!ghosttyColorsEqual(first.background, snapshot.background)) { - context.fillStyle = cssColor(first.background); + if (!ghosttyColorsEqual(firstBackground, defaultBackground)) { + context.fillStyle = cssColor(firstBackground); context.fillRect(left, top, width, metrics.height); } if (first.selected) { @@ -193,7 +328,25 @@ export function renderGhosttySnapshot(options: { runStart += 1; continue; } - const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first)); + const blockRects = terminalBlockRects(first.text); + if (blockRects !== null) { + if (!first.invisible) { + context.fillStyle = cssColor(resolveForeground(first.foreground)); + const cellLeft = padding + runStart * metrics.width; + for (const [x, y, width, height] of blockRects) { + const [left, right] = blockPixelSpan(cellLeft, x, width, metrics.width); + const [rectTop, bottom] = blockPixelSpan(top, y, height, metrics.height); + context.fillRect(left, rectTop, right - left, bottom - rectTop); + } + } + runStart += 1; + continue; + } + const runEnd = ghosttyTextRunEnd( + row.cells, + runStart, + (cell) => terminalBlockRects(cell.text) === null && sameTextStyle(cell, first), + ); const text = row.cells .slice(runStart, runEnd) .map((cell) => cell.text) @@ -209,7 +362,7 @@ export function renderGhosttySnapshot(options: { ); context.clip(); context.font = fontForCell(first, fontSize, fontFamily); - context.fillStyle = cssColor(first.foreground); + context.fillStyle = cssColor(resolveForeground(first.foreground)); context.fillText( text, padding + runStart * metrics.width, @@ -232,7 +385,7 @@ export function renderGhosttySnapshot(options: { if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) { continue; } - context.fillStyle = cssColor(cell.foreground); + context.fillStyle = cssColor(resolveForeground(cell.foreground)); const left = padding + column * metrics.width; if (cell.underline || hoveredLink) { context.fillRect(left, top + metrics.height - 2, metrics.width, 1); @@ -247,24 +400,28 @@ export function renderGhosttySnapshot(options: { if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { const left = padding + snapshot.cursorX * metrics.width; const top = originY + snapshot.cursorY * metrics.height; - context.fillStyle = cssColor(snapshot.cursor); + const cursor = + themeOverride && ghosttyColorsEqual(snapshot.cursor, themeOverride.source.cursor) + ? themeOverride.target.cursor + : snapshot.cursor; + context.fillStyle = cssColor(cursor); if (!focused) { // An unfocused terminal draws a hollow cursor so the active pane is obvious. - context.strokeStyle = cssColor(snapshot.cursor); + context.strokeStyle = cssColor(cursor); context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); } else if (snapshot.cursorStyle === 0) { context.fillRect(left, top, 2, metrics.height); } else if (snapshot.cursorStyle === 2) { context.fillRect(left, top + metrics.height - 2, metrics.width, 2); } else if (snapshot.cursorStyle === 3) { - context.strokeStyle = cssColor(snapshot.cursor); + context.strokeStyle = cssColor(cursor); context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); } else { context.fillRect(left, top, metrics.width, metrics.height); const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; if (cell?.text) { context.font = fontForCell(cell, fontSize, fontFamily); - context.fillStyle = cssColor(snapshot.background); + context.fillStyle = cssColor(defaultBackground); context.fillText(cell.text, left, top + metrics.baseline, metrics.width); } } diff --git a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts index 7d4782b4b409..cdc271c798d1 100644 --- a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts +++ b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts @@ -196,6 +196,11 @@ describe("vendored libghostty-vt WebAssembly", () => { expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0); expect(Number(scrollbarView.getBigUint64(8, true))).toBe(36); + scrollView.setUint32(0, 0, true); + call("ghostty_terminal_scroll_viewport", terminal, scroll); + expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0); + expect(Number(scrollbarView.getBigUint64(8, true))).toBe(0); + call("ghostty_wasm_free_u8_array", scroll, 24); call("ghostty_wasm_free_u8_array", scrollbar, 24); call("ghostty_wasm_free_u8_array", inputPointer, input.length); @@ -403,7 +408,7 @@ describe("vendored libghostty-vt WebAssembly", () => { free(options, 8); }); - it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => { + it("uses Ghostty for terminal modes, mouse encoding, selection, and link hit testing", async () => { const result = await WebAssembly.instantiate( decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, { env: { log: () => {} } }, @@ -449,6 +454,36 @@ describe("vendored libghostty-vt WebAssembly", () => { call("ghostty_terminal_vt_write", terminal, anyEventResetPointer, anyEventReset.length); expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0); + const synchronizedOutput = new TextEncoder().encode("\u001b[?2026h"); + const synchronizedOutputPointer = alloc(synchronizedOutput.length); + new Uint8Array(memory.buffer, synchronizedOutputPointer, synchronizedOutput.length).set( + synchronizedOutput, + ); + call( + "ghostty_terminal_vt_write", + terminal, + synchronizedOutputPointer, + synchronizedOutput.length, + ); + expect(call("ghostty_terminal_mode_get", terminal, 2026, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1); + const synchronizedOutputReset = new TextEncoder().encode("\u001b[?2026l"); + const synchronizedOutputResetPointer = alloc(synchronizedOutputReset.length); + new Uint8Array( + memory.buffer, + synchronizedOutputResetPointer, + synchronizedOutputReset.length, + ).set(synchronizedOutputReset); + call( + "ghostty_terminal_vt_write", + terminal, + synchronizedOutputResetPointer, + synchronizedOutputReset.length, + ); + expect(call("ghostty_terminal_mode_get", terminal, 2026, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0); + free(synchronizedOutputResetPointer, synchronizedOutputReset.length); + free(synchronizedOutputPointer, synchronizedOutput.length); free(anyEventResetPointer, anyEventReset.length); free(anyEventPointer, anyEventInput.length); free(modeFlag, 1); @@ -596,7 +631,7 @@ describe("vendored libghostty-vt WebAssembly", () => { free(terminalOptions, 8); }); - it("encodes modified printable keys in Kitty keyboard mode", async () => { + it("encodes legacy controls and modified printable keys in Kitty keyboard mode", async () => { const result = await WebAssembly.instantiate( decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, { env: { log: () => {} } }, @@ -616,11 +651,6 @@ describe("vendored libghostty-vt WebAssembly", () => { const terminalSlot = call("ghostty_wasm_alloc_opaque"); expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); - const kittyMode = new TextEncoder().encode("\u001b[>1u"); - const kittyModePointer = alloc(kittyMode.length); - new Uint8Array(memory.buffer, kittyModePointer, kittyMode.length).set(kittyMode); - call("ghostty_terminal_vt_write", terminal, kittyModePointer, kittyMode.length); - const encoderSlot = call("ghostty_wasm_alloc_opaque"); const eventSlot = call("ghostty_wasm_alloc_opaque"); expect(call("ghostty_key_encoder_new", 0, encoderSlot)).toBe(0); @@ -641,6 +671,31 @@ describe("vendored libghostty-vt WebAssembly", () => { call("ghostty_key_event_set_utf8", keyEvent, textPointer, text.length); const written = call("ghostty_wasm_alloc_usize"); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); + const legacyOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const legacyOutput = alloc(legacyOutputSize); + expect( + call( + "ghostty_key_encoder_encode", + keyEncoder, + keyEvent, + legacyOutput, + legacyOutputSize, + written, + ), + ).toBe(0); + const legacyOutputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, legacyOutput, legacyOutputLength)), + ).toBe("\u0003"); + free(legacyOutput, legacyOutputSize); + + const kittyMode = new TextEncoder().encode("\u001b[>1u"); + const kittyModePointer = alloc(kittyMode.length); + new Uint8Array(memory.buffer, kittyModePointer, kittyMode.length).set(kittyMode); + call("ghostty_terminal_vt_write", terminal, kittyModePointer, kittyMode.length); + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); const output = alloc(outputSize); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ee3240b41b08..ea9b8ec6a99a 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core"; +import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow, type GhosttyTheme } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, @@ -18,11 +18,13 @@ import { primeTerminalCopyInput, resolveTerminalMouseData, resolveTerminalMouseTrackingState, + resolveTerminalAsyncPasteClaim, shouldBlinkTerminalCursor, shouldReportTerminalMouse, terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, + terminalThemeForScreen, terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, @@ -351,6 +353,41 @@ describe("GhosttyTerminalSurface visibility", () => { ); }); +const lightTerminalTheme = { + background: { r: 255, g: 255, b: 255 }, + foreground: { r: 20, g: 20, b: 20 }, + cursor: { r: 38, g: 56, b: 78 }, + selectionBackground: "rgb(37 63 99 / 20%)", + alternateScreen: { + background: { r: 12, g: 12, b: 12 }, + foreground: { r: 244, g: 244, b: 244 }, + cursor: { r: 199, g: 218, b: 255 }, + selectionBackground: "rgb(37 63 99 / 16%)", + }, +} satisfies GhosttyTheme; + +describe("terminalThemeForScreen", () => { + it("keeps the app theme on the normal shell screen", () => { + expect(terminalThemeForScreen(lightTerminalTheme, false)).toBe(lightTerminalTheme); + }); + + it("uses coherent dark defaults for a full-screen app under a light host theme", () => { + expect(terminalThemeForScreen(lightTerminalTheme, true)).toBe( + lightTerminalTheme.alternateScreen, + ); + }); + + it("leaves an existing dark app theme untouched in the alternate screen", () => { + const darkTheme = { + background: { r: 0, g: 0, b: 0 }, + foreground: { r: 245, g: 245, b: 245 }, + cursor: { r: 180, g: 203, b: 255 }, + } satisfies GhosttyTheme; + + expect(terminalThemeForScreen(darkTheme, true)).toBe(darkTheme); + }); +}); + const cell = (text: string): GhosttyCell => ({ text, wide: 0, @@ -669,6 +706,23 @@ describe("isTerminalPasteShortcut", () => { }); }); +describe("resolveTerminalAsyncPasteClaim", () => { + it("leaves a duplicate marker while the native shortcut event can still arrive", () => { + expect(resolveTerminalAsyncPasteClaim(4, 4, true)).toEqual({ + nextToken: 4, + deliveredToken: 4, + }); + }); + + it("invalidates a late async marker after keyup ends the shortcut", () => { + expect(resolveTerminalAsyncPasteClaim(4, 4, false)).toEqual({ + nextToken: 5, + deliveredToken: null, + }); + expect(resolveTerminalAsyncPasteClaim(4, 5, false)).toBeNull(); + }); +}); + describe("isTerminalCompositionCommitInput", () => { it("identifies browser composition follow-up input", () => { expect(isTerminalCompositionCommitInput({ inputType: "" })).toBe(true); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 29aaac6f6abd..7b9b961e17f7 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -4,6 +4,7 @@ import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../term import { GhosttyTerminalCore, type GhosttyScrollbar, + type GhosttyScreenTheme, type GhosttySnapshot, type GhosttyTheme, } from "./core"; @@ -37,6 +38,19 @@ const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; +// A missing synchronized-output reset must not leave the visible terminal +// frozen forever. A short fallback is no worse than rendering unsynchronized. +const SYNCHRONIZED_OUTPUT_RENDER_TIMEOUT_MS = 500; +// Hidden and occluded documents can suspend animation frames indefinitely. +// The timeout drains the accumulated queue without a per-frame budget when +// there is no paint cadence to protect. +const TERMINAL_WRITE_DRAIN_TIMEOUT_MS = 100; +const TERMINAL_WRITE_CHUNK_CODE_UNITS = 64 * 1024; +const TERMINAL_IMMEDIATE_WRITE_CODE_UNITS = 16 * 1024; +// Normal visible writes yield between chunks to protect paints. If a producer +// outruns the display for long enough, finish the bounded backlog in one pass +// instead of letting it grow without limit behind one 64 KB animation frame. +const TERMINAL_WRITE_QUEUE_MAX_CODE_UNITS = 1024 * 1024; const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; const TERMINAL_FONT_LOAD_VARIANTS = [ "normal 400", @@ -51,6 +65,33 @@ export interface GhosttyTerminalFont { readonly size?: number; } +function linearColorChannel(value: number): number { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; +} + +function terminalColorLuminance(color: GhosttyTheme["background"]): number { + return ( + 0.2126 * linearColorChannel(color.r) + + 0.7152 * linearColorChannel(color.g) + + 0.0722 * linearColorChannel(color.b) + ); +} + +/** + * Full-screen terminal apps often reset cells to the terminal defaults while + * repainting a dark interface. Give a light host theme coherent dark defaults + * only while the standard alternate screen is active; explicit app colors + * still win, and returning to the shell restores the host theme. + */ +export function terminalThemeForScreen( + theme: GhosttyTheme, + alternateScreen: boolean, +): GhosttyScreenTheme { + if (!alternateScreen || terminalColorLuminance(theme.background) < 0.5) return theme; + return theme.alternateScreen ?? theme; +} + let symbolsFontLoad: Promise | null = null; /** @@ -378,6 +419,17 @@ export function isTerminalPasteShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } +export function resolveTerminalAsyncPasteClaim( + pendingToken: number, + currentToken: number, + shortcutActive: boolean, +): { readonly nextToken: number; readonly deliveredToken: number | null } | null { + if (pendingToken !== currentToken) return null; + return shortcutActive + ? { nextToken: currentToken, deliveredToken: pendingToken } + : { nextToken: currentToken + 1, deliveredToken: null }; +} + export function isTerminalCompositionCommitInput(event: Pick): boolean { return ( event.inputType === "" || @@ -525,6 +577,7 @@ export interface GhosttyTerminalSurfaceOptions { readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; + readonly onScrollbackTop?: () => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; /** @@ -558,6 +611,15 @@ export class GhosttyTerminalSurface { private readonly scrollbarThumb: HTMLDivElement; private snapshot: GhosttySnapshot | null = null; private frame = 0; + private synchronizedRenderTimer: number | null = null; + private writeFrame = 0; + private writeDrainTimer: number | null = null; + private writeQueue: Array<{ data: string; offset: number; replay: boolean }> = []; + private writeQueueIndex = 0; + private writeQueueCodeUnits = 0; + private replayActive = false; + private replayStreamOpen = false; + private scrollToTopWhenWritesDrain = false; private cursorTimer: number | null = null; private compositionInputToSuppress: string | null = null; private compositionSuppressionTimer: number | null = null; @@ -590,6 +652,8 @@ export class GhosttyTerminalSurface { private linkActivationPointerId: number | null = null; private hoveredLink: TerminalLinkWithRange | null = null; private hoverPointer: { x: number; y: number } | null = null; + /** Cursor shown when no link is hovered: "default" while an app owns the mouse. */ + private hoverBaseCursor = ""; private linkModifierActive = false; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; @@ -597,9 +661,18 @@ export class GhosttyTerminalSurface { private focused = false; private resizeNotified = false; private canvasConfigured = false; - private theme: GhosttyTheme; + private canvasDevicePixelRatio = 0; + private pendingCanvasConfiguration: { + readonly width: number; + readonly height: number; + readonly ratio: number; + } | null = null; + private appTheme: GhosttyTheme; + private theme: GhosttyScreenTheme; + private alternateScreenActive = false; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; + private pasteShortcutDeliveredToken: number | null = null; private copyShortcutToken = 0; private clearSelectionAfterCopy = false; private primedCopySelection = ""; @@ -636,6 +709,7 @@ export class GhosttyTerminalSurface { this.metrics = metrics; this.options = options; this.visible = options.visible ?? true; + this.appTheme = options.theme; this.theme = options.theme; this.fontFamily = fontFamily; this.requestedFontFamily = options.font?.family; @@ -737,9 +811,28 @@ export class GhosttyTerminalSurface { } write(data: string): void { - if (this.disposed) return; - this.core.write(data); + if (this.disposed || data.length === 0) return; + if ( + !this.replayActive && + this.writeQueueIndex >= this.writeQueue.length && + data.length <= TERMINAL_IMMEDIATE_WRITE_CODE_UNITS + ) { + this.core.write(data); + this.didWriteOutput(); + return; + } + + // Output from the PTY only follows the server's replay-complete marker. + // Anything written while a streamed replay is open is renderer-local + // status text, so keep it ordered inside the replay instead of ending the + // stream before later history chunks arrive. + this.enqueueWrite(data, this.replayStreamOpen); + } + + private didWriteOutput(): void { + this.synchronizeScreenTheme(); this.synchronizeMouseTrackingState(); + this.refreshHoverBaseCursor(); // Restart the blink cycle from the visible phase so the cursor never sits // invisible through a stream of output or a burst of typing echo. this.cursorOn = true; @@ -749,8 +842,16 @@ export class GhosttyTerminalSurface { resetAndWrite(data: string): void { if (this.disposed) return; + this.cancelPendingWrites(); this.lastMouseMotionData = ""; - this.core.resetAndWrite(data); + this.core.beginReplay(); + this.replayActive = true; + this.synchronizeScreenTheme(); + if (data.length > 0) { + this.enqueueWrite(data, true); + } else { + this.finishReplay(); + } this.synchronizeMouseTrackingState(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. @@ -760,14 +861,190 @@ export class GhosttyTerminalSurface { this.requestRender(); } + /** Reset once, then accept ordered replay chunks until the caller marks the stream complete. */ + beginStreamingReplay(data: string): void { + if (this.disposed) return; + this.cancelPendingWrites(); + this.lastMouseMotionData = ""; + this.core.beginReplay(); + this.replayActive = true; + this.replayStreamOpen = true; + this.synchronizeScreenTheme(); + if (data.length > 0) { + this.enqueueWrite(data, true); + } + this.synchronizeMouseTrackingState(); + this.cursorOn = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + appendStreamingReplay(data: string): void { + if (this.disposed || data.length === 0) return; + if (!this.replayStreamOpen) { + this.beginStreamingReplay(data); + return; + } + this.enqueueWrite(data, true); + } + + completeStreamingReplay(): void { + if (this.disposed || !this.replayStreamOpen) return; + this.replayStreamOpen = false; + if (this.writeQueueIndex < this.writeQueue.length) return; + this.finishReplay(); + if (this.scrollToTopWhenWritesDrain) { + this.scrollToTopWhenWritesDrain = false; + this.scrollToTop(); + } + } + + scrollToTopAfterWrites(): void { + if (this.disposed) return; + if (this.writeQueueIndex < this.writeQueue.length || this.replayActive) { + this.scrollToTopWhenWritesDrain = true; + return; + } + this.scrollToTop(); + } + + private enqueueWrite(data: string, replay: boolean): void { + this.writeQueue.push({ data, offset: 0, replay }); + this.writeQueueCodeUnits += data.length; + if (this.writeQueueCodeUnits < TERMINAL_WRITE_QUEUE_MAX_CODE_UNITS) { + this.requestWriteDrain(); + return; + } + + if (this.writeFrame !== 0) { + window.cancelAnimationFrame(this.writeFrame); + this.writeFrame = 0; + } + if (this.writeDrainTimer !== null) { + window.clearTimeout(this.writeDrainTimer); + this.writeDrainTimer = null; + } + this.drainWrites(Number.POSITIVE_INFINITY); + } + + private requestWriteDrain(): void { + if (this.disposed || this.writeFrame !== 0 || this.writeDrainTimer !== null) return; + this.writeFrame = window.requestAnimationFrame(this.drainWritesOnFrame); + this.writeDrainTimer = window.setTimeout( + this.drainWritesAfterFrameTimeout, + TERMINAL_WRITE_DRAIN_TIMEOUT_MS, + ); + } + + private readonly drainWritesOnFrame = () => { + this.writeFrame = 0; + if (this.writeDrainTimer !== null) { + window.clearTimeout(this.writeDrainTimer); + this.writeDrainTimer = null; + } + this.drainWrites(TERMINAL_WRITE_CHUNK_CODE_UNITS); + }; + + private readonly drainWritesAfterFrameTimeout = () => { + this.writeDrainTimer = null; + if (this.writeFrame !== 0) { + window.cancelAnimationFrame(this.writeFrame); + this.writeFrame = 0; + } + this.drainWrites(Number.POSITIVE_INFINITY); + }; + + private drainWrites(budget: number): void { + if (this.disposed) return; + + while (budget > 0) { + const pending = this.writeQueue[this.writeQueueIndex]; + if (!pending) break; + if (this.replayActive && !pending.replay) this.finishReplay(); + let end = Math.min(pending.data.length, pending.offset + budget); + const lastCodeUnit = pending.data.charCodeAt(end - 1); + const nextCodeUnit = pending.data.charCodeAt(end); + if ( + end < pending.data.length && + lastCodeUnit >= 0xd800 && + lastCodeUnit <= 0xdbff && + nextCodeUnit >= 0xdc00 && + nextCodeUnit <= 0xdfff + ) { + end -= 1; + } + const chunk = pending.data.slice(pending.offset, end); + // A one-code-unit remainder can land before a surrogate pair. Leave it + // for the next frame, whose full budget can consume the pair together. + if (chunk.length === 0) break; + if (pending.replay && this.replayActive) this.core.writeReplay(chunk); + else this.core.write(chunk); + budget -= chunk.length; + this.writeQueueCodeUnits -= chunk.length; + pending.offset = end; + if (pending.offset >= pending.data.length) this.writeQueueIndex += 1; + } + + if (this.writeQueueIndex >= this.writeQueue.length) { + this.writeQueue = []; + this.writeQueueIndex = 0; + this.writeQueueCodeUnits = 0; + if (!this.replayStreamOpen) { + this.finishReplay(); + if (this.scrollToTopWhenWritesDrain) { + this.scrollToTopWhenWritesDrain = false; + this.scrollToTop(); + } + } + } else { + this.requestWriteDrain(); + } + this.didWriteOutput(); + } + + private finishReplay(): void { + if (!this.replayActive) return; + this.core.endReplay(); + this.replayActive = false; + this.replayStreamOpen = false; + } + + private cancelPendingWrites(): void { + this.cancelSynchronizedRenderTimer(); + if (this.writeFrame !== 0) { + window.cancelAnimationFrame(this.writeFrame); + this.writeFrame = 0; + } + if (this.writeDrainTimer !== null) { + window.clearTimeout(this.writeDrainTimer); + this.writeDrainTimer = null; + } + this.writeQueue = []; + this.writeQueueIndex = 0; + this.writeQueueCodeUnits = 0; + this.replayStreamOpen = false; + this.scrollToTopWhenWritesDrain = false; + this.finishReplay(); + } + setTheme(theme: GhosttyTheme): void { if (this.disposed) return; - this.theme = theme; + this.appTheme = theme; this.core.setTheme(theme); - this.forceFullRender = true; + this.synchronizeScreenTheme(true); this.requestRender(); } + private synchronizeScreenTheme(force = false): void { + const alternateScreen = this.core.isAlternateScreen(); + if (!force && alternateScreen === this.alternateScreenActive) return; + this.alternateScreenActive = alternateScreen; + this.theme = terminalThemeForScreen(this.appTheme, alternateScreen); + this.mount.style.backgroundColor = `rgb(${this.theme.background.r} ${this.theme.background.g} ${this.theme.background.b})`; + this.forceFullRender = true; + } + async setFont(font: GhosttyTerminalFont): Promise { if (this.disposed) return; const fontSize = terminalFontSize(font.size); @@ -851,15 +1128,24 @@ export class GhosttyTerminalSurface { if ( this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight || - !this.canvasConfigured + !this.canvasConfigured || + this.canvasDevicePixelRatio !== ratio ) { - this.canvas.width = pixelWidth; - this.canvas.height = pixelHeight; - this.context.setTransform(ratio, 0, 0, ratio, 0, 0); - this.canvasConfigured = true; + // Changing a canvas backing size clears it immediately. Keep the last + // complete frame visible until the gated paint can resize and redraw in + // one callback, especially while a full-screen app owns mode 2026. + this.pendingCanvasConfiguration = { + width: pixelWidth, + height: pixelHeight, + ratio, + }; this.forceFullRender = true; this.scrollbarDirty = true; shouldRender = true; + } else { + // A rapid drag can return to the currently painted size before its queued + // frame runs. Do not apply the stale intermediate backing dimensions. + this.pendingCanvasConfiguration = null; } const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING); this.mountHeight = height; @@ -874,10 +1160,14 @@ export class GhosttyTerminalSurface { this.scrollbarDirty = true; 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. - if (shouldRender || this.forceFullRender) this.renderFrame(); + // ResizeObserver runs after animation frame callbacks. Outside a gated TUI + // update, repaint now so this frame never stretches the old backing store. + // Mode 2026 keeps its atomicity and applies the pending size with its next + // complete synchronized frame. + if (shouldRender || this.forceFullRender) { + if (this.core.isSynchronizedOutput()) this.requestRender(); + else this.renderFrame(); + } return true; } @@ -976,6 +1266,13 @@ export class GhosttyTerminalSurface { this.requestRender(); } + scrollToTop(): void { + this.core.scrollToTop(); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + isAtBottom(): boolean { return this.core.isViewportActive(); } @@ -997,6 +1294,10 @@ export class GhosttyTerminalSurface { this.options.onResize(this.cols, this.rows); } this.cancelRender(); + this.cancelSynchronizedRenderTimer(); + if (this.writeFrame !== 0) window.cancelAnimationFrame(this.writeFrame); + if (this.writeDrainTimer !== null) window.clearTimeout(this.writeDrainTimer); + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); if (this.compositionSuppressionTimer !== null) { window.clearTimeout(this.compositionSuppressionTimer); } @@ -1082,17 +1383,25 @@ export class GhosttyTerminalSurface { this.suppressedKeyCodes.add(event.code); const clipboard = navigator.clipboard; if (typeof clipboard?.readText === "function") { - // Race the async clipboard read against the browser's own paste event: - // the native event (dispatched synchronously with the default action) - // always claims the token first when it fires, and the read covers - // browsers whose paste shortcut produces no paste event. Not preventing - // the default keeps the native path alive when the read is denied. + // Race the async clipboard read against the browser's own paste event. + // Either may arrive first, so the winning path records the gesture and + // the other path becomes a no-op. The read covers browsers whose paste + // shortcut produces no paste event; leaving the default intact keeps + // the native path alive when clipboard permission is denied. const token = ++this.pasteShortcutToken; void clipboard.readText().then( (text) => { if (this.disposed || this.pasteShortcutToken !== token) return; - this.pasteShortcutToken += 1; - if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + if (text.length === 0) return; + const claim = resolveTerminalAsyncPasteClaim( + token, + this.pasteShortcutToken, + this.suppressedKeyCodes.has(event.code), + ); + if (claim === null) return; + this.pasteShortcutDeliveredToken = claim.deliveredToken; + this.pasteShortcutToken = claim.nextToken; + this.options.onData(this.core.encodePaste(text)); }, () => { // Clipboard read denied; the native paste event remains the path. @@ -1118,7 +1427,15 @@ export class GhosttyTerminalSurface { private readonly onKeyUp = (event: KeyboardEvent) => { this.updateLinkModifier(event); - if (this.suppressedKeyCodes.delete(event.code)) return; + if (this.suppressedKeyCodes.delete(event.code)) { + // A native paste belongs to the same shortcut gesture and arrives before + // its keyup. Do not let an async-only shortcut suppress a later context- + // menu paste just because the browser never dispatched the native event. + if (event.code === "KeyV" || event.code === "Insert") { + this.pasteShortcutDeliveredToken = null; + } + return; + } if (isTerminalCompositionKey(event, this.composing)) { return; } @@ -1139,8 +1456,9 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; - this.linkModifierActive = false; - this.refreshHoveredLink(); + // The link modifier deliberately survives input blur: hover-linking is a + // pointer gesture and the window-level listeners keep tracking the key + // while focus lives in the composer or elsewhere. // Suppressions survive blur deliberately: a shortcut that moves focus (for // example terminal-toggle) must still swallow its own keyup if focus comes // back before release. Stale entries are harmless — an encoding keydown @@ -1199,8 +1517,14 @@ export class GhosttyTerminalSurface { event.preventDefault(); const data = event.clipboardData?.getData("text/plain") ?? ""; if (data.length === 0) return; - // The native paste won the race with actual text; a pending clipboard read - // must not double. An empty native paste leaves the read as the only path. + const token = this.pasteShortcutToken; + if (this.pasteShortcutDeliveredToken === token) { + this.pasteShortcutDeliveredToken = null; + this.pasteShortcutToken += 1; + return; + } + // The native paste won the race with actual text; invalidate a pending + // clipboard read. An empty native paste leaves the read as the only path. this.pasteShortcutToken += 1; this.options.onData(this.core.encodePaste(data)); }; @@ -1321,8 +1645,15 @@ export class GhosttyTerminalSurface { // A drag whose press was already sent to the terminal application cannot // turn into link activation midway through, so link feedback would lie. this.setHoveredLink(null); + this.hoverBaseCursor = "default"; this.canvas.style.cursor = "default"; - this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); + // The application may stop tracking mid-drag (it may have just exited). + // The press was its, so the rest of the gesture stays captured and + // silent until pointerup rather than typing reports into whatever now + // owns the shell or turning into a text selection. + if (this.core.isMouseTracking()) { + this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); + } return; } this.lastMouseMotionData = ""; @@ -1393,6 +1724,11 @@ export class GhosttyTerminalSurface { private updateHoverCursor(event: PointerEvent): void { this.hoverPointer = { x: event.clientX, y: event.clientY }; this.linkModifierActive = isTerminalLinkPointerGesture(event); + // While an application owns the mouse, a click goes to it rather than + // starting a text selection, so the I-beam would lie about the gesture. + this.hoverBaseCursor = shouldReportTerminalMouse(this.core.isMouseTracking(), event) + ? "default" + : ""; this.refreshHoveredLink(); } @@ -1403,6 +1739,17 @@ export class GhosttyTerminalSurface { this.refreshHoveredLink(); } + private readonly onWindowModifierKey = (event: KeyboardEvent) => { + this.updateLinkModifier(event); + }; + + private readonly onWindowBlur = () => { + // A modifier released in another window or app never produces a keyup + // here; do not leave a link underlined by a key that is no longer held. + this.linkModifierActive = false; + this.refreshHoveredLink(); + }; + private readonly onPointerLeave = () => { this.lastMouseMotionData = ""; this.clearHoveredLink(); @@ -1410,6 +1757,7 @@ export class GhosttyTerminalSurface { private clearHoveredLink(cursor = ""): void { this.hoverPointer = null; + this.hoverBaseCursor = cursor; this.setHoveredLink(null); this.canvas.style.cursor = cursor; } @@ -1420,6 +1768,19 @@ export class GhosttyTerminalSurface { this.setHoveredLink(link); } + /** + * Re-derive the no-link hover cursor after output may have toggled mouse + * tracking under a stationary pointer. Approximates the modifier state with + * the tracked link modifier; the next pointer event restores exactness. + */ + private refreshHoverBaseCursor(): void { + if (this.hoverPointer === null) return; + const next = this.core.isMouseTracking() && !this.linkModifierActive ? "default" : ""; + if (next === this.hoverBaseCursor) return; + this.hoverBaseCursor = next; + if (!this.hoveredLink) this.canvas.style.cursor = next; + } + private setHoveredLink(link: TerminalLinkWithRange | null): void { const previous = this.hoveredLink; const unchanged = @@ -1428,7 +1789,7 @@ export class GhosttyTerminalSurface { previous?.range.start.y === link?.range.start.y && previous?.range.end.x === link?.range.end.x && previous?.range.end.y === link?.range.end.y; - this.canvas.style.cursor = link ? "pointer" : ""; + this.canvas.style.cursor = link ? "pointer" : this.hoverBaseCursor; if (unchanged) return; this.hoveredLink = link; this.forceFullRender = true; @@ -1453,7 +1814,11 @@ export class GhosttyTerminalSurface { if (this.mouseReportingPointerId === event.pointerId) { event.preventDefault(); event.stopPropagation(); - this.sendMouse("release", this.mouseReportingButton, event); + // Skip the release report when the application already stopped tracking + // (a quit-button click ends tracking before the button comes back up). + if (this.core.isMouseTracking()) { + this.sendMouse("release", this.mouseReportingButton, event); + } this.mouseReportingPointerId = null; this.mouseReportingButton = null; if (this.canvas.hasPointerCapture(event.pointerId)) { @@ -1464,6 +1829,11 @@ export class GhosttyTerminalSurface { } else { this.hoverPointer = { x: event.clientX, y: event.clientY }; this.linkModifierActive = isTerminalLinkPointerGesture(event); + // The click may have ended tracking (a quit button): the base cursor + // set at press time must not keep showing an arrow over the shell. + this.hoverBaseCursor = shouldReportTerminalMouse(this.core.isMouseTracking(), event) + ? "default" + : ""; this.refreshHoveredLink(); } return; @@ -1569,12 +1939,27 @@ export class GhosttyTerminalSurface { case "PageDown": delta = Math.max(1, state.len); break; - case "Home": - delta = -state.offset; - break; - case "End": - delta = state.total - state.len - state.offset; - break; + case "Home": { + event.preventDefault(); + event.stopPropagation(); + this.core.scrollToTop(); + this.scrollbarState = { ...state, offset: 0 }; + this.options.onScrollbackTop?.(); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + return; + } + case "End": { + event.preventDefault(); + event.stopPropagation(); + this.core.scrollToBottom(); + this.scrollbarState = { ...state, offset: Math.max(0, state.total - state.len) }; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + return; + } default: return; } @@ -1584,6 +1969,12 @@ export class GhosttyTerminalSurface { }; private installEvents(): void { + // Link hovering is a pointer gesture: the Ctrl/Cmd modifier must light + // links up even while keyboard focus lives in the composer or elsewhere, + // so the modifier is tracked at the window rather than the hidden input. + window.addEventListener("keydown", this.onWindowModifierKey); + window.addEventListener("keyup", this.onWindowModifierKey); + window.addEventListener("blur", this.onWindowBlur); this.input.addEventListener("keydown", this.onKeyDown); this.input.addEventListener("keyup", this.onKeyUp); this.input.addEventListener("focus", this.onFocus); @@ -1609,6 +2000,9 @@ export class GhosttyTerminalSurface { } private removeEvents(): void { + window.removeEventListener("keydown", this.onWindowModifierKey); + window.removeEventListener("keyup", this.onWindowModifierKey); + window.removeEventListener("blur", this.onWindowBlur); this.input.removeEventListener("keydown", this.onKeyDown); this.input.removeEventListener("keyup", this.onKeyUp); this.input.removeEventListener("focus", this.onFocus); @@ -1641,6 +2035,7 @@ export class GhosttyTerminalSurface { const offset = Math.max(0, Math.min(state.offset + delta, maxOffset)); delta = offset - state.offset; this.scrollbarState = { ...state, offset }; + if (deltaRows < 0 && offset === 0) this.options.onScrollbackTop?.(); } if (delta === 0) return; this.core.scroll(delta); @@ -1689,13 +2084,36 @@ export class GhosttyTerminalSurface { } private requestRender(): void { - if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return; + if (this.disposed || !this.visible || !this.hasSize) return; + if (this.core.isSynchronizedOutput()) { + // A render requested before the opening marker may still be queued. Once + // Ghostty has accepted partial frame data, that paint is no longer safe. + if (this.frame !== 0) { + window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + if (this.synchronizedRenderTimer === null) { + this.synchronizedRenderTimer = window.setTimeout(() => { + this.synchronizedRenderTimer = null; + this.renderFrame(); + }, SYNCHRONIZED_OUTPUT_RENDER_TIMEOUT_MS); + } + return; + } + this.cancelSynchronizedRenderTimer(); + if (this.frame !== 0) return; this.frame = window.requestAnimationFrame(() => { this.frame = 0; this.renderFrame(); }); } + private cancelSynchronizedRenderTimer(): void { + if (this.synchronizedRenderTimer === null) return; + window.clearTimeout(this.synchronizedRenderTimer); + this.synchronizedRenderTimer = null; + } + private cancelRender(): void { if (this.frame !== 0) { window.cancelAnimationFrame(this.frame); @@ -1705,6 +2123,7 @@ export class GhosttyTerminalSurface { window.clearTimeout(this.cursorTimer); this.cursorTimer = null; } + this.cancelSynchronizedRenderTimer(); } private renderFrame(): void { @@ -1722,6 +2141,15 @@ export class GhosttyTerminalSurface { this.cancelRender(); return; } + const canvasConfiguration = this.pendingCanvasConfiguration; + if (canvasConfiguration !== null) { + this.pendingCanvasConfiguration = null; + this.canvas.width = canvasConfiguration.width; + this.canvas.height = canvasConfiguration.height; + this.context.setTransform(canvasConfiguration.ratio, 0, 0, canvasConfiguration.ratio, 0, 0); + this.canvasConfigured = true; + this.canvasDevicePixelRatio = canvasConfiguration.ratio; + } this.snapshot = this.core.snapshot(); // A cursor that is not blinking right now must be drawn, never caught in an // off phase left behind by a blink that has since been turned off. @@ -1757,6 +2185,9 @@ export class GhosttyTerminalSurface { previousCursorY: this.renderedCursorY, focused: this.focused, hoveredLinkRange: this.hoveredLink?.range ?? null, + ...(this.alternateScreenActive + ? { defaultThemeOverride: { source: this.appTheme, target: this.theme } } + : {}), ...(this.theme.selectionBackground !== undefined ? { selectionBackground: this.theme.selectionBackground } : {}), diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 3bc2bca78f0f..406808674df6 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -14,6 +14,7 @@ import { applyTerminalAttachStreamEvent, applyTerminalMetadataStreamEvent, nextTerminalAttachSeedState, + terminalOutputRetentionBytes, } from "./terminalSession.ts"; export function createTerminalEnvironmentAtoms( @@ -39,10 +40,22 @@ export function createTerminalEnvironmentAtoms( return { attach: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:terminal:attach", + // PTYs live on the server. Keeping an idle attach stream alive only + // burns output parsing and network work for a renderer that is gone. + idleTtlMs: 0, subscribe: (input: EnvironmentRpcInput) => + // Suspend so every run of the stream (the registry re-installs it on + // connection hand-off) scans from a fresh seed epoch instead of the + // shared empty state, which stale renderer cursors could alias. Stream.suspend(() => subscribe(WS_METHODS.terminalAttach, input).pipe( - Stream.scan(nextTerminalAttachSeedState(), applyTerminalAttachStreamEvent), + Stream.scan(nextTerminalAttachSeedState(), (state, event) => + applyTerminalAttachStreamEvent( + state, + event, + terminalOutputRetentionBytes(input.replayBytes), + ), + ), ), ), }), diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts index fcb0389c434b..bcee3bf3784d 100644 --- a/packages/client-runtime/src/state/terminalOutput.ts +++ b/packages/client-runtime/src/state/terminalOutput.ts @@ -1,6 +1,9 @@ +import { splitStringByUtf8Bytes } from "@t3tools/shared/utf8"; + export interface TerminalOutputChunk { /** UTF-16 string offset within this generation and reset. */ readonly startOffset: number; + readonly delivery: "replay" | "live"; readonly data: string; readonly byteLength: number; } @@ -38,6 +41,10 @@ export type TerminalOutputUpdate = } | { readonly type: "append"; + readonly segments: ReadonlyArray<{ + readonly data: string; + readonly delivery: "replay" | "live"; + }>; readonly cursor: TerminalOutputCursor; readonly data: string; }; @@ -57,50 +64,6 @@ export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze({ nextOffset: 0, }); -interface Utf8Chunk { - readonly data: string; - readonly byteLength: number; -} - -/** - * Split a string into chunks of at most `maxBytes` UTF-8 bytes without cutting - * a code point in half. The retained-output budget always supplies a positive - * size. Only new output is encoded on live updates. - * - * A chunk that fits whole is returned as the original string, so the common - * small-write path pays one encode and no decode. - */ -function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray { - if (data.length === 0) return []; - - const encoded = textEncoder.encode(data); - if (encoded.byteLength <= maxBytes) { - return [{ data, byteLength: encoded.byteLength }]; - } - - const chunks: Utf8Chunk[] = []; - let offset = 0; - while (offset < encoded.byteLength) { - let end = Math.min(offset + maxBytes, encoded.byteLength); - while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { - end -= 1; - } - // A degenerate budget smaller than one code point still has to advance: - // include the whole code point rather than looping forever. - if (end === offset) { - end = Math.min(offset + maxBytes, encoded.byteLength); - while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { - end += 1; - } - } - const bytes = encoded.subarray(offset, end); - chunks.push({ data: textDecoder.decode(bytes), byteLength: bytes.byteLength }); - offset = end; - } - - return chunks; -} - function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { if (maxBufferBytes <= 0) { return ""; @@ -126,6 +89,7 @@ function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { function splitOutputChunks( data: string, firstOffset: number, + delivery: "replay" | "live", maxChunkBytes = DEFAULT_TERMINAL_CHUNK_BYTES, ): { readonly chunks: ReadonlyArray; @@ -141,6 +105,7 @@ function splitOutputChunks( nextOffset += chunk.data.length; return { startOffset, + delivery, data: chunk.data, byteLength: chunk.byteLength, }; @@ -163,11 +128,13 @@ function compactRetainedChunks(chunks: ReadonlyArray) { const previous = compacted.at(-1); if ( previous !== undefined && + previous.delivery === chunk.delivery && previous.startOffset + previous.data.length === chunk.startOffset && previous.byteLength + chunk.byteLength <= DEFAULT_TERMINAL_CHUNK_BYTES ) { compacted[compacted.length - 1] = { startOffset: previous.startOffset, + delivery: previous.delivery, data: `${previous.data}${chunk.data}`, byteLength: previous.byteLength + chunk.byteLength, }; @@ -202,6 +169,7 @@ function appendOutput( current: TerminalOutputState, data: string, maxBufferBytes: number, + delivery: "replay" | "live" = "live", ): TerminalOutputState { if (data.length === 0) return current; if (maxBufferBytes <= 0) { @@ -216,6 +184,7 @@ function appendOutput( const appended = splitOutputChunks( data, current.nextOffset, + delivery, Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), ); @@ -269,6 +238,7 @@ function resetOutput( const reset = splitOutputChunks( retained, 0, + "replay", Math.min(DEFAULT_TERMINAL_CHUNK_BYTES, Math.max(1, maxBufferBytes)), ); return { @@ -308,11 +278,17 @@ export function readTerminalOutputUpdate( if (appended.length === 0) { return { type: "none", cursor: nextCursor }; } + const segments: Array<{ data: string; delivery: "replay" | "live" }> = []; + for (const chunk of appended) { + const data = chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset)); + const previous = segments.at(-1); + if (previous?.delivery === chunk.delivery) previous.data += data; + else segments.push({ data, delivery: chunk.delivery }); + } return { type: "append", - data: appended - .map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset))) - .join(""), + segments, + data: segments.map((segment) => segment.data).join(""), cursor: nextCursor, }; } diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index 3c9486b288a3..a314a9a4abfe 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -13,6 +13,7 @@ import { readTerminalOutputUpdate, selectRunningSubprocessTerminalIds, terminalOutputText, + terminalOutputRetentionBytes, } from "./terminalSession.ts"; const TARGET = { @@ -491,4 +492,195 @@ describe("terminal session reducers", () => { data: writes.slice(100).join(""), }); }); + it("retains adjacent maximum-size events after the smaller initial replay", () => { + const retentionBytes = terminalOutputRetentionBytes(64 * 1024); + expect(retentionBytes).toBe(512 * 1024); + expect(terminalOutputRetentionBytes(4 * 1024 * 1024)).toBe(4 * 1024 * 1024); + + const snapshot = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "" } }, + retentionBytes, + ); + const cursor = { + generation: snapshot.output.generation, + resetVersion: snapshot.output.resetVersion, + offset: snapshot.output.nextOffset, + }; + const first = applyTerminalAttachStreamEvent( + snapshot, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "a".repeat(64 * 1024), + }, + retentionBytes, + ); + const second = applyTerminalAttachStreamEvent( + first, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "b".repeat(64 * 1024), + }, + retentionBytes, + ); + + const update = readTerminalOutputUpdate(second.output, cursor); + if (update.type !== "append") throw new Error(`Expected append, received ${update.type}`); + expect(update.segments.map((segment) => segment.data).join("")).toHaveLength(128 * 1024); + }); + + it("advances repeated snapshots so renderers apply overflow resyncs", () => { + const first = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const second = applyTerminalAttachStreamEvent(first, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "resynced" }, + }); + const cleared = applyTerminalAttachStreamEvent(second, { + type: "cleared", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + sequence: 1, + }); + + expect(second.version).toBe(2); + expect(second.replayStartVersion).toBe(0); + expect(cleared.replayStartVersion).toBe(0); + expect(terminalOutputText(second.output)).toBe("resynced"); + }); + + it("tracks replay boundaries independently from snapshots and output", () => { + const started = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "replay-start", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + sequence: 1, + }); + const snapshot = applyTerminalAttachStreamEvent(started, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const completed = applyTerminalAttachStreamEvent(snapshot, { + type: "replay-complete", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + sequence: 1, + }); + const reconnected = applyTerminalAttachStreamEvent(completed, { + type: "replay-start", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + sequence: 2, + }); + + expect(completed).toMatchObject({ replayStartVersion: 1, replayCompleteVersion: 1 }); + expect(reconnected).toMatchObject({ replayStartVersion: 2, replayCompleteVersion: 1 }); + }); + + it("does not make replay-start override metadata before its snapshot arrives", () => { + const summary = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: "running", + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + })[0]!; + const started = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "replay-start", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + sequence: 1, + }); + + expect(started.version).toBe(0); + expect(started.replayStartVersion).toBe(1); + expect(combineTerminalSessionState(summary, started).status).toBe("running"); + }); + + it("keeps replay and live appends distinct when both reduce before a renderer reads", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const cursor = { + generation: snapshot.output.generation, + resetVersion: snapshot.output.resetVersion, + offset: snapshot.output.nextOffset, + }; + const replayStarted = applyTerminalAttachStreamEvent(snapshot, { + type: "replay-start", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + const replayOutput = applyTerminalAttachStreamEvent(replayStarted, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: " replay", + }); + const replayCompleted = applyTerminalAttachStreamEvent(replayOutput, { + type: "replay-complete", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + const liveOutput = applyTerminalAttachStreamEvent(replayCompleted, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: " live", + }); + + expect(readTerminalOutputUpdate(liveOutput.output, cursor)).toMatchObject({ + type: "append", + segments: [ + { data: " replay", delivery: "replay" }, + { data: " live", delivery: "live" }, + ], + }); + }); + + it("closes every open replay when a completion marker arrives after a lost one", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "replay-start", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + // The transport re-ran the attach without the first replay completing. + state = applyTerminalAttachStreamEvent(state, { + type: "replay-start", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + state = applyTerminalAttachStreamEvent(state, { + type: "replay-complete", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + expect(state.replayCompleteVersion).toBe(state.replayStartVersion); + + const liveOutput = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "after", + }); + expect(liveOutput.output.chunks.at(-1)?.delivery).toBe("live"); + }); }); diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index b1ef6500d414..89a26b788798 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -1,10 +1,10 @@ -import type { - EnvironmentId, - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, - TerminalSummary, - ThreadId, +import { + type EnvironmentId, + type TerminalAttachStreamEvent, + type TerminalMetadataStreamEvent, + type TerminalSessionSnapshot, + type TerminalSummary, + type ThreadId, } from "@t3tools/contracts"; import { appendOutput, @@ -31,6 +31,8 @@ export interface TerminalSessionState { readonly error: string | null; readonly hasRunningSubprocess: boolean; readonly updatedAt: string | null; + readonly replayStartVersion: number; + readonly replayCompleteVersion: number; readonly version: number; readonly lifecycleVersion: number; } @@ -40,6 +42,8 @@ export interface TerminalBufferState { readonly status: TerminalSessionSnapshot["status"] | "closed"; readonly error: string | null; readonly updatedAt: string | null; + readonly replayStartVersion: number; + readonly replayCompleteVersion: number; readonly version: number; readonly lifecycleVersion: number; } @@ -68,17 +72,8 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ status: "closed", error: null, updatedAt: null, - version: 0, - lifecycleVersion: 0, -}); - -export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ - summary: null, - output: EMPTY_TERMINAL_OUTPUT_STATE, - status: "closed", - error: null, - hasRunningSubprocess: false, - updatedAt: null, + replayStartVersion: 0, + replayCompleteVersion: 0, version: 0, lifecycleVersion: 0, }); @@ -96,7 +91,25 @@ export function nextTerminalAttachSeedState(): TerminalBufferState { }; } -function terminalBufferStateFromSnapshot( +export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ + summary: null, + output: EMPTY_TERMINAL_BUFFER_STATE.output, + status: "closed", + error: null, + hasRunningSubprocess: false, + updatedAt: null, + replayStartVersion: 0, + replayCompleteVersion: 0, + version: 0, + lifecycleVersion: 0, +}); + +/** Keep attach replay size and live client retention as separate budgets. */ +export function terminalOutputRetentionBytes(replayBytes?: number): number { + return Math.max(DEFAULT_MAX_TERMINAL_BUFFER_BYTES, replayBytes ?? 0); +} + +export function terminalBufferStateFromSnapshot( snapshot: TerminalSessionSnapshot, maxBufferBytes: number, current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, @@ -106,6 +119,8 @@ function terminalBufferStateFromSnapshot( status: snapshot.status, error: null, updatedAt: snapshot.updatedAt, + replayStartVersion: current.replayStartVersion, + replayCompleteVersion: current.replayCompleteVersion, version: current.version + 1, lifecycleVersion: current.lifecycleVersion, }; @@ -128,6 +143,8 @@ export function combineTerminalSessionState( error: buffer.error, hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), + replayStartVersion: buffer.replayStartVersion, + replayCompleteVersion: buffer.replayCompleteVersion, version: buffer.version, lifecycleVersion: buffer.lifecycleVersion, }; @@ -139,6 +156,11 @@ export function applyTerminalAttachStreamEvent( maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES, ): TerminalBufferState { switch (event.type) { + case "replay-start": + return { + ...current, + replayStartVersion: current.replayStartVersion + 1, + }; case "snapshot": return { ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), @@ -153,11 +175,25 @@ export function applyTerminalAttachStreamEvent( case "output": return { ...current, - output: appendOutput(current.output, event.data, maxBufferBytes), + output: appendOutput( + current.output, + event.data, + maxBufferBytes, + current.replayStartVersion > current.replayCompleteVersion ? "replay" : "live", + ), status: current.status === "closed" ? "running" : current.status, error: null, version: current.version + 1, }; + case "replay-complete": + // Latch to the start counter instead of incrementing. Replay markers can + // be lost (slow-consumer resync) or repeated (transport hand-off re-runs + // the attach), and one completion always closes every open replay. + return { + ...current, + replayCompleteVersion: current.replayStartVersion, + version: current.version + 1, + }; case "cleared": return { ...current, diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 066253602a49..c80309b996e4 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_TERMINAL_ID, + DEFAULT_TERMINAL_REPLAY_BYTES, + EXTENDED_TERMINAL_REPLAY_BYTES, + MAX_TERMINAL_REPLAY_BYTES, TerminalAttachInput, + TerminalAttachStreamEvent, TerminalClearInput, TerminalCloseInput, TerminalEvent, @@ -164,6 +168,51 @@ describe("TerminalAttachInput", () => { expect(parsed.restartIfNotRunning).toBe(true); }); + + it("bounds requested replay history", () => { + const parsed = decodeSync(TerminalAttachInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES, + }); + + expect(parsed.replayBytes).toBe(EXTENDED_TERMINAL_REPLAY_BYTES); + expect( + decodes(TerminalAttachInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + replayBytes: DEFAULT_TERMINAL_REPLAY_BYTES - 1, + }), + ).toBe(false); + expect( + decodes(TerminalAttachInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + replayBytes: MAX_TERMINAL_REPLAY_BYTES + 1, + }), + ).toBe(false); + }); +}); + +describe("TerminalAttachStreamEvent", () => { + it("accepts ordered replay boundary markers", () => { + expect( + decodes(TerminalAttachStreamEvent, { + type: "replay-start", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + sequence: 42, + }), + ).toBe(true); + expect( + decodes(TerminalAttachStreamEvent, { + type: "replay-complete", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + sequence: 42, + }), + ).toBe(true); + }); }); describe("TerminalWriteInput", () => { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 595ad5438873..43d8d6fca799 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -9,6 +9,15 @@ import { ProviderInstanceId } from "./providerInstance.ts"; */ export const DEFAULT_TERMINAL_ID = "term-1"; +/** Maximum terminal output replayed when a client attaches or resynchronizes. */ +export const DEFAULT_TERMINAL_REPLAY_BYTES = 64 * 1024; + +/** Deeper attach replay requested by clients that can consume output incrementally. */ +export const EXTENDED_TERMINAL_REPLAY_BYTES = 4 * 1024 * 1024; + +/** Upper bound accepted from a client for one terminal attach replay. */ +export const MAX_TERMINAL_REPLAY_BYTES = 8 * 1024 * 1024; + const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check( Schema.isLessThanOrEqualTo(1000), @@ -57,6 +66,11 @@ export const TerminalAttachInput = Schema.Struct({ env: Schema.optional(TerminalEnvSchema), providerInstanceId: Schema.optional(ProviderInstanceId), restartIfNotRunning: Schema.optional(Schema.Boolean), + replayBytes: Schema.optional( + Schema.Int.check(Schema.isGreaterThanOrEqualTo(DEFAULT_TERMINAL_REPLAY_BYTES)).check( + Schema.isLessThanOrEqualTo(MAX_TERMINAL_REPLAY_BYTES), + ), + ), }); export type TerminalAttachInput = typeof TerminalAttachInput.Type; @@ -224,8 +238,20 @@ const TerminalAttachSnapshotEvent = Schema.Struct({ snapshot: TerminalSessionSnapshot, }); +const TerminalReplayStartEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("replay-start"), +}); + +const TerminalReplayCompleteEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("replay-complete"), +}); + export const TerminalAttachStreamEvent = Schema.Union([ + TerminalReplayStartEvent, TerminalAttachSnapshotEvent, + TerminalReplayCompleteEvent, TerminalOutputEvent, TerminalExitedEvent, TerminalClosedEvent, diff --git a/packages/shared/package.json b/packages/shared/package.json index 5573e0ab6b69..26494831d1b3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -99,6 +99,10 @@ "types": "./src/String.ts", "import": "./src/String.ts" }, + "./utf8": { + "types": "./src/utf8.ts", + "import": "./src/utf8.ts" + }, "./projectScripts": { "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" diff --git a/packages/shared/src/utf8.ts b/packages/shared/src/utf8.ts new file mode 100644 index 000000000000..a32b984605f4 --- /dev/null +++ b/packages/shared/src/utf8.ts @@ -0,0 +1,47 @@ +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { ignoreBOM: true }); + +export interface Utf8Chunk { + readonly data: string; + readonly byteLength: number; +} + +/** + * Split a string into chunks of at most `maxBytes` UTF-8 bytes without cutting + * a code point in half. Used by the terminal pipeline on both the server + * (bounded output batches, streamed history) and clients (retained output + * chunks) so both sides split identical streams at identical byte boundaries. + * + * A chunk that fits whole is returned as the original string, so the common + * small-write path pays one encode and no decode. + */ +export function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray { + if (data.length === 0) return []; + + const encoded = textEncoder.encode(data); + if (encoded.byteLength <= maxBytes) { + return [{ data, byteLength: encoded.byteLength }]; + } + + const chunks: Utf8Chunk[] = []; + let offset = 0; + while (offset < encoded.byteLength) { + let end = Math.min(offset + maxBytes, encoded.byteLength); + while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { + end -= 1; + } + // A degenerate budget smaller than one code point still has to advance: + // include the whole code point rather than looping forever. + if (end === offset) { + end = Math.min(offset + maxBytes, encoded.byteLength); + while (end < encoded.byteLength && ((encoded[end] ?? 0) & 0xc0) === 0x80) { + end += 1; + } + } + const bytes = encoded.subarray(offset, end); + chunks.push({ data: textDecoder.decode(bytes), byteLength: bytes.byteLength }); + offset = end; + } + + return chunks; +} From 5865aac9d8c0120693ece0322b365fcf343b7899 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:02:38 +0200 Subject: [PATCH 2/9] fix(client-runtime): keep terminal snapshot reducer private --- packages/client-runtime/src/state/terminalSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index 89a26b788798..8aec8fafedc3 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -109,7 +109,7 @@ export function terminalOutputRetentionBytes(replayBytes?: number): number { return Math.max(DEFAULT_MAX_TERMINAL_BUFFER_BYTES, replayBytes ?? 0); } -export function terminalBufferStateFromSnapshot( +function terminalBufferStateFromSnapshot( snapshot: TerminalSessionSnapshot, maxBufferBytes: number, current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, From e524dfb308cc90b242f5ca25560310947246a84e Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:35:21 +0200 Subject: [PATCH 3/9] fix(terminal): preserve lifecycle events during attach overflow --- apps/server/src/terminal/Manager.test.ts | 37 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 23 ++++++++++----- packages/shared/src/utf8.test.ts | 20 +++++++++++++ packages/shared/src/utf8.ts | 3 ++ 4 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/utf8.test.ts diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 3bf1dbc4f0a1..9de7a5158c40 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -3186,6 +3186,43 @@ it.layer( }), ); + it.effect("delivers terminal close after repeated attach-buffer overflows", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager({ outputBatchWindowMs: 0 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + let burstDrained = yield* Deferred.make(); + const stopObserving = yield* manager.subscribe((event) => + event.type === "output" && event.data.endsWith("burst-end") + ? Deferred.succeed(burstDrained, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(stopObserving)); + const received: TerminalAttachStreamEvent[] = []; + let snapshotCount = 0; + const stop = yield* manager.attachStream(openInput(), (event) => + Effect.gen(function* () { + received.push(event); + if (event.type !== "snapshot") return; + snapshotCount += 1; + burstDrained = yield* Deferred.make(); + for (let index = 0; index < 65; index += 1) process.emitData("x".repeat(64 * 1024)); + process.emitData("burst-end"); + yield* Deferred.await(burstDrained); + if (snapshotCount === 4) { + yield* manager + .close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }) + .pipe(Effect.orDie); + } + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + expect(snapshotCount).toBe(4); + expect(received.at(-1)?.type).toBe("closed"); + expect(Option.isNone(yield* manager.readSnapshot(openInput()))).toBe(true); + }), + ); + it.effect("cancels extended history replay when its attach scope closes", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 3898965a6032..548d604694fe 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -3307,6 +3307,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func let bufferedEventBytes = 0; let bufferedOverflow = false; let deliverLive = false; + // Snapshots cannot recover a removed session or transient errors. Keep + // the latest of each in order without letting lifecycle events grow unbounded. + const discardBufferedSnapshotEvents = () => { + const closed = bufferedEvents.findLast(({ event }) => event.type === "closed"); + const error = bufferedEvents.findLast(({ event }) => event.type === "error"); + const retained = bufferedEvents.filter((entry) => entry === closed || entry === error); + bufferedEvents.splice(0, bufferedEvents.length, ...retained); + bufferedEventBytes = 0; + }; // Old clients decode the attach stream against a union without the // replay markers. Sending replayBytes proves the client understands them. const emitReplayMarkers = input.replayBytes !== undefined; @@ -3322,8 +3331,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func bufferedEvents.length >= DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT || bufferedEventBytes + eventBytes > DEFAULT_ATTACH_BUFFERED_MAX_BYTES ) { - bufferedEvents.splice(0); - bufferedEventBytes = 0; + discardBufferedSnapshotEvents(); bufferedOverflow = true; } bufferedEvents.push({ event, bytes: eventBytes }); @@ -3401,12 +3409,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func overflowResyncCount += 1; if (overflowResyncCount > 3) { // A consumer this far behind keeps overflowing while the resync - // itself is being delivered. Go live anyway; the transport's own - // overflow path resynchronizes it from the latest snapshot. - bufferedEvents.splice(0); - bufferedEventBytes = 0; - deliverLive = true; - break; + // itself is being delivered. Drain unrecoverable lifecycle events + // before going live; the transport resynchronizes snapshot state. + discardBufferedSnapshotEvents(); + overflowResyncCount = 0; + continue; } const latest = yield* readSnapshot(input); if (Option.isSome(latest)) { diff --git a/packages/shared/src/utf8.test.ts b/packages/shared/src/utf8.test.ts new file mode 100644 index 000000000000..d6d847d1d8db --- /dev/null +++ b/packages/shared/src/utf8.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { splitStringByUtf8Bytes } from "./utf8.ts"; + +describe("splitStringByUtf8Bytes", () => { + it.each([0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + "rejects invalid chunk budget %s", + (maxBytes) => { + expect(() => splitStringByUtf8Bytes("output", maxBytes)).toThrow(RangeError); + }, + ); + + it("preserves whole code points even when a positive budget is smaller than one", () => { + expect(splitStringByUtf8Bytes("a🙂名", 1)).toEqual([ + { data: "a", byteLength: 1 }, + { data: "🙂", byteLength: 4 }, + { data: "名", byteLength: 3 }, + ]); + }); +}); diff --git a/packages/shared/src/utf8.ts b/packages/shared/src/utf8.ts index a32b984605f4..5345694bd170 100644 --- a/packages/shared/src/utf8.ts +++ b/packages/shared/src/utf8.ts @@ -16,6 +16,9 @@ export interface Utf8Chunk { * small-write path pays one encode and no decode. */ export function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new RangeError("maxBytes must be a positive safe integer"); + } if (data.length === 0) return []; const encoded = textEncoder.encode(data); From e33cbd17f36c89a9eddfc5362eef202738400757 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:23:10 +0200 Subject: [PATCH 4/9] fix(terminal): preserve live queries and queued output during replay --- .../terminal/NativeTerminalSurface.tsx | 47 +++-- .../terminal/terminalBufferReplay.test.ts | 30 ++- .../features/terminal/terminalBufferReplay.ts | 21 ++ apps/server/src/terminal/AttachStream.test.ts | 193 ++++++++++++++++++ apps/server/src/terminal/AttachStream.ts | 27 +++ apps/server/src/terminal/Manager.ts | 2 +- apps/server/src/ws.ts | 53 +---- .../src/components/ThreadTerminalDrawer.tsx | 98 ++++----- apps/web/src/index.css | 14 -- apps/web/src/terminal/ghostty/core.test.ts | 26 ++- apps/web/src/terminal/ghostty/core.ts | 7 +- .../web/src/terminal/ghostty/renderer.test.ts | 22 +- apps/web/src/terminal/ghostty/renderer.ts | 53 +---- apps/web/src/terminal/ghostty/surface.test.ts | 131 ++++++++---- apps/web/src/terminal/ghostty/surface.ts | 53 +---- .../src/state/terminalOutput.ts | 42 ++-- .../src/state/terminalSession.test.ts | 23 +++ 17 files changed, 517 insertions(+), 325 deletions(-) create mode 100644 apps/server/src/terminal/AttachStream.test.ts create mode 100644 apps/server/src/terminal/AttachStream.ts diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 7634a882cb44..d0975b903a9c 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -32,6 +32,7 @@ import { type TerminalTheme, } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; +import { nativeTerminalOutputCommands } from "./terminalBufferReplay"; interface TerminalInputEvent { readonly data: string; @@ -214,6 +215,9 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf const nativeRef = useRef(null); const nativeCommandQueueRef = useRef(Promise.resolve()); const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); + // A replacement can cancel queued commands. Only acknowledged bytes may be + // treated as consumed when rebuilding the native surface. + const appliedOutputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const streamIdentityRef = useRef(""); const deferredEmptyResetRef = useRef(false); const surfaceIdentity = `${props.terminalKey}:${fontSize}:${themeAppearance}:${themeConfig}`; @@ -252,30 +256,26 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf if (!supportsStreaming) return; const streamIdentity = props.replayPaused ? `${resetIdentity}:paused` : resetIdentity; const forceReset = streamIdentityRef.current !== streamIdentity; - const update = - forceReset || props.replayPaused - ? { - type: "reset" as const, - data: props.replayPaused ? "" : terminalOutputText(props.output), - cursor: { - resetVersion: props.output.resetVersion, - generation: props.output.generation, - offset: props.output.nextOffset, - }, - } - : readTerminalOutputUpdate(props.output, outputCursorRef.current); + const update = props.replayPaused + ? { + type: "reset" as const, + data: "", + segments: [], + cursor: { + resetVersion: props.output.resetVersion, + generation: props.output.generation, + offset: props.output.nextOffset, + }, + } + : readTerminalOutputUpdate( + props.output, + forceReset ? appliedOutputCursorRef.current : outputCursorRef.current, + forceReset, + ); streamIdentityRef.current = streamIdentity; - outputCursorRef.current = update.cursor; + if (!props.replayPaused) outputCursorRef.current = update.cursor; if (!forceReset && props.replayPaused) return; - let commands: Array<{ type: "reset" | "write" | "writeReplay"; data: string }> = - update.type === "reset" - ? [{ type: "reset", data: update.data }] - : update.type === "append" - ? update.segments.map((segment) => ({ - type: segment.delivery === "replay" ? ("writeReplay" as const) : ("write" as const), - data: segment.data, - })) - : []; + let commands = nativeTerminalOutputCommands(update); if ( !forceReset && props.replayPending === true && @@ -332,6 +332,9 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf } } } + if (!props.replayPaused && streamIdentityRef.current === streamIdentity) { + appliedOutputCursorRef.current = update.cursor; + } }) .catch((error: unknown) => { console.error("Failed to update native terminal output", error); diff --git a/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts b/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts index b9c09ba700cf..63863c6e8347 100644 --- a/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts +++ b/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts @@ -1,8 +1,36 @@ import { describe, expect, it } from "vite-plus/test"; -import { getTerminalBufferReplayKey, isTerminalBufferReplayPaused } from "./terminalBufferReplay"; +import { + getTerminalBufferReplayKey, + isTerminalBufferReplayPaused, + nativeTerminalOutputCommands, +} from "./terminalBufferReplay"; +import { + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, +} from "@t3tools/client-runtime/state/terminal"; describe("terminalBufferReplay", () => { + it("keeps unread live queries out of the native reset command", () => { + const output = { + generation: 1, + resetVersion: 1, + nextOffset: 13, + retainedBytes: 13, + chunks: [ + { startOffset: 0, data: "old\x1b[6n", delivery: "replay" as const, byteLength: 7 }, + { startOffset: 7, data: "hi\x1b[6n", delivery: "live" as const, byteLength: 6 }, + ], + }; + const update = readTerminalOutputUpdate(output, INITIAL_TERMINAL_OUTPUT_CURSOR); + expect(nativeTerminalOutputCommands(update)).toEqual([ + { type: "reset", data: "old\x1b[6n" }, + { type: "write", data: "hi\x1b[6n" }, + ]); + expect( + nativeTerminalOutputCommands(readTerminalOutputUpdate(output, update.cursor, true)), + ).toEqual([{ type: "reset", data: "old\x1b[6nhi\x1b[6n" }]); + }); it("keys replay readiness by terminal identity and font metrics", () => { expect( getTerminalBufferReplayKey({ diff --git a/apps/mobile/src/features/terminal/terminalBufferReplay.ts b/apps/mobile/src/features/terminal/terminalBufferReplay.ts index 043ee991626c..0af347693683 100644 --- a/apps/mobile/src/features/terminal/terminalBufferReplay.ts +++ b/apps/mobile/src/features/terminal/terminalBufferReplay.ts @@ -1,3 +1,5 @@ +import type { TerminalOutputUpdate } from "@t3tools/client-runtime/state/terminal"; + export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180; export function getTerminalBufferReplayKey(input: { @@ -13,3 +15,22 @@ export function isTerminalBufferReplayPaused(input: { }): boolean { return input.readyReplayKey !== null && input.readyReplayKey !== input.replayKey; } + +/** Native resets suppress replies; keep unread live bytes in separate write commands. */ +export function nativeTerminalOutputCommands(update: TerminalOutputUpdate) { + const commands: Array<{ type: "reset" | "write" | "writeReplay"; data: string }> = []; + if (update.type === "none") return commands; + if (update.type === "reset") commands.push({ type: "reset", data: "" }); + for (const segment of update.segments) { + const previous = commands.at(-1); + if (segment.delivery === "replay" && previous?.type === "reset") { + previous.data += segment.data; + } else { + commands.push({ + type: segment.delivery === "replay" ? "writeReplay" : "write", + data: segment.data, + }); + } + } + return commands; +} diff --git a/apps/server/src/terminal/AttachStream.test.ts b/apps/server/src/terminal/AttachStream.test.ts new file mode 100644 index 000000000000..a49d6d413945 --- /dev/null +++ b/apps/server/src/terminal/AttachStream.test.ts @@ -0,0 +1,193 @@ +import { it } from "@effect/vitest"; +import { expect } from "vite-plus/test"; +import { + ThreadId, + EXTENDED_TERMINAL_REPLAY_BYTES, + type TerminalAttachStreamEvent, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; + +import type { TerminalManager } from "./Manager.ts"; +import { terminalAttachStream } from "./AttachStream.ts"; + +const input = { + threadId: ThreadId.make("thread-1"), + terminalId: "default", + cwd: "/tmp", + cols: 80, + rows: 24, + replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES, +}; +const target = { threadId: input.threadId, terminalId: input.terminalId }; + +it.effect("delivers every queued output before close when the attach consumer stalls", () => + Effect.gen(function* () { + const subscribed = + yield* Deferred.make[1]>(); + const consumerStarted = yield* Deferred.make(); + const resumeConsumer = yield* Deferred.make(); + let unsubscribed = false; + const received: TerminalAttachStreamEvent[] = []; + const stream = terminalAttachStream( + { + attachStream: (_input, listener) => + Deferred.succeed(subscribed, listener).pipe( + Effect.as(() => { + unsubscribed = true; + }), + ), + }, + input, + ); + const consumer = yield* stream.pipe( + Stream.takeUntil((event) => event.type === "closed"), + Stream.runForEach((event) => + Effect.gen(function* () { + received.push(event); + if (received.length === 1) { + yield* Deferred.succeed(consumerStarted, undefined); + yield* Deferred.await(resumeConsumer); + } + }), + ), + Effect.forkChild, + ); + const publish = yield* Deferred.await(subscribed); + yield* publish({ type: "replay-complete", ...target }, "replay"); + yield* Deferred.await(consumerStarted); + for (let index = 0; index < 32; index += 1) { + yield* publish({ type: "output", ...target, data: `output-${index}\n` }, "live"); + } + const producer = yield* publish({ type: "closed", ...target }, "live").pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(resumeConsumer, undefined); + yield* Fiber.join(producer); + yield* Fiber.join(consumer); + expect(received.map((event) => (event.type === "output" ? event.data : event.type))).toEqual([ + "replay-complete", + ...Array.from({ length: 32 }, (_, index) => `output-${index}\n`), + "closed", + ]); + expect(unsubscribed).toBe(true); + }), +); + +it.effect("retains extended replay and its completion boundary when the consumer stalls", () => + Effect.gen(function* () { + const subscribed = + yield* Deferred.make[1]>(); + const replayStarted = yield* Deferred.make(); + const resumeConsumer = yield* Deferred.make(); + const queueFilled = yield* Deferred.make(); + const replayCompleted = yield* Deferred.make(); + const resumeLive = yield* Deferred.make(); + let unsubscribed = false; + const received: TerminalAttachStreamEvent[] = []; + const stream = terminalAttachStream( + { + attachStream: (request, listener) => { + expect(request.replayBytes).toBe(EXTENDED_TERMINAL_REPLAY_BYTES); + return Deferred.succeed(subscribed, listener).pipe( + Effect.as(() => { + unsubscribed = true; + }), + ); + }, + }, + input, + ); + const consumer = yield* stream.pipe( + Stream.takeUntil((event) => event.type === "output" && event.data === "live-end"), + Stream.runForEach((event) => + Effect.gen(function* () { + received.push(event); + if (event.type === "replay-start") { + yield* Deferred.succeed(replayStarted, undefined); + yield* Deferred.await(resumeConsumer); + } else if (event.type === "replay-complete") { + yield* Deferred.succeed(replayCompleted, undefined); + yield* Deferred.await(resumeLive); + } + }), + ), + Effect.forkChild, + ); + const publish = yield* Deferred.await(subscribed); + yield* publish({ type: "replay-start", ...target }, "replay"); + yield* Deferred.await(replayStarted); + const producer = yield* Effect.gen(function* () { + for (let index = 0; index < EXTENDED_TERMINAL_REPLAY_BYTES / (64 * 1024); index += 1) { + yield* publish({ type: "output", ...target, data: "x".repeat(64 * 1024) }, "replay"); + if (index === 31) yield* Deferred.succeed(queueFilled, undefined); + } + yield* publish({ type: "replay-complete", ...target }, "replay"); + }).pipe(Effect.forkChild); + yield* Deferred.await(queueFilled); + yield* Deferred.succeed(resumeConsumer, undefined); + yield* Fiber.join(producer); + yield* Deferred.await(replayCompleted); + for (let index = 0; index < 32; index += 1) { + yield* publish({ type: "output", ...target, data: "live" }, "live"); + } + const liveProducer = yield* publish( + { type: "output", ...target, data: "live-end" }, + "live", + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(resumeLive, undefined); + yield* Fiber.join(liveProducer); + yield* Fiber.join(consumer); + expect( + received + .filter((event) => event.type === "output") + .map((event) => event.data) + .join(""), + ).toBe("x".repeat(EXTENDED_TERMINAL_REPLAY_BYTES) + "live".repeat(32) + "live-end"); + expect(received.filter((event) => event.type !== "output").map((event) => event.type)).toEqual([ + "replay-start", + "replay-complete", + ]); + expect(unsubscribed).toBe(true); + }), +); + +it.effect("unsubscribes and releases blocked output when the transport consumer disconnects", () => + Effect.gen(function* () { + const subscribed = + yield* Deferred.make[1]>(); + const consumerStarted = yield* Deferred.make(); + let unsubscribed = false; + const stream = terminalAttachStream( + { + attachStream: (_input, listener) => + Deferred.succeed(subscribed, listener).pipe( + Effect.as(() => { + unsubscribed = true; + }), + ), + }, + input, + ); + const consumer = yield* stream.pipe( + Stream.runForEach(() => + Deferred.succeed(consumerStarted, undefined).pipe(Effect.andThen(Effect.never)), + ), + Effect.forkChild, + ); + const publish = yield* Deferred.await(subscribed); + yield* publish({ type: "replay-complete", ...target }, "replay"); + yield* Deferred.await(consumerStarted); + for (let index = 0; index < 32; index += 1) { + yield* publish({ type: "output", ...target, data: "output" }, "live"); + } + const producer = yield* publish({ type: "output", ...target, data: "blocked" }, "live").pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.interrupt(consumer); + yield* Fiber.join(producer); + expect(unsubscribed).toBe(true); + }), +); diff --git a/apps/server/src/terminal/AttachStream.ts b/apps/server/src/terminal/AttachStream.ts new file mode 100644 index 000000000000..ddbf45ed0bc4 --- /dev/null +++ b/apps/server/src/terminal/AttachStream.ts @@ -0,0 +1,27 @@ +import type { + TerminalAttachInput, + TerminalAttachStreamEvent, + TerminalError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import type { TerminalManager } from "./Manager.ts"; + +/** Backpressure preserves output and replay boundaries, including the final bytes before close. */ +export function terminalAttachStream( + manager: Pick, + input: TerminalAttachInput, +) { + return Stream.callback( + (queue) => + Effect.acquireRelease( + manager.attachStream(input, (event) => + Queue.offer(queue, event).pipe(Effect.asVoid, Effect.ignore), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ), + { bufferSize: 32, strategy: "suspend" }, + ); +} diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 548d604694fe..72d6379f446a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -3410,7 +3410,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (overflowResyncCount > 3) { // A consumer this far behind keeps overflowing while the resync // itself is being delivered. Drain unrecoverable lifecycle events - // before going live; the transport resynchronizes snapshot state. + // before going live with the most recently delivered snapshot. discardBufferedSnapshotEvents(); overflowResyncCount = 0; continue; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6712b51df9f..d3262f16ec1f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -66,8 +66,6 @@ import { RpcClientId, EnvironmentAuthorizationError, ThreadId, - type TerminalAttachStreamEvent, - type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, WS_METHODS, @@ -111,6 +109,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import { terminalAttachStream } from "./terminal/AttachStream.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -160,7 +159,6 @@ const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchComma const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); -const TERMINAL_ATTACH_BUFFERED_EVENT_LIMIT = 32; const resolveDiscoveryForConfig = ( discovery: Effect.Effect, @@ -2586,54 +2584,7 @@ const makeWsRpcLayer = ( [WS_METHODS.terminalAttach]: (input) => observeRpcStream( WS_METHODS.terminalAttach, - Stream.callback( - (queue) => - Effect.acquireRelease( - terminalManager.attachStream(input, (event, delivery): Effect.Effect => - Effect.gen(function* () { - if (delivery === "replay") { - yield* Queue.offer(queue, event); - return; - } - if (Queue.offerUnsafe(queue, event)) return; - - yield* Queue.clear(queue); - // The clear may have wiped queued replay events, - // including the replay-complete marker. Re-emit it so - // the client never stays latched in replay mode. Only - // clients that sent replayBytes decode the marker. - if (input.replayBytes !== undefined) { - yield* Queue.offer(queue, { - type: "replay-complete" as const, - threadId: input.threadId, - terminalId: input.terminalId, - }); - } - if (event.type === "closed") { - yield* Queue.offer(queue, event); - return; - } - - const latest = yield* terminalManager.readSnapshot(input); - yield* Queue.offer( - queue, - Option.match(latest, { - onNone: () => event, - onSome: (snapshot) => ({ type: "snapshot" as const, snapshot }), - }), - ); - if (Option.isSome(latest) && event.type === "error") { - yield* Queue.offer(queue, event); - } - }).pipe(Effect.ignore), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ), - { - bufferSize: TERMINAL_ATTACH_BUFFERED_EVENT_LIMIT, - strategy: "suspend", - }, - ), + terminalAttachStream(terminalManager, input), { "rpc.aggregate": "terminal" }, ), [WS_METHODS.terminalWrite]: (input) => diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 2d78e12d20bc..820f6ee82f63 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -112,14 +112,29 @@ function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): } export function writeTerminalOutputUpdate( - terminal: Pick, + terminal: Pick< + GhosttyTerminalSurface, + "beginStreamingReplay" | "appendStreamingReplay" | "completeStreamingReplay" | "write" + >, update: TerminalOutputUpdate, -): void { + replayState: TerminalReplayRendererState = "idle", + onReplayComplete: () => void = () => {}, +): { replayState: TerminalReplayRendererState; didWrite: boolean } { + if (update.type === "none") return { replayState, didWrite: false }; if (update.type === "reset") { - terminal.resetAndWrite(update.data); - } else if (update.type === "append") { - terminal.write(update.data); + terminal.beginStreamingReplay(""); + } + const result = writeTerminalOutputSegments({ + terminal, + segments: update.segments, + replayState: update.type === "reset" ? "replaying" : replayState, + onReplayComplete, + }); + if (replayState === "idle" && result.replayState !== "idle") { + terminal.completeStreamingReplay(); + result.replayState = "idle"; } + return { ...result, didWrite: update.type === "reset" || result.didWrite }; } type TerminalReplayRendererState = "idle" | "waiting" | "replaying"; @@ -260,28 +275,6 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty "--terminal-selection-background", isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", ); - const colorProbe = document.createElement("span"); - colorProbe.ariaHidden = "true"; - colorProbe.style.cssText = "position:fixed;width:0;height:0;overflow:hidden;pointer-events:none"; - drawerSurface.append(colorProbe); - const readResolvedThemeColor = (variable: string, fallback: string) => { - colorProbe.style.color = `var(${variable}, ${fallback})`; - return normalizeComputedColor(getComputedStyle(colorProbe).color, fallback); - }; - const alternateBackground = readResolvedThemeColor( - "--terminal-alt-screen-background", - terminalBackground, - ); - const alternateForeground = readResolvedThemeColor( - "--terminal-alt-screen-foreground", - terminalForeground, - ); - const alternateCursor = readResolvedThemeColor("--terminal-alt-screen-cursor", terminalCursor); - const alternateSelection = readResolvedThemeColor( - "--terminal-alt-screen-selection-background", - terminalSelection, - ); - colorProbe.remove(); const backgroundColor = parseTerminalColor( terminalBackground, isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, @@ -299,12 +292,6 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty foreground: foregroundColor, cursor: cursorColor, selectionBackground: terminalSelection, - alternateScreen: { - background: parseTerminalColor(alternateBackground, backgroundColor), - foreground: parseTerminalColor(alternateForeground, foregroundColor), - cursor: parseTerminalColor(alternateCursor, cursorColor), - selectionBackground: alternateSelection, - }, }; } @@ -642,12 +629,18 @@ export function TerminalViewport({ } const latestSession = latestSessionRef.current; previousSessionRef.current = latestSession; + scrollbackReplayRendererStateRef.current = + latestSession.replayStartVersion > latestSession.replayCompleteVersion ? "waiting" : "idle"; const initialOutput = readTerminalOutputUpdate( latestSession.output, INITIAL_TERMINAL_OUTPUT_CURSOR, ); if (initialOutput.type === "reset" && initialOutput.data.length > 0) { - writeTerminalOutputUpdate(terminal, initialOutput); + scrollbackReplayRendererStateRef.current = writeTerminalOutputUpdate( + terminal, + initialOutput, + scrollbackReplayRendererStateRef.current, + ).replayState; } outputCursorRef.current = initialOutput.cursor; if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); @@ -1098,32 +1091,23 @@ export function TerminalViewport({ terminal.scrollToTopAfterWrites(); }; let didWriteOutput = false; - if (outputUpdate.type === "append") { - const result = writeTerminalOutputSegments({ + if (outputUpdate.type === "reset" && outputUpdate.data.length === 0 && current.version === 0) { + // A restarted attach stream emits its pristine seed state before the + // server replies. Keep the current screen until real content arrives; + // the cursor above already adopted the new stream's epoch. + } else if (outputUpdate.type === "reset" && streamingReplay && outputUpdate.data.length === 0) { + // The extended attach begins with an empty snapshot. Keep the current + // screen visible until its first retained-history chunk arrives. + scrollbackReplayRendererStateRef.current = "waiting"; + } else { + const result = writeTerminalOutputUpdate( terminal, - segments: outputUpdate.segments, - replayState: scrollbackReplayRendererStateRef.current, - onReplayComplete: completePendingScrollbackReplay, - }); + outputUpdate, + scrollbackReplayRendererStateRef.current, + completePendingScrollbackReplay, + ); scrollbackReplayRendererStateRef.current = result.replayState; didWriteOutput = result.didWrite; - } else if (outputUpdate.type === "reset") { - if (outputUpdate.data.length === 0 && current.version === 0) { - // A restarted attach stream emits its pristine seed state before the - // server replies. Keep the current screen until real content arrives; - // the cursor above already adopted the new stream's epoch. - } else if (streamingReplay && outputUpdate.data.length === 0) { - // The extended attach begins with an empty snapshot. Keep the current - // screen visible until its first retained-history chunk arrives. - scrollbackReplayRendererStateRef.current = "waiting"; - } else if (streamingReplay) { - terminal.beginStreamingReplay(outputUpdate.data); - scrollbackReplayRendererStateRef.current = "replaying"; - didWriteOutput = true; - } else { - terminal.resetAndWrite(outputUpdate.data); - didWriteOutput = true; - } } if (didWriteOutput) terminal.clearSelection(); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 2ae0e39ceadb..1d7975d24677 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1032,20 +1032,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --terminal-foreground: var(--foreground); --terminal-cursor: rgb(38 56 78); --terminal-selection-background: rgb(37 63 99 / 20%); - /* Full-screen TUIs commonly reset cells to terminal defaults. Derive a dark - companion from the active terminal palette so custom themes keep control. */ - --terminal-alt-screen-background: color-mix(in srgb, var(--terminal-background) 5%, rgb(0 0 0)); - --terminal-alt-screen-foreground: color-mix( - in srgb, - var(--terminal-foreground) 5%, - rgb(255 255 255) - ); - --terminal-alt-screen-cursor: color-mix(in srgb, var(--terminal-cursor) 75%, rgb(255 255 255)); - --terminal-alt-screen-selection-background: color-mix( - in srgb, - var(--terminal-alt-screen-foreground) 25%, - transparent - ); @variant dark { color-scheme: dark; diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 48cc4256de61..ec3484c71c40 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -7,12 +7,28 @@ import { readTerminalOutputUpdate, terminalOutputText, type TerminalBufferState, + type TerminalOutputUpdate, } from "@t3tools/client-runtime/state/terminal"; -import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer"; +import { writeTerminalOutputUpdate as writeSurfaceOutputUpdate } from "../../components/ThreadTerminalDrawer"; import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core"; import { loadGhosttyRuntime } from "./runtime"; +function writeTerminalOutputUpdate(core: GhosttyTerminalCore, update: TerminalOutputUpdate) { + return writeSurfaceOutputUpdate( + { + beginStreamingReplay: (data) => { + core.beginReplay(); + core.writeReplay(data); + }, + appendStreamingReplay: (data) => core.writeReplay(data), + completeStreamingReplay: () => core.endReplay(), + write: (data) => core.write(data), + }, + update, + ); +} + vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ default: (await import("./vendor/ghostty-vt.wasm?inline")).default, })); @@ -194,7 +210,7 @@ describe("GhosttyTerminalCore snapshots", () => { writeTerminalOutputUpdate(core, first); reference.resetAndWrite(initial); let cursor = first.cursor; - const reset = vi.spyOn(core, "resetAndWrite"); + const reset = vi.spyOn(core, "beginReplay"); const inputs: string[] = []; let receivedCharacters = 0; @@ -226,7 +242,7 @@ describe("GhosttyTerminalCore snapshots", () => { const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); writeTerminalOutputUpdate(core, first); let cursor = first.cursor; - const reset = vi.spyOn(core, "resetAndWrite"); + const reset = vi.spyOn(core, "beginReplay"); const inputs = [ `${"a".repeat(16_383)}🙂`, "\x1b[3", @@ -290,7 +306,7 @@ describe("GhosttyTerminalCore snapshots", () => { let state = createSession("\x1b[31mold"); const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); writeTerminalOutputUpdate(core, initial); - const reset = vi.spyOn(core, "resetAndWrite"); + const reset = vi.spyOn(core, "beginReplay"); const data = "line\r\n".repeat(8192); for (let index = 0; index < 16; index += 1) state = append(state, data); @@ -322,7 +338,7 @@ describe("GhosttyTerminalCore snapshots", () => { writeTerminalOutputUpdate(core, first); const reference = await createCore(); reference.resetAndWrite(terminalOutputText(state.output)); - const reset = vi.spyOn(core, "resetAndWrite"); + const reset = vi.spyOn(core, "beginReplay"); state = append(state, "\r\nafter"); const next = readTerminalOutputUpdate(state.output, first.cursor); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index b0afd863002e..d54c7b4f62ab 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -67,7 +67,7 @@ export interface GhosttyColor { readonly b: number; } -export interface GhosttyScreenTheme { +export interface GhosttyTheme { readonly foreground: GhosttyColor; readonly background: GhosttyColor; readonly cursor: GhosttyColor; @@ -75,11 +75,6 @@ export interface GhosttyScreenTheme { readonly selectionBackground?: string; } -export interface GhosttyTheme extends GhosttyScreenTheme { - /** Theme-owned defaults used while the standard alternate screen is active. */ - readonly alternateScreen?: GhosttyScreenTheme; -} - export interface GhosttyCell { readonly text: string; readonly wide: number; diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts index 58b28b7654db..94935c24e338 100644 --- a/apps/web/src/terminal/ghostty/renderer.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -71,7 +71,7 @@ describe("ghosttyTextRunEnd", () => { }); describe("renderGhosttySnapshot", () => { - it("remaps terminal defaults without overriding explicit application colors", () => { + it("renders cell colors unchanged, including colors equal to either terminal default", () => { const fillRectCalls: Array<{ args: number[]; style: string }> = []; const fillTextCalls: Array<{ args: unknown[]; style: string }> = []; let fillStyle = ""; @@ -136,27 +136,15 @@ describe("renderGhosttySnapshot", () => { padding: 4, forceFull: true, cursorOn: true, - defaultThemeOverride: { - source: { - background: defaultCell.background, - foreground: defaultCell.foreground, - cursor: defaultCell.foreground, - }, - target: { - background: { r: 1, g: 2, b: 3 }, - foreground: { r: 250, g: 251, b: 252 }, - cursor: { r: 200, g: 201, b: 202 }, - }, - }, }); expect(fillRectCalls).toContainEqual({ args: [0, 0, 200, 40], - style: "rgb(1, 2, 3)", + style: "rgb(0, 0, 0)", }); expect(fillRectCalls).toContainEqual({ args: [14, 4, 10, 20], - style: "rgb(250, 251, 252)", + style: "rgb(255, 255, 255)", }); expect(fillRectCalls).toContainEqual({ args: [24, 4, 10, 20], @@ -167,8 +155,8 @@ describe("renderGhosttySnapshot", () => { style: "rgb(9, 8, 7)", }); expect(fillTextCalls).toEqual([ - { args: ["a", 4, 19, 10], style: "rgb(250, 251, 252)" }, - { args: ["i", 14, 19, 10], style: "rgb(1, 2, 3)" }, + { args: ["a", 4, 19, 10], style: "rgb(255, 255, 255)" }, + { args: ["i", 14, 19, 10], style: "rgb(0, 0, 0)" }, { args: ["b", 24, 19, 10], style: "rgb(10, 20, 30)" }, ]); }); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 78d5c8a8b6cb..e6ad5b3a7005 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -3,7 +3,6 @@ import { ghosttyColorsEqual, type GhosttyCell, type GhosttyColor, - type GhosttyScreenTheme, type GhosttySnapshot, } from "./core"; @@ -201,11 +200,6 @@ export function renderGhosttySnapshot(options: { readonly previousCursorY?: number | null; readonly focused?: boolean; readonly selectionBackground?: string; - /** Remap only terminal-default colors; explicit ANSI application colors win. */ - readonly defaultThemeOverride?: { - readonly source: GhosttyScreenTheme; - readonly target: GhosttyScreenTheme; - }; readonly hoveredLinkRange?: GhosttyCellRange | null; /** Vertical origin of row 0; defaults to the horizontal padding. */ readonly originY?: number; @@ -223,37 +217,7 @@ export function renderGhosttySnapshot(options: { } = options; const focused = options.focused ?? true; const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; - const themeOverride = options.defaultThemeOverride; - const defaultBackground = themeOverride?.target.background ?? snapshot.background; - const defaultForeground = themeOverride?.target.foreground ?? snapshot.foreground; - const resolveDefaultColor = ( - color: GhosttyColor, - sourceDefault: GhosttyColor, - sourceInverse: GhosttyColor, - targetDefault: GhosttyColor, - targetInverse: GhosttyColor, - ) => { - if (!themeOverride) return color; - if (ghosttyColorsEqual(color, sourceDefault)) return targetDefault; - if (ghosttyColorsEqual(color, sourceInverse)) return targetInverse; - return color; - }; - const resolveBackground = (color: GhosttyColor) => - resolveDefaultColor( - color, - themeOverride?.source.background ?? snapshot.background, - themeOverride?.source.foreground ?? snapshot.foreground, - defaultBackground, - defaultForeground, - ); - const resolveForeground = (color: GhosttyColor) => - resolveDefaultColor( - color, - themeOverride?.source.foreground ?? snapshot.foreground, - themeOverride?.source.background ?? snapshot.background, - defaultForeground, - defaultBackground, - ); + const defaultBackground = snapshot.background; const hoveredLinkRange = options.hoveredLinkRange ?? null; const originY = options.originY ?? padding; const rowsToDraw = forceFull @@ -292,14 +256,14 @@ export function renderGhosttySnapshot(options: { while (backgroundStart < row.cells.length) { const first = row.cells[backgroundStart]; if (!first) break; - const firstBackground = resolveBackground(first.background); + const firstBackground = first.background; let backgroundEnd = backgroundStart + 1; while (backgroundEnd < row.cells.length) { const next = row.cells[backgroundEnd]; if ( !next || next.selected !== first.selected || - !ghosttyColorsEqual(resolveBackground(next.background), firstBackground) + !ghosttyColorsEqual(next.background, firstBackground) ) { break; } @@ -331,7 +295,7 @@ export function renderGhosttySnapshot(options: { const blockRects = terminalBlockRects(first.text); if (blockRects !== null) { if (!first.invisible) { - context.fillStyle = cssColor(resolveForeground(first.foreground)); + context.fillStyle = cssColor(first.foreground); const cellLeft = padding + runStart * metrics.width; for (const [x, y, width, height] of blockRects) { const [left, right] = blockPixelSpan(cellLeft, x, width, metrics.width); @@ -362,7 +326,7 @@ export function renderGhosttySnapshot(options: { ); context.clip(); context.font = fontForCell(first, fontSize, fontFamily); - context.fillStyle = cssColor(resolveForeground(first.foreground)); + context.fillStyle = cssColor(first.foreground); context.fillText( text, padding + runStart * metrics.width, @@ -385,7 +349,7 @@ export function renderGhosttySnapshot(options: { if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) { continue; } - context.fillStyle = cssColor(resolveForeground(cell.foreground)); + context.fillStyle = cssColor(cell.foreground); const left = padding + column * metrics.width; if (cell.underline || hoveredLink) { context.fillRect(left, top + metrics.height - 2, metrics.width, 1); @@ -400,10 +364,7 @@ export function renderGhosttySnapshot(options: { if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { const left = padding + snapshot.cursorX * metrics.width; const top = originY + snapshot.cursorY * metrics.height; - const cursor = - themeOverride && ghosttyColorsEqual(snapshot.cursor, themeOverride.source.cursor) - ? themeOverride.target.cursor - : snapshot.cursor; + const cursor = snapshot.cursor; context.fillStyle = cssColor(cursor); if (!focused) { // An unfocused terminal draws a hollow cursor so the active pane is obvious. diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ea9b8ec6a99a..3f9afcf4701a 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,6 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow, type GhosttyTheme } from "./core"; +import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; +import { + applyTerminalAttachStreamEvent, + EMPTY_TERMINAL_BUFFER_STATE, + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, +} from "@t3tools/client-runtime/state/terminal"; +import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer"; + +import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, @@ -24,7 +32,6 @@ import { terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, - terminalThemeForScreen, terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, @@ -100,8 +107,10 @@ describe("GhosttyTerminalSurface visibility", () => { const canvas = new TerminalTestElement(); const mount = new TerminalTestElement(); + const paintedText: Array<{ text: string; color: string }> = []; const context = { canvas, + fillStyle: "", beginPath() {}, clip() {}, rect() {}, @@ -111,7 +120,10 @@ describe("GhosttyTerminalSurface visibility", () => { setTransform() {}, fillRect: (...args: number[]) => paint("fillRect", args), strokeRect: (...args: number[]) => paint("strokeRect", args), - fillText: (...args: [string, number, number, number?]) => paint("fillText", args), + fillText: (...args: [string, number, number, number?]) => { + paintedText.push({ text: args[0], color: context.fillStyle }); + paint("fillText", args); + }, measureText: (text: string) => ({ width: text.length * 8, actualBoundingBoxAscent: 9, @@ -154,6 +166,7 @@ describe("GhosttyTerminalSurface visibility", () => { mount, frames, paint, + paintedText, requestFrame, snapshot, onData, @@ -212,6 +225,81 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + it.each(["idle", "waiting"] as const)( + "answers live queries when replay, completion and live output batch into a reset (%s)", + async (replayState) => { + const harness = createHarness(); + const surface = await harness.create(); + const target = { threadId: ThreadId.make("thread-1"), terminalId: "default" }; + const events: TerminalAttachStreamEvent[] = [ + { type: "replay-start", ...target }, + { + type: "snapshot", + snapshot: { + ...target, + cwd: "/tmp", + worktreePath: null, + status: "running", + pid: 1, + history: "", + exitCode: null, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-09-08T00:00:00.000Z", + }, + }, + { type: "output", ...target, data: "old\x1b[6n" }, + { type: "replay-complete", ...target }, + { type: "output", ...target, data: "live\x1b[6n" }, + ]; + const state = events.reduce( + (state, event) => applyTerminalAttachStreamEvent(state, event), + EMPTY_TERMINAL_BUFFER_STATE, + ); + const update = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR); + expect(update.type).toBe("reset"); + writeTerminalOutputUpdate(surface, update, replayState); + harness.flushFrame(); + harness.flushFrame(); + expect(harness.onData.mock.calls).toEqual([["\x1b[1;8R"]]); + expect(harness.renderedSnapshot.rowData[0]?.text).toContain("oldlive"); + + // Recreating a surface repaints consumed live queries without replying + // twice, while a newly arrived query still gets an answer. + const next = applyTerminalAttachStreamEvent(state, { + type: "output", + ...target, + data: "\x1b[6n", + }); + const reset = readTerminalOutputUpdate(next.output, update.cursor, true); + writeTerminalOutputUpdate(surface, reset); + harness.flushFrame(); + expect(harness.onData.mock.calls).toEqual([["\x1b[1;8R"], ["\x1b[1;8R"]]); + }, + ); + + it("preserves explicit truecolor equal to either host default in the alternate screen", async () => { + const harness = createHarness(); + const surface = await harness.create({ + theme: { + background: { r: 255, g: 255, b: 255 }, + foreground: { r: 20, g: 20, b: 20 }, + cursor: { r: 20, g: 20, b: 20 }, + }, + }); + surface.write( + "\x1b[?1049h\x1b[38;2;255;255;255m\x1b[48;2;20;20;20mA" + + "\x1b[38;2;20;20;20m\x1b[48;2;255;255;255mB", + ); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.cells.slice(0, 2)).toMatchObject([ + { foreground: { r: 255, g: 255, b: 255 }, background: { r: 20, g: 20, b: 20 } }, + { foreground: { r: 20, g: 20, b: 20 }, background: { r: 255, g: 255, b: 255 } }, + ]); + expect(harness.paintedText).toContainEqual({ text: "A", color: "rgb(255, 255, 255)" }); + expect(harness.paintedText).toContainEqual({ text: "B", color: "rgb(20, 20, 20)" }); + }); + it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { const harness = createHarness(); const surface = await harness.create(); @@ -353,41 +441,6 @@ describe("GhosttyTerminalSurface visibility", () => { ); }); -const lightTerminalTheme = { - background: { r: 255, g: 255, b: 255 }, - foreground: { r: 20, g: 20, b: 20 }, - cursor: { r: 38, g: 56, b: 78 }, - selectionBackground: "rgb(37 63 99 / 20%)", - alternateScreen: { - background: { r: 12, g: 12, b: 12 }, - foreground: { r: 244, g: 244, b: 244 }, - cursor: { r: 199, g: 218, b: 255 }, - selectionBackground: "rgb(37 63 99 / 16%)", - }, -} satisfies GhosttyTheme; - -describe("terminalThemeForScreen", () => { - it("keeps the app theme on the normal shell screen", () => { - expect(terminalThemeForScreen(lightTerminalTheme, false)).toBe(lightTerminalTheme); - }); - - it("uses coherent dark defaults for a full-screen app under a light host theme", () => { - expect(terminalThemeForScreen(lightTerminalTheme, true)).toBe( - lightTerminalTheme.alternateScreen, - ); - }); - - it("leaves an existing dark app theme untouched in the alternate screen", () => { - const darkTheme = { - background: { r: 0, g: 0, b: 0 }, - foreground: { r: 245, g: 245, b: 245 }, - cursor: { r: 180, g: 203, b: 255 }, - } satisfies GhosttyTheme; - - expect(terminalThemeForScreen(darkTheme, true)).toBe(darkTheme); - }); -}); - const cell = (text: string): GhosttyCell => ({ text, wide: 0, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 7b9b961e17f7..d16b64e7d6ce 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -4,7 +4,6 @@ import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../term import { GhosttyTerminalCore, type GhosttyScrollbar, - type GhosttyScreenTheme, type GhosttySnapshot, type GhosttyTheme, } from "./core"; @@ -65,33 +64,6 @@ export interface GhosttyTerminalFont { readonly size?: number; } -function linearColorChannel(value: number): number { - const channel = value / 255; - return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; -} - -function terminalColorLuminance(color: GhosttyTheme["background"]): number { - return ( - 0.2126 * linearColorChannel(color.r) + - 0.7152 * linearColorChannel(color.g) + - 0.0722 * linearColorChannel(color.b) - ); -} - -/** - * Full-screen terminal apps often reset cells to the terminal defaults while - * repainting a dark interface. Give a light host theme coherent dark defaults - * only while the standard alternate screen is active; explicit app colors - * still win, and returning to the shell restores the host theme. - */ -export function terminalThemeForScreen( - theme: GhosttyTheme, - alternateScreen: boolean, -): GhosttyScreenTheme { - if (!alternateScreen || terminalColorLuminance(theme.background) < 0.5) return theme; - return theme.alternateScreen ?? theme; -} - let symbolsFontLoad: Promise | null = null; /** @@ -667,9 +639,7 @@ export class GhosttyTerminalSurface { readonly height: number; readonly ratio: number; } | null = null; - private appTheme: GhosttyTheme; - private theme: GhosttyScreenTheme; - private alternateScreenActive = false; + private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; private pasteShortcutDeliveredToken: number | null = null; @@ -709,7 +679,6 @@ export class GhosttyTerminalSurface { this.metrics = metrics; this.options = options; this.visible = options.visible ?? true; - this.appTheme = options.theme; this.theme = options.theme; this.fontFamily = fontFamily; this.requestedFontFamily = options.font?.family; @@ -830,7 +799,6 @@ export class GhosttyTerminalSurface { } private didWriteOutput(): void { - this.synchronizeScreenTheme(); this.synchronizeMouseTrackingState(); this.refreshHoverBaseCursor(); // Restart the blink cycle from the visible phase so the cursor never sits @@ -846,7 +814,6 @@ export class GhosttyTerminalSurface { this.lastMouseMotionData = ""; this.core.beginReplay(); this.replayActive = true; - this.synchronizeScreenTheme(); if (data.length > 0) { this.enqueueWrite(data, true); } else { @@ -869,7 +836,6 @@ export class GhosttyTerminalSurface { this.core.beginReplay(); this.replayActive = true; this.replayStreamOpen = true; - this.synchronizeScreenTheme(); if (data.length > 0) { this.enqueueWrite(data, true); } @@ -1030,19 +996,11 @@ export class GhosttyTerminalSurface { setTheme(theme: GhosttyTheme): void { if (this.disposed) return; - this.appTheme = theme; + this.theme = theme; this.core.setTheme(theme); - this.synchronizeScreenTheme(true); - this.requestRender(); - } - - private synchronizeScreenTheme(force = false): void { - const alternateScreen = this.core.isAlternateScreen(); - if (!force && alternateScreen === this.alternateScreenActive) return; - this.alternateScreenActive = alternateScreen; - this.theme = terminalThemeForScreen(this.appTheme, alternateScreen); - this.mount.style.backgroundColor = `rgb(${this.theme.background.r} ${this.theme.background.g} ${this.theme.background.b})`; + this.mount.style.backgroundColor = `rgb(${theme.background.r} ${theme.background.g} ${theme.background.b})`; this.forceFullRender = true; + this.requestRender(); } async setFont(font: GhosttyTerminalFont): Promise { @@ -2185,9 +2143,6 @@ export class GhosttyTerminalSurface { previousCursorY: this.renderedCursorY, focused: this.focused, hoveredLinkRange: this.hoveredLink?.range ?? null, - ...(this.alternateScreenActive - ? { defaultThemeOverride: { source: this.appTheme, target: this.theme } } - : {}), ...(this.theme.selectionBackground !== undefined ? { selectionBackground: this.theme.selectionBackground } : {}), diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts index bcee3bf3784d..b07777dc6a73 100644 --- a/packages/client-runtime/src/state/terminalOutput.ts +++ b/packages/client-runtime/src/state/terminalOutput.ts @@ -37,6 +37,10 @@ export type TerminalOutputUpdate = | { readonly type: "reset"; readonly data: string; + readonly segments: ReadonlyArray<{ + readonly data: string; + readonly delivery: "replay" | "live"; + }>; readonly cursor: TerminalOutputCursor; } | { @@ -257,6 +261,7 @@ export function terminalOutputText(output: TerminalOutputState): string { export function readTerminalOutputUpdate( output: TerminalOutputState, cursor: TerminalOutputCursor, + forceReset = false, ): TerminalOutputUpdate { const nextCursor = { generation: output.generation, @@ -264,29 +269,32 @@ export function readTerminalOutputUpdate( offset: output.nextOffset, }; const firstChunk = output.chunks[0]; - if ( - cursor.generation !== output.generation || - cursor.resetVersion !== output.resetVersion || - cursor.offset < (firstChunk?.startOffset ?? output.nextOffset) - ) { - return { type: "reset", data: terminalOutputText(output), cursor: nextCursor }; - } - - const appended = output.chunks.filter( - (chunk) => chunk.startOffset + chunk.data.length > cursor.offset, - ); - if (appended.length === 0) { + const sameReset = + cursor.generation === output.generation && cursor.resetVersion === output.resetVersion; + const reset = + forceReset || !sameReset || cursor.offset < (firstChunk?.startOffset ?? output.nextOffset); + const chunks = reset + ? output.chunks + : output.chunks.filter((chunk) => chunk.startOffset + chunk.data.length > cursor.offset); + if (!reset && chunks.length === 0) { return { type: "none", cursor: nextCursor }; } const segments: Array<{ data: string; delivery: "replay" | "live" }> = []; - for (const chunk of appended) { - const data = chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset)); + const appendSegment = (data: string, delivery: "replay" | "live") => { + if (data.length === 0) return; const previous = segments.at(-1); - if (previous?.delivery === chunk.delivery) previous.data += data; - else segments.push({ data, delivery: chunk.delivery }); + if (previous?.delivery === delivery) previous.data += data; + else segments.push({ data, delivery }); + }; + for (const chunk of chunks) { + const consumed = sameReset ? Math.max(0, cursor.offset - chunk.startOffset) : 0; + // A reset must repaint consumed bytes without answering their queries a + // second time. Unread live bytes still need replies, even in the same chunk. + if (reset) appendSegment(chunk.data.slice(0, consumed), "replay"); + appendSegment(chunk.data.slice(consumed), chunk.delivery); } return { - type: "append", + type: reset ? "reset" : "append", segments, data: segments.map((segment) => segment.data).join(""), cursor: nextCursor, diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index a314a9a4abfe..a31abb1f813c 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -654,6 +654,29 @@ describe("terminal session reducers", () => { { data: " live", delivery: "live" }, ], }); + expect( + readTerminalOutputUpdate(liveOutput.output, INITIAL_TERMINAL_OUTPUT_CURSOR), + ).toMatchObject({ + type: "reset", + segments: [ + { data: "hello replay", delivery: "replay" }, + { data: " live", delivery: "live" }, + ], + }); + const consumed = readTerminalOutputUpdate(liveOutput.output, cursor).cursor; + const next = applyTerminalAttachStreamEvent(liveOutput, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: " unread", + }); + expect(readTerminalOutputUpdate(next.output, consumed, true)).toMatchObject({ + type: "reset", + segments: [ + { data: "hello replay live", delivery: "replay" }, + { data: " unread", delivery: "live" }, + ], + }); }); it("closes every open replay when a completion marker arrives after a lost one", () => { From d037a6dcfb3517d63898d116cf1153879f7c32bd Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:34:58 +0200 Subject: [PATCH 5/9] refactor(terminal): acquire attach service from Effect context --- apps/server/src/terminal/AttachStream.test.ts | 82 +++++++++++-------- apps/server/src/terminal/AttachStream.ts | 24 +++--- apps/server/src/ws.ts | 8 +- 3 files changed, 61 insertions(+), 53 deletions(-) diff --git a/apps/server/src/terminal/AttachStream.test.ts b/apps/server/src/terminal/AttachStream.test.ts index a49d6d413945..8cdb62483276 100644 --- a/apps/server/src/terminal/AttachStream.test.ts +++ b/apps/server/src/terminal/AttachStream.test.ts @@ -10,7 +10,8 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; -import type { TerminalManager } from "./Manager.ts"; +import * as TerminalManager from "./Manager.ts"; +import * as Layer from "effect/Layer"; import { terminalAttachStream } from "./AttachStream.ts"; const input = { @@ -26,21 +27,24 @@ const target = { threadId: input.threadId, terminalId: input.terminalId }; it.effect("delivers every queued output before close when the attach consumer stalls", () => Effect.gen(function* () { const subscribed = - yield* Deferred.make[1]>(); + yield* Deferred.make< + Parameters[1] + >(); const consumerStarted = yield* Deferred.make(); const resumeConsumer = yield* Deferred.make(); let unsubscribed = false; const received: TerminalAttachStreamEvent[] = []; - const stream = terminalAttachStream( - { - attachStream: (_input, listener) => - Deferred.succeed(subscribed, listener).pipe( - Effect.as(() => { - unsubscribed = true; - }), - ), - }, - input, + const stream = terminalAttachStream(input).pipe( + Stream.provide( + Layer.mock(TerminalManager.TerminalManager)({ + attachStream: (_input, listener) => + Deferred.succeed(subscribed, listener).pipe( + Effect.as(() => { + unsubscribed = true; + }), + ), + }), + ), ); const consumer = yield* stream.pipe( Stream.takeUntil((event) => event.type === "closed"), @@ -79,7 +83,9 @@ it.effect("delivers every queued output before close when the attach consumer st it.effect("retains extended replay and its completion boundary when the consumer stalls", () => Effect.gen(function* () { const subscribed = - yield* Deferred.make[1]>(); + yield* Deferred.make< + Parameters[1] + >(); const replayStarted = yield* Deferred.make(); const resumeConsumer = yield* Deferred.make(); const queueFilled = yield* Deferred.make(); @@ -87,18 +93,19 @@ it.effect("retains extended replay and its completion boundary when the consumer const resumeLive = yield* Deferred.make(); let unsubscribed = false; const received: TerminalAttachStreamEvent[] = []; - const stream = terminalAttachStream( - { - attachStream: (request, listener) => { - expect(request.replayBytes).toBe(EXTENDED_TERMINAL_REPLAY_BYTES); - return Deferred.succeed(subscribed, listener).pipe( - Effect.as(() => { - unsubscribed = true; - }), - ); - }, - }, - input, + const stream = terminalAttachStream(input).pipe( + Stream.provide( + Layer.mock(TerminalManager.TerminalManager)({ + attachStream: (request, listener) => { + expect(request.replayBytes).toBe(EXTENDED_TERMINAL_REPLAY_BYTES); + return Deferred.succeed(subscribed, listener).pipe( + Effect.as(() => { + unsubscribed = true; + }), + ); + }, + }), + ), ); const consumer = yield* stream.pipe( Stream.takeUntil((event) => event.type === "output" && event.data === "live-end"), @@ -157,19 +164,22 @@ it.effect("retains extended replay and its completion boundary when the consumer it.effect("unsubscribes and releases blocked output when the transport consumer disconnects", () => Effect.gen(function* () { const subscribed = - yield* Deferred.make[1]>(); + yield* Deferred.make< + Parameters[1] + >(); const consumerStarted = yield* Deferred.make(); let unsubscribed = false; - const stream = terminalAttachStream( - { - attachStream: (_input, listener) => - Deferred.succeed(subscribed, listener).pipe( - Effect.as(() => { - unsubscribed = true; - }), - ), - }, - input, + const stream = terminalAttachStream(input).pipe( + Stream.provide( + Layer.mock(TerminalManager.TerminalManager)({ + attachStream: (_input, listener) => + Deferred.succeed(subscribed, listener).pipe( + Effect.as(() => { + unsubscribed = true; + }), + ), + }), + ), ); const consumer = yield* stream.pipe( Stream.runForEach(() => diff --git a/apps/server/src/terminal/AttachStream.ts b/apps/server/src/terminal/AttachStream.ts index ddbf45ed0bc4..b42598ea16fc 100644 --- a/apps/server/src/terminal/AttachStream.ts +++ b/apps/server/src/terminal/AttachStream.ts @@ -7,21 +7,21 @@ import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import type { TerminalManager } from "./Manager.ts"; +import * as TerminalManager from "./Manager.ts"; /** Backpressure preserves output and replay boundaries, including the final bytes before close. */ -export function terminalAttachStream( - manager: Pick, - input: TerminalAttachInput, -) { - return Stream.callback( +export function terminalAttachStream(input: TerminalAttachInput) { + return Stream.callback( (queue) => - Effect.acquireRelease( - manager.attachStream(input, (event) => - Queue.offer(queue, event).pipe(Effect.asVoid, Effect.ignore), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Effect.gen(function* () { + const manager = yield* TerminalManager.TerminalManager; + return yield* Effect.acquireRelease( + manager.attachStream(input, (event) => + Queue.offer(queue, event).pipe(Effect.asVoid, Effect.ignore), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); + }), { bufferSize: 32, strategy: "suspend" }, ); } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d3262f16ec1f..6a669b0afa80 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2582,11 +2582,9 @@ const makeWsRpcLayer = ( "rpc.aggregate": "terminal", }), [WS_METHODS.terminalAttach]: (input) => - observeRpcStream( - WS_METHODS.terminalAttach, - terminalAttachStream(terminalManager, input), - { "rpc.aggregate": "terminal" }, - ), + observeRpcStream(WS_METHODS.terminalAttach, terminalAttachStream(input), { + "rpc.aggregate": "terminal", + }), [WS_METHODS.terminalWrite]: (input) => observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { "rpc.aggregate": "terminal", From 0e22886887f8a724aa8d24799ebcb29008a9806b Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:15:17 +0200 Subject: [PATCH 6/9] fix(terminal): backpressure attach replay and cancel blocked producers --- apps/server/src/terminal/AttachStream.test.ts | 33 +++ apps/server/src/terminal/AttachStream.ts | 8 +- apps/server/src/terminal/Manager.test.ts | 148 ++++++++++--- apps/server/src/terminal/Manager.ts | 204 +++++++----------- 4 files changed, 233 insertions(+), 160 deletions(-) diff --git a/apps/server/src/terminal/AttachStream.test.ts b/apps/server/src/terminal/AttachStream.test.ts index 8cdb62483276..1c096cbe6daf 100644 --- a/apps/server/src/terminal/AttachStream.test.ts +++ b/apps/server/src/terminal/AttachStream.test.ts @@ -201,3 +201,36 @@ it.effect("unsubscribes and releases blocked output when the transport consumer expect(unsubscribed).toBe(true); }), ); + +it.effect("interrupts an attach that is still replaying when the consumer disconnects", () => + Effect.gen(function* () { + const consumerStarted = yield* Deferred.make(); + let replayInterrupted = false; + const consumer = yield* terminalAttachStream(input).pipe( + Stream.provide( + Layer.mock(TerminalManager.TerminalManager)({ + attachStream: (_input, listener) => + Effect.gen(function* () { + for (let index = 0; index < 100; index += 1) { + yield* listener({ type: "output", ...target, data: "history" }, "replay"); + } + return () => {}; + }).pipe( + Effect.onInterrupt(() => + Effect.sync(() => { + replayInterrupted = true; + }), + ), + ), + }), + ), + Stream.runForEach(() => + Deferred.succeed(consumerStarted, undefined).pipe(Effect.andThen(Effect.never)), + ), + Effect.forkChild, + ); + yield* Deferred.await(consumerStarted); + yield* Fiber.interrupt(consumer); + expect(replayInterrupted).toBe(true); + }), +); diff --git a/apps/server/src/terminal/AttachStream.ts b/apps/server/src/terminal/AttachStream.ts index b42598ea16fc..43efe41d9a7e 100644 --- a/apps/server/src/terminal/AttachStream.ts +++ b/apps/server/src/terminal/AttachStream.ts @@ -16,9 +16,11 @@ export function terminalAttachStream(input: TerminalAttachInput) { Effect.gen(function* () { const manager = yield* TerminalManager.TerminalManager; return yield* Effect.acquireRelease( - manager.attachStream(input, (event) => - Queue.offer(queue, event).pipe(Effect.asVoid, Effect.ignore), - ), + manager + .attachStream(input, (event) => + Queue.offer(queue, event).pipe(Effect.asVoid, Effect.ignore), + ) + .pipe(Effect.interruptible), (unsubscribe) => Effect.sync(unsubscribe), ); }), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 9de7a5158c40..d126be4b41cf 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -43,6 +43,7 @@ import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; +import { terminalAttachStream } from "./AttachStream.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ readonly message: string; @@ -3186,43 +3187,134 @@ it.layer( }), ); - it.effect("delivers terminal close after repeated attach-buffer overflows", () => + it.effect( + "preserves extended replay and final output when attach backpressure reaches the PTY", + () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager({ outputBatchWindowMs: 0 }); + const history = "history\n".repeat(EXTENDED_TERMINAL_REPLAY_BYTES / 8); + yield* historyLogPath(logsDir).pipe( + Effect.flatMap((filePath) => writeFileString(filePath, history)), + ); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + const consumerStarted = yield* Deferred.make(); + const resumeConsumer = yield* Deferred.make(); + const bufferFilled = yield* Deferred.make(); + let liveOutputCount = 0; + const stopObserving = yield* manager.subscribe((event) => { + if (event.type !== "output" || ++liveOutputCount !== 65) return Effect.void; + return Deferred.succeed(bufferFilled, undefined).pipe(Effect.asVoid); + }); + yield* Effect.addFinalizer(() => Effect.sync(stopObserving)); + const received: TerminalAttachStreamEvent[] = []; + const attach = yield* manager + .attachStream( + { + ...openInput(), + replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES, + }, + (event) => + Effect.gen(function* () { + received.push(event); + if (event.type === "replay-start") { + yield* Deferred.succeed(consumerStarted, undefined); + yield* Deferred.await(resumeConsumer); + } + }), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(consumerStarted); + for (let index = 0; index < 130; index += 1) process.emitData("x".repeat(64 * 1024)); + process.emitData("final-output"); + expect(process.pauseCalls).toBeGreaterThan(0); + yield* Deferred.await(bufferFilled); + const close = yield* manager + .close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(resumeConsumer, undefined); + yield* Fiber.join(close); + const stop = yield* Fiber.join(attach); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + expect(received.filter((event) => event.type === "snapshot")).toHaveLength(1); + const output = received + .filter((event) => event.type === "output") + .map((event) => event.data) + .join(""); + const expectedOutput = history + "x".repeat(130 * 64 * 1024) + "final-output"; + expect(output.length).toBe(expectedOutput.length); + expect(output === expectedOutput, "replay and live bytes stay in order").toBe(true); + expect(received.at(-1)?.type).toBe("closed"); + }), + ); + + it.effect("streams a full extended replay through the bounded transport", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager(); + const history = "history\n".repeat(EXTENDED_TERMINAL_REPLAY_BYTES / 8); + yield* historyLogPath(logsDir).pipe( + Effect.flatMap((filePath) => writeFileString(filePath, history)), + ); + const events = yield* terminalAttachStream({ + ...openInput(), + replayBytes: EXTENDED_TERMINAL_REPLAY_BYTES, + }).pipe( + Stream.provideService(TerminalManager.TerminalManager, manager), + Stream.takeUntil((event) => event.type === "replay-complete"), + Stream.runCollect, + ); + expect( + events + .filter((event) => event.type === "output") + .map((event) => event.data) + .join(""), + ).toBe(history); + }), + ); + + it.effect("cancels a replay with a full live buffer without blocking terminal close", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager({ outputBatchWindowMs: 0 }); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]!; - let burstDrained = yield* Deferred.make(); - const stopObserving = yield* manager.subscribe((event) => - event.type === "output" && event.data.endsWith("burst-end") - ? Deferred.succeed(burstDrained, undefined).pipe(Effect.asVoid) - : Effect.void, - ); + const replayStarted = yield* Deferred.make(); + const bufferFilled = yield* Deferred.make(); + let outputCount = 0; + const stopObserving = yield* manager.subscribe((event) => { + if (event.type !== "output" || ++outputCount !== 65) return Effect.void; + return Deferred.succeed(bufferFilled, undefined).pipe(Effect.asVoid); + }); yield* Effect.addFinalizer(() => Effect.sync(stopObserving)); - const received: TerminalAttachStreamEvent[] = []; - let snapshotCount = 0; - const stop = yield* manager.attachStream(openInput(), (event) => - Effect.gen(function* () { - received.push(event); - if (event.type !== "snapshot") return; - snapshotCount += 1; - burstDrained = yield* Deferred.make(); - for (let index = 0; index < 65; index += 1) process.emitData("x".repeat(64 * 1024)); - process.emitData("burst-end"); - yield* Deferred.await(burstDrained); - if (snapshotCount === 4) { - yield* manager - .close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }) - .pipe(Effect.orDie); - } - }), - ); - yield* Effect.addFinalizer(() => Effect.sync(stop)); - expect(snapshotCount).toBe(4); - expect(received.at(-1)?.type).toBe("closed"); + const attach = yield* manager + .attachStream(openInput(), () => + Deferred.succeed(replayStarted, undefined).pipe(Effect.andThen(Effect.never)), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(replayStarted); + for (let index = 0; index < 65; index += 1) process.emitData("x".repeat(64 * 1024)); + yield* Deferred.await(bufferFilled); + yield* Fiber.interrupt(attach); + yield* manager.close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); expect(Option.isNone(yield* manager.readSnapshot(openInput()))).toBe(true); }), ); + it.effect("assigns close a sequence after the output it drains", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + yield* manager.open(openInput()); + ptyAdapter.processes[0]!.emitData("final output"); + yield* manager.close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); + const events = (yield* getEvents).filter( + (event) => event.type === "output" || event.type === "closed", + ); + expect(events).toEqual([ + expect.objectContaining({ type: "output", data: "final output", sequence: 2 }), + expect.objectContaining({ type: "closed", sequence: 3 }), + ]); + }), + ); + it.effect("cancels extended history replay when its attach scope closes", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 72d6379f446a..2257dd227578 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -54,6 +54,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -104,12 +105,9 @@ const DEFAULT_OUTPUT_BATCH_MAX_BYTES = 64 * 1024; // rather than allowing an unbounded server heap queue. const DEFAULT_PENDING_PROCESS_EVENT_MAX_BYTES = 4 * 1024 * 1024; const DEFAULT_HISTORY_STREAM_CHUNK_BYTES = 64 * 1024; -// Events published while an attach is still replaying buffer until the replay -// finishes. The budget must comfortably cover live output produced during a -// multi-second extended replay over a slow link; overflowing it degrades the -// subscriber to a bounded resync snapshot, which discards streamed scrollback. -const DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT = 1_024; -const DEFAULT_ATTACH_BUFFERED_MAX_BYTES = 4 * 1024 * 1024; +// Hold at most 4 MiB of bounded output events while replay is being sent. +// Backpressure reaches the PTY instead of discarding live bytes or scrollback. +const DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT = 64; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_PERSIST_CHUNK_BYTES = 64 * 1024; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; @@ -474,18 +472,6 @@ function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamE } } -function isDuplicateAttachSnapshotEvent( - event: TerminalEvent, - initialSnapshot: TerminalSessionSnapshot, -) { - return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" - ? event.sequence <= initialSnapshot.sequence - : event.type === "started" && - event.snapshot.threadId === initialSnapshot.threadId && - event.snapshot.terminalId === initialSnapshot.terminalId && - event.snapshot.updatedAt <= initialSnapshot.updatedAt; -} - function advanceEventSequence(session: TerminalSessionState): { readonly updatedAt: string; readonly sequence: number; @@ -2783,7 +2769,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ) { const key = toSessionKey(threadId, terminalId); const session = yield* getSession(threadId, terminalId); - const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; if (Option.isSome(session)) { yield* stopProcess(session.value); @@ -2806,7 +2791,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func type: "closed", threadId, terminalId, - sequence: closedEventSequence, + sequence: Option.isSome(session) ? session.value.eventSequence + 1 : 0, }); } @@ -3145,7 +3130,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); - const openOrAttachForStream = (input: TerminalAttachInput) => + const openOrAttachForStream = (input: TerminalAttachInput, onSnapshot: () => void) => withThreadLock( input.threadId, Effect.gen(function* () { @@ -3225,6 +3210,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } as const; })(); + // Start buffering synchronously with the captured history. Earlier + // events are already in the snapshot; buffering them could block the + // bootstrap's own process drain before the replay consumer can start. + onSnapshot(); + // A full-screen app repaints only dirty cells, so the capped replay // cannot reconstruct its whole screen. Wiggle the PTY size so the // SIGWINCH makes the app repaint everything; its output lands after @@ -3303,150 +3293,106 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func let unsubscribe: (() => void) | null = null; return Effect.gen(function* () { - const bufferedEvents: Array<{ event: TerminalEvent; bytes: number }> = []; - let bufferedEventBytes = 0; - let bufferedOverflow = false; + const bufferedEvents = yield* Queue.bounded( + DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT, + ); + let capturedSnapshot = false; let deliverLive = false; - // Snapshots cannot recover a removed session or transient errors. Keep - // the latest of each in order without letting lifecycle events grow unbounded. - const discardBufferedSnapshotEvents = () => { - const closed = bufferedEvents.findLast(({ event }) => event.type === "closed"); - const error = bufferedEvents.findLast(({ event }) => event.type === "error"); - const retained = bufferedEvents.filter((entry) => entry === closed || entry === error); - bufferedEvents.splice(0, bufferedEvents.length, ...retained); - bufferedEventBytes = 0; - }; - // Old clients decode the attach stream against a union without the - // replay markers. Sending replayBytes proves the client understands them. - const emitReplayMarkers = input.replayBytes !== undefined; - - unsubscribe = yield* subscribe((event) => { - if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { - return Effect.void; - } - - if (!deliverLive) { - const eventBytes = event.type === "output" ? Buffer.byteLength(event.data) : 0; - if ( - bufferedEvents.length >= DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT || - bufferedEventBytes + eventBytes > DEFAULT_ATTACH_BUFFERED_MAX_BYTES - ) { - discardBufferedSnapshotEvents(); - bufferedOverflow = true; + return yield* Effect.gen(function* () { + // Old clients decode the attach stream against a union without the + // replay markers. Sending replayBytes proves the client understands them. + const emitReplayMarkers = input.replayBytes !== undefined; + + unsubscribe = yield* subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; } - bufferedEvents.push({ event, bytes: eventBytes }); - bufferedEventBytes += eventBytes; - return Effect.void; - } - const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent, "live") : Effect.void; - }); + if (!capturedSnapshot) return Effect.void; + if (!deliverLive) return Queue.offer(bufferedEvents, event).pipe(Effect.asVoid); - const bootstrap = yield* openOrAttachForStream(input); - let synchronizedSnapshot = bootstrap.snapshot; - - if (emitReplayMarkers) { - yield* listener( - { - type: "replay-start", - threadId: input.threadId, - terminalId: input.terminalId, - ...(typeof bootstrap.snapshot.sequence === "number" - ? { sequence: bootstrap.snapshot.sequence } - : {}), - }, - "replay", - ); - } + const attachEvent = terminalEventToAttachEvent(event); + return attachEvent ? listener(attachEvent, "live") : Effect.void; + }); - yield* listener( - { - type: "snapshot", - snapshot: bootstrap.snapshot, - }, - "replay", - ); + const bootstrap = yield* openOrAttachForStream(input, () => { + capturedSnapshot = true; + }); - if (bootstrap.replayHistory !== null && bootstrap.replayHistory.length > 0) { - for (const { data } of splitStringByUtf8Bytes( - bootstrap.replayHistory, - DEFAULT_HISTORY_STREAM_CHUNK_BYTES, - )) { + if (emitReplayMarkers) { yield* listener( { - type: "output", + type: "replay-start", threadId: input.threadId, terminalId: input.terminalId, ...(typeof bootstrap.snapshot.sequence === "number" ? { sequence: bootstrap.snapshot.sequence } : {}), - data, }, "replay", ); } - } - if (emitReplayMarkers) { yield* listener( { - type: "replay-complete", - threadId: input.threadId, - terminalId: input.terminalId, - ...(typeof bootstrap.snapshot.sequence === "number" - ? { sequence: bootstrap.snapshot.sequence } - : {}), + type: "snapshot", + snapshot: bootstrap.snapshot, }, "replay", ); - } - let overflowResyncCount = 0; - while (true) { - if (bufferedOverflow) { - bufferedOverflow = false; - overflowResyncCount += 1; - if (overflowResyncCount > 3) { - // A consumer this far behind keeps overflowing while the resync - // itself is being delivered. Drain unrecoverable lifecycle events - // before going live with the most recently delivered snapshot. - discardBufferedSnapshotEvents(); - overflowResyncCount = 0; - continue; - } - const latest = yield* readSnapshot(input); - if (Option.isSome(latest)) { - synchronizedSnapshot = latest.value; + if (bootstrap.replayHistory !== null && bootstrap.replayHistory.length > 0) { + for (const { data } of splitStringByUtf8Bytes( + bootstrap.replayHistory, + DEFAULT_HISTORY_STREAM_CHUNK_BYTES, + )) { yield* listener( { - type: "snapshot", - snapshot: latest.value, + type: "output", + threadId: input.threadId, + terminalId: input.terminalId, + ...(typeof bootstrap.snapshot.sequence === "number" + ? { sequence: bootstrap.snapshot.sequence } + : {}), + data, }, "replay", ); } - continue; } - const buffered = bufferedEvents.shift(); - if (!buffered) { - deliverLive = true; - break; + if (emitReplayMarkers) { + yield* listener( + { + type: "replay-complete", + threadId: input.threadId, + terminalId: input.terminalId, + ...(typeof bootstrap.snapshot.sequence === "number" + ? { sequence: bootstrap.snapshot.sequence } + : {}), + }, + "replay", + ); } - bufferedEventBytes -= buffered.bytes; - if (isDuplicateAttachSnapshotEvent(buffered.event, synchronizedSnapshot)) continue; - const attachEvent = terminalEventToAttachEvent(buffered.event); - if (attachEvent) { - yield* listener(attachEvent, "replay"); + while (true) { + const buffered = yield* Effect.sync(() => { + const next = Queue.takeUnsafe(bufferedEvents); + // Switch delivery in the same turn as the empty check, before a + // yielding consumer can allow another event into the old queue. + if (next === undefined) deliverLive = true; + return next; + }); + if (buffered === undefined) break; + const attachEvent = terminalEventToAttachEvent(yield* buffered); + if (attachEvent) yield* listener(attachEvent, "live"); } - } - return () => { - unsubscribe?.(); - unsubscribe = null; - }; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe(Effect.ensuring(Queue.shutdown(bufferedEvents))); }).pipe( Effect.catchCause((cause) => Effect.flatMap( From a7bc5e7a4fbf1acd77c2e4cedbae3e1064980b38 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:43:46 +0200 Subject: [PATCH 7/9] fix(terminal): release stalled subscribers and preserve startup errors --- apps/server/src/terminal/AttachStream.test.ts | 62 +++++++++++++++++++ apps/server/src/terminal/AttachStream.ts | 56 +++++++++++++---- apps/server/src/terminal/Manager.test.ts | 32 ++++++++++ apps/server/src/terminal/Manager.ts | 14 ++++- packages/contracts/src/terminal.test.ts | 13 ++++ packages/contracts/src/terminal.ts | 13 ++++ 6 files changed, 176 insertions(+), 14 deletions(-) diff --git a/apps/server/src/terminal/AttachStream.test.ts b/apps/server/src/terminal/AttachStream.test.ts index 1c096cbe6daf..5679e1e96739 100644 --- a/apps/server/src/terminal/AttachStream.test.ts +++ b/apps/server/src/terminal/AttachStream.test.ts @@ -6,9 +6,11 @@ import { type TerminalAttachStreamEvent, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as TerminalManager from "./Manager.ts"; import * as Layer from "effect/Layer"; @@ -234,3 +236,63 @@ it.effect("interrupts an attach that is still replaying when the consumer discon expect(replayInterrupted).toBe(true); }), ); + +it.effect.each(["replay", "live"] as const)( + "disconnects a stalled %s consumer and releases its subscription", + (phase) => + Effect.gen(function* () { + const subscribed = + yield* Deferred.make< + Parameters[1] + >(); + const consumerStarted = yield* Deferred.make(); + const resumeConsumer = yield* Deferred.make(); + const detached = yield* Deferred.make(); + const clock = yield* Clock.clockWith(Effect.succeed); + const consumer = yield* terminalAttachStream(input).pipe( + Stream.provideService(Clock.Clock, clock), + Stream.provide( + Layer.mock(TerminalManager.TerminalManager)({ + attachStream: (_input, listener) => + Effect.gen(function* () { + yield* Deferred.succeed(subscribed, listener); + if (phase === "replay") { + for (let index = 0; index < 100; index += 1) { + yield* listener({ type: "output", ...target, data: "history" }, "replay"); + } + } + return () => { + Deferred.doneUnsafe(detached, Effect.void); + }; + }).pipe(Effect.onInterrupt(() => Deferred.succeed(detached, undefined))), + }), + ), + Stream.runForEach(() => + Deferred.succeed(consumerStarted, undefined).pipe( + Effect.andThen(Deferred.await(resumeConsumer)), + ), + ), + Effect.flip, + Effect.forkChild, + ); + const publish = yield* Deferred.await(subscribed); + if (phase === "live") yield* publish({ type: "replay-complete", ...target }, "replay"); + yield* Deferred.await(consumerStarted); + const producer = + phase === "live" + ? yield* Effect.gen(function* () { + for (let index = 0; index < 33; index += 1) { + yield* publish({ type: "output", ...target, data: "live" }, "live"); + } + }).pipe(Effect.forkChild({ startImmediately: true })) + : null; + yield* TestClock.adjust("30 seconds"); + yield* Deferred.await(detached); + if (producer) yield* Fiber.join(producer); + yield* Deferred.succeed(resumeConsumer, undefined); + expect(yield* Fiber.join(consumer)).toMatchObject({ + _tag: "TerminalAttachTimeoutError", + ...target, + }); + }), +); diff --git a/apps/server/src/terminal/AttachStream.ts b/apps/server/src/terminal/AttachStream.ts index 43efe41d9a7e..006f40297ff2 100644 --- a/apps/server/src/terminal/AttachStream.ts +++ b/apps/server/src/terminal/AttachStream.ts @@ -1,27 +1,59 @@ -import type { - TerminalAttachInput, - TerminalAttachStreamEvent, - TerminalError, +import { + TerminalAttachTimeoutError, + type TerminalAttachInput, + type TerminalAttachStreamEvent, + type TerminalError, } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as TerminalManager from "./Manager.ts"; -/** Backpressure preserves output and replay boundaries, including the final bytes before close. */ +/** Preserve ordered output under backpressure; disconnect consumers that stop draining. */ export function terminalAttachStream(input: TerminalAttachInput) { return Stream.callback( (queue) => Effect.gen(function* () { const manager = yield* TerminalManager.TerminalManager; - return yield* Effect.acquireRelease( - manager - .attachStream(input, (event) => - Queue.offer(queue, event).pipe(Effect.asVoid, Effect.ignore), - ) - .pipe(Effect.interruptible), - (unsubscribe) => Effect.sync(unsubscribe), + const stalled = yield* Deferred.make(); + const deliver = (event: TerminalAttachStreamEvent) => + Effect.suspend(() => { + if (Queue.offerUnsafe(queue, event)) return Effect.void; + return Queue.offer(queue, event).pipe( + Effect.timeoutOption("30 seconds"), + Effect.flatMap((offered) => { + if (Option.isSome(offered)) return Effect.void; + return Effect.gen(function* () { + yield* Queue.fail( + queue, + new TerminalAttachTimeoutError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ); + // The timed-out offer is already interrupted. Closing its + // queue must not race the timeout and cancel this cleanup. + yield* Queue.shutdown(queue); + yield* Deferred.succeed(stalled, undefined); + }); + }), + ); + }); + return yield* Effect.gen(function* () { + yield* Effect.acquireRelease( + manager.attachStream(input, deliver).pipe(Effect.interruptible), + (unsubscribe) => Effect.sync(unsubscribe), + ); + return yield* Effect.never; + }).pipe( + Effect.scoped, + Effect.raceFirst(Deferred.await(stalled)), + Effect.catchCause((cause) => + Queue.failCause(queue, cause).pipe(Effect.andThen(Queue.shutdown(queue))), + ), ); }), { bufferSize: 32, strategy: "suspend" }, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index d126be4b41cf..7de07b6400d8 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -3147,6 +3147,38 @@ it.layer( }), ); + it.effect("delivers a startup failure message after the attach snapshot", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager({ + shellResolver: () => "/bin/sh", + env: {}, + }); + ptyAdapter.spawnFailures.push( + ...Array.from({ length: 10 }, () => new Error("spawn unavailable")), + ); + const received: TerminalAttachStreamEvent[] = []; + const stop = yield* manager.attachStream( + { ...openInput(), replayBytes: DEFAULT_TERMINAL_REPLAY_BYTES }, + (event) => + Effect.sync(() => { + received.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + const snapshotIndex = received.findIndex((event) => event.type === "snapshot"); + const errorIndex = received.findIndex((event) => event.type === "error"); + expect(received[snapshotIndex]).toMatchObject({ + type: "snapshot", + snapshot: { status: "error" }, + }); + expect(errorIndex).toBeGreaterThan(snapshotIndex); + expect(received[errorIndex]).toMatchObject({ + type: "error", + message: expect.stringContaining("Failed to spawn PTY process"), + }); + }), + ); + it.effect("streams extended persisted history before live terminal output", () => Effect.gen(function* () { const { manager, logsDir } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 2257dd227578..184411de792f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -186,7 +186,7 @@ export class TerminalManager extends Context.Service< ) => Effect.Effect, ) => Effect.Effect<() => void, TerminalError>; - /** Read the current bounded snapshot for a slow-subscriber resync. */ + /** Read the current bounded terminal snapshot. */ readonly readSnapshot: ( input: TerminalClearInput, ) => Effect.Effect>; @@ -3297,6 +3297,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT, ); let capturedSnapshot = false; + let bootstrapError: Extract | null = null; let deliverLive = false; return yield* Effect.gen(function* () { // Old clients decode the attach stream against a union without the @@ -3308,7 +3309,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return Effect.void; } - if (!capturedSnapshot) return Effect.void; + if (!capturedSnapshot) { + // The snapshot carries status but no startup failure message. + if (event.type === "error") bootstrapError = event; + return Effect.void; + } if (!deliverLive) return Queue.offer(bufferedEvents, event).pipe(Effect.asVoid); const attachEvent = terminalEventToAttachEvent(event); @@ -3341,6 +3346,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func "replay", ); + if (bootstrap.snapshot.status === "error" && bootstrapError !== null) { + yield* listener(bootstrapError, "replay"); + } + bootstrapError = null; + if (bootstrap.replayHistory !== null && bootstrap.replayHistory.length > 0) { for (const { data } of splitStringByUtf8Bytes( bootstrap.replayHistory, diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index c80309b996e4..ee319ffbb74f 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -7,6 +7,7 @@ import { EXTENDED_TERMINAL_REPLAY_BYTES, MAX_TERMINAL_REPLAY_BYTES, TerminalAttachInput, + TerminalAttachTimeoutError, TerminalAttachStreamEvent, TerminalClearInput, TerminalCloseInput, @@ -394,3 +395,15 @@ describe("TerminalEvent", () => { ).toBe(true); }); }); + +describe("TerminalAttachTimeoutError", () => { + it("round-trips a stalled subscription failure", () => { + const error = new TerminalAttachTimeoutError({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + const decoded = decodeTerminalError(encodeTerminalError(error)); + expect(decoded).toEqual(error); + expect(decoded.message).toBe("Terminal output subscription stalled. Reattach to resume."); + }); +}); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 43d8d6fca799..6afd68c4a230 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -394,7 +394,20 @@ export class TerminalResizeError extends Schema.TaggedError } } +export class TerminalAttachTimeoutError extends Schema.TaggedError()( + "TerminalAttachTimeoutError", + { + threadId: Schema.String, + terminalId: Schema.String, + }, +) { + override get message() { + return "Terminal output subscription stalled. Reattach to resume."; + } +} + export const TerminalError = Schema.Union([ + TerminalAttachTimeoutError, TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, From b09142c8ee13994f2168133a5f87e1559ffcec3d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:02:46 +0200 Subject: [PATCH 8/9] fix(terminal): retain launch errors and compacted terminal modes --- apps/server/src/terminal/Manager.test.ts | 102 ++++++++++++++++------- apps/server/src/terminal/Manager.ts | 54 +++++++----- 2 files changed, 106 insertions(+), 50 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 7de07b6400d8..fd86b7f2d564 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1445,6 +1445,38 @@ it.layer( }), ); + it.effect("restores DEC modes from compacted history in a fresh manager", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir } = yield* createManager({ + historyTargetBytes: 128, + historyMaxBytes: 256, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + const compacted = yield* Deferred.make(); + const stop = yield* manager.subscribe((event) => + event.type === "output" && event.data.endsWith("frame-one\r") + ? Deferred.succeed(compacted, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + const modes = "\u001b[?1049h\u001b[?25l\u001b[?1002h"; + process.emitData(modes + "x".repeat(512) + "\rframe-one\r"); + yield* Deferred.await(compacted); + process.emitData("frame-two\r"); + yield* manager.close({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); + const persisted = yield* historyLogPath(logsDir).pipe(Effect.flatMap(readFileString)); + const restored = yield* createManager(); + yield* historyLogPath(restored.logsDir).pipe( + Effect.flatMap((filePath) => writeFileString(filePath, persisted)), + ); + const reopened = yield* restored.manager.open(openInput()); + expect(reopened.history).toBe( + modes + "frame-one\rframe-two\r\u001b[?1049l\u001b[?25h\u001b[?1002l\r\n", + ); + }), + ); + it.effect("keeps durable history larger than snapshots sent to clients", () => Effect.gen(function* () { const { manager, logsDir } = yield* createManager({ @@ -3147,36 +3179,50 @@ it.layer( }), ); - it.effect("delivers a startup failure message after the attach snapshot", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager({ - shellResolver: () => "/bin/sh", - env: {}, - }); - ptyAdapter.spawnFailures.push( - ...Array.from({ length: 10 }, () => new Error("spawn unavailable")), - ); - const received: TerminalAttachStreamEvent[] = []; - const stop = yield* manager.attachStream( - { ...openInput(), replayBytes: DEFAULT_TERMINAL_REPLAY_BYTES }, - (event) => + it.effect.each(["during attach", "before attach"] as const)( + "delivers a startup failure message when startup fails %s", + (timing) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager({ + shellResolver: () => "/bin/sh", + env: {}, + }); + ptyAdapter.spawnFailures.push( + ...Array.from({ length: 10 }, () => new Error("spawn unavailable")), + ); + if (timing === "before attach") yield* manager.open(openInput()); + const received: TerminalAttachStreamEvent[] = []; + const stop = yield* manager.attachStream( + { ...openInput(), replayBytes: DEFAULT_TERMINAL_REPLAY_BYTES }, + (event) => + Effect.sync(() => { + received.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + const snapshotIndex = received.findIndex((event) => event.type === "snapshot"); + const errorIndex = received.findIndex((event) => event.type === "error"); + expect(received[snapshotIndex]).toMatchObject({ + type: "snapshot", + snapshot: { status: "error" }, + }); + expect(errorIndex).toBeGreaterThan(snapshotIndex); + expect(received[errorIndex]).toMatchObject({ + type: "error", + message: expect.stringContaining("Failed to spawn PTY process"), + }); + ptyAdapter.spawnFailures.length = 0; + yield* manager.open(openInput()); + const retried: TerminalAttachStreamEvent[] = []; + const stopRetry = yield* manager.attachStream(openInput(), (event) => Effect.sync(() => { - received.push(event); + retried.push(event); }), - ); - yield* Effect.addFinalizer(() => Effect.sync(stop)); - const snapshotIndex = received.findIndex((event) => event.type === "snapshot"); - const errorIndex = received.findIndex((event) => event.type === "error"); - expect(received[snapshotIndex]).toMatchObject({ - type: "snapshot", - snapshot: { status: "error" }, - }); - expect(errorIndex).toBeGreaterThan(snapshotIndex); - expect(received[errorIndex]).toMatchObject({ - type: "error", - message: expect.stringContaining("Failed to spawn PTY process"), - }); - }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopRetry)); + expect(retried.some((event) => event.type === "error")).toBe(false); + expect(retried[0]).toMatchObject({ type: "snapshot", snapshot: { status: "running" } }); + }), ); it.effect("streams extended persisted history before live terminal output", () => diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 184411de792f..f41a4267005e 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -289,6 +289,7 @@ export interface TerminalSessionState { cwd: string; worktreePath: string | null; status: TerminalSessionStatus; + startupError: string | null; pid: number | null; history: string; historyBytes: number; @@ -299,8 +300,6 @@ export interface TerminalSessionState { trackedDecModes: Map; /** Mode state at the first byte of `history`, advanced as caps drop its prefix. */ historyStartDecModes: Map; - /** Mode state at the first byte of `persistenceHistory`. */ - persistenceStartDecModes: Map; pendingOutputHighSurrogate: string; pendingProcessEvents: Array; pendingProcessEventIndex: number; @@ -1784,9 +1783,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const capped = capHistoryByBytes(nextHistory, historyTargetBytes); - advanceDecModesPastDroppedPrefix(session.persistenceStartDecModes, nextHistory, capped); - session.persistenceHistory = capped; - session.persistenceHistoryBytes = Buffer.byteLength(capped); + const startModes = new Map(); + advanceDecModesPastDroppedPrefix(startModes, nextHistory, capped); + // Keep durable history self-contained so a manager restart replays the + // same modes as an attach before restart, including append recovery. + session.persistenceHistory = `${decModeReplayPrefix(startModes)}${capped}`; + session.persistenceHistoryBytes = Buffer.byteLength(session.persistenceHistory); return { visibleText: sanitized.visibleText, write: { contents: session.persistenceHistory, mode: "truncate" }, @@ -2699,6 +2701,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.process = ptyProcess; session.pid = processPid; session.status = "running"; + session.startupError = null; session.unsubscribeData = unsubscribeData; session.unsubscribeExit = unsubscribeExit; eventStamp = advanceEventSequence(session); @@ -2730,6 +2733,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* modifyManagerState((state) => { cleanupProcessHandles(session); session.status = "error"; + session.startupError = error.message; session.pid = null; session.process = null; session.hasRunningSubprocess = false; @@ -2999,6 +3003,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cwd: input.cwd, worktreePath: input.worktreePath ?? null, status: "starting", + startupError: null, pid: null, history, historyBytes: Buffer.byteLength(history), @@ -3007,7 +3012,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func pendingHistoryControlSequence: "", trackedDecModes: new Map(), historyStartDecModes, - persistenceStartDecModes: new Map(), pendingOutputHighSurrogate: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, @@ -3077,7 +3081,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.persistenceHistory = ""; liveSession.persistenceHistoryBytes = 0; liveSession.historyStartDecModes = new Map(); - liveSession.persistenceStartDecModes = new Map(); liveSession.pendingHistoryControlSequence = ""; liveSession.pendingOutputHighSurrogate = ""; resetPendingProcessQueue(liveSession); @@ -3090,7 +3093,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.persistenceHistory = ""; liveSession.persistenceHistoryBytes = 0; liveSession.historyStartDecModes = new Map(); - liveSession.persistenceStartDecModes = new Map(); liveSession.pendingHistoryControlSequence = ""; liveSession.pendingOutputHighSurrogate = ""; resetPendingProcessQueue(liveSession); @@ -3191,14 +3193,18 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const bootstrap = (() => { const requestedReplayBytes = input.replayBytes ?? DEFAULT_TERMINAL_REPLAY_BYTES; if (requestedReplayBytes <= DEFAULT_TERMINAL_REPLAY_BYTES) { - return { snapshot: initialSnapshot, replayHistory: null } as const; + return { + snapshot: initialSnapshot, + replayHistory: null, + startupError: session.startupError, + } as const; } const replayHistory = session.persistenceHistoryBytes > requestedReplayBytes ? capHistoryByBytes(session.persistenceHistory, requestedReplayBytes) : session.persistenceHistory; - const replayStartDecModes = new Map(session.persistenceStartDecModes); + const replayStartDecModes = new Map(); advanceDecModesPastDroppedPrefix( replayStartDecModes, session.persistenceHistory, @@ -3206,6 +3212,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); return { snapshot: { ...initialSnapshot, history: "" }, + startupError: session.startupError, replayHistory: `${decModeReplayPrefix(replayStartDecModes)}${replayHistory}`, } as const; })(); @@ -3297,7 +3304,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func DEFAULT_ATTACH_BUFFERED_EVENT_LIMIT, ); let capturedSnapshot = false; - let bootstrapError: Extract | null = null; let deliverLive = false; return yield* Effect.gen(function* () { // Old clients decode the attach stream against a union without the @@ -3309,11 +3315,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return Effect.void; } - if (!capturedSnapshot) { - // The snapshot carries status but no startup failure message. - if (event.type === "error") bootstrapError = event; - return Effect.void; - } + if (!capturedSnapshot) return Effect.void; if (!deliverLive) return Queue.offer(bufferedEvents, event).pipe(Effect.asVoid); const attachEvent = terminalEventToAttachEvent(event); @@ -3346,10 +3348,20 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func "replay", ); - if (bootstrap.snapshot.status === "error" && bootstrapError !== null) { - yield* listener(bootstrapError, "replay"); + if (bootstrap.snapshot.status === "error" && bootstrap.startupError !== null) { + yield* listener( + { + type: "error", + threadId: input.threadId, + terminalId: input.terminalId, + ...(typeof bootstrap.snapshot.sequence === "number" + ? { sequence: bootstrap.snapshot.sequence } + : {}), + message: bootstrap.startupError, + }, + "replay", + ); } - bootstrapError = null; if (bootstrap.replayHistory !== null && bootstrap.replayHistory.length > 0) { for (const { data } of splitStringByUtf8Bytes( @@ -3599,7 +3611,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.persistenceHistory = ""; session.persistenceHistoryBytes = 0; session.historyStartDecModes = new Map(); - session.persistenceStartDecModes = new Map(); session.pendingHistoryControlSequence = ""; session.pendingOutputHighSurrogate = ""; session.pendingProcessEvents = []; @@ -3635,6 +3646,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cwd: input.cwd, worktreePath: input.worktreePath ?? null, status: "starting", + startupError: null, pid: null, history: "", historyBytes: 0, @@ -3643,7 +3655,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func pendingHistoryControlSequence: "", trackedDecModes: new Map(), historyStartDecModes: new Map(), - persistenceStartDecModes: new Map(), pendingOutputHighSurrogate: "", pendingProcessEvents: [], pendingProcessEventIndex: 0, @@ -3688,7 +3699,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.persistenceHistory = ""; session.persistenceHistoryBytes = 0; session.historyStartDecModes = new Map(); - session.persistenceStartDecModes = new Map(); session.pendingHistoryControlSequence = ""; session.pendingOutputHighSurrogate = ""; resetPendingProcessQueue(session); From 0e42b01a03c0257af3bfaa189d83b2137d632be6 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:55:21 -0700 Subject: [PATCH 9/9] perf(mobile): avoid materializing unused terminal history --- apps/mobile/src/state/use-terminal-session.ts | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 6be57007a60f..a5af28b9a75b 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -2,7 +2,6 @@ import { combineTerminalSessionState, EMPTY_TERMINAL_BUFFER_STATE, EMPTY_TERMINAL_SESSION_STATE, - terminalOutputText, type KnownTerminalSession, type TerminalSessionState, } from "@t3tools/client-runtime/state/terminal"; @@ -12,16 +11,10 @@ import { useMemo } from "react"; import { useEnvironmentQuery } from "./query"; import { terminalEnvironment } from "./terminal"; -type LegacyTerminalSessionState = TerminalSessionState & { readonly buffer: string }; -const EMPTY_LEGACY_TERMINAL_SESSION_STATE: LegacyTerminalSessionState = { - ...EMPTY_TERMINAL_SESSION_STATE, - buffer: "", -}; - export function useAttachedTerminalSession(input: { readonly environmentId: EnvironmentId | null; readonly terminal: TerminalAttachInput | null; -}): LegacyTerminalSessionState { +}): TerminalSessionState { const attach = useEnvironmentQuery( input.environmentId !== null && input.terminal !== null ? terminalEnvironment.attach({ @@ -38,14 +31,9 @@ export function useAttachedTerminalSession(input: { input: null, }), ); - const output = attach.data?.output ?? EMPTY_TERMINAL_BUFFER_STATE.output; - // Installed native binaries still accept initialBuffer. Keep materialization - // at this mobile boundary until the native streaming API is released. - const buffer = useMemo(() => terminalOutputText(output), [output]); - return useMemo(() => { if (input.environmentId === null || input.terminal === null) { - return EMPTY_LEGACY_TERMINAL_SESSION_STATE; + return EMPTY_TERMINAL_SESSION_STATE; } const summary = metadata.data?.find( @@ -53,12 +41,9 @@ export function useAttachedTerminalSession(input: { terminal.threadId === input.terminal?.threadId && terminal.terminalId === input.terminal?.terminalId, ) ?? null; - const state = { - ...combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE), - buffer, - }; + const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; - }, [attach.data, attach.error, buffer, input.environmentId, input.terminal, metadata.data]); + }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); } export function useKnownTerminalSessions(input: {