Reduce typing lag from sidebar re-evaluation and hitTest overhead (#1204)

* Add typing hot path timing diagnostics

* Add stress workspace debug menu item

* Restore stress workspace preload debug path

* Reduce typing lag from sidebar re-evaluation and hitTest overhead

hitTest: gate divider/sidebar/drag routing to pointer events only,
avoiding two full view-tree walks per non-pointer event.

forceRefresh: replace per-keystroke ISO8601DateFormatter + FileHandle
I/O with dlog() in DEBUG builds.

TabItemView: replace @EnvironmentObject subscriptions with plain refs
and precomputed parameters, add Equatable conformance to skip body
re-evaluation when parent rebuilds with unchanged values. @self changed
re-evaluations dropped from 668 to 1 during rapid typing.

* Add typing-latency guardrail comments and CLAUDE.md pitfalls

Strategic comments on hitTest, TabItemView, and forceRefresh to prevent
future regressions. Adds typing-latency-sensitive paths to CLAUDE.md
pitfalls section so agents know the constraints before editing.

* Add workspace palette actions and fix release autosave typing guard

Add Move Up/Down/Top, Close Other/Above/Below, Mark Read/Unread to
Cmd+Shift+P command palette and a Workspace submenu in the menu bar.

Fix recordTypingActivity() being gated behind #if DEBUG, which prevented
release builds from honoring the typing quiet period in autosave.
This commit is contained in:
Lawrence Chen 2026-03-11 17:54:02 -07:00 committed by GitHub
parent 18bdbef882
commit 6849b83f8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1863 additions and 151 deletions

View file

@ -126,6 +126,10 @@ This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the
- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`).
- Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.
- **Typing-latency-sensitive paths** (read carefully before touching these areas):
- `WindowTerminalHostView.hitTest()` in `TerminalWindowPortal.swift`: called on every event including keyboard. All divider/sidebar/drag routing is gated to pointer events only. Do not add work outside the `isPointerEvent` guard.
- `TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `@EnvironmentObject`, `@ObservedObject` (besides `tab`), or `@Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use the precomputed `let` parameters instead.
- `TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift`: called on every keystroke. Do not add allocations, file I/O, or formatting here.
- **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
- **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc.

View file

@ -788,6 +788,23 @@
}
}
},
"debug.menu.openStressWorkspacesWithLoadedSurfaces": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Stress Workspaces and Load All Terminals"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "負荷テスト用ワークスペースを開いてすべてのターミナルを読み込む"
}
}
}
},
"debug.devBuildBanner.title": {
"extractionState": "manual",
"localizations": {

File diff suppressed because it is too large Load diff

View file

@ -1527,6 +1527,11 @@ struct ContentView: View {
static let workspaceShouldPin = "workspace.shouldPin"
static let workspaceHasPullRequests = "workspace.hasPullRequests"
static let workspaceHasSplits = "workspace.hasSplits"
static let workspaceHasPeers = "workspace.hasPeers"
static let workspaceHasAbove = "workspace.hasAbove"
static let workspaceHasBelow = "workspace.hasBelow"
static let workspaceHasUnread = "workspace.hasUnread"
static let workspaceHasRead = "workspace.hasRead"
static let hasFocusedPanel = "panel.hasFocus"
static let panelName = "panel.name"
@ -2319,6 +2324,10 @@ struct ContentView: View {
reconcileMountedWorkspaceIds()
})
view = AnyView(view.onReceive(tabManager.$debugPinnedWorkspaceLoadIds) { _ in
reconcileMountedWorkspaceIds()
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
@ -2699,7 +2708,9 @@ struct ContentView: View {
let orderedTabIds = currentTabs.map { $0.id }
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
let pinnedIds = handoffPinnedIds.union(tabManager.pendingBackgroundWorkspaceLoadIds)
let pinnedIds = handoffPinnedIds
.union(tabManager.pendingBackgroundWorkspaceLoadIds)
.union(tabManager.debugPinnedWorkspaceLoadIds)
let isCycleHot = tabManager.isWorkspaceCycleHot
let shouldKeepHandoffPair = isCycleHot && !handoffPinnedIds.isEmpty
let baseMaxMounted = shouldKeepHandoffPair
@ -4027,6 +4038,21 @@ struct ContentView: View {
CommandPaletteContextKeys.workspaceHasSplits,
workspace.bonsplitController.allPaneIds.count > 1
)
let workspaceIndex = tabManager.tabs.firstIndex { $0.id == workspace.id }
snapshot.setBool(CommandPaletteContextKeys.workspaceHasPeers, tabManager.tabs.count > 1)
snapshot.setBool(CommandPaletteContextKeys.workspaceHasAbove, (workspaceIndex ?? 0) > 0)
snapshot.setBool(
CommandPaletteContextKeys.workspaceHasBelow,
(workspaceIndex ?? tabManager.tabs.count - 1) < tabManager.tabs.count - 1
)
snapshot.setBool(
CommandPaletteContextKeys.workspaceHasUnread,
notificationStore.notifications.contains { $0.tabId == workspace.id && !$0.isRead }
)
snapshot.setBool(
CommandPaletteContextKeys.workspaceHasRead,
notificationStore.notifications.contains { $0.tabId == workspace.id && $0.isRead }
)
}
if let panelContext = focusedPanelContext {
@ -4320,6 +4346,86 @@ struct ContentView: View {
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.moveWorkspaceUp",
title: constant(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")),
subtitle: workspaceSubtitle,
keywords: ["workspace", "move", "up", "reorder"],
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) }
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.moveWorkspaceDown",
title: constant(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")),
subtitle: workspaceSubtitle,
keywords: ["workspace", "move", "down", "reorder"],
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasBelow) }
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.moveWorkspaceToTop",
title: constant(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")),
subtitle: workspaceSubtitle,
keywords: ["workspace", "move", "top", "reorder"],
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) }
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.closeOtherWorkspaces",
title: constant(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")),
subtitle: workspaceSubtitle,
keywords: ["close", "other", "workspaces", "reset", "workspace"],
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasPeers) }
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.closeWorkspacesBelow",
title: constant(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")),
subtitle: workspaceSubtitle,
keywords: ["close", "below", "workspaces", "workspace"],
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasBelow) }
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.closeWorkspacesAbove",
title: constant(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")),
subtitle: workspaceSubtitle,
keywords: ["close", "above", "workspaces", "workspace"],
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) }
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.markWorkspaceRead",
title: constant(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")),
subtitle: workspaceSubtitle,
keywords: ["workspace", "read", "notification", "inbox"],
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasUnread) }
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.markWorkspaceUnread",
title: constant(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")),
subtitle: workspaceSubtitle,
keywords: ["workspace", "unread", "notification", "inbox"],
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasRead) }
)
)
contributions.append(
CommandPaletteCommandContribution(
@ -4796,6 +4902,43 @@ struct ContentView: View {
registry.register(commandId: "palette.previousWorkspace") {
tabManager.selectPreviousTab()
}
registry.register(commandId: "palette.moveWorkspaceUp") {
moveSelectedWorkspace(by: -1)
}
registry.register(commandId: "palette.moveWorkspaceDown") {
moveSelectedWorkspace(by: 1)
}
registry.register(commandId: "palette.moveWorkspaceToTop") {
guard let workspace = tabManager.selectedWorkspace else {
NSSound.beep()
return
}
tabManager.moveTabsToTop([workspace.id])
tabManager.selectWorkspace(workspace)
}
registry.register(commandId: "palette.closeOtherWorkspaces") {
closeOtherSelectedWorkspaces()
}
registry.register(commandId: "palette.closeWorkspacesBelow") {
closeSelectedWorkspacesBelow()
}
registry.register(commandId: "palette.closeWorkspacesAbove") {
closeSelectedWorkspacesAbove()
}
registry.register(commandId: "palette.markWorkspaceRead") {
guard let workspaceId = tabManager.selectedWorkspace?.id else {
NSSound.beep()
return
}
notificationStore.markRead(forTabId: workspaceId)
}
registry.register(commandId: "palette.markWorkspaceUnread") {
guard let workspaceId = tabManager.selectedWorkspace?.id else {
NSSound.beep()
return
}
notificationStore.markUnread(forTabId: workspaceId)
}
registry.register(commandId: "palette.renameTab") {
beginRenameTabFlow()
@ -5796,6 +5939,48 @@ struct ContentView: View {
)
}
private func selectedWorkspaceIndex() -> Int? {
guard let workspace = tabManager.selectedWorkspace else { return nil }
return tabManager.tabs.firstIndex { $0.id == workspace.id }
}
private func moveSelectedWorkspace(by delta: Int) {
guard let workspace = tabManager.selectedWorkspace,
let currentIndex = selectedWorkspaceIndex() else { return }
let targetIndex = currentIndex + delta
guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return }
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex)
tabManager.selectWorkspace(workspace)
}
private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) {
for workspaceId in workspaceIds {
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue }
guard allowPinned || !workspace.isPinned else { continue }
tabManager.closeWorkspaceWithConfirmation(workspace)
}
}
private func closeOtherSelectedWorkspaces() {
guard let workspace = tabManager.selectedWorkspace else { return }
let workspaceIds = tabManager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id }
closeWorkspaceIds(workspaceIds, allowPinned: false)
}
private func closeSelectedWorkspacesBelow() {
guard let workspace = tabManager.selectedWorkspace,
let anchorIndex = selectedWorkspaceIndex() else { return }
let workspaceIds = tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id)
closeWorkspaceIds(workspaceIds, allowPinned: false)
}
private func closeSelectedWorkspacesAbove() {
guard let workspace = tabManager.selectedWorkspace,
let anchorIndex = selectedWorkspaceIndex() else { return }
let workspaceIds = tabManager.tabs.prefix(upTo: anchorIndex).map(\.id)
closeWorkspaceIds(workspaceIds, allowPinned: false)
}
private func beginRenameWorkspaceFlow() {
guard let workspace = tabManager.selectedWorkspace else {
NSSound.beep()
@ -6960,6 +7145,7 @@ struct VerticalTabsSidebar: View {
@ObservedObject var updateViewModel: UpdateViewModel
let onSendFeedback: () -> Void
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
@ -6985,10 +7171,21 @@ struct VerticalTabsSidebar: View {
LazyVStack(spacing: tabRowSpacing) {
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
TabItemView(
tabManager: tabManager,
notificationStore: notificationStore,
tab: tab,
index: index,
isActive: tabManager.selectedTabId == tab.id,
tabCount: tabManager.tabs.count,
unreadCount: notificationStore.unreadCount(forTabId: tab.id),
latestNotificationText: {
guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil }
let text = notification.body.isEmpty ? notification.title : notification.body
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}(),
rowSpacing: tabRowSpacing,
selection: $selection,
setSelectionToTabs: { selection = .tabs },
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed,
@ -6996,6 +7193,7 @@ struct VerticalTabsSidebar: View {
draggedTabId: $draggedTabId,
dropIndicator: $dropIndicator
)
.equatable()
}
}
.padding(.vertical, 8)
@ -9231,14 +9429,40 @@ enum SidebarWorkspaceShortcutHintMetrics {
#endif
}
private struct TabItemView: View {
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when
// the parent rebuilds with unchanged values. Without this, every TabManager
// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of
// main thread during typing). If you add new properties, update == below.
// Do NOT add @EnvironmentObject or new @Binding without updating ==.
// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar.
private struct TabItemView: View, Equatable {
// Closures, Bindings, and object references are excluded from ==
// because they're recreated every parent eval but don't affect rendering.
nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool {
lhs.tab === rhs.tab &&
lhs.index == rhs.index &&
lhs.isActive == rhs.isActive &&
lhs.tabCount == rhs.tabCount &&
lhs.unreadCount == rhs.unreadCount &&
lhs.latestNotificationText == rhs.latestNotificationText &&
lhs.rowSpacing == rhs.rowSpacing &&
lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints
}
// Use plain references instead of @EnvironmentObject to avoid subscribing
// to ALL changes on these objects. Body reads use precomputed parameters;
// action handlers use the plain references without triggering re-evaluation.
let tabManager: TabManager
let notificationStore: TerminalNotificationStore
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var tab: Tab
let index: Int
let isActive: Bool
let tabCount: Int
let unreadCount: Int
let latestNotificationText: String?
let rowSpacing: CGFloat
@Binding var selection: SidebarSelection
let setSelectionToTabs: () -> Void
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
let showsModifierShortcutHints: Bool
@ -9264,10 +9488,6 @@ private struct TabItemView: View {
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
var isActive: Bool {
tabManager.selectedTabId == tab.id
}
var isMultiSelected: Bool {
selectedTabIds.contains(tab.id)
}
@ -9340,11 +9560,11 @@ private struct TabItemView: View {
}
private var workspaceShortcutDigit: Int? {
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count)
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount)
}
private var showCloseButton: Bool {
isHovering && tabManager.tabs.count > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
}
private var workspaceShortcutLabel: String? {
@ -9409,7 +9629,6 @@ private struct TabItemView: View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
let unreadCount = notificationStore.unreadCount(forTabId: tab.id)
if unreadCount > 0 {
ZStack {
Circle()
@ -9969,7 +10188,7 @@ private struct TabItemView: View {
}
private var accessibilityTitle: String {
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabManager.tabs.count)")
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)")
}
private func moveBy(_ delta: Int) {
@ -9979,7 +10198,7 @@ private struct TabItemView: View {
selectedTabIds = [tab.id]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id }
tabManager.selectTab(tab)
selection = .tabs
setSelectionToTabs()
}
private func updateSelection() {
@ -10024,7 +10243,7 @@ private struct TabItemView: View {
surfaceId: tabManager.focusedSurfaceId(for: tab.id)
)
}
selection = .tabs
setSelectionToTabs()
}
private func contextTargetIds() -> [UUID] {
@ -10134,12 +10353,8 @@ private struct TabItemView: View {
syncSelectionAfterMutation()
}
private var latestNotificationText: String? {
guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil }
let text = notification.body.isEmpty ? notification.title : notification.body
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
// latestNotificationText is now passed as a parameter from the parent view
// to avoid subscribing to notificationStore changes in every TabItemView.
private func branchDirectoryRow(
gitSummary: String?,

View file

@ -3031,16 +3031,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)"
}
#if DEBUG
let ts = ISO8601DateFormatter().string(from: Date())
let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n"
let logPath = "/tmp/cmux-refresh-debug.log"
if let handle = FileHandle(forWritingAtPath: logPath) {
handle.seekToEndOfFile()
handle.write(line.data(using: .utf8)!)
handle.closeFile()
} else {
FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8))
}
dlog("forceRefresh: \(id) reason=\(reason) \(viewState)")
#endif
guard let view = attachedView,
view.window != nil,
@ -4302,10 +4293,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#if DEBUG
private func recordKeyLatency(path: String, event: NSEvent) {
guard Self.keyLatencyProbeEnabled else { return }
guard event.timestamp > 0 else { return }
let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000)
let delayText = String(format: "%.2f", delayMs)
dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
CmuxTypingTiming.logEventDelay(path: path, event: event)
}
#endif
@ -4322,6 +4310,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
defer {
CmuxTypingTiming.logDuration(
path: "terminal.performKeyEquivalent",
startedAt: typingTimingStart,
event: event
)
}
#endif
guard event.type == .keyDown else { return false }
guard let fr = window?.firstResponder as? NSView,
fr === self || fr.isDescendant(of: self) else { return false }
@ -4443,15 +4441,59 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
override func keyDown(with event: NSEvent) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
let phaseTotalStart = ProcessInfo.processInfo.systemUptime
var ensureSurfaceMs: Double = 0
var dismissNotificationMs: Double = 0
var keyboardCopyModeMs: Double = 0
var interpretMs: Double = 0
var syncPreeditMs: Double = 0
var ghosttySendMs: Double = 0
var refreshMs: Double = 0
defer {
let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0
CmuxTypingTiming.logBreakdown(
path: "terminal.keyDown.phase",
totalMs: totalMs,
event: event,
thresholdMs: 1.0,
parts: [
("ensureSurfaceMs", ensureSurfaceMs),
("dismissNotificationMs", dismissNotificationMs),
("keyboardCopyModeMs", keyboardCopyModeMs),
("interpretMs", interpretMs),
("syncPreeditMs", syncPreeditMs),
("ghosttySendMs", ghosttySendMs),
("refreshMs", refreshMs),
],
extra: "marked=\(hasMarkedText() ? 1 : 0)"
)
CmuxTypingTiming.logDuration(path: "terminal.keyDown", startedAt: typingTimingStart, event: event)
}
let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime
#endif
guard let surface = ensureSurfaceReadyForInput() else {
#if DEBUG
ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0
#endif
super.keyDown(with: event)
return
}
#if DEBUG
ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0
#endif
if let terminalSurface {
#if DEBUG
let dismissNotificationStart = ProcessInfo.processInfo.systemUptime
#endif
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
tabId: terminalSurface.tabId,
surfaceId: terminalSurface.id
)
#if DEBUG
dismissNotificationMs = (ProcessInfo.processInfo.systemUptime - dismissNotificationStart) * 1000.0
#endif
}
if event.keyCode != 53 {
endFindEscapeSuppression()
@ -4459,10 +4501,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if shouldConsumeSuppressedFindEscape(event) {
return
}
#if DEBUG
let keyboardCopyModeStart = ProcessInfo.processInfo.systemUptime
#endif
if handleKeyboardCopyModeIfNeeded(event, surface: surface) {
#if DEBUG
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
#endif
keyboardCopyModeConsumedKeyUps.insert(event.keyCode)
return
}
#if DEBUG
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
#endif
#if DEBUG
recordKeyLatency(path: "keyDown", event: event)
#endif
@ -4500,12 +4551,36 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let handled: Bool
if text.isEmpty {
keyEvent.text = nil
#if DEBUG
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
handled = sendTimedGhosttyKey(
surface,
keyEvent,
path: "terminal.keyDown.ctrlGhosttySend",
event: event
)
ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
#else
handled = ghostty_surface_key(surface, keyEvent)
#endif
} else {
#if DEBUG
let sendTimingStart = CmuxTypingTiming.start()
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
#endif
handled = text.withCString { ptr in
keyEvent.text = ptr
return ghostty_surface_key(surface, keyEvent)
}
#if DEBUG
ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
CmuxTypingTiming.logDuration(
path: "terminal.keyDown.ctrlGhosttySend",
startedAt: sendTimingStart,
event: event,
extra: "handled=\(handled ? 1 : 0)"
)
#endif
}
#if DEBUG
dlog(
@ -4582,18 +4657,42 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
// Let the input system handle the event (for IME, dead keys, etc.)
#if DEBUG
let interpretTimingStart = CmuxTypingTiming.start()
let interpretPhaseStart = ProcessInfo.processInfo.systemUptime
#endif
interpretKeyEvents([translationEvent])
#if DEBUG
interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0
CmuxTypingTiming.logDuration(
path: "terminal.keyDown.interpretKeyEvents",
startedAt: interpretTimingStart,
event: event
)
#endif
// If the keyboard layout changed, an input method grabbed the event.
// Sync preedit and return without sending the key to Ghostty.
if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id {
#if DEBUG
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
#endif
syncPreedit(clearIfNeeded: markedTextBefore)
#if DEBUG
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
#endif
return
}
// Sync the preedit state with Ghostty so it can render the IME
// composition overlay (e.g. for Korean, Japanese, Chinese input).
#if DEBUG
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
#endif
syncPreedit(clearIfNeeded: markedTextBefore)
#if DEBUG
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
#endif
// Build the key event
var keyEvent = ghostty_input_key_s()
@ -4623,13 +4722,37 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
for text in accumulatedText {
if shouldSendText(text) {
shouldRefreshAfterTextInput = true
#if DEBUG
let sendTimingStart = CmuxTypingTiming.start()
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
#endif
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
#if DEBUG
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
CmuxTypingTiming.logDuration(
path: "terminal.keyDown.accumulatedGhosttySend",
startedAt: sendTimingStart,
event: event,
extra: "textBytes=\(text.utf8.count)"
)
#endif
} else {
keyEvent.text = nil
#if DEBUG
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
_ = sendTimedGhosttyKey(
surface,
keyEvent,
path: "terminal.keyDown.accumulatedGhosttySend",
event: event
)
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
#else
_ = ghostty_surface_key(surface, keyEvent)
#endif
}
}
} else {
@ -4644,22 +4767,63 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if let text = textForKeyEvent(translationEvent) {
if shouldSendText(text), !suppressShiftSpaceFallbackText {
shouldRefreshAfterTextInput = true
#if DEBUG
let sendTimingStart = CmuxTypingTiming.start()
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
#endif
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
#if DEBUG
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
CmuxTypingTiming.logDuration(
path: "terminal.keyDown.ghosttySend",
startedAt: sendTimingStart,
event: event,
extra: "textBytes=\(text.utf8.count)"
)
#endif
} else {
keyEvent.text = nil
#if DEBUG
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
_ = sendTimedGhosttyKey(
surface,
keyEvent,
path: "terminal.keyDown.ghosttySend",
event: event
)
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
#else
_ = ghostty_surface_key(surface, keyEvent)
#endif
}
} else {
keyEvent.text = nil
#if DEBUG
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
_ = sendTimedGhosttyKey(
surface,
keyEvent,
path: "terminal.keyDown.ghosttySend",
event: event
)
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
#else
_ = ghostty_surface_key(surface, keyEvent)
#endif
}
}
if shouldRefreshAfterTextInput {
#if DEBUG
let refreshStart = ProcessInfo.processInfo.systemUptime
#endif
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
#if DEBUG
refreshMs = (ProcessInfo.processInfo.systemUptime - refreshStart) * 1000.0
#endif
}
// Rendering is driven by Ghostty's wakeups/renderer.
@ -4673,6 +4837,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return ghostty_surface_key(surface, keyEvent)
}
#if DEBUG
@discardableResult
private func sendTimedGhosttyKey(
_ surface: ghostty_surface_t,
_ keyEvent: ghostty_input_key_s,
path: String,
event: NSEvent? = nil,
extra: String? = nil
) -> Bool {
let timingStart = CmuxTypingTiming.start()
let handled = sendGhosttyKey(surface, keyEvent)
let baseExtra = "handled=\(handled ? 1 : 0)"
let mergedExtra: String
if let extra, !extra.isEmpty {
mergedExtra = "\(baseExtra) \(extra)"
} else {
mergedExtra = baseExtra
}
CmuxTypingTiming.logDuration(path: path, startedAt: timingStart, event: event, extra: mergedExtra)
return handled
}
#endif
override func keyUp(with event: NSEvent) {
guard let surface = ensureSurfaceReadyForInput() else {
super.keyUp(with: event)
@ -7453,6 +7640,9 @@ final class GhosttySurfaceScrollView: NSView {
extension GhosttyNSView: NSTextInputClient {
fileprivate func sendTextToSurface(_ chars: String) {
guard let surface = surface else { return }
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
#endif
#if DEBUG
cmuxWriteChildExitProbe(
[
@ -7472,6 +7662,13 @@ extension GhosttyNSView: NSTextInputClient {
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
}
#if DEBUG
CmuxTypingTiming.logDuration(
path: "terminal.sendTextToSurface",
startedAt: typingTimingStart,
extra: "textBytes=\(chars.utf8.count)"
)
#endif
}
func hasMarkedText() -> Bool {
@ -7488,6 +7685,16 @@ extension GhosttyNSView: NSTextInputClient {
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
defer {
CmuxTypingTiming.logDuration(
path: "terminal.setMarkedText",
startedAt: typingTimingStart,
extra: "markedLength=\(markedText.length)"
)
}
#endif
switch string {
case let v as NSAttributedString:
markedText = NSMutableAttributedString(attributedString: v)
@ -7506,6 +7713,17 @@ extension GhosttyNSView: NSTextInputClient {
}
func unmarkText() {
#if DEBUG
let hadMarkedText = markedText.length > 0
let typingTimingStart = CmuxTypingTiming.start()
defer {
CmuxTypingTiming.logDuration(
path: "terminal.unmarkText",
startedAt: typingTimingStart,
extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)"
)
}
#endif
if markedText.length > 0 {
markedText.mutableString.setString("")
syncPreedit()
@ -7516,6 +7734,16 @@ extension GhosttyNSView: NSTextInputClient {
/// This tells Ghostty about IME composition text so it can render the
/// preedit overlay (e.g. for Korean, Japanese, Chinese input).
private func syncPreedit(clearIfNeeded: Bool = true) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
defer {
CmuxTypingTiming.logDuration(
path: "terminal.syncPreedit",
startedAt: typingTimingStart,
extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)"
)
}
#endif
guard let surface = surface else { return }
if markedText.length > 0 {
@ -7583,6 +7811,17 @@ extension GhosttyNSView: NSTextInputClient {
}
func insertText(_ string: Any, replacementRange: NSRange) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
defer {
CmuxTypingTiming.logDuration(
path: "terminal.insertText",
startedAt: typingTimingStart,
event: NSApp.currentEvent,
extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)"
)
}
#endif
// Get the string value
var chars = ""
switch string {

View file

@ -1288,6 +1288,17 @@ struct BrowserPanelView: View {
}
private func refreshSuggestions() {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
defer {
let trimmedQuery = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
CmuxTypingTiming.logDuration(
path: "browser.omnibar.refreshSuggestions",
startedAt: typingTimingStart,
extra: "focused=\(addressBarFocused ? 1 : 0) queryLen=\(trimmedQuery.utf8.count) suggestionCount=\(omnibarState.suggestions.count)"
)
}
#endif
suggestionTask?.cancel()
suggestionTask = nil
isLoadingRemoteSuggestions = false
@ -2702,6 +2713,18 @@ private final class OmnibarNativeTextField: NSTextField {
}
override func keyDown(with event: NSEvent) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var route = "super"
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.keyDown",
startedAt: typingTimingStart,
event: event,
extra: "route=\(route)"
)
}
#endif
// Reset shift-click anchor on any keyboard input so that a subsequent
// Shift+click uses the post-keyboard selection as its anchor, not a
// stale value from a prior mouse interaction.
@ -2711,20 +2734,46 @@ private final class OmnibarNativeTextField: NSTextField {
return
}
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
#if DEBUG
route = "custom"
#endif
return
}
super.keyDown(with: event)
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var handled = false
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.performKeyEquivalent",
startedAt: typingTimingStart,
event: event,
extra: "handled=\(handled ? 1 : 0)"
)
}
#endif
shiftClickAnchor = nil
if (currentEditor() as? NSTextView)?.hasMarkedText() == true {
return super.performKeyEquivalent(with: event)
let result = super.performKeyEquivalent(with: event)
#if DEBUG
handled = result
#endif
return result
}
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
#if DEBUG
handled = true
#endif
return true
}
return super.performKeyEquivalent(with: event)
let result = super.performKeyEquivalent(with: event)
#if DEBUG
handled = result
#endif
return result
}
}
@ -2947,6 +2996,17 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
func controlTextDidChange(_ obj: Notification) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.controlTextDidChange",
startedAt: typingTimingStart,
event: NSApp.currentEvent,
extra: "programmatic=\(isProgrammaticMutation ? 1 : 0)"
)
}
#endif
guard !isProgrammaticMutation else { return }
guard let field = obj.object as? NSTextField else { return }
let editor = field.currentEditor() as? NSTextView
@ -2960,36 +3020,69 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var handled = false
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.doCommandBy",
startedAt: typingTimingStart,
event: NSApp.currentEvent,
extra: "handled=\(handled ? 1 : 0) selector=\(NSStringFromSelector(commandSelector))"
)
}
#endif
switch commandSelector {
case #selector(NSResponder.moveDown(_:)):
parent.onMoveSelection(+1)
#if DEBUG
handled = true
#endif
return true
case #selector(NSResponder.moveUp(_:)):
parent.onMoveSelection(-1)
#if DEBUG
handled = true
#endif
return true
case #selector(NSResponder.insertNewline(_:)):
let currentFlags = NSApp.currentEvent?.modifierFlags ?? []
guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false }
parent.onSubmit()
#if DEBUG
handled = true
#endif
return true
case #selector(NSResponder.cancelOperation(_:)):
parent.onEscape()
#if DEBUG
handled = true
#endif
return true
case #selector(NSResponder.moveRight(_:)), #selector(NSResponder.moveToEndOfLine(_:)):
if parent.inlineCompletion != nil {
parent.onAcceptInlineCompletion()
#if DEBUG
handled = true
#endif
return true
}
return false
case #selector(NSResponder.insertTab(_:)):
if parent.inlineCompletion != nil {
parent.onAcceptInlineCompletion()
#if DEBUG
handled = true
#endif
return true
}
return false
case #selector(NSResponder.deleteBackward(_:)):
if suffixSelectionMatchesInline(textView, inline: parent.inlineCompletion) {
parent.onDeleteBackwardWithInlineSelection()
#if DEBUG
handled = true
#endif
return true
}
return false
@ -3057,6 +3150,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
func handleKeyEvent(_ event: NSEvent, editor: NSTextView?) -> Bool {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var handled = false
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.handleKeyEvent",
startedAt: typingTimingStart,
event: event,
extra: "handled=\(handled ? 1 : 0)"
)
}
#endif
let keyCode = event.keyCode
let modifiers = event.modifierFlags.intersection([.command, .control, .shift, .option, .function])
let lowered = event.charactersIgnoringModifiers?.lowercased() ?? ""
@ -3065,16 +3170,25 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
// Cmd/Ctrl+N and Cmd/Ctrl+P should repeat while held.
if hasCommandOrControl, lowered == "n" {
parent.onMoveSelection(+1)
#if DEBUG
handled = true
#endif
return true
}
if hasCommandOrControl, lowered == "p" {
parent.onMoveSelection(-1)
#if DEBUG
handled = true
#endif
return true
}
// Shift+Delete removes the selected history suggestion when possible.
if modifiers.contains(.shift), (keyCode == 51 || keyCode == 117) {
parent.onDeleteSelectedSuggestion()
#if DEBUG
handled = true
#endif
return true
}
@ -3082,30 +3196,51 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
case 36, 76: // Return / keypad Enter
guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false }
parent.onSubmit()
#if DEBUG
handled = true
#endif
return true
case 53: // Escape
parent.onEscape()
#if DEBUG
handled = true
#endif
return true
case 125: // Down
parent.onMoveSelection(+1)
#if DEBUG
handled = true
#endif
return true
case 126: // Up
parent.onMoveSelection(-1)
#if DEBUG
handled = true
#endif
return true
case 124, 119: // Right arrow / End
if parent.inlineCompletion != nil {
parent.onAcceptInlineCompletion()
#if DEBUG
handled = true
#endif
return true
}
case 48: // Tab
if parent.inlineCompletion != nil {
parent.onAcceptInlineCompletion()
#if DEBUG
handled = true
#endif
return true
}
case 51: // Backspace
if let inline = parent.inlineCompletion,
(suffixSelectionMatchesInline(editor, inline: inline) || selectionIsTypedPrefixBoundary(editor, inline: inline)) {
parent.onDeleteBackwardWithInlineSelection()
#if DEBUG
handled = true
#endif
return true
}
default:

View file

@ -114,6 +114,18 @@ final class CmuxWebView: WKWebView {
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var handled = false
defer {
CmuxTypingTiming.logDuration(
path: "browser.web.performKeyEquivalent",
startedAt: typingTimingStart,
event: event,
extra: "handled=\(handled ? 1 : 0)"
)
}
#endif
if event.keyCode == 36 || event.keyCode == 76 {
// Always bypass app/menu key-equivalent routing for Return/Enter so WebKit
// receives the keyDown path used by form submission handlers.
@ -124,28 +136,57 @@ final class CmuxWebView: WKWebView {
// Menu/app shortcut routing is only needed for Command equivalents
// (New Tab, Close Tab, tab switching, split commands, etc).
guard flags.contains(.command) else {
return super.performKeyEquivalent(with: event)
let result = super.performKeyEquivalent(with: event)
#if DEBUG
handled = result
#endif
return result
}
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
#if DEBUG
handled = true
#endif
return true
}
// Handle app-level shortcuts that are not menu-backed (for example split commands).
// Without this, WebKit can consume Cmd-based shortcuts before the app monitor sees them.
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
#if DEBUG
handled = true
#endif
return true
}
return super.performKeyEquivalent(with: event)
let result = super.performKeyEquivalent(with: event)
#if DEBUG
handled = result
#endif
return result
}
override func keyDown(with event: NSEvent) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var route = "super"
defer {
CmuxTypingTiming.logDuration(
path: "browser.web.keyDown",
startedAt: typingTimingStart,
event: event,
extra: "route=\(route)"
)
}
#endif
// Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent.
// Route them through the same app-level shortcut handler as a fallback.
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
#if DEBUG
route = "appShortcut"
#endif
return
}

View file

@ -570,6 +570,7 @@ class TabManager: ObservableObject {
@Published var tabs: [Workspace] = []
@Published private(set) var isWorkspaceCycleHot: Bool = false
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
@Published private(set) var debugPinnedWorkspaceLoadIds: Set<UUID> = []
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
@ -1059,10 +1060,25 @@ class TabManager: ObservableObject {
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
}
func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
guard !workspaceIds.isEmpty else { return }
debugPinnedWorkspaceLoadIds.formUnion(workspaceIds)
}
func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
guard !workspaceIds.isEmpty else { return }
debugPinnedWorkspaceLoadIds.subtract(workspaceIds)
}
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
pendingBackgroundWorkspaceLoadIds = pruned
if pruned != pendingBackgroundWorkspaceLoadIds {
pendingBackgroundWorkspaceLoadIds = pruned
}
let retained = debugPinnedWorkspaceLoadIds.intersection(existingIds)
if retained != debugPinnedWorkspaceLoadIds {
debugPinnedWorkspaceLoadIds = retained
}
}
// Keep addTab as convenience alias

View file

@ -129,44 +129,69 @@ final class WindowTerminalHostView: NSView {
clearActiveDividerCursor(restoreArrow: true)
}
// PERF: hitTest is called on EVERY event including keyboard. Keep non-pointer
// path minimal. Do not add work outside the isPointerEvent guard.
override func hitTest(_ point: NSPoint) -> NSView? {
updateDividerCursor(at: point)
if shouldPassThroughToSidebarResizer(at: point) {
return nil
let currentEvent = NSApp.currentEvent
let isPointerEvent: Bool
switch currentEvent?.type {
case .mouseMoved, .mouseEntered, .mouseExited,
.leftMouseDown, .leftMouseUp, .leftMouseDragged,
.rightMouseDown, .rightMouseUp, .rightMouseDragged,
.otherMouseDown, .otherMouseUp, .otherMouseDragged,
.scrollWheel, .cursorUpdate:
isPointerEvent = true
default:
isPointerEvent = false
}
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
if isPointerEvent {
if shouldPassThroughToSidebarResizer(at: point) {
clearActiveDividerCursor(restoreArrow: false)
return nil
}
let dragPasteboardTypes = NSPasteboard(name: .drag).types
let eventType = NSApp.currentEvent?.type
let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
pasteboardTypes: dragPasteboardTypes,
eventType: eventType
)
if shouldPassThrough {
// Compute divider hit once and reuse for both cursor update and pass-through.
if let kind = splitDividerCursorKind(at: point) {
activeDividerCursorKind = kind
kind.cursor.set()
return nil
}
clearActiveDividerCursor(restoreArrow: true)
let dragPasteboardTypes = NSPasteboard(name: .drag).types
let eventType = currentEvent?.type
let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
pasteboardTypes: dragPasteboardTypes,
eventType: eventType
)
if shouldPassThrough {
#if DEBUG
logDragRouteDecision(
passThrough: true,
eventType: eventType,
pasteboardTypes: dragPasteboardTypes,
hitView: nil
)
#endif
return nil
}
let hitView = super.hitTest(point)
#if DEBUG
logDragRouteDecision(
passThrough: true,
eventType: eventType,
passThrough: false,
eventType: currentEvent?.type,
pasteboardTypes: dragPasteboardTypes,
hitView: nil
hitView: hitView
)
#endif
return nil
return hitView === self ? nil : hitView
}
// Non-pointer event: skip divider/drag routing, just do standard hit testing.
let hitView = super.hitTest(point)
#if DEBUG
logDragRouteDecision(
passThrough: false,
eventType: eventType,
pasteboardTypes: dragPasteboardTypes,
hitView: hitView
)
#endif
return hitView === self ? nil : hitView
}

View file

@ -933,6 +933,7 @@ final class Workspace: Identifiable, ObservableObject {
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
private var isProgrammaticSplit = false
private var debugStressPreloadSelectionDepth = 0
/// Last terminal panel used as an inheritance source (typically last focused terminal).
private var lastTerminalConfigInheritancePanelId: UUID?
@ -968,6 +969,10 @@ final class Workspace: Identifiable, ObservableObject {
return panel
}
func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? {
bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) }
}
enum FocusPanelTrigger {
case standard
case terminalFirstResponder
@ -1493,6 +1498,36 @@ final class Workspace: Identifiable, ObservableObject {
}
}
@discardableResult
func preloadTerminalPanelForDebugStress(
tabId: TabID,
inPane paneId: PaneID
) -> TerminalPanel? {
guard let panelId = panelIdFromSurfaceId(tabId),
let terminalPanel = panels[panelId] as? TerminalPanel else {
return nil
}
debugStressPreloadSelectionDepth += 1
defer { debugStressPreloadSelectionDepth -= 1 }
let isVisibleSelection =
bonsplitController.focusedPaneId == paneId &&
bonsplitController.selectedTab(inPane: paneId)?.id == tabId &&
terminalPanel.hostedView.window != nil &&
terminalPanel.hostedView.superview != nil
if isVisibleSelection {
terminalPanel.requestViewReattach()
scheduleTerminalGeometryReconcile()
}
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
return terminalPanel
}
func scheduleDebugStressTerminalGeometryReconcile() {
scheduleTerminalGeometryReconcile()
}
func hasLoadedTerminalSurface() -> Bool {
let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel }
guard !terminalPanels.isEmpty else { return true }
@ -4132,23 +4167,37 @@ extension Workspace: BonsplitDelegate {
return
}
// Focus the selected panel
guard let panelId = panelIdFromSurfaceId(selectedTabId),
let panel = panels[panelId] else {
// Focus the selected panel, but keep the previously focused terminal active while a
// newly created split terminal is still unattached.
guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else {
return
}
let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId
guard let panel = panels[effectiveFocusedPanelId] else {
return
}
if debugStressPreloadSelectionDepth > 0 {
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.requestViewReattach()
scheduleTerminalGeometryReconcile()
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
return
}
if shouldTreatCurrentEventAsExplicitFocusIntent() {
markExplicitFocusIntent(on: panelId)
markExplicitFocusIntent(on: effectiveFocusedPanelId)
}
let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation()
panel.prepareFocusIntentForActivation(activationIntent)
let panelId = effectiveFocusedPanelId
syncPinnedStateForTab(selectedTabId, panelId: panelId)
syncUnreadBadgeStateForPanel(panelId)
syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId)
syncUnreadBadgeStateForPanel(selectedPanelId)
// Unfocus all other panels
for (id, p) in panels where id != panelId {
for (id, p) in panels where id != effectiveFocusedPanelId {
p.unfocus()
}

View file

@ -322,6 +322,15 @@ struct cmuxApp: App {
appDelegate.openDebugColorComparisonWorkspaces(nil)
}
Button(
String(
localized: "debug.menu.openStressWorkspacesWithLoadedSurfaces",
defaultValue: "Open Stress Workspaces and Load All Terminals"
)
) {
appDelegate.openDebugStressWorkspacesWithLoadedSurfaces(nil)
}
Divider()
Menu("Debug Windows") {
Button("Debug Window Controls…") {
@ -462,6 +471,10 @@ struct cmuxApp: App {
closeTabOrWindow()
}
Menu(String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace")) {
workspaceCommandMenuContent(manager: activeTabManager)
}
Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) {
_ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
}
@ -819,6 +832,199 @@ struct cmuxApp: App {
_ = tabManager.createBrowserSplit(direction: direction)
}
private func selectedWorkspaceIndex(in manager: TabManager, workspaceId: UUID) -> Int? {
manager.tabs.firstIndex { $0.id == workspaceId }
}
private func selectedWorkspaceWindowMoveTargets(in manager: TabManager) -> [AppDelegate.WindowMoveTarget] {
let referenceWindowId = AppDelegate.shared?.windowId(for: manager)
return AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? []
}
private func toggleSelectedWorkspacePinned(in manager: TabManager) {
guard let workspace = manager.selectedWorkspace else { return }
manager.setPinned(workspace, pinned: !workspace.isPinned)
}
private func clearSelectedWorkspaceCustomName(in manager: TabManager) {
guard let workspace = manager.selectedWorkspace else { return }
manager.clearCustomTitle(tabId: workspace.id)
}
private func moveSelectedWorkspace(in manager: TabManager, by delta: Int) {
guard let workspace = manager.selectedWorkspace,
let currentIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
let targetIndex = currentIndex + delta
guard targetIndex >= 0, targetIndex < manager.tabs.count else { return }
_ = manager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex)
manager.selectWorkspace(workspace)
}
private func moveSelectedWorkspaceToTop(in manager: TabManager) {
guard let workspace = manager.selectedWorkspace else { return }
manager.moveTabsToTop([workspace.id])
manager.selectWorkspace(workspace)
}
private func moveSelectedWorkspace(in manager: TabManager, toWindow windowId: UUID) {
guard let workspace = manager.selectedWorkspace else { return }
_ = AppDelegate.shared?.moveWorkspaceToWindow(workspaceId: workspace.id, windowId: windowId, focus: true)
}
private func moveSelectedWorkspaceToNewWindow(in manager: TabManager) {
guard let workspace = manager.selectedWorkspace else { return }
_ = AppDelegate.shared?.moveWorkspaceToNewWindow(workspaceId: workspace.id, focus: true)
}
private func closeWorkspaceIds(
_ workspaceIds: [UUID],
in manager: TabManager,
allowPinned: Bool
) {
for workspaceId in workspaceIds {
guard let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue }
guard allowPinned || !workspace.isPinned else { continue }
manager.closeWorkspaceWithConfirmation(workspace)
}
}
private func closeOtherSelectedWorkspacePeers(in manager: TabManager) {
guard let workspace = manager.selectedWorkspace else { return }
let workspaceIds = manager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id }
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false)
}
private func closeSelectedWorkspacesBelow(in manager: TabManager) {
guard let workspace = manager.selectedWorkspace,
let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
let workspaceIds = manager.tabs.suffix(from: anchorIndex + 1).map(\.id)
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false)
}
private func closeSelectedWorkspacesAbove(in manager: TabManager) {
guard let workspace = manager.selectedWorkspace,
let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
let workspaceIds = manager.tabs.prefix(upTo: anchorIndex).map(\.id)
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false)
}
private func selectedWorkspaceHasUnreadNotifications(in manager: TabManager) -> Bool {
guard let workspaceId = manager.selectedWorkspace?.id else { return false }
return notificationStore.notifications.contains { $0.tabId == workspaceId && !$0.isRead }
}
private func selectedWorkspaceHasReadNotifications(in manager: TabManager) -> Bool {
guard let workspaceId = manager.selectedWorkspace?.id else { return false }
return notificationStore.notifications.contains { $0.tabId == workspaceId && $0.isRead }
}
private func markSelectedWorkspaceRead(in manager: TabManager) {
guard let workspaceId = manager.selectedWorkspace?.id else { return }
notificationStore.markRead(forTabId: workspaceId)
}
private func markSelectedWorkspaceUnread(in manager: TabManager) {
guard let workspaceId = manager.selectedWorkspace?.id else { return }
notificationStore.markUnread(forTabId: workspaceId)
}
@ViewBuilder
private func workspaceCommandMenuContent(manager: TabManager) -> some View {
let workspace = manager.selectedWorkspace
let workspaceIndex = workspace.flatMap { selectedWorkspaceIndex(in: manager, workspaceId: $0.id) }
let windowMoveTargets = selectedWorkspaceWindowMoveTargets(in: manager)
Button(
workspace?.isPinned == true
? String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace")
: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace")
) {
toggleSelectedWorkspacePinned(in: manager)
}
.disabled(workspace == nil)
Button(String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…")) {
_ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette()
}
.disabled(workspace == nil)
if workspace?.hasCustomTitle == true {
Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) {
clearSelectedWorkspaceCustomName(in: manager)
}
}
Divider()
Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) {
moveSelectedWorkspace(in: manager, by: -1)
}
.disabled(workspaceIndex == nil || workspaceIndex == 0)
Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) {
moveSelectedWorkspace(in: manager, by: 1)
}
.disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1)
Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) {
moveSelectedWorkspaceToTop(in: manager)
}
.disabled(workspace == nil || workspaceIndex == 0)
Menu(String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window")) {
Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) {
moveSelectedWorkspaceToNewWindow(in: manager)
}
.disabled(workspace == nil)
if !windowMoveTargets.isEmpty {
Divider()
}
ForEach(windowMoveTargets) { target in
Button(target.label) {
moveSelectedWorkspace(in: manager, toWindow: target.windowId)
}
.disabled(target.isCurrentWindow || workspace == nil)
}
}
.disabled(workspace == nil)
Divider()
Button(String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace")) {
manager.closeCurrentWorkspaceWithConfirmation()
}
.disabled(workspace == nil)
Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) {
closeOtherSelectedWorkspacePeers(in: manager)
}
.disabled(workspace == nil || manager.tabs.count <= 1)
Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) {
closeSelectedWorkspacesBelow(in: manager)
}
.disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1)
Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) {
closeSelectedWorkspacesAbove(in: manager)
}
.disabled(workspaceIndex == nil || workspaceIndex == 0)
Divider()
Button(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")) {
markSelectedWorkspaceRead(in: manager)
}
.disabled(!selectedWorkspaceHasUnreadNotifications(in: manager))
Button(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")) {
markSelectedWorkspaceUnread(in: manager)
}
.disabled(!selectedWorkspaceHasReadNotifications(in: manager))
}
@ViewBuilder
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
if let key = shortcut.keyEquivalent {