From 507d77a0de399b96e4c42ec717674dfbac845db3 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 27 Apr 2026 13:18:27 +1000 Subject: [PATCH] Add further scrollback option for longer terminal history --- .../CommandNotch.xcodeproj/project.pbxproj | 26 +++++- .../UserInterfaceState.xcuserstate | Bin 44303 -> 46635 bytes .../xcschemes/xcschememanagement.plist | 28 +----- CommandNotch/CommandNotch/AppDelegate.swift | 11 +++ .../CommandNotch/Models/AppSettings.swift | 3 + .../Models/AppSettingsController.swift | 3 +- .../Models/AppSettingsStore.swift | 2 + .../CommandNotch/Models/NotchSettings.swift | 3 + .../Models/TerminalScrollbackEstimator.swift | 54 ++++++++++++ .../CommandNotch/Models/TerminalSession.swift | 81 +++++++++++++++++- .../Models/WorkspaceController.swift | 10 +++ .../Models/WorkspaceRegistry.swift | 6 ++ .../Views/TerminalSettingsView.swift | 63 ++++++++++++++ .../AppSettingsControllerTests.swift | 2 + .../AppSettingsStoreTests.swift | 1 + .../ScreenRegistryTests.swift | 3 +- .../TerminalScrollCoordinatorTests.swift | 46 ++++++++++ .../TerminalScrollbackEstimatorTests.swift | 34 ++++++++ .../WorkspaceRegistryTests.swift | 5 +- 19 files changed, 349 insertions(+), 32 deletions(-) create mode 100644 CommandNotch/CommandNotch/Models/TerminalScrollbackEstimator.swift create mode 100644 CommandNotch/CommandNotchTests/TerminalScrollCoordinatorTests.swift create mode 100644 CommandNotch/CommandNotchTests/TerminalScrollbackEstimatorTests.swift diff --git a/CommandNotch/CommandNotch.xcodeproj/project.pbxproj b/CommandNotch/CommandNotch.xcodeproj/project.pbxproj index 9cb5476..4f3e447 100644 --- a/CommandNotch/CommandNotch.xcodeproj/project.pbxproj +++ b/CommandNotch/CommandNotch.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 187F4B521BFC3BD29ADA79E3 /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */; }; 1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */; }; 2089566A2BBAA65EA82119B3 /* NotchOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */; }; + 21373D6E9C2F34FD63CEC6A5 /* TerminalScrollCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D9E323185F6D722B360C3C /* TerminalScrollCoordinatorTests.swift */; }; 2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */; }; 23E2DDCF36D0DAB2EA72C39C /* NotchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */; }; 26AE379040149CBE05B314BB /* WorkspacesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */; }; @@ -22,6 +23,7 @@ 2DF22798D3A7514E2A9183FC /* WorkspaceSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */; }; 34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */; }; 3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */; }; + 40183737D8C237022D0882FD /* TerminalScrollbackEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */; }; 4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */; }; 4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6C98C406899F4B242075AF /* AppSettingsStore.swift */; }; 4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */; }; @@ -56,6 +58,7 @@ D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */; }; DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3125FD3DC55420122CF85D80 /* SettingsView.swift */; }; DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */; }; + DD116B0FCC341D66F1534EC4 /* TerminalScrollbackEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165DCCD7BB164A6470D49BBF /* TerminalScrollbackEstimator.swift */; }; E63FD5862C0EC54E284F6A0F /* ScreenRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */; }; E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */; }; EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */; }; @@ -82,6 +85,7 @@ /* Begin PBXFileReference section */ 0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistry.swift; sourceTree = ""; }; + 165DCCD7BB164A6470D49BBF /* TerminalScrollbackEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollbackEstimator.swift; sourceTree = ""; }; 27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; 297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = ""; }; 2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = ""; }; @@ -91,6 +95,7 @@ 3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceController.swift; sourceTree = ""; }; 3F57837A7115DEEE11E14B40 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = ""; }; 3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsControllerTests.swift; sourceTree = ""; }; + 40D9E323185F6D722B360C3C /* TerminalScrollCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollCoordinatorTests.swift; sourceTree = ""; }; 48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = ""; }; 496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 49E1791BB45E1505500ACC67 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = ""; }; @@ -124,6 +129,7 @@ CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = ""; }; D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeySettingsView.swift; sourceTree = ""; }; D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrameCalculatorTests.swift; sourceTree = ""; }; + D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollbackEstimatorTests.swift; sourceTree = ""; }; DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestrator.swift; sourceTree = ""; }; DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSwitcherView.swift; sourceTree = ""; }; E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = ""; }; @@ -165,6 +171,7 @@ 7181BB1F3926B457445105E5 /* ScreenContext.swift */, AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */, 567E85A2ED628460CEC760DB /* TerminalManager.swift */, + 165DCCD7BB164A6470D49BBF /* TerminalScrollbackEstimator.swift */, 49E1791BB45E1505500ACC67 /* TerminalSession.swift */, CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */, 3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */, @@ -218,6 +225,8 @@ A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */, EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */, C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */, + D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */, + 40D9E323185F6D722B360C3C /* TerminalScrollCoordinatorTests.swift */, D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */, 591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */, 4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */, @@ -370,7 +379,6 @@ }; }; buildConfigurationList = C47A3896770C98F2A3E62B7A /* Build configuration list for PBXProject "CommandNotch" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -383,6 +391,7 @@ 28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 77; + productRefGroup = B269158E04E8E603B61448F0 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -423,6 +432,8 @@ D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */, 8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */, CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */, + 21373D6E9C2F34FD63CEC6A5 /* TerminalScrollCoordinatorTests.swift in Sources */, + 40183737D8C237022D0882FD /* TerminalScrollbackEstimatorTests.swift in Sources */, 0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */, 154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */, 2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */, @@ -465,6 +476,7 @@ 88113BA9B217DA579C36BEBE /* TabBar.swift in Sources */, DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */, 6F249EDFA2D654457DF385F1 /* TerminalManager.swift in Sources */, + DD116B0FCC341D66F1534EC4 /* TerminalScrollbackEstimator.swift in Sources */, 7A69B5AAC686174BCA54D0F0 /* TerminalSession.swift in Sources */, 65C7DB7296C6C6A77598A1F4 /* TerminalSettingsView.swift in Sources */, 1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */, @@ -499,14 +511,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G698BP272N; INFOPLIST_FILE = CommandNotch/Resources/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MARKETING_VERSION = 0.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app; PRODUCT_NAME = CommandNotch; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; }; name = Debug; @@ -598,14 +616,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G698BP272N; INFOPLIST_FILE = CommandNotch/Resources/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MARKETING_VERSION = 0.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app; PRODUCT_NAME = CommandNotch; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; }; name = Release; diff --git a/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate b/CommandNotch/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate index bdfdd8a4f5846646f7bf5d5b46b1d6ea74929bfc..181eef1481a83727b3e708f6d5e8915d937ff46e 100644 GIT binary patch delta 23396 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 a$z~Z(nbo4)GJ{b0t>U2k?)jWG_5TBvU_)U5 diff --git a/CommandNotch/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist b/CommandNotch/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist index aed665a..fbbe581 100644 --- a/CommandNotch/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/CommandNotch/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist @@ -5,33 +5,9 @@ SchemeUserState CommandNotch.xcscheme_^#shared#^_ - - orderHint - 0 - - CommandNotchTests.xcscheme_^#shared#^_ - - orderHint - 2 - - CommandNotchUITests.xcscheme_^#shared#^_ - - orderHint - 2 - + Release-CommandNotch.xcscheme_^#shared#^_ - - orderHint - 1 - - - SuppressBuildableAutocreation - - 1485207FA11756EC2DF4F08B - - primary - - + diff --git a/CommandNotch/CommandNotch/AppDelegate.swift b/CommandNotch/CommandNotch/AppDelegate.swift index 423e855..5ba6061 100644 --- a/CommandNotch/CommandNotch/AppDelegate.swift +++ b/CommandNotch/CommandNotch/AppDelegate.swift @@ -33,6 +33,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { observeSizePreferences() observeFontSizeChanges() observeTerminalThemeChanges() + observeTerminalScrollbackChanges() applyUITestLaunchBehaviorIfNeeded() } @@ -90,6 +91,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { .store(in: &cancellables) } + private func observeTerminalScrollbackChanges() { + settingsController.$settings + .map(\.terminal.scrollbackLines) + .removeDuplicates() + .sink { scrollbackLines in + WorkspaceRegistry.shared.updateAllWorkspacesScrollbackLines(scrollbackLines) + } + .store(in: &cancellables) + } + private var launchArguments: [String] { ProcessInfo.processInfo.arguments } diff --git a/CommandNotch/CommandNotch/Models/AppSettings.swift b/CommandNotch/CommandNotch/Models/AppSettings.swift index 89ac030..e051aa8 100644 --- a/CommandNotch/CommandNotch/Models/AppSettings.swift +++ b/CommandNotch/CommandNotch/Models/AppSettings.swift @@ -48,6 +48,7 @@ struct AppSettings: Equatable, Codable { fontSize: NotchSettings.Defaults.terminalFontSize, shellPath: NotchSettings.Defaults.terminalShell, themeRawValue: NotchSettings.Defaults.terminalTheme, + scrollbackLines: NotchSettings.Defaults.terminalScrollbackLines, sizePresetsJSON: NotchSettings.Defaults.terminalSizePresets ), hotkeys: HotkeySettings( @@ -106,6 +107,7 @@ extension AppSettings { var fontSize: Double var shellPath: String var themeRawValue: String + var scrollbackLines: Int var sizePresetsJSON: String var theme: TerminalTheme { @@ -155,6 +157,7 @@ struct TerminalSessionConfiguration: Equatable { var fontSize: CGFloat var theme: TerminalTheme var shellPath: String + var scrollbackLines: Int } @MainActor diff --git a/CommandNotch/CommandNotch/Models/AppSettingsController.swift b/CommandNotch/CommandNotch/Models/AppSettingsController.swift index 0db8e3a..b8d15ce 100644 --- a/CommandNotch/CommandNotch/Models/AppSettingsController.swift +++ b/CommandNotch/CommandNotch/Models/AppSettingsController.swift @@ -46,7 +46,8 @@ final class AppSettingsController: ObservableObject, TerminalSessionConfiguratio TerminalSessionConfiguration( fontSize: CGFloat(settings.terminal.fontSize), theme: settings.terminal.theme, - shellPath: settings.terminal.shellPath + shellPath: settings.terminal.shellPath, + scrollbackLines: settings.terminal.scrollbackLines ) } diff --git a/CommandNotch/CommandNotch/Models/AppSettingsStore.swift b/CommandNotch/CommandNotch/Models/AppSettingsStore.swift index 74960bb..82abd5f 100644 --- a/CommandNotch/CommandNotch/Models/AppSettingsStore.swift +++ b/CommandNotch/CommandNotch/Models/AppSettingsStore.swift @@ -52,6 +52,7 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType { fontSize: double(NotchSettings.Keys.terminalFontSize, default: NotchSettings.Defaults.terminalFontSize), shellPath: string(NotchSettings.Keys.terminalShell, default: NotchSettings.Defaults.terminalShell), themeRawValue: string(NotchSettings.Keys.terminalTheme, default: NotchSettings.Defaults.terminalTheme), + scrollbackLines: integer(NotchSettings.Keys.terminalScrollbackLines, default: NotchSettings.Defaults.terminalScrollbackLines), sizePresetsJSON: string(NotchSettings.Keys.terminalSizePresets, default: NotchSettings.Defaults.terminalSizePresets) ), hotkeys: .init( @@ -101,6 +102,7 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType { defaults.set(settings.terminal.fontSize, forKey: NotchSettings.Keys.terminalFontSize) defaults.set(settings.terminal.shellPath, forKey: NotchSettings.Keys.terminalShell) defaults.set(settings.terminal.themeRawValue, forKey: NotchSettings.Keys.terminalTheme) + defaults.set(settings.terminal.scrollbackLines, forKey: NotchSettings.Keys.terminalScrollbackLines) defaults.set(settings.terminal.sizePresetsJSON, forKey: NotchSettings.Keys.terminalSizePresets) defaults.set(settings.hotkeys.toggle.toJSON(), forKey: NotchSettings.Keys.hotkeyToggle) diff --git a/CommandNotch/CommandNotch/Models/NotchSettings.swift b/CommandNotch/CommandNotch/Models/NotchSettings.swift index b56dabd..9a1a421 100644 --- a/CommandNotch/CommandNotch/Models/NotchSettings.swift +++ b/CommandNotch/CommandNotch/Models/NotchSettings.swift @@ -47,6 +47,7 @@ enum NotchSettings { static let terminalFontSize = "terminalFontSize" static let terminalShell = "terminalShell" static let terminalTheme = "terminalTheme" + static let terminalScrollbackLines = "terminalScrollbackLines" static let terminalSizePresets = "terminalSizePresets" static let workspaceSummaries = "workspaceSummaries" static let screenAssignments = "screenAssignments" @@ -98,6 +99,7 @@ enum NotchSettings { static let terminalFontSize: Double = 13 static let terminalShell: String = "" static let terminalTheme: String = TerminalTheme.terminalApp.rawValue + static let terminalScrollbackLines: Int = 500 static let terminalSizePresets: String = TerminalSizePresetStore.defaultPresetsJSON() // Default hotkey bindings as JSON @@ -148,6 +150,7 @@ enum NotchSettings { Keys.terminalFontSize: Defaults.terminalFontSize, Keys.terminalShell: Defaults.terminalShell, Keys.terminalTheme: Defaults.terminalTheme, + Keys.terminalScrollbackLines: Defaults.terminalScrollbackLines, Keys.terminalSizePresets: Defaults.terminalSizePresets, Keys.hotkeyToggle: Defaults.hotkeyToggle, diff --git a/CommandNotch/CommandNotch/Models/TerminalScrollbackEstimator.swift b/CommandNotch/CommandNotch/Models/TerminalScrollbackEstimator.swift new file mode 100644 index 0000000..fe5f70d --- /dev/null +++ b/CommandNotch/CommandNotch/Models/TerminalScrollbackEstimator.swift @@ -0,0 +1,54 @@ +import AppKit +import CoreText +import SwiftTerm + +enum TerminalScrollbackEstimator { + private static let minimumColumns = 1 + private static let minimumRows = 1 + private static let defaultBytesPerLineOverhead = 256 + + struct Estimate: Equatable { + let bytes: Int + let columns: Int + let rows: Int + + var formattedBytes: String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .memory) + } + } + + static func estimate( + scrollbackLines: Int, + fontSize: Double, + openWidth: Double, + openHeight: Double + ) -> Estimate { + let safeScrollbackLines = max(0, scrollbackLines) + let dimensions = terminalGridDimensions( + fontSize: fontSize, + openWidth: openWidth, + openHeight: openHeight + ) + let totalLines = safeScrollbackLines + dimensions.rows + let bytesPerCell = MemoryLayout.stride + let bytesPerLine = (dimensions.columns * bytesPerCell) + defaultBytesPerLineOverhead + let totalBytes = max(0, totalLines * bytesPerLine) + + return Estimate(bytes: totalBytes, columns: dimensions.columns, rows: dimensions.rows) + } + + private static func terminalGridDimensions( + fontSize: Double, + openWidth: Double, + openHeight: Double + ) -> (columns: Int, rows: Int) { + let font = NSFont.monospacedSystemFont(ofSize: CGFloat(max(1, fontSize)), weight: .regular) + let cellWidth = max(1, font.advancement(forGlyph: font.glyph(withName: "W")).width) + let cellHeight = max(1, ceil(CTFontGetAscent(font) + CTFontGetDescent(font) + CTFontGetLeading(font))) + + let columns = max(minimumColumns, Int(CGFloat(max(1, openWidth)) / cellWidth)) + let rows = max(minimumRows, Int(CGFloat(max(1, openHeight)) / cellHeight)) + + return (columns, rows) + } +} diff --git a/CommandNotch/CommandNotch/Models/TerminalSession.swift b/CommandNotch/CommandNotch/Models/TerminalSession.swift index 50780d4..8f19165 100644 --- a/CommandNotch/CommandNotch/Models/TerminalSession.swift +++ b/CommandNotch/CommandNotch/Models/TerminalSession.swift @@ -2,6 +2,58 @@ import AppKit import SwiftTerm import Combine +/// Tracks whether the terminal viewport should follow live output or preserve +/// the user's current scrollback position. +final class TerminalScrollCoordinator { + private let bottomThreshold: Double + private var suppressScrollTracking = false + + private(set) var followsOutput = true + private(set) var preservedScrollPosition: Double = 1 + + init(bottomThreshold: Double = 0.999) { + self.bottomThreshold = bottomThreshold + } + + func terminalDidScroll(to position: Double, canScroll: Bool) { + guard !suppressScrollTracking else { return } + + guard canScroll else { + followsOutput = true + preservedScrollPosition = 1 + return + } + + let clampedPosition = min(max(position, 0), 1) + if clampedPosition >= bottomThreshold { + followsOutput = true + preservedScrollPosition = 1 + } else { + followsOutput = false + preservedScrollPosition = clampedPosition + } + } + + func outputRestorePosition(canScroll: Bool) -> Double? { + guard canScroll, !followsOutput else { return nil } + return preservedScrollPosition + } + + @discardableResult + func userDidStartTyping() -> Bool { + let shouldJumpToBottom = !followsOutput + followsOutput = true + preservedScrollPosition = 1 + return shouldJumpToBottom + } + + func suppressTracking(_ body: () -> T) -> T { + suppressScrollTracking = true + defer { suppressScrollTracking = false } + return body() + } +} + /// Wraps a single SwiftTerm TerminalView + LocalProcess pair. @MainActor class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate { @@ -12,7 +64,9 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon private var keyEventMonitor: Any? private let backgroundColor = NSColor.black private let configuredShellPath: String + private var scrollbackLines: Int private let launchDirectory: String + private let scrollCoordinator = TerminalScrollCoordinator() @Published var title: String = "shell" @Published var isRunning: Bool = true @@ -22,11 +76,13 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? = nil, startImmediately: Bool = true ) { terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) configuredShellPath = shellPath + self.scrollbackLines = max(0, scrollbackLines) launchDirectory = Self.resolveInitialDirectory(initialDirectory) currentDirectory = launchDirectory super.init() @@ -36,6 +92,7 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) terminalView.font = font applyTheme(theme) + updateScrollbackLines(self.scrollbackLines) installCommandArrowMonitor() if startImmediately { @@ -128,6 +185,12 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon terminalView.installColors(theme.ansiColors) } + func updateScrollbackLines(_ scrollbackLines: Int) { + let sanitizedScrollbackLines = max(0, scrollbackLines) + self.scrollbackLines = sanitizedScrollbackLines + terminalView.getTerminal().changeHistorySize(sanitizedScrollbackLines) + } + func terminate() { process?.terminate() process = nil @@ -142,7 +205,16 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon nonisolated func dataReceived(slice: ArraySlice) { let data = slice - Task { @MainActor in self.terminalView.feed(byteArray: data) } + Task { @MainActor in + if let restorePosition = self.scrollCoordinator.outputRestorePosition(canScroll: self.terminalView.canScroll) { + self.scrollCoordinator.suppressTracking { + self.terminalView.feed(byteArray: data) + self.terminalView.scroll(toPosition: restorePosition) + } + } else { + self.terminalView.feed(byteArray: data) + } + } } nonisolated func getWindowSize() -> winsize { @@ -155,6 +227,9 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon // MARK: - TerminalViewDelegate func send(source: TerminalView, data: ArraySlice) { + if scrollCoordinator.userDidStartTyping() { + terminalView.scroll(toPosition: 1) + } process?.send(data: data) } @@ -179,7 +254,9 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon currentDirectory = normalizedDirectory } - func scrolled(source: TerminalView, position: Double) {} + func scrolled(source: TerminalView, position: Double) { + scrollCoordinator.terminalDidScroll(to: position, canScroll: source.canScroll) + } func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} func clipboardCopy(source: TerminalView, content: Data) { diff --git a/CommandNotch/CommandNotch/Models/WorkspaceController.swift b/CommandNotch/CommandNotch/Models/WorkspaceController.swift index 732b808..fc63b4b 100644 --- a/CommandNotch/CommandNotch/Models/WorkspaceController.swift +++ b/CommandNotch/CommandNotch/Models/WorkspaceController.swift @@ -7,6 +7,7 @@ protocol TerminalSessionFactoryType { fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession } @@ -16,12 +17,14 @@ struct LiveTerminalSessionFactory: TerminalSessionFactoryType { fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession { TerminalSession( fontSize: fontSize, theme: theme, shellPath: shellPath, + scrollbackLines: scrollbackLines, initialDirectory: initialDirectory ) } @@ -106,6 +109,7 @@ final class WorkspaceController: ObservableObject { fontSize: config.fontSize, theme: config.theme, shellPath: config.shellPath, + scrollbackLines: config.scrollbackLines, initialDirectory: activeTab?.currentDirectory ) @@ -187,4 +191,10 @@ final class WorkspaceController: ObservableObject { tab.applyTheme(theme) } } + + func updateAllScrollbackLines(_ scrollbackLines: Int) { + for tab in tabs { + tab.updateScrollbackLines(scrollbackLines) + } + } } diff --git a/CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift b/CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift index caf893c..0387426 100644 --- a/CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift +++ b/CommandNotch/CommandNotch/Models/WorkspaceRegistry.swift @@ -157,6 +157,12 @@ final class WorkspaceRegistry: ObservableObject { } } + func updateAllWorkspacesScrollbackLines(_ scrollbackLines: Int) { + for controller in controllers.values { + controller.updateAllScrollbackLines(scrollbackLines) + } + } + private func resolvedWorkspaceName(from proposedName: String?) -> String { let trimmed = proposedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmed.isEmpty { diff --git a/CommandNotch/CommandNotch/Views/TerminalSettingsView.swift b/CommandNotch/CommandNotch/Views/TerminalSettingsView.swift index 5662940..d802cd6 100644 --- a/CommandNotch/CommandNotch/Views/TerminalSettingsView.swift +++ b/CommandNotch/CommandNotch/Views/TerminalSettingsView.swift @@ -1,6 +1,8 @@ import SwiftUI struct TerminalSettingsView: View { + private static let scrollbackRange = 0...1_000_000 + @ObservedObject private var settingsController = AppSettingsController.shared @State private var sizePresets: [TerminalSizePreset] = [] @@ -40,6 +42,34 @@ struct TerminalSettingsView: View { .foregroundStyle(.secondary) } + Section("Scrollback") { + HStack { + Text("Lines") + Spacer() + TextField( + "Scrollback lines", + value: scrollbackBinding, + format: .number.grouping(.automatic) + ) + .textFieldStyle(.roundedBorder) + .frame(width: 140) + } + + Stepper( + value: scrollbackBinding, + in: Self.scrollbackRange, + step: scrollbackStepSize + ) { + Text("\(settingsController.settings.terminal.scrollbackLines.formatted()) lines") + .monospacedDigit() + } + + let estimate = scrollbackEstimate + Text("Based on the current terminal size, this may use ~\(estimate.formattedBytes) of RAM (\(estimate.columns) columns x \(estimate.rows) rows visible).") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Size Presets") { ForEach($sizePresets) { $preset in TerminalSizePresetEditor( @@ -94,6 +124,39 @@ struct TerminalSettingsView: View { sizePresets.removeAll { $0.id == id } } + private var scrollbackBinding: Binding { + Binding( + get: { settingsController.settings.terminal.scrollbackLines }, + set: { newValue in + let clampedValue = min(max(Self.scrollbackRange.lowerBound, newValue), Self.scrollbackRange.upperBound) + settingsController.update { + $0.terminal.scrollbackLines = clampedValue + } + } + ) + } + + private var scrollbackEstimate: TerminalScrollbackEstimator.Estimate { + TerminalScrollbackEstimator.estimate( + scrollbackLines: settingsController.settings.terminal.scrollbackLines, + fontSize: settingsController.settings.terminal.fontSize, + openWidth: settingsController.settings.display.openWidth, + openHeight: settingsController.settings.display.openHeight + ) + } + + private var scrollbackStepSize: Int { + let lines = settingsController.settings.terminal.scrollbackLines + switch lines { + case ..<10_000: + return 500 + case ..<100_000: + return 5_000 + default: + return 10_000 + } + } + private func applyPreset(_ preset: TerminalSizePreset) { settingsController.update { $0.display.openWidth = preset.width diff --git a/CommandNotch/CommandNotchTests/AppSettingsControllerTests.swift b/CommandNotch/CommandNotchTests/AppSettingsControllerTests.swift index a104004..13e8a73 100644 --- a/CommandNotch/CommandNotchTests/AppSettingsControllerTests.swift +++ b/CommandNotch/CommandNotchTests/AppSettingsControllerTests.swift @@ -7,11 +7,13 @@ final class AppSettingsControllerTests: XCTestCase { let store = InMemoryAppSettingsStore() var settings = AppSettings.default settings.terminal.shellPath = "/opt/homebrew/bin/fish" + settings.terminal.scrollbackLines = 12_000 store.storedSettings = settings let controller = AppSettingsController(store: store) XCTAssertEqual(controller.terminalSessionConfiguration.shellPath, "/opt/homebrew/bin/fish") + XCTAssertEqual(controller.terminalSessionConfiguration.scrollbackLines, 12_000) } func testTerminalSizePresetsDecodeFromTypedSettings() { diff --git a/CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift b/CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift index 4fcb853..a7ffaaf 100644 --- a/CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift +++ b/CommandNotch/CommandNotchTests/AppSettingsStoreTests.swift @@ -26,6 +26,7 @@ final class AppSettingsStoreTests: XCTestCase { settings.appearance.blurRadius = 4.5 settings.terminal.fontSize = 16 settings.terminal.themeRawValue = TerminalTheme.dracula.rawValue + settings.terminal.scrollbackLines = 25_000 settings.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets([ TerminalSizePreset(name: "Wide", width: 960, height: 420, hotkey: .cmdShiftDigit(4)) ]) diff --git a/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift b/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift index 8c366c7..12773d2 100644 --- a/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift +++ b/CommandNotch/CommandNotchTests/ScreenRegistryTests.swift @@ -306,7 +306,7 @@ private final class TestAppSettingsStore: AppSettingsStoreType { } private final class ScreenRegistryTestSettingsProvider: TerminalSessionConfigurationProviding { - let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "") + let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "", scrollbackLines: 500) let hotkeySettings = AppSettings.default.hotkeys let terminalSizePresets = TerminalSizePresetStore.loadDefaults() } @@ -317,6 +317,7 @@ private struct ScreenRegistryUnusedTerminalSessionFactory: TerminalSessionFactor fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession { fatalError("ScreenRegistryTests should not create live terminal sessions.") diff --git a/CommandNotch/CommandNotchTests/TerminalScrollCoordinatorTests.swift b/CommandNotch/CommandNotchTests/TerminalScrollCoordinatorTests.swift new file mode 100644 index 0000000..508bc7b --- /dev/null +++ b/CommandNotch/CommandNotchTests/TerminalScrollCoordinatorTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import CommandNotch + +final class TerminalScrollCoordinatorTests: XCTestCase { + func testScrollAwayFromBottomDisablesOutputFollow() { + let coordinator = TerminalScrollCoordinator() + + coordinator.terminalDidScroll(to: 0.42, canScroll: true) + + XCTAssertFalse(coordinator.followsOutput) + XCTAssertEqual(coordinator.outputRestorePosition(canScroll: true) ?? .nan, 0.42, accuracy: 0.0001) + } + + func testTypingReEnablesFollowAndRequestsJumpToBottom() { + let coordinator = TerminalScrollCoordinator() + coordinator.terminalDidScroll(to: 0.42, canScroll: true) + + let shouldJump = coordinator.userDidStartTyping() + + XCTAssertTrue(shouldJump) + XCTAssertTrue(coordinator.followsOutput) + XCTAssertNil(coordinator.outputRestorePosition(canScroll: true)) + } + + func testScrollingBackToBottomReEnablesOutputFollow() { + let coordinator = TerminalScrollCoordinator() + coordinator.terminalDidScroll(to: 0.42, canScroll: true) + + coordinator.terminalDidScroll(to: 1, canScroll: true) + + XCTAssertTrue(coordinator.followsOutput) + XCTAssertNil(coordinator.outputRestorePosition(canScroll: true)) + } + + func testSuppressedTrackingIgnoresProgrammaticScrollUpdates() { + let coordinator = TerminalScrollCoordinator() + coordinator.terminalDidScroll(to: 0.42, canScroll: true) + + coordinator.suppressTracking { + coordinator.terminalDidScroll(to: 1, canScroll: true) + } + + XCTAssertFalse(coordinator.followsOutput) + XCTAssertEqual(coordinator.outputRestorePosition(canScroll: true) ?? .nan, 0.42, accuracy: 0.0001) + } +} diff --git a/CommandNotch/CommandNotchTests/TerminalScrollbackEstimatorTests.swift b/CommandNotch/CommandNotchTests/TerminalScrollbackEstimatorTests.swift new file mode 100644 index 0000000..6621e84 --- /dev/null +++ b/CommandNotch/CommandNotchTests/TerminalScrollbackEstimatorTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import CommandNotch + +final class TerminalScrollbackEstimatorTests: XCTestCase { + func testEstimateIncreasesAsScrollbackGrows() { + let small = TerminalScrollbackEstimator.estimate( + scrollbackLines: 5_000, + fontSize: 13, + openWidth: 640, + openHeight: 350 + ) + let large = TerminalScrollbackEstimator.estimate( + scrollbackLines: 100_000, + fontSize: 13, + openWidth: 640, + openHeight: 350 + ) + + XCTAssertGreaterThan(large.bytes, small.bytes) + XCTAssertGreaterThan(large.columns, 0) + XCTAssertGreaterThan(large.rows, 0) + } + + func testEstimateClampsNegativeScrollbackToZero() { + let estimate = TerminalScrollbackEstimator.estimate( + scrollbackLines: -1_000, + fontSize: 13, + openWidth: 640, + openHeight: 350 + ) + + XCTAssertGreaterThan(estimate.bytes, 0) + } +} diff --git a/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift b/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift index c1c2a4e..b135fb9 100644 --- a/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift +++ b/CommandNotch/CommandNotchTests/WorkspaceRegistryTests.swift @@ -191,7 +191,7 @@ private final class InMemoryWorkspaceStore: WorkspaceStoreType { } private final class TestSettingsProvider: TerminalSessionConfigurationProviding { - let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "") + let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "", scrollbackLines: 500) let hotkeySettings = AppSettings.default.hotkeys let terminalSizePresets = TerminalSizePresetStore.loadDefaults() } @@ -204,6 +204,7 @@ private final class RecordingTerminalSessionFactory: TerminalSessionFactoryType fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession { requestedDirectories.append(initialDirectory) @@ -211,6 +212,7 @@ private final class RecordingTerminalSessionFactory: TerminalSessionFactoryType fontSize: fontSize, theme: theme, shellPath: shellPath, + scrollbackLines: scrollbackLines, initialDirectory: initialDirectory, startImmediately: false ) @@ -223,6 +225,7 @@ private struct UnusedTerminalSessionFactory: TerminalSessionFactoryType { fontSize: CGFloat, theme: TerminalTheme, shellPath: String, + scrollbackLines: Int, initialDirectory: String? ) -> TerminalSession { fatalError("WorkspaceRegistryTests should not create live terminal sessions.")