From 98c30480f4b1f87dcd9ce1daa0e007c680a8f439 Mon Sep 17 00:00:00 2001 From: Evan Hoffman Date: Sat, 20 Jun 2026 13:21:14 -0400 Subject: [PATCH] feat: add app.restorePreviousSession toggle to disable startup session restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmux unconditionally restores the previous launch (windows, workspaces, working directories, and terminal content) on startup. There was no user-facing way to turn that off — only the undocumented CMUX_DISABLE_SESSION_RESTORE=1 env var, which is awkward for a GUI app. Add app.restorePreviousSession (default true, preserving current behavior). When false, the automatic startup restore is skipped and each launch opens a fresh workspace seeded by app.defaultWorkspacePath. The explicit "Restore Previous Launch" command is intentionally unaffected — it does not route through shouldAttemptRestore(). The gate lives in SessionRestorePolicy.shouldAttemptRestore(), alongside the existing CMUX_DISABLE_SESSION_RESTORE / automated-test gates, so all startup-restore suppression stays in one place. Wired through every surface per repo conventions: - AppCatalogSection.restorePreviousSession (DefaultsKey) - SessionRestorePolicy gate + UserDefaults reader (default true when unset) - cmux.json boolean mapping (CmuxSettingsJSONPathSupport) - JSON schema (web/data/cmux.schema.json) - Settings → App toggle row + settings-search entry - generated cmux.json template - localization (en + ja) in Localizable.xcstrings - docs/configuration.md - regression tests (SessionPersistenceTests, wired target) Claude-Session: https://claude.ai/code/session_01CNYr3XgjVDLsPNnPQxarn4 --- .github/swift-file-length-budget.tsv | 6 +-- .../CmuxSettings/Keys/AppCatalogSection.swift | 10 +++++ .../CuratedSettingEntry+Default.swift | 1 + .../CmuxSettingsUI/Sections/AppSection.swift | 17 +++++++- Resources/Localizable.xcstrings | 34 +++++++++++++++ Sources/CmuxSettingsJSONPathSupport.swift | 4 ++ ...rdShortcutSettingsFileStore+Template.swift | 1 + Sources/SessionPersistence.swift | 24 ++++++++++- cmuxTests/SessionPersistenceTests.swift | 43 +++++++++++++++++++ docs/configuration.md | 19 ++++++++ web/data/cmux.schema.json | 5 +++ 11 files changed, 159 insertions(+), 5 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index d556ea5a0504..4a501f40316d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -13,7 +13,7 @@ 7988 Sources/Panels/BrowserPanelView.swift 7355 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift -6317 cmuxTests/SessionPersistenceTests.swift +6360 cmuxTests/SessionPersistenceTests.swift 6220 cmuxTests/GhosttyConfigTests.swift 6183 Sources/TabManager.swift 6151 CLI/cmux_open.swift @@ -45,8 +45,8 @@ 2257 Sources/TerminalNotificationStore.swift 2257 Sources/TerminalWindowPortal.swift 2126 cmuxTests/CmuxConfigTests.swift +2104 Sources/SessionPersistence.swift 2091 cmuxTests/ShortcutAndCommandPaletteTests.swift -2082 Sources/SessionPersistence.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1940 Sources/KeyboardShortcutSettingsFileStore.swift 1869 cmuxTests/NotificationAndMenuBarTests.swift @@ -88,7 +88,7 @@ 1021 cmuxUITests/TerminalCmdClickUITests.swift 1009 cmuxTests/CmuxTopSnapshotScopeTests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift -959 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +974 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 951 Sources/App/TerminalDirectoryOpenSupport.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift index 8ed3499f2d45..48c4cc343423 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AppCatalogSection.swift @@ -56,6 +56,16 @@ public struct AppCatalogSection: SettingCatalogSection { userDefaultsKey: "defaultWorkspacePath" ) + /// When false, cmux does not automatically restore the previous launch's + /// windows, workspaces, and terminal content on startup; each launch opens a + /// fresh workspace instead. The explicit "Restore Previous Launch" command is + /// unaffected. Defaults to `true` (the historical always-restore behavior). + public let restorePreviousSession = DefaultsKey( + id: "app.restorePreviousSession", + defaultValue: true, + userDefaultsKey: "restorePreviousSession" + ) + public let presentationMode = DefaultsKey( id: "app.minimalMode", defaultValue: .standard, diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index f5513e611796..04caad333c40 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -34,6 +34,7 @@ extension Array where Element == CuratedSettingEntry { .init(section: .app, id: "new-workspace-placement", title: "New Workspace Placement", synonyms: "app.newWorkspacePlacement new tab insert position order top bottom end"), .init(section: .app, id: "workspace-inherit-working-directory", title: "Inherit Workspace Working Directory", synonyms: "app.workspaceInheritWorkingDirectory workspace cwd directory inherit current focused working-directory"), .init(section: .app, id: "default-workspace-path", title: "Default Workspace Directory", synonyms: "app.defaultWorkspacePath default working directory new workspace path cwd pinned root folder"), + .init(section: .app, id: "restore-previous-session", title: "Restore Previous Session", synonyms: "app.restorePreviousSession session restore reopen previous launch startup fresh workspace remember scrollback persist memory"), .init(section: .app, id: "minimal-mode", title: "Minimal Mode", synonyms: "app.minimalMode presentation compact chrome layout simple titlebar controls"), .init(section: .app, id: "keep-workspace-open", title: "Keep Workspace Open When Closing Last Surface", synonyms: "app.keepWorkspaceOpenWhenClosingLastSurface close last pane surface keep tab workspace"), .init(section: .app, id: "focus-pane-first-click", title: "Focus Pane on First Click", synonyms: "app.focusPaneOnFirstClick click to focus focus follows mouse first click mouse activation"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift index 877a1b76055b..5353751a7688 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -35,6 +35,7 @@ public struct AppSection: View { @State private var fileDrop: DefaultsValueModel @State private var preferredEditor: DefaultsValueModel @State private var defaultWorkspacePath: DefaultsValueModel + @State private var restorePreviousSession: DefaultsValueModel @State private var openSupported: DefaultsValueModel @State private var openMarkdown: DefaultsValueModel @State private var markdownFontSize: DefaultsValueModel @@ -82,6 +83,7 @@ public struct AppSection: View { _fileDrop = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.fileDropDefaultBehavior)) _preferredEditor = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.preferredEditor)) _defaultWorkspacePath = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.defaultWorkspacePath)) + _restorePreviousSession = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.restorePreviousSession)) _openSupported = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.openSupportedFilesInCmux)) _openMarkdown = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.openMarkdownInCmuxViewer)) _markdownFontSize = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.markdown.fontSize)) @@ -129,7 +131,7 @@ public struct AppSection: View { mainCard } .task { - startSettingsObservation([language, appearance, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, fileDrop, preferredEditor, defaultWorkspacePath, openSupported, openMarkdown, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, soundName, soundCommand, customSoundFile, telemetry, confirmQuit, warnCloseTab, warnCloseX, hideCloseButton, renameSelects, paletteAllSurfaces]) + startSettingsObservation([language, appearance, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, fileDrop, preferredEditor, defaultWorkspacePath, restorePreviousSession, openSupported, openMarkdown, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, soundName, soundCommand, customSoundFile, telemetry, confirmQuit, warnCloseTab, warnCloseX, hideCloseButton, renameSelects, paletteAllSurfaces]) if languageAtAppear == nil { languageAtAppear = language.current }; if telemetryAtAppear == nil { telemetryAtAppear = telemetry.current } } } @@ -239,6 +241,19 @@ public struct AppSection: View { } SettingsCardDivider() + // Restore Previous Session + SettingsCardRow( + configurationReview: .json("app.restorePreviousSession"), + String(localized: "settings.app.restorePreviousSession", defaultValue: "Restore Previous Session"), + subtitle: String(localized: "settings.app.restorePreviousSession.subtitle", defaultValue: "Reopen the previous launch's windows, workspaces, and terminal content on startup. Turn off to start each launch with a fresh workspace. The Restore Previous Launch command still works.") + ) { + Toggle("", isOn: Binding(get: { restorePreviousSession.current }, set: { restorePreviousSession.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsRestorePreviousSessionToggle") + } + SettingsCardDivider() + // Minimal Mode SettingsCardRow( configurationReview: .json("app.minimalMode"), diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 038ea6a7e301..a1faf6c2c380 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -198186,6 +198186,40 @@ } } }, + "settings.app.restorePreviousSession": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore Previous Session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前回のセッションを復元" + } + } + } + }, + "settings.app.restorePreviousSession.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reopen the previous launch's windows, workspaces, and terminal content on startup. Turn off to start each launch with a fresh workspace. The Restore Previous Launch command still works." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動時に前回のウィンドウ、ワークスペース、ターミナルの内容を復元します。オフにすると、毎回新しいワークスペースで起動します。「前回の起動を復元」コマンドは引き続き使用できます。" + } + } + } + }, "settings.app.preferredEditor": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 56b579ba2127..717cf33b40ea 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -131,6 +131,10 @@ enum AppSettingsFileMapping { jsonKey: "commandPaletteSearchesAllSurfaces", defaultsKey: app.commandPaletteSearchesAllSurfaces.userDefaultsKey ), + .init( + jsonKey: "restorePreviousSession", + defaultsKey: app.restorePreviousSession.userDefaultsKey + ), ] static let stringSettings: [SettingsFileStringMapping] = [ diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index c360ce0353cf..82598021d863 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -66,6 +66,7 @@ extension CmuxSettingsFileStore { "forkConversationDefaultDestination": AgentConversationForkDefaultSettings.defaultDestination.rawValue, "workspaceInheritWorkingDirectory": SettingCatalog().app.workspaceInheritWorkingDirectory.defaultValue, "defaultWorkspacePath": SettingCatalog().app.defaultWorkspacePath.defaultValue, + "restorePreviousSession": SettingCatalog().app.restorePreviousSession.defaultValue, "minimalMode": false, "keepWorkspaceOpenWhenClosingLastSurface": !SettingCatalog().app.keepWorkspaceOpenWhenClosingLastSurface.defaultValue, "focusPaneOnFirstClick": PaneFirstClickFocusSettings.defaultEnabled, diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 7d7197a92971..b4d48fbadb1b 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -154,10 +154,32 @@ enum SessionRestorePolicy { return false } + /// UserDefaults key backing `app.restorePreviousSession` (mirrors + /// `AppCatalogSection.restorePreviousSession.userDefaultsKey`). Kept as a + /// literal so this file need not depend on CmuxSettings, matching the + /// `AgentSessionAutoResumeSettings` pattern. + static let restorePreviousSessionDefaultsKey = "restorePreviousSession" + + /// Reads `app.restorePreviousSession`, defaulting to `true` when unset so + /// the historical always-restore behavior is preserved. + static func restorePreviousSessionEnabled(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: restorePreviousSessionDefaultsKey) != nil else { + return true + } + return defaults.bool(forKey: restorePreviousSessionDefaultsKey) + } + static func shouldAttemptRestore( arguments: [String] = CommandLine.arguments, - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + restorePreviousSessionEnabled: Bool = SessionRestorePolicy.restorePreviousSessionEnabled() ) -> Bool { + // User opted out of automatic restore (Settings → App, or + // `app.restorePreviousSession: false` in cmux.json). The explicit + // "Restore Previous Launch" command still works — it does not call here. + if !restorePreviousSessionEnabled { + return false + } if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" { return false } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 6484a4432e38..d7e22e38d5e2 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -6314,4 +6314,47 @@ extension SessionPersistenceTests { XCTAssertNil(MarkdownPanelFileLinkResolver.resolve(rawPath: "notes.txt", relativeToMarkdownFile: openedFile.path)) XCTAssertNil(MarkdownPanelFileLinkResolver.resolve(rawPath: "https://example.com/notes.md", relativeToMarkdownFile: openedFile.path)) } + + // MARK: - app.restorePreviousSession + + func testRestorePreviousSessionEnabledDefaultsToTrueWhenUnset() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "cmux-restore-pref-\(UUID().uuidString)")) + defer { defaults.removeObject(forKey: SessionRestorePolicy.restorePreviousSessionDefaultsKey) } + + XCTAssertNil(defaults.object(forKey: SessionRestorePolicy.restorePreviousSessionDefaultsKey)) + XCTAssertTrue(SessionRestorePolicy.restorePreviousSessionEnabled(defaults: defaults)) + } + + func testRestorePreviousSessionEnabledReflectsStoredValue() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "cmux-restore-pref-\(UUID().uuidString)")) + defer { defaults.removeObject(forKey: SessionRestorePolicy.restorePreviousSessionDefaultsKey) } + + defaults.set(false, forKey: SessionRestorePolicy.restorePreviousSessionDefaultsKey) + XCTAssertFalse(SessionRestorePolicy.restorePreviousSessionEnabled(defaults: defaults)) + + defaults.set(true, forKey: SessionRestorePolicy.restorePreviousSessionDefaultsKey) + XCTAssertTrue(SessionRestorePolicy.restorePreviousSessionEnabled(defaults: defaults)) + } + + func testShouldAttemptRestoreSkipsWhenRestorePreviousSessionDisabled() { + // Even with an otherwise-restorable launch (no extra args, no test env), + // the user opting out of restore must short-circuit to no restore. + XCTAssertFalse( + SessionRestorePolicy.shouldAttemptRestore( + arguments: ["cmux"], + environment: [:], + restorePreviousSessionEnabled: false + ) + ) + } + + func testShouldAttemptRestoreProceedsWhenRestorePreviousSessionEnabled() { + XCTAssertTrue( + SessionRestorePolicy.shouldAttemptRestore( + arguments: ["cmux"], + environment: [:], + restorePreviousSessionEnabled: true + ) + ) + } } diff --git a/docs/configuration.md b/docs/configuration.md index aef34713d865..1c40483cc2a6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,6 +64,25 @@ Pins the default working directory for new workspaces instead of always using th Editable in **Settings → General → Default Workspace Directory**. +## `app.restorePreviousSession` + +Controls whether cmux automatically restores the previous launch on startup — its windows, workspaces, working directories, and terminal content. + +```json +{ + "app": { + "restorePreviousSession": false + } +} +``` + +- `true` (the default) preserves the historical behavior: reopening cmux brings back exactly where you left off. +- `false` starts every launch with a single fresh workspace (seeded by `app.defaultWorkspacePath`), so reopening the app no longer replays the previous session's terminals. +- The explicit **Restore Previous Launch** command (File menu, Command Palette, and its keyboard shortcut) is unaffected — you can still pull back the last session on demand. +- Equivalent to launching with the `CMUX_DISABLE_SESSION_RESTORE=1` environment variable, but persisted. + +Editable in **Settings → General → Restore Previous Session**. + ## `terminal.agentHibernation` Opt-in Agent Hibernation. cmux kills idle background agent processes to free RAM and CPU, then resumes each one with its saved session when you visit its tab. See [agent-hooks.md](agent-hooks.md#agent-hibernation) for the full behavior, including the confirmation settle window and how resume works. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index d6a0cb6537d9..0db783b1e43c 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -308,6 +308,11 @@ "default": "", "description": "Default working directory for new workspaces. Supports ~ and $VAR/${VAR} expansion. When set, it takes precedence over workspaceInheritWorkingDirectory (the inherited last-used directory). An invalid or nonexistent path falls back to the last-used directory (or the home directory). Leave empty to keep the inherited last-used behavior." }, + "restorePreviousSession": { + "type": "boolean", + "default": true, + "description": "When true, cmux restores the previous launch's windows, workspaces, and terminal content on startup. Set false to start each launch with a fresh workspace (the explicit 'Restore Previous Launch' command still works)." + }, "minimalMode": { "type": "boolean", "default": false,