feat: Add session save/open feature for exporting and importing current session - #1427
ashutoshpw wants to merge 1 commit into
Conversation
|
@ashutoshpw is attempting to deploy a commit to the Manaflow Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThis pull request introduces session save/open functionality to cmux. It adds new CLI commands ( Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Menu as Menu Item
participant AppDelegate
participant FilePanel as File Panel
participant SessionExportStore
participant File as File System
participant TerminalController as RPC Handler
User->>Menu: Click "Save Session…"
Menu->>AppDelegate: saveSessionToFile()
AppDelegate->>FilePanel: Present save panel
User->>FilePanel: Select location & confirm
FilePanel-->>AppDelegate: File path
AppDelegate->>AppDelegate: buildSessionSnapshot(includeScrollback: false)
AppDelegate->>SessionExportStore: save(snapshot, to: fileURL)
SessionExportStore->>SessionExportStore: Encode snapshot as JSON
SessionExportStore->>File: Write atomically
File-->>SessionExportStore: Success
SessionExportStore-->>AppDelegate: Boolean result
AppDelegate-->>User: Confirm save complete
sequenceDiagram
actor User
participant Menu as Menu Item
participant AppDelegate
participant FilePanel as File Panel
participant SessionExportStore
participant File as File System
participant TerminalController as Workspace Creator
User->>Menu: Click "Open Session…"
Menu->>AppDelegate: openSessionFromFile()
AppDelegate->>FilePanel: Present open panel
User->>FilePanel: Select file & confirm
FilePanel-->>AppDelegate: File path
AppDelegate->>SessionExportStore: load(from: fileURL)
SessionExportStore->>File: Read file data
File-->>SessionExportStore: JSON bytes
SessionExportStore->>SessionExportStore: Decode into AppSessionSnapshot
SessionExportStore->>SessionExportStore: Validate version & windows
SessionExportStore-->>AppDelegate: Snapshot or nil
AppDelegate->>TerminalController: Create windows from snapshots
TerminalController->>TerminalController: Recreate panes, panels, terminals
TerminalController-->>AppDelegate: Windows created
AppDelegate-->>User: Session restored
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip CodeRabbit can generate a title for your PR based on the changes.Add |
…on files Allow users to save their entire session (all windows, workspaces, tabs, splits, and foreground commands like ssh/tmux) to a .cmux-session JSON file and restore it later as new windows. Accessible via File menu (Shift+Cmd+S / Shift+Cmd+O), socket commands (session.save / session.open), and CLI (cmux session save/open <path>). - Add command field to SessionTerminalPanelSnapshot for restoring shell commands - Add SessionExportStore for pretty-printed JSON export/import - Add Save Session / Open Session menu items, socket commands, and CLI subcommands - Add localized strings for all 18 supported languages
0576b67 to
39df922
Compare
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
There was a problem hiding this comment.
5 issues found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Sources/AppDelegate.swift">
<violation number="1" location="Sources/AppDelegate.swift:5043">
P2: Handle `SessionExportStore.save` failure in the menu save path; currently export errors are silently ignored.</violation>
<violation number="2" location="Sources/AppDelegate.swift:5057">
P2: Avoid silent failure when opening a session file; notify the user when `SessionExportStore.load` returns `nil`.</violation>
</file>
<file name="Sources/GhosttyTerminalView.swift">
<violation number="1" location="Sources/GhosttyTerminalView.swift:3105">
P1: Clear `surfaceConfig.command` when no explicit command is requested to avoid inheriting stale commands into new surfaces.</violation>
</file>
<file name="Sources/Workspace.swift">
<violation number="1" location="Sources/Workspace.swift:321">
P1: Default terminal tabs can be persisted as command "Terminal", causing broken session restore commands.</violation>
</file>
<file name="Sources/SessionPersistence.swift">
<violation number="1" location="Sources/SessionPersistence.swift:227">
P1: Opening a session file can execute arbitrary commands from `terminal.command` with no trust gate. Imported snapshots should require explicit confirmation or strip executable commands by default.</violation>
</file>
Since this is your first cubic review, here's how it works:
- cubic automatically reviews your code and comments on bugs and improvements
- Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
- Add one-off context when rerunning by tagging
@cubic-dev-aiwith guidance or docs links (includingllms.txt) - Ask questions if you need clarification on any suggestion
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| if let command = self.command, !command.isEmpty { | ||
| command.withCString { cCommand in | ||
| surfaceConfig.command = cCommand | ||
| createSurface() | ||
| } | ||
| } else { | ||
| createSurface() | ||
| } |
There was a problem hiding this comment.
P1: Clear surfaceConfig.command when no explicit command is requested to avoid inheriting stale commands into new surfaces.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/GhosttyTerminalView.swift, line 3105:
<comment>Clear `surfaceConfig.command` when no explicit command is requested to avoid inheriting stale commands into new surfaces.</comment>
<file context>
@@ -3098,13 +3101,24 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
+ let applyCommandAndCreate = {
+ if let command = self.command, !command.isEmpty {
+ command.withCString { cCommand in
+ surfaceConfig.command = cCommand
</file context>
| if let command = self.command, !command.isEmpty { | |
| command.withCString { cCommand in | |
| surfaceConfig.command = cCommand | |
| createSurface() | |
| } | |
| } else { | |
| createSurface() | |
| } | |
| if let command = self.command, !command.isEmpty { | |
| command.withCString { cCommand in | |
| surfaceConfig.command = cCommand | |
| createSurface() | |
| } | |
| } else { | |
| surfaceConfig.command = nil | |
| createSurface() | |
| } |
| let shellNames: Set<String> = [ | ||
| "bash", "zsh", "fish", "sh", "dash", "tcsh", "csh", "ksh", | ||
| "nu", "nushell", "pwsh", "elvish", "login", | ||
| ] |
There was a problem hiding this comment.
P1: Default terminal tabs can be persisted as command "Terminal", causing broken session restore commands.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 321:
<comment>Default terminal tabs can be persisted as command "Terminal", causing broken session restore commands.</comment>
<file context>
@@ -316,9 +316,20 @@ extension Workspace {
)
+ let snapshotCommand: String? = {
+ let title = panelTitles[panelId] ?? ""
+ let shellNames: Set<String> = [
+ "bash", "zsh", "fish", "sh", "dash", "tcsh", "csh", "ksh",
+ "nu", "nushell", "pwsh", "elvish", "login",
</file context>
| let shellNames: Set<String> = [ | |
| "bash", "zsh", "fish", "sh", "dash", "tcsh", "csh", "ksh", | |
| "nu", "nushell", "pwsh", "elvish", "login", | |
| ] | |
| let shellNames: Set<String> = [ | |
| "bash", "zsh", "fish", "sh", "dash", "tcsh", "csh", "ksh", | |
| "nu", "nushell", "pwsh", "elvish", "login", "terminal", | |
| ] |
| struct SessionTerminalPanelSnapshot: Codable, Sendable { | ||
| var workingDirectory: String? | ||
| var scrollback: String? | ||
| var command: String? |
There was a problem hiding this comment.
P1: Opening a session file can execute arbitrary commands from terminal.command with no trust gate. Imported snapshots should require explicit confirmation or strip executable commands by default.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/SessionPersistence.swift, line 227:
<comment>Opening a session file can execute arbitrary commands from `terminal.command` with no trust gate. Imported snapshots should require explicit confirmation or strip executable commands by default.</comment>
<file context>
@@ -224,6 +224,7 @@ struct SessionGitBranchSnapshot: Codable, Sendable {
struct SessionTerminalPanelSnapshot: Codable, Sendable {
var workingDirectory: String?
var scrollback: String?
+ var command: String?
}
</file context>
| panel.title = String(localized: "menu.file.openSession.panelTitle", defaultValue: "Open Session") | ||
| panel.prompt = String(localized: "menu.file.openSession.panelPrompt", defaultValue: "Open") | ||
| guard panel.runModal() == .OK, let url = panel.url else { return } | ||
| guard let snapshot = SessionExportStore.load(from: url) else { return } |
There was a problem hiding this comment.
P2: Avoid silent failure when opening a session file; notify the user when SessionExportStore.load returns nil.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/AppDelegate.swift, line 5057:
<comment>Avoid silent failure when opening a session file; notify the user when `SessionExportStore.load` returns `nil`.</comment>
<file context>
@@ -5026,6 +5027,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
+ panel.title = String(localized: "menu.file.openSession.panelTitle", defaultValue: "Open Session")
+ panel.prompt = String(localized: "menu.file.openSession.panelPrompt", defaultValue: "Open")
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ guard let snapshot = SessionExportStore.load(from: url) else { return }
+ for windowSnapshot in snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) {
+ createMainWindow(sessionWindowSnapshot: windowSnapshot)
</file context>
| guard let snapshot = SessionExportStore.load(from: url) else { return } | |
| guard let snapshot = SessionExportStore.load(from: url) else { | |
| NSSound.beep() | |
| return | |
| } |
| panel.title = String(localized: "menu.file.saveSession.panelTitle", defaultValue: "Save Session") | ||
| panel.prompt = String(localized: "menu.file.saveSession.panelPrompt", defaultValue: "Save") | ||
| guard panel.runModal() == .OK, let url = panel.url else { return } | ||
| SessionExportStore.save(snapshot, to: url) |
There was a problem hiding this comment.
P2: Handle SessionExportStore.save failure in the menu save path; currently export errors are silently ignored.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/AppDelegate.swift, line 5043:
<comment>Handle `SessionExportStore.save` failure in the menu save path; currently export errors are silently ignored.</comment>
<file context>
@@ -5026,6 +5027,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
+ panel.title = String(localized: "menu.file.saveSession.panelTitle", defaultValue: "Save Session")
+ panel.prompt = String(localized: "menu.file.saveSession.panelPrompt", defaultValue: "Save")
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ SessionExportStore.save(snapshot, to: url)
+ }
+
</file context>
| SessionExportStore.save(snapshot, to: url) | |
| guard SessionExportStore.save(snapshot, to: url) else { | |
| NSSound.beep() | |
| return | |
| } |
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@CLI/cmux.swift`:
- Around line 2088-2118: The session save/open handlers currently only use
subArgs.first and ignore any trailing tokens; update both cases (the "save" and
"open" branches that reference subArgs, resolvePath, client.sendV2, jsonOutput,
formatIDs, idFormat) to reject unexpected extra arguments by validating
subArgs.count == 1 (or that there are no additional tokens) and throw a CLIError
with a clear usage message if extra tokens are present, before resolving the
path and calling client.sendV2.
In `@Resources/Localizable.xcstrings`:
- Around line 32761-32873: The menu labels menu.file.openSession and
menu.file.saveSession use mixed ellipsis forms; normalize them to a single
Unicode ellipsis character "…" (no leading space) across all locale
stringUnit.value entries: search for variants " ...", " …", and "..." under both
keys and replace them with a single trailing "…" so every localization uses the
same convention.
In `@Sources/AppDelegate.swift`:
- Line 5038: Replace the hard-coded filename stem "session" with a localized
string; update the assignment to use String(localized:..., defaultValue:...) for
the stem and then append ".\(SessionExportStore.fileExtension)". Specifically
change the use of panel.nameFieldStringValue so it builds the filename from
String(localized: "session", defaultValue: "session") (or your chosen
localization key) combined with SessionExportStore.fileExtension.
- Line 5057: The guard silently returns when SessionExportStore.load(from: url)
fails; change the call site in AppDelegate.swift to surface parse errors to the
user by handling the failure instead of returning: modify
SessionExportStore.load(from:) to throw or return a Result/optional+error, then
replace the guard let snapshot = SessionExportStore.load(from: url) else {
return } with a do/catch or Result switch that captures the parsing error and
presents a user-facing message (e.g., NSAlert or your app’s error UI) describing
the import failure and the underlying error, referencing the
SessionExportStore.load(from:) call and the local snapshot variable so callers
clearly log and display the parsing error.
- Around line 5046-5060: openSessionFromFile currently restores windows
immediately after loading a snapshot, which can relaunch foreground commands;
update openSessionFromFile to detect whether the loaded snapshot or any
windowSnapshot contains relaunchable/foreground commands (inspect the
snapshot/windowSnapshot properties that describe processes or a relaunchable
flag), and if so present a modal confirmation dialog to the user listing the
risk and requiring explicit consent before calling createMainWindow for those
window snapshots; if the user cancels, abort restoration and do not call
createMainWindow for any relaunchable windows (still allow restoring
non-relaunchable windows only after explicit consent).
In `@Sources/SessionPersistence.swift`:
- Around line 443-449: The load(from:) function reads the entire session file
into memory without size limits, risking OOM on large .cmux-session files;
before creating Data(contentsOf:), check the file size using FileManager or URL
resourceValues (e.g., values(forKey: .fileSizeKey) or attributesOfItem) and
reject files over a reasonable cap (choose a constant max size) by returning
nil, then proceed to decode AppSessionSnapshot and validate
SessionSnapshotSchema.currentVersion and non-empty windows as before.
In `@Sources/TerminalController.swift`:
- Around line 2177-2181: The code registers RPC handlers for "session.save" and
"session.open" (v2SessionSave and v2SessionOpen) but never advertises them in
the system.capabilities list, causing clients to think these methods are
unsupported; update the capability discovery where system.capabilities is
assembled to include the strings "session.save" and "session.open" (alongside
existing capability entries) so the advertised capabilities reflect the
registered handlers (ensure names match exactly the case used in the switch:
"session.save" and "session.open").
- Around line 6283-6297: The restore currently returns .ok even when no windows
were actually created; after the v2MainSync loop that calls
AppDelegate.shared?.createMainWindow(sessionWindowSnapshot:) and collects
windowIds, check if windowIds.count is zero and return an error result instead
of .ok (e.g., return a failure/error enum case with a descriptive message like
"no windows restored" and include filePath for context); keep the existing
successful .ok response when windowIds.count > 0 and still include
"windows_created" and "window_ids" as before.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f32332e6-fac2-45eb-85da-0b252b2e6e32
📒 Files selected for processing (9)
CLI/cmux.swiftResources/Localizable.xcstringsSources/AppDelegate.swiftSources/GhosttyTerminalView.swiftSources/Panels/TerminalPanel.swiftSources/SessionPersistence.swiftSources/TerminalController.swiftSources/Workspace.swiftSources/cmuxApp.swift
| switch subcommand { | ||
| case "save": | ||
| guard let rawPath = subArgs.first, !rawPath.isEmpty else { | ||
| throw CLIError(message: "session save requires a file path. Usage: cmux session save <path>") | ||
| } | ||
| let absolutePath = resolvePath(rawPath) | ||
| let payload = try client.sendV2(method: "session.save", params: ["path": absolutePath]) | ||
| if jsonOutput { | ||
| print(jsonString(formatIDs(payload, mode: idFormat))) | ||
| } else { | ||
| let savedPath = (payload["path"] as? String) ?? absolutePath | ||
| print("OK path=\(savedPath)") | ||
| } | ||
|
|
||
| case "open": | ||
| guard let rawPath = subArgs.first, !rawPath.isEmpty else { | ||
| throw CLIError(message: "session open requires a file path. Usage: cmux session open <path>") | ||
| } | ||
| let absolutePath = resolvePath(rawPath) | ||
| let payload = try client.sendV2(method: "session.open", params: ["path": absolutePath]) | ||
| if jsonOutput { | ||
| print(jsonString(formatIDs(payload, mode: idFormat))) | ||
| } else { | ||
| let windowCount = (payload["windows_created"] as? Int) ?? 0 | ||
| let savedPath = (payload["path"] as? String) ?? absolutePath | ||
| print("OK windows_created=\(windowCount) path=\(savedPath)") | ||
| } | ||
|
|
||
| default: | ||
| throw CLIError(message: "Unknown session subcommand: \(subcommand). Usage: cmux session save <path> | cmux session open <path>") | ||
| } |
There was a problem hiding this comment.
Reject unexpected trailing arguments in session save/open.
At Line 2090 and Line 2103, only the first token after the subcommand is used as path; extra tokens are silently ignored. That can mask typos/misuse while still performing file operations.
♻️ Proposed fix
case "save":
guard let rawPath = subArgs.first, !rawPath.isEmpty else {
throw CLIError(message: "session save requires a file path. Usage: cmux session save <path>")
}
+ if subArgs.count > 1 {
+ let extras = subArgs.dropFirst().joined(separator: " ")
+ throw CLIError(message: "session save: unexpected argument(s): \(extras)")
+ }
let absolutePath = resolvePath(rawPath)
let payload = try client.sendV2(method: "session.save", params: ["path": absolutePath])
case "open":
guard let rawPath = subArgs.first, !rawPath.isEmpty else {
throw CLIError(message: "session open requires a file path. Usage: cmux session open <path>")
}
+ if subArgs.count > 1 {
+ let extras = subArgs.dropFirst().joined(separator: " ")
+ throw CLIError(message: "session open: unexpected argument(s): \(extras)")
+ }
let absolutePath = resolvePath(rawPath)
let payload = try client.sendV2(method: "session.open", params: ["path": absolutePath])📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| switch subcommand { | |
| case "save": | |
| guard let rawPath = subArgs.first, !rawPath.isEmpty else { | |
| throw CLIError(message: "session save requires a file path. Usage: cmux session save <path>") | |
| } | |
| let absolutePath = resolvePath(rawPath) | |
| let payload = try client.sendV2(method: "session.save", params: ["path": absolutePath]) | |
| if jsonOutput { | |
| print(jsonString(formatIDs(payload, mode: idFormat))) | |
| } else { | |
| let savedPath = (payload["path"] as? String) ?? absolutePath | |
| print("OK path=\(savedPath)") | |
| } | |
| case "open": | |
| guard let rawPath = subArgs.first, !rawPath.isEmpty else { | |
| throw CLIError(message: "session open requires a file path. Usage: cmux session open <path>") | |
| } | |
| let absolutePath = resolvePath(rawPath) | |
| let payload = try client.sendV2(method: "session.open", params: ["path": absolutePath]) | |
| if jsonOutput { | |
| print(jsonString(formatIDs(payload, mode: idFormat))) | |
| } else { | |
| let windowCount = (payload["windows_created"] as? Int) ?? 0 | |
| let savedPath = (payload["path"] as? String) ?? absolutePath | |
| print("OK windows_created=\(windowCount) path=\(savedPath)") | |
| } | |
| default: | |
| throw CLIError(message: "Unknown session subcommand: \(subcommand). Usage: cmux session save <path> | cmux session open <path>") | |
| } | |
| switch subcommand { | |
| case "save": | |
| guard let rawPath = subArgs.first, !rawPath.isEmpty else { | |
| throw CLIError(message: "session save requires a file path. Usage: cmux session save <path>") | |
| } | |
| if subArgs.count > 1 { | |
| let extras = subArgs.dropFirst().joined(separator: " ") | |
| throw CLIError(message: "session save: unexpected argument(s): \(extras)") | |
| } | |
| let absolutePath = resolvePath(rawPath) | |
| let payload = try client.sendV2(method: "session.save", params: ["path": absolutePath]) | |
| if jsonOutput { | |
| print(jsonString(formatIDs(payload, mode: idFormat))) | |
| } else { | |
| let savedPath = (payload["path"] as? String) ?? absolutePath | |
| print("OK path=\(savedPath)") | |
| } | |
| case "open": | |
| guard let rawPath = subArgs.first, !rawPath.isEmpty else { | |
| throw CLIError(message: "session open requires a file path. Usage: cmux session open <path>") | |
| } | |
| if subArgs.count > 1 { | |
| let extras = subArgs.dropFirst().joined(separator: " ") | |
| throw CLIError(message: "session open: unexpected argument(s): \(extras)") | |
| } | |
| let absolutePath = resolvePath(rawPath) | |
| let payload = try client.sendV2(method: "session.open", params: ["path": absolutePath]) | |
| if jsonOutput { | |
| print(jsonString(formatIDs(payload, mode: idFormat))) | |
| } else { | |
| let windowCount = (payload["windows_created"] as? Int) ?? 0 | |
| let savedPath = (payload["path"] as? String) ?? absolutePath | |
| print("OK windows_created=\(windowCount) path=\(savedPath)") | |
| } | |
| default: | |
| throw CLIError(message: "Unknown session subcommand: \(subcommand). Usage: cmux session save <path> | cmux session open <path>") | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CLI/cmux.swift` around lines 2088 - 2118, The session save/open handlers
currently only use subArgs.first and ignore any trailing tokens; update both
cases (the "save" and "open" branches that reference subArgs, resolvePath,
client.sendV2, jsonOutput, formatIDs, idFormat) to reject unexpected extra
arguments by validating subArgs.count == 1 (or that there are no additional
tokens) and throw a CLIError with a clear usage message if extra tokens are
present, before resolving the path and calling client.sendV2.
| "menu.file.openSession": { | ||
| "extractionState": "manual", | ||
| "localizations": { | ||
| "en": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Open Session…" | ||
| } | ||
| }, | ||
| "ja": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "セッションを開く…" | ||
| } | ||
| }, | ||
| "zh-Hans": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "打开会话..." | ||
| } | ||
| }, | ||
| "zh-Hant": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "開啟工作階段..." | ||
| } | ||
| }, | ||
| "ko": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "세션 열기…" | ||
| } | ||
| }, | ||
| "de": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Sitzung öffnen …" | ||
| } | ||
| }, | ||
| "es": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Abrir sesión…" | ||
| } | ||
| }, | ||
| "fr": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Ouvrir une session..." | ||
| } | ||
| }, | ||
| "it": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Apri sessione…" | ||
| } | ||
| }, | ||
| "da": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Åbn session…" | ||
| } | ||
| }, | ||
| "pl": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Otwórz sesję…" | ||
| } | ||
| }, | ||
| "ru": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Открыть сессию..." | ||
| } | ||
| }, | ||
| "bs": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Otvori sesiju…" | ||
| } | ||
| }, | ||
| "ar": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "فتح جلسة…" | ||
| } | ||
| }, | ||
| "nb": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Åpne økt …" | ||
| } | ||
| }, | ||
| "pt-BR": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Abrir Sessão…" | ||
| } | ||
| }, | ||
| "th": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "เปิดเซสชัน..." | ||
| } | ||
| }, | ||
| "tr": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Oturumu Aç…" | ||
| } | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify the suffix style used by the new dialog-launching File menu items.
python - <<'PY'
import json
from pathlib import Path
strings = json.loads(Path("Resources/Localizable.xcstrings").read_text(encoding="utf-8"))["strings"]
def suffix_kind(value: str) -> str:
if value.endswith(" …"):
return "space+ellipsis"
if value.endswith("…"):
return "ellipsis"
if value.endswith("..."):
return "three-dots"
return "other"
for key in ("menu.file.openSession", "menu.file.saveSession"):
print(f"\n{key}")
for locale, entry in sorted(strings[key]["localizations"].items()):
value = entry["stringUnit"]["value"]
print(f"{locale:8} {suffix_kind(value):14} {value}")
PYRepository: manaflow-ai/cmux
Length of output: 1465
Normalize ellipsis style across File-menu commands.
menu.file.openSession and menu.file.saveSession currently mix three suffix styles—…, …, and ...—across locales (18 total). Use one consistent ellipsis convention across both commands and all locales before shipping.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Resources/Localizable.xcstrings` around lines 32761 - 32873, The menu labels
menu.file.openSession and menu.file.saveSession use mixed ellipsis forms;
normalize them to a single Unicode ellipsis character "…" (no leading space)
across all locale stringUnit.value entries: search for variants " ...", " …",
and "..." under both keys and replace them with a single trailing "…" so every
localization uses the same convention.
| panel.allowedContentTypes = [ | ||
| UTType(filenameExtension: SessionExportStore.fileExtension) ?? .json, | ||
| ] | ||
| panel.nameFieldStringValue = "session.\(SessionExportStore.fileExtension)" |
There was a problem hiding this comment.
Localize the save panel’s default filename stem.
"session" is user-visible text in the save dialog and should be localized like the other dialog strings.
🌐 Suggested fix
- panel.nameFieldStringValue = "session.\(SessionExportStore.fileExtension)"
+ let defaultFilename = String(
+ localized: "menu.file.saveSession.defaultFilename",
+ defaultValue: "session"
+ )
+ panel.nameFieldStringValue = "\(defaultFilename).\(SessionExportStore.fileExtension)"As per coding guidelines “All user-facing strings must be localized. Use String(localized:..., defaultValue: ...) for every string shown in the UI.”
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| panel.nameFieldStringValue = "session.\(SessionExportStore.fileExtension)" | |
| let defaultFilename = String( | |
| localized: "menu.file.saveSession.defaultFilename", | |
| defaultValue: "session" | |
| ) | |
| panel.nameFieldStringValue = "\(defaultFilename).\(SessionExportStore.fileExtension)" |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/AppDelegate.swift` at line 5038, Replace the hard-coded filename stem
"session" with a localized string; update the assignment to use
String(localized:..., defaultValue:...) for the stem and then append
".\(SessionExportStore.fileExtension)". Specifically change the use of
panel.nameFieldStringValue so it builds the filename from String(localized:
"session", defaultValue: "session") (or your chosen localization key) combined
with SessionExportStore.fileExtension.
| @objc func openSessionFromFile(_ sender: Any?) { | ||
| let panel = NSOpenPanel() | ||
| panel.allowedContentTypes = [ | ||
| UTType(filenameExtension: SessionExportStore.fileExtension) ?? .json, | ||
| ] | ||
| panel.canChooseFiles = true | ||
| panel.canChooseDirectories = false | ||
| panel.allowsMultipleSelection = false | ||
| panel.title = String(localized: "menu.file.openSession.panelTitle", defaultValue: "Open Session") | ||
| panel.prompt = String(localized: "menu.file.openSession.panelPrompt", defaultValue: "Open") | ||
| guard panel.runModal() == .OK, let url = panel.url else { return } | ||
| guard let snapshot = SessionExportStore.load(from: url) else { return } | ||
| for windowSnapshot in snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { | ||
| createMainWindow(sessionWindowSnapshot: windowSnapshot) | ||
| } |
There was a problem hiding this comment.
Require explicit confirmation before importing sessions that can relaunch commands.
openSessionFromFile(_:) restores snapshot windows immediately after load. Since session files can contain relaunchable foreground commands, importing an untrusted file can execute unexpected commands.
🔒 Suggested safeguard
`@objc` func openSessionFromFile(_ sender: Any?) {
let panel = NSOpenPanel()
panel.allowedContentTypes = [
UTType(filenameExtension: SessionExportStore.fileExtension) ?? .json,
]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.title = String(localized: "menu.file.openSession.panelTitle", defaultValue: "Open Session")
panel.prompt = String(localized: "menu.file.openSession.panelPrompt", defaultValue: "Open")
guard panel.runModal() == .OK, let url = panel.url else { return }
guard let snapshot = SessionExportStore.load(from: url) else { return }
+
+ let warning = NSAlert()
+ warning.alertStyle = .warning
+ warning.messageText = String(
+ localized: "menu.file.openSession.confirm.title",
+ defaultValue: "Open Session?"
+ )
+ warning.informativeText = String(
+ localized: "menu.file.openSession.confirm.message",
+ defaultValue: "Opening a session may relaunch commands saved in this file. Only open trusted session files."
+ )
+ warning.addButton(withTitle: String(localized: "menu.file.openSession.panelPrompt", defaultValue: "Open"))
+ warning.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
+ guard warning.runModal() == .alertFirstButtonReturn else { return }
+
for windowSnapshot in snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) {
createMainWindow(sessionWindowSnapshot: windowSnapshot)
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/AppDelegate.swift` around lines 5046 - 5060, openSessionFromFile
currently restores windows immediately after loading a snapshot, which can
relaunch foreground commands; update openSessionFromFile to detect whether the
loaded snapshot or any windowSnapshot contains relaunchable/foreground commands
(inspect the snapshot/windowSnapshot properties that describe processes or a
relaunchable flag), and if so present a modal confirmation dialog to the user
listing the risk and requiring explicit consent before calling createMainWindow
for those window snapshots; if the user cancels, abort restoration and do not
call createMainWindow for any relaunchable windows (still allow restoring
non-relaunchable windows only after explicit consent).
| panel.title = String(localized: "menu.file.openSession.panelTitle", defaultValue: "Open Session") | ||
| panel.prompt = String(localized: "menu.file.openSession.panelPrompt", defaultValue: "Open") | ||
| guard panel.runModal() == .OK, let url = panel.url else { return } | ||
| guard let snapshot = SessionExportStore.load(from: url) else { return } |
There was a problem hiding this comment.
Surface import errors instead of silently returning on parse failure.
If parsing fails, the current flow exits with no user feedback, which makes the feature appear broken.
🛠️ Suggested user-facing error path
- guard let snapshot = SessionExportStore.load(from: url) else { return }
+ guard let snapshot = SessionExportStore.load(from: url) else {
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = String(
+ localized: "menu.file.openSession.error.title",
+ defaultValue: "Couldn’t Open Session"
+ )
+ alert.informativeText = String(
+ localized: "menu.file.openSession.error.message",
+ defaultValue: "The selected session file is invalid or unreadable."
+ )
+ alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
+ alert.runModal()
+ return
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| guard let snapshot = SessionExportStore.load(from: url) else { return } | |
| guard let snapshot = SessionExportStore.load(from: url) else { | |
| let alert = NSAlert() | |
| alert.alertStyle = .warning | |
| alert.messageText = String( | |
| localized: "menu.file.openSession.error.title", | |
| defaultValue: "Couldn't Open Session" | |
| ) | |
| alert.informativeText = String( | |
| localized: "menu.file.openSession.error.message", | |
| defaultValue: "The selected session file is invalid or unreadable." | |
| ) | |
| alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) | |
| alert.runModal() | |
| return | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/AppDelegate.swift` at line 5057, The guard silently returns when
SessionExportStore.load(from: url) fails; change the call site in
AppDelegate.swift to surface parse errors to the user by handling the failure
instead of returning: modify SessionExportStore.load(from:) to throw or return a
Result/optional+error, then replace the guard let snapshot =
SessionExportStore.load(from: url) else { return } with a do/catch or Result
switch that captures the parsing error and presents a user-facing message (e.g.,
NSAlert or your app’s error UI) describing the import failure and the underlying
error, referencing the SessionExportStore.load(from:) call and the local
snapshot variable so callers clearly log and display the parsing error.
| static func load(from fileURL: URL) -> AppSessionSnapshot? { | ||
| guard let data = try? Data(contentsOf: fileURL) else { return nil } | ||
| let decoder = JSONDecoder() | ||
| guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil } | ||
| guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } | ||
| guard !snapshot.windows.isEmpty else { return nil } | ||
| return snapshot |
There was a problem hiding this comment.
Add an import size guard before decoding session files.
Line 444 reads the full file into memory with no cap. A very large .cmux-session file can spike memory and hang the app during import.
💡 Suggested hardening patch
enum SessionExportStore {
static let fileExtension = "cmux-session"
+ private static let maxImportBytes = 8 * 1024 * 1024
@@
static func load(from fileURL: URL) -> AppSessionSnapshot? {
- guard let data = try? Data(contentsOf: fileURL) else { return nil }
+ guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
+ let fileSize = values.fileSize,
+ fileSize > 0,
+ fileSize <= maxImportBytes else { return nil }
+ guard let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe) else { return nil }
let decoder = JSONDecoder()
guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil }
guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil }
guard !snapshot.windows.isEmpty else { return nil }
return snapshot
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| static func load(from fileURL: URL) -> AppSessionSnapshot? { | |
| guard let data = try? Data(contentsOf: fileURL) else { return nil } | |
| let decoder = JSONDecoder() | |
| guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil } | |
| guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } | |
| guard !snapshot.windows.isEmpty else { return nil } | |
| return snapshot | |
| static func load(from fileURL: URL) -> AppSessionSnapshot? { | |
| guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), | |
| let fileSize = values.fileSize, | |
| fileSize > 0, | |
| fileSize <= maxImportBytes else { return nil } | |
| guard let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe) else { return nil } | |
| let decoder = JSONDecoder() | |
| guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil } | |
| guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } | |
| guard !snapshot.windows.isEmpty else { return nil } | |
| return snapshot | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/SessionPersistence.swift` around lines 443 - 449, The load(from:)
function reads the entire session file into memory without size limits, risking
OOM on large .cmux-session files; before creating Data(contentsOf:), check the
file size using FileManager or URL resourceValues (e.g., values(forKey:
.fileSizeKey) or attributesOfItem) and reject files over a reasonable cap
(choose a constant max size) by returning nil, then proceed to decode
AppSessionSnapshot and validate SessionSnapshotSchema.currentVersion and
non-empty windows as before.
| // Session export/import | ||
| case "session.save": | ||
| return v2Result(id: id, self.v2SessionSave(params: params)) | ||
| case "session.open": | ||
| return v2Result(id: id, self.v2SessionOpen(params: params)) |
There was a problem hiding this comment.
Add session.save/session.open to system.capabilities
Lines 2178-2181 register handlers, but capability discovery won’t advertise them. Clients that gate on system.capabilities will treat these methods as unsupported.
Suggested patch
@@
"surface.send_key",
"surface.read_text",
"surface.clear_history",
"surface.trigger_flash",
+ "session.save",
+ "session.open",
"pane.list",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/TerminalController.swift` around lines 2177 - 2181, The code
registers RPC handlers for "session.save" and "session.open" (v2SessionSave and
v2SessionOpen) but never advertises them in the system.capabilities list,
causing clients to think these methods are unsupported; update the capability
discovery where system.capabilities is assembled to include the strings
"session.save" and "session.open" (alongside existing capability entries) so the
advertised capabilities reflect the registered handlers (ensure names match
exactly the case used in the switch: "session.save" and "session.open").
| var windowIds: [String] = [] | ||
| v2MainSync { | ||
| for windowSnapshot in snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { | ||
| if let windowId = AppDelegate.shared?.createMainWindow(sessionWindowSnapshot: windowSnapshot) { | ||
| windowIds.append(windowId.uuidString) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return .ok([ | ||
| "path": filePath, | ||
| "windows_created": windowIds.count, | ||
| "window_ids": windowIds | ||
| ]) | ||
| } |
There was a problem hiding this comment.
Don’t return success when restore creates zero windows
If restore attempts windows but createMainWindow(sessionWindowSnapshot:) fails for all, the method still returns .ok. That masks restore failure for automation and CLI callers.
Suggested patch
- var windowIds: [String] = []
+ var windowIds: [String] = []
+ let requestedCount = min(snapshot.windows.count, SessionPersistencePolicy.maxWindowsPerSnapshot)
v2MainSync {
for windowSnapshot in snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) {
if let windowId = AppDelegate.shared?.createMainWindow(sessionWindowSnapshot: windowSnapshot) {
windowIds.append(windowId.uuidString)
}
}
}
+ if requestedCount > 0 && windowIds.isEmpty {
+ return .err(
+ code: "internal_error",
+ message: "Failed to restore any windows from session file",
+ data: ["path": filePath]
+ )
+ }
+
return .ok([
"path": filePath,
+ "windows_requested": requestedCount,
"windows_created": windowIds.count,
"window_ids": windowIds
])📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| var windowIds: [String] = [] | |
| v2MainSync { | |
| for windowSnapshot in snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { | |
| if let windowId = AppDelegate.shared?.createMainWindow(sessionWindowSnapshot: windowSnapshot) { | |
| windowIds.append(windowId.uuidString) | |
| } | |
| } | |
| } | |
| return .ok([ | |
| "path": filePath, | |
| "windows_created": windowIds.count, | |
| "window_ids": windowIds | |
| ]) | |
| } | |
| var windowIds: [String] = [] | |
| let requestedCount = min(snapshot.windows.count, SessionPersistencePolicy.maxWindowsPerSnapshot) | |
| v2MainSync { | |
| for windowSnapshot in snapshot.windows.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { | |
| if let windowId = AppDelegate.shared?.createMainWindow(sessionWindowSnapshot: windowSnapshot) { | |
| windowIds.append(windowId.uuidString) | |
| } | |
| } | |
| } | |
| if requestedCount > 0 && windowIds.isEmpty { | |
| return .err( | |
| code: "internal_error", | |
| message: "Failed to restore any windows from session file", | |
| data: ["path": filePath] | |
| ) | |
| } | |
| return .ok([ | |
| "path": filePath, | |
| "windows_requested": requestedCount, | |
| "windows_created": windowIds.count, | |
| "window_ids": windowIds | |
| ]) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Sources/TerminalController.swift` around lines 6283 - 6297, The restore
currently returns .ok even when no windows were actually created; after the
v2MainSync loop that calls
AppDelegate.shared?.createMainWindow(sessionWindowSnapshot:) and collects
windowIds, check if windowIds.count is zero and return an error result instead
of .ok (e.g., return a failure/error enum case with a descriptive message like
"no windows restored" and include filePath for context); keep the existing
successful .ok response when windowIds.count > 0 and still include
"windows_created" and "window_ids" as before.
Summary
.cmux-sessionJSON fileSessionPersistencesnapshot infrastructure (AppSessionSnapshot,Workspace.sessionSnapshot(),TabManager.restoreSessionSnapshot()) — no new persistence model neededcommandconfig fieldsession.saveandsession.openv2 socket commands andcmux session save/openCLI subcommands.cmux-sessionUTType in Info.plistTesting
./scripts/setup.sh./scripts/reload.sh --tag session-save-openContext
.cmux-sessionfiles can also serve as reusable workspace templates (e.g. save a "backend dev" layout and import it whenever needed, similar to vscode "Open Workspace from file")Issues
Summary by cubic
Add session export/import to save the full app state to a
.cmux-sessionJSON file and reopen it later as new windows. Restores windows, workspaces, tabs, splits, and non-shell foreground commands; scrollback is excluded.command.cmux session save <path>andcmux session open <path>; Socket:session.save,session.open.Written for commit 39df922. Summary will update on new commits.
Summary by CodeRabbit
Release Notes
cmux session save <path>andcmux session open <path>commands for managing sessions from the command line.