COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
POCKET

Session Recording & Replay for Terminal Debugging

Pocket is an iOS terminal app for running Claude Code on Mac. The debugging loop was painful:

2025-01-04 // RAW LEARNING CAPTURE
PROJECTPOCKET

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:

  • TerminalDataSource protocol for testability
  • MockTerminalDataSource for 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 recording
  • f37982f - fix: Improve session replay debug view UX

Files created:

  • Core/SessionRecording.swift - Data model + persistence
  • Core/SessionPlayer.swift - Playback engine
  • UI/Debug/RecordingOverlay.swift - Floating REC button
  • UI/Debug/TerminalDebugView.swift - Terminal + playback controls + hex inspector

The debugging loop is now:

Edit Code → Preview Updates → See Fix
    └────── < 1 second ──────┘

Takeaways

  1. 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.

  2. withAnimation propagates up the view hierarchy — wrapping a scrollTo can animate parent layouts unexpectedly.

  3. Clearing a terminal requires escape sequences — just clearing your data model doesn't clear the rendered terminal. Send ESC[2J ESC[H.

  4. @MainActor is viral — if a class is @MainActor, extension methods that create instances need the annotation too.

  5. String interpolation in os.Logger is a closure — Swift's capture semantics require explicit self even in what looks like simple string interpolation.

LOG.ENTRY_END
ref:pocket
RAW