Session restore: windows, tabs, splits, cwd, and last-command pre-type - #13
Conversation
Adds a scanner for VS Code shell integration's OSC 633;E sequence, which reports the command line a shell is about to run. Mirrors osc133.rs's split-detection and resync approach as a standalone module, and decodes VS Code's command-line escaping (\\ and \xHH). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matches the pattern already used in osc133.rs's equivalent test and clears the clippy::manual_repeat_n warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fold the OSC 633 scanner (Task 1) into AlacrittyProjection::advance alongside the existing 133/1337 scans, carrying its split-sequence tail the same way 1337's is carried. OscEvent::CommandLine(String) rides the same buffer-ordered dispatch as the other marks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend zsh preexec and bash DEBUG trap to emit OSC 633;E with the escaped command line, allowing sessions to track which commands are being executed. Escaping matches the decoder: backslash, semicolon, and newline are escaped as per OSC 633 spec. Guard bash DEBUG trap against firing for PROMPT_COMMAND itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… latch The DEBUG trap was firing during PROMPT_COMMAND evaluation, causing spurious 633;E emissions for the user's existing PROMPT_COMMAND fragments (starship, direnv, etc). Now use a latch set by _ember_precmd at the end of PROMPT_COMMAND to gate 633;E emission: only the first DEBUG after the prompt is shown (the user's typed command) reports. Skip completion mode (COMP_LINE check) and clear the latch after reporting. Preserves existing 133;C emission behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o a stale latch cannot misattribute The latch-based guard could persist across prompt cycles when the user presses bare Enter (no new history entry), causing the stale latch to consume the first DEBUG in the next PROMPT_COMMAND evaluation and spuriously emit 633;E for the user's prompt text or _ember_precmd. Now use bash history deduplication: clear the latch first, then check if the current history entry (via HISTTIMEFORMAT= history 1) differs from the stored one. Only emit 633;E if a new entry was created, and use the full history line text (with leading whitespace+number stripped) instead of per-piece BASH_COMMAND. This matches zsh preexec semantics and prevents stale-latch misattribution. Known edge: with HISTCONTROL=ignoredups, retyping an identical command adds no history entry, so its 633;E is skipped (a missed report, never a misattribution). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the session-restore snapshot model (SessionSnapshot/WindowSnap/ TabSnap/NodeSnap/PaneSnap), state_path() mirroring config.rs's XDG idiom, an atomic write_atomic (same-directory temp file at 0600, fsync, rename over the target), and a SnapshotWriter background thread that debounces updates: 300ms of quiet or 1s of total deferral, whichever comes first, sleeping indefinitely when idle. SnapshotHandle::drop flushes any pending snapshot synchronously so a clean shutdown never loses the latest state. Write failures are logged once and swallowed; the next update retries naturally. This is a leaf module: nothing wires it into the running app yet, that lands in later tasks, so it carries a scoped allow(dead_code). Pulls in serde (already a workspace dependency via ember-core) as a direct ember-app dependency for the derive macros. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntinuously Wires the session-snapshot capture path: a PaneMeta map on Shared mirrors each session's cwd and OSC 633;E command line/end reports, a pure assemble() in session_state.rs maps live WindowTree/PaneMeta state into the on-disk snapshot shape (with a single 1KiB last_cmd truncation point), and a snapshot_dirty flag plus session_dirty funnel flush it through the debounced writer from every structural mutation (new tab, tab close, rename commit, split, tab reorder, cross-window move, window resize/move/close). named_by_user tracks interactive double-click renames via a WindowState-side named_tabs set, since core's Tab has no such field. Command capture is gated on a literal true pending the config field a later task adds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes four dirty-coverage gaps found in review: closing a pane (both the Cmd+W path and a shell exiting on its own) never marked the snapshot dirty since sync_layout only takes &Shared; a brand-new window never reached disk until some other mutation touched it; the same-window tear-off-and-redrop tab reorder bypassed both existing funnels; and a cross-window tab move dropped named_by_user because named_tabs was never carried across the window boundary. Pulls window_owning_tab out as a small generic helper (mirroring the existing resolve_index/next_prev_index pattern) so the cross-window carry's search logic is unit-tested without needing a live WindowState/Renderer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssions Adds RestoreMode (Off/Ask/Always) and RestoreConfig (mode + capture_commands) to Config, both serde-defaulted so existing config.toml files without a [restore] table still load. Widens resolve_rows to take a saved-session count so the Settings overlay can show three new rows: "Session restore" (cycle), "Capture commands" (toggle, hidden while restore is off), and "Delete saved sessions (N)..." (a new RowKind::Action row, hidden at N=0). Wires the effects in window_state.rs: switching restore off drops the snapshot writer (flushing on Drop, leaving existing files on disk); switching it back on respawns the writer; turning capture off immediately strips every last_cmd already on disk. Adds delete_all_state() and strip_commands() to session_state.rs, sharing a footprint helper with the new saved_state_count() so the row's count and the delete action can never disagree about what's on disk. Also replaces main.rs's TODO(restore-config) placeholder with the real config gate on OSC 633;E command-line capture, and gates the initial snapshot writer spawn on the configured restore mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arrows settings_key routed ArrowLeft/ArrowRight/Space/Enter all into the same activation path, and that path fired RowKind::Action unconditionally. Since "Delete saved sessions" is the only Action row, routine Left/Right navigation on it (exactly what the overlay's own hint line advertised as "change") irreversibly deleted every saved session file. Extracts the key -> action decision into a pure settings_action_for_key(key, selected_kind) -> SettingsAction function: Activate is only ever produced for Enter/Space on an Action row, Adjust(dir) for everything else. This also made the fix unit-testable without a live WindowState/Shared (the Renderer needs a real GPU context this crate's test harness doesn't provide) - constructing one was infeasible, so the decision logic itself is what's tested. Also updates the overlay's footer hint to read "enter/space activate" instead of "arrows change" while an Action row is selected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement LoadOutcome enum, ArchiveEntry struct, load/archive/list_archives functions per Task 7 requirements. Load parses snapshot JSON and renames corrupt files to session.json.corrupt. Archive renames to session.json.prev-* with YYYYMMDD-HHMMSS timestamp and prunes to keep MAX_ARCHIVES=10 newest. Tests use archive_with_stamp with fake distinct stamps (no sleeps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix two issues found in review: 1. Same-second archive collision: archive_with_stamp now checks if the destination exists and appends numeric suffix (-2, -3, etc) to avoid silently destroying snapshots. Suffixed names sort lexicographically as newer, maintaining newest-first ordering. Added test verifying both archives exist with distinct names after collision. 2. Corrupt-quarantine rename failure: now logs via log_write_failure_once instead of silently swallowing the error. Caller still returns LoadOutcome::Corrupt (best-effort quarantine, but failure is now visible). Minor improvements: list_archives now skips unparseable prev-* files (already did, but test now verifies this behavior). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the pure pieces the restore modal and skeleton rebuild need from session_state: split_ops flattens a saved split tree into an ordered sequence of split operations that reproduce it when replayed through the app's existing split_pane path, and humanize_age/humanize_stamp turn a saved timestamp into the modal's "2h ago" / "3 weeks ago" style label. load_archive reads one archived snapshot back for the Older picker. Test-driven: split_ops_recreates_a_nested_tree lands first and fails, then the pre-order/parent-index walk that makes it pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds RestoreView (Main: header + Restore/Start fresh/Older buttons; Older: header + up to 10 archive rows) and its draw code, following the existing confirm modal's button-panel layout for Main and the command palette's single-buffer list layout for Older. Wired into the live renderer, the headless capture path, and the live-window ctl screenshot path (Renderer::capture_to_png) identically, so a captured PNG always matches what's on screen. screenshot.rs gets --restore-main/--restore-older flags (mirroring the existing --confirm demo flag) as a headless verification hook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wires up session restore end to end:
- WindowState::restore_prompt (Main/Older) with keyboard navigation
(Left/Right/Tab/Up/Down/Enter/Esc) mirroring the pending_close modal
pattern, in both the real keyboard path and the ctl mirror.
- split_pane_with_cwd and new_tab_with_cwd let a caller spawn a pane
in an explicit cwd instead of split_pane's normal live-cwd
inheritance, which has nothing to inherit from a shell that was
just spawned during a rebuild.
- spawn_restored/spawn_restored_window rebuild every window from a
loaded snapshot through the existing window/tab/split creation
paths, clamping a saved position onto a visible monitor
(clamp_to_visible_monitor) and falling back a missing cwd to $HOME
with no pre-type armed for that pane.
- Shared::pretype (Pretype { cmd, armed_at }) is armed for every
restored pane with a saved command and a cwd that still exists;
Task 9 consumes it.
- Startup wiring in resumed(): Ask + a loaded snapshot shows the
prompt over the normal default window; Always skips the default
window and rebuilds directly; Off/no file/corrupt are unchanged.
session_dirty won't overwrite the on-disk file while the prompt is
still up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rompt window Two review fixes for the session-restore skeleton rebuild: - spawn_restored_window requested the saved window size but discarded the result and kept replaying tabs/splits against the window's still-default px. split_pane_with_cwd's min-size acceptance reads that live size, so a saved window larger than default with a dense split tree could have panes silently dropped that would have fit fine once actually resized. request_inner_size is synchronous on some platforms (returns the applied size immediately) and only a request on others (Wayland; returns None, the real size arrives as a later Resized event). The synchronous case now updates px/resizes the renderer before replaying, right in spawn_restored_window; the async case defers the whole tab/split replay via a new Shared::pending_restore_replay map, drained by window_event's Resized handler once the real size is known. Verified the synchronous path (macOS). Added a pure unit test exercising the exact viewport-dependent accept/refuse seam (ember_core::apply's SplitPane check) at default vs. saved window width. - Ask-mode Restore left the prompting default window (and its idle shell) open alongside the restored windows, which then persisted into the next snapshot and compounded on repeat. spawn_restored now reports whether it actually created a window; resolve_restore_action closes the prompting window afterward via the plain close_window primitive (no last-window/quit check, no confirm needed for a brand-new idle shell) once at least one window restored, keeping it as the fallback otherwise so the app never ends up with zero windows. Also (reviewer minor): the snapshot-dirty flag's clear in about_to_wait is now gated on the same "no restore prompt is open" check session_dirty applies internally, so a Moved/Resized delta that arrives while the modal is still up keeps the flag set for a real flush once the modal resolves, instead of being silently dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A restored pane with a saved command types it into the shell's edit buffer the moment that pane reports its first real prompt (OSC 633;A), never before and only once. Bracketed-paste shells get the exact command wrapped in the paste guards; unbracketed shells get newlines flattened to spaces so a bare newline can never auto-submit it. Panes with no shell-integration hook never fire the prompt signal, so they never pre-type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…R pretype Three final-review fixes for session restore: - Turning off "Capture commands" now actually keeps commands off disk. The assemble closure used to read pane_meta unconditionally, so the next dirty event silently wrote every live command back after the on-disk strip. It's now gated on the capture flag directly, pane_meta is cleared the instant capture is toggled off, and the snapshot is marked dirty afterward so a clean write supersedes anything already queued in the debounced writer. - Bash's exit-status gutter marks were wrong for anyone with their own PROMPT_COMMAND (starship, direnv, etc.): appending our hook after theirs meant it captured their exit status, not the command the user actually ran. The status is now captured before any user fragment runs, with the hook still last so the DEBUG-trap latch keeps working. - A restored pane's pre-typed command could be submitted early if it contained a bare carriage return; the un-bracketed pretype path now flattens both newline and carriage return to a space. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflicts were import-list rewraps in the render crate; kept the superset that includes the restore-modal builders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main restored the CI fmt gate; these test blocks predated it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Satisfies the restored clippy gate (op_ref). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The
|
| Run order | Result |
|---|---|
| X11 first → Wayland second | Wayland fails |
| Wayland first → X11 second | X11 fails |
| Either backend, clean state | passes |
Both legs share $HOME in the job, so the second launch restores the session the first one persisted. That second launch is the one that breaks, whichever backend it happens to be.
It needs shell integration armed
With SHELL=/bin/bash it reproduces every time. With the /bin/sh fallback it never triggers — dash has no DEBUG trap, so the last-command capture never arms. That's why it shows up on CI runners (bash) and why a container defaulting to sh looks clean.
Two symptoms, both on restored launches
- On CI: the restored pane showed the previous run's command —
echo smoke-x11-42— sitting pre-typed in a suggestion box, and the newly typed text never appeared. (screenshot in the run artifacts) - Locally: the restored pane rendered fully black, while
ctl statestill reported a live prompt behind it.
No panics in either app log. session.json is written correctly and last_cmd is null in the persisted state, so the pre-typed content may be coming from somewhere other than the field that's meant to carry it.
Repro
# needs bash, and a second launch against state the first one left behind
SHELL=/bin/bash scripts/smoke/container-smoke.sh bothOr directly: run scripts/smoke/linux-window-smoke.sh <backend> twice against the same $HOME with SHELL=/bin/bash, using a different marker each time.
One caveat
The smoke injects keystrokes through the control socket, not real hardware key events, so "typed input doesn't reach the grid" is proven for the injection path — whether a human at a keyboard hits the same wall needs a manual check. The black pane and the stale pre-typed command are independent of injection, though, and neither looks intentional.
And a fair hit on the gate itself
The two legs sharing $HOME was accidental — leg 2 was never designed as a restored-session test. Isolating $HOME per leg would make the gate "correct" and would have missed this entirely. I'll fix it to cover both states deliberately: one clean-launch leg, one restore leg.
On async-resize platforms (X11, Wayland), request_inner_size returns None and spawn_restored_window unconditionally deferred the whole window replay, including the first tab's shell spawn, until a WindowEvent::Resized arrived. When the saved size already matched the window's creation size, that resize request was a no-op and no Resized event ever came, leaving the restored window a dead black pane with no shell, default grid, and no input. Compare the requested size against the window's actual current size before deferring. If they already match, or the saved size is degenerate (zero in either dimension), replay immediately at the current size instead of waiting forever. Only genuinely pending resizes still defer into pending_restore_replay. Extracted the decision into needs_deferred_replay with unit tests for the three cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ps the keystrokes While the restore-on-launch modal is up, a printable keystroke (or Space) now dismisses it as Start fresh and forwards the keystroke to the fresh shell, so nothing typed is lost. ControlMsg::Type gets the same treatment for scripted launches. Modal navigation (Enter/Esc/arrows/Tab) and the swallow of other named keys are unchanged. The key-to-action decision was pulled into a pure classifier (restore_key_action), following the settings_action_for_key precedent, so it can be unit-tested without a GPU-backed WindowState. Both the keyboard and ctl input paths route through it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… typing through Cmd chords (Cmd+Q, Cmd+N, Cmd+W, ...) arrive as Key::Character on macOS, so the restore-modal keyboard branch was classifying them as printable keystrokes: it dismissed the modal, archived the session, and typed a stray letter instead of letting the shortcut run. The keyboard branch now skips entirely while Super is held, mirroring the palette/search/rename capture guards, so those chords fall through to the normal Cmd+Q and Super-shortcut handling untouched. The classifier also now takes the held modifiers: a Ctrl-modified character is swallowed rather than typed through (Ctrl+C must not type a literal "c"), and a Super-modified character is swallowed too as defense in depth, even though the keyboard path never reaches the classifier that way anymore. The ctl path can't attach modifiers to ControlMsg::Key at all, so it always passes empty modifiers there; a ctl Type with an empty string is now a no-op instead of dismissing the prompt for nothing typed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session restore
Ember now remembers your session and offers to bring it back.
What it does
~/.local/state/ember/session.json(mode 0600, atomic writes). Snapshots debounce at 300ms of quiet with a 1s cap, so even a hard kill or reboot loses at most a second of bookkeeping.Safety properties
.corrupt) and launch proceeds fresh; unknown future versions are left untouched.Testing
🤖 Generated with Claude Code