Skip to content
2 changes: 1 addition & 1 deletion apps/desktop/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
38 changes: 37 additions & 1 deletion apps/desktop/Sources/PlexusOneDesktop/Models/Session.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(),
Expand All @@ -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
Expand All @@ -30,6 +65,7 @@ struct Session: Identifiable, Codable, Hashable {
self.lastActivity = lastActivity
self.metadata = metadata
self.inputStatus = inputStatus
self.wrapper = wrapper
}
}

Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/Sources/PlexusOneDesktop/Models/TerminalWrapper.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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[..<space]) else { continue }
commandByPID[pid] = String(trimmed[trimmed.index(after: space)...])
}

// For each pane, flag its session if the pane process is a known wrapper.
var result: [String: TerminalWrapper] = [:]
for line in paneList.split(separator: "\n") {
let parts = line.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2, let pid = Int(parts[1]) else { continue }
let session = String(parts[0])
guard result[session] == nil else { continue } // first wrapped pane wins
if let command = commandByPID[pid],
let wrapper = TerminalWrapper.detect(inCommand: command) {
result[session] = wrapper
}
}
return result
}

private func runTmux(_ arguments: [String]) async throws -> 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)
}
}
29 changes: 0 additions & 29 deletions apps/desktop/Sources/PlexusOneDesktop/Views/AppTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<scrollCount {
terminal.sendEvent(buttonFlags: buttonCode, x: col, y: row)
}
return true
}

// MARK: - Session Management

Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/Sources/PlexusOneDesktop/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,29 @@ struct ContentView: View {
@State private var showRestorePrompt = false
@State private var showErrorAlert = false
@State private var isReady = false
@State private var wrapperBannerDismissed = false
@AppStorage("suppressWrapperWarning") private var suppressWrapperWarning = false

private var sessionManager: SessionManager {
appState.sessionManager
}

/// Distinct PTY autocomplete wrappers detected across current sessions,
/// in a stable order, for the warning banner.
private var detectedWrappers: [TerminalWrapper] {
var seen: [TerminalWrapper] = []
for session in sessionManager.sessions {
if let wrapper = session.wrapper, !seen.contains(wrapper) {
seen.append(wrapper)
}
}
return seen
}

private var showWrapperBanner: Bool {
isReady && !suppressWrapperWarning && !wrapperBannerDismissed && !detectedWrappers.isEmpty
}

private var windowStateManager: WindowStateManager {
appState.windowStateManager
}
Expand All @@ -25,6 +43,14 @@ struct ContentView: View {

var body: some View {
VStack(spacing: 0) {
if showWrapperBanner {
WrapperWarningBanner(
wrappers: detectedWrappers,
onDismiss: { wrapperBannerDismissed = true },
onSuppress: { suppressWrapperWarning = true }
)
}

if !isReady {
// Loading state
VStack {
Expand Down Expand Up @@ -278,6 +304,9 @@ struct GridStatusBarView: View {
Text(session.name)
.font(.system(size: 10))
.lineLimit(1)
if let wrapper = session.wrapper {
WrapperWarningBadge(wrapper: wrapper)
}
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ struct SessionPillView: View {
Text(session.name)
.font(.system(size: 11))
.lineLimit(1)
if let wrapper = session.wrapper {
WrapperWarningBadge(wrapper: wrapper)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 3)
Expand All @@ -90,7 +93,11 @@ struct SessionPillView: View {
private var tooltipText: String {
let status = session.status.displayName
let ago = session.lastActivity.timeAgoString()
return "\(session.name) - \(status) (\(ago))"
var text = "\(session.name) - \(status) (\(ago))"
if let wrapper = session.wrapper {
text += "\n⚠︎ \(wrapper.warning)"
}
return text
}
}

Expand Down
Loading
Loading