Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2138a8c
Add iOS terminal typing replay regressions
austinywang Jul 2, 2026
1d2b505
Stabilize iOS typing render-grid catch-up
austinywang Jul 2, 2026
5003f14
Fix iOS render-grid replay guard fallout
austinywang Jul 2, 2026
8246cd1
Retry render-grid replay dropped during input catch-up
austinywang Jul 2, 2026
2842814
Harden render-grid input catch-up recovery
austinywang Jul 2, 2026
1cad377
Complete synchronized render-grid replay reset
austinywang Jul 2, 2026
400ae09
Coalesce render-grid input catch-up refreshes
austinywang Jul 2, 2026
91c39dd
Clear replay barrier after pending input retry exhaustion
austinywang Jul 2, 2026
ff26df7
Reset dynamic OSC colors during render-grid replay
austinywang Jul 2, 2026
ec0af10
Require replay after dropped render-grid deltas
austinywang Jul 2, 2026
53caec9
Keep dropped render-grid marker until replay delivery
austinywang Jul 2, 2026
7cd3b15
Fix render-grid stale recovery reset edges
austinywang Jul 2, 2026
ff7b0c1
Reset render-grid replay style before clears
austinywang Jul 2, 2026
dedd37a
Update render-grid replay byte expectation
austinywang Jul 2, 2026
28b3961
Fix render-grid replay test helpers
austinywang Jul 2, 2026
6245bda
Retry stale non-barrier render-grid replays
austinywang Jul 2, 2026
82325a6
Reset render-grid replay modes before restore
austinywang Jul 2, 2026
e244980
Reset render-grid report modes
austinywang Jul 2, 2026
661fee5
Import diagnostics for replay retry logging
austinywang Jul 2, 2026
009ecb2
Restore grapheme mode before render-grid paint
austinywang Jul 2, 2026
43d5d83
Bound dropped render-grid replay requests
austinywang Jul 2, 2026
434d7e3
Merge remote-tracking branch 'origin/main' into issue-7164-ios-typing…
austinywang Jul 2, 2026
e5128a0
Treat CRLF as one grapheme in replay presentation probe
austinywang Jul 2, 2026
0240e2c
Widen render-grid test frames for descriptive marker texts
austinywang Jul 2, 2026
a13b144
Reset OSC 133 semantic prompt state in full render-grid replay
austinywang Jul 2, 2026
ebacf70
Count no-progress replay responses against the retry budget
austinywang Jul 2, 2026
b4492ef
Overwrite Ghostty's saved-mode bank during full render-grid replay
austinywang Jul 2, 2026
00e3868
Reset primary cursor shape before alternate-screen replay entry
austinywang Jul 2, 2026
38d370c
Merge remote-tracking branch 'origin/main' into issue-7164-ios-typing…
austinywang Jul 2, 2026
fb09b5b
Reset replay retry budget at new input catch-up episodes
austinywang Jul 2, 2026
5d6298c
Arm bounded replay for dropped hybrid alternate-exit frames
austinywang Jul 2, 2026
059e42d
Include cursor modes in the replay saved-mode bank reset
austinywang Jul 2, 2026
c819347
Clarify fresh-budget test comment and use the retry constant
austinywang Jul 2, 2026
5d8639a
Track fresh-budget test comment growth in the length budget
austinywang Jul 2, 2026
2820189
Reset saved cursors to the RIS baseline during full replay
austinywang Jul 2, 2026
01fc8b1
Clear stale DECCOLM state during full render-grid replay
austinywang Jul 2, 2026
00fea82
Merge remote-tracking branch 'origin/main' into issue-7164-ios-typing…
austinywang Jul 2, 2026
98101ba
Reset ?1048 save-cursor mode state in the replay baseline
austinywang Jul 2, 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
4 changes: 3 additions & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
8032 Sources/Panels/BrowserPanelView.swift
8016 CLI/cmux_open.swift
7647 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
7738 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
7404 cmuxTests/WorkspaceUnitTests.swift
7218 cmuxTests/WorkspaceRemoteConnectionTests.swift
6359 cmuxTests/SessionPersistenceTests.swift
Expand Down Expand Up @@ -159,6 +159,7 @@
667 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift
664 Sources/CmuxTopSnapshot.swift
663 Sources/PortScanner.swift
660 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift
655 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift
655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift
Expand Down Expand Up @@ -237,6 +238,7 @@
518 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift
516 Sources/TerminalImageTransfer.swift
515 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift
514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift
514 cmuxUITests/UpdatePillUITests.swift
513 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation

extension MobileTerminalRenderGridReplay {
func appendStructuralScreenReset(to bytes: inout Data) {
bytes.append(Data("\u{1B}[?47l\u{1B}[?1047l\u{1B}[?1049l".utf8))
}

func appendDefaultModeBaseline(to bytes: inout Data) {
// ?3l (DECCOLM) must follow ?40l: with mode 40 off Ghostty's deccolm
// clears the stored ?3 value and returns without resizing, which is
// the only safe way to reset the mode without fighting the remote
// grid's viewport policy.
bytes.append(Data(
(
"\u{1B}[2l\u{1B}[4l\u{1B}[12h\u{1B}[20l"
+ "\u{1B}[?1l\u{1B}[?4l\u{1B}[?5l\u{1B}[?6l\u{1B}[?7h\u{1B}[?8l\u{1B}[?9l"
+ "\u{1B}[?40l\u{1B}[?3l\u{1B}[?45l\u{1B}[?66l\u{1B}>\u{1B}[?67l\u{1B}[?69l"
+ "\u{1B}[?1000l\u{1B}[?1002l\u{1B}[?1003l\u{1B}[?1004l"
+ "\u{1B}[?1005l\u{1B}[?1006l\u{1B}[?1007h\u{1B}[?1015l\u{1B}[?1016l"
+ "\u{1B}[?1035h\u{1B}[?1036h\u{1B}[?1039l\u{1B}[?1045l\u{1B}[?2004l"
+ "\u{1B}[?2027l\u{1B}[?2031l\u{1B}[?2048l"
).utf8
))
}

func appendSavedModeBankReset(to bytes: inout Data) {
// XTSAVE (CSI ? Pm s) overwrites Ghostty's saved-mode slots with the
// current values, which are all defaults right after the structural
// reset and default baseline. RIS cleared the saved bank outright;
// without this, a mode XTSAVE'd by a previous program on the reused
// surface would survive the replay and a later XTRESTORE (CSI ? Pm r)
// could resurrect it. The cursor modes ?12/?25/?1048 are forced to their
// Ghostty defaults first so their saved slots are deterministic; the
// paint sequence and the final cursor restore adjust the live values
// afterwards without touching the bank. Ghostty caps CSI parameters
// at 24 per sequence, so the bank is overwritten in two batches.
// 2026 is deliberately absent: it is held on for the synchronized
// replay and must not be saved in that state.
bytes.append(Data("\u{1B}[?12l\u{1B}[?25h\u{1B}[?1048l".utf8))
bytes.append(Data("\u{1B}[?1;3;4;5;6;7;8;9;12;25;40;45;47;66;67;69;1000;1002;1003s".utf8))
bytes.append(Data(
"\u{1B}[?1004;1005;1006;1007;1015;1016;1035;1036;1039;1045;1047;1048;1049;2004;2027;2031;2048s".utf8
))
}

func appendPrePaintModeRestores(to bytes: inout Data) {
for mode in frame.modes where !mode.ansi && mode.code == 2027 {
bytes.append(Data("\u{1B}[?2027\(mode.on ? "h" : "l")".utf8))
}
}

func isReplayExcludedMode(_ mode: MobileTerminalRenderGridFrame.ModeSetting) -> Bool {
guard !mode.ansi else { return false }
switch mode.code {
// DECCOLM (?3) is geometry, not paint state: Ghostty implements reset
// as a resize to 80 columns, while mobile render-grid delivery applies
// the authoritative remote grid through its viewport policy.
case 3, 12, 25, 47, 1047, 1048, 1049, 2026, 2031, 2048:
return true
default:
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,6 @@ public struct MobileTerminalRenderGridReplay: Sendable {
patchBytes()
}

/// DEC private mode codes that switch screens or save the cursor. The
/// active screen is restored explicitly via the frame's `activeScreen`, so
/// these are never replayed from `modes` (replaying them would
/// double-switch).
private let screenSwitchModeCodes: Set<Int> = [47, 1047, 1048, 1049]

private func deltaPatchBytes() -> Data {
var bytes = Data()
let stylesByID = styleMapByID(frame.styles)
Expand Down Expand Up @@ -88,17 +82,56 @@ public struct MobileTerminalRenderGridReplay: Sendable {
var bytes = Data()
let stylesByID = styleMapByID(frame.styles)
let defaultStyle = stylesByID[0] ?? .default

// Reset to a known state, then apply everything inside a synchronized
// update so the client never shows a partially-restored screen.
bytes.append(Data("\u{1B}c".utf8))
bytes.append(Data("\u{1B}[?2026h".utf8))

// Dynamic default colors (OSC 10/11/12). Cells already carry explicit
// RGB, so these mainly fix the cursor color and color queries.
if let osc = oscColorBytes(10, frame.terminalForeground) { bytes.append(osc) }
if let osc = oscColorBytes(11, frame.terminalBackground) { bytes.append(osc) }
if let osc = oscColorBytes(12, frame.terminalCursorColor) { bytes.append(osc) }
// Leads with DECSCUSR 0: cursor shape is per-screen state in Ghostty
// and survives the alternate-screen roundtrip, so without this a stale
// bar/underline shape from the reused surface's primary screen would
// resurface when a replayed TUI later exits the alternate screen. RIS
// used to clear it; the frame's captured cursor style is reapplied on
// the active screen at the end of the restore.
let screenStateReset = "\u{1B}[0 q\u{1B}[1\"q\u{1B}[0\"q\u{1B}[999<u\u{1B}[0;1=u\u{0F}\u{1B}(B\u{1B})B\u{1B}*B\u{1B}+B"
let hyperlinkStateReset = "\u{1B}]8;;\u{1B}\\"
// OSC 133;D returns the cursor's semantic content to `.output`, the
// fresh-screen default. RIS used to clear this; without it a reused
// surface still inside an OSC 133 prompt/input region would stamp that
// stale semantic state onto every replayed cell. Per-screen state, so
// emit it alongside each hyperlink reset (once per screen).
let semanticPromptReset = "\u{1B}]133;D\u{1B}\\"

// Apply the whole restore inside a synchronized update so the client
// never presents the empty reset/clear frame before the snapshot lands.
// Avoid `ESC c`: RIS clears before synchronized output can be enabled.
// These are Ghostty-supported resets for state the replay depends on:
// main display, protected cells, key/input flags, OSC 8 hyperlinks,
// charset mapping, scroll margins, tabs, both screens, cursor position,
// viewport contents, and scrollback.
bytes.append(Data("\u{1B}[?2026h\u{1B}[0$}\u{1B}[>m\u{1B}[r\u{1B}[?69l\u{1B}[?5W".utf8))
appendStructuralScreenReset(to: &bytes)
bytes.append(Data(hyperlinkStateReset.utf8))
bytes.append(Data(semanticPromptReset.utf8))
bytes.append(Data(screenStateReset.utf8))
appendDefaultModeBaseline(to: &bytes)
appendSavedModeBankReset(to: &bytes)
appendPrePaintModeRestores(to: &bytes)

// Dynamic default colors (OSC 10/11/12). Nil frame values reset the
// previous override so a full snapshot behaves like the old RIS path.
// Apply them before clearing so blank cells use the captured defaults.
bytes.append(oscColorOrResetBytes(10, reset: 110, frame.terminalForeground))
bytes.append(oscColorOrResetBytes(11, reset: 111, frame.terminalBackground))
bytes.append(oscColorOrResetBytes(12, reset: 112, frame.terminalCursorColor))
bytes.append(sgrBytes(for: defaultStyle))
// DECSC at home with the default pen resets each screen's saved
// cursor to the RIS baseline; a stale DECSC from the reused surface
// must not survive the replay, and the snapshot cursor is never
// saved (a later bare DECRC/?1048l restore should land on the
// default, matching what RIS left behind).
bytes.append(Data("\u{1B}[H\u{1B}7\u{1B}[2J\u{1B}[3J\u{1B}[?1049h".utf8))

bytes.append(Data(hyperlinkStateReset.utf8))
bytes.append(Data(semanticPromptReset.utf8))
bytes.append(Data(screenStateReset.utf8))
bytes.append(sgrBytes(for: defaultStyle))
bytes.append(Data("\u{1B}[H\u{1B}7\u{1B}[2J\u{1B}[?1049l\u{1B}[H".utf8))

// Paint with autowrap and the cursor off so a full-width row plus an
// explicit newline cannot wrap into a phantom blank line, and so the
Expand All @@ -119,6 +152,7 @@ public struct MobileTerminalRenderGridReplay: Sendable {
terminateLast: true
)
bytes.append(Data("\u{1B}[?1049h".utf8))
bytes.append(Data(screenStateReset.utf8))
bytes.append(sgrBytes(for: defaultStyle))
appendFlowLines(
&bytes,
Expand Down Expand Up @@ -152,7 +186,10 @@ public struct MobileTerminalRenderGridReplay: Sendable {

// Reapply modes last so autowrap returns to its captured value
// (undoing the temporary `?7l`) and mouse/paste/app-key modes are live.
for mode in frame.modes where !screenSwitchModeCodes.contains(mode.code) {
// The baseline also covers older frames that omitted `modes`, so stale
// state from a reused surface cannot leak through the full replay.
appendDefaultModeBaseline(to: &bytes)
for mode in frame.modes where !isReplayExcludedMode(mode) {
bytes.append(modeBytes(mode))
}

Expand Down Expand Up @@ -389,6 +426,10 @@ public struct MobileTerminalRenderGridReplay: Sendable {
return Data("\u{1B}]\(ps);\(spec)\u{1B}\\".utf8)
}

private func oscColorOrResetBytes(_ ps: Int, reset resetPs: Int, _ hex: String?) -> Data {
oscColorBytes(ps, hex) ?? Data("\u{1B}]\(resetPs)\u{1B}\\".utf8)
}

private func appendVTPrintable(_ text: String, to bytes: inout Data) {
for scalar in text.unicodeScalars {
appendVTPrintable(scalar, to: &bytes)
Expand Down
Loading
Loading