diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 5ab1a9f..154ae4d 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -22,6 +22,17 @@ class TabItem { var badgeState: BadgeState = .none /// Set during restore — suppresses completedUnseen until hook.session-start fires. var suppressUnseen: Bool = false + /// Deferred shell-start parameters. Set when the tab is created lazily + /// the process only spawns when the tab is first shown. + var pendingStart: PendingStart? + + struct PendingStart { + let workingDirectory: String + let envVars: [String: String] + let initialInput: String? + let tmuxSession: String? + let forkSession: Bool + } var isClaude: Bool { kind == .claude } var isCodex: Bool { kind == .codex } @@ -577,10 +588,14 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { workspace.name = snapshot.name workspace.defaultArgs = snapshot.defaultArgs workspace.defaultCodexArgs = snapshot.defaultCodexArgs + // When lazy restore is on, only the selected tab's process starts + // otherwise all tabs start eagerly. + let lazy = UserDefaults.standard.bool(forKey: "lazySessionRestore") for ts in snapshot.tabs { createTabInWorkspace(workspace, kind: ts.kind, name: ts.name, sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, - tmuxSessionToResume: ts.tmuxSessionName) + tmuxSessionToResume: ts.tmuxSessionName, + deferStart: lazy) } workspace.selectedTabIndex = min(snapshot.selectedTabIndex, workspace.tabs.count - 1) } @@ -754,7 +769,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { createTabInWorkspace(workspace, kind: isClaude ? .claude : .terminal, name: name, sessionIdToResume: sessionIdToResume, forkSession: forkSession, tmuxSessionToResume: tmuxSessionToResume, extraArgs: extraArgs) } - func createTabInWorkspace(_ workspace: WorkspaceItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { + func createTabInWorkspace(_ workspace: WorkspaceItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil, deferStart: Bool = false) { let surface = TerminalSurface() let tabName: String if let name = name { @@ -823,14 +838,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { initialInput = nil } - DiagnosticLog.shared.log("surface", "createTab: \(kind.rawValue) surfaceId=\(surface.surfaceId)") - - surface.startShell( - workingDirectory: workspace.path, - envVars: envVars, - initialInput: initialInput, - tmuxSession: tmuxSessionToResume - ) + DiagnosticLog.shared.log("surface", "createTab: \(kind.rawValue) surfaceId=\(surface.surfaceId) deferred=\(deferStart)") surface.onProcessExit = { [weak self] exitedSurface in DispatchQueue.main.async { @@ -838,11 +846,83 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } + if deferStart { + tab.pendingStart = TabItem.PendingStart( + workingDirectory: workspace.path, + envVars: envVars, + initialInput: initialInput, + tmuxSession: tmuxSessionToResume, + forkSession: forkSession + ) + + if kind == .terminal { + surface.tmuxSessionName = tmuxSessionToResume + } + } else { + surface.startShell( + workingDirectory: workspace.path, + envVars: envVars, + initialInput: initialInput, + tmuxSession: tmuxSessionToResume + ) + if kind == .codex && (tab.sessionId == nil || forkSession) { + scheduleCodexSessionDiscovery(forSurfaceId: tab.id, workspacePath: workspace.path) + } + } + workspace.tabs.append(tab) tabCreationOrder.append(tab.id) + } - if kind == .codex && (tab.sessionId == nil || forkSession) { - scheduleCodexSessionDiscovery(forSurfaceId: tab.id, workspacePath: workspace.path) + /// Start the shell for a lazily-created tab the first time it is shown. + /// - Parameter refreshSidebar: when true, schedules a sidebar rebuild so the + /// tab's dot fills in. The eager bulk-start path passes + /// false and rebuilds once at the end instead. + func startPendingShellIfNeeded(_ tab: TabItem, refreshSidebar: Bool = true) { + guard let pending = tab.pendingStart else { return } + // A tab closed before its deferred start ran must never spawn a process. + // The progressive-restore walk (and any queued caller) can still hold a + // reference after closeTabById/closeWorkspace removed it from its + // workspace, so bail if it's no longer present anywhere. + guard workspaces.contains(where: { $0.tabs.contains(where: { $0.id == tab.id }) }) else { + tab.pendingStart = nil + return + } + tab.pendingStart = nil + + DiagnosticLog.shared.log("surface", + "lazy start: \(tab.kind.rawValue) surfaceId=\(tab.surface.surfaceId) cwd=\(pending.workingDirectory)") + + tab.surface.startShell( + workingDirectory: pending.workingDirectory, + envVars: pending.envVars, + initialInput: pending.initialInput, + tmuxSession: pending.tmuxSession + ) + + if tab.kind == .codex && (tab.sessionId == nil || pending.forkSession) { + scheduleCodexSessionDiscovery(forSurfaceId: tab.id, workspacePath: pending.workingDirectory) + } + + // The tab is no longer pending — refresh the sidebar so its dot fills in + // (hollow → solid). Async to avoid re-entrancy when called mid-rebuild. + if refreshSidebar { + DispatchQueue.main.async { [weak self] in + self?.rebuildSidebar() + } + } + } + + /// Eager-restore helper: start every still-pending tab one at a time with a + /// small delay, so a full session restore doesn't spawn N processes at once. + private func startPendingTabsProgressively(_ remaining: [TabItem]) { + guard let tab = remaining.first else { + rebuildSidebar() + return + } + startPendingShellIfNeeded(tab, refreshSidebar: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.startPendingTabsProgressively(Array(remaining.dropFirst())) } } @@ -1068,6 +1148,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func showTab(_ tab: TabItem) { hideEmptyState() + + startPendingShellIfNeeded(tab) + let view = tab.surface.view // Remove the previous surface view from the hierarchy. @@ -1247,7 +1330,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tabInfos.append(ProcessMonitor.TabInfo( surfaceId: tab.id, kind: tab.kind, name: tab.name, workspacePath: workspace.path)) - if tab.kind == .codex { + // Skip deferred codex tabs: their process was never spawned, + // so a stale on-disk .jsonl must not drive the badge to + // .codexThinking on a tab that isn't actually running. + if tab.kind == .codex, tab.pendingStart == nil { codexTargets.append(CodexBadgePollTarget( surfaceId: tab.id, workspacePath: workspace.path, @@ -1393,6 +1479,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if let ti = workspace.tabs.firstIndex(where: { $0.id == surfaceId }) { let tab = workspace.tabs[ti] + // A deferred tab closed before it ever started must not leave a + // pending start queued — the progressive-restore walk could + // otherwise reach it and spawn an orphaned process. + tab.pendingStart = nil + // Terminal tabs: restart shell instead of removing the tab. // Reconnects to the tmux session if it still exists, otherwise // starts a fresh shell. Rate-limited to prevent crash loops. @@ -1694,40 +1785,29 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return nil } - // Phase 1: Create the active workspace's active tab immediately so the user - // sees a working terminal right away. Collect remaining tabs for Phase 2. - var pending: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)] = [] - for (i, ps) in workspaceStates.enumerated() { + for ps in workspaceStates { let workspace = WorkspaceItem(path: ps.path) workspace.name = ps.name workspace.defaultArgs = ps.defaultArgs workspace.defaultCodexArgs = ps.defaultCodexArgs - let selTab = min(max(ps.selectedTabIndex, 0), max(ps.tabs.count - 1, 0)) - - for (t, ts) in ps.tabs.enumerated() { + for ts in ps.tabs { var restoredTab = ts if restoredTab.kind == .codex, restoredTab.sessionId == nil { restoredTab.sessionId = recoverCodexSessionId(for: ps.path, tabName: restoredTab.name) } - - if i == selectedIdx && t == selTab { - // Create the active tab's surface synchronously - createTabInWorkspace(workspace, kind: restoredTab.kind, name: restoredTab.name, - sessionIdToResume: restoredTab.kind.isAgent ? restoredTab.sessionId : nil, - tmuxSessionToResume: restoredTab.tmuxSessionName) - } else { - pending.append((workspace: workspace, tab: restoredTab, originalIndex: t)) - } + createTabInWorkspace(workspace, kind: restoredTab.kind, name: restoredTab.name, + sessionIdToResume: restoredTab.kind.isAgent ? restoredTab.sessionId : nil, + tmuxSessionToResume: restoredTab.tmuxSessionName, + deferStart: true) } - workspace.selectedTabIndex = selTab + workspace.selectedTabIndex = min(max(ps.selectedTabIndex, 0), max(ps.tabs.count - 1, 0)) workspaces.append(workspace) } - // Keep isRestoring = true until Phase 2 finishes, so selectWorkspace - // won't clamp selectedTabIndex before all tabs are inserted. + isRestoring = false // Restore sidebar groups restoreSidebarGroups(from: state) @@ -1736,9 +1816,23 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if selectedIdx >= 0 && selectedIdx < workspaces.count { selectWorkspace(at: selectedIdx) } + rebuildTabBar() + saveState() + + // Start autosave now that all tabs are restored. + SessionManager.shared.startAutosave { [weak self] in + self?.captureState() ?? DeckardState() + } + + let lazy = UserDefaults.standard.bool(forKey: "lazySessionRestore") + DiagnosticLog.shared.log("restore", + "restored \(workspaces.count) workspaces, \(workspaces.reduce(0) { $0 + $1.tabs.count }) tabs (lazy=\(lazy))") - // Phase 2: Create remaining surfaces progressively with small delays for UX. - createTabsProgressively(pending) + + if !lazy { + let pendingTabs = workspaces.flatMap { $0.tabs.filter { $0.pendingStart != nil } } + startPendingTabsProgressively(pendingTabs) + } } private func restoreSidebarGroups(from state: DeckardState) { @@ -1788,57 +1882,6 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // If no saved order, ensureSidebarOrder() will build one from workspaces } - private func createTabsProgressively(_ remaining: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)]) { - guard let first = remaining.first else { - // All tabs created — rebuild UI to reflect the full state - isRestoring = false - rebuildSidebar() - rebuildTabBar() - saveState() - - // Start autosave now that restore is complete — autosaving - // during progressive restore would lose tabs on crash. - SessionManager.shared.startAutosave { [weak self] in - self?.captureState() ?? DeckardState() - } - - // Dump tab creation order -> PID mapping for diagnostics - let mapping = tabCreationOrder.enumerated().map { (i, id) -> String in - var label = "?" - for workspace in workspaces { - if let tab = workspace.tabs.first(where: { $0.id == id }) { - label = "\(tab.kind.rawValue.prefix(1).uppercased()):\(tab.name)@\(workspace.name)" - break - } - } - return " [\(i)] \(label)" - }.joined(separator: "\n") - DiagnosticLog.shared.log("processmon", "tabCreationOrder after restore (\(tabCreationOrder.count) tabs):\n\(mapping)") - - return - } - - let ts = first.tab - let workspace = first.workspace - let insertAt = first.originalIndex - - // Create the tab (appends to workspace.tabs) - createTabInWorkspace(workspace, kind: ts.kind, name: ts.name, - sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, - tmuxSessionToResume: ts.tmuxSessionName) - - // Move it from the end to its original position - if insertAt < workspace.tabs.count - 1 { - let tab = workspace.tabs.removeLast() - workspace.tabs.insert(tab, at: min(insertAt, workspace.tabs.count)) - } - - // Small delay between tab creations for smoother UX during restore. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [self] in - self.createTabsProgressively(Array(remaining.dropFirst())) - } - } - // MARK: - Theme @objc private func vibrancyDidChange() { diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index 580faf7..432eb8d 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -156,6 +156,20 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie tabConfigHelp.textColor = .secondaryLabelColor grid.addRow(with: [NSGridCell.emptyContentView, tabConfigHelp]) + let lazyLabel = NSTextField(labelWithString: "Startup:") + lazyLabel.alignment = .right + + let lazyCheck = NSButton(checkboxWithTitle: "Restore sessions lazily", + target: self, action: #selector(lazyRestoreToggled(_:))) + lazyCheck.state = UserDefaults.standard.bool(forKey: "lazySessionRestore") ? .on : .off + grid.addRow(with: [lazyLabel, lazyCheck]) + + let lazyHelp = NSTextField(wrappingLabelWithString: + "Don't launch a session until its tab is first opened. Not-yet-loaded sessions show a hollow dot in the sidebar.") + lazyHelp.font = .systemFont(ofSize: 11) + lazyHelp.textColor = .secondaryLabelColor + grid.addRow(with: [NSGridCell.emptyContentView, lazyHelp]) + pane.addSubview(grid) NSLayoutConstraint.activate([ grid.topAnchor.constraint(equalTo: pane.topAnchor, constant: 20), @@ -295,6 +309,10 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie UserDefaults.standard.set(sender.state == .on, forKey: "promptForSessionArgs") } + @objc private func lazyRestoreToggled(_ sender: NSButton) { + UserDefaults.standard.set(sender.state == .on, forKey: "lazySessionRestore") + } + @objc private func codexPerSessionArgsToggled(_ sender: NSButton) { UserDefaults.standard.set(sender.state == .on, forKey: "promptForCodexSessionArgs") } diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index f7498c4..d872858 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -86,7 +86,7 @@ extension DeckardWindowController { target: self, action: #selector(workspaceRowClicked(_:))) row.shortcutBadge = shortcutForWorkspaceIndex[pi] row.badgeInfos = workspace.tabs.filter { $0.badgeState != .none }.map { tab in - (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id]) + (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id], pending: tab.pendingStart != nil) } row.onRename = { [weak self] newName in guard let self = self else { return } @@ -126,11 +126,11 @@ extension DeckardWindowController { } // Aggregate badge infos from all workspaces in the group - var aggregatedBadges: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] + var aggregatedBadges: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?, pending: Bool)] = [] for pid in group.workspaceIds { if let workspace = workspaceById(pid) { for tab in workspace.tabs where tab.badgeState != .none { - aggregatedBadges.append((state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id])) + aggregatedBadges.append((state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id], pending: tab.pendingStart != nil)) } } } @@ -161,7 +161,7 @@ extension DeckardWindowController { row.indent = 16 row.shortcutBadge = shortcutForWorkspaceIndex[pi] row.badgeInfos = workspace.tabs.filter { $0.badgeState != .none }.map { tab in - (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id]) + (state: tab.badgeState, name: tab.name, activity: self.terminalActivity[tab.id], pending: tab.pendingStart != nil) } row.onRename = { [weak self] newName in guard let self = self else { return } diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 586387b..611fa0d 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -10,7 +10,8 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { didSet { needsDisplay = true } } /// Badge info for each Claude tab in this workspace, shown as right-aligned dots. - var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] { + /// `pending marks a not-yet-loaded (lazy) tab — drawn as a hollow outline dot. + var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?, pending: Bool)] = [] { didSet { updateBadgeDots() } } var onRename: ((String) -> Void)? @@ -112,10 +113,13 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { for info in badgeInfos where info.state != .none { let dot = BadgeShapeView( shape: Self.shapeForBadge(info.state), - color: Self.colorForBadge(info.state) + color: Self.colorForBadge(info.state), + filled: !info.pending ) - dot.toolTip = "\(info.name): \(Self.tooltipForBadge(info.state, activity: info.activity))" - if SettingsWindowController.isBadgeAnimated(info.state) { + let suffix = info.pending ? " (not loaded)" : "" + dot.toolTip = "\(info.name): \(Self.tooltipForBadge(info.state, activity: info.activity))\(suffix)" + // Pending tabs aren't actually running, so never pulse them. + if !info.pending && SettingsWindowController.isBadgeAnimated(info.state) { Self.addPulseAnimation(to: dot) } badgeContainer.addArrangedSubview(dot) @@ -308,7 +312,8 @@ class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { } /// Badge info aggregated from all workspaces in the group. - var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?)] = [] { + /// `pending` marks a not-yet-loaded (lazy) tab — drawn as a hollow outline dot. + var badgeInfos: [(state: TabItem.BadgeState, name: String, activity: ProcessMonitor.ActivityInfo?, pending: Bool)] = [] { didSet { updateBadgeDots() } } @@ -502,10 +507,12 @@ class SidebarGroupView: NSView, NSTextFieldDelegate, NSDraggingSource { for info in badgeInfos where info.state != .none { let dot = BadgeShapeView( shape: VerticalTabRowView.shapeForBadge(info.state), - color: VerticalTabRowView.colorForBadge(info.state) + color: VerticalTabRowView.colorForBadge(info.state), + filled: !info.pending ) - dot.toolTip = "\(info.name): \(VerticalTabRowView.tooltipForBadge(info.state, activity: info.activity))" - if SettingsWindowController.isBadgeAnimated(info.state) { + let suffix = info.pending ? " (not loaded)" : "" + dot.toolTip = "\(info.name): \(VerticalTabRowView.tooltipForBadge(info.state, activity: info.activity))\(suffix)" + if !info.pending && SettingsWindowController.isBadgeAnimated(info.state) { VerticalTabRowView.addPulseAnimation(to: dot) } badgeContainer.addArrangedSubview(dot) @@ -890,20 +897,23 @@ class BadgeShapeView: NSView { private var shape: TabItem.BadgeShape private var color: NSColor + /// When false, the dot is drawn as a stroked outline instead of filled, used to indicate a not-yet-loaded (lazy) session. + private var filled: Bool private var isPulseAnimationEnabled = false private var pulseTimer: Timer? private var pulseStartTime: TimeInterval = 0 - init(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { + init(shape: TabItem.BadgeShape, color: NSColor, filled: Bool = true, size: CGFloat = 7) { self.shape = shape self.color = color + self.filled = filled super.init(frame: NSRect(x: 0, y: 0, width: size, height: size)) translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ widthAnchor.constraint(equalToConstant: size), heightAnchor.constraint(equalToConstant: size), ]) - updateAppearance(shape: shape, color: color, size: size) + updateAppearance(shape: shape, color: color, filled: filled, size: size) } required init?(coder: NSCoder) { fatalError() } @@ -932,18 +942,29 @@ class BadgeShapeView: NSView { } } - func updateAppearance(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { + func updateAppearance(shape: TabItem.BadgeShape, color: NSColor, filled: Bool = true, size: CGFloat = 7) { self.shape = shape self.color = color + self.filled = filled needsDisplay = true } override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() - context.addPath(Self.path(for: shape, in: bounds)) - context.setFillColor(color.cgColor) - context.fillPath() + if filled { + context.addPath(Self.path(for: shape, in: bounds)) + context.setFillColor(color.cgColor) + context.fillPath() + } else { + // Hollow outline inset by half the line width so the stroke isn't clipped. + let lineWidth: CGFloat = 1.25 + let inset = bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2) + context.addPath(Self.path(for: shape, in: inset)) + context.setStrokeColor(color.cgColor) + context.setLineWidth(lineWidth) + context.strokePath() + } context.restoreGState() } diff --git a/Sources/Window/TabBarController.swift b/Sources/Window/TabBarController.swift index 41bd2c4..ea086f6 100644 --- a/Sources/Window/TabBarController.swift +++ b/Sources/Window/TabBarController.swift @@ -43,6 +43,7 @@ extension DeckardWindowController { kind: tab.kind, badgeState: tab.badgeState, activity: terminalActivity[tab.id], + pending: tab.pendingStart != nil, isSelected: isSelected, index: i, target: self, diff --git a/Sources/Window/TabBarViews.swift b/Sources/Window/TabBarViews.swift index c1e894b..cfdfb78 100644 --- a/Sources/Window/TabBarViews.swift +++ b/Sources/Window/TabBarViews.swift @@ -29,6 +29,7 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { init(displayTitle: String, editableName: String, kind: TabKind = .terminal, badgeState: TabItem.BadgeState = .none, activity: ProcessMonitor.ActivityInfo? = nil, + pending: Bool = false, isSelected: Bool, index: Int, target: AnyObject, clickAction: Selector) { self.index = index @@ -54,14 +55,16 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { if badgeState != .none { let dot = BadgeShapeView( shape: VerticalTabRowView.shapeForBadge(badgeState), - color: VerticalTabRowView.colorForBadge(badgeState) + color: VerticalTabRowView.colorForBadge(badgeState), + filled: !pending ) - dot.toolTip = VerticalTabRowView.tooltipForBadge(badgeState, activity: activity) + let suffix = pending ? " (not loaded)" : "" + dot.toolTip = VerticalTabRowView.tooltipForBadge(badgeState, activity: activity) + suffix addSubview(dot) NSLayoutConstraint.activate([ dot.centerYAnchor.constraint(equalTo: centerYAnchor), ]) - if SettingsWindowController.isBadgeAnimated(badgeState) { + if !pending && SettingsWindowController.isBadgeAnimated(badgeState) { VerticalTabRowView.addPulseAnimation(to: dot) } badgeDot = dot