Add comprehensive features: snippets, export/import, tab renaming, session persistence, and enhanced auto-suggestions

- Snippets: Start empty, add via right-click context menu on selected text
- Export/Import: Save and load snippets as JSON files with merge/replace options
- Tab Renaming: Double-click tabs to rename them
- Session Persistence: Tabs and their names are saved and restored on app restart
- Delete Snippets: Remove unwanted snippets from the panel
- Auto-suggestions: Comprehensive command database with 400+ commands
  - Includes Git, Docker, Kubernetes, NPM, Python, Ruby, Rust, Go, and more
  - Suggestions from command history, common commands, and system PATH
  - Updates in real-time as you type
  - Press Tab to accept suggestions
- Fixed tab content persistence when switching between tabs
- Fixed tab bar layout and positioning
- All features fully functional and tested
This commit is contained in:
2026-01-31 23:17:02 +00:00
parent 5874b31464
commit 8573e09563
7 changed files with 611 additions and 48 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -5,9 +5,246 @@ class HistoryManager {
static let shared = HistoryManager()
private var history: [String] = []
private let maxHistory = 1000
private var systemCommands: Set<String> = []
private var commonCommands: [String] = [
// Basic Unix/Linux commands
"ls", "ls -la", "ls -lah", "ls -ltr", "ls -lh", "ls -R",
"cd", "cd ..", "cd ~", "cd -", "cd /",
"pwd", "mkdir", "mkdir -p", "rmdir",
"rm", "rm -f", "rm -rf", "rm -i",
"cp", "cp -r", "cp -i", "cp -v",
"mv", "mv -i", "mv -v",
"cat", "less", "more", "head", "head -n", "tail", "tail -f", "tail -n",
"touch", "file", "stat",
// Text processing
"grep", "grep -r", "grep -i", "grep -v", "grep -n", "grep -A", "grep -B",
"egrep", "fgrep", "sed", "awk", "cut", "sort", "uniq", "wc", "wc -l",
"diff", "diff -u", "patch", "tr", "fmt", "fold", "expand", "unexpand",
// File search and find
"find", "find . -name", "find . -type f", "find . -type d", "find . -size",
"locate", "updatedb", "which", "whereis", "whatis", "apropos",
// File permissions
"chmod", "chmod +x", "chmod 755", "chmod 644", "chmod -R",
"chown", "chown -R", "chgrp", "umask",
// Process management
"ps", "ps aux", "ps -ef", "ps aux | grep", "pgrep", "pkill",
"top", "htop", "kill", "kill -9", "killall", "nice", "renice",
"bg", "fg", "jobs", "nohup", "screen", "tmux",
// System info
"uname", "uname -a", "hostname", "uptime", "whoami", "who", "w", "last",
"date", "cal", "df", "df -h", "du", "du -sh", "du -h", "free", "free -h",
"lsblk", "mount", "umount", "fdisk", "lsusb", "lspci", "dmesg",
// Network commands
"ping", "ping -c", "traceroute", "netstat", "netstat -an", "netstat -tulpn",
"ss", "ss -tulpn", "ifconfig", "ip", "ip addr", "ip route",
"curl", "curl -O", "curl -L", "wget", "wget -O", "wget -c",
"ssh", "ssh -i", "ssh -p", "scp", "scp -r", "sftp", "rsync", "rsync -av",
"nc", "nmap", "dig", "nslookup", "host", "whois",
// Archive and compression
"tar", "tar -xvf", "tar -cvf", "tar -xzvf", "tar -czvf", "tar -xjvf", "tar -cjvf",
"zip", "zip -r", "unzip", "unzip -l", "gzip", "gunzip", "bzip2", "bunzip2",
"xz", "unxz", "7z", "rar", "unrar",
// Git commands (comprehensive)
"git", "git init", "git clone", "git clone --depth 1",
"git status", "git status -s",
"git add", "git add .", "git add -A", "git add -p", "git add -u",
"git commit", "git commit -m", "git commit -am", "git commit --amend",
"git push", "git push origin", "git push -u origin", "git push --force", "git push --tags",
"git pull", "git pull origin", "git pull --rebase",
"git fetch", "git fetch origin", "git fetch --all", "git fetch --prune",
"git merge", "git merge --no-ff", "git merge --squash",
"git rebase", "git rebase -i", "git rebase --continue", "git rebase --abort",
"git branch", "git branch -a", "git branch -d", "git branch -D", "git branch -m",
"git checkout", "git checkout -b", "git checkout -", "git checkout --",
"git switch", "git switch -c",
"git log", "git log --oneline", "git log --graph", "git log --all --graph --oneline",
"git diff", "git diff --staged", "git diff HEAD", "git diff --cached",
"git stash", "git stash pop", "git stash list", "git stash apply", "git stash drop",
"git remote", "git remote -v", "git remote add origin", "git remote set-url origin",
"git tag", "git tag -a", "git tag -d", "git push --tags",
"git reset", "git reset --hard", "git reset --soft", "git reset HEAD",
"git revert", "git cherry-pick", "git bisect",
"git clean", "git clean -fd", "git rm", "git mv",
"git show", "git blame", "git reflog",
"git config", "git config --global user.name", "git config --global user.email",
// GitHub CLI
"gh", "gh repo clone", "gh repo create", "gh pr create", "gh pr list", "gh issue create",
// NPM/Node.js
"npm", "npm init", "npm init -y",
"npm install", "npm i", "npm install --save", "npm install --save-dev",
"npm uninstall", "npm update", "npm outdated",
"npm start", "npm run", "npm run build", "npm run dev", "npm run test",
"npm test", "npm run lint", "npm run format",
"npm list", "npm list -g", "npm search", "npm info",
"npm publish", "npm version", "npm audit", "npm audit fix",
"node", "node -v", "npx", "npx create-react-app", "npx create-next-app",
// Yarn
"yarn", "yarn init", "yarn add", "yarn add --dev", "yarn remove",
"yarn install", "yarn upgrade", "yarn start", "yarn build", "yarn test",
"yarn run", "yarn global add",
// pnpm
"pnpm", "pnpm install", "pnpm add", "pnpm remove", "pnpm run",
// Python
"python", "python3", "python -m", "python -c",
"pip", "pip install", "pip install -r requirements.txt", "pip uninstall",
"pip list", "pip freeze", "pip freeze > requirements.txt", "pip show",
"pip3", "pip3 install",
"python -m venv", "python3 -m venv venv",
"source venv/bin/activate", "deactivate",
"python -m pip install --upgrade pip",
"pytest", "pytest -v", "python -m pytest",
// Ruby/Rails
"ruby", "ruby -v", "gem", "gem install", "gem list", "gem update",
"bundle", "bundle install", "bundle update", "bundle exec",
"rails", "rails new", "rails server", "rails console", "rails generate",
"rake", "rake db:migrate", "rake db:seed",
// Docker (comprehensive)
"docker", "docker --version",
"docker ps", "docker ps -a", "docker ps -q",
"docker images", "docker images -a",
"docker run", "docker run -it", "docker run -d", "docker run -p", "docker run --rm",
"docker start", "docker stop", "docker restart", "docker pause", "docker unpause",
"docker rm", "docker rm -f", "docker rmi", "docker rmi -f",
"docker exec", "docker exec -it", "docker exec -it bash", "docker exec -it sh",
"docker logs", "docker logs -f", "docker logs --tail",
"docker build", "docker build -t", "docker build --no-cache",
"docker pull", "docker push", "docker tag",
"docker inspect", "docker stats", "docker top",
"docker network", "docker network ls", "docker network create", "docker network rm",
"docker volume", "docker volume ls", "docker volume create", "docker volume rm",
"docker system prune", "docker system prune -a",
"docker-compose", "docker-compose up", "docker-compose up -d", "docker-compose down",
"docker-compose build", "docker-compose logs", "docker-compose ps",
// Kubernetes
"kubectl", "kubectl version", "kubectl cluster-info",
"kubectl get", "kubectl get pods", "kubectl get services", "kubectl get deployments",
"kubectl get nodes", "kubectl get namespaces", "kubectl get all",
"kubectl describe", "kubectl describe pod", "kubectl describe service",
"kubectl logs", "kubectl logs -f", "kubectl logs --tail",
"kubectl exec", "kubectl exec -it", "kubectl exec -it -- /bin/bash",
"kubectl apply", "kubectl apply -f", "kubectl delete", "kubectl delete -f",
"kubectl create", "kubectl edit", "kubectl scale", "kubectl rollout",
"kubectl port-forward", "kubectl proxy",
// Terraform
"terraform", "terraform init", "terraform plan", "terraform apply", "terraform destroy",
"terraform fmt", "terraform validate", "terraform output",
// AWS CLI
"aws", "aws s3 ls", "aws s3 cp", "aws s3 sync", "aws ec2 describe-instances",
"aws configure", "aws sts get-caller-identity",
// Package managers
"brew", "brew install", "brew update", "brew upgrade", "brew list", "brew search",
"brew uninstall", "brew info", "brew doctor", "brew cleanup",
"apt", "apt update", "apt upgrade", "apt install", "apt remove", "apt search",
"apt-get", "apt-get update", "apt-get upgrade", "apt-get install",
"yum", "yum install", "yum update", "yum remove", "yum search",
"dnf", "dnf install", "dnf update", "dnf remove",
// Rust/Cargo
"cargo", "cargo new", "cargo init", "cargo build", "cargo run", "cargo test",
"cargo check", "cargo clean", "cargo update", "rustc", "rustup",
// Go
"go", "go run", "go build", "go test", "go get", "go mod init", "go mod tidy",
"go fmt", "go vet", "go install",
// Java/Maven/Gradle
"java", "javac", "jar", "mvn", "mvn clean", "mvn install", "mvn test", "mvn package",
"gradle", "gradle build", "gradle test", "gradle run",
// Database
"mysql", "mysql -u root -p", "mysqldump", "psql", "mongo", "redis-cli",
"sqlite3",
// Editors
"vim", "vi", "nano", "emacs", "code", "subl", "atom",
// Shell/Environment
"echo", "echo $PATH", "export", "env", "printenv", "set", "unset",
"alias", "unalias", "source", ".", "exec", "eval",
"history", "clear", "reset", "exit", "logout",
// Help/Documentation
"man", "help", "info", "tldr", "--help", "-h",
// Misc utilities
"xargs", "tee", "yes", "seq", "bc", "expr", "factor", "sleep",
"watch", "time", "timeout", "at", "cron", "crontab", "crontab -e",
"make", "cmake", "gcc", "g++", "clang",
"ln", "ln -s", "readlink", "basename", "dirname",
"md5sum", "sha256sum", "shasum", "openssl",
"jq", "yq", "xmllint", "base64",
// macOS specific
"open", "pbcopy", "pbpaste", "say", "caffeinate", "defaults",
"diskutil", "softwareupdate", "system_profiler",
// Common patterns and combinations
"sudo !!", "sudo -i", "sudo su", "su -",
"ls -la | grep", "ps aux | grep", "history | grep",
"find . -name '*.log'", "find . -type f -name",
"tar -xzvf file.tar.gz", "tar -czvf archive.tar.gz",
"chmod +x script.sh", "./script.sh",
"cat file.txt | grep", "tail -f /var/log/syslog",
"df -h | grep", "du -sh *", "du -sh * | sort -h",
"netstat -tulpn | grep", "lsof -i :8080",
"kill -9 $(ps aux | grep process | awk '{print $2}')",
"export PATH=$PATH:/usr/local/bin",
"source ~/.bashrc", "source ~/.zshrc",
"git log --oneline --graph --all --decorate",
"docker run -it --rm ubuntu bash",
"kubectl get pods -A", "kubectl get pods -o wide",
"npm install && npm start",
"python -m http.server", "python3 -m http.server 8000"
]
init() {
loadHistory()
loadSystemCommands()
}
private func loadSystemCommands() {
// Load commands from PATH in background
DispatchQueue.global(qos: .background).async { [weak self] in
var commands = Set<String>()
// Get PATH directories
let pathString = ProcessInfo.processInfo.environment["PATH"] ?? ""
let paths = pathString.split(separator: ":").map(String.init)
// Common directories to check
let commonPaths = ["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"]
let allPaths = Set(paths + commonPaths)
for path in allPaths {
if let contents = try? FileManager.default.contentsOfDirectory(atPath: path) {
commands.formUnion(contents)
}
}
DispatchQueue.main.async {
self?.systemCommands = commands
NSLog("Loaded \(commands.count) system commands for suggestions")
}
}
}
func addCommand(_ command: String) {
@@ -45,10 +282,33 @@ class HistoryManager {
func getSuggestion(for partial: String) -> String? {
guard !partial.isEmpty else { return nil }
// Find the most recent command that starts with the partial input
return history.reversed().first { command in
command.hasPrefix(partial) && command != partial
let trimmed = partial.trimmingCharacters(in: .whitespaces)
// Priority 1: Recent history (most relevant)
if let historyMatch = history.reversed().first(where: { command in
command.hasPrefix(trimmed) && command != trimmed
}) {
return historyMatch
}
// Priority 2: Common commands with arguments
if let commonMatch = commonCommands.first(where: { command in
command.hasPrefix(trimmed) && command != trimmed
}) {
return commonMatch
}
// Priority 3: System commands (just the command name)
// Only suggest if we're typing a single word (no spaces yet)
if !trimmed.contains(" ") {
if let systemMatch = systemCommands.sorted().first(where: { command in
command.hasPrefix(trimmed) && command != trimmed
}) {
return systemMatch
}
}
return nil
}
func getAllHistory() -> [String] {

View File

@@ -26,7 +26,9 @@ class MainWindowController: NSWindowController {
setupUI()
setupPanels()
setupNotifications()
addNewTab()
// Restore previous session or create new tab
restoreSession()
// Force window to show
window.makeKeyAndOrderFront(nil)
@@ -38,6 +40,28 @@ class MainWindowController: NSWindowController {
NSLog("Window content view: \(window.contentView?.frame ?? .zero)")
}
private func restoreSession() {
let savedSessions = tabManager.loadSession()
if savedSessions.isEmpty {
// No saved session, create default tab
addNewTab()
} else {
// Restore saved tabs
for sessionData in savedSessions {
let session = tabManager.addTab(title: sessionData.title)
session.title = sessionData.title // Override the auto-generated title
session.setupTextView(frame: contentView.bounds)
session.startShell()
}
// Restore current tab index
let savedIndex = tabManager.getSavedCurrentIndex()
updateTabBar()
switchToTab(index: min(savedIndex, tabManager.tabs.count - 1))
}
}
private func setupUI() {
guard let window = window else { return }
@@ -203,7 +227,7 @@ class MainWindowController: NSWindowController {
tabBar.arrangedSubviews.forEach { $0.removeFromSuperview() }
// Add tab buttons
for (index, _) in tabManager.tabs.enumerated() {
for (index, session) in tabManager.tabs.enumerated() {
// Create container for tab button and close button
let tabContainer = NSView()
tabContainer.wantsLayer = true
@@ -211,7 +235,7 @@ class MainWindowController: NSWindowController {
// Tab button
let tabButton = NSButton()
tabButton.title = "Tab \(index + 1)"
tabButton.title = session.title
tabButton.bezelStyle = .rounded
tabButton.tag = index
tabButton.target = self
@@ -219,6 +243,11 @@ class MainWindowController: NSWindowController {
tabButton.font = NSFont.systemFont(ofSize: 11)
tabButton.translatesAutoresizingMaskIntoConstraints = false
// Add double-click for rename
let doubleClickGesture = NSClickGestureRecognizer(target: self, action: #selector(tabDoubleClicked(_:)))
doubleClickGesture.numberOfClicksRequired = 2
tabButton.addGestureRecognizer(doubleClickGesture)
// Highlight current tab
if index == tabManager.currentIndex {
tabButton.contentTintColor = .systemBlue
@@ -321,6 +350,34 @@ class MainWindowController: NSWindowController {
switchToTab(index: sender.tag)
}
@objc func tabDoubleClicked(_ sender: NSClickGestureRecognizer) {
guard let button = sender.view as? NSButton else { return }
let index = button.tag
let alert = NSAlert()
alert.messageText = "Rename Tab"
alert.informativeText = "Enter a new name for this tab:"
let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24))
textField.stringValue = tabManager.tabs[index].title
textField.placeholderString = "Tab name"
alert.accessoryView = textField
alert.addButton(withTitle: "Rename")
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = textField
if alert.runModal() == .alertFirstButtonReturn {
let newName = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if !newName.isEmpty {
tabManager.tabs[index].title = newName
updateTabBar()
tabManager.saveSession()
}
}
}
private func switchToTab(index: Int) {
guard index < tabManager.tabs.count else { return }
@@ -329,6 +386,7 @@ class MainWindowController: NSWindowController {
// Select the new tab
tabManager.selectTab(at: index)
tabManager.saveSession()
// Add the selected tab's scroll view to content view
if let session = tabManager.getCurrentSession() {

View File

@@ -1,4 +1,5 @@
import Cocoa
import UniformTypeIdentifiers
// MARK: - History Search Panel (Ctrl+R)
class HistorySearchPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate {
@@ -156,19 +157,35 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF
container.autoresizingMask = [.width, .height]
// Search field
searchField = NSTextField(frame: NSRect(x: 20, y: container.bounds.height - 42, width: container.bounds.width - 140, height: 24))
searchField = NSTextField(frame: NSRect(x: 20, y: container.bounds.height - 42, width: container.bounds.width - 260, height: 24))
searchField.placeholderString = "Search snippets..."
searchField.autoresizingMask = [.width, .minYMargin]
searchField.delegate = self
// Add button
let addButton = NSButton(frame: NSRect(x: container.bounds.width - 110, y: container.bounds.height - 45, width: 90, height: 30))
addButton.title = "Add New"
let addButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: container.bounds.height - 45, width: 70, height: 30))
addButton.title = "Add"
addButton.bezelStyle = .rounded
addButton.target = self
addButton.action = #selector(addSnippet)
addButton.autoresizingMask = [.minXMargin, .minYMargin]
// Export button
let exportButton = NSButton(frame: NSRect(x: container.bounds.width - 155, y: container.bounds.height - 45, width: 70, height: 30))
exportButton.title = "Export"
exportButton.bezelStyle = .rounded
exportButton.target = self
exportButton.action = #selector(exportSnippets)
exportButton.autoresizingMask = [.minXMargin, .minYMargin]
// Import button
let importButton = NSButton(frame: NSRect(x: container.bounds.width - 80, y: container.bounds.height - 45, width: 70, height: 30))
importButton.title = "Import"
importButton.bezelStyle = .rounded
importButton.target = self
importButton.action = #selector(importSnippets)
importButton.autoresizingMask = [.minXMargin, .minYMargin]
// Table view
let scrollView = NSScrollView(frame: NSRect(x: 20, y: 50, width: container.bounds.width - 40, height: container.bounds.height - 110))
scrollView.autoresizingMask = [.width, .height]
@@ -218,7 +235,14 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF
useButton.action = #selector(selectSnippet)
useButton.autoresizingMask = [.minXMargin, .maxYMargin]
let cancelButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: 10, width: 100, height: 30))
let deleteButton = NSButton(frame: NSRect(x: container.bounds.width - 230, y: 10, width: 100, height: 30))
deleteButton.title = "Delete"
deleteButton.bezelStyle = .rounded
deleteButton.target = self
deleteButton.action = #selector(deleteSnippet)
deleteButton.autoresizingMask = [.minXMargin, .maxYMargin]
let cancelButton = NSButton(frame: NSRect(x: container.bounds.width - 340, y: 10, width: 100, height: 30))
cancelButton.title = "Cancel"
cancelButton.bezelStyle = .rounded
cancelButton.target = self
@@ -227,8 +251,11 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF
container.addSubview(searchField)
container.addSubview(addButton)
container.addSubview(exportButton)
container.addSubview(importButton)
container.addSubview(scrollView)
container.addSubview(useButton)
container.addSubview(deleteButton)
container.addSubview(cancelButton)
contentView = container
@@ -300,6 +327,111 @@ class SnippetPanel: NSPanel, NSTableViewDataSource, NSTableViewDelegate, NSTextF
close()
}
@objc private func deleteSnippet() {
let row = tableView.selectedRow
guard row >= 0 && row < filteredSnippets.count else { return }
let snippet = filteredSnippets[row]
let alert = NSAlert()
alert.messageText = "Delete Snippet?"
alert.informativeText = "Are you sure you want to delete '\(snippet.name)'?"
alert.addButton(withTitle: "Delete")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
SnippetManager.shared.removeSnippet(snippet.id)
filteredSnippets = SnippetManager.shared.getAllSnippets()
tableView.reloadData()
updateEmptyState()
}
}
@objc private func exportSnippets() {
guard let data = SnippetManager.shared.exportSnippets() else {
let alert = NSAlert()
alert.messageText = "Export Failed"
alert.informativeText = "Could not export snippets."
alert.alertStyle = .warning
alert.runModal()
return
}
let savePanel = NSSavePanel()
savePanel.allowedContentTypes = [.json]
savePanel.nameFieldStringValue = "snippets.json"
savePanel.title = "Export Snippets"
if savePanel.runModal() == .OK, let url = savePanel.url {
do {
try data.write(to: url)
let alert = NSAlert()
alert.messageText = "Export Successful"
alert.informativeText = "Snippets exported to \(url.lastPathComponent)"
alert.alertStyle = .informational
alert.runModal()
} catch {
let alert = NSAlert()
alert.messageText = "Export Failed"
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
alert.runModal()
}
}
}
@objc private func importSnippets() {
let openPanel = NSOpenPanel()
openPanel.allowedContentTypes = [.json]
openPanel.allowsMultipleSelection = false
openPanel.title = "Import Snippets"
if openPanel.runModal() == .OK, let url = openPanel.url {
do {
let data = try Data(contentsOf: url)
// Ask if user wants to replace or merge
let alert = NSAlert()
alert.messageText = "Import Snippets"
alert.informativeText = "Do you want to replace existing snippets or merge with them?"
alert.addButton(withTitle: "Merge")
alert.addButton(withTitle: "Replace")
alert.addButton(withTitle: "Cancel")
let response = alert.runModal()
if response == .alertThirdButtonReturn {
return
}
let replace = (response == .alertSecondButtonReturn)
if SnippetManager.shared.importSnippets(from: data, replace: replace) {
filteredSnippets = SnippetManager.shared.getAllSnippets()
tableView.reloadData()
updateEmptyState()
let success = NSAlert()
success.messageText = "Import Successful"
success.informativeText = "Snippets imported successfully."
success.alertStyle = .informational
success.runModal()
} else {
let error = NSAlert()
error.messageText = "Import Failed"
error.informativeText = "Could not parse snippets file."
error.alertStyle = .warning
error.runModal()
}
} catch {
let alert = NSAlert()
alert.messageText = "Import Failed"
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
alert.runModal()
}
}
}
@objc private func cancel() {
completion?(nil)
close()

View File

@@ -52,6 +52,28 @@ class SnippetManager {
return snippets
}
func exportSnippets() -> Data? {
return try? JSONEncoder().encode(snippets)
}
func importSnippets(from data: Data, replace: Bool = false) -> Bool {
guard let imported = try? JSONDecoder().decode([Snippet].self, from: data) else {
return false
}
if replace {
snippets = imported
} else {
// Merge, avoiding duplicates by command
let existingCommands = Set(snippets.map { $0.command })
let newSnippets = imported.filter { !existingCommands.contains($0.command) }
snippets.append(contentsOf: newSnippets)
}
saveSnippets()
return true
}
private func addDefaultSnippets() {
// Removed - snippets now start empty
// Users can add snippets via right-click context menu

View File

@@ -10,7 +10,6 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
var promptPosition = 0
var sessionID: UUID
var title: String
private var suggestionLabel: NSTextField?
private var currentSuggestion: String?
init(title: String = "Terminal") {
@@ -120,15 +119,8 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
}
private func setupSuggestionLabel() {
suggestionLabel = NSTextField(frame: .zero)
suggestionLabel?.isEditable = false
suggestionLabel?.isSelectable = false
suggestionLabel?.isBordered = false
suggestionLabel?.backgroundColor = .clear
suggestionLabel?.isHidden = true
if let label = suggestionLabel {
textView.addSubview(label)
}
// We'll use a different approach - inline gray text
// No separate label needed
}
func applyTheme() {
@@ -140,9 +132,6 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
.backgroundColor: theme.selection
]
textView?.font = NSFont(name: TerminalSettings.shared.fontName, size: TerminalSettings.shared.fontSize) ?? NSFont.monospacedSystemFont(ofSize: TerminalSettings.shared.fontSize, weight: .regular)
suggestionLabel?.textColor = theme.suggestion
suggestionLabel?.font = textView?.font
}
func startShell() {
@@ -257,12 +246,24 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
}
}
// Update suggestions after text changes
// 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 true
return result
}
private func getCurrentCommand() -> String {
@@ -272,57 +273,120 @@ class TerminalSession: NSObject, NSTextViewDelegate, NSMenuDelegate {
}
private func updateSuggestion() {
// First, always hide any existing suggestion
hideSuggestion()
guard TerminalSettings.shared.enableAutoSuggestions else {
hideSuggestion()
return
}
let partial = getCurrentCommand()
let partial = getCurrentCommand().trimmingCharacters(in: .whitespaces)
guard !partial.isEmpty else {
hideSuggestion()
return
}
if let suggestion = HistoryManager.shared.getSuggestion(for: partial) {
showSuggestion(suggestion, for: partial)
} else {
hideSuggestion()
}
}
private func showSuggestion(_ fullCommand: String, for partial: String) {
guard let label = suggestionLabel else { return }
currentSuggestion = fullCommand
// Get the remaining part of the suggestion
let remaining = String(fullCommand.dropFirst(partial.count))
label.stringValue = remaining
guard !remaining.isEmpty else { return }
// Position the label at the cursor
if let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer {
let glyphRange = layoutManager.glyphRange(for: textContainer)
let lastGlyphIndex = layoutManager.glyphIndexForCharacter(at: textView.string.count)
if lastGlyphIndex < glyphRange.upperBound {
let rect = layoutManager.boundingRect(forGlyphRange: NSRange(location: lastGlyphIndex, length: 0), in: textContainer)
label.frame = NSRect(x: rect.maxX, y: rect.minY, width: 400, height: rect.height)
label.isHidden = false
}
}
// 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
suggestionLabel?.isHidden = true
}
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))
textView.insertText(remaining, replacementRange: NSRange(location: textView.string.count, length: 0))
hideSuggestion()
// 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) {
@@ -417,10 +481,16 @@ class TabManager {
var tabs: [TerminalSession] = []
var currentIndex: Int = 0
struct SessionData: Codable {
let title: String
let sessionID: 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
}
@@ -431,6 +501,7 @@ class TabManager {
if currentIndex >= tabs.count {
currentIndex = max(0, tabs.count - 1)
}
saveSession()
}
func selectTab(at index: Int) {
@@ -442,4 +513,24 @@ class TabManager {
guard currentIndex < tabs.count else { return nil }
return tabs[currentIndex]
}
func saveSession() {
let sessionData = tabs.map { SessionData(title: $0.title, sessionID: $0.sessionID.uuidString) }
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")
}
}