- 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
757 lines
28 KiB
Swift
757 lines
28 KiB
Swift
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")
|
||
}
|
||
}
|