Add further scrollback option for longer terminal history

This commit is contained in:
2026-04-27 13:18:27 +10:00
parent 9f6e607e78
commit 507d77a0de
19 changed files with 349 additions and 32 deletions

View File

@@ -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 = "<group>"; };
165DCCD7BB164A6470D49BBF /* TerminalScrollbackEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollbackEstimator.swift; sourceTree = "<group>"; };
27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };
297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; };
2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = "<group>"; };
@@ -91,6 +95,7 @@
3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceController.swift; sourceTree = "<group>"; };
3F57837A7115DEEE11E14B40 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = "<group>"; };
3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsControllerTests.swift; sourceTree = "<group>"; };
40D9E323185F6D722B360C3C /* TerminalScrollCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollCoordinatorTests.swift; sourceTree = "<group>"; };
48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -124,6 +129,7 @@
CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = "<group>"; };
D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeySettingsView.swift; sourceTree = "<group>"; };
D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrameCalculatorTests.swift; sourceTree = "<group>"; };
D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollbackEstimatorTests.swift; sourceTree = "<group>"; };
DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestrator.swift; sourceTree = "<group>"; };
DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSwitcherView.swift; sourceTree = "<group>"; };
E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = "<group>"; };
@@ -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;

View File

@@ -5,33 +5,9 @@
<key>SchemeUserState</key>
<dict>
<key>CommandNotch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>CommandNotchTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>CommandNotchUITests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<dict/>
<key>Release-CommandNotch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>1485207FA11756EC2DF4F08B</key>
<dict>
<key>primary</key>
<true/>
</dict>
<dict/>
</dict>
</dict>
</plist>

View File

@@ -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
}

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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<CharData>.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)
}
}

View File

@@ -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<T>(_ 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<UInt8>) {
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<UInt8>) {
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) {

View File

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

View File

@@ -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 {

View File

@@ -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<Int> {
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

View File

@@ -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() {

View File

@@ -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))
])

View File

@@ -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.")

View File

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

View File

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

View File

@@ -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.")