Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b845408
test(server): attached terminal streams learn the shared PTY size
juliusmarminge Oct 22, 2026
1213bf2
Merge remote-tracking branch 'origin/main' into fix/issue-14463
juliusmarminge Oct 22, 2026
ea47c9f
fix(terminal): attached viewers share the smallest PTY grid and draw …
juliusmarminge Oct 24, 2026
329dd82
test(server): the narrowest viewer detaching gives the PTY back
juliusmarminge Oct 24, 2026
22c399b
Merge remote-tracking branch 'origin/main' into fix/issue-14463
juliusmarminge Oct 24, 2026
d63c54a
test: add portable shared terminal evidence fixtures
juliusmarminge Oct 24, 2026
438e67d
test: use fixed RGB colors in terminal fixture
juliusmarminge Oct 24, 2026
a666d58
test: add reproducible unmasked evidence pixel diffs
juliusmarminge Oct 24, 2026
75d234b
test: keep native fixture JSX in a TSX module
juliusmarminge Oct 24, 2026
65a7679
fix(terminal): a viewer keeps its PTY size through output recovery
juliusmarminge Oct 24, 2026
2f6167c
fix(mobile): iOS terminal feeds new output after the batch's grid lands
juliusmarminge Oct 24, 2026
ee16276
fix(mobile): iOS terminal refresh no longer feeds a pending buffer early
juliusmarminge Oct 24, 2026
e977cdd
test: add same-batch native fixture scenarios for issue 14463
juliusmarminge Oct 24, 2026
c43af3a
Merge main into fix/issue-14463
juliusmarminge Oct 24, 2026
792b345
fix(mobile): iOS terminal refresh no longer leaves the old surface's …
juliusmarminge Oct 24, 2026
66b795c
fix(mobile): Android terminal feeds output after the prop batch's gri…
juliusmarminge Oct 24, 2026
5700aa6
test(server): a repeated attach keeps the viewer's resized grid
juliusmarminge Oct 24, 2026
9cb8624
fix(server): repeated terminal attaches keep the viewer's resized grid
juliusmarminge Oct 24, 2026
c4af00d
chore: remove shared terminal PR evidence fixtures
juliusmarminge Oct 28, 2026
0e02906
Merge main into fix/issue-14463
juliusmarminge Oct 28, 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 @@ -66,8 +66,16 @@ class T3TerminalModule : Module() {
Prop("captureRequest") { view: T3TerminalView, request: Double ->
view.captureRequest = request
}

Prop("sharedGrid") { view: T3TerminalView, sharedGrid: List<Int> ->
view.sharedGrid = sharedGrid
}
Events("onInput", "onResize", "onCapture")

OnViewDidUpdateProps { view: T3TerminalView ->
view.applyPendingBuffer()
}

OnViewDestroys { view: T3TerminalView ->
view.cleanup()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
private var streamedOutput: TerminalOutputBuffer? = null
private var cols = 0
private var rows = 0
private var measuredCols = 0
private var measuredRows = 0
private var clearingInput = false
private var isCleanedUp = false
private var backgroundColorValue = Color.parseColor("#24292E")
Expand All @@ -74,12 +76,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
recreateTerminal()
}

// Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch:
// output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the
// previous grid.
var initialBuffer: String = ""
set(value) {
if (field == value) return
field = value
feedPendingBuffer()
hasPendingBuffer = true
}
private var hasPendingBuffer = false

var fontSize: Float = 10f
set(value) {
Expand All @@ -91,6 +97,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex

var appearanceScheme: String = "dark"

// The PTY grid shared by every attached client, or empty before the server reports one.
// The terminal draws this grid, clamped to the view's own fit (the server's grid lags a
// local resize, and read-only observers never constrain it), so its wrapping and cursor
// match the PTY; the view's own fit still goes out through onResize.
var sharedGrid: List<Int> = emptyList()
set(value) {
if (field == value) return
field = value
applyGrid()
}

var themeConfig: String = ""
set(value) {
field = value
Expand Down Expand Up @@ -327,6 +344,19 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx)
.toInt()
.coerceIn(2, 200)
if (nextCols != measuredCols || nextRows != measuredRows) {
measuredCols = nextCols
measuredRows = nextRows
onResize(mapOf("cols" to measuredCols, "rows" to measuredRows))
}
applyGrid()
}

private fun applyGrid() {
if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return
val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 }
val nextCols = shared?.get(0)?.coerceAtMost(measuredCols) ?: measuredCols
val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows
if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return
cols = nextCols
rows = nextRows
Expand All @@ -343,8 +373,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
)
}
emitResponse(response)
onResize(mapOf("cols" to cols, "rows" to rows))
feedPendingBuffer()
if (!hasPendingBuffer) feedPendingBuffer()
renderSnapshot()
}

Expand All @@ -369,7 +398,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
if (terminalHandle == 0L) return
destroyTerminal()
createTerminal()
feedPendingBuffer()
if (!hasPendingBuffer) feedPendingBuffer()
renderSnapshot()
}

Expand All @@ -381,6 +410,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
terminalCanvas.resetSelectionState()
}

fun applyPendingBuffer() {
if (!hasPendingBuffer) return
hasPendingBuffer = false
feedPendingBuffer()
}

private fun feedPendingBuffer() {
if (streamedOutput != null || terminalHandle == 0L || initialBuffer == fedBuffer) return
if (!initialBuffer.startsWith(fedBuffer)) {
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,15 @@ public class T3TerminalModule: Module {
Prop("captureRequest") { (view: T3TerminalView, request: Double) in
view.captureRequest = request
}

Prop("sharedGrid") { (view: T3TerminalView, sharedGrid: [Int]) in
view.sharedGrid = sharedGrid
}
Events("onInput", "onResize", "onCapture")

OnViewDidUpdateProps { (view: T3TerminalView) in
view.applyPendingBuffer()
}
}
}
}
67 changes: 48 additions & 19 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,23 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
}
}

// Expo sets a batch's props in no fixed order, so the buffer waits for the whole batch:
// output fed before the same batch's sharedGrid lands would be drawn, and clipped, at the
// previous grid.
var initialBuffer: String = "" {
didSet {
applyRemoteBuffer(initialBuffer)
hasPendingBuffer = true
}
}
private var hasPendingBuffer = false

// The PTY grid shared by every attached client, or empty before the server reports one.
// The surface draws this grid so its wrapping and cursor match the PTY; the view's own
// fit still goes out through onResize.
var sharedGrid: [Int] = [] {
didSet {
guard oldValue != sharedGrid else { return }
resizeSurface()
}
}

Expand Down Expand Up @@ -529,7 +543,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
resizeSurface()
if let streamedOutput {
feedData(streamedOutput.data)
} else {
} else if !hasPendingBuffer {
feedBuffer(initialBuffer)
}
}
Expand Down Expand Up @@ -559,6 +573,19 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
}
surface = nil
app = nil
// Ghostty adds its IOSurface layer to the viewport on iOS but never removes it on
// free, leaving the last frame on screen under the next surface's layer. The
// viewport has no subviews, so every sublayer belongs to a freed surface.
CATransaction.begin()
CATransaction.setDisableActions(true)
terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
CATransaction.commit()
}

func applyPendingBuffer() {
guard hasPendingBuffer else { return }
hasPendingBuffer = false
applyRemoteBuffer(initialBuffer)
}

private func applyRemoteBuffer(_ buffer: String) {
Expand Down Expand Up @@ -647,30 +674,32 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
terminalViewport.contentScaleFactor = scale
ghostty_surface_set_content_scale(surface, Double(scale), Double(scale))
ghostty_surface_set_size(surface, width, height)
let fitted = ghostty_surface_size(surface)
let fittedCols = max(1, Int(fitted.columns))
let fittedRows = max(1, Int(fitted.rows))
// Trimming whole cells keeps ghostty's padding, and the surface can only shrink
// within the view, so a stale larger grid falls back to the fitted one.
var surfaceWidth = width
var surfaceHeight = height
if sharedGrid.count == 2, sharedGrid[0] > 0, sharedGrid[1] > 0 {
surfaceWidth -= UInt32(max(fittedCols - sharedGrid[0], 0)) * fitted.cell_width_px
surfaceHeight -= UInt32(max(fittedRows - sharedGrid[1], 0)) * fitted.cell_height_px
if surfaceWidth != width || surfaceHeight != height {
ghostty_surface_set_size(surface, surfaceWidth, surfaceHeight)
}
}
ghostty_surface_set_occlusion(surface, window != nil)
configureIOSurfaceLayers()
configureIOSurfaceLayers(
size: CGSize(width: CGFloat(surfaceWidth) / scale, height: CGFloat(surfaceHeight) / scale))
redrawSurface()
emitGhosttyResize()
emitResize(cols: fittedCols, rows: fittedRows)
}

private func redrawSurface() {
guard let surface else { return }
ghostty_surface_refresh(surface)
ghostty_surface_draw(surface)
markIOSurfaceLayersForDisplay()
emitGhosttyResize()
}

private func emitGhosttyResize() {
guard let surface else {
emitEstimatedResize()
return
}

let size = ghostty_surface_size(surface)
let cols = max(1, Int(size.columns))
let rows = max(1, Int(size.rows))
emitResize(cols: cols, rows: rows)
}

private func emitEstimatedResize() {
Expand Down Expand Up @@ -721,8 +750,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
ghostty_app_keyboard_changed(app)
}

private func configureIOSurfaceLayers() {
let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size)
private func configureIOSurfaceLayers(size: CGSize) {
let targetBounds = CGRect(origin: .zero, size: size)
CATransaction.begin()
CATransaction.setDisableActions(true)
terminalViewport.layer.sublayers?.forEach { sublayer in
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TerminalOutputState } from "@t3tools/client-runtime/state/terminal";
import { memo, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
import type { TerminalGrid, TerminalOutputState } from "@t3tools/client-runtime/state/terminal";
import { memo, useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react";
import {
Pressable,
ScrollView,
Expand Down Expand Up @@ -47,6 +47,7 @@ interface TerminalSurfaceProps extends ViewProps {
readonly buffer: string | null;
readonly output: TerminalOutputState | null;
readonly fontSize?: number;
readonly grid?: TerminalGrid | null;
readonly isRunning: boolean;
readonly readOnly?: boolean;
readonly autoFocus?: boolean;
Expand Down Expand Up @@ -204,6 +205,12 @@ const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerm
const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
const { onInput, onResize } = props;
const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();
const gridCols = props.grid?.cols;
const gridRows = props.grid?.rows;
const sharedGrid = useMemo(
() => (gridCols !== undefined && gridRows !== undefined ? [gridCols, gridRows] : []),
[gridCols, gridRows],
);
const streaming = supportsNativeTerminalOutput();
const nativeRef = useRef<NativeTerminalSurfaceHandle>(null);
const writerRef = useRef<ReturnType<typeof createTerminalOutputWriter> | null>(null);
Expand Down Expand Up @@ -260,6 +267,7 @@ const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerm
terminalKey={props.terminalKey}
initialBuffer={streaming ? "" : props.buffer}
fontSize={fontSize}
sharedGrid={sharedGrid}
style={{ flex: 1 }}
themeConfig={buildGhosttyThemeConfig(theme)}
onInput={handleNativeInput}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,7 @@ function ThreadTerminal(props: {
buffer={terminal.version === 0 ? null : terminal.buffer}
output={terminal.version === 0 ? null : terminal.output}
fontSize={fontSize}
grid={terminal.grid}
isRunning={isRunning}
keyboardFocusRequest={keyboardFocusRequest}
captureRequest={captureRequest}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/terminal/nativeTerminalModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface NativeTerminalSurfaceProps extends ViewProps {
readonly terminalKey: string;
readonly initialBuffer: string;
readonly fontSize: number;
/** `[cols, rows]` of the shared PTY, or empty before the server reports one. */
readonly sharedGrid?: ReadonlyArray<number>;
readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void;
readonly onResize?: (event: NativeSyntheticEvent<TerminalResizeEvent>) => void;
}
Expand Down
Loading
Loading