Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2c43d31
Decouple iOS primary terminal scroll
lawrencecchen Jun 14, 2026
eee05fb
Configure iOS dev API base URL
lawrencecchen Jun 14, 2026
25eb92e
Fix iOS native auth LAN callback
lawrencecchen Jun 14, 2026
2438ab6
Fix native auth route tests
lawrencecchen Jun 14, 2026
171d624
Add iOS science demo bypass
lawrencecchen Jun 14, 2026
8e4636f
Improve iOS science demo scrollback
lawrencecchen Jun 14, 2026
a8b2ead
Update Ghostty pixel scroll renderer
lawrencecchen Jun 14, 2026
a1697cc
Enable desktop smooth scroll path
lawrencecchen Jun 14, 2026
c156503
Use wheel events for desktop smooth scroll
lawrencecchen Jun 14, 2026
f82bcbf
Use fractional row offsets for desktop smooth scroll
lawrencecchen Jun 14, 2026
aff8198
Add debug fractional scroll verification hook
lawrencecchen Jun 14, 2026
b6e9f97
Fix smooth scroll fractional direction
lawrencecchen Jun 14, 2026
f73e2c3
Preload Ghostty rows for smooth scroll edges
lawrencecchen Jun 15, 2026
415b28b
Add iOS instant terminal scroll mode
lawrencecchen Jun 15, 2026
c617afa
Hydrate iOS terminal replay with full scrollback
lawrencecchen Jun 15, 2026
d0797e3
Fix pixel scroll boundary clamp
lawrencecchen Jun 16, 2026
ed18dda
Retry mobile replay after workspace mapping
lawrencecchen Jun 16, 2026
6b54974
Fix mobile dogfood attach during session restore
lawrencecchen Jun 16, 2026
a7c2a20
Merge remote-tracking branch 'origin/main' into feat-ios-smooth-scrol…
lawrencecchen Jun 16, 2026
e05ca47
Fix iOS local scrollback offset control
lawrencecchen Jun 16, 2026
c3b8822
Gate iOS replay on terminal geometry
lawrencecchen Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ extension ControlCommandCoordinator {
return debugSimulateTerminalFileDrop(request.params)
case "debug.terminal.read_text":
return debugReadTerminalText(request.params)
case "debug.terminal.scroll_to_row_offset":
return debugTerminalScrollToRowOffset(request.params)
case "debug.terminal.render_stats":
return debugRenderStats(request.params)
case "debug.layout":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,28 @@ extension ControlCommandCoordinator {
return .ok(.object(["base64": .string(b64)]))
}

/// `debug.terminal.scroll_to_row_offset` — drive Ghostty's fractional
/// row-offset scroll API for renderer verification.
func debugTerminalScrollToRowOffset(_ params: [String: JSONValue]) -> ControlCallResult {
let surfaceArg = string(params, "surface_id") ?? ""
guard let rowOffset = double(params, "row_offset"), rowOffset.isFinite else {
return .err(code: "invalid_params", message: "Missing or invalid row_offset", data: nil)
}
let didScroll = debugContext?.controlDebugTerminalScrollToRowOffset(
surfaceArgument: surfaceArg,
rowOffset: rowOffset
) ?? false
guard didScroll else {
return .err(code: "not_found", message: "Terminal surface not found", data: .object([
"surface_id": .string(surfaceArg)
]))
}
return .ok(.object([
"surface_id": .string(surfaceArg),
"row_offset": .double(rowOffset)
]))
}

/// `debug.terminal.render_stats` — renderer stats (shared v1 body's JSON,
/// decoded exactly as the legacy wrapper did).
func debugRenderStats(_ params: [String: JSONValue]) -> ControlCallResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ public protocol ControlDebugContext: AnyObject {
/// - Returns: The raw v1 response (`"OK <base64>"` or an `ERROR:` line).
func controlDebugReadTerminalText(surfaceArgument: String) -> String

/// Drives a terminal's Ghostty scrollback to a fractional row offset for
/// renderer verification.
///
/// - Parameters:
/// - surfaceArgument: The surface id/index argument (may be empty for the
/// focused surface).
/// - rowOffset: Fractional row offset from the top of primary scrollback.
/// - Returns: `true` when the target terminal exists and has a live surface.
func controlDebugTerminalScrollToRowOffset(surfaceArgument: String, rowOffset: Double) -> Bool

/// Runs the shared v1 `render_stats` body for `debug.terminal.render_stats`.
///
/// - Parameter surfaceArgument: The surface id/index argument.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension ControlDebugContext {
func controlDebugActivateApp() -> String { "ERROR: not implemented" }
func controlDebugIsTerminalFocused(surfaceArgument: String) -> String { "ERROR: not implemented" }
func controlDebugReadTerminalText(surfaceArgument: String) -> String { "ERROR: not implemented" }
func controlDebugTerminalScrollToRowOffset(surfaceArgument: String, rowOffset: Double) -> Bool { false }
func controlDebugRenderStats(surfaceArgument: String) -> String { "ERROR: not implemented" }
func controlDebugLayout() -> String { "ERROR: not implemented" }
func controlDebugBonsplitUnderflowCount() -> String { "ERROR: not implemented" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ extension MobileShellComposite {
terminalOutputQueuesBySurfaceID[surfaceID] = queue
if let immediate {
continuation.yield(
MobileTerminalOutputChunk(data: immediate.bytes, streamToken: streamToken)
MobileTerminalOutputChunk(
data: immediate.bytes,
streamToken: streamToken,
activeScreen: immediate.activeScreen
)
)
}
}
Expand All @@ -45,6 +49,10 @@ extension MobileShellComposite {
terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken else {
return
}
continuation.yield(MobileTerminalOutputChunk(data: next.bytes, streamToken: streamToken))
continuation.yield(MobileTerminalOutputChunk(
data: next.bytes,
streamToken: streamToken,
activeScreen: next.activeScreen
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ struct TerminalOutputDelivery: Equatable, Sendable {
frame.vtPatchBytes()
}
}

var activeScreen: MobileTerminalRenderGridFrame.Screen? {
switch payload {
case .bytes:
nil
case .renderGrid(let frame):
frame.activeScreen
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

/// Backpressure queue for one mounted mobile terminal output stream.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ import Testing
#expect(!vt.contains("old"))
}

@Test func terminalOutputDeliveryCarriesRenderGridActiveScreen() throws {
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal",
stateSeq: 1,
columns: 12,
rows: 2,
rowSpans: [],
activeScreen: .alternate
)
let delivery = TerminalOutputDelivery(renderGrid: frame, replaceable: false)
let rawDelivery = TerminalOutputDelivery(bytes: Data("raw".utf8), replaceable: false)

#expect(delivery.activeScreen == .alternate)
#expect(rawDelivery.activeScreen == nil)
}

@Test func terminalOutputQueuePreservesNonreplaceableBarriers() {
var queue = TerminalOutputDeliveryQueue()
let inFlight = TerminalOutputDelivery(bytes: Data("in-flight".utf8), replaceable: false)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
public import CMUXMobileCore
public import Foundation

/// A seam exposing per-surface terminal output as an `AsyncStream`.
Expand All @@ -14,10 +15,18 @@ public import Foundation
/// propagation is a structured, cancellable `AsyncSequence` instead of a stored
/// callback.
public struct MobileTerminalOutputChunk: Sendable {
/// The active terminal screen captured by the render-grid frame that
/// produced ``data``. Raw byte fallback chunks carry `nil`.
public let activeScreen: MobileTerminalRenderGridFrame.Screen?
public let data: Data
public let streamToken: UUID

public init(data: Data, streamToken: UUID) {
public init(
data: Data,
streamToken: UUID,
activeScreen: MobileTerminalRenderGridFrame.Screen? = nil
) {
self.activeScreen = activeScreen
self.data = data
self.streamToken = streamToken
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
for await chunk in store.terminalOutputStream(surfaceID: surfaceID) {
guard !Task.isCancelled else { return }
guard let surfaceView else { return }
surfaceView.applyTerminalOutputMetadata(activeScreen: chunk.activeScreen)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply screen metadata after processing output

When a render-grid chunk switches screens, this updates activeScreen before the VT patch has been applied to the local Ghostty surface. While processOutputAndWait is still queued/running, a user scroll can hit the old local surface state but the new forwarding policy; for example an exit-from-alt-screen chunk marks the view primary, the still-alt local scroll drops the wheel bytes, and the guard suppresses the host scroll, so the gesture is lost. Apply the metadata only after the chunk has been processed, or make the local scroll/forward decision use the same committed screen state.

Useful? React with 👍 / 👎.

await surfaceView.processOutputAndWait(chunk.data)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Metadata update races VT bytes during processOutputAndWait suspension

applyTerminalOutputMetadata commits the new activeScreen (e.g., .primary) before the await processOutputAndWait suspension returns. Because the @MainActor is free to interleave work during that suspension, a display-link scroll flush can fire in the window where activeScreen already says .primary but local Ghostty is still rendering the alternate surface. In that state flushPendingScrollIfNeeded calls applyLocalScrollbackScroll on what is still an alternate-screen surface, while simultaneously suppressing host forwarding — the net effect is a scroll event that neither reaches the TUI nor lands usefully in the local scrollback. Swapping the call order so metadata is applied after processOutputAndWait closes this window.

store.terminalOutputDidProcess(
surfaceID: surfaceID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ extension GhosttySurfaceView {
/// screens libghostty turns this into mouse-wheel bytes; the mirror is
/// display-only and drops those bytes, so the authoritative Mac response
/// remains the visible update for TUIs.
func applyLocalScrollbackScroll(lines: Double, col: Int, row: Int) {
guard lines != 0, let surface else { return }
func applyLocalScrollbackScroll(pixelDeltaY: Double, col: Int, row: Int) {
guard pixelDeltaY != 0, let surface else { return }
let displayScale = window?.windowScene?.screen.scale ?? traitCollection.displayScale
let scale = max(Double(displayScale), 1)
let size = ghostty_surface_size(surface)
Expand All @@ -19,7 +19,8 @@ extension GhosttySurfaceView {
let posX = (Double(max(0, col)) + 0.5) * cellWidthPt
let posY = (Double(max(0, row)) + 0.5) * cellHeightPt
ghostty_surface_mouse_pos(surface, posX, posY, GHOSTTY_MODS_NONE)
ghostty_surface_mouse_scroll(surface, 0, lines, 0)
let precisionScrollMods: Int32 = 0b0000_0001
ghostty_surface_mouse_scroll(surface, 0, pixelDeltaY, precisionScrollMods)
drawForWakeup()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,6 @@ import UIKit

private let log = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.surface")

// lint:allow namespace-enum — file-local DEBUG input-trace logger on the off-limits typing-latency render path; type reshape deferred to the GhosttySurfaceView UI-god-object split wave.
enum TerminalInputDebugLog {
private static let isEnabled = ProcessInfo.processInfo.environment["CMUX_INPUT_DEBUG"] == "1"
private static let logger = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.input")

static func log(_ message: String) {
#if DEBUG
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
return
}
#endif
guard isEnabled else { return }
logger.debug("input: \(message, privacy: .public)")
}

static func textSummary(_ text: String) -> String {
let summary = String(reflecting: text)
guard summary.count > 96 else { return summary }
return "\(summary.prefix(96))..."
}

static func dataSummary(_ data: Data) -> String {
let prefix = data.prefix(32)
let prefixData = Data(prefix)
let hex = prefix.map { String(format: "%02X", $0) }.joined(separator: " ")
let utf8 = String(data: prefixData, encoding: .utf8) ?? "<non-utf8>"
let suffix = data.count > prefix.count ? " ..." : ""
return "len=\(data.count) hex=\(hex)\(suffix) utf8=\(textSummary(utf8))"
}
}

@MainActor
public protocol GhosttySurfaceViewDelegate: AnyObject {
func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didProduceInput data: Data)
Expand Down Expand Up @@ -622,6 +591,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
private var lastAppliedContentScale: CGFloat = 0
private var surfaceHasReceivedOutput: Bool = false
private var shouldScrollInitialOutputToBottom = true
private var activeScreen: MobileTerminalRenderGridFrame.Screen = .primary

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale activeScreen survives stream tear-down

activeScreen is never reset when the output stream is detached and reattached (e.g., after a network drop or background transition). If the previous session ended while the terminal was in .alternate mode, the property stays .alternate until the first render-grid frame arrives in the new stream. During that window every primary-screen scroll gesture is forwarded to the host instead of staying local. Consider resetting activeScreen to .primary in the view's stream-teardown path, or alternatively applying the reset at the start of each new stream token before yielding the first chunk.

private let scrollForwardingPolicy = MobileTerminalScrollForwardingPolicy()
/// Serial background queue for `ghostty_surface_process_output`, which
Comment on lines +605 to 611

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle unknown screen metadata as “forward” to preserve raw-byte fallback behavior.

Line 625 initializes activeScreen to .primary, and Line 2127 drops nil metadata. For raw-byte fallback chunks (where metadata is nil), Line 1816 always treats scroll as primary and suppresses host forwarding, which breaks alternate-screen TUI wheel events on legacy-host compatibility paths.

💡 Suggested fix
-    private var activeScreen: MobileTerminalRenderGridFrame.Screen = .primary
+    private var activeScreen: MobileTerminalRenderGridFrame.Screen?
@@
-        guard scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) else {
-            return
-        }
+        if let activeScreen,
+           !scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) {
+            return
+        }
@@
     public func applyTerminalOutputMetadata(
         activeScreen: MobileTerminalRenderGridFrame.Screen?
     ) {
         guard let activeScreen else { return }
         self.activeScreen = activeScreen
     }

Also applies to: 1816-1819, 2124-2129

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`
around lines 625 - 627, The code currently defaults to treating unknown screen
metadata (when nil) as primary screen and suppressing host forwarding, breaking
alternate-screen TUI wheel events on legacy hosts. Fix this by modifying the
scroll handling logic to forward scroll events when screen metadata is unknown
instead of suppressing them. At the activeScreen initialization site (lines
625-627), ensure the default handling accommodates nil metadata. At the scroll
processing logic (lines 1816-1819), modify the condition to forward scroll
events when metadata is nil rather than assuming primary screen behavior. At the
metadata handling site (lines 2124-2129), ensure nil metadata is preserved
through the pipeline rather than dropped so downstream logic can apply
forward-fallback behavior.

/// blocks on libghostty's internal renderer/IO futex. Running it on the
/// main thread hangs the app until the scene-update watchdog kills it.
Expand Down Expand Up @@ -1784,14 +1755,17 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
// deceleration, and momentum. The Mac still owns terminal semantics:
// normal-screen scrollback and alt-screen mouse-wheel delivery.
guard deltaY != 0 else { return }
let scale = max(preferredScreenScale, 1)
let cellHeightPt = cellPixelSize.height / max(preferredScreenScale, 1)
let divisor = cellHeightPt > 1 ? Double(cellHeightPt) * 3 : 42
pendingScrollLines += -Double(deltaY) / divisor
pendingLocalScrollPixels += -Double(deltaY) * Double(scale)
pendingScrollCell = scrollCell(at: touchPoint)
}

/// Coalesced native scroll forwarded to the Mac once per display-link frame.
private var pendingScrollLines: Double = 0
private var pendingLocalScrollPixels: Double = 0
private var pendingScrollCell: (col: Int, row: Int) = (0, 0)

/// Map a touch point to a grid cell (shared effective grid with the Mac), so
Expand All @@ -1806,11 +1780,17 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
}

private func flushPendingScrollIfNeeded() {
guard pendingScrollLines != 0 else { return }
guard pendingScrollLines != 0 || pendingLocalScrollPixels != 0 else { return }
let lines = pendingScrollLines
let pixelDeltaY = pendingLocalScrollPixels
let cell = pendingScrollCell
pendingScrollLines = 0
applyLocalScrollbackScroll(lines: lines, col: cell.col, row: cell.row)
pendingLocalScrollPixels = 0
applyLocalScrollbackScroll(pixelDeltaY: pixelDeltaY, col: cell.col, row: cell.row)
guard scrollForwardingPolicy.shouldForwardToHost(activeScreen: activeScreen) else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve alternate-screen state before gating scrolls

Here activeScreen is not authoritative for all chunks yet: raw-byte fallback events carry nil so the view remains at its default .primary, and render-grid cursor-only/no-row-change deltas are built in MobileTerminalRenderObserver.emitRenderGrid without snapshot.frame.activeScreen, so they encode .primary even while a TUI is still on the alternate screen. In both cases an alternate-screen app such as vim/less/htop stops receiving wheel events after that chunk because this returns before didScrollLines; keep forwarding for unknown state and preserve the screen on every delta before using it to suppress host scrolls.

Useful? React with 👍 / 👎.

return
}
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve scrollback prefetch for primary-screen scrolls

When activeScreen is .primary, this new guard returns before the delegate call, so GhosttySurfaceRepresentable never invokes store.scrollTerminal(...). That RPC path is also the only place that primes and refreshes the larger max_scrollback_rows render-grid prefetch, so after a user scrolls beyond the cold-attach local scrollback window the phone has no way to fetch older primary-screen history and scrollback stops short even though the Mac still has it.

Useful? React with 👍 / 👎.

guard lines != 0 else { return }
delegate?.ghosttySurfaceView(self, didScrollLines: lines, atCol: cell.col, row: cell.row)
}

Expand Down Expand Up @@ -2109,6 +2089,20 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
processOutput(data, completion: nil)
}

/// Applies metadata attached to the next terminal output chunk.
///
/// Render-grid output carries the authoritative active screen, which lets
/// local scrollback stay phone-local on the primary screen while alternate
/// screen TUIs still receive host mouse-wheel events.
/// - Parameter activeScreen: The active screen from the render-grid frame,
/// or `nil` for raw byte fallback chunks.
public func applyTerminalOutputMetadata(
activeScreen: MobileTerminalRenderGridFrame.Screen?
) {
guard let activeScreen else { return }
self.activeScreen = activeScreen
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

/// Process terminal output and return after the output has been applied.
///
/// The call still performs libghostty output processing on the serial
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#if canImport(UIKit)
import Foundation
import OSLog

// lint:allow namespace-enum — DEBUG input-trace logger on the off-limits typing-latency render path; type reshape deferred to the GhosttySurfaceView UI-god-object split wave.
enum TerminalInputDebugLog {
private static let isEnabled = ProcessInfo.processInfo.environment["CMUX_INPUT_DEBUG"] == "1"
private static let logger = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.input")

static func log(_ message: String) {
#if DEBUG
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
return
}
#endif
guard isEnabled else { return }
logger.debug("input: \(message, privacy: .public)")
Comment on lines +10 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make debug logging lazy to avoid per-keystroke overhead when disabled.

On Line 10, log(_:) takes an eager String, so callers still build textSummary(...)/dataSummary(...) before guard isEnabled runs. On the input path this adds avoidable work even when debug logging is off.

Proposed fix
-    static func log(_ message: String) {
-        `#if` DEBUG
-        if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
-            return
-        }
-        `#endif`
-        guard isEnabled else { return }
-        logger.debug("input: \(message, privacy: .public)")
-    }
+    static func log(_ message: `@autoclosure` () -> String) {
+        guard isEnabled else { return }
+        `#if` DEBUG
+        if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil {
+            return
+        }
+        `#endif`
+        logger.debug("input: \(message(), privacy: .public)")
+    }

As per coding guidelines, typing-latency-sensitive paths must avoid adding unnecessary per-event work, and this helper is used on terminal input events.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputDebugLog.swift`
around lines 10 - 17, The `log(_:)` method accepts an eager String parameter,
which forces callers to construct the message string (via `textSummary(...)` or
`dataSummary(...)`) before the function runs, adding unnecessary per-keystroke
overhead when debug logging is disabled. Change the parameter from `_ message:
String` to use a closure parameter (either `_ message: `@autoclosure` () ->
String` or `_ message: () -> String`) so the message construction is deferred
until after the `guard isEnabled else { return }` check. If using
`@autoclosure`, the callers will not need to change their call sites; if using a
regular closure, callers will need to wrap their message arguments in a closure.

Source: Coding guidelines

}

static func textSummary(_ text: String) -> String {
let summary = String(reflecting: text)
guard summary.count > 96 else { return summary }
return "\(summary.prefix(96))..."
}

static func dataSummary(_ data: Data) -> String {
let prefix = data.prefix(32)
let prefixData = Data(prefix)
let hex = prefix.map { String(format: "%02X", $0) }.joined(separator: " ")
let utf8 = String(data: prefixData, encoding: .utf8) ?? "<non-utf8>"
let suffix = data.count > prefix.count ? " ..." : ""
return "len=\(data.count) hex=\(hex)\(suffix) utf8=\(textSummary(utf8))"
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
public import CMUXMobileCore

/// Decides whether a mobile terminal scroll gesture must be sent to the Mac.
public struct MobileTerminalScrollForwardingPolicy: Sendable {
/// Creates the forwarding policy.
public init() {}

/// Returns whether a scroll should be forwarded to the host surface.
///
/// Primary-screen scrollback is already mirrored into the phone's local
/// Ghostty surface, so forwarding would make scroll feel network-bound.
/// Alternate-screen scroll must still reach the host so TUIs with mouse
/// reporting receive wheel events.
/// - Parameter activeScreen: The screen currently rendered by the mobile
/// Ghostty mirror.
/// - Returns: `true` when the scroll should be sent to the Mac.
public func shouldForwardToHost(
activeScreen: MobileTerminalRenderGridFrame.Screen
) -> Bool {
activeScreen == .alternate
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import CMUXMobileCore
import CmuxMobileTerminalKit
import Testing

@Suite struct MobileTerminalScrollForwardingPolicyTests {
@Test func primaryScreenScrollStaysLocal() {
let policy = MobileTerminalScrollForwardingPolicy()

#expect(policy.shouldForwardToHost(activeScreen: .primary) == false)
}

@Test func alternateScreenScrollForwardsToHost() {
let policy = MobileTerminalScrollForwardingPolicy()

#expect(policy.shouldForwardToHost(activeScreen: .alternate))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ extension TerminalSurface {
additionalEnvironment
}

/// Test-only helper to drive Ghostty scrollback by fractional row offset.
@MainActor
public func debugScrollToRowOffsetForTesting(_ rowOffset: Double) -> Bool {
guard let surface else { return false }
ghostty_surface_scroll_to_offset(surface, rowOffset)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the scroll-to-offset Ghostty test stub

When running the CmuxTerminal package tests in DEBUG, this new GhosttyKit call is compiled into the library, but Packages/CmuxTerminal/Tests/GhosttyRuntimeTestStubs still does not define ghostty_surface_scroll_to_offset; that stub target exists specifically to satisfy these Ghostty symbols when the package test runner is not linking the real archive. A standalone swift test --package-path Packages/CmuxTerminal with submodules initialized will therefore fail at link time with an undefined symbol until the C/header stub is added.

Useful? React with 👍 / 👎.

forceRefresh(reason: "debugScrollToRowOffsetForTesting")
return true
}

/// How many force refreshes ran since the last reset.
public func debugForceRefreshCount() -> Int {
debugForceRefreshCountLock.lock()
Expand Down
13 changes: 13 additions & 0 deletions Sources/TerminalController+ControlDebugContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ extension TerminalController: ControlDebugContext {
readTerminalText(surfaceArgument)
}

func controlDebugTerminalScrollToRowOffset(surfaceArgument: String, rowOffset: Double) -> Bool {
guard let tabManager else { return false }
let trimmed = surfaceArgument.trimmingCharacters(in: .whitespacesAndNewlines)
let panel: TerminalPanel?
if trimmed.isEmpty {
panel = tabManager.selectedTerminalPanel
} else {
panel = resolveTerminalPanel(from: trimmed, tabManager: tabManager)
}
guard let panel else { return false }
return panel.surface.debugScrollToRowOffsetForTesting(rowOffset)
}

func controlDebugRenderStats(surfaceArgument: String) -> String {
renderStats(surfaceArgument)
}
Expand Down
Loading
Loading