Skip to content

feat: add Profiles — save and restore workspace layouts - #1617

Closed
andrekat wants to merge 1 commit into
manaflow-ai:mainfrom
andrekat:feature/profiles
Closed

andrekat wants to merge 1 commit into
manaflow-ai:mainfrom
andrekat:feature/profiles

Conversation

@andrekat

@andrekat andrekat commented Mar 17, 2026 •

Copy link
Copy Markdown

Summary

  • What changed? Adds a Profiles feature that lets users save their current workspace layout as a named profile (e.g. "Work", "Personal") and restore it later — either in the current window or a new one. Think of it like VS Code workspaces: a preset of terminals, splits, and browser panels you can switch between.

  • Why? Power users often have distinct contexts — a "Work" setup with API server, frontend, and test workspaces, and a "Personal" setup with side projects. Today, these have to be manually recreated each time. Profiles make this a one-click operation.

What's included

Component Details
Sources/ProfileStore.swift Profile model + ProfileStore persistence layer (save/load/delete/rename/list as JSON in ~/Library/Application Support/cmux/profiles/)
Sources/cmuxApp.swift File → Workspace → Profiles submenu (save, load in new window / current window, delete)
Sources/ContentView.swift Sidebar footer badge showing active profile name, inline next to the ? help button
Sources/AppDelegate.swift Auto-save active profiles on existing 8s timer + app termination (with scrollback)
Sources/TabManager.swift activeProfileName published property — persists across workspace changes
Sources/TerminalController.swift Socket commands: profile.list, profile.save, profile.load, profile.delete (v1 + v2)
cmuxTests/ProfileStoreTests.swift 18 unit tests

Socket/CLI API

v1:

profile_list
profile_save <name>
profile_load <name> [--new-window]
profile_delete <name>

v2 JSON:

profile.list   → {profiles: [...], count: N}
profile.save   {name, include_scrollback?} → {id, name, workspace_count}
profile.load   {name, new_window?} → {name, window_id, workspace_count, mode}
profile.delete {name} → {name, deleted}

How it works

Profiles are a thin layer on top of the existing SessionTabManagerSnapshot — the same system powering session restore on app restart. Saving calls tabManager.sessionSnapshot(), loading calls tabManager.restoreSessionSnapshot(). Auto-save piggybacks on the existing 8-second autosave timer.

Testing

  • Unit tests: 18 tests in ProfileStoreTests covering Codable round-trip, save/load/delete/rename, list sorting, filename sanitization, active profile state, snapshot field preservation, edge cases. All passing.
  • Manual verification: Tested all socket commands (v1 + v2) via raw socket — save, load (both modes), delete, list, error cases. Verified auto-save updates profile after workspace changes. Verified sidebar badge appears/disappears correctly.

Demo Video

Screenshot (badge visible in sidebar footer):

The active profile badge sits inline next to the ? button in the sidebar footer, showing the currently loaded profile name (e.g. "Work"). It appears when a profile is loaded and persists across workspace additions/removals since changes auto-save back to the profile.

Checklist

  • I tested the change locally
  • I added or updated tests for behavior changes
  • I updated docs/changelog if needed
  • I requested bot reviews after my latest commit (copy/paste block above or equivalent)
  • All code review bot comments are resolved
  • All human review comments are resolved

Summary by cubic

Add Profiles to save the current workspace layout as a named profile and restore it in the current window or a new one. Makes switching between work/personal setups a one-click action.

  • New Features
    • ProfileStore: save/load/delete/rename/list profiles as JSON in ~/Library/Application Support/cmux/profiles/ (max 24).
    • Menu: File → Workspace → Profiles with save, load (current/new window), delete, and delete-all.
    • UI: sidebar badge shows the active profile name.
    • Autosave: active profiles auto-save on the existing 8s timer and on app quit (includes scrollback).
    • Socket/CLI: v1 profile_list, profile_save, profile_load [--new-window], profile_delete; v2 profile.list|save|load|delete JSON APIs.
    • TabManager: new activeProfileName published property.
    • Tests: 18 unit tests for model, persistence, and integration.

Written for commit d7b2d1f. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Profile management system: save, load, rename, and delete named workspace profiles
    • Active profile indicator displayed in sidebar footer
    • Profiles menu for quick access to profile operations
    • Terminal commands for profile management automation
    • Auto-save of active profiles on application termination
  • Tests

    • Added comprehensive unit tests for profile functionality and persistence

Add a Profiles feature that lets users save their current workspace layout
as a named profile (e.g. "Work", "Personal") and restore it later in the
current window or a new one.

- ProfileStore: save/load/delete/rename/list profiles as JSON in
  ~/Library/Application Support/cmux/profiles/
- Menu: File > Workspace > Profiles submenu with save, load, delete
- Sidebar badge: shows active profile name inline next to help button
- Auto-save: active profiles auto-save on the existing 8s timer and at
  app termination (with scrollback)
- Socket commands: profile.list/save/load/delete (v1 and v2)
- 18 unit tests covering model, persistence, and TabManager integration
@vercel

vercel Bot commented Mar 17, 2026

Copy link
Copy Markdown

@andrekat is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai

coderabbitai Bot commented Mar 17, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 275dcd98-1f15-4d3e-8858-a5882fd1a7e7

📥 Commits

Reviewing files that changed from the base of the PR and between 8d8fadb and d7b2d1f.

📒 Files selected for processing (8)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/ProfileStore.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/cmuxApp.swift
  • cmuxTests/ProfileStoreTests.swift

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive profile management system for saving, loading, renaming, and deleting user session profiles. It adds filesystem-based persistence using JSON encoding, terminal command interfaces (v1 and v2), UI menu integration with dialogs, autosave on app termination, and extensive test coverage for the new functionality.

Changes

Cohort / File(s) Summary
Profile Storage Core
Sources/ProfileStore.swift, cmuxTests/ProfileStoreTests.swift
New Profile struct (Codable, Identifiable, Sendable) and ProfileStore enum managing profiles as JSON files under Application Support. Supports list/load/save/delete/rename operations with max profile limit (24), filename sanitization, atomic writes, and date encoding. Comprehensive unit tests for serialization, CRUD lifecycle, ordering, and edge cases.
App Lifecycle Integration
Sources/AppDelegate.swift
Adds autosaveActiveProfiles(includeScrollback:) helper invoked during app termination and autosave ticks to persist active profiles via ProfileStore.
UI Components
Sources/ContentView.swift
Introduces SidebarActiveProfileBadge view to display active profile indicator in sidebar footer. Updates SidebarFooterButtons to accept and thread through optional activeProfileName parameter.
State Management
Sources/TabManager.swift
Adds activeProfileName published property and setActiveProfileName(_:) setter to track currently loaded profile name; cleared when workspaces are manually modified.
Terminal Command Interfaces
Sources/TerminalController.swift
Introduces v1 profile commands (profile_list, profile_save, profile_load, profile_delete) and corresponding v2 JSON-based endpoints returning V2CallResult payloads. Commands interact with ProfileStore to save/load/manage profiles across window contexts.
App Menu Integration
Sources/cmuxApp.swift
Adds Profiles menu section with: profile listing and saving UI, per-profile actions (open in new window, load in current window, delete), and dialogs for save confirmation, duplicate detection, overwrite options, and deletion confirmation.
Project Configuration
GhosttyTabs.xcodeproj/project.pbxproj
Updates build configuration to include ProfileStore.swift and ProfileStoreTests.swift in appropriate build phases and file references.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Hop through profiles, save with glee,
Every session stored so free!
Load and bounce from state to state,
Workspace magic—truly great! ✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can make CodeRabbit's review stricter and more nitpicky using the `assertive` profile, if that's what you prefer.

Change the reviews.profile setting to assertive to make CodeRabbit's nitpick more issues in your PRs.

@andrekat andrekat closed this Mar 17, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 8 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/cmuxApp.swift">

<violation number="1" location="Sources/cmuxApp.swift:937">
P2: Profiles menu content is derived from ProfileStore.list() without any observable state. Save/delete actions don't update any @State/@Published value, so the SwiftUI command menu may not refresh immediately, leaving saved/deleted profiles stale until unrelated state changes occur.</violation>
</file>

<file name="cmuxTests/ProfileStoreTests.swift">

<violation number="1" location="cmuxTests/ProfileStoreTests.swift:123">
P2: Tests write/delete profiles using the real ProfileStore location (Application Support) with fixed names, so they can overwrite or delete a user’s real profiles and be flaky if data already exists. There’s no test-specific directory override or setup/teardown to isolate storage.</violation>
</file>

<file name="Sources/ProfileStore.swift">

<violation number="1" location="Sources/ProfileStore.swift:149">
P2: rename deletes the original profile before a successful save and ignores delete failure, which can cause data loss or duplicate profiles if save/delete fails.</violation>

<violation number="2" location="Sources/ProfileStore.swift:184">
P2: Profile filenames are derived from a lossy sanitized version of `Profile.name`, but overwrite checks compare the raw name. Distinct names that sanitize to the same filename can overwrite each other or be loaded/deleted incorrectly.</violation>
</file>

<file name="Sources/AppDelegate.swift">

<violation number="1" location="Sources/AppDelegate.swift:2374">
P2: Profile autosave now runs synchronous snapshot creation and JSON file writes on the main thread during each autosave tick, which can cause UI hitches for large scrollback or multiple windows.</violation>

<violation number="2" location="Sources/AppDelegate.swift:3231">
P2: Autosave for active profiles is added to normal termination and timer paths, but the existing fallback shutdown/update lifecycle paths (update relaunch, power-off, session resign while terminating) still only save the session snapshot. In those nonstandard termination paths, profile changes can be lost even though the session snapshot is preserved. Consider invoking autosaveActiveProfiles in those fallback handlers as well.</violation>
</file>

<file name="Sources/TerminalController.swift">

<violation number="1" location="Sources/TerminalController.swift:2328">
P2: system.capabilities does not advertise the new profile.* v2 methods, so clients using capability discovery will not see profile.list/save/load/delete even though they are implemented.</violation>

<violation number="2" location="Sources/TerminalController.swift:10922">
P2: `profile_save`/`profile_load` run on the socket worker thread but mutate AppKit/TabManager state without hopping to the main thread. This differs from the v2 implementations which wrap the same operations in `v2MainSync`, and can trigger AppKit thread-safety violations or workspace races. Wrap these v1 operations in a main-thread hop (e.g., `DispatchQueue.main.sync`) before calling `ProfileStore.saveCurrentSession`, `AppDelegate.createMainWindow`, and `TabManager.restoreSessionSnapshot`/`setActiveProfileName`.</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-ai with guidance or docs links (including llms.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.

Comment thread Sources/cmuxApp.swift

@ViewBuilder
private func profilesMenuContent(manager: TabManager) -> some View {
let profiles = ProfileStore.list()

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Profiles menu content is derived from ProfileStore.list() without any observable state. Save/delete actions don't update any @State/@published value, so the SwiftUI command menu may not refresh immediately, leaving saved/deleted profiles stale until unrelated state changes occur.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/cmuxApp.swift, line 937:

<comment>Profiles menu content is derived from ProfileStore.list() without any observable state. Save/delete actions don't update any @State/@Published value, so the SwiftUI command menu may not refresh immediately, leaving saved/deleted profiles stale until unrelated state changes occur.</comment>

<file context>
@@ -928,6 +930,182 @@ struct cmuxApp: App {
+
+    @ViewBuilder
+    private func profilesMenuContent(manager: TabManager) -> some View {
+        let profiles = ProfileStore.list()
+
+        Menu(String(localized: "menu.file.profiles", defaultValue: "Profiles")) {
</file context>
Fix with Cubic

var profile = Profile(name: "SaveLoad", snapshot: snapshot)
profile.updatedAt = Date()

XCTAssertTrue(ProfileStore.save(profile))

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Tests write/delete profiles using the real ProfileStore location (Application Support) with fixed names, so they can overwrite or delete a user’s real profiles and be flaky if data already exists. There’s no test-specific directory override or setup/teardown to isolate storage.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxTests/ProfileStoreTests.swift, line 123:

<comment>Tests write/delete profiles using the real ProfileStore location (Application Support) with fixed names, so they can overwrite or delete a user’s real profiles and be flaky if data already exists. There’s no test-specific directory override or setup/teardown to isolate storage.</comment>

<file context>
@@ -0,0 +1,333 @@
+        var profile = Profile(name: "SaveLoad", snapshot: snapshot)
+        profile.updatedAt = Date()
+
+        XCTAssertTrue(ProfileStore.save(profile))
+
+        let loaded = ProfileStore.load(name: "SaveLoad")
</file context>
Fix with Cubic

}

// Delete old file first (the filename is derived from the name).
delete(name: oldName)

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: rename deletes the original profile before a successful save and ignores delete failure, which can cause data loss or duplicate profiles if save/delete fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ProfileStore.swift, line 149:

<comment>rename deletes the original profile before a successful save and ignores delete failure, which can cause data loss or duplicate profiles if save/delete fails.</comment>

<file context>
@@ -0,0 +1,196 @@
+        }
+
+        // Delete old file first (the filename is derived from the name).
+        delete(name: oldName)
+
+        profile.name = trimmedNew
</file context>
Fix with Cubic

let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
// Replace characters that are problematic in filenames.
let sanitized = trimmed.replacingOccurrences(

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Profile filenames are derived from a lossy sanitized version of Profile.name, but overwrite checks compare the raw name. Distinct names that sanitize to the same filename can overwrite each other or be loaded/deleted incorrectly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ProfileStore.swift, line 184:

<comment>Profile filenames are derived from a lossy sanitized version of `Profile.name`, but overwrite checks compare the raw name. Distinct names that sanitize to the same filename can overwrite each other or be loaded/deleted incorrectly.</comment>

<file context>
@@ -0,0 +1,196 @@
+        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return "" }
+        // Replace characters that are problematic in filenames.
+        let sanitized = trimmed.replacingOccurrences(
+            of: "[/\\\\:*?\"<>|]",
+            with: "_",
</file context>
Fix with Cubic

Comment thread Sources/AppDelegate.swift
@@ -2371,12 +2371,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Autosave for active profiles is added to normal termination and timer paths, but the existing fallback shutdown/update lifecycle paths (update relaunch, power-off, session resign while terminating) still only save the session snapshot. In those nonstandard termination paths, profile changes can be lost even though the session snapshot is preserved. Consider invoking autosaveActiveProfiles in those fallback handlers as well.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/AppDelegate.swift, line 3231:

<comment>Autosave for active profiles is added to normal termination and timer paths, but the existing fallback shutdown/update lifecycle paths (update relaunch, power-off, session resign while terminating) still only save the session snapshot. In those nonstandard termination paths, profile changes can be lost even though the session snapshot is preserved. Consider invoking autosaveActiveProfiles in those fallback handlers as well.</comment>

<file context>
@@ -3224,6 +3227,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
     }
 
+    /// Auto-saves any active profiles so changes are persisted without explicit user action.
+    private func autosaveActiveProfiles(includeScrollback: Bool = false) {
+        let contexts = Array(mainWindowContexts.values)
+        for context in contexts {
</file context>
Fix with Cubic

Comment thread Sources/AppDelegate.swift
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
isTerminatingApp = true
_ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false)
autosaveActiveProfiles(includeScrollback: true)

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Profile autosave now runs synchronous snapshot creation and JSON file writes on the main thread during each autosave tick, which can cause UI hitches for large scrollback or multiple windows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/AppDelegate.swift, line 2374:

<comment>Profile autosave now runs synchronous snapshot creation and JSON file writes on the main thread during each autosave tick, which can cause UI hitches for large scrollback or multiple windows.</comment>

<file context>
@@ -2371,12 +2371,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
     func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
         isTerminatingApp = true
         _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false)
+        autosaveActiveProfiles(includeScrollback: true)
         return .terminateNow
     }
</file context>
Fix with Cubic

return v2Result(id: id, self.v2MarkdownOpen(params: params))

// Profiles
case "profile.list":

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: system.capabilities does not advertise the new profile.* v2 methods, so clients using capability discovery will not see profile.list/save/load/delete even though they are implemented.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TerminalController.swift, line 2328:

<comment>system.capabilities does not advertise the new profile.* v2 methods, so clients using capability discovery will not see profile.list/save/load/delete even though they are implemented.</comment>

<file context>
@@ -2313,6 +2324,16 @@ class TerminalController {
             return v2Result(id: id, self.v2MarkdownOpen(params: params))
 
+        // Profiles
+        case "profile.list":
+            return v2Result(id: id, self.v2ProfileList(params: params))
+        case "profile.save":
</file context>
Fix with Cubic

guard let tabManager else {
return "ERROR: TabManager not available"
}
guard let profile = ProfileStore.saveCurrentSession(name: name, tabManager: tabManager) else {

@cubic-dev-ai cubic-dev-ai Bot Mar 17, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: profile_save/profile_load run on the socket worker thread but mutate AppKit/TabManager state without hopping to the main thread. This differs from the v2 implementations which wrap the same operations in v2MainSync, and can trigger AppKit thread-safety violations or workspace races. Wrap these v1 operations in a main-thread hop (e.g., DispatchQueue.main.sync) before calling ProfileStore.saveCurrentSession, AppDelegate.createMainWindow, and TabManager.restoreSessionSnapshot/setActiveProfileName.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TerminalController.swift, line 10922:

<comment>`profile_save`/`profile_load` run on the socket worker thread but mutate AppKit/TabManager state without hopping to the main thread. This differs from the v2 implementations which wrap the same operations in `v2MainSync`, and can trigger AppKit thread-safety violations or workspace races. Wrap these v1 operations in a main-thread hop (e.g., `DispatchQueue.main.sync`) before calling `ProfileStore.saveCurrentSession`, `AppDelegate.createMainWindow`, and `TabManager.restoreSessionSnapshot`/`setActiveProfileName`.</comment>

<file context>
@@ -10758,6 +10898,93 @@ class TerminalController {
+        guard let tabManager else {
+            return "ERROR: TabManager not available"
+        }
+        guard let profile = ProfileStore.saveCurrentSession(name: name, tabManager: tabManager) else {
+            return "ERROR: Failed to save profile '\(name)'"
+        }
</file context>
Fix with Cubic

genesiscz added a commit to genesiscz/GenesisTools that referenced this pull request Apr 28, 2026
* feat(cmux): add tools cmux profiles save / view / restore

Snapshot cmux workspace layouts (windows, panes, surfaces, geometry, cwd, last
shell-history command) into named JSON profiles under
~/.genesis-tools/cmux/profiles/, then recreate them on demand. Geometry
fidelity uses the new pane.list pixel_frame fields (cmux 0.63.0+); restore is
non-destructive (always creates new prefixed workspaces).

Why: cmux's built-in autosave only restores layout on relaunch and offers no
named profile system (PR manaflow-ai/cmux#1617 was closed unmerged). This tool
bridges the gap so you can switch between curated work setups on the fly.

* docs(cmux): add comprehensive Cmux.md command reference

Covers every cmux CLI verb and JSON-RPC method tools cmux uses, with the sharp
edges I hit during implementation:

- pane.list and surface.split both ignore the workspace/surface params and
  operate on the focused workspace/pane — must use the CLI for splits and
  focus-switch for cross-workspace reads.
- new-workspace --name is best-effort; cmux often overrides with auto title.
- cmux --json is inconsistent across commands (some return JSON, some plain
  text "OK <ref>"). Each verb noted.
- tab-action's allow-list is unwritten; known-good action is "rename".
- pane geometry (pixel_frame, columns, rows) requires cmux 0.63.0+.

Includes a step-by-step trace of what tools cmux profiles save / restore
actually invokes, useful as a debugging reference.

* fix(cmux/save): propagate interactive overwrite confirmation to store.write

After user confirmed "Overwrite?" interactively, the `force: false` from
the original flag was still passed to `store.write`, triggering
ProfileExistsError every time. Track `forceWrite` separately and set it
to `true` after the user confirms.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/snapshot): ListPaneSurfacesResponse was wrapping a bare array + use identify.focused for scope

cmux list-pane-surfaces returns a bare JSON array, not {surfaces:[]}.
The old type caused surfacesInfo.surfaces to always be undefined,
making the spread crash at runtime.

Also fixes scope=window/workspace in multi-window sessions: instead of
all.find((ws)=>ws.selected) which ambiguously picks the first selected
workspace across all windows, we now call `cmux identify --json` to get
the globally focused workspace_ref and derive the target window from that.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/cli): switch to Bun.spawn and reject signal-terminated processes

Replaces node:child_process.spawn with Bun.spawn (per CLAUDE.md). Also
fixes the bug where a signal-killed cmux process (code===null) was
resolved as exit code 0, causing runCmuxJSON to attempt JSON parsing on
potentially truncated output. Also uses SafeJSON per project convention.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/shell-probe): handle paths with spaces and bare ~ or … titles

The user@host:path regex used \S+ which breaks on directory paths
containing spaces. Switching to .+ captures the full remainder of the
title after the colon.

Also adds the bare ~ and … edge cases (without trailing slash) to the
condition so they reach expandHome(), which already handles them.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/profiles): add suggestCommand to non-interactive refusals

Per repo convention (CLAUDE.md), non-interactive failure paths should
pair the error with the exact rerun command. delete and restore were
only printing a generic error message.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/restore): shell-quote cwd path before sending cd command

Raw interpolation `cd ${surface.cwd}\n` breaks on paths with spaces
and treats shell metacharacters literally (e.g. /tmp/a b or /tmp/x;echo).
Now uses POSIX single-quote escaping: cd -- '<escaped-path>'.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/edit): switch to Bun.spawn and guard empty EDITOR env value

Drops node:child_process.spawn in favour of Bun.spawn (per CLAUDE.md).
Also adds a length guard before accessing parts[0] so a whitespace-only
$EDITOR value safely falls back to vi instead of spawning undefined.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/resize): re-read layout after MAX_ITERATIONS to get accurate convergence report

The loop measured deltas before applying resize calls, so when iteration
hit MAX_ITERATIONS the final pass's resize effects were never measured.
converged could be false even when the last pass pushed every pane within
TOLERANCE_CELLS. A final paneList read is now done after the loop exits
without converging so the logged deltas and returned converged flag
reflect the actual post-resize state.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/store): normalize profile.name and version at write time

write(name, profile) could persist a file whose internal profile.name
differed from the filename key, creating inconsistent behaviour in
list/view/restore. Now the written JSON always has name===name and
version===PROFILE_VERSION regardless of what the caller passed in.
Also uses SafeJSON per project convention.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux/docs): add language labels to all unlabeled fenced code blocks

Fixes MD040 lint warnings. Plain-text diagrams and error snippets are
now tagged as `text`; shell walkthroughs are tagged as `bash`.

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* style(cmux): apply biome import-order and trailing-comma formatting

https://claude.ai/code/session_011bfXscyS4Q7VYaLtHtviLK

* fix(cmux): per-pane screen replay, correct surface placement, split-time resize

Three bugs the smoke save+restore comparison surfaced, all rooted in cmux
0.63.2 quirks where v1 RPC routing falls back to the focused workspace/pane
instead of using explicit params:

1. Surfaces stacking into one pane.
   `surface.create { pane }` ignores the param and creates the surface in
   the focused pane (verified: ask for pane:54/workspace:17, get pane:5/
   workspace:2). Switched to `cmux new-surface --pane <ref>` CLI which is
   v2-routed.

2. Pane sizes never converging.
   Post-hoc `convergeToTarget` loop tried to move borders that don't exist
   in the binary split tree; cmux silently rejects with "no adjacent border".
   Replaced with split-time resize: each `new-split` is followed immediately
   by one `resize-pane` call, while only two panes share that border. Direction
   choice picks the pane that has a neighbor in that direction (-L/-U on NEW,
   -R/-D on OLD) so cmux never errors. `--amount` is empirically pixels not
   cells, so deltas multiply by `cell_width_px` / `cell_height_px`.

3. Wrong "command pre-typed at restored prompt".
   The previous global $HISTFILE hint stamped the same string (typically the
   `tools cmux profiles save` invocation) on every pane. Replaced with per-pane
   `cmux capture-pane` parsing, only matching prompts with directory/git/host
   context (oh-my-zsh, [user@host dir]$, user@host:dir$); bare `❯`/`$` are
   rejected to avoid Claude/Forge input boxes capturing chat text.

Plus a new feature: visible terminal content is captured per-pane and replayed
on restore via `printf '\033[2J\033[3J\033[H'; printf %s '<base64>' | base64 -d`,
so each restored pane shows its saved screen with a fresh shell prompt below.

Cmux.md updated with the four V1 routing bugs we hit (`pane.list`,
`surface.split`, `surface.create`, `surface.read_text`), the broken-in-0.63.2
`capture-pane --scrollback` flag, the ANSI-stripping (monochrome replay), and
the pixel-based `--amount`. `convergeToTarget` and the `resize.ts` module
removed; tests grew from 20 to 30, all passing.

* fix(cmux/snapshot): list-pane-surfaces does return a {surfaces:[]} wrapper

Empirically verified — `cmux --json list-pane-surfaces --workspace <ws> --pane <p>`
on cmux 0.63.2 returns:

  {
    "pane_ref": "...",
    "workspace_ref": "...",
    "window_ref": "...",
    "surfaces": [...]
  }

The earlier "fix" that retyped this as a bare `SurfaceListEntry[]` array was
wrong against the live CLI — `[...surfacesInfo]` then threw `Spread syntax
requires ...iterable[Symbol.iterator] to be a function` and aborted save with
no profile written. Restore the wrapper interface and the `.surfaces` access.

* feat(cmux/save): clack multiselect step — pick what to capture per pane

After the scope picker (and before the name prompt) the interactive save
flow now asks which per-pane data to record:

  ◆  What should be captured per pane?
  ◇  Working directory — from each pane's tab title
  ◇  Visible screen contents — saved scrollback redrawn on restore
  ◇  Last shell command — pre-typed (not executed) at the new prompt

All three default-checked. The prompt is skipped when any of `--no-cwd`,
`--no-screen`, `--no-history` is explicitly passed (those are unambiguous
intent) and in non-interactive mode (where flags are the only input).

Closes the gap user flagged: saving used to silently capture everything
with no chance to opt out short of remembering the right `--no-*` flag.

* fix(cmux/snapshot): skip screen capture for the surface running save

The pane where the user invokes \`tools cmux profiles save\` has the save
command itself echoed at the bottom of its visible content, plus the
running clack prompts. Capturing that pane's screen would record the save
invocation as the "saved scrollback" and replay it verbatim into the
restored pane — meta-circular.

Get the caller surface_ref from \`cmux identify\`'s \`caller\` field, plumb
it through to captureSurface, and pass screen:false for that one surface
only. Other panes still capture screen normally; cwd and command capture
are unaffected for the caller too.

* fix(cmux): address remaining PR #154 review comments

- cli.ts: parse cmux --json subprocess output in strict mode (rejects
  comments / malformed JSON)
- shell-probe.ts: only treat user@host: title suffix as cwd when it
  looks like a path; titles like "user@host:build" no longer round-trip
  as `cd build` on restore
- snapshot.ts: throw a clear error when --window/--workspace ref does
  not match anything (instead of silently writing an empty profile)
- save.ts: reject incompatible --window/--workspace + --scope combos
  up front; validate positional profile names with the same rules used
  by the interactive prompt
- edit.ts: description mentions \$VISUAL and \$EDITOR; ProfileNotFoundError
  is caught locally and prints a clean message + exits 1
- restore.ts: pass full error object to logger.warn (preserves stack)
- Cmux.md: fix broken anchor for "the BIG GOTCHA" heading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant