diff --git a/apps/desktop/Package.swift b/apps/desktop/Package.swift index ba36080..6b0e524 100644 --- a/apps/desktop/Package.swift +++ b/apps/desktop/Package.swift @@ -12,7 +12,7 @@ let package = Package( .executable(name: "PlexusOneDesktop", targets: ["PlexusOneDesktop"]) ], dependencies: [ - .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", branch: "main"), + .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", revision: "b6ce28a4b222b06d76a3fd44e904e00a95044d53"), .package(url: "https://github.com/plexusone/assistantkit-swift.git", from: "0.1.0") ], targets: [ diff --git a/apps/desktop/Sources/PlexusOneDesktop/Models/Session.swift b/apps/desktop/Sources/PlexusOneDesktop/Models/Session.swift index 32cffe7..2719925 100644 --- a/apps/desktop/Sources/PlexusOneDesktop/Models/Session.swift +++ b/apps/desktop/Sources/PlexusOneDesktop/Models/Session.swift @@ -1,6 +1,35 @@ import Foundation +import CryptoKit import AssistantKit +extension UUID { + /// Fixed namespace for PlexusOne tmux-session identities (RFC 4122). + private static let tmuxSessionNamespace = UUID(uuidString: "b3f0e6a2-1c4d-5e6f-8a9b-0c1d2e3f4a5b")! + + /// Derive a stable, deterministic UUID (version 5, RFC 4122) from a tmux + /// session name. The same name always yields the same UUID, so a session + /// keeps one identity across periodic refreshes instead of getting a fresh + /// random UUID each cycle (which would churn SwiftUI identity). + static func forTmuxSession(named name: String) -> UUID { + var hasher = Insecure.SHA1() + withUnsafeBytes(of: tmuxSessionNamespace.uuid) { hasher.update(bufferPointer: $0) } + hasher.update(data: Data(name.utf8)) + var digest = Array(hasher.finalize()) // 20 bytes; use the first 16 + + // Set the version (5) and variant (RFC 4122) bits. + digest[6] = (digest[6] & 0x0F) | 0x50 + digest[8] = (digest[8] & 0x3F) | 0x80 + + let bytes = ( + digest[0], digest[1], digest[2], digest[3], + digest[4], digest[5], digest[6], digest[7], + digest[8], digest[9], digest[10], digest[11], + digest[12], digest[13], digest[14], digest[15] + ) + return UUID(uuid: bytes) + } +} + /// Represents a tmux session that can be attached to a pane struct Session: Identifiable, Codable, Hashable { let id: UUID @@ -11,6 +40,11 @@ struct Session: Identifiable, Codable, Hashable { var lastActivity: Date var metadata: [String: String] var inputStatus: InputStatus? + /// The `figterm`-style PTY autocomplete wrapper detected in this session's + /// panes, if any. Set at refresh time; `nil` means no known wrapper (or not + /// yet checked). A non-nil value means the session is at risk of freezing — + /// see `TerminalWrapper`. + var wrapper: TerminalWrapper? init( id: UUID = UUID(), @@ -20,7 +54,8 @@ struct Session: Identifiable, Codable, Hashable { status: SessionStatus = .detached, lastActivity: Date = Date(), metadata: [String: String] = [:], - inputStatus: InputStatus? = nil + inputStatus: InputStatus? = nil, + wrapper: TerminalWrapper? = nil ) { self.id = id self.name = name @@ -30,6 +65,7 @@ struct Session: Identifiable, Codable, Hashable { self.lastActivity = lastActivity self.metadata = metadata self.inputStatus = inputStatus + self.wrapper = wrapper } } diff --git a/apps/desktop/Sources/PlexusOneDesktop/Models/TerminalWrapper.swift b/apps/desktop/Sources/PlexusOneDesktop/Models/TerminalWrapper.swift new file mode 100644 index 0000000..5d6e145 --- /dev/null +++ b/apps/desktop/Sources/PlexusOneDesktop/Models/TerminalWrapper.swift @@ -0,0 +1,53 @@ +import Foundation + +/// A `figterm`-style pseudo-terminal wrapper that some shell-autocomplete tools +/// inject in front of your shell. +/// +/// These tools re-exec the shell inside a PTY shim so they can watch keystrokes +/// for inline completions. The shim relays I/O between the terminal and the +/// shell — and when it deadlocks (a well-known failure mode of this design) the +/// program running inside, e.g. an agent CLI, blocks on I/O and the pane freezes +/// for *every* attached client. The shim renames its process to the shell's name +/// with a suffix, e.g. `zsh (kiro-cli-term)`, which is how it is detected. +enum TerminalWrapper: String, Codable, Hashable, CaseIterable, Sendable { + /// Kiro CLI (`kiro-cli-term`). + case kiroCli = "kiro-cli-term" + /// Fig (`figterm`). + case fig = "figterm" + /// Amazon Q (`qterm`). + case amazonQ = "qterm" + + /// Human-readable product name. + var displayName: String { + switch self { + case .kiroCli: return "Kiro CLI" + case .fig: return "Fig" + case .amazonQ: return "Amazon Q" + } + } + + /// The marker this wrapper leaves in a shell's `ps` command, + /// e.g. `zsh (kiro-cli-term)`. + var processMarker: String { rawValue } + + /// Shell command that removes the wrapper's dotfiles integration, so new + /// shells launch unwrapped. + var remediation: String { + switch self { + case .kiroCli: return "kiro-cli integration uninstall dotfiles" + case .fig: return "fig integrations uninstall dotfiles" + case .amazonQ: return "q integrations uninstall dotfiles" + } + } + + /// One-line warning suitable for a tooltip or banner. + var warning: String { + "Shell wrapped by \(displayName)'s autocomplete PTY shim (\(processMarker)) — a known cause of frozen terminals. Fix: \(remediation)" + } + + /// Detect a wrapper from a process command string such as + /// `"zsh (kiro-cli-term)"`, returning the first family whose marker appears. + static func detect(inCommand command: String) -> TerminalWrapper? { + allCases.first { command.contains($0.processMarker) } + } +} diff --git a/apps/desktop/Sources/PlexusOneDesktop/Services/SessionManager.swift b/apps/desktop/Sources/PlexusOneDesktop/Services/SessionManager.swift index a5b3410..d0b4562 100644 --- a/apps/desktop/Sources/PlexusOneDesktop/Services/SessionManager.swift +++ b/apps/desktop/Sources/PlexusOneDesktop/Services/SessionManager.swift @@ -19,12 +19,16 @@ final class SessionManager { private let commandExecutor: any CommandExecuting private let tmuxPaths: [String] + private let wrapperDetector: TerminalWrapperDetector + init( commandExecutor: any CommandExecuting = ProcessCommandExecutor(), tmuxPaths: [String]? = nil ) { + let paths = tmuxPaths ?? TmuxEnvironment.searchPaths self.commandExecutor = commandExecutor - self.tmuxPaths = tmuxPaths ?? TmuxEnvironment.searchPaths + self.tmuxPaths = paths + self.wrapperDetector = TerminalWrapperDetector(commandExecutor: commandExecutor, tmuxPaths: paths) } /// Start periodic refresh (call from view's onAppear) @@ -50,7 +54,17 @@ final class SessionManager { error = nil do { - sessions = try await listTmuxSessions() + var listed = try await listTmuxSessions() + // Flag sessions whose panes are behind a figterm-style PTY wrapper + // (a known cause of hangs). Detection failures yield an empty map and + // never break session listing. + let wrappers = await wrapperDetector.detectWrappers() + if !wrappers.isEmpty { + for index in listed.indices { + listed[index].wrapper = wrappers[listed[index].tmuxSession] + } + } + sessions = listed } catch let err as SessionManagerError { error = err } catch { @@ -195,6 +209,7 @@ final class SessionManager { let status = determineStatus(lastActivity: lastActivity) let session = Session( + id: .forTmuxSession(named: name), name: name, tmuxSession: name, status: status, diff --git a/apps/desktop/Sources/PlexusOneDesktop/Services/TerminalWrapperDetector.swift b/apps/desktop/Sources/PlexusOneDesktop/Services/TerminalWrapperDetector.swift new file mode 100644 index 0000000..eab4e76 --- /dev/null +++ b/apps/desktop/Sources/PlexusOneDesktop/Services/TerminalWrapperDetector.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Detects `figterm`-style PTY autocomplete wrappers (see `TerminalWrapper`) in +/// running tmux panes, so the UI can warn that a session is at risk of freezing. +/// +/// The wrapper re-execs as the pane's top process, so its marker appears in the +/// `ps` command of `#{pane_pid}` — no process-tree walking is required. +struct TerminalWrapperDetector: Sendable { + private let commandExecutor: any CommandExecuting + private let tmuxPaths: [String] + + init( + commandExecutor: any CommandExecuting = ProcessCommandExecutor(), + tmuxPaths: [String]? = nil + ) { + self.commandExecutor = commandExecutor + self.tmuxPaths = tmuxPaths ?? TmuxEnvironment.searchPaths + } + + /// Map of tmux session name → detected wrapper, for every session with at + /// least one wrapped pane. Sessions without a wrapper are omitted. Returns an + /// empty map on any failure, so detection never blocks session listing. + func detectWrappers() async -> [String: TerminalWrapper] { + do { + let panes = try await runTmux(["list-panes", "-a", "-F", "#{session_name}|#{pane_pid}"]) + guard panes.success, !panes.stdout.isEmpty else { return [:] } + + let ps = try await commandExecutor.execute("/bin/ps", arguments: ["-Ao", "pid=,command="]) + guard ps.success else { return [:] } + + return Self.parse(paneList: panes.stdout, psOutput: ps.stdout) + } catch { + return [:] + } + } + + /// Pure parsing step, separated for deterministic testing. + /// - Parameters: + /// - paneList: `#{session_name}|#{pane_pid}` lines from `tmux list-panes -a`. + /// - psOutput: `pid command…` lines from `ps -Ao pid=,command=`. + /// - Returns: session name → the wrapper found in its first wrapped pane. + static func parse(paneList: String, psOutput: String) -> [String: TerminalWrapper] { + // Build pid → command from ps output. + var commandByPID: [Int: String] = [:] + for line in psOutput.split(separator: "\n") { + let trimmed = line.drop { $0 == " " } + guard let space = trimmed.firstIndex(of: " "), + let pid = Int(trimmed[.. CommandResult { + for path in tmuxPaths where FileManager.default.fileExists(atPath: path) { + return try await commandExecutor.execute(path, arguments: arguments) + } + return try await commandExecutor.execute("/usr/bin/env", arguments: ["tmux"] + arguments) + } +} diff --git a/apps/desktop/Sources/PlexusOneDesktop/Views/AppTerminalView.swift b/apps/desktop/Sources/PlexusOneDesktop/Views/AppTerminalView.swift index 6c28aa6..cc4cedd 100644 --- a/apps/desktop/Sources/PlexusOneDesktop/Views/AppTerminalView.swift +++ b/apps/desktop/Sources/PlexusOneDesktop/Views/AppTerminalView.swift @@ -34,35 +34,6 @@ class AppTerminalView: LocalProcessTerminalView { } } - // MARK: - Mouse Wheel Event Handling - - /// Send mouse wheel event to terminal application (e.g., tmux with mouse mode) - /// Returns true if event was handled, false if should use native scrollback - func handleMouseWheelEvent(_ event: NSEvent) -> Bool { - // Check if we should send mouse wheel events to the terminal application - guard allowMouseReporting && terminal.mouseMode != .off else { - return false - } - - // Get cell size from terminal - guard let cellSize = cellSizeInPixels(source: terminal) else { - return false - } - - // Calculate position in terminal grid - let locationInView = convert(event.locationInWindow, from: nil) - let col = Int(locationInView.x / CGFloat(cellSize.width)) - let row = Int((bounds.height - locationInView.y) / CGFloat(cellSize.height)) - - // Mouse wheel: button 64 = up, 65 = down - let scrollCount = max(1, Int(abs(event.scrollingDeltaY) / 3)) - let buttonCode = event.scrollingDeltaY > 0 ? 64 : 65 - - for _ in 0.. Bool { // Forward first responder to terminal and notify focus change let result = terminalView.becomeFirstResponder() @@ -115,12 +110,6 @@ struct AppTerminalViewRepresentable: NSViewRepresentable { // Configure appearance configureAppearance(terminalView) - // Add local event monitor for scroll wheel events (trackpad two-finger scroll) - context.coordinator.scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { event in - context.coordinator.handleScrollEvent(event) - return event - } - let container = TerminalContainerView(terminalView: terminalView) context.coordinator.containerView = container @@ -175,7 +164,6 @@ struct AppTerminalViewRepresentable: NSViewRepresentable { var parent: AppTerminalViewRepresentable weak var terminalView: AppTerminalView? weak var containerView: TerminalContainerView? - var scrollMonitor: Any? var inputDetectionTimer: Timer? /// Cache last content hash to skip redundant input detection @@ -187,9 +175,6 @@ struct AppTerminalViewRepresentable: NSViewRepresentable { deinit { inputDetectionTimer?.invalidate() - if let monitor = scrollMonitor { - NSEvent.removeMonitor(monitor) - } } /// Start periodic input detection and focus checking @@ -239,23 +224,6 @@ struct AppTerminalViewRepresentable: NSViewRepresentable { } } - func handleScrollEvent(_ event: NSEvent) { - guard let terminalView = terminalView else { return } - - // Check if the event is within the terminal view's bounds - guard terminalView.window != nil else { return } - let locationInWindow = event.locationInWindow - let locationInView = terminalView.convert(locationInWindow, from: nil) - - guard terminalView.bounds.contains(locationInView) else { return } - - // First try to send mouse wheel events to the terminal app (e.g., tmux) - // If mouse reporting is not enabled, fall back to native scrollback - if !terminalView.handleMouseWheelEvent(event) { - terminalView.scrollWheel(with: event) - } - } - // MARK: - LocalProcessTerminalViewDelegate func processTerminated(source: TerminalView, exitCode: Int32?) { diff --git a/apps/desktop/Sources/PlexusOneDesktop/Views/WrapperWarningView.swift b/apps/desktop/Sources/PlexusOneDesktop/Views/WrapperWarningView.swift new file mode 100644 index 0000000..38b5865 --- /dev/null +++ b/apps/desktop/Sources/PlexusOneDesktop/Views/WrapperWarningView.swift @@ -0,0 +1,88 @@ +import AppKit +import SwiftUI + +/// Small amber warning triangle shown next to a session that is running behind a +/// `figterm`-style PTY autocomplete wrapper (a known cause of frozen terminals). +/// Hovering reveals the wrapper and the fix. +struct WrapperWarningBadge: View { + let wrapper: TerminalWrapper + + var body: some View { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 9)) + .foregroundColor(.orange) + .help(wrapper.warning) + .accessibilityLabel("\(wrapper.displayName) wrapper detected — hang risk") + } +} + +/// Dismissible banner shown when any visible session is wrapped. Explains the +/// hang risk once and offers to copy the fix command. +struct WrapperWarningBanner: View { + /// Distinct wrappers detected across current sessions. + let wrappers: [TerminalWrapper] + let onDismiss: () -> Void + let onSuppress: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 12)) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 2) { + Text(headline) + .font(.system(size: 11, weight: .semibold)) + ForEach(wrappers, id: \.self) { wrapper in + HStack(spacing: 6) { + Text("Fix:") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Text(wrapper.remediation) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + Button("Copy") { copyToClipboard(wrapper.remediation) } + .font(.system(size: 10)) + .buttonStyle(.borderless) + } + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Dismiss") + + Button("Don't show again", action: onSuppress) + .font(.system(size: 10)) + .buttonStyle(.borderless) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.orange.opacity(0.12)) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(Color.orange.opacity(0.4)), + alignment: .bottom + ) + } + + private var headline: String { + let names = wrappers.map { $0.displayName }.joined(separator: ", ") + return "Some sessions run behind an autocomplete PTY wrapper (\(names)) — a known cause of frozen panes." + } + + private func copyToClipboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} diff --git a/apps/desktop/Tests/PlexusOneDesktopTests/SessionManagerTests.swift b/apps/desktop/Tests/PlexusOneDesktopTests/SessionManagerTests.swift index a508ace..0a761b4 100644 --- a/apps/desktop/Tests/PlexusOneDesktopTests/SessionManagerTests.swift +++ b/apps/desktop/Tests/PlexusOneDesktopTests/SessionManagerTests.swift @@ -160,6 +160,32 @@ final class SessionManagerTests: XCTestCase { XCTAssertEqual(sessions[0].status, .running) } + func testParseSessionOutputIdStableAcrossRefreshes() { + let manager = SessionManager() + let now = Date() + let ts = Int(now.timeIntervalSince1970 - 10) + + // Same session parsed on two separate refresh cycles (different activity + // timestamps, as tmux would report) must keep the same identity. + let first = manager.parseSessionOutput("my-session|\(ts)|1", referenceDate: now) + let second = manager.parseSessionOutput("my-session|\(ts + 5)|1", referenceDate: now) + + XCTAssertEqual(first.count, 1) + XCTAssertEqual(second.count, 1) + XCTAssertEqual(first[0].id, second[0].id, "Session id must be stable across refreshes") + } + + func testParseSessionOutputIdDiffersByName() { + let manager = SessionManager() + let now = Date() + let ts = Int(now.timeIntervalSince1970 - 10) + + let sessions = manager.parseSessionOutput("alpha|\(ts)|1\nbeta|\(ts)|1", referenceDate: now) + + XCTAssertEqual(sessions.count, 2) + XCTAssertNotEqual(sessions[0].id, sessions[1].id, "Different sessions must have distinct ids") + } + func testParseSessionOutputMultipleSessions() { let manager = SessionManager() let now = Date() diff --git a/apps/desktop/Tests/PlexusOneDesktopTests/TerminalWrapperDetectorTests.swift b/apps/desktop/Tests/PlexusOneDesktopTests/TerminalWrapperDetectorTests.swift new file mode 100644 index 0000000..0ddd405 --- /dev/null +++ b/apps/desktop/Tests/PlexusOneDesktopTests/TerminalWrapperDetectorTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import PlexusOneDesktop + +final class TerminalWrapperDetectorTests: XCTestCase { + + // MARK: - TerminalWrapper.detect + + func testDetectKiroCliTerm() { + XCTAssertEqual(TerminalWrapper.detect(inCommand: "zsh (kiro-cli-term)"), .kiroCli) + } + + func testDetectFigterm() { + XCTAssertEqual(TerminalWrapper.detect(inCommand: "figterm"), .fig) + } + + func testDetectAmazonQ() { + XCTAssertEqual(TerminalWrapper.detect(inCommand: "bash (qterm)"), .amazonQ) + } + + func testDetectPlainShellIsNil() { + XCTAssertNil(TerminalWrapper.detect(inCommand: "-zsh")) + XCTAssertNil(TerminalWrapper.detect(inCommand: "/bin/zsh")) + XCTAssertNil(TerminalWrapper.detect(inCommand: "claude")) + } + + func testMetadata() { + XCTAssertEqual(TerminalWrapper.kiroCli.displayName, "Kiro CLI") + XCTAssertEqual(TerminalWrapper.kiroCli.processMarker, "kiro-cli-term") + XCTAssertEqual(TerminalWrapper.kiroCli.remediation, "kiro-cli integration uninstall dotfiles") + XCTAssertTrue(TerminalWrapper.kiroCli.warning.contains("kiro-cli-term")) + } + + // MARK: - TerminalWrapperDetector.parse + + func testParseFlagsOnlyWrappedSessions() { + let paneList = """ + work|111 + pbhqweb|222 + mkt|333 + """ + let psOutput = """ + 111 -zsh + 222 zsh (kiro-cli-term) + 333 /bin/zsh + 999 figterm + """ + + let result = TerminalWrapperDetector.parse(paneList: paneList, psOutput: psOutput) + + XCTAssertEqual(result, ["pbhqweb": .kiroCli]) + XCTAssertNil(result["work"]) + XCTAssertNil(result["mkt"]) + } + + func testParseFirstWrappedPaneWins() { + // A session with multiple panes; one is wrapped. + let paneList = """ + multi|10 + multi|11 + """ + let psOutput = """ + 10 /bin/zsh + 11 zsh (kiro-cli-term) + """ + let result = TerminalWrapperDetector.parse(paneList: paneList, psOutput: psOutput) + XCTAssertEqual(result["multi"], .kiroCli) + } + + func testParseHandlesEmptyInput() { + XCTAssertTrue(TerminalWrapperDetector.parse(paneList: "", psOutput: "").isEmpty) + } + + func testParseIgnoresMalformedLines() { + let result = TerminalWrapperDetector.parse( + paneList: "no-pipe-here\nsess|notanumber\ngood|55", + psOutput: " 55 zsh (kiro-cli-term)\ngarbage line" + ) + XCTAssertEqual(result, ["good": .kiroCli]) + } + + // MARK: - Async detection through the command executor + + func testDetectWrappersEndToEnd() async { + let mock = MockCommandExecutor() + // tmuxPaths [] forces the /usr/bin/env fallback for tmux. + mock.stub(path: "/usr/bin/env", result: CommandResult( + exitCode: 0, + stdout: "pbhqweb|222\nwork|111", + stderr: "" + )) + mock.stub(path: "/bin/ps", result: CommandResult( + exitCode: 0, + stdout: " 111 -zsh\n 222 zsh (kiro-cli-term)", + stderr: "" + )) + + let detector = TerminalWrapperDetector(commandExecutor: mock, tmuxPaths: []) + let result = await detector.detectWrappers() + + XCTAssertEqual(result, ["pbhqweb": .kiroCli]) + } + + func testDetectWrappersReturnsEmptyOnTmuxFailure() async { + let mock = MockCommandExecutor() + mock.stub(path: "/usr/bin/env", result: CommandResult( + exitCode: 1, stdout: "", stderr: "no server running" + )) + let detector = TerminalWrapperDetector(commandExecutor: mock, tmuxPaths: []) + let result = await detector.detectWrappers() + XCTAssertTrue(result.isEmpty) + } +} diff --git a/docs/specs/initiatives/INIT-PLEXUSONEAPP-001/ROADMAP.md b/docs/specs/initiatives/INIT-PLEXUSONEAPP-001/ROADMAP.md new file mode 100644 index 0000000..bd5afdc --- /dev/null +++ b/docs/specs/initiatives/INIT-PLEXUSONEAPP-001/ROADMAP.md @@ -0,0 +1,41 @@ +# Desktop Terminal Scrolling & Rendering Performance — Roadmap + +**Initiative:** `INIT-PLEXUSONEAPP-001` +**Repository:** `github.com/plexusone/plexusone-app` +**Status:** Phase 1 in progress + +> RMI IDs are stable and permanent. Commits implementing an item carry the trailer `Refs: RMI--`. Phase status is derived from member RMIs — a phase is complete only when all its required RMIs are complete. + +## Phase 1 — Scroll Integration Fixes + +**Theme:** Remove double-dispatch and smooth trackpad scrolling +**Status:** In progress — 3 of 4 items completed + +- [x] `RMI-PLEXUSONEAPP-001` Remove double-dispatched scroll path (NSEvent monitor + custom wheel handler) + - Delete the app-wide NSEvent.addLocalMonitorForEvents scroll monitor and Coordinator.handleScrollEvent in TerminalViewRepresentable.swift, the custom AppTerminalView.handleMouseWheelEvent, and the TerminalContainerView.scrollWheel forward. SwiftTerm's built-in scrollWheel already handles mouse reporting, alternate-screen conversion, and native scrollback; the current setup processes every wheel event twice and installs one app-wide monitor per pane. +- [ ] `RMI-PLEXUSONEAPP-002` Fractional scroll-delta accumulation for trackpad smoothness + - SwiftTerm's scrollWheel truncates event.deltaY to whole lines and drops small fractional trackpad deltas, causing steppy scrolling. Accumulate fractional deltas in the AppTerminalView subclass and emit line scrolls when the accumulator crosses a cell height. Consider upstreaming to SwiftTerm. +- [x] `RMI-PLEXUSONEAPP-003` Stable Session identity across 5s refresh cycles + - SessionManager.parseSessionOutput mints a new UUID for every Session on every refresh, breaking SwiftUI ForEach identity and the session-picker checkmark, and forcing needless view rebuilds. Derive identity deterministically from the tmux session name. +- [x] `RMI-PLEXUSONEAPP-004` Pin SwiftTerm to a fixed revision instead of branch main + - Package.swift tracks SwiftTerm branch main, an unpinned moving target whose perf characteristics can shift under us. Pin to a specific revision (currently b6ce28a) or tagged release. + +## Phase 2 — Rendering & Measurement + +**Theme:** Enable Metal renderer and profile before/after +**Status:** Planned — 0 of 2 items completed + +- [ ] `RMI-PLEXUSONEAPP-005` Enable SwiftTerm Metal renderer with CoreGraphics fallback + - The pinned SwiftTerm revision ships MetalTerminalRenderer (glyph atlas + GPU quads) behind setUseMetal(true), which the app never calls. Enable it after the view is added to a window, falling back to CoreGraphics if MetalError is thrown. +- [ ] `RMI-PLEXUSONEAPP-006` Profile heavy-output scrolling with Instruments (baseline vs Metal) + - No profiling data exists behind the Rust-migration discussion. Capture Instruments time-profiles during heavy AI-agent output bursts and sustained scrolling, before and after the phase-1 fixes and Metal enablement. Decide whether SwiftTerm core (parser/buffer) is a real bottleneck; only then revisit the alacritty_terminal FFI option from IDEATION_CHAT_RUST.md. + +## Phase 3 — tmux Strategy + +**Theme:** Mouse mode now, control mode (-CC) evaluation for native scrollback +**Status:** Planned — 0 of 2 items completed + +- [ ] `RMI-PLEXUSONEAPP-007` Enable tmux mouse mode for app-created sessions + - Sessions attach via tmux attach on the alternate screen, so wheel events degrade to arrow keys and SwiftTerm's 10k scrollback goes unused. Set mouse mode (set -g mouse on) for sessions the app creates so scrolling enters copy-mode smoothly. +- [ ] `RMI-PLEXUSONEAPP-008` Evaluate tmux control mode (-CC) integration for native scrollback + - iTerm2-class fluidity (native scrollback over tmux history, no copy-mode round-trip) requires speaking the tmux control-mode protocol and owning the buffer in-app. Scope the protocol work in Swift, estimate effort, and prototype attach/detach with one session. This is the feature that defines professional-grade for a tmux orchestrator; it is protocol work, not a rendering rewrite.