- 01.Starting Point
- 02.Architecture Design
- 03.Core Files Created
- 04.`Core/SessionRecording.swift`
- 05.`Core/SessionPlayer.swift`
- 06.Hooking into EtClient
- 07.The Event Inspector Color Problem
- 08.The Layout Animation Bug
- 09.Play Button UX
- 10.Terminal Clear Not Working
- 11.Auto-play on Hot Reload
- 12.Where We Landed
- 13.Takeaways
Session Recording & Replay for Terminal Debugging
Pocket is an iOS terminal app for running Claude Code on Mac. The debugging loop was painful:
Starting Point
Pocket is an iOS terminal app for running Claude Code on Mac. The debugging loop was painful:
Edit Code → Build → Deploy to Sim → SSH Connect → ET Connect → Reproduce → Check Logs
└─────────────────── 30+ seconds minimum ───────────────────┘
We had already built:
TerminalDataSourceprotocol for testabilityMockTerminalDataSourcefor tests and previews- 31 input sequence tests in
InputSequenceTests.swift
The insight: record real ET sessions, replay them through MockTerminalDataSource in previews. Debug with hot reload, no network.
Architecture Design
Recording (one-time):
EtClient ←──bytes──→ etserver
│
▼
SessionRecorder (taps all I/O)
│
▼
paste-bug.json
Replay (infinite):
#Preview {
let mock = MockTerminalDataSource()
let player = SessionPlayer(.load("paste-bug"))
TerminalDebugView(dataSource: mock, player: player)
}
Core Files Created
Core/SessionRecording.swift
Data model for recorded sessions:
struct SessionEvent: Codable, Identifiable {
let id: UUID
let timestamp: TimeInterval // Relative to session start
let direction: Direction
let data: Data
enum Direction: String, Codable {
case sent // App → Server
case received // Server → App
}
}
struct SessionRecording: Codable, Identifiable {
let id: UUID
let name: String
let createdAt: Date
let events: [SessionEvent]
}Persistence to Documents/SessionRecordings/:
private static var recordingsDirectory: URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let dir = docs.appendingPathComponent("SessionRecordings", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}Hit a Swift compiler error on capture semantics:
error: reference to property 'events' in closure requires explicit use of 'self'
Fix: logger.info("Recording stopped: \(self.events.count) events...") — the string interpolation is a closure.
Core/SessionPlayer.swift
Playback engine with play/pause/seek/step:
@MainActor
class SessionPlayer: ObservableObject {
@Published private(set) var state: PlaybackState = .stopped
@Published private(set) var currentIndex: Int = 0
@Published private(set) var currentTime: TimeInterval = 0
@Published var speed: Double = 1.0
func play(into dataSource: MockTerminalDataSource)
func pause()
func stepForward()
func stepBackward()
func seek(to index: Int)
func seek(toTime time: TimeInterval)
}The @MainActor annotation caused issues with the convenience extension:
extension MockTerminalDataSource {
func playRecording(_ recording: SessionRecording, speed: Double = 1.0) -> SessionPlayer {
let player = SessionPlayer(recording: recording) // ERROR: main actor-isolated
player.speed = speed
player.play(into: self)
return player
}
}Fix: Add @MainActor to the extension method too.
Hooking into EtClient
Two integration points in Network/EtClient.swift:
class EtClient: ObservableObject, TerminalDataSource {
#if DEBUG
let sessionRecorder = SessionRecorder()
#endif
func sendInput(data: Data) {
#if DEBUG
sessionRecorder.recordSent(data)
#endif
channel?.writeAndFlush(EtClientCommand.input(data), promise: nil)
}
}And in EtConnectionHandler.handleEncryptedPacket():
if let termBuf = try? Et_TerminalBuffer(serializedBytes: payloadData) {
if !termBuf.buffer.isEmpty {
#if DEBUG
client.sessionRecorder.recordReceived(termBuf.buffer)
#endif
client.delegate?.onDataReceived(data: termBuf.buffer)
}
}The Event Inspector Color Problem
First screenshot showed white bleeding through at bottom of Event Inspector:
Event Inspector 0.000s
┌─────────────────────────┐
│ 0 ↓ 0.000s 24 20 2B │ ← dark, readable
│ 1 ↑ 0.500s 6C 1B │ ← dark, readable
│ ... │
│ 8 ↓ 1.100s 24 20 2B │ ← dark, readable
├─────────────────────────┤
│ WHITE │ ← problem
└─────────────────────────┘
First diagnosis (wrong): Background color not set. Added .background(Color.black) to ScrollView, EventInspectorView, etc.
Real issue: System colors (.primary, .secondary) adapt to light/dark mode, but preview was showing in mixed state. Some rows dark, some white.
Fix: Explicit colors instead of system colors:
struct EventRow: View {
private let textPrimary = Color.white
private let textSecondary = Color.gray
var body: some View {
// ... use textPrimary/textSecondary instead of .primary/.secondary
.background(isActive ? Color.accent.opacity(0.3) : Color(white: 0.1))
}
}The Layout Animation Bug
User reported: "it starts at the bottom, and then it kind of 'animates' up"
This wasn't a color issue — it was a layout animation propagating incorrectly.
The culprit:
.onChange(of: player.currentIndex) { _, newIndex in
withAnimation { // ← THIS
proxy.scrollTo(newIndex, anchor: .center)
}
}The withAnimation was propagating to parent layout, causing the whole inspector to animate its position.
Fix: Remove the animation wrapper:
.onChange(of: player.currentIndex) { _, newIndex in
proxy.scrollTo(newIndex, anchor: .center)
}Play Button UX
User wanted simpler controls:
- Single button that becomes "restart" when at end
- Clear terminal on restart
private var playButtonIcon: String {
if player.isAtEnd {
return "arrow.counterclockwise" // Restart
} else if player.state == .playing {
return "pause.fill"
} else {
return "play.fill"
}
}
Button(action: {
if player.isAtEnd {
dataSource.clear()
player.stop()
player.play(into: dataSource)
} else if player.state == .playing {
player.pause()
} else {
player.play(into: dataSource)
}
}) {
Image(systemName: playButtonIcon)
}Terminal Clear Not Working
MockTerminalDataSource.clear() only cleared recorded data, not the terminal display:
func clear() {
sentInputs.removeAll()
resizeEvents.removeAll()
// Terminal still shows old content!
}Fix: Send clear screen escape sequence:
func clear() {
sentInputs.removeAll()
resizeEvents.removeAll()
// ESC[2J = clear screen, ESC[H = cursor to home
let clearSequence = "\u{1b}[2J\u{1b}[H"
if let data = clearSequence.data(using: .utf8) {
delegate?.onDataReceived(data: data)
}
}Auto-play on Hot Reload
Preview was auto-playing on every hot reload because of:
#Preview("Debug View - Sample Recording") {
let mock = MockTerminalDataSource()
let player = SessionPlayer(recording: .sample)
TerminalDebugView(dataSource: mock, player: player)
.onAppear {
player.play(into: mock) // ← ANNOYING
}
}Fix: Just remove the .onAppear. Let user tap play.
Where We Landed
Commits:
532162f- Add developer experience tooling: behavior tests and session recordingf37982f- fix: Improve session replay debug view UX
Files created:
Core/SessionRecording.swift- Data model + persistenceCore/SessionPlayer.swift- Playback engineUI/Debug/RecordingOverlay.swift- Floating REC buttonUI/Debug/TerminalDebugView.swift- Terminal + playback controls + hex inspector
The debugging loop is now:
Edit Code → Preview Updates → See Fix
└────── < 1 second ──────┘
Takeaways
-
SwiftUI color issues are often not about colors — they're about which color scheme the view thinks it's in. System colors adapt; explicit colors don't.
-
withAnimationpropagates up the view hierarchy — wrapping ascrollTocan animate parent layouts unexpectedly. -
Clearing a terminal requires escape sequences — just clearing your data model doesn't clear the rendered terminal. Send
ESC[2J ESC[H. -
@MainActoris viral — if a class is@MainActor, extension methods that create instances need the annotation too. -
String interpolation in os.Logger is a closure — Swift's capture semantics require explicit
selfeven in what looks like simple string interpolation.