diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift index 76c5bcdc4688..8af2247f4cb8 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift @@ -20,6 +20,12 @@ public struct TerminalCatalogSection: SettingCatalogSection { userDefaultsKey: "terminal.autoResumeAgentSessions" ) + public let regexHighlights = DefaultsKey( + id: "terminal.regexHighlights", + defaultValue: "", + userDefaultsKey: "terminal.regexHighlights" + ) + public let agentHibernationEnabled = DefaultsKey( id: "terminal.agentHibernation.enabled", defaultValue: false, diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index f6b7062a1080..7bbdd8c26af9 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -65,6 +65,7 @@ extension Array where Element == CuratedSettingEntry { .init(section: .terminal, id: "scrollbar", title: "Show Terminal Scroll Bar", synonyms: "terminal.showScrollBar scrollback scrollbar scroll bar right edge alternate screen tui"), .init(section: .terminal, id: "copy-on-select", title: "Copy on Selection", synonyms: "terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"), .init(section: .terminal, id: "agent-auto-resume", title: "Resume Agent Sessions on Reopen", synonyms: "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), + .init(section: .terminal, id: "regex-highlights", title: "Automatic Text Highlights", synonyms: "terminal.regexHighlights regex regular expression highlight alert error warning terminal text"), .init(section: .terminal, id: "agent-hibernation", title: "Agent Hibernation", synonyms: "terminal.agentHibernation.enabled idle hibernate suspend background agents claude code codex opencode live terminals"), .init(section: .terminal, id: "agent-hibernation-idle", title: "Hibernate After Idle Seconds", synonyms: "terminal.agentHibernation.idleSeconds idle seconds timeout delay hibernate suspend"), .init(section: .terminal, id: "agent-hibernation-max", title: "Max Live Agent Terminals", synonyms: "terminal.agentHibernation.maxLiveTerminals max live agent terminals limit count hibernate"), diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index c9615a9ea8f7..230c8499bafa 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -1,3 +1,4 @@ +import AppKit import CmuxSettings import SwiftUI @@ -17,6 +18,7 @@ public struct TerminalSection: View { @State private var scrollBar: DefaultsValueModel @State private var copyOnSelect: DefaultsValueModel @State private var autoResume: DefaultsValueModel + @State private var regexHighlights: DefaultsValueModel @State private var hibernation: DefaultsValueModel @State private var idleSeconds: DefaultsValueModel @State private var maxLive: DefaultsValueModel @@ -34,6 +36,7 @@ public struct TerminalSection: View { _scrollBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showScrollBar)) _copyOnSelect = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.copyOnSelect)) _autoResume = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.autoResumeAgentSessions)) + _regexHighlights = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.regexHighlights)) _hibernation = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationEnabled)) _idleSeconds = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationIdleSeconds)) _maxLive = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationMaxLiveTerminals)) @@ -168,6 +171,8 @@ public struct TerminalSection: View { .accessibilityIdentifier("SettingsTerminalAgentAutoResumeToggle") } SettingsCardDivider() + regexHighlightsRow + SettingsCardDivider() SettingsCardRow( configurationReview: .json("terminal.agentHibernation.enabled"), String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), @@ -213,4 +218,45 @@ public struct TerminalSection: View { } } + private var regexHighlightsBinding: Binding { + Binding( + get: { regexHighlights.current }, + set: { regexHighlights.set($0) } + ) + } + + @ViewBuilder + private var regexHighlightsRow: some View { + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + configurationReview: .json("terminal.regexHighlights"), + String(localized: "settings.terminal.regexHighlights", defaultValue: "Automatic Text Highlights"), + subtitle: String( + localized: "settings.terminal.regexHighlights.subtitle", + defaultValue: "Highlights visible terminal text that matches a regex. One pattern per line; prefix with #RRGGBB or #RRGGBBAA and a tab to choose the highlight color." + ) + ) { + EmptyView() + } + + TextEditor(text: regexHighlightsBinding) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 14) + .padding(.bottom, 10) + .accessibilityIdentifier("SettingsTerminalRegexHighlightsEditor") + .accessibilityLabel( + String(localized: "settings.terminal.regexHighlights", defaultValue: "Automatic Text Highlights") + ) + } + } + } diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 1452d17526b4..c9ba5f9edd3e 100644 --- a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -105,6 +105,7 @@ struct SettingsRowAnchorResolutionTests { "terminal.agentHibernation.maxLiveTerminals", "terminal.autoResumeAgentSessions", "terminal.copyOnSelect", + "terminal.regexHighlights", "terminal.resumeCommands", "terminal.focusTextBoxOnNewTerminals", "terminal.showScrollBar", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 42bf09241ab8..9c9c3fd90f64 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -148971,6 +148971,8 @@ } } }, + "settings.terminal.regexHighlights": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"تمييز النص التلقائي"}},"bs":{"stringUnit":{"state":"translated","value":"Automatsko isticanje teksta"}},"da":{"stringUnit":{"state":"translated","value":"Automatiske tekstmarkeringer"}},"de":{"stringUnit":{"state":"translated","value":"Automatische Texthervorhebungen"}},"en":{"stringUnit":{"state":"translated","value":"Automatic Text Highlights"}},"es":{"stringUnit":{"state":"translated","value":"Resaltados automáticos de texto"}},"fr":{"stringUnit":{"state":"translated","value":"Surlignages automatiques du texte"}},"it":{"stringUnit":{"state":"translated","value":"Evidenziazioni automatiche del testo"}},"ja":{"stringUnit":{"state":"translated","value":"自動テキストハイライト"}},"km":{"stringUnit":{"state":"translated","value":"ការបន្លិចអត្ថបទដោយស្វ័យប្រវត្តិ"}},"ko":{"stringUnit":{"state":"translated","value":"자동 텍스트 강조"}},"nb":{"stringUnit":{"state":"translated","value":"Automatiske tekstuthevinger"}},"pl":{"stringUnit":{"state":"translated","value":"Automatyczne wyróżnienia tekstu"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Destaques automáticos de texto"}},"ru":{"stringUnit":{"state":"translated","value":"Автоматическая подсветка текста"}},"th":{"stringUnit":{"state":"translated","value":"การไฮไลต์ข้อความอัตโนมัติ"}},"tr":{"stringUnit":{"state":"translated","value":"Otomatik Metin Vurgulamaları"}},"uk":{"stringUnit":{"state":"translated","value":"Автоматичне підсвічування тексту"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"自动文本高亮"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"自動文字醒目提示"}}}}, + "settings.terminal.regexHighlights.subtitle": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"يميّز نص الطرفية المرئي الذي يطابق تعبيرًا منتظمًا. نمط واحد في كل سطر؛ ابدأ السطر بـ #RRGGBB أو #RRGGBBAA ثم Tab لاختيار لون التمييز."}},"bs":{"stringUnit":{"state":"translated","value":"Ističe vidljivi tekst terminala koji odgovara regularnom izrazu. Jedan uzorak po redu; dodajte #RRGGBB ili #RRGGBBAA i tabulator na početak da odaberete boju isticanja."}},"da":{"stringUnit":{"state":"translated","value":"Markerer synlig terminaltekst, der matcher et regulært udtryk. Ét mønster pr. linje; sæt #RRGGBB eller #RRGGBBAA og en tabulator foran for at vælge markeringsfarven."}},"de":{"stringUnit":{"state":"translated","value":"Hebt sichtbaren Terminaltext hervor, der einem regulären Ausdruck entspricht. Ein Muster pro Zeile; stellen Sie #RRGGBB oder #RRGGBBAA und einen Tabulator voran, um die Hervorhebungsfarbe zu wählen."}},"en":{"stringUnit":{"state":"translated","value":"Highlights visible terminal text that matches a regex. One pattern per line; prefix with #RRGGBB or #RRGGBBAA and a tab to choose the highlight color."}},"es":{"stringUnit":{"state":"translated","value":"Resalta el texto visible de la terminal que coincide con una expresión regular. Un patrón por línea; anteponga #RRGGBB o #RRGGBBAA y una tabulación para elegir el color de resaltado."}},"fr":{"stringUnit":{"state":"translated","value":"Surligne le texte visible du terminal qui correspond à une expression régulière. Un motif par ligne; préfixez avec #RRGGBB ou #RRGGBBAA et une tabulation pour choisir la couleur de surlignage."}},"it":{"stringUnit":{"state":"translated","value":"Evidenzia il testo visibile del terminale che corrisponde a un'espressione regolare. Un pattern per riga; anteponi #RRGGBB o #RRGGBBAA e una tabulazione per scegliere il colore dell'evidenziazione."}},"ja":{"stringUnit":{"state":"translated","value":"正規表現に一致する表示中のターミナルテキストをハイライトします。1 行に 1 パターンを入力し、#RRGGBB または #RRGGBBAA とタブを先頭に付けると色を指定できます。"}},"km":{"stringUnit":{"state":"translated","value":"បន្លិចអត្ថបទស្ថានីយដែលមើលឃើញដែលត្រូវនឹង regex។ មួយលំនាំក្នុងមួយបន្ទាត់; ដាក់ #RRGGBB ឬ #RRGGBBAA និង Tab នៅខាងមុខ ដើម្បីជ្រើសពណ៌បន្លិច។"}},"ko":{"stringUnit":{"state":"translated","value":"정규식과 일치하는 보이는 터미널 텍스트를 강조합니다. 한 줄에 패턴 하나를 입력하고, 강조 색상을 선택하려면 앞에 #RRGGBB 또는 #RRGGBBAA와 탭을 붙이세요."}},"nb":{"stringUnit":{"state":"translated","value":"Uthever synlig terminaltekst som samsvarer med et regulært uttrykk. Ett mønster per linje; bruk #RRGGBB eller #RRGGBBAA og en tabulator foran for å velge uthevingsfarge."}},"pl":{"stringUnit":{"state":"translated","value":"Wyróżnia widoczny tekst terminala pasujący do wyrażenia regularnego. Jeden wzorzec na wiersz; poprzedź go #RRGGBB lub #RRGGBBAA oraz tabulatorem, aby wybrać kolor wyróżnienia."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Destaca o texto visível do terminal que corresponde a uma regex. Um padrão por linha; prefixe com #RRGGBB ou #RRGGBBAA e uma tabulação para escolher a cor do destaque."}},"ru":{"stringUnit":{"state":"translated","value":"Подсвечивает видимый текст терминала, совпадающий с регулярным выражением. Один шаблон на строку; добавьте в начало #RRGGBB или #RRGGBBAA и табуляцию, чтобы выбрать цвет подсветки."}},"th":{"stringUnit":{"state":"translated","value":"ไฮไลต์ข้อความเทอร์มินัลที่มองเห็นซึ่งตรงกับ regex หนึ่งรูปแบบต่อบรรทัด; ใส่ #RRGGBB หรือ #RRGGBBAA และแท็บนำหน้าเพื่อเลือกสีไฮไลต์"}},"tr":{"stringUnit":{"state":"translated","value":"Regex ile eşleşen görünür terminal metnini vurgular. Satır başına bir desen kullanın; vurgu rengini seçmek için başına #RRGGBB veya #RRGGBBAA ve bir sekme ekleyin."}},"uk":{"stringUnit":{"state":"translated","value":"Підсвічує видимий текст термінала, що відповідає регулярному виразу. Один шаблон на рядок; додайте на початок #RRGGBB або #RRGGBBAA і табуляцію, щоб вибрати колір підсвічування."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"高亮显示与正则表达式匹配的可见终端文本。每行一个模式;在前面加上 #RRGGBB 或 #RRGGBBAA 和一个制表符可选择高亮颜色。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"醒目提示符合正規表示式的可見終端機文字。每行一個模式;在前面加上 #RRGGBB 或 #RRGGBBAA 和一個 Tab,可選擇醒目提示顏色。"}}}}, "settings.terminal.resumeCommands": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index 830d30fcbbe2..4e5b71945071 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -252,6 +252,312 @@ enum AgentSessionAutoResumeSettings { } } +struct TerminalRegexHighlightRule: Equatable { + static let defaultBackgroundHex = "#FFE06680" + + let pattern: String + let backgroundHex: String +} + +struct TerminalRegexHighlightRun: Equatable { + let row: Int + let column: Int + let length: Int + let backgroundHex: String +} + +struct TerminalRegexHighlightCompiledRule { + let pattern: String + let backgroundHex: String + let expression: NSRegularExpression + + init?(rule: TerminalRegexHighlightRule) { + guard let expression = try? NSRegularExpression(pattern: rule.pattern) else { + return nil + } + self.pattern = rule.pattern + self.backgroundHex = rule.backgroundHex + self.expression = expression + } +} + +enum TerminalRegexHighlightSettings { + static let highlightsKey = "terminal.regexHighlights" + static let defaultHighlights = "" + static let didChangeNotification = Notification.Name("cmux.terminalRegexHighlightSettingsDidChange") + + private static let runtimeStateLock = NSLock() + private static var runtimeRawHighlights = rawHighlights() + private static var runtimeHasRules = hasCompiledRules() + private static var userDefaultsObserver: NSObjectProtocol? + + static func hasRuntimeRules() -> Bool { + startObservingUserDefaultsChanges() + runtimeStateLock.lock() + defer { runtimeStateLock.unlock() } + return runtimeHasRules + } + + static func startObservingUserDefaultsChanges(notificationCenter: NotificationCenter = .default) { + runtimeStateLock.lock() + if userDefaultsObserver != nil { + runtimeStateLock.unlock() + return + } + runtimeStateLock.unlock() + + let observer = notificationCenter.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: nil + ) { _ in + synchronizeRuntimeStateIfNeeded() + } + + runtimeStateLock.lock() + if userDefaultsObserver == nil { + userDefaultsObserver = observer + runtimeStateLock.unlock() + } else { + runtimeStateLock.unlock() + notificationCenter.removeObserver(observer) + } + } + + static func rawHighlights(defaults: UserDefaults = .standard) -> String { + defaults.string(forKey: highlightsKey) ?? defaultHighlights + } + + static func rules(defaults: UserDefaults = .standard) -> [TerminalRegexHighlightRule] { + rules(from: rawHighlights(defaults: defaults)) + } + + static func rules(from rawValue: String) -> [TerminalRegexHighlightRule] { + rawValue + .split(whereSeparator: \.isNewline) + .compactMap { line in + let text = line.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty else { return nil } + if let tabIndex = text.firstIndex(of: "\t") { + let color = String(text[.. Bool { + let previous = rawHighlights(defaults: defaults) + defaults.removeObject(forKey: highlightsKey) + let didChange = previous != rawHighlights(defaults: defaults) + if didChange { + notifyDidChange(defaults: defaults, notificationCenter: notificationCenter) + } + return didChange + } + + static func notifyDidChange( + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) { + updateRuntimeRuleState(rawValue: rawHighlights(defaults: defaults)) + notificationCenter.post(name: didChangeNotification, object: nil) + } + + @discardableResult + static func synchronizeRuntimeStateIfNeeded( + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) -> Bool { + let rawValue = rawHighlights(defaults: defaults) + runtimeStateLock.lock() + let didChange = rawValue != runtimeRawHighlights + runtimeStateLock.unlock() + + guard didChange else { return false } + updateRuntimeRuleState(rawValue: rawValue) + notificationCenter.post(name: didChangeNotification, object: nil) + return true + } + + private static func updateRuntimeRuleState(rawValue: String) { + let hasRules = hasCompiledRules(from: rawValue) + runtimeStateLock.lock() + runtimeRawHighlights = rawValue + runtimeHasRules = hasRules + runtimeStateLock.unlock() + } + + private static func hasCompiledRules(defaults: UserDefaults = .standard) -> Bool { + hasCompiledRules(from: rawHighlights(defaults: defaults)) + } + + private static func hasCompiledRules(from rawValue: String) -> Bool { + !TerminalRegexHighlightMatcher.compiledRules(from: rules(from: rawValue)).isEmpty + } + + private static func isSupportedHexColor(_ rawValue: String) -> Bool { + let normalized = normalizedHexColor(rawValue) + guard normalized.count == 7 || normalized.count == 9 else { return false } + return normalized.dropFirst().allSatisfy(\.isHexDigit) + } + + private static func normalizedHexColor(_ rawValue: String) -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let withPrefix = trimmed.hasPrefix("#") ? trimmed : "#\(trimmed)" + return withPrefix.uppercased() + } +} + +enum TerminalRegexHighlightMatcher { + static let maxRuns = 512 + + static func compiledRules( + from rules: [TerminalRegexHighlightRule] + ) -> [TerminalRegexHighlightCompiledRule] { + rules.compactMap(TerminalRegexHighlightCompiledRule.init(rule:)) + } + + static func runs( + in lines: [String], + compiledRules: [TerminalRegexHighlightCompiledRule], + rowOffset: Int = 0, + maxColumnCount: Int? = nil + ) -> [TerminalRegexHighlightRun] { + guard !compiledRules.isEmpty else { return [] } + + var runs: [TerminalRegexHighlightRun] = [] + runs.reserveCapacity(min(32, maxRuns)) + + for (lineIndex, line) in lines.enumerated() { + guard !line.isEmpty else { continue } + let searchRange = NSRange(line.startIndex.. 0, + let range = Range(match.range, in: line) else { + return + } + let column = terminalColumnWidth(line[.. 0 else { return } + + let clippedLength: Int + if let maxColumnCount { + guard column < maxColumnCount else { return } + clippedLength = min(length, maxColumnCount - column) + } else { + clippedLength = length + } + guard clippedLength > 0 else { return } + + runs.append(TerminalRegexHighlightRun( + row: rowOffset + lineIndex, + column: column, + length: clippedLength, + backgroundHex: compiledRule.backgroundHex + )) + if runs.count >= maxRuns { + reachedMaxRuns = true + stop.pointee = true + } + } + if reachedMaxRuns { + return runs + } + } + } + + return runs + } + + private static func terminalColumnWidth(_ text: Substring) -> Int { + text.reduce(0) { total, character in + total + terminalColumnWidth(for: character) + } + } + + private static func terminalColumnWidth(for character: Character) -> Int { + var hasNonZeroWidthScalar = false + var hasWideScalar = false + for scalar in character.unicodeScalars { + let scalarWidth = terminalColumnWidth(for: scalar) + guard scalarWidth > 0 else { continue } + hasNonZeroWidthScalar = true + if isWideTerminalScalar(scalar.value) { + hasWideScalar = true + } + } + guard hasNonZeroWidthScalar else { return 0 } + return hasWideScalar ? 2 : 1 + } + + private static func terminalColumnWidth(for scalar: Unicode.Scalar) -> Int { + let value = scalar.value + if value == 0 || + value == 0x200D || + (0xFE00...0xFE0F).contains(value) { + return 0 + } + + switch scalar.properties.generalCategory { + case .control, .enclosingMark, .format, .nonspacingMark: + return 0 + default: + break + } + + return isWideTerminalScalar(value) ? 2 : 1 + } + + private static func isWideTerminalScalar(_ value: UInt32) -> Bool { + switch value { + case 0x1100...0x115F, + 0x2329...0x232A, + 0x2E80...0xA4CF, + 0xAC00...0xD7A3, + 0xF900...0xFAFF, + 0xFE10...0xFE19, + 0xFE30...0xFE6F, + 0xFF00...0xFF60, + 0xFFE0...0xFFE6, + 0x1F1E6...0x1F1FF, + 0x1F300...0x1FAFF, + 0x20000...0x3FFFD: + return true + default: + return false + } + } +} + enum AgentHibernationSettings { struct Values: Equatable, Sendable { var enabled: Bool diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index b073897914b2..c1b16dbc9fc0 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -91,6 +91,7 @@ extension CmuxSettingsFileStore { "terminal.showScrollBar", "terminal.copyOnSelect", "terminal.autoResumeAgentSessions", + "terminal.regexHighlights", "terminal.showTextBoxOnNewTerminals", "terminal.focusTextBoxOnNewTerminals", "terminal.agentHibernation.enabled", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8e02f7d4b687..64c644a463cb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4574,12 +4574,13 @@ class GhosttyApp { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.toggleSplitZoom(tabId: tabId, surfaceId: surfaceId) } - case GHOSTTY_ACTION_RENDER: - return false case GHOSTTY_ACTION_SCROLLBAR: let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) surfaceView.enqueueScrollbarUpdate(scrollbar) return true + case GHOSTTY_ACTION_RENDER: + surfaceView.enqueueRegexHighlightRefreshAfterRender() + return false case GHOSTTY_ACTION_CELL_SIZE: let cellSize = CGSize( width: CGFloat(action.action.cell_size.width), @@ -8177,6 +8178,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var _pendingScrollbar: GhosttyScrollbar? private var _scrollbarFlushScheduled = false private let _scrollbarLock = NSLock() + private var _regexHighlightRenderRefreshScheduled = false + private let _regexHighlightRenderRefreshLock = NSLock() private var _renderedFrameFlushScheduled = false private let _renderedFrameLock = NSLock() var cellSize: CGSize = .zero @@ -8223,6 +8226,28 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } + func enqueueRegexHighlightRefreshAfterRender() { + guard TerminalRegexHighlightSettings.hasRuntimeRules() else { return } + + _regexHighlightRenderRefreshLock.lock() + let needsSchedule = !_regexHighlightRenderRefreshScheduled + if needsSchedule { _regexHighlightRenderRefreshScheduled = true } + _regexHighlightRenderRefreshLock.unlock() + + guard needsSchedule else { return } + DispatchQueue.main.async { [weak self] in + self?.flushRegexHighlightRenderRefresh() + } + } + + private func flushRegexHighlightRenderRefresh() { + _regexHighlightRenderRefreshLock.lock() + _regexHighlightRenderRefreshScheduled = false + _regexHighlightRenderRefreshLock.unlock() + + terminalSurface?.hostedView.scheduleRegexHighlightRefreshAfterRender() + } + private func flushPendingScrollbarIfAvailable() -> Bool { _scrollbarLock.lock() let hasPending = _pendingScrollbar != nil @@ -12260,6 +12285,7 @@ final class GhosttySurfaceScrollView: NSView { private let paneDropTargetView = TerminalPaneDropTargetView(frame: .zero) private let notificationRingOverlayView: GhosttyFlashOverlayView private let notificationRingLayer: CAShapeLayer + private let regexHighlightOverlayView: TerminalRegexHighlightOverlayView private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer var isRightSidebarDockSurface: Bool { @@ -12288,6 +12314,8 @@ final class GhosttySurfaceScrollView: NSView { private let imageTransferCancelButton: NSButton private var searchOverlayHostingView: NSHostingView? private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem? + private var regexHighlightRefreshTask: Task? + private var regexHighlightCompiledRules: [TerminalRegexHighlightCompiledRule] = [] private var imageTransferIndicatorShowWorkItem: DispatchWorkItem? private var activeImageTransferOperation: TerminalImageTransferOperation? private var activeImageTransferCancelHandler: (() -> Void)? @@ -12508,6 +12536,7 @@ final class GhosttySurfaceScrollView: NSView { dropZoneOverlayView = GhosttyFlashOverlayView(frame: .zero) notificationRingOverlayView = GhosttyFlashOverlayView(frame: .zero) notificationRingLayer = CAShapeLayer() + regexHighlightOverlayView = TerminalRegexHighlightOverlayView(frame: .zero) flashOverlayView = GhosttyFlashOverlayView(frame: .zero) flashLayer = CAShapeLayer() keyboardCopyModeBadgeContainerView = GhosttyFlashOverlayView(frame: .zero) @@ -12543,6 +12572,9 @@ final class GhosttySurfaceScrollView: NSView { backgroundView.layer?.isOpaque = false addSubview(backgroundView) addSubview(scrollView) + regexHighlightOverlayView.wantsLayer = false + regexHighlightOverlayView.autoresizingMask = [.width, .height] + regexHighlightOverlayView.isHidden = true mobileViewportBorderOverlayView.isHidden = true addSubview(mobileViewportBorderOverlayView, positioned: .above, relativeTo: scrollView) paneDropTargetView.hostedView = self @@ -12808,6 +12840,7 @@ final class GhosttySurfaceScrollView: NSView { queue: .main ) { [weak self] _ in self?.synchronizeScrollView() + self?.scheduleRegexHighlightRefresh() }) observers.append(NotificationCenter.default.addObserver( @@ -12828,6 +12861,15 @@ final class GhosttySurfaceScrollView: NSView { self?.handleTerminalScrollBarPreferenceChange() }) + observers.append(NotificationCenter.default.addObserver( + forName: TerminalRegexHighlightSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.reloadRegexHighlightRules() + }) + reloadRegexHighlightRules() + } required init?(coder: NSCoder) { @@ -12845,6 +12887,7 @@ final class GhosttySurfaceScrollView: NSView { observers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.forEach { NotificationCenter.default.removeObserver($0) } deferredSearchOverlayMutationWorkItem?.cancel() + regexHighlightRefreshTask?.cancel() imageTransferIndicatorShowWorkItem?.cancel() dropZoneOverlayView.removeFromSuperview() cancelFocusRequest() @@ -12891,10 +12934,14 @@ final class GhosttySurfaceScrollView: NSView { synchronizeGeometryAndContent() _ = setFrameIfNeeded(paneDropTargetView, to: bounds) bringPaneDropTargetToFrontIfNeeded() + bringRegexHighlightOverlayToFrontIfNeeded() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() + attachRegexHighlightOverlayIfNeeded() + _ = setFrameIfNeeded(regexHighlightOverlayView, to: regexHighlightOverlayFrame()) + bringRegexHighlightOverlayToFrontIfNeeded() guard activeDropZone != nil || pendingDropZone != nil else { return } attachDropZoneOverlayIfNeeded() if let zone = activeDropZone ?? pendingDropZone { @@ -12977,11 +13024,14 @@ final class GhosttySurfaceScrollView: NSView { setDropZoneOverlay(zone: pending) } _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) + attachRegexHighlightOverlayIfNeeded() + _ = setFrameIfNeeded(regexHighlightOverlayView, to: regexHighlightOverlayFrame()) _ = setFrameIfNeeded(flashOverlayView, to: bounds) if let overlay = searchOverlayHostingView { _ = setFrameIfNeeded(overlay, to: bounds) } bringPaneDropTargetToFrontIfNeeded() + bringRegexHighlightOverlayToFrontIfNeeded() // NSScrollView can defer clip-view/content-size updates until its own layout pass, // which makes interactive width changes arrive a queue turn late on Sequoia. if didScrollbarAppearanceChange { @@ -12994,7 +13044,11 @@ final class GhosttySurfaceScrollView: NSView { synchronizeScrollView() synchronizeSurfaceView() let didCoreSurfaceChange = synchronizeCoreSurface() - return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange + let didGeometryChange = !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange + if didGeometryChange { + scheduleRegexHighlightRefresh() + } + return didGeometryChange } func setMobileViewportBorder(size: CGSize?, drawRight: Bool, drawBottom: Bool) { @@ -13031,6 +13085,72 @@ final class GhosttySurfaceScrollView: NSView { } } + private func attachRegexHighlightOverlayIfNeeded() { + let container = regexHighlightOverlayContainerView() + guard regexHighlightOverlayView.superview !== container else { return } + regexHighlightOverlayView.removeFromSuperview() + if container === self { + addSubview(regexHighlightOverlayView, positioned: .above, relativeTo: scrollView) + } else { + container.addSubview( + regexHighlightOverlayView, + positioned: .above, + relativeTo: regexHighlightOverlayReferenceView(in: container) + ) + } + } + + private func regexHighlightOverlayContainerView() -> NSView { + superview?.superview ?? superview ?? self + } + + private func regexHighlightOverlayReferenceView(in container: NSView) -> NSView? { + if superview?.superview === container { + return superview + } + if superview === container { + return self + } + return nil + } + + private func regexHighlightOverlayFrame() -> CGRect { + let container = regexHighlightOverlayContainerView() + guard container !== self else { return bounds } + return convert(bounds, to: container) + } + + private func bringRegexHighlightOverlayToFrontIfNeeded() { + let container = regexHighlightOverlayContainerView() + guard regexHighlightOverlayView.superview === container else { + attachRegexHighlightOverlayIfNeeded() + return + } + + guard container !== self else { + if let searchOverlayHostingView, + searchOverlayHostingView.superview === self { + guard let regexIndex = subviews.firstIndex(of: regexHighlightOverlayView), + let searchIndex = subviews.firstIndex(of: searchOverlayHostingView), + regexIndex + 1 == searchIndex else { + addSubview(regexHighlightOverlayView, positioned: .below, relativeTo: searchOverlayHostingView) + return + } + return + } + + guard subviews.last !== regexHighlightOverlayView else { return } + addSubview(regexHighlightOverlayView, positioned: .above, relativeTo: nil) + return + } + + guard let reference = regexHighlightOverlayReferenceView(in: container), + let referenceIndex = container.subviews.firstIndex(of: reference), + let overlayIndex = container.subviews.firstIndex(of: regexHighlightOverlayView), + overlayIndex <= referenceIndex else { return } + container.addSubview(regexHighlightOverlayView, positioned: .above, relativeTo: reference) + } + private func attachDropZoneOverlayIfNeeded() { // Keep the hover indicator outside the hosted terminal subtree so it stays purely additive // and cannot invalidate the scroll/surface layout that Ghostty renders into. @@ -13138,7 +13258,10 @@ final class GhosttySurfaceScrollView: NSView { super.viewDidMoveToWindow() windowObservers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.removeAll() - guard let window else { return } + guard let window else { + regexHighlightOverlayView.clear() + return + } windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: window, @@ -13174,6 +13297,7 @@ final class GhosttySurfaceScrollView: NSView { if window.isKeyWindow { scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow") } + scheduleRegexHighlightRefresh() } func attachSurface(_ terminalSurface: TerminalSurface) { @@ -13182,6 +13306,7 @@ final class GhosttySurfaceScrollView: NSView { // has produced a real host size instead of a transient 1x1 placeholder. guard bounds.width > 1, bounds.height > 1 else { return } _ = synchronizeGeometryAndContent() + scheduleRegexHighlightRefresh() } func setFocusHandler(_ handler: (() -> Void)?) { @@ -13895,6 +14020,7 @@ final class GhosttySurfaceScrollView: NSView { ) } if !visible { + regexHighlightOverlayView.clear() // If we were focused, yield first responder. if let window = uiWindow, let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { @@ -13905,6 +14031,7 @@ final class GhosttySurfaceScrollView: NSView { // without a portal frame delta or a focus handoff. Reuse the portal refresh // path so the Metal layer is nudged immediately on plain visibility restores. refreshSurfaceNow(reason: "setVisibleInUI") + scheduleRegexHighlightRefresh() scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") } } @@ -15192,6 +15319,126 @@ final class GhosttySurfaceScrollView: NSView { return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } + private func reloadRegexHighlightRules() { + TerminalRegexHighlightSettings.startObservingUserDefaultsChanges() + regexHighlightCompiledRules = TerminalRegexHighlightMatcher.compiledRules( + from: TerminalRegexHighlightSettings.rules() + ) + if regexHighlightCompiledRules.isEmpty { + regexHighlightRefreshTask?.cancel() + regexHighlightRefreshTask = nil + regexHighlightOverlayView.clear() + return + } + scheduleRegexHighlightRefresh() + } + + fileprivate func scheduleRegexHighlightRefreshAfterRender() { + scheduleRegexHighlightRefresh() + } + + private func scheduleRegexHighlightRefresh() { + guard !regexHighlightCompiledRules.isEmpty else { + regexHighlightRefreshTask?.cancel() + regexHighlightRefreshTask = nil + return + } + + guard regexHighlightRefreshTask == nil else { return } + regexHighlightRefreshTask = Task { @MainActor [weak self] in + guard let self, !Task.isCancelled else { return } + self.regexHighlightRefreshTask = nil + self.refreshRegexHighlightOverlay() + } + } + + private func refreshRegexHighlightOverlay() { + guard !regexHighlightCompiledRules.isEmpty else { + regexHighlightOverlayView.clear() + return + } + guard window != nil, + !isHidden, + surfaceView.isVisibleInUI, + bounds.width > 1, + bounds.height > 1, + let terminalSurface = surfaceView.terminalSurface, + let surface = terminalSurface.liveSurfaceForGhosttyAccess(reason: "regexHighlight.refresh") else { + regexHighlightOverlayView.clear() + return + } + + let size = ghostty_surface_size(surface) + let rows = max(Int(size.rows), 1) + let columns = max(Int(size.columns), 1) + let resolvedCellSize: CGSize + if surfaceView.cellSize.width > 0, surfaceView.cellSize.height > 0 { + resolvedCellSize = surfaceView.cellSize + } else { + let pixelCellSize = CGSize(width: CGFloat(size.cell_width_px), height: CGFloat(size.cell_height_px)) + resolvedCellSize = surfaceView.convertFromBacking(NSRect(origin: .zero, size: pixelCellSize)).size + } + guard resolvedCellSize.width > 0, resolvedCellSize.height > 0 else { + regexHighlightOverlayView.clear() + return + } + guard let visibleText = readViewportText(surface: surface) else { + regexHighlightOverlayView.clear() + return + } + + let visibleLines = cmuxVisibleTerminalLines(from: visibleText, rows: rows) + let runs = TerminalRegexHighlightMatcher.runs( + in: visibleLines, + compiledRules: regexHighlightCompiledRules, + rowOffset: 0, + maxColumnCount: columns + ) + let overlayBounds = surfaceView.bounds + let metrics = TerminalRegexHighlightOverlayMetrics( + cellSize: resolvedCellSize, + rowCount: rows, + columnCount: columns, + xInset: max(0, (overlayBounds.width - (CGFloat(columns) * resolvedCellSize.width)) / 2), + yInset: max(0, (overlayBounds.height - (CGFloat(rows) * resolvedCellSize.height)) / 2) + ) + regexHighlightOverlayView.configure(runs: runs, metrics: metrics) + } + + private func readViewportText(surface: ghostty_surface_t) -> String? { + let topLeft = ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ) + let bottomRight = ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ) + let selection = ghostty_selection_s( + top_left: topLeft, + bottom_right: bottomRight, + rectangle: false + ) + + var text = ghostty_text_s() + guard ghostty_surface_read_text(surface, selection, &text) else { + return nil + } + defer { + ghostty_surface_free_text(surface, &text) + } + + guard let ptr = text.text, text.text_len > 0 else { + return "" + } + let rawData = Data(bytes: ptr, count: Int(text.text_len)) + return String(decoding: rawData, as: UTF8.self) + } + private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, @@ -15290,6 +15537,7 @@ final class GhosttySurfaceScrollView: NSView { private func handleScrollChange() { synchronizeSurfaceView() + scheduleRegexHighlightRefresh() } private func handleLiveScroll() { @@ -15328,9 +15576,11 @@ final class GhosttySurfaceScrollView: NSView { let isVisible = shouldShowTerminalScrollBar() if wasVisible != isVisible { _ = synchronizeGeometryAndContent() + scheduleRegexHighlightRefresh() return } synchronizeScrollView() + scheduleRegexHighlightRefresh() } @discardableResult diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index e160da56ce0c..3881d1f0c797 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -91,6 +91,10 @@ extension CmuxSettingsFileStore { "showScrollBar": TerminalScrollBarSettings.defaultShowScrollBar, "copyOnSelect": TerminalCopyOnSelectSettings.defaultCopyOnSelect, "autoResumeAgentSessions": AgentSessionAutoResumeSettings.defaultAutoResumeAgentSessions, + "regexHighlights": [ + "#FFE06680\tERROR", + "#FF6B6B80\tpanic|fatal", + ], "showTextBoxOnNewTerminals": TerminalTextBoxInputSettings.defaultShowOnNewTerminals, "focusTextBoxOnNewTerminals": TerminalTextBoxInputSettings.defaultFocusOnNewTerminals, "agentHibernation": [ diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 8c443363ffc6..dcb40791f45f 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -547,6 +547,16 @@ final class CmuxSettingsFileStore { logInvalid("terminal.autoResumeAgentSessions", sourcePath: sourcePath) } + if let raw = jsonString(section["regexHighlights"]) { + snapshot.managedUserDefaults[TerminalRegexHighlightSettings.highlightsKey] = .string(raw) + } else if let values = jsonStringArray(section["regexHighlights"]) { + snapshot.managedUserDefaults[TerminalRegexHighlightSettings.highlightsKey] = .string( + values.joined(separator: "\n") + ) + } else if section.keys.contains("regexHighlights") { + logInvalid("terminal.regexHighlights", sourcePath: sourcePath) + } + if let value = jsonBool(section["showTextBoxOnNewTerminals"]) { snapshot.managedUserDefaults[TerminalTextBoxInputSettings.showOnNewTerminalsKey] = .bool(value) } else if section.keys.contains("showTextBoxOnNewTerminals") { @@ -1624,6 +1634,10 @@ final class CmuxSettingsFileStore { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) } + if change.defaultsKey == TerminalRegexHighlightSettings.highlightsKey { + TerminalRegexHighlightSettings.notifyDidChange(notificationCenter: notificationCenter) + } + if change.defaultsKey == TerminalCopyOnSelectSettings.copyOnSelectKey { TerminalCopyOnSelectSettings.notifyDidChange(notificationCenter: notificationCenter) } diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 97fcf516bedf..d0b198f1fb71 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -354,6 +354,7 @@ enum SettingsSearchIndex { setting(.terminal, "copy-on-select", String(localized: "settings.terminal.copyOnSelect", defaultValue: "Copy on Selection"), "terminal.copyOnSelect clipboard selection mouse double click triple click"), setting(.terminal, "tab-bar-font-size", String(localized: "settings.terminal.tabBarFontSize", defaultValue: "Tab Bar Font Size"), "font size text scale terminal browser pane tab title surface-tab-bar-font-size"), 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(.terminal, "regex-highlights", String(localized: "settings.terminal.regexHighlights", defaultValue: "Automatic Text Highlights"), "terminal.regexHighlights regex regular expression highlight alert error warning terminal text"), setting(.terminal, "agent-hibernation", String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), "terminal.agentHibernation idle hibernate suspend background agents claude code codex opencode live terminals"), setting(.terminal, "resume-commands", String(localized: "settings.terminal.resumeCommands", defaultValue: "Resume Commands"), "surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), setting(.textBox, "show-textbox-new-terminals", String(localized: "settings.textBox.showOnNewTerminals", defaultValue: "Show TextBox on New Terminals"), "terminal.showTextBoxOnNewTerminals textbox text box rich input prompt default new workspace split tab beta"), @@ -495,6 +496,7 @@ enum SettingsSearchIndex { "terminal.textBoxMaxLines": settingID(for: .textBox, idSuffix: "textbox-max-lines"), "terminal.copyOnSelect": settingID(for: .terminal, idSuffix: "copy-on-select"), "terminal.autoResumeAgentSessions": settingID(for: .terminal, idSuffix: "agent-auto-resume"), + "terminal.regexHighlights": settingID(for: .terminal, idSuffix: "regex-highlights"), "terminal.agentHibernation.enabled": settingID(for: .terminal, idSuffix: "agent-hibernation"), "terminal.agentHibernation.idleSeconds": settingID(for: .terminal, idSuffix: "agent-hibernation"), "terminal.agentHibernation.maxLiveTerminals": settingID(for: .terminal, idSuffix: "agent-hibernation"), diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index a7ad06cf58f7..9f51dd71a6ff 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -17969,26 +17969,27 @@ class TerminalController { // Capture the main window on main thread var captureError: String? v2MainSync { - let candidateWindows = NSApp.windows.filter { window in - window.isVisible && - !window.isMiniaturized && - window.contentView != nil && - !window.frame.isEmpty - } - let preferredWindow = [NSApp.keyWindow, NSApp.mainWindow] - .compactMap { $0 } - .first { candidateWindows.contains($0) } - let window = preferredWindow ?? candidateWindows.max { lhs, rhs in - (lhs.frame.width * lhs.frame.height) < (rhs.frame.width * rhs.frame.height) - } ?? NSApp.mainWindow ?? NSApp.windows.first - - guard let window else { - captureError = "No window available" - return - } - - guard let pngData = self.captureCompositedWindowPNGData(window) - ?? self.captureAppKitWindowPNGData(window) else { + let orderedWindows = ([NSApp.mainWindow, NSApp.keyWindow].compactMap { $0 } + NSApp.windows) + var seenWindowNumbers: Set = [] + let windowCandidates = orderedWindows.filter { window in + guard window.isVisible, + !window.isMiniaturized, + window.contentView != nil, + window.frame.width > 100, + window.frame.height > 100 else { return false } + let rawWindowNumber = window.windowNumber + guard rawWindowNumber > 0 else { return false } + return seenWindowNumbers.insert(rawWindowNumber).inserted + } + guard !windowCandidates.isEmpty else { + captureError = "No capturable window available" + return + } + + guard let pngData = windowCandidates.lazy.compactMap({ window in + self.captureCompositedWindowPNGData(window) + ?? self.captureAppKitWindowPNGData(window) + }).first else { captureError = "Failed to create PNG data" return } @@ -18789,69 +18790,69 @@ class TerminalController { result = "OK" } } - return result - } - - private func dragSurfaceToSplit(_ args: String) -> String { - guard let tabManager = tabManager else { return "ERROR: TabManager not available" } - - let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.split(separator: " ").map(String.init) - guard parts.count >= 2 else { return "ERROR: Usage: drag_surface_to_split " } - - let surfaceArg = parts[0] - let directionArg = parts[1] - guard let direction = parseSplitDirection(directionArg) else { - return "ERROR: Invalid direction. Use left, right, up, or down." - } - - let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical - let insertFirst = (direction == .left || direction == .up) - - v2MainSync { self.v2RefreshKnownRefs() } - if let stableSurfaceId = v2UUID(["surface_id": surfaceArg], "surface_id") { - switch v2SurfaceSplitOff(params: [ - "surface_id": stableSurfaceId.uuidString, - "direction": directionArg, - "focus": false - ]) { - case .ok(let payload): - let dict = payload as? [String: Any] - let paneId = (dict?["pane_id"] as? String) ?? "" - return paneId.isEmpty ? "OK" : "OK \(paneId)" - case .err(_, let message, _): - return "ERROR: \(message)" - } - } - - var result = "ERROR: Failed to move surface" - v2MainSync { - guard let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - result = "ERROR: No tab selected" - return - } - - guard let panelId = resolveSurfaceId(from: surfaceArg, tab: tab), - let bonsplitTabId = tab.surfaceIdFromPanelId(panelId) else { - result = "ERROR: Surface not found" - return - } - - guard let newPaneId = tab.bonsplitController.splitPane( - orientation: orientation, - movingTab: bonsplitTabId, - insertFirst: insertFirst - ) else { - result = "ERROR: Failed to split pane" - return - } - - result = "OK \(newPaneId.id.uuidString)" - } - return result - } - + return result + } + + private func dragSurfaceToSplit(_ args: String) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: " ").map(String.init) + guard parts.count >= 2 else { return "ERROR: Usage: drag_surface_to_split " } + + let surfaceArg = parts[0] + let directionArg = parts[1] + guard let direction = parseSplitDirection(directionArg) else { + return "ERROR: Invalid direction. Use left, right, up, or down." + } + + let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical + let insertFirst = (direction == .left || direction == .up) + + v2MainSync { self.v2RefreshKnownRefs() } + if let stableSurfaceId = v2UUID(["surface_id": surfaceArg], "surface_id") { + switch v2SurfaceSplitOff(params: [ + "surface_id": stableSurfaceId.uuidString, + "direction": directionArg, + "focus": false + ]) { + case .ok(let payload): + let dict = payload as? [String: Any] + let paneId = (dict?["pane_id"] as? String) ?? "" + return paneId.isEmpty ? "OK" : "OK \(paneId)" + case .err(_, let message, _): + return "ERROR: \(message)" + } + } + + var result = "ERROR: Failed to move surface" + v2MainSync { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + result = "ERROR: No tab selected" + return + } + + guard let panelId = resolveSurfaceId(from: surfaceArg, tab: tab), + let bonsplitTabId = tab.surfaceIdFromPanelId(panelId) else { + result = "ERROR: Surface not found" + return + } + + guard let newPaneId = tab.bonsplitController.splitPane( + orientation: orientation, + movingTab: bonsplitTabId, + insertFirst: insertFirst + ) else { + result = "ERROR: Failed to split pane" + return + } + + result = "OK \(newPaneId.id.uuidString)" + } + return result + } + private func newPane(_ args: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } diff --git a/Sources/TerminalRegexHighlightOverlayView.swift b/Sources/TerminalRegexHighlightOverlayView.swift new file mode 100644 index 000000000000..663db4b20d0f --- /dev/null +++ b/Sources/TerminalRegexHighlightOverlayView.swift @@ -0,0 +1,115 @@ +import AppKit + +struct TerminalRegexHighlightOverlayMetrics: Equatable { + var cellSize: CGSize = .zero + var rowCount: Int = 0 + var columnCount: Int = 0 + var xInset: CGFloat = 0 + var yInset: CGFloat = 0 +} + +final class TerminalRegexHighlightOverlayView: NSView { + private var runs: [TerminalRegexHighlightRun] = [] + private var metrics = TerminalRegexHighlightOverlayMetrics() + private var colorsByHex: [String: NSColor] = [:] + + override var acceptsFirstResponder: Bool { false } + override var isFlipped: Bool { true } + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func configure( + runs: [TerminalRegexHighlightRun], + metrics: TerminalRegexHighlightOverlayMetrics + ) { + guard self.runs != runs || self.metrics != metrics || isHidden != runs.isEmpty else { + return + } + self.runs = runs + self.metrics = metrics + colorsByHex = Self.colorsByHex(for: runs) + isHidden = runs.isEmpty + needsDisplay = true + } + + func clear() { + configure(runs: [], metrics: metrics) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard !runs.isEmpty, + metrics.cellSize.width > 0, + metrics.cellSize.height > 0, + metrics.rowCount > 0, + metrics.columnCount > 0 else { + return + } + + for run in runs { + guard run.row >= 0, + run.row < metrics.rowCount, + run.column >= 0, + run.column < metrics.columnCount, + run.length > 0 else { + continue + } + + let length = min(run.length, metrics.columnCount - run.column) + guard length > 0 else { continue } + + let rect = CGRect( + x: metrics.xInset + CGFloat(run.column) * metrics.cellSize.width, + y: metrics.yInset + CGFloat(run.row) * metrics.cellSize.height, + width: CGFloat(length) * metrics.cellSize.width, + height: metrics.cellSize.height + ).insetBy(dx: 1, dy: 1) + + (colorsByHex[run.backgroundHex] ?? Self.color(for: run.backgroundHex)).setFill() + NSBezierPath( + roundedRect: rect, + xRadius: min(3, rect.height / 3), + yRadius: min(3, rect.height / 3) + ).fill() + } + } + + private static func color(for hex: String) -> NSColor { + let trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) + let raw = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard raw.count == 6 || raw.count == 8, + let value = UInt64(raw, radix: 16) else { + return NSColor.systemYellow.withAlphaComponent(0.5) + } + + let red: CGFloat + let green: CGFloat + let blue: CGFloat + let alpha: CGFloat + if raw.count == 8 { + red = CGFloat((value >> 24) & 0xFF) / 255 + green = CGFloat((value >> 16) & 0xFF) / 255 + blue = CGFloat((value >> 8) & 0xFF) / 255 + alpha = CGFloat(value & 0xFF) / 255 + } else { + red = CGFloat((value >> 16) & 0xFF) / 255 + green = CGFloat((value >> 8) & 0xFF) / 255 + blue = CGFloat(value & 0xFF) / 255 + alpha = 0.5 + } + + return NSColor(srgbRed: red, green: green, blue: blue, alpha: alpha) + } + + private static func colorsByHex(for runs: [TerminalRegexHighlightRun]) -> [String: NSColor] { + var colors: [String: NSColor] = [:] + for run in runs where colors[run.backgroundHex] == nil { + colors[run.backgroundHex] = color(for: run.backgroundHex) + } + return colors + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3409ea170e50..c8d211208122 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -563,6 +563,7 @@ D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; + D2788000A1B2C3D4E5F60001 /* TerminalRegexHighlightOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2788000A1B2C3D4E5F60002 /* TerminalRegexHighlightOverlayView.swift */; }; C0DE53350000000000000001 /* TerminalSearchOverlayHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE53350000000000000002 /* TerminalSearchOverlayHostingView.swift */; }; C0DE53360000000000000001 /* TerminalSearchOverlayMouseReleaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE53360000000000000002 /* TerminalSearchOverlayMouseReleaseTests.swift */; }; A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; }; @@ -1232,6 +1233,7 @@ D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = ""; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; + D2788000A1B2C3D4E5F60002 /* TerminalRegexHighlightOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRegexHighlightOverlayView.swift; sourceTree = ""; }; C0DE53350000000000000002 /* TerminalSearchOverlayHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/TerminalSearchOverlayHostingView.swift; sourceTree = ""; }; C0DE53360000000000000002 /* TerminalSearchOverlayMouseReleaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSearchOverlayMouseReleaseTests.swift; sourceTree = ""; }; A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = ""; }; @@ -1677,6 +1679,7 @@ D3284102A1B2C3D4E5F60718 /* GhosttyTextInputSupport.swift */, D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */, D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */, + D2788000A1B2C3D4E5F60002 /* TerminalRegexHighlightOverlayView.swift */, D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */, D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */, D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */, @@ -2753,6 +2756,7 @@ D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, + D2788000A1B2C3D4E5F60001 /* TerminalRegexHighlightOverlayView.swift in Sources */, C0DE53350000000000000001 /* TerminalSearchOverlayHostingView.swift in Sources */, A5001543 /* TerminalSSHSessionDetector.swift in Sources */, C13519000000000000000001 /* TerminalStartupEnvironment.swift in Sources */, diff --git a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift index 168939f0ad93..992261e3bc44 100644 --- a/cmuxTests/AgentSessionAutoResumeSettingsTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -777,6 +777,208 @@ final class AgentSessionAutoResumeSettingsTests: XCTestCase { } } +final class TerminalRegexHighlightSettingsTests: XCTestCase { + func testParsesPlainAndColorPrefixedRegexRules() throws { + let suiteName = "cmux-terminal-regex-highlights-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let notificationCenter = NotificationCenter() + var notificationCount = 0 + let observer = notificationCenter.addObserver( + forName: TerminalRegexHighlightSettings.didChangeNotification, + object: nil, + queue: nil + ) { _ in + notificationCount += 1 + } + defer { notificationCenter.removeObserver(observer) } + + TerminalRegexHighlightSettings.setRawHighlights( + """ + ERROR|WARN + #ff6b6b80\tpanic|fatal + """, + defaults: defaults, + notificationCenter: notificationCenter + ) + + let rules = TerminalRegexHighlightSettings.rules(defaults: defaults) + XCTAssertEqual( + rules, + [ + TerminalRegexHighlightRule( + pattern: "ERROR|WARN", + backgroundHex: TerminalRegexHighlightRule.defaultBackgroundHex + ), + TerminalRegexHighlightRule( + pattern: "panic|fatal", + backgroundHex: "#FF6B6B80" + ), + ] + ) + XCTAssertEqual(notificationCount, 1) + + TerminalRegexHighlightSettings.setRawHighlights( + TerminalRegexHighlightSettings.rawHighlights(defaults: defaults), + defaults: defaults, + notificationCenter: notificationCenter + ) + XCTAssertEqual(notificationCount, 1) + + TerminalRegexHighlightSettings.reset(defaults: defaults, notificationCenter: notificationCenter) + XCTAssertEqual(TerminalRegexHighlightSettings.rules(defaults: defaults), []) + XCTAssertEqual(notificationCount, 2) + } + + func testMatcherReturnsClippedRunsAndSkipsInvalidPatterns() { + let rules = [ + TerminalRegexHighlightRule(pattern: "ERR\\d+", backgroundHex: "#FFE06680"), + TerminalRegexHighlightRule(pattern: "[", backgroundHex: "#FF000080"), + TerminalRegexHighlightRule(pattern: "WARN", backgroundHex: "#7BD88F80"), + ] + + let runs = TerminalRegexHighlightMatcher.runs( + in: [ + "ok", + "ERR12 WARN ERR345", + ], + compiledRules: TerminalRegexHighlightMatcher.compiledRules(from: rules), + rowOffset: 3, + maxColumnCount: 12 + ) + + XCTAssertEqual( + runs, + [ + TerminalRegexHighlightRun(row: 4, column: 0, length: 5, backgroundHex: "#FFE06680"), + TerminalRegexHighlightRun(row: 4, column: 11, length: 1, backgroundHex: "#FFE06680"), + TerminalRegexHighlightRun(row: 4, column: 6, length: 4, backgroundHex: "#7BD88F80"), + ] + ) + } + + func testMatcherUsesTerminalColumnsForWideCharacters() { + let rules = [ + TerminalRegexHighlightRule(pattern: "日本", backgroundHex: "#7BD88F80"), + TerminalRegexHighlightRule(pattern: "👨‍👩‍👧‍👦", backgroundHex: "#FF6B6B80"), + TerminalRegexHighlightRule(pattern: "ERROR", backgroundHex: "#FFE06680"), + ] + + let runs = TerminalRegexHighlightMatcher.runs( + in: [ + "日本語 ERROR", + "👨‍👩‍👧‍👦 ERROR", + ], + compiledRules: TerminalRegexHighlightMatcher.compiledRules(from: rules) + ) + + XCTAssertEqual( + runs, + [ + TerminalRegexHighlightRun(row: 0, column: 0, length: 4, backgroundHex: "#7BD88F80"), + TerminalRegexHighlightRun(row: 0, column: 7, length: 5, backgroundHex: "#FFE06680"), + TerminalRegexHighlightRun(row: 1, column: 0, length: 2, backgroundHex: "#FF6B6B80"), + TerminalRegexHighlightRun(row: 1, column: 3, length: 5, backgroundHex: "#FFE06680"), + ] + ) + } + + func testRuntimeRuleStateRequiresCompiledRegex() { + let notificationCenter = NotificationCenter() + let suiteName = "TerminalRegexHighlightRuntimeRuleStateTests" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { + defaults.removePersistentDomain(forName: suiteName) + TerminalRegexHighlightSettings.notifyDidChange(notificationCenter: notificationCenter) + } + + TerminalRegexHighlightSettings.setRawHighlights( + "[", + defaults: defaults, + notificationCenter: notificationCenter + ) + XCTAssertFalse(TerminalRegexHighlightSettings.hasRuntimeRules()) + + TerminalRegexHighlightSettings.setRawHighlights( + "ERROR", + defaults: defaults, + notificationCenter: notificationCenter + ) + XCTAssertTrue(TerminalRegexHighlightSettings.hasRuntimeRules()) + } + + func testRuntimeRuleStateSynchronizesFromDefaultsChanges() { + let notificationCenter = NotificationCenter() + let suiteName = "TerminalRegexHighlightRuntimeDefaultsSyncTests" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + + var notificationCount = 0 + let observer = notificationCenter.addObserver( + forName: TerminalRegexHighlightSettings.didChangeNotification, + object: nil, + queue: nil + ) { _ in + notificationCount += 1 + } + defer { + notificationCenter.removeObserver(observer) + defaults.removePersistentDomain(forName: suiteName) + TerminalRegexHighlightSettings.notifyDidChange(notificationCenter: notificationCenter) + } + + defaults.set("ERROR", forKey: TerminalRegexHighlightSettings.highlightsKey) + XCTAssertTrue( + TerminalRegexHighlightSettings.synchronizeRuntimeStateIfNeeded( + defaults: defaults, + notificationCenter: notificationCenter + ) + ) + XCTAssertTrue(TerminalRegexHighlightSettings.hasRuntimeRules()) + XCTAssertEqual(notificationCount, 1) + + XCTAssertFalse( + TerminalRegexHighlightSettings.synchronizeRuntimeStateIfNeeded( + defaults: defaults, + notificationCenter: notificationCenter + ) + ) + XCTAssertEqual(notificationCount, 1) + + defaults.set("[", forKey: TerminalRegexHighlightSettings.highlightsKey) + XCTAssertTrue( + TerminalRegexHighlightSettings.synchronizeRuntimeStateIfNeeded( + defaults: defaults, + notificationCenter: notificationCenter + ) + ) + XCTAssertFalse(TerminalRegexHighlightSettings.hasRuntimeRules()) + XCTAssertEqual(notificationCount, 2) + } + + func testParserSkipsInvalidColorPrefixedRules() { + let rules = TerminalRegexHighlightSettings.rules( + from: """ + red\tERROR + #BAD\tWARN + #FFE06680\tpanic + """ + ) + + XCTAssertEqual( + rules, + [ + TerminalRegexHighlightRule( + pattern: "panic", + backgroundHex: "#FFE06680" + ), + ] + ) + } +} + final class TerminalCopyOnSelectSettingsTests: XCTestCase { func testDefaultsNotificationAndGhosttyConfigMapping() throws { let suiteName = "cmux-terminal-copy-on-select-\(UUID().uuidString)" diff --git a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index b7ea57331708..14a10071dc85 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -1040,6 +1040,47 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { XCTAssertEqual(defaults.object(forKey: key) as? Bool, false) } + func testSettingsFileStoreAppliesTerminalRegexHighlightsSetting() throws { + let defaults = UserDefaults.standard + let key = TerminalRegexHighlightSettings.highlightsKey + + try preservingDefaults(keys: [key, settingsFileBackupsDefaultsKey, 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": { + "regexHighlights": [ + "#FFE06680\\tERROR", + "#FF6B6B80\\tpanic|fatal" + ] + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + + XCTAssertEqual( + defaults.string(forKey: key), + "#FFE06680\tERROR\n#FF6B6B80\tpanic|fatal" + ) + } + } + func testSettingsFileStoreAppliesTerminalTextBoxMaxLinesSetting() throws { let defaults = UserDefaults.standard try preservingDefaults(keys: [ diff --git a/tests/test_terminal_regex_highlights.py b/tests/test_terminal_regex_highlights.py new file mode 100644 index 000000000000..105aecb1f74c --- /dev/null +++ b/tests/test_terminal_regex_highlights.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +Regression test: terminal regex highlights must be visible in the window. + +This intentionally uses the full-window debug `screenshot` command instead of +`panel_snapshot`. `panel_snapshot` captures the terminal IOSurface directly and +does not include AppKit overlay views. +""" + +import json +import os +import struct +import subprocess +import sys +import time +import zlib +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock") +CONFIG_PATH = Path.home() / ".config" / "cmux" / "cmux.json" +MARKER = "CMUX_HIGHLIGHT_REPRO" +HIGHLIGHT_COLOR = "#00FF00FF" +MIN_GREEN_PIXELS = 600 + + +def _wait_for(predicate, timeout_s: float, step_s: float = 0.05) -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _capture_screenshot(client: cmux, label: str) -> Path: + response = client._send_command(f"screenshot {label}").strip() + if not response.startswith("OK "): + raise cmuxError(f"screenshot failed: {response}") + parts = response.split(" ", 2) + if len(parts) != 3: + raise cmuxError(f"screenshot parse failed: {response}") + path = Path(parts[2]) + if not path.exists(): + raise cmuxError(f"screenshot path does not exist: {path}") + return path + + +def _write_regex_highlight_config() -> bytes | None: + previous = CONFIG_PATH.read_bytes() if CONFIG_PATH.exists() else None + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = { + "terminal": { + "regexHighlights": [ + f"{HIGHLIGHT_COLOR}\t{MARKER}", + ], + }, + } + CONFIG_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return previous + + +def _restore_config(previous: bytes | None) -> None: + if previous is None: + try: + CONFIG_PATH.unlink() + except FileNotFoundError: + pass + return + + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_bytes(previous) + + +def _read_regex_highlight_default(bundle_id: str) -> str | None: + result = subprocess.run( + ["defaults", "read", bundle_id, "terminal.regexHighlights"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + return result.stdout.rstrip("\n") + + +def _write_regex_highlight_default(bundle_id: str, value: str) -> None: + subprocess.run( + ["defaults", "write", bundle_id, "terminal.regexHighlights", "-string", value], + check=False, + capture_output=True, + ) + + +def _delete_regex_highlight_default(bundle_id: str) -> None: + subprocess.run( + ["defaults", "delete", bundle_id, "terminal.regexHighlights"], + check=False, + capture_output=True, + ) + + +def _png_rgb_rows(path: Path) -> tuple[int, int, list[bytes], int]: + data = path.read_bytes() + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + raise cmuxError(f"not a PNG: {path}") + + pos = 8 + width = 0 + height = 0 + bit_depth = 0 + color_type = 0 + idat = bytearray() + while pos + 8 <= len(data): + length = struct.unpack(">I", data[pos:pos + 4])[0] + chunk_type = data[pos + 4:pos + 8] + chunk_data = data[pos + 8:pos + 8 + length] + pos += 12 + length + if chunk_type == b"IHDR": + width, height, bit_depth, color_type, _compression, _filter, interlace = struct.unpack( + ">IIBBBBB", + chunk_data, + ) + if bit_depth != 8 or color_type not in (2, 6) or interlace != 0: + raise cmuxError( + f"unsupported PNG format: bit_depth={bit_depth} " + f"color_type={color_type} interlace={interlace}" + ) + elif chunk_type == b"IDAT": + idat.extend(chunk_data) + elif chunk_type == b"IEND": + break + + if width <= 0 or height <= 0: + raise cmuxError(f"missing PNG dimensions: {path}") + + channels = 3 if color_type == 2 else 4 + stride = width * channels + raw = zlib.decompress(bytes(idat)) + rows: list[bytes] = [] + prev = bytearray(stride) + cursor = 0 + for _ in range(height): + filter_type = raw[cursor] + cursor += 1 + current = bytearray(raw[cursor:cursor + stride]) + cursor += stride + for i in range(stride): + left = current[i - channels] if i >= channels else 0 + up = prev[i] + up_left = prev[i - channels] if i >= channels else 0 + if filter_type == 0: + pass + elif filter_type == 1: + current[i] = (current[i] + left) & 0xFF + elif filter_type == 2: + current[i] = (current[i] + up) & 0xFF + elif filter_type == 3: + current[i] = (current[i] + ((left + up) // 2)) & 0xFF + elif filter_type == 4: + pa = abs(up - up_left) + pb = abs(left - up_left) + pc = abs(left + up - 2 * up_left) + predictor = left if pa <= pb and pa <= pc else up if pb <= pc else up_left + current[i] = (current[i] + predictor) & 0xFF + else: + raise cmuxError(f"unsupported PNG filter type: {filter_type}") + rows.append(bytes(current)) + prev = current + + return width, height, rows, channels + + +def _count_green_pixels(path: Path) -> int: + _width, _height, rows, channels = _png_rgb_rows(path) + count = 0 + for row in rows: + for i in range(0, len(row), channels): + red = row[i] + green = row[i + 1] + blue = row[i + 2] + if red <= 70 and green >= 180 and blue <= 90: + count += 1 + return count + + +def main() -> int: + bundle_id = cmux.default_bundle_id() + previous_config = _write_regex_highlight_config() + previous_default = _read_regex_highlight_default(bundle_id) + _delete_regex_highlight_default(bundle_id) + screenshot_path: Path | None = None + try: + with cmux(SOCKET_PATH) as client: + if not client.ping(): + raise cmuxError(f"Socket ping failed on {SOCKET_PATH}") + + response = client._send_command("reload_config") + if not response.startswith("OK"): + raise cmuxError(f"reload_config failed: {response}") + + client.activate_app() + time.sleep(0.25) + + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + time.sleep(0.4) + + surfaces = client.list_surfaces() + if not surfaces: + raise cmuxError("Expected at least 1 surface after new_workspace") + panel_id = next((sid for _i, sid, focused in surfaces if focused), surfaces[0][1]) + _wait_for(lambda: client.is_terminal_focused(panel_id), timeout_s=3.0) + + client.send_surface(panel_id, f"printf '{MARKER}\\n'\n") + _wait_for(lambda: MARKER in client.read_terminal_text(panel_id), timeout_s=4.0) + + time.sleep(0.35) + screenshot_path = _capture_screenshot(client, "terminal_regex_highlight") + green_pixels = _count_green_pixels(screenshot_path) + if green_pixels < MIN_GREEN_PIXELS: + raise cmuxError( + "Expected configured terminal regex highlight to be visibly green.\n" + f"green_pixels={green_pixels} min_green_pixels={MIN_GREEN_PIXELS}\n" + f"screenshot_path={screenshot_path}" + ) + finally: + _restore_config(previous_config) + if previous_default is None: + _delete_regex_highlight_default(bundle_id) + else: + _write_regex_highlight_default(bundle_id, previous_default) + try: + with cmux(SOCKET_PATH) as client: + client._send_command("reload_config") + except Exception: + pass + + print(f"PASS: terminal regex highlight visible in screenshot {screenshot_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index c15cd8256143..4e5b39a650ba 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -74,6 +74,7 @@ function buildSettingsFileExample(t: ConfigurationTranslation) { // "showScrollBar": false, // "copyOnSelect": true, // "autoResumeAgentSessions": true, + // "regexHighlights": ["#FFE06680\\tERROR", "#FF6B6B80\\tpanic|fatal"], // "showTextBoxOnNewTerminals": false, // "focusTextBoxOnNewTerminals": false, // "agentHibernation": { diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 5dedf1507b8f..381067aa934d 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -398,6 +398,21 @@ "default": true, "description": "Automatically run agent resume commands for restored terminal sessions when cmux reopens after quit. Set false to restore panes while keeping Claude Code, Codex, OpenCode, and other saved agent sessions idle until you resume them manually." }, + "regexHighlights": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "default": "", + "description": "Regex-based automatic text highlights for visible terminal text. Use one pattern per entry, or prefix an entry with #RRGGBB or #RRGGBBAA and a tab to choose the highlight color." + }, "showTextBoxOnNewTerminals": { "type": "boolean", "default": false,