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

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")
}
}