import AppKit import SwiftTerm // MARK: - Configuration // // Every value is read from the app bundle's Info.plist, so a single compiled // binary can power many different .app bundles (each with its own command, // working directory, title and icon). See make-app.sh. // // MTCommand String Command to run, e.g. "claude". Empty = plain shell. // MTWorkingDirectory String Directory to start in. "~" is expanded. Empty = home. // MTFontSize Number Font point size (default 13). // MTFontName String Font family name (default "Menlo"). Falls back to // the system monospaced font if the name is missing. // MTWindowTitle String Window title (default = CFBundleName). // MTCloseOnExit Bool Close the window when the command exits (default // false = drop back into an interactive login shell). // struct AppConfig { let command: String let workingDirectory: String? let fontSize: CGFloat let fontName: String let windowTitle: String let engine: String // "ghostty" (Default) oder "swiftterm" let closeOnExit: Bool // true = Fenster schließt, wenn der Befehl endet static func load() -> AppConfig { let info = Bundle.main.infoDictionary ?? [:] let cmd = ((info["MTCommand"] as? String) ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) let engine = ((info["MTEngine"] as? String) ?? "ghostty") .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() var workdir: String? = nil if let raw = info["MTWorkingDirectory"] as? String, !raw.trimmingCharacters(in: .whitespaces).isEmpty { workdir = (raw as NSString).expandingTildeInPath } let size = (info["MTFontSize"] as? NSNumber)?.doubleValue ?? 13.0 let fontName = ((info["MTFontName"] as? String) ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) let title = (info["MTWindowTitle"] as? String) ?? (info["CFBundleName"] as? String) ?? "Terminal" let closeOnExit = (info["MTCloseOnExit"] as? NSNumber)?.boolValue ?? false return AppConfig(command: cmd, workingDirectory: workdir, fontSize: CGFloat(size), fontName: fontName.isEmpty ? "Menlo" : fontName, windowTitle: title, engine: engine == "swiftterm" ? "swiftterm" : "ghostty", closeOnExit: closeOnExit) } } // MARK: - App delegate final class AppDelegate: NSObject, NSApplicationDelegate, LocalProcessTerminalViewDelegate { private var window: NSWindow! private var terminalView: LocalProcessTerminalView! private var keyMonitor: Any? private var ghosttyApp: GhosttyApp? private var ghosttyView: GhosttySurfaceView? private let config = AppConfig.load() func applicationDidFinishLaunching(_ notification: Notification) { let frame = NSRect(x: 0, y: 0, width: 900, height: 560) window = NSWindow( contentRect: frame, styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) window.title = config.windowTitle window.setFrameAutosaveName("MiniTermMain") window.center() // Engine wählen. libghostty (GPU-Renderer) ist Default; SwiftTerm bleibt // als Fallback (auch wenn die libghostty-Init scheitert). if config.engine == "ghostty", let contentView = setupGhostty(frame: frame) { window.contentView = contentView window.makeKeyAndOrderFront(nil) window.makeFirstResponder(contentView) } else { setupSwiftTerm(frame: frame) } setupMenu() } private func setupGhostty(frame: NSRect) -> NSView? { guard let gapp = GhosttyApp() else { NSLog("libghostty-Init fehlgeschlagen – Fallback auf SwiftTerm") return nil } ghosttyApp = gapp let view = GhosttySurfaceView(ghostty: gapp, appConfig: config, frame: frame) ghosttyView = view return view } private func setupSwiftTerm(frame: NSRect) { terminalView = LocalProcessTerminalView(frame: frame) terminalView.processDelegate = self terminalView.font = resolvedFont() installKeyMonitor() window.contentView = terminalView window.makeKeyAndOrderFront(nil) window.makeFirstResponder(terminalView) launchProcess() } private func launchProcess() { let shell = userShell() // We always go through a LOGIN shell (-l) so PATH is set up exactly like // in a normal terminal (Homebrew, ~/.local/bin, etc.). Without this, // tools like `claude` are often "command not found". let args: [String] if config.command.isEmpty { args = ["-l"] } else if config.closeOnExit { // Run the command and let the process end with it; the window // closes via processTerminated(). args = ["-l", "-c", config.command] } else { // Run the requested command, then hand control to an interactive // login shell so the window stays open after the command exits. args = ["-l", "-c", "\(config.command); exec \(shell) -l"] } let env = Terminal.getEnvironmentVariables(termName: "xterm-256color", trueColor: true) terminalView.startProcess( executable: shell, args: args, environment: env, execName: nil, currentDirectory: config.workingDirectory) } // Intercept Shift+Return (and Shift+Enter on the numpad) before it reaches // the terminal and send a line feed (\n) instead of the normal carriage // return (\r). This lets CLIs like Claude Code insert a newline in // multi-line input while plain Enter still submits. We use a local event // monitor because SwiftTerm's keyDown(with:) is `public`, not `open`, so it // can't be overridden from outside the module. private func installKeyMonitor() { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self = self else { return event } // Only act when our window is focused and the keyboard focus is in // the terminal (the view itself or one of its subviews). guard self.window.isKeyWindow else { return event } if let responder = self.window.firstResponder as? NSView, !(responder === self.terminalView || responder.isDescendant(of: self.terminalView)) { return event } let isReturn = (event.keyCode == 36 || event.keyCode == 76) let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if isReturn && mods == .shift { self.terminalView.send(txt: "\n") return nil // consume the event } return event } } // Resolve the configured font, falling back gracefully so the app never // launches with a broken/empty font if the name doesn't exist on the system. private func resolvedFont() -> NSFont { if let f = NSFont(name: config.fontName, size: config.fontSize) { return f } if let menlo = NSFont(name: "Menlo", size: config.fontSize) { return menlo } return NSFont.monospacedSystemFont(ofSize: config.fontSize, weight: .regular) } private func userShell() -> String { if let shell = ProcessInfo.processInfo.environment["SHELL"], !shell.isEmpty { return shell } return "/bin/zsh" } private func setupMenu() { let mainMenu = NSMenu() // Application menu let appItem = NSMenuItem() mainMenu.addItem(appItem) let appMenu = NSMenu() let name = config.windowTitle appMenu.addItem(withTitle: "Hide \(name)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") appMenu.addItem(NSMenuItem.separator()) appMenu.addItem(withTitle: "Quit \(name)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") appItem.submenu = appMenu // Edit menu (enables ⌘C / ⌘V in the terminal) let editItem = NSMenuItem() mainMenu.addItem(editItem) let editMenu = NSMenu(title: "Edit") editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") editItem.submenu = editMenu NSApp.mainMenu = mainMenu } // MARK: LocalProcessTerminalViewDelegate func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} func setTerminalTitle(source: LocalProcessTerminalView, title: String) { // Keep a stable window title. Uncomment to follow the shell's title: // window.title = title.isEmpty ? config.windowTitle : title } func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} func processTerminated(source: TerminalView, exitCode: Int32?) { NSApp.terminate(nil) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } } // MARK: - Entry point let app = NSApplication.shared app.setActivationPolicy(.regular) // a real Dock app, not a background process let delegate = AppDelegate() app.delegate = delegate app.activate(ignoringOtherApps: true) app.run()