From 582a4988eb03de0b2f951078461a27bd7a6e6e95 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:48:49 -0700 Subject: [PATCH 01/28] feat: add terminal timestamp gutter --- Resources/Localizable.xcstrings | 136 ++++++++++ Sources/App/WorkspaceRuntimeSettings.swift | 37 +++ Sources/AppDelegate.swift | 11 + Sources/CmuxSettingsJSONPathSupport.swift | 1 + ...ntentView+RightSidebarCommandPalette.swift | 2 + Sources/ContentView.swift | 15 ++ Sources/GhosttyTerminalView.swift | 247 +++++++++++++++++- Sources/KeyboardShortcutSettings.swift | 4 + ...rdShortcutSettingsFileStore+Template.swift | 1 + .../KeyboardShortcutSettingsFileStore.swift | 10 + Sources/SettingsNavigation.swift | 2 + Sources/SettingsSearchAliases.swift | 1 + Sources/cmuxApp.swift | 40 +++ ...ttyTerminalViewVisibilityPolicyTests.swift | 85 ++++++ ...hortcutSettingsFileStoreStartupTests.swift | 53 ++++ web/app/[locale]/docs/configuration/page.tsx | 1 + web/data/cmux-shortcuts.ts | 1 + web/data/cmux.schema.json | 5 + 18 files changed, 651 insertions(+), 1 deletion(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f51105c327ad..c7f4c7d0429c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -105432,6 +105432,142 @@ } } }, + "settings.terminal.timestamps": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Terminal Timestamps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのタイムスタンプを表示" + } + } + } + }, + "settings.terminal.timestamps.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hides the terminal timestamp gutter. Timestamps can also be toggled from the View menu or command palette." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのタイムスタンプ欄を隠します。タイムスタンプは表示メニューやコマンドパレットからも切り替えられます。" + } + } + } + }, + "settings.terminal.timestamps.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows a left-side time gutter aligned with terminal output rows." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル出力行にそろえた左側の時刻欄を表示します。" + } + } + } + }, + "shortcut.toggleTerminalTimestamps.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Terminal Timestamps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルタイムスタンプを切り替え" + } + } + } + }, + "menu.view.toggleTerminalTimestamps": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Terminal Timestamps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルタイムスタンプを切り替え" + } + } + } + }, + "command.terminalToggleTimestamps.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Terminal Timestamps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルタイムスタンプを切り替え" + } + } + } + }, + "terminal.timestamps.gutter.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal timestamps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルタイムスタンプ" + } + } + } + }, + "settings.search.alias.setting.terminal.timestamps": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps タイムスタンプ 時刻 出力 行 長時間 エージェント 停止 デバッグ" + } + } + } + }, "settings.terminal.agentAutoResume": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index 09034793d438..e94038fa3b40 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -109,6 +109,43 @@ enum TerminalScrollBarSettings { } } +enum TerminalTimestampsSettings { + static let showTimestampsKey = "terminal.showTimestamps" + static let defaultShowTimestamps = false + static let didChangeNotification = Notification.Name("cmux.terminalTimestampsSettingsDidChange") + + static func isVisible(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showTimestampsKey) == nil { + return defaultShowTimestamps + } + return defaults.bool(forKey: showTimestampsKey) + } + + @discardableResult + static func setVisible( + _ visible: Bool, + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) -> Bool { + guard isVisible(defaults: defaults) != visible else { return visible } + defaults.set(visible, forKey: showTimestampsKey) + notifyDidChange(notificationCenter: notificationCenter) + return visible + } + + @discardableResult + static func toggle( + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) -> Bool { + setVisible(!isVisible(defaults: defaults), defaults: defaults, notificationCenter: notificationCenter) + } + + static func notifyDidChange(notificationCenter: NotificationCenter = .default) { + notificationCenter.post(name: didChangeNotification, object: nil) + } +} + enum AgentSessionAutoResumeSettings { static let autoResumeAgentSessionsKey = "terminal.autoResumeAgentSessions" static let defaultAutoResumeAgentSessions = true diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9cd24971caa3..be55d0aa422c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11253,6 +11253,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return handled } + if matchConfiguredShortcut(event: event, action: .toggleTerminalTimestamps) { + let enabled = TerminalTimestampsSettings.toggle() +#if DEBUG + cmuxDebugLog( + "shortcut.action name=toggleTerminalTimestamps enabled=\(enabled ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + return true + } + // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { #if DEBUG diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index f40f1fd297db..74ef61f41bac 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -37,6 +37,7 @@ extension CmuxSettingsFileStore { "app.renameSelectsExistingName", "app.commandPaletteSearchesAllSurfaces", "terminal.showScrollBar", + "terminal.showTimestamps", "terminal.autoResumeAgentSessions", "notifications.dockBadge", "notifications.showInMenuBar", diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 21190b652e95..510daeadc88f 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -67,6 +67,8 @@ extension ContentView { return .hideFind case "palette.terminalUseSelectionForFind": return .useSelectionForFind + case "palette.toggleTerminalTimestamps": + return .toggleTerminalTimestamps case "palette.toggleSplitZoom": return .toggleSplitZoom case "palette.equalizeSplits": diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0570b9b74126..f3c6b56a3513 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6008,6 +6008,8 @@ struct ContentView: View { return "⌥⌘⇧F" case "palette.terminalUseSelectionForFind": return "⌘E" + case "palette.toggleTerminalTimestamps": + return "⌥⌘⇧E" case "palette.toggleFullScreen": return "\u{2303}\u{2318}F" default: @@ -6899,6 +6901,16 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTerminalTimestamps", + title: constant(String(localized: "command.terminalToggleTimestamps.title", defaultValue: "Toggle Terminal Timestamps")), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌥⌘⇧E", + keywords: ["terminal", "timestamps", "time", "output"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalSplitRight", @@ -7466,6 +7478,9 @@ struct ContentView: View { registry.register(commandId: "palette.terminalUseSelectionForFind") { tabManager.searchSelection() } + registry.register(commandId: "palette.toggleTerminalTimestamps") { + _ = TerminalTimestampsSettings.toggle() + } registry.register(commandId: "palette.terminalSplitRight") { if !executeConfiguredAction(id: CmuxSurfaceTabBarBuiltInAction.splitRight.configID) { tabManager.createSplit(direction: .right) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 259e40192c3a..4ca2a5601ec8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9695,6 +9695,166 @@ private final class GhosttyFlashOverlayView: NSView { } } +struct TerminalTimestampScrollbarState: Equatable { + let total: Int + let offset: Int + let len: Int + + init(total: Int, offset: Int, len: Int) { + self.total = max(0, total) + self.offset = max(0, offset) + self.len = max(0, len) + } + + init(_ scrollbar: GhosttyScrollbar) { + self.init(total: scrollbar.total, offset: scrollbar.offset, len: scrollbar.len) + } +} + +struct TerminalTimestampVisibleRow: Equatable { + let row: Int + let timestamp: Date +} + +final class TerminalTimestampStore { + private let maxRetainedRows: Int + private var timestampsByRow: [Int: Date] = [:] + private var lastScrollbar: TerminalTimestampScrollbarState? + + init(maxRetainedRows: Int = 20_000) { + self.maxRetainedRows = max(1, maxRetainedRows) + } + + func record( + scrollbar: TerminalTimestampScrollbarState, + at date: Date, + markVisibleRows: Bool + ) { + if let previous = lastScrollbar { + if scrollbar.total < previous.total { + timestampsByRow = timestampsByRow.filter { entry in entry.key < scrollbar.total } + } else if scrollbar.total > previous.total { + for row in previous.total.. [TerminalTimestampVisibleRow] { + visibleRange(for: scrollbar).compactMap { row in + guard let timestamp = timestampsByRow[row] else { return nil } + return TerminalTimestampVisibleRow(row: row, timestamp: timestamp) + } + } + + func reset() { + timestampsByRow.removeAll() + lastScrollbar = nil + } + + private func visibleRange(for scrollbar: TerminalTimestampScrollbarState) -> Range { + guard scrollbar.total > 0, scrollbar.len > 0 else { return 0..<0 } + let lower = min(scrollbar.offset, scrollbar.total) + let upper = min(scrollbar.total, lower + scrollbar.len) + return lower..= minimumRow && entry.key < totalRows + } + } +} + +private final class TerminalTimestampGutterView: NSView { + private static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter + }() + + var onMouseDown: (() -> Void)? + private var rows: [TerminalTimestampVisibleRow] = [] + private var visibleTopRow: CGFloat = 0 + private var cellHeight: CGFloat = 0 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.isOpaque = false + setAccessibilityRole(.group) + setAccessibilityLabel( + String(localized: "terminal.timestamps.gutter.accessibility", defaultValue: "Terminal timestamps") + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + override var acceptsFirstResponder: Bool { false } + override var isOpaque: Bool { false } + + func update( + rows: [TerminalTimestampVisibleRow], + visibleTopRow: CGFloat, + cellHeight: CGFloat + ) { + self.rows = rows + self.visibleTopRow = visibleTopRow + self.cellHeight = cellHeight + needsDisplay = true + } + + override func mouseDown(with event: NSEvent) { + onMouseDown?() + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + NSColor.separatorColor.withAlphaComponent(0.35).setFill() + NSRect(x: bounds.maxX - 1, y: 0, width: 1, height: bounds.height).fill() + + guard cellHeight > 0, !rows.isEmpty else { return } + + let fontSize = min(11, max(9, cellHeight * 0.58)) + let font = NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .regular) + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .right + paragraph.lineBreakMode = .byClipping + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.secondaryLabelColor.withAlphaComponent(0.72), + .paragraphStyle: paragraph, + ] + let lineHeight = font.ascender - font.descender + font.leading + let textWidth = max(0, bounds.width - 12) + + for row in rows { + let rowOffset = CGFloat(row.row) - visibleTopRow + let rowTopFromBottom = bounds.height - ((rowOffset + 1) * cellHeight) + let textY = floor(rowTopFromBottom + ((cellHeight - lineHeight) / 2)) + let textRect = NSRect(x: 4, y: textY, width: textWidth, height: lineHeight) + guard textRect.intersects(dirtyRect) else { continue } + Self.formatter.string(from: row.timestamp).draw(in: textRect, withAttributes: attributes) + } + } +} + final class GhosttySurfaceScrollView: NSView { enum FlashStyle { case navigation @@ -9719,6 +9879,8 @@ final class GhosttySurfaceScrollView: NSView { } } + private static let timestampGutterWidth: CGFloat = 88 + private enum NotificationRingMetrics { static let inset = PanelOverlayRingMetrics.inset static let cornerRadius = PanelOverlayRingMetrics.cornerRadius @@ -9726,6 +9888,7 @@ final class GhosttySurfaceScrollView: NSView { } private let backgroundView: NSView + private let timestampGutterView: TerminalTimestampGutterView private let scrollView: GhosttyScrollView private let documentView: NSView private let surfaceView: GhosttyNSView @@ -9758,6 +9921,7 @@ final class GhosttySurfaceScrollView: NSView { private var imageTransferIndicatorShowWorkItem: DispatchWorkItem? private var activeImageTransferOperation: TerminalImageTransferOperation? private var activeImageTransferCancelHandler: (() -> Void)? + private let timestampStore = TerminalTimestampStore() private var lastSearchOverlayStateID: ObjectIdentifier? private weak var cachedOwningWorkspace: Workspace? private weak var observedWorkspaceTerminalScrollBar: Workspace? @@ -9973,6 +10137,7 @@ final class GhosttySurfaceScrollView: NSView { self.surfaceView = surfaceView backgroundView = NSView(frame: .zero) + timestampGutterView = TerminalTimestampGutterView(frame: .zero) scrollView = GhosttyScrollView() inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero) dropZoneOverlayView = GhosttyFlashOverlayView(frame: .zero) @@ -10013,6 +10178,11 @@ final class GhosttySurfaceScrollView: NSView { backgroundView.layer?.isOpaque = false addSubview(backgroundView) addSubview(scrollView) + timestampGutterView.isHidden = !TerminalTimestampsSettings.isVisible() + timestampGutterView.onMouseDown = { [weak self] in + self?.moveFocus() + } + addSubview(timestampGutterView) paneDropTargetView.hostedView = self addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) synchronizeScrollbarAppearance() @@ -10292,6 +10462,14 @@ final class GhosttySurfaceScrollView: NSView { self?.handleTerminalScrollBarPreferenceChange() }) + observers.append(NotificationCenter.default.addObserver( + forName: TerminalTimestampsSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleTerminalTimestampsPreferenceChange() + }) + } required init?(coder: NSCoder) { @@ -10405,8 +10583,20 @@ final class GhosttySurfaceScrollView: NSView { let didScrollbarAppearanceChange = synchronizeScrollbarAppearance() let previousSurfaceSize = surfaceView.frame.size + let timestampGutterWidth = terminalTimestampGutterWidth() _ = setFrameIfNeeded(backgroundView, to: bounds) - _ = setFrameIfNeeded(scrollView, to: bounds) + _ = setFrameIfNeeded( + timestampGutterView, + to: CGRect(x: 0, y: 0, width: timestampGutterWidth, height: bounds.height) + ) + timestampGutterView.isHidden = timestampGutterWidth <= 0 + let scrollFrame = CGRect( + x: timestampGutterWidth, + y: 0, + width: max(0, bounds.width - timestampGutterWidth), + height: bounds.height + ) + _ = setFrameIfNeeded(scrollView, to: scrollFrame) let targetSize = scrollView.bounds.size #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) @@ -10457,6 +10647,7 @@ final class GhosttySurfaceScrollView: NSView { synchronizeScrollView() synchronizeSurfaceView() let didCoreSurfaceChange = synchronizeCoreSurface() + refreshTimestampGutter() return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange } @@ -12511,6 +12702,33 @@ final class GhosttySurfaceScrollView: NSView { // Intentionally no-op (no retry loops). } + private func terminalTimestampGutterWidth() -> CGFloat { + TerminalTimestampsSettings.isVisible() ? Self.timestampGutterWidth : 0 + } + + private func currentVisibleTopRow(cellHeight: CGFloat) -> CGFloat { + guard cellHeight > 0 else { return 0 } + let visibleRect = scrollView.contentView.documentVisibleRect + let scrollOffset = documentView.frame.height - visibleRect.origin.y - visibleRect.height + return max(0, scrollOffset / cellHeight) + } + + private func refreshTimestampGutter() { + guard TerminalTimestampsSettings.isVisible(), + let scrollbar = surfaceView.scrollbar, + surfaceView.cellSize.height > 0 else { + timestampGutterView.update(rows: [], visibleTopRow: 0, cellHeight: 0) + return + } + + let state = TerminalTimestampScrollbarState(scrollbar) + timestampGutterView.update( + rows: timestampStore.visibleRows(for: state), + visibleTopRow: currentVisibleTopRow(cellHeight: surfaceView.cellSize.height), + cellHeight: surfaceView.cellSize.height + ) + } + private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return } @@ -12628,6 +12846,7 @@ final class GhosttySurfaceScrollView: NSView { private func handleScrollChange() { synchronizeSurfaceView() + refreshTimestampGutter() } private func handleLiveScroll() { @@ -12657,6 +12876,13 @@ final class GhosttySurfaceScrollView: NSView { return } let wasVisible = scrollView.hasVerticalScroller + let isUserInitiatedScroll = pendingExplicitWheelScroll || isLiveScrolling + let shouldMarkVisibleTimestampRows = !isUserInitiatedScroll && !userScrolledAwayFromBottom + timestampStore.record( + scrollbar: TerminalTimestampScrollbarState(scrollbar), + at: .now, + markVisibleRows: shouldMarkVisibleTimestampRows + ) if pendingExplicitWheelScroll { userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total allowExplicitScrollbarSync = true @@ -12669,6 +12895,7 @@ final class GhosttySurfaceScrollView: NSView { return } synchronizeScrollView() + refreshTimestampGutter() } @discardableResult @@ -12715,6 +12942,24 @@ final class GhosttySurfaceScrollView: NSView { _ = synchronizeGeometryAndContent() } + private func handleTerminalTimestampsPreferenceChange() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.handleTerminalTimestampsPreferenceChange() + } + return + } + + if TerminalTimestampsSettings.isVisible(), let scrollbar = surfaceView.scrollbar { + timestampStore.record( + scrollbar: TerminalTimestampScrollbarState(scrollbar), + at: .now, + markVisibleRows: true + ) + } + _ = synchronizeGeometryAndContent() + } + private func updateWorkspaceTerminalScrollBarObserver(_ workspace: Workspace?) { if let observedWorkspaceTerminalScrollBar, observedWorkspaceTerminalScrollBar === workspace, diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f34776d4fe18..20f8ccc7f5a0 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -102,6 +102,7 @@ enum KeyboardShortcutSettings { case reopenClosedBrowserPanel case newSurface case toggleTerminalCopyMode + case toggleTerminalTimestamps // Panes / splits case focusLeft @@ -181,6 +182,7 @@ enum KeyboardShortcutSettings { case .reopenClosedBrowserPanel: return String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel") case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface") case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode") + case .toggleTerminalTimestamps: return String(localized: "shortcut.toggleTerminalTimestamps.label", defaultValue: "Toggle Terminal Timestamps") case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left") case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right") case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up") @@ -329,6 +331,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) case .toggleTerminalCopyMode: return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false) + case .toggleTerminalTimestamps: + return StoredShortcut(key: "e", command: true, shift: true, option: true, control: false) case .selectWorkspaceByNumber: return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) case .toggleRightSidebar: diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 066656c88316..214adb44badd 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -78,6 +78,7 @@ extension CmuxSettingsFileStore { [ "terminal": [ "showScrollBar": TerminalScrollBarSettings.defaultShowScrollBar, + "showTimestamps": TerminalTimestampsSettings.defaultShowTimestamps, "autoResumeAgentSessions": AgentSessionAutoResumeSettings.defaultAutoResumeAgentSessions, ], ], diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 0ed9252c47bc..9364a95bd761 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -457,6 +457,12 @@ final class CmuxSettingsFileStore { logInvalid("terminal.showScrollBar", sourcePath: sourcePath) } + if let value = jsonBool(section["showTimestamps"]) { + snapshot.managedUserDefaults[TerminalTimestampsSettings.showTimestampsKey] = .bool(value) + } else if section.keys.contains("showTimestamps") { + logInvalid("terminal.showTimestamps", sourcePath: sourcePath) + } + if let value = jsonBool(section["autoResumeAgentSessions"]) { snapshot.managedUserDefaults[AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey] = .bool(value) } else if section.keys.contains("autoResumeAgentSessions") { @@ -1190,6 +1196,7 @@ final class CmuxSettingsFileStore { ) -> ManagedDefaultBatchSideEffects { let notificationCenter = notificationCenter let notifyScrollBar = defaultsKey == TerminalScrollBarSettings.showScrollBarKey + let notifyTimestamps = defaultsKey == TerminalTimestampsSettings.showTimestampsKey var sideEffects = ManagedDefaultBatchSideEffects() sideEffects.agentSessionAutoResumeDidChange = defaultsKey == AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey @@ -1201,6 +1208,9 @@ final class CmuxSettingsFileStore { if notifyScrollBar { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) } + if notifyTimestamps { + TerminalTimestampsSettings.notifyDidChange(notificationCenter: notificationCenter) + } if let language { LanguageSettings.apply(language) diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 6d2c4943b768..31e5c117f961 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -315,6 +315,7 @@ enum SettingsSearchIndex { setting(.app, "rename-selects-name", String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), "command palette rename text selection"), setting(.app, "palette-search-all", String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), "cmd p search terminal browser markdown"), setting(.terminal, "scrollbar", String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), "terminal shell scrollback"), + setting(.terminal, "timestamps", String(localized: "settings.terminal.timestamps", defaultValue: "Show Terminal Timestamps"), "terminal output time timestamp gutter"), setting(.terminal, "agent-auto-resume", String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), setting(.sidebarAppearance, "match-terminal", String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), "sidebar material transparency"), setting(.sidebarAppearance, "hide-sidebar-details", String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), "workspace sidebar compact"), @@ -412,6 +413,7 @@ enum SettingsSearchIndex { "sidebar.showProgress": settingID(for: .sidebarAppearance, idSuffix: "show-progress"), "sidebar.showCustomMetadata": settingID(for: .sidebarAppearance, idSuffix: "show-metadata"), "terminal.showScrollBar": settingID(for: .terminal, idSuffix: "scrollbar"), + "terminal.showTimestamps": settingID(for: .terminal, idSuffix: "timestamps"), "terminal.autoResumeAgentSessions": settingID(for: .terminal, idSuffix: "agent-auto-resume"), "workspaceColors.indicatorStyle": settingID(for: .workspaceColors, idSuffix: "indicator"), "workspaceColors.selectionColor": settingID(for: .workspaceColors, idSuffix: "selection"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 35a435cabe44..3e1646b83346 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -105,6 +105,7 @@ enum SettingsSearchAliasIndex { "globalHotkey:shortcut": localized("settings.search.alias.setting.globalHotkey.shortcut", defaultValue: "global hotkey shortcut recorder key command option control"), "keyboardShortcuts:shortcut-chords": localized("settings.search.alias.setting.keyboardShortcuts.shortcut-chords", defaultValue: "tmux prefix ctrl-b control-b multi key sequence chord cmux json"), "keyboardShortcuts:shortcuts": localized("settings.search.alias.setting.keyboardShortcuts.shortcuts", defaultValue: "hotkeys keybindings key bindings commands keyboard accelerators shortcuts cmux json"), + "terminal:timestamps": localized("settings.search.alias.setting.terminal.timestamps", defaultValue: "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug"), "workspaceColors:indicator": localized("settings.search.alias.setting.workspaceColors.indicator", defaultValue: "workspaceColors.indicatorStyle tab indicator active workspace style color stripe dot"), "workspaceColors:selection": localized("settings.search.alias.setting.workspaceColors.selection", defaultValue: "workspaceColors.selectionColor selected workspace color highlight background active tab"), "workspaceColors:badge": localized("settings.search.alias.setting.workspaceColors.badge", defaultValue: "workspaceColors.notificationBadgeColor unread notification badge color dot count"), diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index b7b8e94170cd..badb7f7f66ed 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -743,6 +743,10 @@ struct cmuxApp: App { targetWindow.toggleFullScreen(nil) } + splitCommandButton(title: String(localized: "menu.view.toggleTerminalTimestamps", defaultValue: "Toggle Terminal Timestamps"), shortcut: menuShortcut(for: .toggleTerminalTimestamps)) { + _ = TerminalTimestampsSettings.toggle() + } + Divider() splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: menuShortcut(for: .splitRight)) { @@ -5011,6 +5015,8 @@ struct SettingsView: View { private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled @AppStorage(TerminalScrollBarSettings.showScrollBarKey) private var showTerminalScrollBar = TerminalScrollBarSettings.defaultShowScrollBar + @AppStorage(TerminalTimestampsSettings.showTimestampsKey) + private var showTerminalTimestamps = TerminalTimestampsSettings.defaultShowTimestamps @AppStorage(FileDropBehaviorSettings.defaultBehaviorKey) private var fileDropDefaultBehavior = FileDropBehaviorSettings.defaultBehavior.rawValue @AppStorage(AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) @@ -5139,6 +5145,17 @@ struct SettingsView: View { ) } + private var showTerminalTimestampsBinding: Binding { + Binding( + get: { showTerminalTimestamps }, + set: { newValue in + guard showTerminalTimestamps != newValue else { return } + showTerminalTimestamps = newValue + TerminalTimestampsSettings.notifyDidChange() + } + ) + } + private var selectedFileDropDefaultBehavior: FileDropDefaultBehavior { FileDropBehaviorSettings.behavior(for: fileDropDefaultBehavior) } @@ -6137,6 +6154,24 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.showTimestamps"), + String(localized: "settings.terminal.timestamps", defaultValue: "Show Terminal Timestamps"), + subtitle: showTerminalTimestamps + ? String(localized: "settings.terminal.timestamps.subtitleOn", defaultValue: "Shows a left-side time gutter aligned with terminal output rows.") + : String(localized: "settings.terminal.timestamps.subtitleOff", defaultValue: "Hides the terminal timestamp gutter. Timestamps can also be toggled from the View menu or command palette.") + ) { + Toggle("", isOn: showTerminalTimestampsBinding) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalTimestampsToggle") + .accessibilityLabel( + String(localized: "settings.terminal.timestamps", defaultValue: "Show Terminal Timestamps") + ) + } + + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("terminal.autoResumeAgentSessions"), String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), @@ -7247,6 +7282,11 @@ struct SettingsView: View { if previousShowTerminalScrollBar != showTerminalScrollBar { TerminalScrollBarSettings.notifyDidChange() } + let previousShowTerminalTimestamps = showTerminalTimestamps + showTerminalTimestamps = TerminalTimestampsSettings.defaultShowTimestamps + if previousShowTerminalTimestamps != showTerminalTimestamps { + TerminalTimestampsSettings.notifyDidChange() + } fileDropDefaultBehavior = FileDropBehaviorSettings.defaultBehavior.rawValue let previousAutoResumeAgentSessions = autoResumeAgentSessions autoResumeAgentSessions = AgentSessionAutoResumeSettings.defaultAutoResumeAgentSessions diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 2f9a6d0858e9..7984c4e08b3d 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -57,4 +57,89 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { "Interactive resize should use the immediate portal sync path" ) } + + func testTerminalTimestampStoreAssignsNewRowsFromScrollbarGrowth() { + let store = TerminalTimestampStore() + let first = Date(timeIntervalSince1970: 100) + let second = Date(timeIntervalSince1970: 140) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 3, offset: 0, len: 3), + at: first, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 2, len: 3), + at: second, + markVisibleRows: true + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 5, offset: 2, len: 3)), + [ + TerminalTimestampVisibleRow(row: 2, timestamp: first), + TerminalTimestampVisibleRow(row: 3, timestamp: second), + TerminalTimestampVisibleRow(row: 4, timestamp: second), + ] + ) + } + + func testTerminalTimestampStoreDoesNotInventOldRowsDuringUserScroll() { + let store = TerminalTimestampStore() + let first = Date(timeIntervalSince1970: 100) + let userScroll = Date(timeIntervalSince1970: 200) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 8, offset: 5, len: 3), + at: first, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 8, offset: 0, len: 3), + at: userScroll, + markVisibleRows: false + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 8, offset: 0, len: 3)), + [] + ) + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 8, offset: 5, len: 3)), + [ + TerminalTimestampVisibleRow(row: 5, timestamp: first), + TerminalTimestampVisibleRow(row: 6, timestamp: first), + TerminalTimestampVisibleRow(row: 7, timestamp: first), + ] + ) + } + + func testTerminalTimestampStoreMarksNewRowsWithoutBackfillingVisibleScrollback() { + let store = TerminalTimestampStore() + let first = Date(timeIntervalSince1970: 100) + let laterOutput = Date(timeIntervalSince1970: 200) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 8, offset: 5, len: 3), + at: first, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 10, offset: 0, len: 3), + at: laterOutput, + markVisibleRows: false + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 10, offset: 0, len: 3)), + [] + ) + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 10, offset: 8, len: 2)), + [ + TerminalTimestampVisibleRow(row: 8, timestamp: laterOutput), + TerminalTimestampVisibleRow(row: 9, timestamp: laterOutput), + ] + ) + } } diff --git a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index 6988914c96c1..d4721e70abfa 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -461,6 +461,59 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { XCTAssertEqual(defaults.object(forKey: key) as? Bool, false) } + func testSettingsFileStoreAppliesTerminalTimestampSetting() throws { + let defaults = UserDefaults.standard + let key = TerminalTimestampsSettings.showTimestampsKey + let previousValue = defaults.object(forKey: key) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + let previousImportedDefaults = defaults.data(forKey: importedManagedDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + if let previousImportedDefaults { + defaults.set(previousImportedDefaults, forKey: importedManagedDefaultsKey) + } else { + defaults.removeObject(forKey: importedManagedDefaultsKey) + } + } + + defaults.removeObject(forKey: key) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + defaults.removeObject(forKey: importedManagedDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "terminal": { + "showTimestamps": true + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(defaults.object(forKey: key) as? Bool, true) + } + private func makeTemporaryDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory.appendingPathComponent( "cmux-settings-startup-\(UUID().uuidString)", diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 22b9bd91915c..a47631198b9a 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -61,6 +61,7 @@ const settingsFileExample = `{ // "terminal": { // "showScrollBar": false, + // "showTimestamps": true, // "autoResumeAgentSessions": true // }, diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 7b915dc20916..1513bef6a0ee 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -103,6 +103,7 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "closeOtherTabsInPane", combos: [["⌥", "⌘", "T"]], description: { en: "Close other tabs in pane", ja: "ペイン内の他のタブを閉じる" } }, { id: "reopenClosedBrowserPanel", combos: [["⌘", "⇧", "T"]], description: { en: "Reopen closed browser panel", ja: "閉じたブラウザパネルを再度開く" } }, { id: "toggleTerminalCopyMode", combos: [["⌘", "⇧", "M"]], description: { en: "Toggle terminal copy mode", ja: "ターミナルコピーモードを切り替え" } }, + { id: "toggleTerminalTimestamps", combos: [["⌥", "⌘", "⇧", "E"]], description: { en: "Toggle terminal timestamps", ja: "ターミナルタイムスタンプを切り替え" } }, { id: "saveFilePreview", combos: [["⌘", "S"]], diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 6476c9081112..0cc03f36f068 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -301,6 +301,11 @@ "default": true, "description": "Show the right-edge terminal scroll bar when scrollback is available. cmux automatically suppresses it for alternate-screen style TUI surfaces." }, + "showTimestamps": { + "type": "boolean", + "default": false, + "description": "Show a left-side terminal timestamp gutter aligned with output rows." + }, "autoResumeAgentSessions": { "type": "boolean", "default": true, From b154a7a462e1c31bf69e548125386a6ffd185e40 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:54:06 -0700 Subject: [PATCH 02/28] fix: clamp terminal timestamp scrollbar values --- Sources/GhosttyTerminalView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e102db3de9d3..ecc305466056 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9721,7 +9721,11 @@ struct TerminalTimestampScrollbarState: Equatable { } init(_ scrollbar: GhosttyScrollbar) { - self.init(total: scrollbar.total, offset: scrollbar.offset, len: scrollbar.len) + self.init( + total: Int(clamping: scrollbar.total), + offset: Int(clamping: scrollbar.offset), + len: Int(clamping: scrollbar.len) + ) } } From 678d830677ef12a694d4471c46c62e4c3ffb85f0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:59:53 -0700 Subject: [PATCH 03/28] fix: address terminal timestamp review feedback --- Sources/GhosttyTerminalView.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ecc305466056..58b4a22a5cd3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9796,7 +9796,7 @@ final class TerminalTimestampStore { } private final class TerminalTimestampGutterView: NSView { - private static let formatter: DateFormatter = { + private let formatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .none formatter.timeStyle = .medium @@ -9868,7 +9868,7 @@ private final class TerminalTimestampGutterView: NSView { let textY = floor(rowTopFromBottom + ((cellHeight - lineHeight) / 2)) let textRect = NSRect(x: 4, y: textY, width: textWidth, height: lineHeight) guard textRect.intersects(dirtyRect) else { continue } - Self.formatter.string(from: row.timestamp).draw(in: textRect, withAttributes: attributes) + formatter.string(from: row.timestamp).draw(in: textRect, withAttributes: attributes) } } } @@ -12961,13 +12961,6 @@ final class GhosttySurfaceScrollView: NSView { } private func handleTerminalTimestampsPreferenceChange() { - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.handleTerminalTimestampsPreferenceChange() - } - return - } - if TerminalTimestampsSettings.isVisible(), let scrollbar = surfaceView.scrollbar { timestampStore.record( scrollbar: TerminalTimestampScrollbarState(scrollbar), From 9f9946f0512e16c36c9a09054f1f1acdf6f86579 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:42:31 -0700 Subject: [PATCH 04/28] fix: align timestamp gutter with live scroll position --- Sources/GhosttyTerminalView.swift | 34 +++++++++++++++++-- ...ttyTerminalViewVisibilityPolicyTests.swift | 12 +++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 58b4a22a5cd3..299f999c6e1f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9727,6 +9727,29 @@ struct TerminalTimestampScrollbarState: Equatable { len: Int(clamping: scrollbar.len) ) } + + static func visibleWindow( + total: Int, + fallbackLen: Int, + visibleTopRow: CGFloat, + viewportHeight: CGFloat, + cellHeight: CGFloat + ) -> Self { + let total = max(0, total) + guard total > 0 else { + return Self(total: 0, offset: 0, len: 0) + } + + let visibleOffset = min(total, max(0, Int(floor(visibleTopRow)))) + let viewportLen: Int + if cellHeight > 0, viewportHeight > 0 { + viewportLen = Int(ceil(viewportHeight / cellHeight)) + 1 + } else { + viewportLen = fallbackLen + } + let len = min(max(0, total - visibleOffset), max(fallbackLen, viewportLen, 0)) + return Self(total: total, offset: visibleOffset, len: len) + } } struct TerminalTimestampVisibleRow: Equatable { @@ -12739,10 +12762,17 @@ final class GhosttySurfaceScrollView: NSView { return } - let state = TerminalTimestampScrollbarState(scrollbar) + let visibleTopRow = currentVisibleTopRow(cellHeight: surfaceView.cellSize.height) + let state = TerminalTimestampScrollbarState.visibleWindow( + total: Int(clamping: scrollbar.total), + fallbackLen: Int(clamping: scrollbar.len), + visibleTopRow: visibleTopRow, + viewportHeight: scrollView.contentView.documentVisibleRect.height, + cellHeight: surfaceView.cellSize.height + ) timestampGutterView.update( rows: timestampStore.visibleRows(for: state), - visibleTopRow: currentVisibleTopRow(cellHeight: surfaceView.cellSize.height), + visibleTopRow: visibleTopRow, cellHeight: surfaceView.cellSize.height ) } diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 7984c4e08b3d..18c48c2f4c14 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -84,6 +84,18 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) } + func testTerminalTimestampVisibleWindowTracksCurrentScrollPosition() { + let state = TerminalTimestampScrollbarState.visibleWindow( + total: 100, + fallbackLen: 3, + visibleTopRow: 42.25, + viewportHeight: 25, + cellHeight: 10 + ) + + XCTAssertEqual(state, TerminalTimestampScrollbarState(total: 100, offset: 42, len: 4)) + } + func testTerminalTimestampStoreDoesNotInventOldRowsDuringUserScroll() { let store = TerminalTimestampStore() let first = Date(timeIntervalSince1970: 100) From 685fb4028bdfa7ee06a5472dbb2109d076f18970 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 00:58:52 -0700 Subject: [PATCH 05/28] fix: extract terminal timestamp store --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/GhosttyTerminalView.swift | 109 -------------------------- Sources/TerminalTimestampStore.swift | 106 +++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 109 deletions(-) create mode 100644 Sources/TerminalTimestampStore.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d11940b9ec11..c107509bf3ac 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ E30750000000000000000002 /* WorkspaceTabColorResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000001 /* WorkspaceTabColorResolution.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; + D3690A0023690A0023690A02 /* TerminalTimestampStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3690A0013690A0013690A01 /* TerminalTimestampStore.swift */; }; C13519000000000000000001 /* TerminalStartupEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000002 /* TerminalStartupEnvironment.swift */; }; C13519000000000000000005 /* RestorableAgentTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000006 /* RestorableAgentTypes.swift */; }; B3575000000000000000000C /* SessionIndexModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3575000000000000000000B /* SessionIndexModels.swift */; }; @@ -640,6 +641,7 @@ E30750000000000000000001 /* WorkspaceTabColorResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorResolution.swift; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; + D3690A0013690A0013690A01 /* TerminalTimestampStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTimestampStore.swift; sourceTree = ""; }; C13519000000000000000002 /* TerminalStartupEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalStartupEnvironment.swift; sourceTree = ""; }; C13519000000000000000006 /* RestorableAgentTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentTypes.swift; sourceTree = ""; }; B3575000000000000000000B /* SessionIndexModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexModels.swift; sourceTree = ""; }; @@ -1180,6 +1182,7 @@ E30760000000000000000001 /* TmuxWorkspacePaneOverlayView.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, + D3690A0013690A0013690A01 /* TerminalTimestampStore.swift */, C13519000000000000000002 /* TerminalStartupEnvironment.swift */, C13519000000000000000006 /* RestorableAgentTypes.swift */, B3575000000000000000000B /* SessionIndexModels.swift */, @@ -1815,6 +1818,7 @@ E30760000000000000000002 /* TmuxWorkspacePaneOverlayView.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, + D3690A0023690A0023690A02 /* TerminalTimestampStore.swift in Sources */, C13519000000000000000001 /* TerminalStartupEnvironment.swift in Sources */, C13519000000000000000005 /* RestorableAgentTypes.swift in Sources */, B3575000000000000000000C /* SessionIndexModels.swift in Sources */, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 299f999c6e1f..5328198e86e5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9709,115 +9709,6 @@ private final class GhosttyFlashOverlayView: NSView { } } -struct TerminalTimestampScrollbarState: Equatable { - let total: Int - let offset: Int - let len: Int - - init(total: Int, offset: Int, len: Int) { - self.total = max(0, total) - self.offset = max(0, offset) - self.len = max(0, len) - } - - init(_ scrollbar: GhosttyScrollbar) { - self.init( - total: Int(clamping: scrollbar.total), - offset: Int(clamping: scrollbar.offset), - len: Int(clamping: scrollbar.len) - ) - } - - static func visibleWindow( - total: Int, - fallbackLen: Int, - visibleTopRow: CGFloat, - viewportHeight: CGFloat, - cellHeight: CGFloat - ) -> Self { - let total = max(0, total) - guard total > 0 else { - return Self(total: 0, offset: 0, len: 0) - } - - let visibleOffset = min(total, max(0, Int(floor(visibleTopRow)))) - let viewportLen: Int - if cellHeight > 0, viewportHeight > 0 { - viewportLen = Int(ceil(viewportHeight / cellHeight)) + 1 - } else { - viewportLen = fallbackLen - } - let len = min(max(0, total - visibleOffset), max(fallbackLen, viewportLen, 0)) - return Self(total: total, offset: visibleOffset, len: len) - } -} - -struct TerminalTimestampVisibleRow: Equatable { - let row: Int - let timestamp: Date -} - -final class TerminalTimestampStore { - private let maxRetainedRows: Int - private var timestampsByRow: [Int: Date] = [:] - private var lastScrollbar: TerminalTimestampScrollbarState? - - init(maxRetainedRows: Int = 20_000) { - self.maxRetainedRows = max(1, maxRetainedRows) - } - - func record( - scrollbar: TerminalTimestampScrollbarState, - at date: Date, - markVisibleRows: Bool - ) { - if let previous = lastScrollbar { - if scrollbar.total < previous.total { - timestampsByRow = timestampsByRow.filter { entry in entry.key < scrollbar.total } - } else if scrollbar.total > previous.total { - for row in previous.total.. [TerminalTimestampVisibleRow] { - visibleRange(for: scrollbar).compactMap { row in - guard let timestamp = timestampsByRow[row] else { return nil } - return TerminalTimestampVisibleRow(row: row, timestamp: timestamp) - } - } - - func reset() { - timestampsByRow.removeAll() - lastScrollbar = nil - } - - private func visibleRange(for scrollbar: TerminalTimestampScrollbarState) -> Range { - guard scrollbar.total > 0, scrollbar.len > 0 else { return 0..<0 } - let lower = min(scrollbar.offset, scrollbar.total) - let upper = min(scrollbar.total, lower + scrollbar.len) - return lower..= minimumRow && entry.key < totalRows - } - } -} - private final class TerminalTimestampGutterView: NSView { private let formatter: DateFormatter = { let formatter = DateFormatter() diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift new file mode 100644 index 000000000000..d15c75818d61 --- /dev/null +++ b/Sources/TerminalTimestampStore.swift @@ -0,0 +1,106 @@ +import CoreGraphics +import Foundation + +struct TerminalTimestampScrollbarState: Equatable { + let total: Int + let offset: Int + let len: Int + + init(total: Int, offset: Int, len: Int) { + self.total = max(0, total) + self.offset = max(0, offset) + self.len = max(0, len) + } + + init(_ scrollbar: GhosttyScrollbar) { + self.init( + total: Int(clamping: scrollbar.total), + offset: Int(clamping: scrollbar.offset), + len: Int(clamping: scrollbar.len) + ) + } + + static func visibleWindow( + total: Int, + fallbackLen: Int, + visibleTopRow: CGFloat, + viewportHeight: CGFloat, + cellHeight: CGFloat + ) -> Self { + let total = max(0, total) + guard total > 0 else { + return Self(total: 0, offset: 0, len: 0) + } + + let visibleOffset = min(total, max(0, Int(floor(visibleTopRow)))) + let viewportLen: Int + if cellHeight > 0, viewportHeight > 0 { + viewportLen = Int(ceil(viewportHeight / cellHeight)) + 1 + } else { + viewportLen = fallbackLen + } + let len = min(max(0, total - visibleOffset), max(fallbackLen, viewportLen, 0)) + return Self(total: total, offset: visibleOffset, len: len) + } +} + +struct TerminalTimestampVisibleRow: Equatable { + let row: Int + let timestamp: Date +} + +final class TerminalTimestampStore { + private let maxRetainedRows: Int + private var timestampsByRow: [Int: Date] = [:] + private var lastScrollbar: TerminalTimestampScrollbarState? + + init(maxRetainedRows: Int = 20_000) { + self.maxRetainedRows = max(1, maxRetainedRows) + } + + func record( + scrollbar: TerminalTimestampScrollbarState, + at date: Date, + markVisibleRows: Bool + ) { + if let previous = lastScrollbar { + if scrollbar.total < previous.total { + timestampsByRow = timestampsByRow.filter { entry in entry.key < scrollbar.total } + } else if scrollbar.total > previous.total { + for row in previous.total.. [TerminalTimestampVisibleRow] { + visibleRange(for: scrollbar).compactMap { row in + guard let timestamp = timestampsByRow[row] else { return nil } + return TerminalTimestampVisibleRow(row: row, timestamp: timestamp) + } + } + + private func visibleRange(for scrollbar: TerminalTimestampScrollbarState) -> Range { + guard scrollbar.total > 0, scrollbar.len > 0 else { return 0..<0 } + let lower = min(scrollbar.offset, scrollbar.total) + let upper = min(scrollbar.total, lower + scrollbar.len) + return lower..= minimumRow && entry.key < totalRows + } + } +} From 9b160768d9a2102ac273cbcbcdbcb0399124729e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:34:09 -0700 Subject: [PATCH 06/28] fix: avoid timestamp prune hot-path copies --- Sources/TerminalTimestampStore.swift | 97 ++++++++++++++++++- ...ttyTerminalViewVisibilityPolicyTests.swift | 20 ++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index d15c75818d61..5843ca645da0 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -52,6 +52,8 @@ struct TerminalTimestampVisibleRow: Equatable { final class TerminalTimestampStore { private let maxRetainedRows: Int private var timestampsByRow: [Int: Date] = [:] + private var oldestTrackedRow: Int? + private var newestTrackedRow: Int? private var lastScrollbar: TerminalTimestampScrollbarState? init(maxRetainedRows: Int = 20_000) { @@ -65,17 +67,17 @@ final class TerminalTimestampStore { ) { if let previous = lastScrollbar { if scrollbar.total < previous.total { - timestampsByRow = timestampsByRow.filter { entry in entry.key < scrollbar.total } + pruneRows(atOrAbove: scrollbar.total) } else if scrollbar.total > previous.total { for row in previous.total..= upperBound else { return } + for row in upperBound...newestTrackedRow { + timestampsByRow.removeValue(forKey: row) + } + refreshNewestTrackedRow(startingAt: upperBound - 1) + } + private func prune(forTotalRows totalRows: Int) { let minimumRow = max(0, totalRows - maxRetainedRows) - timestampsByRow = timestampsByRow.filter { entry in - entry.key >= minimumRow && entry.key < totalRows + guard let oldestTrackedRow, oldestTrackedRow < minimumRow else { return } + + for row in oldestTrackedRow..= oldestTrackedRow { + if timestampsByRow[candidate] != nil { + newestTrackedRow = candidate + return + } + candidate -= 1 + } + + refreshTrackedBounds() + } + + private func refreshTrackedBounds() { + guard !timestampsByRow.isEmpty else { + clearTrackedBounds() + return + } + + var oldest = Int.max + var newest = Int.min + for row in timestampsByRow.keys { + oldest = min(oldest, row) + newest = max(newest, row) + } + oldestTrackedRow = oldest + newestTrackedRow = newest + } + + private func clearTrackedBounds() { + oldestTrackedRow = nil + newestTrackedRow = nil } } diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 18c48c2f4c14..037eedba1f70 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -154,4 +154,24 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ] ) } + + func testTerminalTimestampStorePrunesRowsOutsideRetentionWindow() { + let store = TerminalTimestampStore(maxRetainedRows: 3) + let timestamp = Date(timeIntervalSince1970: 100) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 5), + at: timestamp, + markVisibleRows: true + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 5)), + [ + TerminalTimestampVisibleRow(row: 2, timestamp: timestamp), + TerminalTimestampVisibleRow(row: 3, timestamp: timestamp), + TerminalTimestampVisibleRow(row: 4, timestamp: timestamp), + ] + ) + } } From f22d321891741cc9a38bd461bc2317b6d1a199b0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:43:32 -0700 Subject: [PATCH 07/28] fix: isolate terminal timestamp store on main actor --- Sources/TerminalTimestampStore.swift | 1 + cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index 5843ca645da0..09f11622cb69 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -49,6 +49,7 @@ struct TerminalTimestampVisibleRow: Equatable { let timestamp: Date } +@MainActor final class TerminalTimestampStore { private let maxRetainedRows: Int private var timestampsByRow: [Int: Date] = [:] diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 037eedba1f70..e3e27e3ae2b6 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -58,6 +58,7 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) } + @MainActor func testTerminalTimestampStoreAssignsNewRowsFromScrollbarGrowth() { let store = TerminalTimestampStore() let first = Date(timeIntervalSince1970: 100) @@ -96,6 +97,7 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { XCTAssertEqual(state, TerminalTimestampScrollbarState(total: 100, offset: 42, len: 4)) } + @MainActor func testTerminalTimestampStoreDoesNotInventOldRowsDuringUserScroll() { let store = TerminalTimestampStore() let first = Date(timeIntervalSince1970: 100) @@ -126,6 +128,7 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) } + @MainActor func testTerminalTimestampStoreMarksNewRowsWithoutBackfillingVisibleScrollback() { let store = TerminalTimestampStore() let first = Date(timeIntervalSince1970: 100) @@ -155,6 +158,7 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) } + @MainActor func testTerminalTimestampStorePrunesRowsOutsideRetentionWindow() { let store = TerminalTimestampStore(maxRetainedRows: 3) let timestamp = Date(timeIntervalSince1970: 100) From 6a00a7b805263fcd3962aa34806cacdee34e9f3e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:24:26 -0700 Subject: [PATCH 08/28] ci: retrigger terminal timestamp checks From 32e49691120f1bf1c2dca9952b7d9f000670598a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:41:06 -0700 Subject: [PATCH 09/28] fix: bound timestamp prune after scrollback shrink --- Sources/TerminalTimestampStore.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index 09f11622cb69..4e9056a3950d 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -111,7 +111,8 @@ final class TerminalTimestampStore { private func pruneRows(atOrAbove upperBound: Int) { guard let newestTrackedRow, newestTrackedRow >= upperBound else { return } - for row in upperBound...newestTrackedRow { + let firstTrackedRowToPrune = max(upperBound, oldestTrackedRow ?? upperBound) + for row in firstTrackedRowToPrune...newestTrackedRow { timestampsByRow.removeValue(forKey: row) } refreshNewestTrackedRow(startingAt: upperBound - 1) From ec41fc6b0b1d2e439d26736ce1edbc987aa45764 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:06:25 -0700 Subject: [PATCH 10/28] test: cover timestamp visible retention --- ...ttyTerminalViewVisibilityPolicyTests.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index e3e27e3ae2b6..d72eb7081381 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -163,10 +163,15 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { let store = TerminalTimestampStore(maxRetainedRows: 3) let timestamp = Date(timeIntervalSince1970: 100) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 0, offset: 0, len: 0), + at: timestamp, + markVisibleRows: false + ) store.record( scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 5), at: timestamp, - markVisibleRows: true + markVisibleRows: false ) XCTAssertEqual( @@ -178,4 +183,30 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ] ) } + + @MainActor + func testTerminalTimestampStoreKeepsVisibleRowsMarkedOutsideRetentionWindow() { + let store = TerminalTimestampStore(maxRetainedRows: 3) + let tailTimestamp = Date(timeIntervalSince1970: 100) + let visibleTimestamp = Date(timeIntervalSince1970: 200) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 2, len: 3), + at: tailTimestamp, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 2), + at: visibleTimestamp, + markVisibleRows: true + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 2)), + [ + TerminalTimestampVisibleRow(row: 0, timestamp: visibleTimestamp), + TerminalTimestampVisibleRow(row: 1, timestamp: visibleTimestamp), + ] + ) + } } From 5d8e7f928729e2e2cff838e980a3b147b3f615d0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:07:02 -0700 Subject: [PATCH 11/28] fix: preserve marked timestamp rows --- Sources/TerminalTimestampStore.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index 4e9056a3950d..40badeca5583 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -76,13 +76,13 @@ final class TerminalTimestampStore { } } + prune(forTotalRows: scrollbar.total) + if markVisibleRows { for row in visibleRange(for: scrollbar) where timestampsByRow[row] == nil { recordTimestamp(for: row, at: date) } } - - prune(forTotalRows: scrollbar.total) lastScrollbar = scrollbar } From 56054d31ddc0b459e64933e96170533c93467c53 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:11:02 -0700 Subject: [PATCH 12/28] fix: bound timestamp growth stamping --- Sources/TerminalTimestampStore.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index 40badeca5583..0e0ef6eafb03 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -70,7 +70,8 @@ final class TerminalTimestampStore { if scrollbar.total < previous.total { pruneRows(atOrAbove: scrollbar.total) } else if scrollbar.total > previous.total { - for row in previous.total.. Date: Tue, 12 May 2026 13:20:43 -0700 Subject: [PATCH 13/28] test: cover timestamp viewport retention --- ...ttyTerminalViewVisibilityPolicyTests.swift | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index d72eb7081381..4e1ad0c08546 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -209,4 +209,47 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ] ) } + + @MainActor + func testTerminalTimestampStorePreservesMarkedVisibleRowsAcrossScrollUpdates() { + let store = TerminalTimestampStore(maxRetainedRows: 3) + let tailTimestamp = Date(timeIntervalSince1970: 100) + let visibleTimestamp = Date(timeIntervalSince1970: 200) + let scrollTimestamp = Date(timeIntervalSince1970: 300) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 2, len: 3), + at: tailTimestamp, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 2), + at: visibleTimestamp, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 2), + at: scrollTimestamp, + markVisibleRows: false + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 2)), + [ + TerminalTimestampVisibleRow(row: 0, timestamp: visibleTimestamp), + TerminalTimestampVisibleRow(row: 1, timestamp: visibleTimestamp), + ] + ) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 2, len: 3), + at: scrollTimestamp, + markVisibleRows: false + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 2)), + [] + ) + } } From 6923862662a858c75ca101970a1a81fe074522b0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:22:36 -0700 Subject: [PATCH 14/28] fix: preserve visible timestamp rows while scrolling --- Sources/TerminalTimestampStore.swift | 41 ++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index 0e0ef6eafb03..8fd5d2e2e4b8 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -1,7 +1,7 @@ import CoreGraphics import Foundation -struct TerminalTimestampScrollbarState: Equatable { +nonisolated struct TerminalTimestampScrollbarState: Equatable, Sendable { let total: Int let offset: Int let len: Int @@ -44,7 +44,7 @@ struct TerminalTimestampScrollbarState: Equatable { } } -struct TerminalTimestampVisibleRow: Equatable { +nonisolated struct TerminalTimestampVisibleRow: Equatable, Sendable { let row: Int let timestamp: Date } @@ -66,6 +66,7 @@ final class TerminalTimestampStore { at date: Date, markVisibleRows: Bool ) { + let visibleRows = visibleRange(for: scrollbar) if let previous = lastScrollbar { if scrollbar.total < previous.total { pruneRows(atOrAbove: scrollbar.total) @@ -77,10 +78,10 @@ final class TerminalTimestampStore { } } - prune(forTotalRows: scrollbar.total) + prune(forTotalRows: scrollbar.total, preserving: visibleRows) if markVisibleRows { - for row in visibleRange(for: scrollbar) where timestampsByRow[row] == nil { + for row in visibleRows where timestampsByRow[row] == nil { recordTimestamp(for: row, at: date) } } @@ -119,14 +120,42 @@ final class TerminalTimestampStore { refreshNewestTrackedRow(startingAt: upperBound - 1) } - private func prune(forTotalRows totalRows: Int) { + private func prune(forTotalRows totalRows: Int, preserving preservedRows: Range) { let minimumRow = max(0, totalRows - maxRetainedRows) guard let oldestTrackedRow, oldestTrackedRow < minimumRow else { return } + let previousNewestTrackedRow = newestTrackedRow + var preservedOldestRow: Int? + var preservedNewestRow: Int? for row in oldestTrackedRow..= minimumRow { + newestTrackedRow = previousNewestTrackedRow + } else { + newestTrackedRow = preservedNewestRow + } + return + } + + if let previousNewestTrackedRow, previousNewestTrackedRow >= minimumRow { + refreshOldestTrackedRow(startingAt: minimumRow) + } else { + clearTrackedBounds() + } } private func refreshOldestTrackedRow(startingAt row: Int) { From cd37d5eab2aa7f5b9024cb2cd8764c374511a6df Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:11:47 -0700 Subject: [PATCH 15/28] fix: cache terminal timestamp gutter draw style --- Sources/GhosttyTerminalView.swift | 58 +++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3242d49dd8cf..36bc9f866c89 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9723,6 +9723,13 @@ private final class GhosttyFlashOverlayView: NSView { } private final class TerminalTimestampGutterView: NSView { + private struct DrawStyle { + let fontSize: CGFloat + let attributes: [NSAttributedString.Key: Any] + let lineHeight: CGFloat + let separatorColor: NSColor + } + private let formatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .none @@ -9734,6 +9741,7 @@ private final class TerminalTimestampGutterView: NSView { private var rows: [TerminalTimestampVisibleRow] = [] private var visibleTopRow: CGFloat = 0 private var cellHeight: CGFloat = 0 + private var cachedDrawStyle: DrawStyle? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -9753,6 +9761,12 @@ private final class TerminalTimestampGutterView: NSView { override var acceptsFirstResponder: Bool { false } override var isOpaque: Bool { false } + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + cachedDrawStyle = nil + needsDisplay = true + } + func update( rows: [TerminalTimestampVisibleRow], visibleTopRow: CGFloat, @@ -9768,34 +9782,48 @@ private final class TerminalTimestampGutterView: NSView { onMouseDown?() } + private func drawStyle(for cellHeight: CGFloat) -> DrawStyle { + let fontSize = min(11, max(9, cellHeight * 0.58)) + if let cachedDrawStyle, cachedDrawStyle.fontSize == fontSize { + return cachedDrawStyle + } + + let font = NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .regular) + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .right + paragraph.lineBreakMode = .byClipping + let style = DrawStyle( + fontSize: fontSize, + attributes: [ + .font: font, + .foregroundColor: NSColor.secondaryLabelColor.withAlphaComponent(0.72), + .paragraphStyle: paragraph, + ], + lineHeight: font.ascender - font.descender + font.leading, + separatorColor: NSColor.separatorColor.withAlphaComponent(0.35) + ) + cachedDrawStyle = style + return style + } + override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) - NSColor.separatorColor.withAlphaComponent(0.35).setFill() + let style = drawStyle(for: cellHeight) + style.separatorColor.setFill() NSRect(x: bounds.maxX - 1, y: 0, width: 1, height: bounds.height).fill() guard cellHeight > 0, !rows.isEmpty else { return } - let fontSize = min(11, max(9, cellHeight * 0.58)) - let font = NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .regular) - let paragraph = NSMutableParagraphStyle() - paragraph.alignment = .right - paragraph.lineBreakMode = .byClipping - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: NSColor.secondaryLabelColor.withAlphaComponent(0.72), - .paragraphStyle: paragraph, - ] - let lineHeight = font.ascender - font.descender + font.leading let textWidth = max(0, bounds.width - 12) for row in rows { let rowOffset = CGFloat(row.row) - visibleTopRow let rowTopFromBottom = bounds.height - ((rowOffset + 1) * cellHeight) - let textY = floor(rowTopFromBottom + ((cellHeight - lineHeight) / 2)) - let textRect = NSRect(x: 4, y: textY, width: textWidth, height: lineHeight) + let textY = floor(rowTopFromBottom + ((cellHeight - style.lineHeight) / 2)) + let textRect = NSRect(x: 4, y: textY, width: textWidth, height: style.lineHeight) guard textRect.intersects(dirtyRect) else { continue } - formatter.string(from: row.timestamp).draw(in: textRect, withAttributes: attributes) + formatter.string(from: row.timestamp).draw(in: textRect, withAttributes: style.attributes) } } } From 22550670f74819dd727c87755152b0b02abab9b1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:53:16 -0700 Subject: [PATCH 16/28] fix: reset timestamp rows after scrollback rebase --- Sources/TerminalTimestampStore.swift | 38 +++---------------- ...ttyTerminalViewVisibilityPolicyTests.swift | 29 ++++++++++++++ 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index 8fd5d2e2e4b8..e789e6f4d69c 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -69,7 +69,7 @@ final class TerminalTimestampStore { let visibleRows = visibleRange(for: scrollbar) if let previous = lastScrollbar { if scrollbar.total < previous.total { - pruneRows(atOrAbove: scrollbar.total) + clearRows() } else if scrollbar.total > previous.total { let firstRetainedNewRow = max(previous.total, scrollbar.total - maxRetainedRows) for row in firstRetainedNewRow..= upperBound else { return } - let firstTrackedRowToPrune = max(upperBound, oldestTrackedRow ?? upperBound) - for row in firstTrackedRowToPrune...newestTrackedRow { - timestampsByRow.removeValue(forKey: row) - } - refreshNewestTrackedRow(startingAt: upperBound - 1) - } - private func prune(forTotalRows totalRows: Int, preserving preservedRows: Range) { let minimumRow = max(0, totalRows - maxRetainedRows) guard let oldestTrackedRow, oldestTrackedRow < minimumRow else { return } @@ -180,28 +171,6 @@ final class TerminalTimestampStore { refreshTrackedBounds() } - private func refreshNewestTrackedRow(startingAt row: Int) { - guard !timestampsByRow.isEmpty else { - clearTrackedBounds() - return - } - guard let oldestTrackedRow else { - refreshTrackedBounds() - return - } - - var candidate = row - while candidate >= oldestTrackedRow { - if timestampsByRow[candidate] != nil { - newestTrackedRow = candidate - return - } - candidate -= 1 - } - - refreshTrackedBounds() - } - private func refreshTrackedBounds() { guard !timestampsByRow.isEmpty else { clearTrackedBounds() @@ -222,4 +191,9 @@ final class TerminalTimestampStore { oldestTrackedRow = nil newestTrackedRow = nil } + + private func clearRows() { + timestampsByRow.removeAll(keepingCapacity: true) + clearTrackedBounds() + } } diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 4e1ad0c08546..46bb78b92d7c 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -184,6 +184,35 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) } + @MainActor + func testTerminalTimestampStoreClearsRebasedRowsAfterScrollbackShrink() { + let store = TerminalTimestampStore() + let oldTimestamp = Date(timeIntervalSince1970: 100) + let reboundTimestamp = Date(timeIntervalSince1970: 200) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 8, offset: 0, len: 8), + at: oldTimestamp, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 5), + at: reboundTimestamp, + markVisibleRows: true + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 5)), + [ + TerminalTimestampVisibleRow(row: 0, timestamp: reboundTimestamp), + TerminalTimestampVisibleRow(row: 1, timestamp: reboundTimestamp), + TerminalTimestampVisibleRow(row: 2, timestamp: reboundTimestamp), + TerminalTimestampVisibleRow(row: 3, timestamp: reboundTimestamp), + TerminalTimestampVisibleRow(row: 4, timestamp: reboundTimestamp), + ] + ) + } + @MainActor func testTerminalTimestampStoreKeepsVisibleRowsMarkedOutsideRetentionWindow() { let store = TerminalTimestampStore(maxRetainedRows: 3) From 5ecdcc9ac80164666b0401473a9208252e0f1a85 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 20:42:05 -0700 Subject: [PATCH 17/28] fix: qualify timestamp store bounds assignment --- Sources/TerminalTimestampStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index e789e6f4d69c..cca35a43af36 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -133,7 +133,7 @@ final class TerminalTimestampStore { } if let preservedOldestRow { - oldestTrackedRow = preservedOldestRow + self.oldestTrackedRow = preservedOldestRow if let previousNewestTrackedRow, previousNewestTrackedRow >= minimumRow { newestTrackedRow = previousNewestTrackedRow } else { From 781bd8d7375e982a5553e17ba6e1fb12bed3d3ff Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 21:58:21 -0700 Subject: [PATCH 18/28] fix: bound timestamp prune cursor --- Sources/GhosttyTerminalView.swift | 1 + Sources/TerminalTimestampStore.swift | 55 +++++++++++++++---- ...ttyTerminalViewVisibilityPolicyTests.swift | 49 +++++++++++++++++ 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fbaf4eb183a0..67c41dd82b33 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10415,6 +10415,7 @@ final class GhosttySurfaceScrollView: NSView { queue: .main ) { [weak self] _ in self?.synchronizeScrollView() + self?.refreshTimestampGutter() }) observers.append(NotificationCenter.default.addObserver( diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index cca35a43af36..c6d66886b692 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -55,6 +55,8 @@ final class TerminalTimestampStore { private var timestampsByRow: [Int: Date] = [:] private var oldestTrackedRow: Int? private var newestTrackedRow: Int? + private var pruneCursor = 0 + private var rowsBelowRetention: Set = [] private var lastScrollbar: TerminalTimestampScrollbarState? init(maxRetainedRows: Int = 20_000) { @@ -107,37 +109,66 @@ final class TerminalTimestampStore { timestampsByRow[row] = date guard isNewRow else { return } + if row < pruneCursor { + rowsBelowRetention.insert(row) + } oldestTrackedRow = min(oldestTrackedRow ?? row, row) newestTrackedRow = max(newestTrackedRow ?? row, row) } private func prune(forTotalRows totalRows: Int, preserving preservedRows: Range) { let minimumRow = max(0, totalRows - maxRetainedRows) - guard let oldestTrackedRow, oldestTrackedRow < minimumRow else { return } - let previousNewestTrackedRow = newestTrackedRow - var preservedOldestRow: Int? - var preservedNewestRow: Int? - for row in oldestTrackedRow..= minimumRow { newestTrackedRow = previousNewestTrackedRow } else { - newestTrackedRow = preservedNewestRow + newestTrackedRow = rowsBelowRetention.max() } return } @@ -190,6 +221,8 @@ final class TerminalTimestampStore { private func clearTrackedBounds() { oldestTrackedRow = nil newestTrackedRow = nil + pruneCursor = 0 + rowsBelowRetention.removeAll(keepingCapacity: true) } private func clearRows() { diff --git a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift index 624a89e437fb..a888852448b3 100644 --- a/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift +++ b/cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift @@ -288,4 +288,53 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { [] ) } + + @MainActor + func testTerminalTimestampStoreRetainsVisibleRowsAcrossAdvancingPruneCursor() { + let store = TerminalTimestampStore(maxRetainedRows: 3) + let tailTimestamp = Date(timeIntervalSince1970: 100) + let visibleTimestamp = Date(timeIntervalSince1970: 200) + let outputTimestamp = Date(timeIntervalSince1970: 300) + let scrollTimestamp = Date(timeIntervalSince1970: 400) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 2, len: 3), + at: tailTimestamp, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 5, offset: 0, len: 2), + at: visibleTimestamp, + markVisibleRows: true + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 7, offset: 0, len: 2), + at: outputTimestamp, + markVisibleRows: false + ) + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 8, offset: 0, len: 2), + at: outputTimestamp, + markVisibleRows: false + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 8, offset: 0, len: 2)), + [ + TerminalTimestampVisibleRow(row: 0, timestamp: visibleTimestamp), + TerminalTimestampVisibleRow(row: 1, timestamp: visibleTimestamp), + ] + ) + + store.record( + scrollbar: TerminalTimestampScrollbarState(total: 8, offset: 5, len: 3), + at: scrollTimestamp, + markVisibleRows: false + ) + + XCTAssertEqual( + store.visibleRows(for: TerminalTimestampScrollbarState(total: 8, offset: 0, len: 2)), + [] + ) + } } From ff3745f588be5d394bd2aadcd92a00c7273f7c04 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:13:15 -0700 Subject: [PATCH 19/28] fix: preserve keyboard scroll timestamp policy --- Sources/GhosttyTerminalAppearance.swift | 1 + Sources/GhosttyTerminalView.swift | 90 +++++++++++++++++++++---- cmuxTests/TerminalAndGhosttyTests.swift | 87 ++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 14 deletions(-) diff --git a/Sources/GhosttyTerminalAppearance.swift b/Sources/GhosttyTerminalAppearance.swift index c6c8c78e4c7b..557bb8901fb7 100644 --- a/Sources/GhosttyTerminalAppearance.swift +++ b/Sources/GhosttyTerminalAppearance.swift @@ -91,6 +91,7 @@ final class GhosttyDefaultBackgroundNotificationDispatcher { enum GhosttyNotificationKey { static let scrollbar = "ghostty.scrollbar" + static let scrollbarWasKeyboardInitiated = "ghostty.scrollbarWasKeyboardInitiated" static let cellSize = "ghostty.cellSize" static let tabId = "ghostty.tabId" static let surfaceId = "ghostty.surfaceId" diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 67c41dd82b33..ffc1efa2b5ad 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3846,7 +3846,10 @@ class GhosttyApp { } case GHOSTTY_ACTION_SCROLLBAR: let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) - surfaceView.enqueueScrollbarUpdate(scrollbar) + surfaceView.enqueueScrollbarUpdate( + scrollbar, + wasKeyboardInitiated: surfaceView.isHandlingKeyboardInitiatedScrollAction() + ) return true case GHOSTTY_ACTION_CELL_SIZE: let cellSize = CGSize( @@ -6108,12 +6111,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } weak var terminalSurface: TerminalSurface? + private static let keyboardInitiatedScrollActionDepthKey = "cmux.keyboardInitiatedScrollActionDepth" var scrollbar: GhosttyScrollbar? /// Pending scrollbar value written from the action callback thread; /// read and cleared on the main thread by `flushPendingScrollbar()`. /// Access is guarded by `_scrollbarLock` because the action callback /// fires on Ghostty's I/O thread while the flush runs on main. private var _pendingScrollbar: GhosttyScrollbar? + private var _pendingScrollbarWasKeyboardInitiated = false private var _scrollbarFlushScheduled = false private let _scrollbarLock = NSLock() var cellSize: CGSize = .zero @@ -6123,11 +6128,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { /// dispatch. The action callback (which may fire thousands of times per /// second during bulk output like `seq 1 100000`) stores the latest value /// and schedules exactly one async flush. - func enqueueScrollbarUpdate(_ newValue: GhosttyScrollbar) { + func enqueueScrollbarUpdate(_ newValue: GhosttyScrollbar, wasKeyboardInitiated: Bool = false) { _scrollbarLock.lock() defer { _scrollbarLock.unlock() } // Store the latest value (always overwrites — only the newest matters). _pendingScrollbar = newValue + _pendingScrollbarWasKeyboardInitiated = wasKeyboardInitiated let needsSchedule = !_scrollbarFlushScheduled if needsSchedule { _scrollbarFlushScheduled = true } @@ -6143,7 +6149,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _scrollbarLock.lock() _scrollbarFlushScheduled = false let pending = _pendingScrollbar + let pendingWasKeyboardInitiated = _pendingScrollbarWasKeyboardInitiated _pendingScrollbar = nil + _pendingScrollbarWasKeyboardInitiated = false _scrollbarLock.unlock() guard let pending else { return } @@ -6151,10 +6159,39 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { NotificationCenter.default.post( name: .ghosttyDidUpdateScrollbar, object: self, - userInfo: [GhosttyNotificationKey.scrollbar: pending] + userInfo: [ + GhosttyNotificationKey.scrollbar: pending, + GhosttyNotificationKey.scrollbarWasKeyboardInitiated: pendingWasKeyboardInitiated, + ] ) } + private func beginKeyboardInitiatedScrollAction() { + let dictionary = Thread.current.threadDictionary + let currentDepth = dictionary[Self.keyboardInitiatedScrollActionDepthKey] as? Int ?? 0 + dictionary[Self.keyboardInitiatedScrollActionDepthKey] = currentDepth + 1 + } + + private func endKeyboardInitiatedScrollAction() { + let dictionary = Thread.current.threadDictionary + let currentDepth = dictionary[Self.keyboardInitiatedScrollActionDepthKey] as? Int ?? 0 + if currentDepth <= 1 { + dictionary.removeObject(forKey: Self.keyboardInitiatedScrollActionDepthKey) + } else { + dictionary[Self.keyboardInitiatedScrollActionDepthKey] = currentDepth - 1 + } + } + + func isHandlingKeyboardInitiatedScrollAction() -> Bool { + (Thread.current.threadDictionary[Self.keyboardInitiatedScrollActionDepthKey] as? Int ?? 0) > 0 + } + + private func withKeyboardInitiatedScrollAction(_ body: () -> T) -> T { + beginKeyboardInitiatedScrollAction() + defer { endKeyboardInitiatedScrollAction() } + return body() + } + var desiredFocus: Bool = false var suppressingReparentFocus: Bool = false var tabId: UUID? @@ -6815,6 +6852,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + private func performKeyboardInitiatedScrollBindingAction(_ action: String) -> Bool { + withKeyboardInitiatedScrollAction { + performBindingAction(action) + } + } + @discardableResult func toggleKeyboardCopyMode() -> Bool { guard surface != nil else { return false } @@ -6851,6 +6894,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + private func performKeyboardInitiatedScrollBindingAction(_ action: String, repeatCount: Int) { + let count = terminalKeyboardCopyModeClampCount(repeatCount) + for _ in 0 ..< count { + _ = performKeyboardInitiatedScrollBindingAction(action) + } + } + private func currentKeyboardCopyModeViewportRow(surface: ghostty_surface_t) -> Int { let rows = max(Int(ghostty_surface_size(surface).rows), 1) let fallback = rows - 1 @@ -6989,34 +7039,37 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _ = ghostty_surface_clear_selection_compat(surface) setKeyboardCopyModeActive(false) case let .scrollLines(delta): - _ = performBindingAction("scroll_page_lines:\(delta * count)") + _ = performKeyboardInitiatedScrollBindingAction("scroll_page_lines:\(delta * count)") refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .scrollPage(delta): - performBindingAction(delta > 0 ? "scroll_page_down" : "scroll_page_up", repeatCount: count) + performKeyboardInitiatedScrollBindingAction( + delta > 0 ? "scroll_page_down" : "scroll_page_up", + repeatCount: count + ) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .scrollHalfPage(delta): let fraction = delta > 0 ? 0.5 : -0.5 - performBindingAction("scroll_page_fractional:\(fraction)", repeatCount: count) + performKeyboardInitiatedScrollBindingAction("scroll_page_fractional:\(fraction)", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .scrollToTop: keyboardCopyModeViewportRow = 0 - _ = performBindingAction("scroll_to_top") + _ = performKeyboardInitiatedScrollBindingAction("scroll_to_top") case .scrollToBottom: keyboardCopyModeViewportRow = max(Int(ghostty_surface_size(surface).rows) - 1, 0) - _ = performBindingAction("scroll_to_bottom") + _ = performKeyboardInitiatedScrollBindingAction("scroll_to_bottom") case let .jumpToPrompt(delta): - _ = performBindingAction("jump_to_prompt:\(delta * count)") + _ = performKeyboardInitiatedScrollBindingAction("jump_to_prompt:\(delta * count)") refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .startSearch: _ = performBindingAction("start_search") case .searchNext: - performBindingAction("navigate_search:next", repeatCount: count) + performKeyboardInitiatedScrollBindingAction("navigate_search:next", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .searchPrevious: - performBindingAction("navigate_search:previous", repeatCount: count) + performKeyboardInitiatedScrollBindingAction("navigate_search:previous", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .adjustSelection(direction): - performBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count) + performKeyboardInitiatedScrollBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count) } return true } @@ -7615,6 +7668,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) #endif + beginKeyboardInitiatedScrollAction() + defer { endKeyboardInitiatedScrollAction() } + // Fast path for control-modified terminal input (for example Ctrl+D). // // These keys are terminal control input, not text composition, so we bypass @@ -11620,6 +11676,10 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.debugRegisteredDropTypes() } + func debugTimestampVisibleRows(for scrollbar: GhosttyScrollbar) -> [TerminalTimestampVisibleRow] { + timestampStore.visibleRows(for: TerminalTimestampScrollbarState(scrollbar)) + } + func debugInactiveOverlayState() -> (isHidden: Bool, alpha: CGFloat) { ( inactiveOverlayView.isHidden, @@ -12857,14 +12917,16 @@ final class GhosttySurfaceScrollView: NSView { return } let wasVisible = scrollView.hasVerticalScroller - let isUserInitiatedScroll = pendingExplicitWheelScroll || isLiveScrolling + let isKeyboardInitiatedScroll = + notification.userInfo?[GhosttyNotificationKey.scrollbarWasKeyboardInitiated] as? Bool ?? false + let isUserInitiatedScroll = pendingExplicitWheelScroll || isKeyboardInitiatedScroll || isLiveScrolling let shouldMarkVisibleTimestampRows = !isUserInitiatedScroll && !userScrolledAwayFromBottom timestampStore.record( scrollbar: TerminalTimestampScrollbarState(scrollbar), at: .now, markVisibleRows: shouldMarkVisibleTimestampRows ) - if pendingExplicitWheelScroll { + if pendingExplicitWheelScroll || isKeyboardInitiatedScroll { userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total allowExplicitScrollbarSync = true pendingExplicitWheelScroll = false diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 5a4611ff126d..09136a653f31 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2789,6 +2789,93 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) } + func testKeyboardInitiatedScrollbarUpdateSyncsWithoutTimestampBackfill() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surfaceView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + surfaceView.cellSize = CGSize(width: 10, height: 10) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { + XCTFail("Expected hosted terminal scroll view") + return + } + + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + XCTAssertEqual(scrollView.contentView.bounds.origin.y, 0, accuracy: 0.01) + XCTAssertEqual( + hostedView + .debugTimestampVisibleRows(for: makeScrollbar(total: 100, offset: 90, len: 10)) + .map(\.row), + Array(90 ..< 100) + ) + + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [ + GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 40, len: 10), + GhosttyNotificationKey.scrollbarWasKeyboardInitiated: true, + ] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + XCTAssertEqual(scrollView.contentView.bounds.origin.y, 500, accuracy: 0.01) + XCTAssertEqual( + hostedView + .debugTimestampVisibleRows(for: makeScrollbar(total: 100, offset: 40, len: 10)) + .map(\.row), + [], + "Keyboard backscroll should not stamp rows the user manually revealed" + ) + + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [ + GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 30, len: 10), + GhosttyNotificationKey.scrollbarWasKeyboardInitiated: true, + ] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + XCTAssertEqual( + scrollView.contentView.bounds.origin.y, + 600, + accuracy: 0.01, + "Keyboard scroll commands should keep syncing while the viewport is already in scrollback" + ) + XCTAssertEqual( + hostedView + .debugTimestampVisibleRows(for: makeScrollbar(total: 100, offset: 30, len: 10)) + .map(\.row), + [] + ) + } + func testInactiveOverlayVisibilityTracksRequestedState() { let hostedView = GhosttySurfaceScrollView( surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50)) From fe51489030372beee3b6caab3a76daf4c803c19a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:23:37 -0700 Subject: [PATCH 20/28] fix: carry keyboard scroll intent across callback --- Sources/GhosttyTerminalView.swift | 63 ++++++++++++++++++------- cmuxTests/TerminalAndGhosttyTests.swift | 18 ++----- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ffc1efa2b5ad..a674dfb7e15b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3848,7 +3848,7 @@ class GhosttyApp { let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) surfaceView.enqueueScrollbarUpdate( scrollbar, - wasKeyboardInitiated: surfaceView.isHandlingKeyboardInitiatedScrollAction() + wasKeyboardInitiated: surfaceView.consumeKeyboardInitiatedScrollAction() ) return true case GHOSTTY_ACTION_CELL_SIZE: @@ -6111,7 +6111,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } weak var terminalSurface: TerminalSurface? - private static let keyboardInitiatedScrollActionDepthKey = "cmux.keyboardInitiatedScrollActionDepth" var scrollbar: GhosttyScrollbar? /// Pending scrollbar value written from the action callback thread; /// read and cleared on the main thread by `flushPendingScrollbar()`. @@ -6120,6 +6119,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var _pendingScrollbar: GhosttyScrollbar? private var _pendingScrollbarWasKeyboardInitiated = false private var _scrollbarFlushScheduled = false + private var _pendingKeyboardInitiatedScrollAction = false + private var _keyboardInitiatedScrollActionDepth = 0 private let _scrollbarLock = NSLock() var cellSize: CGSize = .zero private var lastKnownMousePointInView: NSPoint? @@ -6167,23 +6168,27 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } private func beginKeyboardInitiatedScrollAction() { - let dictionary = Thread.current.threadDictionary - let currentDepth = dictionary[Self.keyboardInitiatedScrollActionDepthKey] as? Int ?? 0 - dictionary[Self.keyboardInitiatedScrollActionDepthKey] = currentDepth + 1 + _scrollbarLock.lock() + _keyboardInitiatedScrollActionDepth += 1 + _pendingKeyboardInitiatedScrollAction = true + _scrollbarLock.unlock() } private func endKeyboardInitiatedScrollAction() { - let dictionary = Thread.current.threadDictionary - let currentDepth = dictionary[Self.keyboardInitiatedScrollActionDepthKey] as? Int ?? 0 - if currentDepth <= 1 { - dictionary.removeObject(forKey: Self.keyboardInitiatedScrollActionDepthKey) - } else { - dictionary[Self.keyboardInitiatedScrollActionDepthKey] = currentDepth - 1 - } + _scrollbarLock.lock() + _keyboardInitiatedScrollActionDepth = max(0, _keyboardInitiatedScrollActionDepth - 1) + _scrollbarLock.unlock() } - func isHandlingKeyboardInitiatedScrollAction() -> Bool { - (Thread.current.threadDictionary[Self.keyboardInitiatedScrollActionDepthKey] as? Int ?? 0) > 0 + func consumeKeyboardInitiatedScrollAction() -> Bool { + _scrollbarLock.lock() + defer { _scrollbarLock.unlock() } + let wasKeyboardInitiated = + _keyboardInitiatedScrollActionDepth > 0 || _pendingKeyboardInitiatedScrollAction + if wasKeyboardInitiated { + _pendingKeyboardInitiatedScrollAction = false + } + return wasKeyboardInitiated } private func withKeyboardInitiatedScrollAction(_ body: () -> T) -> T { @@ -6192,6 +6197,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return body() } +#if DEBUG + func debugEnqueueScrollbarUpdateAfterKeyboardScrollIntent(_ newValue: GhosttyScrollbar) { + beginKeyboardInitiatedScrollAction() + endKeyboardInitiatedScrollAction() + enqueueScrollbarUpdate(newValue, wasKeyboardInitiated: consumeKeyboardInitiatedScrollAction()) + } +#endif + var desiredFocus: Bool = false var suppressingReparentFocus: Bool = false var tabId: UUID? @@ -7421,6 +7434,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { performKeyEquivalent(with: event, shouldRetryMainMenu: false) } + private func keyEventMayTriggerKeyboardScrollAction(_ event: NSEvent) -> Bool { + let flags = ShortcutStroke.normalizedModifierFlags(from: event.modifierFlags) + guard !flags.contains(.command) else { return false } + switch Int(event.keyCode) { + case kVK_PageUp, kVK_PageDown: + return true + default: + return false + } + } + private func performKeyEquivalent(with event: NSEvent, shouldRetryMainMenu: Bool) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() @@ -7668,8 +7692,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) #endif - beginKeyboardInitiatedScrollAction() - defer { endKeyboardInitiatedScrollAction() } + let shouldTrackKeyboardScrollAction = keyEventMayTriggerKeyboardScrollAction(event) + if shouldTrackKeyboardScrollAction { + beginKeyboardInitiatedScrollAction() + } + defer { + if shouldTrackKeyboardScrollAction { + endKeyboardInitiatedScrollAction() + } + } // Fast path for control-modified terminal input (for example Ctrl+D). // diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 09136a653f31..0870eb4b9850 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2835,13 +2835,8 @@ final class GhosttySurfaceOverlayTests: XCTestCase { Array(90 ..< 100) ) - NotificationCenter.default.post( - name: .ghosttyDidUpdateScrollbar, - object: surfaceView, - userInfo: [ - GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 40, len: 10), - GhosttyNotificationKey.scrollbarWasKeyboardInitiated: true, - ] + surfaceView.debugEnqueueScrollbarUpdateAfterKeyboardScrollIntent( + makeScrollbar(total: 100, offset: 40, len: 10) ) RunLoop.current.run(until: Date().addingTimeInterval(0.01)) XCTAssertEqual(scrollView.contentView.bounds.origin.y, 500, accuracy: 0.01) @@ -2853,13 +2848,8 @@ final class GhosttySurfaceOverlayTests: XCTestCase { "Keyboard backscroll should not stamp rows the user manually revealed" ) - NotificationCenter.default.post( - name: .ghosttyDidUpdateScrollbar, - object: surfaceView, - userInfo: [ - GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 30, len: 10), - GhosttyNotificationKey.scrollbarWasKeyboardInitiated: true, - ] + surfaceView.debugEnqueueScrollbarUpdateAfterKeyboardScrollIntent( + makeScrollbar(total: 100, offset: 30, len: 10) ) RunLoop.current.run(until: Date().addingTimeInterval(0.01)) XCTAssertEqual( From b9e8e28031033bd4e62bd024d96788c7e41ae4e3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:36:40 -0700 Subject: [PATCH 21/28] fix: preserve coalesced keyboard scroll intent --- Sources/GhosttyTerminalView.swift | 3 ++- cmuxTests/TerminalAndGhosttyTests.swift | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a674dfb7e15b..ad1694601928 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6134,7 +6134,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { defer { _scrollbarLock.unlock() } // Store the latest value (always overwrites — only the newest matters). _pendingScrollbar = newValue - _pendingScrollbarWasKeyboardInitiated = wasKeyboardInitiated + _pendingScrollbarWasKeyboardInitiated = + _pendingScrollbarWasKeyboardInitiated || wasKeyboardInitiated let needsSchedule = !_scrollbarFlushScheduled if needsSchedule { _scrollbarFlushScheduled = true } diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 0870eb4b9850..7f50de128ef2 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2838,6 +2838,10 @@ final class GhosttySurfaceOverlayTests: XCTestCase { surfaceView.debugEnqueueScrollbarUpdateAfterKeyboardScrollIntent( makeScrollbar(total: 100, offset: 40, len: 10) ) + surfaceView.enqueueScrollbarUpdate( + makeScrollbar(total: 100, offset: 40, len: 10), + wasKeyboardInitiated: false + ) RunLoop.current.run(until: Date().addingTimeInterval(0.01)) XCTAssertEqual(scrollView.contentView.bounds.origin.y, 500, accuracy: 0.01) XCTAssertEqual( From aa2d8fdf614367e1590940fbc4aa65ee75194dca Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 23:07:04 -0700 Subject: [PATCH 22/28] fix: limit keyboard scroll intent to viewport motions --- Sources/GhosttyTerminalView.swift | 63 +++++++++++++++++++++---- cmuxTests/TerminalAndGhosttyTests.swift | 17 +++++++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ad1694601928..b19170127305 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1363,6 +1363,16 @@ enum TerminalKeyboardCopyModeAction: Equatable { case searchNext case searchPrevious case adjustSelection(TerminalKeyboardCopyModeSelectionMove) + + var shouldTreatScrollbarUpdatesAsKeyboardInitiated: Bool { + switch self { + case .scrollLines, .scrollPage, .scrollHalfPage, .scrollToTop, .scrollToBottom, .jumpToPrompt: + return true + case .exit, .startSelection, .clearSelection, .copyAndExit, .copyLineAndExit, .startSearch, .searchNext, + .searchPrevious, .adjustSelection: + return false + } + } } struct TerminalKeyboardCopyModeInputState: Equatable { @@ -6915,6 +6925,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + private func performKeyboardCopyModeBindingAction( + _ bindingAction: String, + copyModeAction: TerminalKeyboardCopyModeAction, + repeatCount: Int = 1 + ) { + if copyModeAction.shouldTreatScrollbarUpdatesAsKeyboardInitiated { + performKeyboardInitiatedScrollBindingAction(bindingAction, repeatCount: repeatCount) + } else { + performBindingAction(bindingAction, repeatCount: repeatCount) + } + } + private func currentKeyboardCopyModeViewportRow(surface: ghostty_surface_t) -> Int { let rows = max(Int(ghostty_surface_size(surface).rows), 1) let fallback = rows - 1 @@ -7053,37 +7075,60 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _ = ghostty_surface_clear_selection_compat(surface) setKeyboardCopyModeActive(false) case let .scrollLines(delta): - _ = performKeyboardInitiatedScrollBindingAction("scroll_page_lines:\(delta * count)") + performKeyboardCopyModeBindingAction( + "scroll_page_lines:\(delta * count)", + copyModeAction: action + ) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .scrollPage(delta): - performKeyboardInitiatedScrollBindingAction( + performKeyboardCopyModeBindingAction( delta > 0 ? "scroll_page_down" : "scroll_page_up", + copyModeAction: action, repeatCount: count ) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .scrollHalfPage(delta): let fraction = delta > 0 ? 0.5 : -0.5 - performKeyboardInitiatedScrollBindingAction("scroll_page_fractional:\(fraction)", repeatCount: count) + performKeyboardCopyModeBindingAction( + "scroll_page_fractional:\(fraction)", + copyModeAction: action, + repeatCount: count + ) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .scrollToTop: keyboardCopyModeViewportRow = 0 - _ = performKeyboardInitiatedScrollBindingAction("scroll_to_top") + performKeyboardCopyModeBindingAction("scroll_to_top", copyModeAction: action) case .scrollToBottom: keyboardCopyModeViewportRow = max(Int(ghostty_surface_size(surface).rows) - 1, 0) - _ = performKeyboardInitiatedScrollBindingAction("scroll_to_bottom") + performKeyboardCopyModeBindingAction("scroll_to_bottom", copyModeAction: action) case let .jumpToPrompt(delta): - _ = performKeyboardInitiatedScrollBindingAction("jump_to_prompt:\(delta * count)") + performKeyboardCopyModeBindingAction( + "jump_to_prompt:\(delta * count)", + copyModeAction: action + ) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .startSearch: _ = performBindingAction("start_search") case .searchNext: - performKeyboardInitiatedScrollBindingAction("navigate_search:next", repeatCount: count) + performKeyboardCopyModeBindingAction( + "navigate_search:next", + copyModeAction: action, + repeatCount: count + ) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .searchPrevious: - performKeyboardInitiatedScrollBindingAction("navigate_search:previous", repeatCount: count) + performKeyboardCopyModeBindingAction( + "navigate_search:previous", + copyModeAction: action, + repeatCount: count + ) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .adjustSelection(direction): - performKeyboardInitiatedScrollBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count) + performKeyboardCopyModeBindingAction( + "adjust_selection:\(direction.rawValue)", + copyModeAction: action, + repeatCount: count + ) } return true } diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 7f50de128ef2..1f02ad2d0e7e 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1377,6 +1377,23 @@ final class TerminalKeyboardCopyModeResolveTests: XCTestCase { XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2)) } + func testOnlyViewportScrollMotionsTagScrollbarUpdatesAsKeyboardInitiated() { + XCTAssertTrue(TerminalKeyboardCopyModeAction.scrollLines(1).shouldTreatScrollbarUpdatesAsKeyboardInitiated) + XCTAssertTrue(TerminalKeyboardCopyModeAction.scrollPage(-1).shouldTreatScrollbarUpdatesAsKeyboardInitiated) + XCTAssertTrue(TerminalKeyboardCopyModeAction.scrollHalfPage(1).shouldTreatScrollbarUpdatesAsKeyboardInitiated) + XCTAssertTrue(TerminalKeyboardCopyModeAction.scrollToTop.shouldTreatScrollbarUpdatesAsKeyboardInitiated) + XCTAssertTrue(TerminalKeyboardCopyModeAction.scrollToBottom.shouldTreatScrollbarUpdatesAsKeyboardInitiated) + XCTAssertTrue(TerminalKeyboardCopyModeAction.jumpToPrompt(1).shouldTreatScrollbarUpdatesAsKeyboardInitiated) + + XCTAssertFalse(TerminalKeyboardCopyModeAction.searchNext.shouldTreatScrollbarUpdatesAsKeyboardInitiated) + XCTAssertFalse(TerminalKeyboardCopyModeAction.searchPrevious.shouldTreatScrollbarUpdatesAsKeyboardInitiated) + XCTAssertFalse( + TerminalKeyboardCopyModeAction + .adjustSelection(.down) + .shouldTreatScrollbarUpdatesAsKeyboardInitiated + ) + } + func testInvalidKeyClearsPendingState() { var state = TerminalKeyboardCopyModeInputState() XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume) From 9785587e3278d0ee39d8b6c5bd6bb005ceedd568 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 16:50:32 -0700 Subject: [PATCH 23/28] fix: preserve timestamp prune bounds --- Sources/TerminalTimestampStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalTimestampStore.swift b/Sources/TerminalTimestampStore.swift index c6d66886b692..9158a9991ba0 100644 --- a/Sources/TerminalTimestampStore.swift +++ b/Sources/TerminalTimestampStore.swift @@ -176,7 +176,7 @@ final class TerminalTimestampStore { if let previousNewestTrackedRow, previousNewestTrackedRow >= minimumRow { refreshOldestTrackedRow(startingAt: minimumRow) } else { - clearTrackedBounds() + refreshTrackedBounds() } } From 9b5cd7db69cd0a535e0bcb50ee8816334d00d872 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:16:49 -0700 Subject: [PATCH 24/28] fix: share timestamp scroll intent enqueue path --- Sources/GhosttyTerminalView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d94fb3de5ed7..0526cf5bc285 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3850,10 +3850,7 @@ class GhosttyApp { } case GHOSTTY_ACTION_SCROLLBAR: let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) - surfaceView.enqueueScrollbarUpdate( - scrollbar, - wasKeyboardInitiated: surfaceView.consumeKeyboardInitiatedScrollAction() - ) + surfaceView.enqueueScrollbarActionUpdate(scrollbar) return true case GHOSTTY_ACTION_CELL_SIZE: let cellSize = CGSize( @@ -6485,6 +6482,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + func enqueueScrollbarActionUpdate(_ newValue: GhosttyScrollbar) { + enqueueScrollbarUpdate(newValue, wasKeyboardInitiated: consumeKeyboardInitiatedScrollAction()) + } + private func flushPendingScrollbar() { _scrollbarLock.lock() _scrollbarFlushScheduled = false @@ -6540,7 +6541,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { func debugEnqueueScrollbarUpdateAfterKeyboardScrollIntent(_ newValue: GhosttyScrollbar) { beginKeyboardInitiatedScrollAction() endKeyboardInitiatedScrollAction() - enqueueScrollbarUpdate(newValue, wasKeyboardInitiated: consumeKeyboardInitiatedScrollAction()) + enqueueScrollbarActionUpdate(newValue) } #endif @@ -7805,7 +7806,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func keyEventMayTriggerKeyboardScrollAction(_ event: NSEvent) -> Bool { let flags = ShortcutStroke.normalizedModifierFlags(from: event.modifierFlags) - guard !flags.contains(.command) else { return false } + guard flags.isDisjoint(with: [.command, .control, .option]) else { return false } switch Int(event.keyCode) { case kVK_PageUp, kVK_PageDown: return true From c1d98b9624af28ca0fa8c40ec04782056cc91a52 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:40:51 -0700 Subject: [PATCH 25/28] Fix search scrollbar sync after keyboard backscroll --- Sources/GhosttyTerminalAppearance.swift | 1 + Sources/GhosttyTerminalView.swift | 138 +++++++++++++++++------- cmuxTests/TerminalAndGhosttyTests.swift | 69 ++++++++++++ 3 files changed, 172 insertions(+), 36 deletions(-) diff --git a/Sources/GhosttyTerminalAppearance.swift b/Sources/GhosttyTerminalAppearance.swift index 557bb8901fb7..a16e00a02a43 100644 --- a/Sources/GhosttyTerminalAppearance.swift +++ b/Sources/GhosttyTerminalAppearance.swift @@ -91,6 +91,7 @@ final class GhosttyDefaultBackgroundNotificationDispatcher { enum GhosttyNotificationKey { static let scrollbar = "ghostty.scrollbar" + static let scrollbarUpdateIntent = "ghostty.scrollbarUpdateIntent" static let scrollbarWasKeyboardInitiated = "ghostty.scrollbarWasKeyboardInitiated" static let cellSize = "ghostty.cellSize" static let tabId = "ghostty.tabId" diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 51e1818dc83f..1281ce08fd0a 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1365,14 +1365,31 @@ enum TerminalKeyboardCopyModeAction: Equatable { case adjustSelection(TerminalKeyboardCopyModeSelectionMove) var shouldTreatScrollbarUpdatesAsKeyboardInitiated: Bool { + scrollbarUpdateIntent.contains(.keyboardInitiated) + } + + var scrollbarUpdateIntent: GhosttyScrollbarUpdateIntent { switch self { case .scrollLines, .scrollPage, .scrollHalfPage, .scrollToTop, .scrollToBottom, .jumpToPrompt: - return true - case .exit, .startSelection, .clearSelection, .copyAndExit, .copyLineAndExit, .startSearch, .searchNext, - .searchPrevious, .adjustSelection: - return false + return [.keyboardInitiated, .explicitSync] + case .searchNext, .searchPrevious: + return [.explicitSync] + case .exit, .startSelection, .clearSelection, .copyAndExit, .copyLineAndExit, .startSearch, + .adjustSelection: + return [] } } + + var shouldCarryScrollbarUpdateIntentAfterBindingAction: Bool { + shouldTreatScrollbarUpdatesAsKeyboardInitiated + } +} + +struct GhosttyScrollbarUpdateIntent: OptionSet { + let rawValue: Int + + static let keyboardInitiated = GhosttyScrollbarUpdateIntent(rawValue: 1 << 0) + static let explicitSync = GhosttyScrollbarUpdateIntent(rawValue: 1 << 1) } struct TerminalKeyboardCopyModeInputState: Equatable { @@ -6625,10 +6642,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { /// Access is guarded by `_scrollbarLock` because the action callback /// fires on Ghostty's I/O thread while the flush runs on main. private var _pendingScrollbar: GhosttyScrollbar? - private var _pendingScrollbarWasKeyboardInitiated = false + private var _pendingScrollbarIntent: GhosttyScrollbarUpdateIntent = [] private var _scrollbarFlushScheduled = false - private var _pendingKeyboardInitiatedScrollAction = false - private var _keyboardInitiatedScrollActionDepth = 0 + private var _pendingScrollbarActionIntent: GhosttyScrollbarUpdateIntent = [] + private var _scrollbarActionIntentStack: [GhosttyScrollbarUpdateIntent] = [] private let _scrollbarLock = NSLock() var cellSize: CGSize = .zero private var lastKnownMousePointInView: NSPoint? @@ -6637,13 +6654,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { /// dispatch. The action callback (which may fire thousands of times per /// second during bulk output like `seq 1 100000`) stores the latest value /// and schedules exactly one async flush. - func enqueueScrollbarUpdate(_ newValue: GhosttyScrollbar, wasKeyboardInitiated: Bool = false) { + func enqueueScrollbarUpdate(_ newValue: GhosttyScrollbar, intent: GhosttyScrollbarUpdateIntent = []) { _scrollbarLock.lock() defer { _scrollbarLock.unlock() } // Store the latest value (always overwrites — only the newest matters). _pendingScrollbar = newValue - _pendingScrollbarWasKeyboardInitiated = - _pendingScrollbarWasKeyboardInitiated || wasKeyboardInitiated + _pendingScrollbarIntent.formUnion(intent) let needsSchedule = !_scrollbarFlushScheduled if needsSchedule { _scrollbarFlushScheduled = true } @@ -6655,17 +6671,24 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } + func enqueueScrollbarUpdate(_ newValue: GhosttyScrollbar, wasKeyboardInitiated: Bool) { + enqueueScrollbarUpdate( + newValue, + intent: wasKeyboardInitiated ? [.keyboardInitiated, .explicitSync] : [] + ) + } + func enqueueScrollbarActionUpdate(_ newValue: GhosttyScrollbar) { - enqueueScrollbarUpdate(newValue, wasKeyboardInitiated: consumeKeyboardInitiatedScrollAction()) + enqueueScrollbarUpdate(newValue, intent: consumeScrollbarActionIntent()) } private func flushPendingScrollbar() { _scrollbarLock.lock() _scrollbarFlushScheduled = false let pending = _pendingScrollbar - let pendingWasKeyboardInitiated = _pendingScrollbarWasKeyboardInitiated + let pendingIntent = _pendingScrollbarIntent _pendingScrollbar = nil - _pendingScrollbarWasKeyboardInitiated = false + _pendingScrollbarIntent = [] _scrollbarLock.unlock() guard let pending else { return } @@ -6675,45 +6698,72 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { object: self, userInfo: [ GhosttyNotificationKey.scrollbar: pending, - GhosttyNotificationKey.scrollbarWasKeyboardInitiated: pendingWasKeyboardInitiated, + GhosttyNotificationKey.scrollbarUpdateIntent: pendingIntent, + GhosttyNotificationKey.scrollbarWasKeyboardInitiated: + pendingIntent.contains(.keyboardInitiated), ] ) } - private func beginKeyboardInitiatedScrollAction() { + private func beginScrollbarActionIntent( + _ intent: GhosttyScrollbarUpdateIntent, + carryAcrossAction: Bool + ) { _scrollbarLock.lock() - _keyboardInitiatedScrollActionDepth += 1 - _pendingKeyboardInitiatedScrollAction = true + _scrollbarActionIntentStack.append(intent) + if carryAcrossAction { + _pendingScrollbarActionIntent.formUnion(intent) + } _scrollbarLock.unlock() } - private func endKeyboardInitiatedScrollAction() { + private func endScrollbarActionIntent() { _scrollbarLock.lock() - _keyboardInitiatedScrollActionDepth = max(0, _keyboardInitiatedScrollActionDepth - 1) + if !_scrollbarActionIntentStack.isEmpty { + _ = _scrollbarActionIntentStack.removeLast() + } _scrollbarLock.unlock() } - func consumeKeyboardInitiatedScrollAction() -> Bool { + private func beginKeyboardInitiatedScrollAction() { + beginScrollbarActionIntent([.keyboardInitiated, .explicitSync], carryAcrossAction: true) + } + + private func endKeyboardInitiatedScrollAction() { + endScrollbarActionIntent() + } + + func consumeScrollbarActionIntent() -> GhosttyScrollbarUpdateIntent { _scrollbarLock.lock() defer { _scrollbarLock.unlock() } - let wasKeyboardInitiated = - _keyboardInitiatedScrollActionDepth > 0 || _pendingKeyboardInitiatedScrollAction - if wasKeyboardInitiated { - _pendingKeyboardInitiatedScrollAction = false + var intent = _pendingScrollbarActionIntent + for activeIntent in _scrollbarActionIntentStack { + intent.formUnion(activeIntent) + } + if !intent.isEmpty { + _pendingScrollbarActionIntent = [] } - return wasKeyboardInitiated + return intent } - private func withKeyboardInitiatedScrollAction(_ body: () -> T) -> T { - beginKeyboardInitiatedScrollAction() - defer { endKeyboardInitiatedScrollAction() } + func consumeKeyboardInitiatedScrollAction() -> Bool { + consumeScrollbarActionIntent().contains(.keyboardInitiated) + } + + private func withScrollbarActionIntent( + _ intent: GhosttyScrollbarUpdateIntent, + carryAcrossAction: Bool, + _ body: () -> T + ) -> T { + beginScrollbarActionIntent(intent, carryAcrossAction: carryAcrossAction) + defer { endScrollbarActionIntent() } return body() } #if DEBUG func debugEnqueueScrollbarUpdateAfterKeyboardScrollIntent(_ newValue: GhosttyScrollbar) { - beginKeyboardInitiatedScrollAction() - endKeyboardInitiatedScrollAction() + beginScrollbarActionIntent([.keyboardInitiated, .explicitSync], carryAcrossAction: true) + endScrollbarActionIntent() enqueueScrollbarActionUpdate(newValue) } #endif @@ -7381,7 +7431,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } private func performKeyboardInitiatedScrollBindingAction(_ action: String) -> Bool { - withKeyboardInitiatedScrollAction { + withScrollbarActionIntent([.keyboardInitiated, .explicitSync], carryAcrossAction: true) { performBindingAction(action) } } @@ -7434,8 +7484,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { copyModeAction: TerminalKeyboardCopyModeAction, repeatCount: Int = 1 ) { - if copyModeAction.shouldTreatScrollbarUpdatesAsKeyboardInitiated { - performKeyboardInitiatedScrollBindingAction(bindingAction, repeatCount: repeatCount) + let intent = copyModeAction.scrollbarUpdateIntent + if !intent.isEmpty { + let count = terminalKeyboardCopyModeClampCount(repeatCount) + for _ in 0 ..< count { + _ = withScrollbarActionIntent( + intent, + carryAcrossAction: copyModeAction.shouldCarryScrollbarUpdateIntentAfterBindingAction + ) { + performBindingAction(bindingAction) + } + } } else { performBindingAction(bindingAction, repeatCount: repeatCount) } @@ -13527,16 +13586,23 @@ final class GhosttySurfaceScrollView: NSView { return } let wasVisible = scrollView.hasVerticalScroller - let isKeyboardInitiatedScroll = + let legacyKeyboardInitiated = notification.userInfo?[GhosttyNotificationKey.scrollbarWasKeyboardInitiated] as? Bool ?? false - let isUserInitiatedScroll = pendingExplicitWheelScroll || isKeyboardInitiatedScroll || isLiveScrolling + var scrollbarIntent = + notification.userInfo?[GhosttyNotificationKey.scrollbarUpdateIntent] as? GhosttyScrollbarUpdateIntent + ?? [] + if legacyKeyboardInitiated { + scrollbarIntent.formUnion([.keyboardInitiated, .explicitSync]) + } + let shouldExplicitlySyncScrollbar = pendingExplicitWheelScroll || scrollbarIntent.contains(.explicitSync) + let isUserInitiatedScroll = shouldExplicitlySyncScrollbar || isLiveScrolling let shouldMarkVisibleTimestampRows = !isUserInitiatedScroll && !userScrolledAwayFromBottom timestampStore.record( scrollbar: TerminalTimestampScrollbarState(scrollbar), at: .now, markVisibleRows: shouldMarkVisibleTimestampRows ) - if pendingExplicitWheelScroll || isKeyboardInitiatedScroll { + if shouldExplicitlySyncScrollbar { userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total allowExplicitScrollbarSync = true pendingExplicitWheelScroll = false diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 42b5683ff64b..d7503fd7eb0c 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1623,6 +1623,8 @@ final class TerminalKeyboardCopyModeResolveTests: XCTestCase { XCTAssertFalse(TerminalKeyboardCopyModeAction.searchNext.shouldTreatScrollbarUpdatesAsKeyboardInitiated) XCTAssertFalse(TerminalKeyboardCopyModeAction.searchPrevious.shouldTreatScrollbarUpdatesAsKeyboardInitiated) + XCTAssertTrue(TerminalKeyboardCopyModeAction.searchNext.scrollbarUpdateIntent.contains(.explicitSync)) + XCTAssertTrue(TerminalKeyboardCopyModeAction.searchPrevious.scrollbarUpdateIntent.contains(.explicitSync)) XCTAssertFalse( TerminalKeyboardCopyModeAction .adjustSelection(.down) @@ -3179,6 +3181,73 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) } + func testSearchScrollbarUpdateSyncsAfterKeyboardBackscroll() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surfaceView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + surfaceView.cellSize = CGSize(width: 10, height: 10) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { + XCTFail("Expected hosted terminal scroll view") + return + } + + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + XCTAssertEqual(scrollView.contentView.bounds.origin.y, 0, accuracy: 0.01) + + surfaceView.debugEnqueueScrollbarUpdateAfterKeyboardScrollIntent( + makeScrollbar(total: 100, offset: 40, len: 10) + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + XCTAssertEqual(scrollView.contentView.bounds.origin.y, 500, accuracy: 0.01) + + surfaceView.enqueueScrollbarUpdate( + makeScrollbar(total: 100, offset: 20, len: 10), + intent: [.explicitSync] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + + XCTAssertEqual( + scrollView.contentView.bounds.origin.y, + 700, + accuracy: 0.01, + "Search navigation should sync the wrapper viewport even after keyboard backscroll disabled passive auto-scroll" + ) + XCTAssertEqual( + hostedView + .debugTimestampVisibleRows(for: makeScrollbar(total: 100, offset: 20, len: 10)) + .map(\.row), + [], + "Search-driven scrollback navigation should not backfill timestamps for manually revealed rows" + ) + } + func testInactiveOverlayVisibilityTracksRequestedState() { let hostedView = GhosttySurfaceScrollView( surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50)) From dac1051b70703640040a20f7e5a6347773c13855 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:46:57 -0700 Subject: [PATCH 26/28] Remove obsolete keyboard scroll intent helper --- Sources/GhosttyTerminalView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1281ce08fd0a..eef88af0457c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6746,10 +6746,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return intent } - func consumeKeyboardInitiatedScrollAction() -> Bool { - consumeScrollbarActionIntent().contains(.keyboardInitiated) - } - private func withScrollbarActionIntent( _ intent: GhosttyScrollbarUpdateIntent, carryAcrossAction: Bool, From 50c6c594f87ee7d782d56892a19c9cbd27a89826 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 07:06:57 -0700 Subject: [PATCH 27/28] Add terminal timestamp localizations --- Resources/Localizable.xcstrings | 920 ++++++++++++++++++++++++++++++-- 1 file changed, 868 insertions(+), 52 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c08402921f53..dc8e6620a200 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -107106,101 +107106,713 @@ "settings.terminal.timestamps": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار الطوابع الزمنية للطرفية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži vremenske oznake terminala" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis terminaltidsstempler" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Zeitstempel anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Terminal Timestamps" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar marcas de tiempo del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les horodatages du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra timestamp del terminale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのタイムスタンプを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 타임스탬프 표시" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis terminaltidsstempler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokaż znaczniki czasu terminala" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar carimbos de data/hora do terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать временные метки терминала" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงเวลาประทับของเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Zaman Damgalarını Göster" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Показувати часові мітки термінала" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示终端时间戳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示終端時間戳" + } + } + } + }, + "settings.terminal.timestamps.subtitleOff": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يخفي هامش الطوابع الزمنية للطرفية. يمكن أيضًا تبديل الطوابع الزمنية من قائمة العرض أو لوحة الأوامر." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sakriva marginu vremenskih oznaka terminala. Vremenske oznake se mogu prebaciti i iz menija Prikaz ili komandne palete." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skjuler terminalens tidsstempelrende. Tidsstempler kan også slås til/fra fra menuen Vis eller kommandopaletten." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Blendet die Zeitstempelleiste des Terminals aus. Zeitstempel können auch über das Menü Ansicht oder die Befehlspalette umgeschaltet werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hides the terminal timestamp gutter. Timestamps can also be toggled from the View menu or command palette." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Oculta el margen de marcas de tiempo del terminal. Las marcas de tiempo también se pueden alternar desde el menú Ver o la paleta de comandos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masque la gouttière d'horodatage du terminal. Les horodatages peuvent aussi être activés ou désactivés depuis le menu Présentation ou la palette de commandes." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nasconde il margine dei timestamp del terminale. I timestamp possono anche essere attivati o disattivati dal menu Vista o dalla tavolozza comandi." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのタイムスタンプ欄を隠します。タイムスタンプは表示メニューやコマンドパレットからも切り替えられます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 타임스탬프 여백을 숨깁니다. 보기 메뉴 또는 명령 팔레트에서도 타임스탬프를 전환할 수 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skjuler tidsstempelmargen i terminalen. Tidsstempler kan også slås av og på fra Vis-menyen eller kommandopaletten." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ukrywa margines znaczników czasu terminala. Znaczniki czasu można też przełączać z menu Widok lub palety poleceń." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Oculta a margem de carimbos de data/hora do terminal. Os carimbos também podem ser alternados pelo menu Visualizar ou pela paleta de comandos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрывает область временных меток терминала. Временные метки также можно переключать в меню Вид или палитре команд." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ซ่อนแถบเวลาประทับของเทอร์มินัล คุณยังเปิด/ปิดเวลาประทับได้จากเมนูมุมมองหรือพาเล็ตคำสั่ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal zaman damgası sütununu gizler. Zaman damgaları Görünüm menüsünden veya komut paletinden de açılıp kapatılabilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Приховує поле часових міток термінала. Часові мітки також можна перемикати з меню Перегляд або палітри команд." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏终端时间戳边栏。也可以从“显示”菜单或命令面板切换时间戳。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏終端時間戳邊欄。也可以從「顯示」選單或命令面板切換時間戳。" + } + } + } + }, + "settings.terminal.timestamps.subtitleOn": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض هامشًا زمنيًا على اليسار بمحاذاة صفوف خرج الطرفية." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikazuje lijevu vremensku marginu poravnatu s redovima izlaza terminala." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Viser en venstresidet tidsrende, der flugter med terminalens outputrækker." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt links eine Zeitleiste an, die an den Ausgabezeilen des Terminals ausgerichtet ist." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows a left-side time gutter aligned with terminal output rows." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra una columna de hora a la izquierda alineada con las filas de salida del terminal." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche une gouttière horaire à gauche alignée avec les lignes de sortie du terminal." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra un margine temporale a sinistra allineato alle righe di output del terminale." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル出力行にそろえた左側の時刻欄を表示します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 출력 행에 맞춘 왼쪽 시간 여백을 표시합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Viser en venstrestilt tidsmarg som er justert med terminalens utdatarader." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokazuje lewy margines czasu wyrównany z wierszami wyjścia terminala." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostra uma margem de horário à esquerda alinhada às linhas de saída do terminal." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывает слева область времени, выровненную по строкам вывода терминала." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงแถบเวลาด้านซ้ายที่จัดแนวกับแถวเอาต์พุตของเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal çıktı satırlarıyla hizalı sol tarafta bir zaman sütunu gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Показує ліворуч поле часу, вирівняне за рядками виводу термінала." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示与终端输出行对齐的左侧时间栏。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示與終端輸出列對齊的左側時間欄。" + } + } + } + }, + "shortcut.toggleTerminalTimestamps.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل الطوابع الزمنية للطرفية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci vremenske oznake terminala" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå terminaltidsstempler til/fra" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Zeitstempel ein-/ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Terminal Timestamps" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar marcas de tiempo del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer/désactiver les horodatages du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva timestamp del terminale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルタイムスタンプを切り替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 타임스탬프 전환" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå terminaltidsstempler av/på" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz znaczniki czasu terminala" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar carimbos de data/hora do terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переключить временные метки терминала" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับเวลาประทับของเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Zaman Damgalarını Aç/Kapat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перемкнути часові мітки термінала" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换终端时间戳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換終端時間戳" + } + } + } + }, + "menu.view.toggleTerminalTimestamps": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل الطوابع الزمنية للطرفية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci vremenske oznake terminala" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå terminaltidsstempler til/fra" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Zeitstempel ein-/ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Terminal Timestamps" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alternar marcas de tiempo del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer/désactiver les horodatages du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva timestamp del terminale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルタイムスタンプを切り替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 타임스탬프 전환" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå terminaltidsstempler av/på" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przełącz znaczniki czasu terminala" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Alternar carimbos de data/hora do terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переключить временные метки терминала" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สลับเวลาประทับของเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal Zaman Damgalarını Aç/Kapat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перемкнути часові мітки термінала" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换终端时间戳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換終端時間戳" + } + } + } + }, + "command.terminalToggleTimestamps.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تبديل الطوابع الزمنية للطرفية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prebaci vremenske oznake terminala" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå terminaltidsstempler til/fra" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Zeitstempel ein-/ausblenden" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Show Terminal Timestamps" + "value": "Toggle Terminal Timestamps" } }, - "ja": { + "es": { "stringUnit": { "state": "translated", - "value": "ターミナルのタイムスタンプを表示" + "value": "Alternar marcas de tiempo del terminal" } - } - } - }, - "settings.terminal.timestamps.subtitleOff": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "fr": { "stringUnit": { "state": "translated", - "value": "Hides the terminal timestamp gutter. Timestamps can also be toggled from the View menu or command palette." + "value": "Activer/désactiver les horodatages du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva/Disattiva timestamp del terminale" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルのタイムスタンプ欄を隠します。タイムスタンプは表示メニューやコマンドパレットからも切り替えられます。" + "value": "ターミナルタイムスタンプを切り替え" } - } - } - }, - "settings.terminal.timestamps.subtitleOn": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "Shows a left-side time gutter aligned with terminal output rows." + "value": "터미널 타임스탬프 전환" } }, - "ja": { + "nb": { "stringUnit": { "state": "translated", - "value": "ターミナル出力行にそろえた左側の時刻欄を表示します。" + "value": "Slå terminaltidsstempler av/på" } - } - } - }, - "shortcut.toggleTerminalTimestamps.label": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "pl": { "stringUnit": { "state": "translated", - "value": "Toggle Terminal Timestamps" + "value": "Przełącz znaczniki czasu terminala" } }, - "ja": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "ターミナルタイムスタンプを切り替え" + "value": "Alternar carimbos de data/hora do terminal" } - } - } - }, - "menu.view.toggleTerminalTimestamps": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ru": { "stringUnit": { "state": "translated", - "value": "Toggle Terminal Timestamps" + "value": "Переключить временные метки терминала" } }, - "ja": { + "th": { "stringUnit": { "state": "translated", - "value": "ターミナルタイムスタンプを切り替え" + "value": "สลับเวลาประทับของเทอร์มินัล" } - } - } - }, - "command.terminalToggleTimestamps.title": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "tr": { "stringUnit": { "state": "translated", - "value": "Toggle Terminal Timestamps" + "value": "Terminal Zaman Damgalarını Aç/Kapat" } }, - "ja": { + "uk": { "stringUnit": { "state": "translated", - "value": "ターミナルタイムスタンプを切り替え" + "value": "Перемкнути часові мітки термінала" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换终端时间戳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換終端時間戳" } } } @@ -107208,34 +107820,238 @@ "terminal.timestamps.gutter.accessibility": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الطوابع الزمنية للطرفية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vremenske oznake terminala" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Terminaltidsstempler" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-Zeitstempel" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Terminal timestamps" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Marcas de tiempo del terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Horodatages du terminal" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Timestamp del terminale" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "ターミナルタイムスタンプ" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 타임스탬프" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Terminaltidsstempler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Znaczniki czasu terminala" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Carimbos de data/hora do terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Временные метки терминала" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวลาประทับของเทอร์มินัล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal zaman damgaları" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Часові мітки термінала" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端时间戳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端時間戳" + } } } }, "settings.search.alias.setting.terminal.timestamps": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug طرفية طوابع زمنية وقت هامش خرج أسطر طويل التشغيل وكيل توقف تصحيح" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal vremenske oznake vrijeme margina izlaz redovi dugo pokretanje agent zastoj debug" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal tidsstempler tid rende output linjer lang kørsel agent hænger fejlfinding" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal zeitstempel zeit leiste ausgabe zeilen lange laufzeit agent hängt debug" + } + }, "en": { "stringUnit": { "state": "translated", "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal marcas tiempo hora margen salida líneas ejecución larga agente bloqueo depuración" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal horodatages heure gouttière sortie lignes longue exécution agent blocage débogage" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal timestamp ora margine output righe lunga esecuzione agente blocco debug" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "terminal.showTimestamps タイムスタンプ 時刻 出力 行 長時間 エージェント 停止 デバッグ" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug 터미널 타임스탬프 시간 여백 출력 행 장시간 실행 에이전트 멈춤 디버그" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal tidsstempler tid marg utdata linjer lang kjøring agent henger feilsøking" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal znaczniki czasu czas margines wyjście wiersze długie działanie agent zawieszenie debugowanie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal carimbos data hora tempo margem saída linhas longa execução agente travamento depuração" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug терминал временные метки время область вывод строки долгий запуск агент зависание отладка" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug เทอร์มินัล เวลาประทับ เวลา แถบ เอาต์พุต บรรทัด ทำงานนาน เอเจนต์ ค้าง ดีบัก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug terminal zaman damgaları saat sütun çıktı satırlar uzun çalışan ajan takılma hata ayıklama" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug термінал часові мітки час поле вивід рядки тривалий запуск агент зависання налагодження" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug 终端 时间戳 时间 边栏 输出 行 长时间运行 代理 卡住 调试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "terminal.showTimestamps timestamps time gutter output lines long running agent hang debug 終端 時間戳 時間 邊欄 輸出 列 長時間執行 代理 卡住 除錯" + } } } }, From 9c1a74e8a90ed40182e99c068901267b0f0ff2c4 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 22 May 2026 13:11:04 -0700 Subject: [PATCH 28/28] fix: align cli helpers with main --- CLI/CMUXCLI+AgentHookDefinitions.swift | 89 ++++++--- CLI/CMUXCLI+Memory.swift | 229 ++++++++++++++++++++++++ CLI/CMUXCLI+MoveTabToNewWorkspace.swift | 38 ++-- CLI/CMUXCLI+ThemeSupport.swift | 101 +++++++++-- CLI/CMUXCLI+Themes.swift | 202 ++++++++++++++++----- 5 files changed, 563 insertions(+), 96 deletions(-) create mode 100644 CLI/CMUXCLI+Memory.swift diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 2f91aa805d91..b766f02ea48f 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -32,6 +32,7 @@ extension CMUXCLI { enum HookFormat { case flat // Cursor: {"hooks": {"event": [{"command": "..."}]}, "version": 1} case nested(timeoutMs: Int) // Codex/Gemini: nested with type/command/timeout + case antigravityJSON(timeoutSeconds: Int) // ~/.gemini/config/hooks.json named hook groups case rovoDevYAML case hermesAgentYAML } @@ -197,6 +198,23 @@ extension CMUXCLI { ], feedHookEvents: ["PreToolUse"] ), + AgentHookDef( + name: "antigravity", displayName: "Antigravity", statusKey: "antigravity", + configDir: ".gemini/config", configFile: "hooks.json", + createConfigDirIfMissing: true, binaryName: "agy", + sessionStoreSuffix: "antigravity", disableEnvVar: "CMUX_ANTIGRAVITY_HOOKS_DISABLED", + hookMarker: "cmux hooks antigravity", format: .antigravityJSON(timeoutSeconds: 10), + events: [ + .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "PreInvocation", cmuxSubcommand: "prompt-submit"), + .init(agentEvent: "Stop", cmuxSubcommand: "stop"), + .init(agentEvent: "turn-completion", cmuxSubcommand: "stop"), + .init(agentEvent: "Notification", cmuxSubcommand: "notification"), + .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), + ], + aliases: ["agy"], + feedHookEvents: ["PreToolUse", "PostToolUse"] + ), AgentHookDef( name: "rovodev", displayName: "Rovo Dev", statusKey: "rovodev", configDir: ".rovodev", configFile: "config.yml", binaryName: "acli", @@ -292,43 +310,55 @@ extension CMUXCLI { } private static let grokPinnedHookMarker = "cmux-grok-hook-v2" + private static let antigravityPinnedHookMarker = "cmux-antigravity-hook-v2" private static func agentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { - if def.name == "grok" { - return grokAgentHookShellCommand(command, for: def) + if usesPinnedHookDispatch(def) { + return pinnedAgentHookShellCommand(command, for: def) } let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then { if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; } || echo '{}'; else echo '{}'; fi" } - private static func grokAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { + private static func usesPinnedHookDispatch(_ def: AgentHookDef) -> Bool { + def.name == "grok" || def.name == "antigravity" + } + + private static func pinnedHookMarker(for def: AgentHookDef) -> String { + def.name == "antigravity" ? antigravityPinnedHookMarker : grokPinnedHookMarker + } + + private static func pinnedAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command - let socketPath = pinnedGrokHookSocketPath() - let shellTraceStart = grokHookShellTraceCommand( + let socketPath = pinnedAgentHookSocketPath() + let shellTraceStart = pinnedHookShellTraceCommand( + agentName: def.name, phase: "start", routedArguments: routedArguments, socketPath: socketPath ) - let shellTraceDisabled = grokHookShellTraceCommand( + let shellTraceDisabled = pinnedHookShellTraceCommand( + agentName: def.name, phase: "disabled", routedArguments: routedArguments, socketPath: socketPath ) - let shellTraceExit = grokHookShellTraceCommand( + let shellTraceExit = pinnedHookShellTraceCommand( + agentName: def.name, phase: "exit", routedArguments: routedArguments, socketPath: socketPath, statusExpression: "$cmux_hook_status" ) - let fallbackInvocation = grokHookInvocation( + let fallbackInvocation = pinnedHookInvocation( executable: "cmux", routedArguments: routedArguments, socketPath: socketPath ) let dispatch: String - if let cliPath = pinnedGrokHookCLIPath() { + if let cliPath = pinnedAgentHookCLIPath() { let quotedCLIPath = shellSingleQuote(cliPath) - let primaryInvocation = grokHookInvocation( + let primaryInvocation = pinnedHookInvocation( executable: quotedCLIPath, routedArguments: routedArguments, socketPath: socketPath @@ -337,10 +367,10 @@ extension CMUXCLI { } else { dispatch = "command -v cmux >/dev/null 2>&1 && \(fallbackInvocation) || echo '{}'" } - return ": \(grokPinnedHookMarker); \(shellTraceStart); printenv \(def.disableEnvVar) | grep -qx 1 && { \(shellTraceDisabled); echo '{}'; } || { \(dispatch); cmux_hook_status=$?; \(shellTraceExit); exit $cmux_hook_status; }" + return ": \(pinnedHookMarker(for: def)); \(shellTraceStart); printenv \(def.disableEnvVar) | grep -qx 1 && { \(shellTraceDisabled); echo '{}'; } || { \(dispatch); cmux_hook_status=$?; \(shellTraceExit); exit $cmux_hook_status; }" } - private static func grokHookInvocation( + private static func pinnedHookInvocation( executable: String, routedArguments: String, socketPath: String? @@ -351,27 +381,41 @@ extension CMUXCLI { return "\(executable) \(routedArguments)" } - private static func pinnedGrokHookCLIPath( + private static func pinnedAgentHookCLIPath( env: [String: String] = ProcessInfo.processInfo.environment, arguments: [String] = ProcessInfo.processInfo.arguments ) -> String? { if let bundledPath = normalizedHookInstallValue(env["CMUX_BUNDLED_CLI_PATH"]) { - return NSString(string: bundledPath).expandingTildeInPath + let expanded = NSString(string: bundledPath).expandingTildeInPath + if isExecutableFilePath(expanded) { + return expanded + } } if let arg0 = normalizedHookInstallValue(arguments.first) { let expanded = NSString(string: arg0).expandingTildeInPath - if expanded.hasPrefix("/") { + if expanded.hasPrefix("/"), isExecutableFilePath(expanded) { return expanded } } if let executablePath = Bundle.main.executableURL?.path, - !executablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + !executablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + isExecutableFilePath(executablePath) { return executablePath } return nil } - private static func pinnedGrokHookSocketPath( + private static func isExecutableFilePath(_ path: String) -> Bool { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), + !isDirectory.boolValue + else { + return false + } + return FileManager.default.isExecutableFile(atPath: path) + } + + private static func pinnedAgentHookSocketPath( env: [String: String] = ProcessInfo.processInfo.environment ) -> String? { if let socketPath = normalizedHookInstallValue(env["CMUX_SOCKET_PATH"]) { @@ -388,25 +432,26 @@ extension CMUXCLI { return "/tmp/cmux-debug-\(slug).sock" } - private static func grokHookShellTraceCommand( + private static func pinnedHookShellTraceCommand( + agentName: String, phase: String, routedArguments: String, socketPath: String?, statusExpression: String? = nil ) -> String { #if DEBUG - let logPath = shellSingleQuote(grokHookShellTraceLogPath(socketPath: socketPath)) + let logPath = shellSingleQuote(pinnedHookShellTraceLogPath(socketPath: socketPath)) let event = shellSingleQuote(routedArguments) let socket = shellSingleQuote(socketPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "nil") let statusField = statusExpression == nil ? "" : " status=%s" let statusArgument = statusExpression.map { " \($0)" } ?? "" - return "printf '%s grokHook.shell phase=%s event=%s pid=%s ppid=%s socket=%s\(statusField)\\n' \"$(date +%s)\" \(shellSingleQuote(phase)) \(event) \"$$\" \"${PPID:-}\" \(socket)\(statusArgument) >> \(logPath) 2>/dev/null || true" + return "printf '%s \(agentName)Hook.shell phase=%s event=%s pid=%s ppid=%s socket=%s\(statusField)\\n' \"$(date +%s)\" \(shellSingleQuote(phase)) \(event) \"$$\" \"${PPID:-}\" \(socket)\(statusArgument) >> \(logPath) 2>/dev/null || true" #else return ":" #endif } - private static func grokHookShellTraceLogPath(socketPath: String?) -> String { + private static func pinnedHookShellTraceLogPath(socketPath: String?) -> String { guard let socketPath else { return "/tmp/cmux-debug.log" } @@ -432,7 +477,7 @@ extension CMUXCLI { } static func isCmuxOwnedHookCommand(_ command: String, for def: AgentHookDef, includeLegacy: Bool = true) -> Bool { - if def.name == "grok", command.contains(grokPinnedHookMarker) { + if usesPinnedHookDispatch(def), command.contains(pinnedHookMarker(for: def)) { return true } if def.events.contains(where: { hookCommandString(for: def, event: $0) == command }) diff --git a/CLI/CMUXCLI+Memory.swift b/CLI/CMUXCLI+Memory.swift new file mode 100644 index 000000000000..f9587327bde9 --- /dev/null +++ b/CLI/CMUXCLI+Memory.swift @@ -0,0 +1,229 @@ +import Foundation + +extension CMUXCLI { + private struct MemoryCommandOptions { + let includeAllWindows: Bool + let workspaceHandle: String? + let jsonOutput: Bool + let topGroupLimit: Int + } + + func runMemoryCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + let options = try parseMemoryCommandOptions(commandArgs) + let payload = try buildMemoryPayload(options: options, client: client) + if jsonOutput || options.jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + print(renderMemoryText(payload: payload, idFormat: idFormat)) + } + } + + private func parseMemoryCommandOptions(_ args: [String]) throws -> MemoryCommandOptions { + let (workspaceOpt, rem0) = parseOption(args, name: "--workspace") + if rem0.contains("--workspace") { + throw CLIError(message: String(localized: "cli.memory.error.workspaceRequiresValue", defaultValue: "memory requires --workspace ")) + } + let (groupsOpt, rem1) = parseOption(rem0, name: "--groups") + if rem1.contains("--groups") { + throw CLIError(message: String(localized: "cli.memory.error.groupsRequiresValue", defaultValue: "memory requires --groups ")) + } + + var includeAll = false + var jsonOutput = false + var remaining: [String] = [] + for arg in rem1 { + if arg == "--all" { + includeAll = true + continue + } + if arg == "--json" { + jsonOutput = true + continue + } + remaining.append(arg) + } + + if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: String.localizedStringWithFormat( + String(localized: "cli.memory.error.unknownFlag", defaultValue: "memory: unknown flag '%@'. Known flags: --all --workspace --groups --json"), + unknown + )) + } + if let extra = remaining.first { + throw CLIError(message: String.localizedStringWithFormat( + String(localized: "cli.memory.error.unexpectedArgument", defaultValue: "memory: unexpected argument '%@'"), + extra + )) + } + + let topGroupLimit: Int + if let groupsOpt { + guard let parsed = Int(groupsOpt), (1...100).contains(parsed) else { + throw CLIError(message: String.localizedStringWithFormat( + String(localized: "cli.memory.error.invalidGroups", defaultValue: "memory: invalid --groups value '%@'. Use an integer from 1 to 100"), + groupsOpt + )) + } + topGroupLimit = parsed + } else { + topGroupLimit = 12 + } + + return MemoryCommandOptions( + includeAllWindows: includeAll, + workspaceHandle: workspaceOpt, + jsonOutput: jsonOutput, + topGroupLimit: topGroupLimit + ) + } + + private func buildMemoryPayload( + options: MemoryCommandOptions, + client: SocketClient + ) throws -> [String: Any] { + var params: [String: Any] = [ + "all_windows": options.includeAllWindows, + "top_group_limit": options.topGroupLimit + ] + if let workspaceRaw = options.workspaceHandle { + guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else { + throw CLIError(message: String(format: String( + localized: "cli.memory.error.invalidWorkspace", + defaultValue: "memory: invalid workspace handle '%@'" + ), workspaceRaw)) + } + params["workspace_id"] = workspaceHandle + } + if let caller = treeCallerContextFromEnvironment() { + params["caller"] = caller + } + + do { + return try client.sendV2(method: "system.memory", params: params) + } catch let error as CLIError where error.message.hasPrefix("method_not_found:") { + throw CLIError(message: String(localized: "cli.memory.error.diagnosticsUnsupported", defaultValue: "cmux memory requires a running cmux build that supports memory diagnostics")) + } + } + + private func renderMemoryText( + payload: [String: Any], + idFormat: CLIIDFormat + ) -> String { + guard let diagnostic = payload["memory_diagnostic"] as? [String: Any] else { + return String(localized: "cli.memory.output.noDiagnostic", defaultValue: "No memory diagnostic available") + } + + let app = diagnostic["app"] as? [String: Any] ?? [:] + let children = diagnostic["children"] as? [String: Any] ?? [:] + let appName = topLabelText(app["name"] as? String) + let appPID = topInt(app["pid"]).map(String.init) ?? "?" + let appFootprint = topInt64(app["physical_footprint_bytes"]) + let appRSS = topInt64(app["resident_bytes"]) + let childRSS = topInt64(children["recursive_rss_bytes"]) + let childCount = topInt(children["process_count"]) ?? 0 + let summary = topLabelText(diagnostic["summary"] as? String) + + var lines: [String] = [] + if !summary.isEmpty { + lines.append(summary) + lines.append("") + } + lines.append(String(localized: "cli.memory.output.appHeader", defaultValue: "APP")) + lines.append(" \(appName.isEmpty ? "cmux" : appName) pid=\(appPID)") + lines.append(String.localizedStringWithFormat( + String(localized: "cli.memory.output.appFootprint", defaultValue: " footprint %@"), + formatBytes(appFootprint) + )) + lines.append(String.localizedStringWithFormat( + String(localized: "cli.memory.output.appRSS", defaultValue: " rss %@"), + formatBytes(appRSS) + )) + lines.append("") + lines.append(String(localized: "cli.memory.output.childrenHeader", defaultValue: "CHILD PROCESSES")) + lines.append(String.localizedStringWithFormat( + String(localized: "cli.memory.output.recursiveRSS", defaultValue: " recursive RSS %@ across %@"), + formatBytes(childRSS), + memoryProcessCountText(childCount) + )) + + let groups = children["groups"] as? [[String: Any]] ?? [] + guard !groups.isEmpty else { + lines.append(String(localized: "cli.memory.output.noChildGroups", defaultValue: " no child process groups")) + return lines.joined(separator: "\n") + } + + lines.append("") + lines.append(String(localized: "cli.memory.output.topGroupsHeader", defaultValue: "TOP CHILD GROUPS")) + lines.append(String(localized: "cli.memory.output.topGroupsColumns", defaultValue: " RSS PROC COMMAND ATTRIBUTION")) + for group in groups { + let rss = padLeft(formatBytes(topInt64(group["rss_bytes"])), width: 9) + let processCount = padLeft(String(topInt(group["process_count"]) ?? 0), width: 5) + let name = topLabelText(group["name"] as? String) + let command = name.padding(toLength: 26, withPad: " ", startingAt: 0) + let attribution = memoryAttributionText(group["top_attribution"], idFormat: idFormat) + lines.append("\(rss) \(processCount) \(command) \(attribution)") + } + + return lines.joined(separator: "\n") + } + + private func memoryProcessCountText(_ count: Int) -> String { + if count == 1 { + return String(localized: "cli.memory.output.processCount.one", defaultValue: "1 process") + } + return String.localizedStringWithFormat( + String(localized: "cli.memory.output.processCount.other", defaultValue: "%lld processes"), + count + ) + } + + private func memoryAttributionText(_ raw: Any?, idFormat: CLIIDFormat) -> String { + guard let attribution = raw as? [String: Any] else { + return String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed") + } + + var parts: [String] = [] + if let workspace = memoryAttributionHandle(attribution, prefix: "workspace", idFormat: idFormat) { + parts.append(String.localizedStringWithFormat( + String(localized: "cli.memory.output.workspaceAttribution", defaultValue: "workspace %@"), + workspace + )) + } + if let pane = memoryAttributionHandle(attribution, prefix: "pane", idFormat: idFormat) { + parts.append(String.localizedStringWithFormat( + String(localized: "cli.memory.output.paneAttribution", defaultValue: "pane %@"), + pane + )) + } + if let surface = memoryAttributionHandle(attribution, prefix: "surface", idFormat: idFormat) { + parts.append(String.localizedStringWithFormat( + String(localized: "cli.memory.output.surfaceAttribution", defaultValue: "surface %@"), + surface + )) + } + return parts.isEmpty ? String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed") : parts.joined(separator: " / ") + } + + private func memoryAttributionHandle( + _ attribution: [String: Any], + prefix: String, + idFormat: CLIIDFormat + ) -> String? { + let ref = topLabelText(attribution["\(prefix)_ref"] as? String) + let id = topLabelText(attribution["\(prefix)_id"] as? String) + switch idFormat { + case .refs: + return ref.isEmpty ? (id.isEmpty ? nil : id) : ref + case .uuids: + return id.isEmpty ? (ref.isEmpty ? nil : ref) : id + case .both: + let values = [ref, id].filter { !$0.isEmpty } + return values.isEmpty ? nil : values.joined(separator: " ") + } + } +} diff --git a/CLI/CMUXCLI+MoveTabToNewWorkspace.swift b/CLI/CMUXCLI+MoveTabToNewWorkspace.swift index f8083ec26bbb..8a171a315ac6 100644 --- a/CLI/CMUXCLI+MoveTabToNewWorkspace.swift +++ b/CLI/CMUXCLI+MoveTabToNewWorkspace.swift @@ -69,7 +69,7 @@ extension CMUXCLI { } static let moveTabToNewWorkspaceCommandHelp = """ - Usage: cmux move-tab-to-new-workspace [--tab ] [--surface ] [--workspace ] [--title ] [--focus ] + Usage: cmux move-tab-to-new-workspace [--tab ] [--surface ] [--workspace ] [--window ] [--title ] [--focus ] Move a tab into a newly created workspace in the same window. @@ -77,6 +77,7 @@ extension CMUXCLI { --tab Target tab (accepts tab: or surface:; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab) --surface Alias for --tab --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) + --window Window context for workspace/tab refs and indexes --title Optional title for the new workspace --focus Focus the new workspace when supported (default: false) @@ -107,10 +108,14 @@ extension CMUXCLI { let windowHandle = try normalizeWindowHandle(windowRaw, client: client) let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client, windowHandle: windowHandle) - let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle, allowFocused: false) - let paneHandle = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: workspaceHandle) - let beforeHandle = try normalizeSurfaceHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle) - let afterHandle = try normalizeSurfaceHandle(afterRaw, client: client, workspaceHandle: workspaceHandle) + let surfaceHandle = try normalizeSurfaceHandle( + surfaceRaw, + client: client, + allowFocused: false + ) + let paneHandle = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: workspaceHandle, windowHandle: windowHandle) + let beforeHandle = try normalizeSurfaceHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle, windowHandle: windowHandle) + let afterHandle = try normalizeSurfaceHandle(afterRaw, client: client, workspaceHandle: workspaceHandle, windowHandle: windowHandle) var params: [String: Any] = [:] if let surfaceHandle { params["surface_id"] = surfaceHandle } @@ -149,19 +154,22 @@ extension CMUXCLI { let (panelArg, rem1) = parseOption(rem0, name: "--panel") let (workspaceArg, rem2) = parseOption(rem1, name: "--workspace") let (focusOpt, rem3) = parseOption(rem2, name: "--focus") + let (windowArg, rem4) = parseOption(rem3, name: "--window") guard let surfaceRaw = surfaceArg ?? panelArg else { throw CLIError(message: "\(commandName) requires --surface ") } - let direction = try validatedSplitDirection(rem3.first, commandName: commandName) - if let unknown = rem3.dropFirst().first(where: { $0.hasPrefix("--") }) { + let direction = try validatedSplitDirection(rem4.first, commandName: commandName) + if let unknown = rem4.dropFirst().first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "\(commandName): unknown flag '\(unknown)'") } var params: [String: Any] = ["direction": direction] - let workspaceHandle = try normalizeWorkspaceHandle(workspaceArg, client: client) + let windowHandle = try normalizeWindowHandle(windowArg, client: client) + if let windowHandle { params["window_id"] = windowHandle } + let workspaceHandle = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: windowHandle) if let workspaceHandle { params["workspace_id"] = workspaceHandle } - let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle) + let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle, windowHandle: windowHandle) if let surfaceHandle { params["surface_id"] = surfaceHandle } try applyFocusOption(focusOpt, defaultValue: false, to: ¶ms) @@ -183,17 +191,21 @@ extension CMUXCLI { } let workspaceRaw = optionValue(commandArgs, name: "--workspace") - let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) - let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle) + let windowRaw = optionValue(commandArgs, name: "--window") + let windowHandle = try normalizeWindowHandle(windowRaw, client: client) + let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client, windowHandle: windowHandle) + let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle, windowHandle: windowHandle) let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-surface") let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-surface") let focusRaw = optionValue(commandArgs, name: "--focus") - let beforeHandle = try normalizeSurfaceHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle) - let afterHandle = try normalizeSurfaceHandle(afterRaw, client: client, workspaceHandle: workspaceHandle) + let beforeHandle = try normalizeSurfaceHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle, windowHandle: windowHandle) + let afterHandle = try normalizeSurfaceHandle(afterRaw, client: client, workspaceHandle: workspaceHandle, windowHandle: windowHandle) var params: [String: Any] = [:] if let surfaceHandle { params["surface_id"] = surfaceHandle } + if let windowHandle { params["window_id"] = windowHandle } + if let workspaceHandle { params["workspace_id"] = workspaceHandle } if let beforeHandle { params["before_surface_id"] = beforeHandle } if let afterHandle { params["after_surface_id"] = afterHandle } if let indexRaw = optionValue(commandArgs, name: "--index") { diff --git a/CLI/CMUXCLI+ThemeSupport.swift b/CLI/CMUXCLI+ThemeSupport.swift index 9d0ea2404232..8af3590e5123 100644 --- a/CLI/CMUXCLI+ThemeSupport.swift +++ b/CLI/CMUXCLI+ThemeSupport.swift @@ -132,7 +132,7 @@ extension CMUXCLI { throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'cmux themes' to list available themes.") } - func themeConfigSearchURLs() -> [URL] { + func themeConfigSearchURLs(targetBundleIdentifier: String) -> [URL] { let fileManager = FileManager.default var urls = [ configURL("~/.config/ghostty/config"), @@ -166,12 +166,13 @@ extension CMUXCLI { append(legacyGhosttyConfigURL) } - let cmuxDirectory = appSupportDirectory.appendingPathComponent( - Self.cmuxThemeOverrideBundleIdentifier, - isDirectory: true - ) - append(cmuxDirectory.appendingPathComponent("config", isDirectory: false)) - append(cmuxDirectory.appendingPathComponent("config.ghostty", isDirectory: false)) + for url in CmuxGhosttyConfigPathResolver.loadConfigURLs( + currentBundleIdentifier: targetBundleIdentifier, + appSupportDirectory: appSupportDirectory, + fileManager: fileManager + ) { + append(url) + } } return urls @@ -223,18 +224,22 @@ extension CMUXCLI { return lastValue } - func cmuxThemeOverrideConfigURL() throws -> URL { + func cmuxThemeOverrideConfigURL(targetBundleIdentifier: String) throws -> URL { guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { throw CLIError(message: "Unable to resolve Application Support directory") } - return appSupport - .appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true) - .appendingPathComponent("config.ghostty", isDirectory: false) + return CmuxGhosttyConfigPathResolver.editableConfigURL( + currentBundleIdentifier: targetBundleIdentifier, + appSupportDirectory: appSupport + ) } - func writeManagedThemeOverride(rawThemeValue: String) throws -> URL { + func writeManagedThemeOverride( + rawThemeValue: String, + targetBundleIdentifier: String + ) throws -> URL { let fileManager = FileManager.default - let configURL = try cmuxThemeOverrideConfigURL() + let configURL = try cmuxThemeOverrideConfigURL(targetBundleIdentifier: targetBundleIdentifier) let directoryURL = configURL.deletingLastPathComponent() try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) @@ -252,9 +257,9 @@ extension CMUXCLI { return configURL } - func clearManagedThemeOverride() throws -> URL { + func clearManagedThemeOverride(targetBundleIdentifier: String) throws -> URL { let fileManager = FileManager.default - let configURL = try cmuxThemeOverrideConfigURL() + let configURL = try cmuxThemeOverrideConfigURL(targetBundleIdentifier: targetBundleIdentifier) guard let existingContents = try readOptionalThemeOverrideContents(at: configURL) else { return configURL } @@ -309,14 +314,72 @@ extension CMUXCLI { return regex.stringByReplacingMatches(in: contents, options: [], range: fullRange, withTemplate: "") } - func reloadThemesIfPossible() -> ThemeReloadStatus { - let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + func reloadThemesIfPossible( + socketPath: String, + targetBundleIdentifier: String, + explicitPassword _: String? + ) -> ThemeReloadStatus { DistributedNotificationCenter.default().post( name: Notification.Name(Self.cmuxThemesReloadNotificationName), object: nil, - userInfo: ["bundleIdentifier": bundleIdentifier] + userInfo: [ + "bundleIdentifier": targetBundleIdentifier, + "socketPath": socketPath, + "phase": "final", + ] ) - return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) + return ThemeReloadStatus(requested: true, targetBundleIdentifier: targetBundleIdentifier) + } + + func themeTargetBundleIdentifier(socketPath: String) -> String { + bundleIdentifierForThemeReloadSocketPath(socketPath) + ?? currentCmuxAppBundleIdentifier() + ?? Self.cmuxThemeOverrideBundleIdentifier + } + + private func bundleIdentifierForThemeReloadSocketPath(_ socketPath: String) -> String? { + let name = URL(fileURLWithPath: socketPath).lastPathComponent + switch name { + case "cmux.sock": + return Self.cmuxThemeOverrideBundleIdentifier + case "cmux-debug.sock": + return "com.cmuxterm.app.debug" + case "cmux-nightly.sock": + return "com.cmuxterm.app.nightly" + case "cmux-staging.sock": + return "com.cmuxterm.app.staging" + default: + break + } + + if name.range(of: #"^cmux-\d+\.sock$"#, options: .regularExpression) != nil { + return Self.cmuxThemeOverrideBundleIdentifier + } + + if let slug = themeReloadSocketSlug(name, prefix: "cmux-debug-", suffix: ".sock") { + return "com.cmuxterm.app.debug.\(slug)" + } + if let slug = themeReloadSocketSlug(name, prefix: "cmux-nightly-", suffix: ".sock") { + return "com.cmuxterm.app.nightly.\(slug)" + } + if let slug = themeReloadSocketSlug(name, prefix: "cmux-staging-", suffix: ".sock") { + return "com.cmuxterm.app.staging.\(slug)" + } + return nil + } + + private func themeReloadSocketSlug(_ name: String, prefix: String, suffix: String) -> String? { + guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { + return nil + } + let start = name.index(name.startIndex, offsetBy: prefix.count) + let end = name.index(name.endIndex, offsetBy: -suffix.count) + let rawSlug = String(name[start.. String? { diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index 4e94e6397da5..65e644b1a93a 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -2,7 +2,7 @@ import Foundation import Darwin extension CMUXCLI { - static let cmuxThemeOverrideBundleIdentifier = "com.cmuxterm.app" + static let cmuxThemeOverrideBundleIdentifier = CmuxGhosttyConfigPathResolver.releaseBundleIdentifier static let cmuxThemesBlockStart = "# cmux themes start" static let cmuxThemesBlockEnd = "# cmux themes end" static let cmuxThemesReloadNotificationName = "com.cmuxterm.themes.reload-config" @@ -25,20 +25,31 @@ extension CMUXCLI { case dark } + private enum InteractiveHelperResult: Equatable { + case completed + case cancelled + } + private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool { guard !jsonOutput else { return false } return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 } - private func runInteractiveThemes() throws { + private func runInteractiveThemes( + socketPath: String, + targetBundleIdentifier: String, + explicitPassword: String? + ) throws { guard let helperURL = bundledHelperURL(named: "ghostty") else { throw CLIError(message: "Bundled Ghostty theme picker helper not found") } - let selection = currentThemeSelection() + let selection = currentThemeSelection(targetBundleIdentifier: targetBundleIdentifier) var environment = ProcessInfo.processInfo.environment - environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path - environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL( + targetBundleIdentifier: targetBundleIdentifier + ).path + environment["CMUX_THEME_PICKER_BUNDLE_ID"] = targetBundleIdentifier environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light" if let light = selection.light { @@ -51,11 +62,17 @@ extension CMUXCLI { environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path } - try execInteractiveHelper( + let result = try runInteractiveHelper( executablePath: helperURL.path, arguments: ["+list-themes"], environment: environment ) + guard result == .completed else { return } + _ = reloadThemesIfPossible( + socketPath: socketPath, + targetBundleIdentifier: targetBundleIdentifier, + explicitPassword: explicitPassword + ) } private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode { @@ -111,31 +128,75 @@ extension CMUXCLI { return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) } - private func execInteractiveHelper( + private func runInteractiveHelper( executablePath: String, arguments: [String], environment: [String: String] - ) throws -> Never { - var argv = ([executablePath] + arguments).map { strdup($0) } - defer { - for item in argv { - free(item) + ) throws -> InteractiveHelperResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + process.standardInput = FileHandle.standardInput + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + let originalForegroundProcessGroup = isatty(STDIN_FILENO) == 1 ? tcgetpgrp(STDIN_FILENO) : -1 + var didForegroundChild = false + do { + try process.run() + } catch { + throw CLIError(message: "Failed to launch interactive theme picker: \(String(describing: error))") + } + if originalForegroundProcessGroup > 0 { + let childProcessGroup = getpgid(process.processIdentifier) + if childProcessGroup > 0 && childProcessGroup != originalForegroundProcessGroup { + do { + try setInteractiveThemePickerForegroundProcessGroup(childProcessGroup) + _ = Darwin.kill(-childProcessGroup, SIGCONT) + didForegroundChild = true + } catch { + process.terminate() + throw error + } } } - argv.append(nil) - - var envp = environment - .map { key, value in strdup("\(key)=\(value)") } defer { - for item in envp { - free(item) + if didForegroundChild { + try? setInteractiveThemePickerForegroundProcessGroup(originalForegroundProcessGroup) } } - envp.append(nil) - execve(executablePath, &argv, &envp) - let code = errno - throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))") + process.waitUntilExit() + if process.terminationReason == .exit, process.terminationStatus == 0 { + return .completed + } + + if isInteractiveThemePickerCancellation(process) { + return .cancelled + } else if process.terminationReason == .uncaughtSignal { + throw CLIError(message: "Interactive theme picker exited from signal \(process.terminationStatus)") + } + throw CLIError(message: "Interactive theme picker exited with status \(process.terminationStatus)") + } + + private func isInteractiveThemePickerCancellation(_ process: Process) -> Bool { + switch process.terminationReason { + case .uncaughtSignal: + return process.terminationStatus == SIGINT || process.terminationStatus == SIGTERM + case .exit: + return process.terminationStatus == 130 || process.terminationStatus == 143 + @unknown default: + return false + } + } + + private func setInteractiveThemePickerForegroundProcessGroup(_ processGroup: pid_t) throws { + let previousHandler = signal(SIGTTOU, SIG_IGN) + defer { _ = signal(SIGTTOU, previousHandler) } + guard tcsetpgrp(STDIN_FILENO, processGroup) == 0 else { + throw CLIError(message: "Interactive theme picker failed to enter foreground: \(String(cString: strerror(errno)))") + } } private func bundledGhosttyResourcesURL() -> URL? { @@ -169,18 +230,34 @@ extension CMUXCLI { return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true) } - func runThemes(commandArgs: [String], jsonOutput: Bool) throws { + func runThemes( + commandArgs: [String], + jsonOutput: Bool, + socketPath: String, + explicitPassword: String? + ) throws { + let targetBundleIdentifier = themeTargetBundleIdentifier(socketPath: socketPath) if commandArgs.isEmpty { if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) { - try runInteractiveThemes() + try runInteractiveThemes( + socketPath: socketPath, + targetBundleIdentifier: targetBundleIdentifier, + explicitPassword: explicitPassword + ) return } - try printThemesList(jsonOutput: jsonOutput) + try printThemesList( + jsonOutput: jsonOutput, + targetBundleIdentifier: targetBundleIdentifier + ) return } guard let subcommand = commandArgs.first else { - try printThemesList(jsonOutput: jsonOutput) + try printThemesList( + jsonOutput: jsonOutput, + targetBundleIdentifier: targetBundleIdentifier + ) return } @@ -189,17 +266,28 @@ extension CMUXCLI { if commandArgs.count > 1 { throw CLIError(message: "themes list does not take any positional arguments") } - try printThemesList(jsonOutput: jsonOutput) + try printThemesList( + jsonOutput: jsonOutput, + targetBundleIdentifier: targetBundleIdentifier + ) case "set": try runThemesSet( args: Array(commandArgs.dropFirst()), - jsonOutput: jsonOutput + jsonOutput: jsonOutput, + socketPath: socketPath, + targetBundleIdentifier: targetBundleIdentifier, + explicitPassword: explicitPassword ) case "clear": if commandArgs.count > 1 { throw CLIError(message: "themes clear does not take any positional arguments") } - try runThemesClear(jsonOutput: jsonOutput) + try runThemesClear( + jsonOutput: jsonOutput, + socketPath: socketPath, + targetBundleIdentifier: targetBundleIdentifier, + explicitPassword: explicitPassword + ) default: if subcommand.hasPrefix("-") { throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.") @@ -207,15 +295,23 @@ extension CMUXCLI { try runThemesSet( args: commandArgs, - jsonOutput: jsonOutput + jsonOutput: jsonOutput, + socketPath: socketPath, + targetBundleIdentifier: targetBundleIdentifier, + explicitPassword: explicitPassword ) } } - private func printThemesList(jsonOutput: Bool) throws { + private func printThemesList( + jsonOutput: Bool, + targetBundleIdentifier: String + ) throws { let themes = availableThemeNames() - let current = currentThemeSelection() - let configPath = try cmuxThemeOverrideConfigURL().path + let current = currentThemeSelection(targetBundleIdentifier: targetBundleIdentifier) + let configPath = try cmuxThemeOverrideConfigURL( + targetBundleIdentifier: targetBundleIdentifier + ).path if jsonOutput { let currentPayload: [String: Any] = [ @@ -265,7 +361,13 @@ extension CMUXCLI { } } - private func runThemesSet(args: [String], jsonOutput: Bool) throws { + private func runThemesSet( + args: [String], + jsonOutput: Bool, + socketPath: String, + targetBundleIdentifier: String, + explicitPassword: String? + ) throws { let (lightOpt, rem0) = parseOption(args, name: "--light") let (darkOpt, rem1) = parseOption(rem0, name: "--dark") @@ -274,7 +376,7 @@ extension CMUXCLI { } let availableThemes = availableThemeNames() - let current = currentThemeSelection() + let current = currentThemeSelection(targetBundleIdentifier: targetBundleIdentifier) let lightTheme: String? let darkTheme: String? @@ -299,8 +401,15 @@ extension CMUXCLI { throw CLIError(message: "themes set requires at least one theme") } - let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue) - let reloadStatus = reloadThemesIfPossible() + let configURL = try writeManagedThemeOverride( + rawThemeValue: rawThemeValue, + targetBundleIdentifier: targetBundleIdentifier + ) + let reloadStatus = reloadThemesIfPossible( + socketPath: socketPath, + targetBundleIdentifier: targetBundleIdentifier, + explicitPassword: explicitPassword + ) if jsonOutput { let payload: [String: Any] = [ @@ -321,9 +430,18 @@ extension CMUXCLI { ) } - private func runThemesClear(jsonOutput: Bool) throws { - let configURL = try clearManagedThemeOverride() - let reloadStatus = reloadThemesIfPossible() + private func runThemesClear( + jsonOutput: Bool, + socketPath: String, + targetBundleIdentifier: String, + explicitPassword: String? + ) throws { + let configURL = try clearManagedThemeOverride(targetBundleIdentifier: targetBundleIdentifier) + let reloadStatus = reloadThemesIfPossible( + socketPath: socketPath, + targetBundleIdentifier: targetBundleIdentifier, + explicitPassword: explicitPassword + ) if jsonOutput { let payload: [String: Any] = [ @@ -340,11 +458,11 @@ extension CMUXCLI { print("OK cleared config=\(configURL.path) reload=requested") } - private func currentThemeSelection() -> ThemeSelection { + private func currentThemeSelection(targetBundleIdentifier: String) -> ThemeSelection { var rawValue: String? var sourcePath: String? - for url in themeConfigSearchURLs() { + for url in themeConfigSearchURLs(targetBundleIdentifier: targetBundleIdentifier) { guard let contents = try? String(contentsOf: url, encoding: .utf8), let nextValue = lastThemeDirective(in: contents) else { continue