import AppKit import SwiftTerm import Combine /// Wraps a single SwiftTerm TerminalView + LocalProcess pair. @MainActor class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate { let id = UUID() let terminalView: TerminalView private var process: LocalProcess? private var keyEventMonitor: Any? private let backgroundColor = NSColor.black private let configuredShellPath: String @Published var title: String = "shell" @Published var isRunning: Bool = true @Published var currentDirectory: String? init(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) { terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) configuredShellPath = shellPath super.init() terminalView.terminalDelegate = self let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) terminalView.font = font applyTheme(theme) installCommandArrowMonitor() startShell() } deinit { if let keyEventMonitor { NSEvent.removeMonitor(keyEventMonitor) } } // MARK: - Shell management private func startShell() { let shellPath = resolveShell() let shellName = (shellPath as NSString).lastPathComponent let loginExecName = "-\(shellName)" let proc = LocalProcess(delegate: self) // Launch as a login shell so user startup files initialize PATH/tools. proc.startProcess( executable: shellPath, args: ["-l"], environment: nil, execName: loginExecName, currentDirectory: NSHomeDirectory() ) process = proc title = shellName } private func resolveShell() -> String { let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines) if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) { return custom } return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" } private func installCommandArrowMonitor() { keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self else { return event } guard let window = self.terminalView.window else { return event } guard event.window === window else { return event } guard window.firstResponder === self.terminalView else { return event } guard let sequence = TerminalCommandArrowBehavior.sequence( for: event.modifierFlags, keyCode: event.keyCode, applicationCursor: self.terminalView.getTerminal().applicationCursor ) else { return event } self.terminalView.send(data: sequence[...]) return nil } } func updateFontSize(_ size: CGFloat) { terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } func applyTheme(_ theme: TerminalTheme) { // Keep the notch visually consistent while swapping the terminal's // default foreground color and ANSI palette for command output. terminalView.nativeBackgroundColor = backgroundColor terminalView.nativeForegroundColor = theme.foregroundColor terminalView.installColors(theme.ansiColors) } func terminate() { process?.terminate() process = nil isRunning = false } // MARK: - LocalProcessDelegate nonisolated func processTerminated(_ source: LocalProcess, exitCode: Int32?) { Task { @MainActor in self.isRunning = false } } nonisolated func dataReceived(slice: ArraySlice) { let data = slice Task { @MainActor in self.terminalView.feed(byteArray: data) } } nonisolated func getWindowSize() -> winsize { var ws = winsize() ws.ws_col = 80 ws.ws_row = 24 return ws } // MARK: - TerminalViewDelegate func send(source: TerminalView, data: ArraySlice) { process?.send(data: data) } func setTerminalTitle(source: TerminalView, title: String) { self.title = title.isEmpty ? "shell" : title } func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { guard newCols > 0, newRows > 0 else { return } guard let proc = process else { return } let fd = proc.childfd guard fd >= 0 else { return } var ws = winsize() ws.ws_col = UInt16(newCols) ws.ws_row = UInt16(newRows) _ = ioctl(fd, TIOCSWINSZ, &ws) } func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { currentDirectory = directory } func scrolled(source: TerminalView, position: Double) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} func clipboardCopy(source: TerminalView, content: Data) { NSPasteboard.general.clearContents() NSPasteboard.general.setData(content, forType: .string) } func requestOpenLink(source: TerminalView, link: String, params: [String : String]) { if let url = URL(string: link) { NSWorkspace.shared.open(url) } } func bell(source: TerminalView) { NSSound.beep() } func iTermContent(source: TerminalView, content: ArraySlice) {} }