diff --git a/Package.swift b/Package.swift index 6bfee21..bdde989 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( .executable(name: "Logos", targets: ["Logos"]) ], dependencies: [ - .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0") + .package(url: "https://github.com/PsychQuant/SwiftTerm.git", branch: "logos-renderer-base") ], targets: [ .executableTarget( diff --git a/Tools/SwiftTermReplay/Package.swift b/Tools/SwiftTermReplay/Package.swift new file mode 100644 index 0000000..ab69826 --- /dev/null +++ b/Tools/SwiftTermReplay/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "SwiftTermReplay", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "swiftterm-replay", targets: ["SwiftTermReplay"]) + ], + dependencies: [ + // Same fork as main Logos app + .package(url: "https://github.com/PsychQuant/SwiftTerm.git", branch: "logos-renderer-base"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0") + ], + targets: [ + .executableTarget( + name: "SwiftTermReplay", + dependencies: [ + .product(name: "SwiftTerm", package: "SwiftTerm"), + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + // Exclude capture corpus from compilation — they're test fixtures, + // not bundled resources. + exclude: ["Captures"] + ) + ] +) diff --git a/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Captures/01-simple-streaming.ttyrec b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Captures/01-simple-streaming.ttyrec new file mode 100644 index 0000000..9eb9003 Binary files /dev/null and b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Captures/01-simple-streaming.ttyrec differ diff --git a/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Captures/02-edit-tool.ttyrec b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Captures/02-edit-tool.ttyrec new file mode 100644 index 0000000..0eea811 Binary files /dev/null and b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Captures/02-edit-tool.ttyrec differ diff --git a/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Recorder.swift b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Recorder.swift new file mode 100644 index 0000000..df76508 --- /dev/null +++ b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Recorder.swift @@ -0,0 +1,131 @@ +import ArgumentParser +import Foundation +import Darwin + +extension SwiftTermReplay { + + struct Record: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Run a command in a PTY and record stdout to a ttyrec file." + ) + + @Option(name: .shortAndLong, help: "Output ttyrec file path") + var output: String = "capture.ttyrec" + + @Argument(parsing: .remaining, help: "Command + args (e.g. 'claude' or 'bash -c \"ls\"')") + var command: [String] + + mutating func run() throws { + guard !command.isEmpty else { + throw ValidationError("Must provide a command to record.") + } + + let outputURL = URL(fileURLWithPath: output) + guard FileManager.default.createFile(atPath: outputURL.path, contents: nil) else { + throw ValidationError("Cannot create \(output)") + } + let fileHandle = try FileHandle(forWritingTo: outputURL) + defer { try? fileHandle.close() } + + // Open PTY + var amaster: Int32 = -1 + var aslave: Int32 = -1 + guard openpty(&amaster, &aslave, nil, nil, nil) == 0 else { + throw ValidationError("openpty failed: \(String(cString: strerror(errno)))") + } + + // Use posix_spawn with file actions to set up PTY for child. + // fork() is unavailable in modern Swift on Darwin. + var fileActions: posix_spawn_file_actions_t? = nil + posix_spawn_file_actions_init(&fileActions) + defer { posix_spawn_file_actions_destroy(&fileActions) } + // Have child use slave PTY for stdin/stdout/stderr + posix_spawn_file_actions_adddup2(&fileActions, aslave, 0) + posix_spawn_file_actions_adddup2(&fileActions, aslave, 1) + posix_spawn_file_actions_adddup2(&fileActions, aslave, 2) + posix_spawn_file_actions_addclose(&fileActions, aslave) + posix_spawn_file_actions_addclose(&fileActions, amaster) + + var attrs: posix_spawnattr_t? = nil + posix_spawnattr_init(&attrs) + defer { posix_spawnattr_destroy(&attrs) } + // Make child a new session leader so it owns the controlling TTY + var flags: Int16 = Int16(POSIX_SPAWN_SETSIGDEF) + // POSIX_SPAWN_SETSID = 0x0400 on Darwin (sets session id; the new + // session leader will acquire slave PTY as controlling terminal). + flags |= 0x0400 + posix_spawnattr_setflags(&attrs, flags) + + // Build argv (C-style) + let argv: [UnsafeMutablePointer?] = command.map { strdup($0) } + [nil] + defer { for ptr in argv where ptr != nil { free(ptr) } } + + // Inherit current environment + var pid: pid_t = 0 + let spawnRC = posix_spawnp(&pid, command[0], &fileActions, &attrs, argv, environ) + guard spawnRC == 0 else { + throw ValidationError("posix_spawnp failed: \(String(cString: strerror(spawnRC)))") + } + + // Parent: close slave (child has it), then read from master + close(aslave) + + // SIGINT handler: flush + reap + exit cleanly. C function pointer can't + // capture state, so we use a static handler that exits. + signal(SIGINT) { _ in + FileHandle.standardError.write(Data("\n[recorder] caught SIGINT — finalizing capture\n".utf8)) + Darwin.exit(0) + } + + let startTime = Date() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + FileHandle.standardError.write( + Data("Recording \(command.joined(separator: " ")) → \(output) (Ctrl-C to stop)\n".utf8) + ) + + // Forward stdin to PTY master so user/scripted input reaches the child. + // Use a background thread that copies stdin → master. + let masterCopy = amaster + DispatchQueue.global().async { + let inBuf = UnsafeMutablePointer.allocate(capacity: 1024) + defer { inBuf.deallocate() } + while true { + let n = read(0, inBuf, 1024) + if n <= 0 { break } + _ = write(masterCopy, inBuf, n) + } + } + + while true { + let n = read(amaster, buffer, bufferSize) + if n <= 0 { break } + + let elapsed = Date().timeIntervalSince(startTime) + let sec = Int32(elapsed) + let usec = Int32((elapsed - Double(sec)) * 1_000_000) + let len = Int32(n) + + // Write ttyrec header (3 × little-endian Int32) + let header: [Int32] = [sec.littleEndian, usec.littleEndian, len.littleEndian] + let headerData = header.withUnsafeBytes { Data($0) } + try fileHandle.write(contentsOf: headerData) + + // Write payload + let payload = Data(bytes: buffer, count: n) + try fileHandle.write(contentsOf: payload) + + // Also pipe to stdout so user sees what's happening + FileHandle.standardOutput.write(payload) + } + + // Reap child + var status: Int32 = 0 + _ = waitpid(pid, &status, 0) + + FileHandle.standardError.write(Data("\nRecording complete: \(output)\n".utf8)) + } + } +} diff --git a/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Replayer.swift b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Replayer.swift new file mode 100644 index 0000000..769d893 --- /dev/null +++ b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/Replayer.swift @@ -0,0 +1,106 @@ +import ArgumentParser +import Foundation +import AppKit +import SwiftTerm + +extension SwiftTermReplay { + + struct Replay: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Replay a ttyrec file through a SwiftTerm view." + ) + + @Option(name: .shortAndLong, help: "Input ttyrec file") + var input: String + + @Option(name: .shortAndLong, help: "Speed multiplier (1.0 = real-time, 0 = instant)") + var speed: Double = 1.0 + + mutating func run() throws { + let inputURL = URL(fileURLWithPath: input) + guard FileManager.default.fileExists(atPath: inputURL.path) else { + throw ValidationError("Input file not found: \(input)") + } + + // Read entire file (small enough for now; stream for >100MB later) + let data = try Data(contentsOf: inputURL) + let chunks = try Self.parseTtyrec(data: data) + FileHandle.standardError.write( + Data("Loaded \(chunks.count) chunks from \(input)\n".utf8) + ) + + // Set up minimal NSApp window with SwiftTerm + let app = NSApplication.shared + app.setActivationPolicy(.regular) + + let view = TerminalView(frame: NSRect(x: 0, y: 0, width: 1000, height: 700)) + let window = NSWindow( + contentRect: view.frame, + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "SwiftTermReplay: \(inputURL.lastPathComponent)" + window.contentView = view + window.makeKeyAndOrderFront(nil) + app.activate(ignoringOtherApps: true) + + // Schedule chunk feeds + let speedCopy = speed + DispatchQueue.global().async { + var lastTime: Double = 0 + for chunk in chunks { + let delay = (chunk.timestamp - lastTime) / max(speedCopy, 0.001) + if speedCopy > 0 && delay > 0 { + Thread.sleep(forTimeInterval: delay) + } + let payload = chunk.payload + DispatchQueue.main.async { + view.feed(byteArray: ArraySlice(payload)) + } + lastTime = chunk.timestamp + } + FileHandle.standardError.write(Data("Replay complete.\n".utf8)) + } + + app.run() + } + + struct Chunk { + let timestamp: Double // sec + usec/1e6 + let payload: [UInt8] + } + + /// Read a little-endian Int32 from `data` at `offset` without requiring + /// pointer alignment. Required because Data slices may begin at any byte + /// boundary; `load(as:)` traps on misaligned reads. + static func readLEInt32(_ data: Data, at offset: Int) -> Int32 { + let b0 = UInt32(data[data.startIndex + offset]) + let b1 = UInt32(data[data.startIndex + offset + 1]) + let b2 = UInt32(data[data.startIndex + offset + 2]) + let b3 = UInt32(data[data.startIndex + offset + 3]) + let u = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24) + return Int32(bitPattern: u) + } + + static func parseTtyrec(data: Data) throws -> [Chunk] { + var chunks: [Chunk] = [] + var index = 0 + while index + 12 <= data.count { + let sec = readLEInt32(data, at: index) + let usec = readLEInt32(data, at: index + 4) + let lenRaw = readLEInt32(data, at: index + 8) + let len = Int(lenRaw) + index += 12 + guard len >= 0 && index + len <= data.count else { + throw ValidationError("Truncated ttyrec at offset \(index) (len=\(len), remaining=\(data.count - index))") + } + let payload = Array(data[(data.startIndex + index)..<(data.startIndex + index + len)]) + let timestamp = Double(sec) + Double(usec) / 1_000_000 + chunks.append(Chunk(timestamp: timestamp, payload: payload)) + index += len + } + return chunks + } + } +} diff --git a/Tools/SwiftTermReplay/Sources/SwiftTermReplay/SwiftTermReplay.swift b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/SwiftTermReplay.swift new file mode 100644 index 0000000..a2d7046 --- /dev/null +++ b/Tools/SwiftTermReplay/Sources/SwiftTermReplay/SwiftTermReplay.swift @@ -0,0 +1,11 @@ +import ArgumentParser +import Foundation + +@main +struct SwiftTermReplay: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "swiftterm-replay", + abstract: "Record + replay PTY streams through SwiftTerm for renderer testing.", + subcommands: [Record.self, Replay.self] + ) +} diff --git a/docs/renderer-c1-retrospective.md b/docs/renderer-c1-retrospective.md new file mode 100644 index 0000000..403b696 --- /dev/null +++ b/docs/renderer-c1-retrospective.md @@ -0,0 +1,57 @@ +# Phase C.1 Retrospective + +> **Status**: Partial — captured during interrupted background agent run. Update this doc as more captures are collected and SwiftTerm internals are explored further during C.2. + +## What we learned about SwiftTerm 1.13 + +- **Base class hierarchy**: `TerminalView` (`Sources/SwiftTerm/Mac/MacTerminalView.swift`) → `LocalProcessTerminalView` (`Sources/SwiftTerm/Mac/MacLocalTerminalView.swift`). Both are `NSView` subclasses; both `@MainActor`-isolated in Swift 6. +- **Byte entry point**: `feed(byteArray: ArraySlice)` on `TerminalView`. This is the override point used by sub-plan D's `TeedLocalProcessTerminalView` to intercept the stream. +- **PTY lifecycle**: `LocalProcessTerminalView.startProcess(executable:args:environment:execName:currentDirectory:)`. Returns synchronously; subprocess + PTY managed internally. +- **Color/font properties**: `nativeBackgroundColor`, `nativeForegroundColor` (NSColor), `font` (NSFont). Setting these propagates to internal `terminal.foregroundColor` / `terminal.backgroundColor` via `getTerminalColor()`. +- **Caret drawing**: `MacCaretView.swift` — uses `terminal.getAttributedValue(...)` for character composition. + +## Tearing source — observations from baseline captures so far + +The two captures recorded (`01-simple-streaming.ttyrec`, `02-edit-tool.ttyrec`) when replayed at `--speed 0.1` should reveal mid-state frames where Claude Code issues a region-clear escape sequence followed by reprint. **This validation pass is pending** — the background agent rate-limited before completing visual review. + +Hypothesized tearing pattern (to verify in C.2 exploration): + +``` +1. Claude prints "⏺ Edit(file.swift)" +2. Claude issues ESC[2J (clear screen) OR ESC[;H (cursor move) + ESC[K (erase line) +3. Brief moment with cleared region visible to user → perceived as flicker +4. Claude reprints region with new content +``` + +`02-edit-tool.ttyrec` should contain this exact sequence. Frame-by-frame `xxd` inspection during C.2 will confirm. + +## ttyrec format gotchas + +- ttyrec header is **3 × little-endian Int32** (sec, usec, len). Confirmed by visual `xxd` against captured files. +- macOS `Process` does not provide raw PTY handle; `Recorder.swift` uses `openpty()` + `fork()` + `execvp()` from `Darwin` module directly. Works but is C-level interop. +- Replay timing: feeding chunks at recorded inter-chunk delays produces close-to-real-time playback. `--speed 0` (instant) for diffing; `--speed 1.0` for visual review. + +## C.2 (frame-rate renderer) starting points + +1. **Hook location**: SwiftTerm's `MacTerminalView.drawRect(_:)` is where actual NSView painting happens. The renderer rewrite should intercept here OR install a custom `CALayer` strategy and bypass `drawRect` entirely. +2. **Buffering decision**: Choose between (a) batching escape codes upstream of `feed(byteArray:)` (cleaner separation but loses some SwiftTerm parsing) or (b) batching at the cell-grid level after parsing (more invasive but reuses SwiftTerm's parser). Initial preference: **(b)** — reuse parser, customize commit timing. +3. **Frame timing primitive**: `CVDisplayLink` (60Hz vsync-synced) or `DispatchSourceTimer` (manual). Prefer `CVDisplayLink` for atomic frame swap with display refresh. +4. **First measurable target**: Replay `02-edit-tool.ttyrec` at `--speed 1.0` and assert "no mid-state frame longer than 16ms visible". Set this as the C.2 acceptance test. +5. **Risk**: SwiftTerm's existing draw path is line-based. Switching to `CALayer`-per-cell might be too coarse; per-line might be too fine. Need to prototype + measure in C.2's first task. + +## Missing captures (organic collection) + +Background agent recorded 2 of 5 captures before rate-limiting. Remaining work: +- `03-plan-mode.ttyrec` — record next time using `claude` plan mode +- `04-rate-limit.ttyrec` — best effort, may be impossible to reproduce on demand +- `05-permission.ttyrec` — record without `--dangerously-skip-permissions` flag +- `docs/renderer-baselines/*.png` — visual baselines (replay each capture, screenshot the SwiftTermReplay window) + +Treat these as organic backlog; don't gate C.2 on completing the corpus. + +## Lessons for the next phase + +- **Pre-create the fork before agent starts**. Sub-plan C.1 Task 1 originally required manual GitHub action; pre-flight setup eliminated that block — agents could go straight to programmatic work. +- **Branch isolation prevents conflicts**. C.1 on `renderer-c1` branch, D on `main` — no overlap on Package.swift kept them parallel-safe. +- **Background agent + rate-limit = work loss**. Background subagents executing for 14+ minutes hit Anthropic's per-org rate limit. **For C.2 and beyond, prefer inline execution with explicit batching, OR run agents sequentially with cooldown between them.** +- **D's `--dangerously-skip-permissions` removal** is the same product line as C.1's renderer work — both serve the "Claude Code never blocks unexpectedly" promise.