Skip to content

Fix terminal scrollback not restoring on quit/relaunch - #6615

Open
grantland wants to merge 3 commits into
manaflow-ai:mainfrom
grantland:fix-scrollback-persist-unknown-shell-state
Open

grantland wants to merge 3 commits into
manaflow-ai:mainfrom
grantland:fix-scrollback-persist-unknown-shell-state

Conversation

@grantland

@grantland grantland commented Jun 22, 2026 •

Copy link
Copy Markdown

Summary

Fixes #2823. Likely also resolves #2016 (restore terminal scrollback content across app restarts) — confirm scope and change to Fixes #2016 if it fits.

(The earlier 150dc9036 addressed the glued prompt replay sub-symptom of #2823; this fixes the remaining half — scrollback was never captured/persisted, so terminals restored empty. Capture + the existing replay fix together make scrollback restore.)

Related / adjacent (not closed by this PR):

What changed?
Terminal scrollback is now persisted at session save unless the shell is positively running a command. Previously persistence was gated on !closeConfirmationRequired, which coupled it to idle/close-confirmation detection: when a panel's shell-activity state was unavailable (.unknown/nil), the conservative needsConfirmClose fallback skipped persistence and the terminal restored empty.

  • WorkspaceSessionRestorePolicyService.shouldPersistSessionScrollback now takes shellActivityState and returns (state ?? .unknown) != .commandRunning (persist for .promptIdle and .unknown/nil; skip only .commandRunning).
  • The session-snapshot gate in Workspace.swift passes the panel's shell state directly (the now-unused closeConfirmationRequired local is removed).
  • The Workspace.shouldPersistSessionScrollback static wrapper keeps its signature; fallbackNeedsConfirmClose is explicitly retired (documented).
  • shouldReplaySessionScrollback still excludes agent/tmux/resume terminals; the close-confirmation warning path (resolveCloseConfirmation) is unchanged.

Why?
Regression from 5f074f810 (2026-03-13, "Fix session restore replay for transient terminal states"). Before it, snapshots always captured scrollback when includeScrollback was set. That commit added a gate to avoid persisting transient/mid-command screens, but it over-applied: it treated .unknown (no shell-integration report yet) the same as transient, so any terminal without a fresh prompt-idle report stopped persisting scrollback. This fix narrows the gate to its original intent — skip only states we positively know are mid-command — so scrollback is no longer silently dropped when the shell state is merely unknown.

Note: this is the resilient behavior fix. There is a separate, deeper record-side issue (see Testing) where prod delivers prompt-idle reports but shellState still ends up .unknown at snapshot time; that is tracked as a follow-up and is not addressed here.

Testing

How did you test this change?

  • Added a red/green pair: a failing app-level regression test (SessionPersistenceTests.testSessionScrollbackPersistsUnlessCommandRunning) that asserts the corrected contract, then the fix. Added a package-level unit test (WorkspaceSessionRestorePolicyServiceTests.scrollbackPersistencePolicy) covering .promptIdle/.unknown/nil/.commandRunning.
  • Empirically verified the behavior: with the gate change, a build whose shellState is always .unknown (the exact failing condition) now captures scrollback at quit (session snapshot gains a scrollback key with content) where it previously produced no scrollback key at all.

What did you verify manually?

  • Reproduced the bug: prod session snapshots have no scrollback key for plain idle terminals (live prod: 7 plain terminals, 0 with scrollback).
  • Instrumented the snapshot gate and confirmed capture was skipped because shellState == nil → closeConfirmationRequired == true → shouldPersistScrollback == false; the read path (VT export / live-surface) was never reached.
  • Confirmed prod's shell integration delivers reports (socket live, CMUX_TAB_ID/CMUX_PANEL_ID set, _CMUX_SEND_TOOL=nc, socket replies OK) — so the bug is record-side, not send-side.

Manually tested locally: built the fix into a local Release-config app and confirmed at quit that a plain terminal with shellState=nil now writes a scrollback key with content to the session snapshot (previously the key was absent). Caveat: the automated red/green suites (swift test/xcodebuild test) run on CI — please confirm green there. Harness note: builds via scripts/reload.sh have working shell-integration reporting (shellState becomes .promptIdle), while plain xcodebuild builds do not — Debug-vs-Release was a red herring. Verify persistence with shellState forced (the unit tests), not by eyeballing a local build.

Demo Video

For UI or behavior changes, include a short demo video (GitHub upload, Loom, or other direct link).

  • Video URL or attachment: TODO — record: open a plain terminal, run a few commands, Cmd+Q, relaunch → scrollback contents restored (before this change they were blank).

Review Trigger (Copy/Paste as PR comment)

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

Checklist

  • I tested the change locally
  • I added or updated tests for behavior changes
  • I updated docs/changelog if needed (not needed — changelog is generated from commits at release; no documented behavior changes)
  • I requested bot reviews after my latest commit (trigger block posted as a PR comment)
  • All code review bot comments are resolved
  • All human review comments are resolved

Summary by CodeRabbit

  • Improvements
    • Enhanced terminal session restoration to intelligently preserve scrollback based on shell activity. Session history is now retained when the shell is idle or in unknown states, and only excluded when commands are actively running.

grantland and others added 2 commits June 22, 2026 15:31
Terminals restore with no scrollback whenever shell-integration state is
unavailable (.unknown/nil): the conservative needsConfirmClose fallback routes
through close-confirmation and skips persistence. This test encodes the correct
contract (persist unless a command is positively running) and fails against the
current implementation.

Co-authored-by: Claude <noreply@anthropic.com>
…i#2823)

Regression introduced by 5f074f8 (2026-03-13, "Fix session restore replay for
transient terminal states"). Before that commit, session snapshots always captured
terminal scrollback when includeScrollback was set. It added a shouldPersistScrollback
gate to avoid persisting transient/mid-command screens; the gate became
`!closeConfirmationRequired`, derived from the panel's shell-activity state:
  .promptIdle     -> persist
  .commandRunning -> skip
  .unknown / nil  -> needsConfirmClose() fallback (conservative; usually skip)

So any terminal whose shell-activity state is .unknown/nil at snapshot time silently
stopped persisting scrollback and restores empty.

Fix (faithful to 5f074f8's intent of skipping only transient states): persist for
.promptIdle AND .unknown/nil, skip only .commandRunning. shouldReplaySessionScrollback
still excludes agent/tmux/resume terminals; the close-confirmation *warning* path
(resolveCloseConfirmation) is unchanged. This is defense-in-depth: scrollback should
never be dropped just because we are unsure the shell is idle.

How it was found / repro:
- Prod session files have no `scrollback` key for plain idle terminals (live prod: 7
  plain terminals, 0 with scrollback).
- Instrumenting the snapshot gate showed capturedScrollback was skipped because
  shellState==nil -> closeConfirmationRequired==true -> shouldPersist==false; the read
  path (VT export / live-surface) was never reached.
- Harness gotcha: builds via scripts/reload.sh have working shell-integration reporting
  (shellState becomes .promptIdle, scrollback persists), while plain `xcodebuild` builds
  do not (report_shell_state never fires). Debug-vs-Release was a red herring. Verify
  persistence behavior with shellState forced (WorkspaceSessionRestorePolicyServiceTests),
  not by eyeballing a local build.

Open follow-up (separate from this change): prod's shell integration DOES report
(socket live, CMUX_TAB_ID/PANEL_ID set, sends "prompt"), and v0.64.16 has the full
report->parse chain, yet shellState is still .unknown at the quit snapshot. The reported
state is not landing as .promptIdle by snapshot time -- likely a recording/keying/timing
issue in the report->shellState pipeline (candidate fixes already on main, e.g.
"stable surface ids across restore"). This change makes scrollback robust regardless,
but the reporting-to-snapshot gap is worth its own investigation.

Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

@grantland

Copy link
Copy Markdown
Author

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@cubic-dev-ai

cubic-dev-ai Bot commented Jun 22, 2026

Copy link
Copy Markdown

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

@grantland I can't start this review because your workspace has reached its free monthly review limit. cubic has reviewed 241,260 of the 240,000 allowed lines of code this month. Reviews resume on 1 July 2026 (in 9 days). Paid plans include much higher monthly review limits. Upgrade now to resume reviews.

To help optimise your usage, you can tune cubic to get the most out of your usage limits:

Learn more →

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026 •

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 67ad929b-67a0-45d2-8fa8-88769db48e36

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

WorkspaceSessionRestorePolicyService.shouldPersistSessionScrollback is updated to accept shellActivityState: PanelShellActivityState? instead of closeConfirmationRequired: Bool. Scrollback is now persisted for all states except .commandRunning (including nil/.unknown). Workspace is updated to pass shell activity state directly, removing the resolveCloseConfirmation derivation. Tests are added and updated accordingly.

Changes

Scrollback Persistence Refactor

Layer / File(s) Summary
Policy service: new shellActivityState API
Packages/macOS/CmuxWorkspaces/Sources/.../WorkspaceSessionRestorePolicyService.swift, Packages/macOS/CmuxWorkspaces/Tests/.../WorkspaceSessionRestorePolicyServiceTests.swift
shouldPersistSessionScrollback signature changes from closeConfirmationRequired: Bool to shellActivityState: PanelShellActivityState?; returns false only for .commandRunning, true for all other states including nil. New unit test scrollbackPersistencePolicy covers .promptIdle, .unknown, nil, and .commandRunning.
Workspace call site and integration tests
Sources/Workspace.swift, cmuxTests/SessionPersistenceTests.swift
Workspace.shouldPersistSessionScrollback no longer calls resolveCloseConfirmation(...); it forwards shellActivityState directly to the policy service. The snapshot path removes the closeConfirmationRequired derivation. Integration test renamed to testSessionScrollbackPersistsUnlessCommandRunning; assertions for .unknown/nil flipped to expect persistence even when fallbackNeedsConfirmClose is true.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 A command is running? Then hold back the scroll!
But idle or unknown? Let the history roll.
No more close-confirm to muddy the view—
Shell state alone tells us what to do.
Hop hop, the logic is crisp and true! 🌿


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Cache Substitution Correctness ❌ Error PR swaps fresh read (resolveCloseConfirmation) for cached panelShellActivityStates in persistence path without handling stale cache: PR acknowledges "prod delivers prompt-idle reports but shellStat... Add event-driven freshness check or fallback to fresh surface read in sessionPanelSnapshot before using panelShellActivityStates for persistence decision, or document staleness-tolerance rationale.
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (21 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: terminal scrollback restoration on app quit and relaunch, which is the central problem addressed across all modified files.
Description check ✅ Passed The description comprehensively covers the problem, solution, testing approach, and verification steps. All required template sections (Summary, Testing, Checklist) are complete with detailed explanations and substantive content.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Production changes properly respect Swift 6 actor isolation: PanelShellActivityState and WorkspaceSessionRestorePolicyService are Sendable, Workspace is @MainActor, and new static method is nonisol...
Cmux Swift Blocking Runtime ✅ Passed Production Swift changes introduce no blocking or timing-based synchronization primitives; shouldPersistSessionScrollback is pure synchronous logic performing enum equality checks without semaphore...
Cmux Expensive Synchronous Load ✅ Passed PR changes shouldPersistSessionScrollback to perform simple enum comparison only: (shellActivityState ?? .unknown) != .commandRunning, with no RestorableAgentSessionIndex.load, JSON parsing, or exp...
Cmux No Hacky Sleeps ✅ Passed All PR changes are in Swift files (.swift), which are explicitly excluded from this check. Rule scope: TypeScript, JavaScript, shell, or build/runtime scripts only. Swift timing primitives covered...
Cmux Algorithmic Complexity ✅ Passed PR introduces no algorithmic complexity violations. Changes are O(1) constant-time operations (dictionary lookups and enum comparisons) within capped panel iteration; complexity is simplified vs. p...
Cmux Swift Concurrency ✅ Passed The PR refactors scrollback persistence logic to use shell activity state instead of close-confirmation flags. All changes are pure synchronous boolean comparisons with no DispatchQueue, Tasks, Com...
Cmux Swift @Concurrent ✅ Passed All Swift changes comply with concurrent annotation rules: nonisolated static method is synchronous and doesn't access @MainActor state; no invalid @concurrent annotations; no async/await violation...
Cmux Swift File And Package Boundaries ✅ Passed PR changes respect file/package boundaries: session restore policy logic refined in dedicated SwiftPM package (198 lines, focused responsibility); Workspace.swift changes decrease file size by 2 li...
Cmux Swiftpm Lockfiles ✅ Passed All SwiftPM lockfile rules met: 12 Package.resolved files added, cmux-owned .gitignore files don't ignore Package.resolved, root Xcode lockfile included.
Cmux Swift Logging ✅ Passed PR introduces no print, debugPrint, dump, NSLog, file I/O, or ad hoc logging in app/runtime code. Changes are logic-only with proper tests.
Cmux User-Facing Error Privacy ✅ Passed PR contains only internal logic and test changes with no user-facing error messages, alerts, or sensitive implementation details exposed to users.
Cmux Full Internationalization ✅ Passed No user-facing text, string catalogs, or localization files were modified. Changes are pure logic/implementation refactoring with developer-facing documentation only.
Cmux Swiftui State Layout ✅ Passed This PR contains no SwiftUI state changes. All modifications are to Swift service classes and test logic (session scrollback persistence). No new @Published, @Observable, @StateObject, @Environment...
Cmux Architecture Rethink ✅ Passed Small correctness fix decoupling scrollback persistence from close-confirmation logic with clear invariant: persist unless shell positively running. No timing machinery, mutable flags, observers, o...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR changes only session scrollback persistence logic without adding or modifying any NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup code. Not applicable to this check.
Cmux Source Artifacts ✅ Passed All 4 changed files are hand-written Swift source or test files in proper locations: production source code in Sources/ and Packages/*/Sources/, test code in Tests/ and cmuxTests/. No build output,...
Cmux No Test Or Debug Seam In Production Source ✅ Passed PR introduces no test/debug seams in production source files. Changed methods in WorkspaceSessionRestorePolicyService and Workspace contain only clean logic with no #if DEBUG blocks, test-specific...
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026 •

Copy link
Copy Markdown

@grantland I’ll review the PR changes now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@greptile-apps

greptile-apps Bot commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR narrows the scrollback persistence gate from !closeConfirmationRequired to shellActivityState != .commandRunning, so terminals with unknown or unreported shell state (nil/.unknown) now capture scrollback at session save instead of restoring empty. The bug traced to a 2026-03-13 regression where the conservative needsConfirmClose fallback incorrectly treated unrecognized shell state the same as a running command.

  • WorkspaceSessionRestorePolicyService.shouldPersistSessionScrollback now accepts shellActivityState: PanelShellActivityState? and persists for .promptIdle, .unknown, and nil; skips only .commandRunning.
  • The snapshot gate in Workspace.swift removes the intermediate closeConfirmationRequired local and passes shell-activity state directly; the static wrapper drops the now-unused fallbackNeedsConfirmClose parameter, completing the clean-up flagged in prior review threads.
  • Red/green regression tests are added at both the package level (WorkspaceSessionRestorePolicyServiceTests) and the app level (SessionPersistenceTests).

Confidence Score: 5/5

Safe to merge — the change is a targeted one-liner fix with exhaustive test coverage at both the package and app level.

The gate change is correct for all three enum cases plus nil: PanelShellActivityState has exactly .unknown, .promptIdle, and .commandRunning — all are covered by the tests. All call sites have been updated, the prior dead-parameter issue flagged in review threads is fully resolved, and resolveCloseConfirmation remains in place for the close-confirmation warning path (unchanged). The only acknowledged open item is a deeper record-side issue (shell state staying .unknown even when reports arrive) which is explicitly tracked as a follow-up and does not affect the correctness of this fix.

No files require special attention.

Important Files Changed

Filename Overview
Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/WorkspaceSessionRestorePolicyService.swift Core fix: replaces closeConfirmationRequired: Bool with shellActivityState: PanelShellActivityState?; the new one-liner correctly covers all three enum cases plus nil.
Sources/Workspace.swift Removes the intermediate closeConfirmationRequired local in the snapshot gate and drops the dead fallbackNeedsConfirmClose parameter from the static wrapper, resolving all prior review-thread concerns.
Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/WorkspaceSessionRestorePolicyServiceTests.swift New scrollbackPersistencePolicy test covers all four cases (promptIdle, unknown, nil, commandRunning) with clear regression comments.
cmuxTests/SessionPersistenceTests.swift App-level regression test renamed and updated; previously passing .unknown + fallbackNeedsConfirmClose: true yielded false (wrong), now correctly expects true.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Session Save / Quit] --> B[panelShellActivityStates panelId]
    B --> C{shellActivityState?}
    C -- nil --> D[Persist scrollback ✅ was: conservative needsConfirmClose → skip ❌]
    C -- .unknown --> E[Persist scrollback ✅ was: needsConfirmClose → skip ❌]
    C -- .promptIdle --> F[Persist scrollback ✅]
    C -- .commandRunning --> G[Skip scrollback ✅]
    D --> H[TerminalController readTerminalTextForSnapshot]
    E --> H
    F --> H
    H --> I[SessionTerminalPanelSnapshot scrollback: content]
    G --> J[SessionTerminalPanelSnapshot scrollback: nil]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Session Save / Quit] --> B[panelShellActivityStates panelId]
    B --> C{shellActivityState?}
    C -- nil --> D[Persist scrollback ✅ was: conservative needsConfirmClose → skip ❌]
    C -- .unknown --> E[Persist scrollback ✅ was: needsConfirmClose → skip ❌]
    C -- .promptIdle --> F[Persist scrollback ✅]
    C -- .commandRunning --> G[Skip scrollback ✅]
    D --> H[TerminalController readTerminalTextForSnapshot]
    E --> H
    F --> H
    H --> I[SessionTerminalPanelSnapshot scrollback: content]
    G --> J[SessionTerminalPanelSnapshot scrollback: nil]
Loading

Reviews (5): Last reviewed commit: "Remove now-dead fallbackNeedsConfirmClos..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes terminal scrollback not being captured at session save, causing terminals to restore empty after quit/relaunch. The root cause was that the scrollback persistence gate was coupled to close-confirmation logic: when shellState was .unknown or nil, the conservative needsConfirmClose fallback caused persistence to be skipped entirely.

  • WorkspaceSessionRestorePolicyService.shouldPersistSessionScrollback is re-keyed from closeConfirmationRequired: Bool to shellActivityState: PanelShellActivityState?, persisting for .promptIdle and .unknown/nil and skipping only for .commandRunning.
  • The snapshot gate in Workspace.swift now passes the panel's shell-activity state directly, removing the intermediate closeConfirmationRequired local; the static wrapper retains its existing signature but explicitly ignores fallbackNeedsConfirmClose.
  • A package-level unit test and an app-level regression test together cover all four shell-state cases and verify the contract holds regardless of the close-confirmation fallback value.

Confidence Score: 4/5

The core change is narrow and well-tested; the static wrapper's dead fallbackNeedsConfirmClose parameter is the only rough edge.

The scrollback persistence logic is simple and correct, and both a package-level unit test and an app-level regression test cover all meaningful states. The one rough edge is the static Workspace.shouldPersistSessionScrollback wrapper, which retains fallbackNeedsConfirmClose in its signature but immediately voids it — a dead parameter in an internal API that future contributors could misread as meaningful.

Sources/Workspace.swift — specifically the shouldPersistSessionScrollback static wrapper and its dead fallbackNeedsConfirmClose parameter.

Important Files Changed

Filename Overview
Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/WorkspaceSessionRestorePolicyService.swift Signature changed from closeConfirmationRequired: Bool to shellActivityState: PanelShellActivityState?; implementation is a clean one-liner (shellActivityState ?? .unknown) != .commandRunning that correctly persists for both .promptIdle and .unknown/nil while skipping only .commandRunning.
Sources/Workspace.swift Snapshot gate correctly passes shell-activity state directly; static wrapper retains the now-unused fallbackNeedsConfirmClose parameter and silently discards it with _ = fallbackNeedsConfirmClose, leaving a dead parameter in an internal API.
Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/WorkspaceSessionRestorePolicyServiceTests.swift New package-level unit test covers all four PanelShellActivityState cases (.promptIdle, .unknown, nil, .commandRunning) with correct expected values.
cmuxTests/SessionPersistenceTests.swift Regression test updated to assert that .unknown and nil shell states persist scrollback even when fallbackNeedsConfirmClose: true; the changed assertions correctly encode the new contract.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Session snapshot triggered] --> B{shellActivityState}
    B -->|commandRunning| C[shouldPersist = false, scrollback skipped]
    B -->|promptIdle| D[shouldPersist = true]
    B -->|unknown or nil| E[shouldPersist = true, FIXED]
    D --> F{shouldReplaySessionScrollback?}
    E --> F
    F -->|hasRestorableAgent / tmux / resumeWork| G[replay = false, no scrollback written]
    F -->|plain idle terminal| H[readTerminalTextForSnapshot]
    H --> I[scrollback key written to snapshot]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Session snapshot triggered] --> B{shellActivityState}
    B -->|commandRunning| C[shouldPersist = false, scrollback skipped]
    B -->|promptIdle| D[shouldPersist = true]
    B -->|unknown or nil| E[shouldPersist = true, FIXED]
    D --> F{shouldReplaySessionScrollback?}
    E --> F
    F -->|hasRestorableAgent / tmux / resumeWork| G[replay = false, no scrollback written]
    F -->|plain idle terminal| H[readTerminalTextForSnapshot]
    H --> I[scrollback key written to snapshot]
Loading

Reviews (1): Last reviewed commit: "Persist scrollback unless a command is p..." | Re-trigger Greptile

Comment thread Sources/Workspace.swift
Comment on lines 965 to 973
nonisolated static func shouldPersistSessionScrollback(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
makeSessionRestorePolicyService().shouldPersistSessionScrollback(
closeConfirmationRequired: resolveCloseConfirmation(
shellActivityState: shellActivityState,
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
)
_ = fallbackNeedsConfirmClose
return makeSessionRestorePolicyService().shouldPersistSessionScrollback(
shellActivityState: shellActivityState
)
}

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.

P2 The fallbackNeedsConfirmClose parameter is now explicitly discarded via _ = fallbackNeedsConfirmClose. The only callers of this static overload today are in the test target, and they pass fallbackNeedsConfirmClose: true specifically to verify the parameter is ignored — confirming it has no production callers. Keeping a dead public parameter in a nonisolated static production method leaves a misleading API surface: any future caller that passes true expecting conservative gating will silently get persistence instead. Consider removing the parameter (the test target can be updated, or the tests can call the new instance-method form directly via @testable import).

Suggested change
nonisolated static func shouldPersistSessionScrollback(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
makeSessionRestorePolicyService().shouldPersistSessionScrollback(
closeConfirmationRequired: resolveCloseConfirmation(
shellActivityState: shellActivityState,
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
)
_ = fallbackNeedsConfirmClose
return makeSessionRestorePolicyService().shouldPersistSessionScrollback(
shellActivityState: shellActivityState
)
}
nonisolated static func shouldPersistSessionScrollback(
shellActivityState: PanelShellActivityState?
) -> Bool {
makeSessionRestorePolicyService().shouldPersistSessionScrollback(
shellActivityState: shellActivityState
)
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread Sources/Workspace.swift
Comment on lines 965 to 973
nonisolated static func shouldPersistSessionScrollback(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
makeSessionRestorePolicyService().shouldPersistSessionScrollback(
closeConfirmationRequired: resolveCloseConfirmation(
shellActivityState: shellActivityState,
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
)
_ = fallbackNeedsConfirmClose
return makeSessionRestorePolicyService().shouldPersistSessionScrollback(
shellActivityState: shellActivityState
)
}

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.

P2 Dead parameter misleads future callers

fallbackNeedsConfirmClose is retained in this internal static wrapper but immediately discarded with _ = fallbackNeedsConfirmClose, meaning every caller sees a parameter that appears to influence persistence but has no effect. Because this is a nonisolated static (not public) method whose only callers are the test file, the parameter can be removed without an API compatibility concern — and doing so makes the contract unambiguous at every call site. The current form leaves bad state representable: a reader (or future caller) passing fallbackNeedsConfirmClose: true cannot tell without reading the body that the value is ignored.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@grantland grantland changed the title Fix terminal scrollback not restoring on quit/relaunch (#2823) Fix terminal scrollback not restoring on quit/relaunch Jun 22, 2026
@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a regression where terminal scrollback was silently dropped at session save whenever shellState was .unknown or nil, causing terminals to restore empty. The root cause was shouldPersistSessionScrollback being gated on closeConfirmationRequired, which routed the conservative needsConfirmClose fallback through for any panel lacking a shell-integration report.

  • WorkspaceSessionRestorePolicyService.shouldPersistSessionScrollback now accepts shellActivityState directly and returns (state ?? .unknown) != .commandRunning, persisting for idle and unknown states and skipping only when a command is positively running.
  • The snapshot call-site in Workspace.swift is updated to pass shell state directly; the static wrapper retains fallbackNeedsConfirmClose but silently discards it.
  • Both package-level and app-level tests are added/corrected to assert the new contract.

Confidence Score: 4/5

The logic change is narrow and correct: scrollback now persists for idle and unknown shell states, skipping only when a command is positively running.

The core fix in WorkspaceSessionRestorePolicyService and the Workspace.swift snapshot call-site are clean. The one remaining rough edge is the static wrapper retaining a fallbackNeedsConfirmClose parameter that is silently discarded — any future caller passing it believes it has an effect when it does not.

Sources/Workspace.swift — the static shouldPersistSessionScrollback wrapper retains and silently discards fallbackNeedsConfirmClose.

Important Files Changed

Filename Overview
Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/WorkspaceSessionRestorePolicyService.swift Core fix: replaces closeConfirmationRequired with shellActivityState parameter; new logic correctly persists for nil/unknown/idle and skips only commandRunning.
Sources/Workspace.swift Snapshot call-site updated correctly; static wrapper retains the now-unused fallbackNeedsConfirmClose parameter and silently discards it, which is a dead-parameter architectural smell.
Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Session/WorkspaceSessionRestorePolicyServiceTests.swift New unit test covers all four PanelShellActivityState cases (.promptIdle, .unknown, nil, .commandRunning) correctly.
cmuxTests/SessionPersistenceTests.swift App-level regression test renamed and corrected: unknown/nil shell state now asserts true (persist), matching the new contract.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Session snapshot] --> B{shellActivityState}
    B -->|nil or unknown| C[shouldPersistScrollback = true]
    B -->|promptIdle| C
    B -->|commandRunning| D[shouldPersistScrollback = false]
    C --> E{shouldReplayScrollback?}
    E -->|agent / tmux / resume| F[skip - no scrollback key]
    E -->|plain terminal| G[capture via readTerminalTextForSnapshot]
    G --> H[scrollback key written to snapshot]
    D --> F
    H --> I[terminal restores with content]
    F --> J[terminal restores empty]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Session snapshot] --> B{shellActivityState}
    B -->|nil or unknown| C[shouldPersistScrollback = true]
    B -->|promptIdle| C
    B -->|commandRunning| D[shouldPersistScrollback = false]
    C --> E{shouldReplayScrollback?}
    E -->|agent / tmux / resume| F[skip - no scrollback key]
    E -->|plain terminal| G[capture via readTerminalTextForSnapshot]
    G --> H[scrollback key written to snapshot]
    D --> F
    H --> I[terminal restores with content]
    F --> J[terminal restores empty]
Loading

Reviews (3): Last reviewed commit: "Persist scrollback unless a command is p..." | Re-trigger Greptile

Comment thread Sources/Workspace.swift
Comment on lines 965 to 973
nonisolated static func shouldPersistSessionScrollback(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
makeSessionRestorePolicyService().shouldPersistSessionScrollback(
closeConfirmationRequired: resolveCloseConfirmation(
shellActivityState: shellActivityState,
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
)
_ = fallbackNeedsConfirmClose
return makeSessionRestorePolicyService().shouldPersistSessionScrollback(
shellActivityState: shellActivityState
)
}

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.

P2 Dead parameter silently ignored in static wrapper. fallbackNeedsConfirmClose is accepted but immediately discarded via _ = fallbackNeedsConfirmClose. The only callers today are in cmuxTests/SessionPersistenceTests.swift, so the parameter does nothing in production or in tests. This leaves future callers with a plausible-but-false belief that the parameter influences the result. Either remove it outright and update the test call-sites, or mark it deprecated — the tests already assert the new contract without relying on fallbackNeedsConfirmClose doing anything.

Suggested change
nonisolated static func shouldPersistSessionScrollback(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
makeSessionRestorePolicyService().shouldPersistSessionScrollback(
closeConfirmationRequired: resolveCloseConfirmation(
shellActivityState: shellActivityState,
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
)
_ = fallbackNeedsConfirmClose
return makeSessionRestorePolicyService().shouldPersistSessionScrollback(
shellActivityState: shellActivityState
)
}
@available(*, deprecated, message: "fallbackNeedsConfirmClose is no longer consulted; pass shellActivityState only.")
nonisolated static func shouldPersistSessionScrollback(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
_ = fallbackNeedsConfirmClose
return makeSessionRestorePolicyService().shouldPersistSessionScrollback(
shellActivityState: shellActivityState
)
}

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Addresses Greptile P2: the static Workspace.shouldPersistSessionScrollback wrapper
kept `fallbackNeedsConfirmClose` but discarded it (`_ = fallbackNeedsConfirmClose`),
leaving a misleading API surface. The parameter was only retained for red/green test
stability; persistence now depends solely on shell-activity state. Drop the parameter
and update the test call sites to the single-argument form.

Co-authored-by: Claude <noreply@anthropic.com>
@grantland

Copy link
Copy Markdown
Author

Addressed in cc515c7 — removed the dead fallbackNeedsConfirmClose parameter from the static wrapper and updated the test call sites to the single-argument form.

@grantland

Copy link
Copy Markdown
Author

@coderabbitai review
@greptile-apps review

@coderabbitai

coderabbitai Bot commented Jun 22, 2026 •

Copy link
Copy Markdown

@grantland I'll kick off a fresh review of the updated changes now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

lawrencecchen added a commit that referenced this pull request Jun 23, 2026
Wire TabManager.workspaceTabsWillChange to drain the pending buffer and
apply each report against the now-reachable workspace. A report that
landed before its workspace joined a manager now replays once it does,
so idle terminals are recognized as .promptIdle instead of being stranded
at .unknown. Only landed reports are cleared, so a surface still missing
its panel retries on the next registration.

This is the record-side half of the scrollback-restore work in #2823;
the persistence-side workaround in #6615 only made scrollback tolerant of
.unknown. Fixes the shellState gap that also degrades close-confirmation
warnings and PR probing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 23, 2026
Wire TabManager.workspaceTabsWillChange to drain the pending buffer and
apply each report against the now-reachable workspace. A report that
landed before its workspace joined a manager now replays once it does,
so idle terminals are recognized as .promptIdle instead of being stranded
at .unknown. Only landed reports are cleared, so a surface still missing
its panel retries on the next registration.

This is the record-side half of the scrollback-restore work in #2823;
the persistence-side workaround in #6615 only made scrollback tolerant of
.unknown. Fixes the shellState gap that also degrades close-confirmation
warnings and PR probing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 23, 2026
Wire TabManager.workspaceTabsWillChange to drain the pending buffer and
apply each report against the now-reachable workspace. A report that
landed before its workspace joined a manager now replays once it does,
so idle terminals are recognized as .promptIdle instead of being stranded
at .unknown. Only landed reports are cleared, so a surface still missing
its panel retries on the next registration.

This is the record-side half of the scrollback-restore work in #2823;
the persistence-side workaround in #6615 only made scrollback tolerant of
.unknown. Fixes the shellState gap that also degrades close-confirmation
warnings and PR probing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@teamleaderleo teamleaderleo added the bug Something isn't working label Sep 30, 2026
@teamleaderleo teamleaderleo added area: terminal Ghostty surface, rendering, scrollback, escape sequences, fonts area: workspaces Workspaces, sessions, restore after relaunch, worktrees S2: major A crash, hang, lost state, broken connection, or a regression on a path people use labels Sep 30, 2026
@teamleaderleo

Copy link
Copy Markdown
Collaborator

The unknown shell-state scrollback loss remains in main’s current session restore policy; keeping this open for a current-main port.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: terminal Ghostty surface, rendering, scrollback, escape sequences, fonts area: workspaces Workspaces, sessions, restore after relaunch, worktrees bug Something isn't working S2: major A crash, hang, lost state, broken connection, or a regression on a path people use

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sessions are lost after app restart Restore terminal scrollback content across app restarts (like iTerm2 / macOS Terminal.app)

2 participants