Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
68bff16
perf(terminal): stream output and history
StiensWout Aug 28, 2026
a58bee9
fix(terminal): harden streamed replay transitions
StiensWout Aug 28, 2026
d50b09a
fix(terminal): close replay edge cases
StiensWout Aug 28, 2026
dcd910f
fix(mobile): satisfy terminal native lint
StiensWout Aug 28, 2026
d37d58a
fix(web): preserve terminal during history replay
StiensWout Aug 28, 2026
2267e7e
fix(web): keep streamed replay ordered
StiensWout Aug 28, 2026
4413c45
fix(web): replay history after reconnect
StiensWout Aug 28, 2026
d572193
fix(terminal): mark replay boundaries explicitly
StiensWout Aug 28, 2026
4703be6
fix(terminal): keep replay markers status-neutral
StiensWout Aug 28, 2026
7f311ce
fix(terminal): render synchronized output atomically
StiensWout Aug 31, 2026
60d6508
fix(terminal): harden shutdown and native buffering
StiensWout Aug 31, 2026
e2dc37d
fix(mobile): satisfy terminal native lint
StiensWout Aug 31, 2026
a9d50c6
fix(web): stabilize terminal app rendering and input
StiensWout Aug 31, 2026
b9c1353
fix(web): keep terminal streaming bounded
StiensWout Aug 31, 2026
9590d32
fix(terminal): bound output backlogs
StiensWout Aug 31, 2026
6fbd750
fix(server): keep terminal output resume in the drain loop
StiensWout Aug 31, 2026
a6d1c87
fix(web): isolate full-screen terminal themes
StiensWout Aug 31, 2026
40dfb46
fix(mobile): preserve terminal replay boundaries
StiensWout Aug 31, 2026
5370cfc
fix(web): preserve inverse terminal colors
StiensWout Aug 31, 2026
20b7187
fix(web): finish terminal interaction edge cases
StiensWout Aug 31, 2026
f0e32ab
fix(web): gate terminal resize paints
StiensWout Aug 31, 2026
12fe174
fix(web): keep terminal resize frames visible
StiensWout Aug 31, 2026
a08dae3
fix(web): align terminal interaction geometry
StiensWout Aug 31, 2026
3a3e9b6
fix(web): harden terminal interaction details
StiensWout Aug 31, 2026
f459a54
fix(web): preserve terminal replay boundaries
StiensWout Aug 31, 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 @@ -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,
Expand Down Expand Up @@ -205,7 +208,7 @@ Java_expo_modules_t3terminal_GhosttyBridge_nativeCreate(
GhosttyTerminalOptions options = {
.cols = static_cast<uint16_t>(std::clamp(cols, 1, 65535)),
.rows = static_cast<uint16_t>(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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,26 @@ 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)
private val onInput by EventDispatcher()
private val onResize by EventDispatcher()
private var terminalHandle = 0L
private var fedBuffer = ""
private var bufferedOutput = ""
private val pendingRemoteData = ArrayDeque<PendingRemoteData>()
private var pendingRemoteDataBytes = 0
private var cols = 0
private var rows = 0
private var clearingInput = false
Expand All @@ -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) {
Expand All @@ -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()
Comment thread
StiensWout marked this conversation as resolved.
}

var fontSize: Float = 10f
set(value) {
field = value
Expand Down Expand Up @@ -339,32 +384,72 @@ 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)
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
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(
GhosttyBridge.nativeSnapshot(terminalHandle)
)?.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)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
}
}
Loading
Loading