Files
EnhancedTerminal/TerminalSession.swift
Paul Bailey 09f90a736a Fix clearScreen/clearScrollback for split panes and update all help text
- Fixed Cmd+K crash by handling both tab and split pane modes
- Updated startup message to show Cmd+I for AI (not Cmd+K)
- clearScreen and clearScrollback now work with active pane
- All documentation now consistent with correct shortcuts
2026-02-01 00:00:32 +00:00

757 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Cocoa
// MARK: - Terminal Session
class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
var textView: NSTextView!
var scrollView: NSScrollView! // Store scroll view as property
var shellTask: Process?
var inputPipe: Pipe?
var outputPipe: Pipe?
var promptPosition = 0
var sessionID: UUID
var title: String
var currentWorkingDirectory: String = "~"
private var currentSuggestion: String?
private var historyIndex: Int = -1
private var historyBuffer: [String] = []
private var currentInput: String = ""
init(title: String = "Terminal") {
self.sessionID = UUID()
self.title = title
super.init()
}
func setupTextView(frame: NSRect) {
scrollView = NSScrollView(frame: frame)
scrollView.autoresizingMask = [.width, .height]
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
textView = NSTextView(frame: scrollView.bounds)
textView.autoresizingMask = [.width, .height]
textView.isEditable = true
textView.delegate = self
textView.isRichText = true
textView.allowsUndo = true
textView.usesFindBar = true
applyTheme()
scrollView.documentView = textView
// Setup context menu
setupContextMenu()
// Setup suggestion label
setupSuggestionLabel()
// Setup middle-click paste
setupMiddleClickPaste()
// Setup clickable URLs
setupClickableURLs()
}
private func setupMiddleClickPaste() {
let clickGesture = NSClickGestureRecognizer(target: self, action: #selector(handleMiddleClick(_:)))
clickGesture.buttonMask = 0x4 // Middle button
textView.addGestureRecognizer(clickGesture)
}
@objc private func handleMiddleClick(_ gesture: NSClickGestureRecognizer) {
if let pasteString = NSPasteboard.general.string(forType: .string) {
textView.insertText(pasteString, replacementRange: textView.selectedRange())
}
}
private func setupClickableURLs() {
textView.isAutomaticLinkDetectionEnabled = true
textView.linkTextAttributes = [
.foregroundColor: NSColor.systemBlue,
.underlineStyle: NSUnderlineStyle.single.rawValue
]
}
private func setupContextMenu() {
let menu = NSMenu()
menu.delegate = self
let copyItem = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "")
let pasteItem = NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "")
let selectAllItem = NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "")
menu.addItem(copyItem)
menu.addItem(pasteItem)
menu.addItem(selectAllItem)
menu.addItem(NSMenuItem.separator())
let saveSnippetItem = NSMenuItem(title: "Save as Snippet", action: #selector(saveSelectionAsSnippet), keyEquivalent: "")
saveSnippetItem.target = self
menu.addItem(saveSnippetItem)
textView.menu = menu
}
@objc private func saveSelectionAsSnippet() {
let selectedRange = textView.selectedRange()
guard selectedRange.length > 0 else { return }
let selectedText = (textView.string as NSString).substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else { return }
let alert = NSAlert()
alert.messageText = "Save as Snippet"
alert.informativeText = "Enter details for this snippet:"
let nameField = NSTextField(frame: NSRect(x: 0, y: 60, width: 300, height: 24))
nameField.placeholderString = "Snippet name"
let categoryField = NSTextField(frame: NSRect(x: 0, y: 30, width: 300, height: 24))
categoryField.placeholderString = "Category (optional)"
let descField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
descField.placeholderString = "Description (optional)"
let commandLabel = NSTextField(labelWithString: "Command: \(selectedText)")
commandLabel.frame = NSRect(x: 0, y: 90, width: 300, height: 20)
commandLabel.font = NSFont.systemFont(ofSize: 11)
commandLabel.textColor = .secondaryLabelColor
commandLabel.lineBreakMode = .byTruncatingTail
let view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 115))
view.addSubview(commandLabel)
view.addSubview(nameField)
view.addSubview(categoryField)
view.addSubview(descField)
alert.accessoryView = view
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
if alert.runModal() == .alertFirstButtonReturn {
let snippet = SnippetManager.Snippet(
name: nameField.stringValue.isEmpty ? "Untitled" : nameField.stringValue,
command: selectedText,
description: descField.stringValue,
category: categoryField.stringValue.isEmpty ? "General" : categoryField.stringValue
)
SnippetManager.shared.addSnippet(snippet)
// Show confirmation
let confirmation = NSAlert()
confirmation.messageText = "Snippet Saved"
confirmation.informativeText = "Your snippet has been saved. Access it with Cmd+P."
confirmation.alertStyle = .informational
confirmation.addButton(withTitle: "OK")
confirmation.runModal()
}
}
private func setupSuggestionLabel() {
// We'll use a different approach - inline gray text
// No separate label needed
}
func applyTheme() {
let theme = TerminalSettings.shared.theme
textView?.backgroundColor = theme.background
textView?.textColor = theme.foreground
textView?.insertionPointColor = theme.cursor
textView?.selectedTextAttributes = [
.backgroundColor: theme.selection
]
textView?.font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
}
func startShell() {
shellTask = Process()
inputPipe = Pipe()
outputPipe = Pipe()
shellTask?.executableURL = URL(fileURLWithPath: "/bin/zsh")
shellTask?.arguments = ["-l"] // Login shell for proper initialization
// Better environment setup
var env = ProcessInfo.processInfo.environment
env["TERM"] = "xterm-256color"
env["PS1"] = "$ "
env["PROMPT"] = "$ "
env["DISABLE_AUTO_TITLE"] = "true"
shellTask?.environment = env
shellTask?.standardInput = inputPipe
shellTask?.standardOutput = outputPipe
shellTask?.standardError = outputPipe
outputPipe?.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
if data.count > 0, let output = String(data: data, encoding: .utf8) {
DispatchQueue.main.async {
self?.appendText(output)
}
}
}
do {
try shellTask?.run()
appendText("Enhanced Terminal v3.0\n")
appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
appendText("Features:\n")
appendText(" • Cmd+T: New tab\n")
appendText(" • Cmd+R: Search history (fuzzy search)\n")
appendText(" • Cmd+I: AI command generator\n")
appendText(" • Cmd+P: Command snippets\n")
appendText(" • Cmd+K: Clear screen\n")
appendText(" • Tab: Accept auto-suggestion\n")
appendText(" • Type 'help' for more commands\n")
appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
// Wait a bit for shell to initialize, then show prompt
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.showPrompt()
// Run startup commands
for command in TerminalSettings.shared.startupCommands {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self?.executeCommand(command)
}
}
}
} catch {
appendText("Error starting shell: \(error)\n")
}
}
func showPrompt() {
appendText("$ ")
promptPosition = textView.string.count
// Move cursor to end and make text view first responder
textView.setSelectedRange(NSRange(location: textView.string.count, length: 0))
textView.window?.makeFirstResponder(textView)
}
func appendText(_ text: String) {
let cleaned = cleanANSI(text)
let theme = TerminalSettings.shared.theme
let attr = NSAttributedString(string: cleaned, attributes: [
.foregroundColor: theme.foreground,
.font: NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
])
textView.textStorage?.append(attr)
textView.scrollToEndOfDocument(nil)
}
private func cleanANSI(_ text: String) -> String {
var cleaned = text
// Remove ANSI escape sequences
let patterns = [
"\u{1B}\\[[0-9;]*[a-zA-Z]",
"\u{1B}\\][0-9];[^\u{07}]*\u{07}",
"\\]633;[^\u{07}]*\u{07}",
"\\]697;[^\u{07}]*\u{07}",
"\\]1337;[^\u{07}]*\u{07}",
"\\]1;[^\u{07}]*\u{07}",
"\\]2;[^\u{07}]*\u{07}",
"\\]7;[^\u{07}]*\\\\",
"\u{1B}\\]0;[^\u{07}]*\u{07}"
]
for pattern in patterns {
cleaned = cleaned.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}
return cleaned
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
// Handle up arrow - previous command in history
if commandSelector == #selector(NSResponder.moveUp(_:)) {
navigateHistory(direction: .previous)
return true
}
// Handle down arrow - next command in history
if commandSelector == #selector(NSResponder.moveDown(_:)) {
navigateHistory(direction: .next)
return true
}
return false
}
private enum HistoryDirection {
case previous, next
}
private func navigateHistory(direction: HistoryDirection) {
// Load history if not already loaded
if historyBuffer.isEmpty {
historyBuffer = HistoryManager.shared.getRecentCommands(100)
}
guard !historyBuffer.isEmpty else { return }
// Save current input if we're starting to navigate
if historyIndex == -1 {
currentInput = getCurrentCommand()
}
// Navigate history
switch direction {
case .previous:
if historyIndex < historyBuffer.count - 1 {
historyIndex += 1
}
case .next:
if historyIndex > -1 {
historyIndex -= 1
}
}
// Update command line
let newCommand: String
if historyIndex == -1 {
// Back to original input
newCommand = currentInput
} else {
// Show history command
newCommand = historyBuffer[historyIndex]
}
// Replace current command with history command
replaceCurrentCommand(with: newCommand)
}
private func replaceCurrentCommand(with newCommand: String) {
let currentLength = textView.string.count
let commandLength = currentLength - promptPosition
if commandLength > 0 {
let range = NSRange(location: promptPosition, length: commandLength)
textView.textStorage?.replaceCharacters(in: range, with: newCommand)
} else {
textView.insertText(newCommand, replacementRange: NSRange(location: promptPosition, length: 0))
}
// Move cursor to end
textView.setSelectedRange(NSRange(location: textView.string.count, length: 0))
}
func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString: String?) -> Bool {
// Don't allow editing before the prompt
if range.location < promptPosition {
return false
}
// Handle Enter key
if replacementString == "\n" {
let command = getCurrentCommand()
executeCommand(command)
hideSuggestion()
return false
}
// Handle Tab key for accepting suggestions
if replacementString == "\t" {
if let suggestion = currentSuggestion {
acceptSuggestion(suggestion)
return false
}
}
// Handle backspace/delete - need to update suggestions
if replacementString == "" {
// Allow the deletion
let result = true
// Update suggestions after deletion
DispatchQueue.main.async { [weak self] in
self?.updateSuggestion()
}
return result
}
// For regular character input, update suggestions after the character is added
let result = true
DispatchQueue.main.async { [weak self] in
self?.updateSuggestion()
}
return result
}
func textViewDidChangeSelection(_ notification: Notification) {
// Allow cursor to be positioned anywhere in the current command
// But if user clicks before the prompt, move cursor to prompt position
let selectedRange = textView.selectedRange()
if selectedRange.location < promptPosition && selectedRange.length == 0 {
textView.setSelectedRange(NSRange(location: promptPosition, length: 0))
}
// Copy on select
if TerminalSettings.shared.copyOnSelect && selectedRange.length > 0 {
let selectedText = (textView.string as NSString).substring(with: selectedRange)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(selectedText, forType: .string)
}
}
private func getCurrentCommand() -> String {
guard promptPosition <= textView.string.count else { return "" }
let startIndex = textView.string.index(textView.string.startIndex, offsetBy: promptPosition)
return String(textView.string[startIndex...])
}
private func updateSuggestion() {
// First, always hide any existing suggestion
hideSuggestion()
guard TerminalSettings.shared.enableAutoSuggestions else {
return
}
let partial = getCurrentCommand().trimmingCharacters(in: .whitespaces)
guard !partial.isEmpty else {
return
}
if let suggestion = HistoryManager.shared.getSuggestion(for: partial) {
showSuggestion(suggestion, for: partial)
}
}
private func showSuggestion(_ fullCommand: String, for partial: String) {
currentSuggestion = fullCommand
// Get the remaining part of the suggestion
let remaining = String(fullCommand.dropFirst(partial.count))
guard !remaining.isEmpty else { return }
// Append the suggestion in gray at the current cursor position
let font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
let suggestionAttr = NSAttributedString(string: remaining, attributes: [
.foregroundColor: NSColor.gray.withAlphaComponent(0.5),
.font: font
])
let currentLength = textView.string.count
textView.textStorage?.append(suggestionAttr)
// Move cursor back to before the suggestion
textView.setSelectedRange(NSRange(location: currentLength, length: 0))
NSLog("Showing suggestion: '\(remaining)' for partial: '\(partial)'")
}
private func hideSuggestion() {
guard let suggestion = currentSuggestion else { return }
// Find and remove any gray text at the end
let textLength = textView.string.count
guard textLength > 0, let storage = textView.textStorage else {
currentSuggestion = nil
return
}
// Scan backwards from the end to find gray text
var removeLength = 0
for i in stride(from: textLength - 1, through: 0, by: -1) {
let attributes = storage.attributes(at: i, effectiveRange: nil)
if let color = attributes[.foregroundColor] as? NSColor,
color.alphaComponent < 1.0 {
removeLength += 1
} else {
break
}
}
if removeLength > 0 {
let removeRange = NSRange(location: textLength - removeLength, length: removeLength)
storage.deleteCharacters(in: removeRange)
NSLog("Removed \(removeLength) chars of gray suggestion")
}
currentSuggestion = nil
}
private func acceptSuggestion(_ suggestion: String) {
guard let suggestion = currentSuggestion else { return }
// Get the current command and calculate remaining
let partial = getCurrentCommand()
let remaining = String(suggestion.dropFirst(partial.count))
// Remove the gray suggestion first by scanning for gray text
let textLength = textView.string.count
var removeLength = 0
if let storage = textView.textStorage {
for i in stride(from: textLength - 1, through: 0, by: -1) {
let attributes = storage.attributes(at: i, effectiveRange: nil)
if let color = attributes[.foregroundColor] as? NSColor,
color.alphaComponent < 1.0 {
removeLength += 1
} else {
break
}
}
if removeLength > 0 {
let removeRange = NSRange(location: textLength - removeLength, length: removeLength)
storage.deleteCharacters(in: removeRange)
}
}
// Add it back in normal color
let theme = TerminalSettings.shared.theme
let font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
let normalAttr = NSAttributedString(string: remaining, attributes: [
.foregroundColor: theme.foreground,
.font: font
])
textView.textStorage?.append(normalAttr)
textView.setSelectedRange(NSRange(location: textView.string.count, length: 0))
currentSuggestion = nil
NSLog("Accepted suggestion: '\(suggestion)'")
}
func executeCommand(_ command: String) {
appendText("\n")
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
// Reset history navigation
historyIndex = -1
currentInput = ""
historyBuffer = [] // Reload on next navigation
// Check for aliases - match the first word
var actualCommand = trimmed
let components = trimmed.split(separator: " ", maxSplits: 1)
if let firstWord = components.first {
let firstWordStr = String(firstWord)
if let aliasCommand = TerminalSettings.shared.commandAliases[firstWordStr] {
// Replace the alias with the actual command, keeping any arguments
if components.count > 1 {
actualCommand = aliasCommand + " " + String(components[1])
} else {
actualCommand = aliasCommand
}
appendText("(alias: \(firstWordStr) → \(aliasCommand))\n")
}
}
// Track directory changes
if actualCommand.hasPrefix("cd ") {
let path = String(actualCommand.dropFirst(3)).trimmingCharacters(in: .whitespaces)
if !path.isEmpty {
currentWorkingDirectory = path
}
}
// Handle built-in commands
if actualCommand == "exit" {
NSApp.terminate(nil)
return
}
if actualCommand == "clear" {
textView.string = ""
showPrompt()
return
}
if actualCommand == "help" {
showHelp()
showPrompt()
return
}
if actualCommand == "aliases" {
showAliases()
showPrompt()
return
}
// Add to history
if !trimmed.isEmpty {
HistoryManager.shared.addCommand(trimmed)
// Send to shell
if let data = (actualCommand + "\n").data(using: .utf8) {
inputPipe?.fileHandleForWriting.write(data)
}
}
// Visual bell on command completion
if TerminalSettings.shared.visualBell {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.flashVisualBell()
}
}
// Show prompt after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.showPrompt()
}
}
private func flashVisualBell() {
guard let layer = textView.layer else { return }
let originalBg = layer.backgroundColor
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.1
layer.backgroundColor = NSColor.white.withAlphaComponent(0.1).cgColor
}, completionHandler: {
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2
layer.backgroundColor = originalBg
})
})
}
private func showHelp() {
appendText("""
Enhanced Terminal Commands:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Keyboard Shortcuts:
• Cmd+T - New tab
• Cmd+W - Close tab
• Cmd+D - Split pane vertically (side-by-side)
• Cmd+Shift+D - Split pane horizontally (top-bottom)
• Cmd+Shift+Enter - Zoom/unzoom active pane
• Cmd+R - Search command history (fuzzy search)
• Cmd+I - AI command generator
• Cmd+P - Command snippets
• Cmd+K - Clear screen
• Cmd+Shift+K - Clear scrollback
• Cmd+, - Settings
• Cmd+? - Full keyboard shortcuts reference
• Tab - Accept auto-suggestion
• Up/Down - Navigate command history
• Cmd+Plus - Increase font size
• Cmd+Minus - Decrease font size
Mouse Actions:
• Select text - Auto-copy to clipboard
• Middle-click - Paste from clipboard
• Cmd+Click URL - Open URL in browser
• Click × button - Close split pane
• Drag divider - Resize split panes
• Double-click tab - Rename tab
Built-in Commands:
• clear - Clear screen
• help - Show this help
• aliases - Show command aliases
• exit - Quit application
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
""")
}
private func showAliases() {
let aliases = TerminalSettings.shared.commandAliases
if aliases.isEmpty {
appendText("No aliases defined. Add them in Settings (Cmd+,)\n")
} else {
appendText("Command Aliases:\n")
appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
for (alias, command) in aliases.sorted(by: { $0.key < $1.key }) {
appendText(" \(alias) → \(command)\n")
}
appendText("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
}
}
func insertCommand(_ command: String) {
textView.insertText(command, replacementRange: NSRange(location: textView.string.count, length: 0))
textView.window?.makeFirstResponder(textView)
}
func cleanup() {
shellTask?.terminate()
outputPipe?.fileHandleForReading.readabilityHandler = nil
}
// MARK: - NSMenuDelegate
func menuNeedsUpdate(_ menu: NSMenu) {
// Enable "Save as Snippet" only if there's selected text
if let saveSnippetItem = menu.items.first(where: { $0.action == #selector(saveSelectionAsSnippet) }) {
let hasSelection = textView.selectedRange().length > 0
saveSnippetItem.isEnabled = hasSelection
}
}
deinit {
cleanup()
}
}
// MARK: - Tab Manager
class TabManager {
var tabs: [TerminalSession] = []
var currentIndex: Int = 0
struct SessionData: Codable {
let title: String
let sessionID: String
let workingDirectory: String?
}
func addTab(title: String = "Terminal") -> TerminalSession {
let session = TerminalSession(title: "\(title) \(tabs.count + 1)")
tabs.append(session)
currentIndex = tabs.count - 1
saveSession()
return session
}
func removeTab(at index: Int) {
guard index < tabs.count else { return }
tabs[index].cleanup()
tabs.remove(at: index)
if currentIndex >= tabs.count {
currentIndex = max(0, tabs.count - 1)
}
saveSession()
}
func selectTab(at index: Int) {
guard index < tabs.count else { return }
currentIndex = index
}
func getCurrentSession() -> TerminalSession? {
guard currentIndex < tabs.count else { return nil }
return tabs[currentIndex]
}
func saveSession() {
let sessionData = tabs.map { SessionData(title: $0.title, sessionID: $0.sessionID.uuidString, workingDirectory: $0.currentWorkingDirectory) }
if let data = try? JSONEncoder().encode(sessionData) {
UserDefaults.standard.set(data, forKey: "terminal_session")
UserDefaults.standard.set(currentIndex, forKey: "terminal_current_tab")
}
}
func loadSession() -> [SessionData] {
guard let data = UserDefaults.standard.data(forKey: "terminal_session"),
let sessions = try? JSONDecoder().decode([SessionData].self, from: data) else {
return []
}
return sessions
}
func getSavedCurrentIndex() -> Int {
return UserDefaults.standard.integer(forKey: "terminal_current_tab")
}
}