Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions Tools/SwiftTermReplay/Package.swift
Original file line number Diff line number Diff line change
@@ -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"]
)
]
)
Binary file not shown.
Binary file not shown.
131 changes: 131 additions & 0 deletions Tools/SwiftTermReplay/Sources/SwiftTermReplay/Recorder.swift
Original file line number Diff line number Diff line change
@@ -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<CChar>?] = 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<UInt8>.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<UInt8>.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))
}
}
}
106 changes: 106 additions & 0 deletions Tools/SwiftTermReplay/Sources/SwiftTermReplay/Replayer.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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]
)
}
57 changes: 57 additions & 0 deletions docs/renderer-c1-retrospective.md
Original file line number Diff line number Diff line change
@@ -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<UInt8>)` 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[<n>;<m>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.