Skip to content

Session restore: windows, tabs, splits, cwd, and last-command pre-type - #13

Merged
kingb merged 25 commits into
mainfrom
feat/session-restore
Sep 3, 2026
Merged

Session restore: windows, tabs, splits, cwd, and last-command pre-type#13
kingb merged 25 commits into
mainfrom
feat/session-restore

Conversation

@kingb

@kingb kingb commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Session restore

Ember now remembers your session and offers to bring it back.

What it does

  • Continuous, crash-safe state capture: windows (position/size), tabs (order, names), split layouts, and each pane's working directory are snapshotted to ~/.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.
  • Last-command capture: the injected shell hooks (zsh preexec, bash DEBUG trap) now report the command line via OSC 633;E (VS Code shell-integration encoding), folded into the existing OSC scan pass. Capped at 1 KiB per pane, plaintext-on-disk documented, with a Settings kill-switch that strips commands from disk immediately.
  • Restore on launch: an Ask modal ("Restore N windows, M tabs (from 2h ago)?") with Restore / Start fresh / Older. Start fresh archives the snapshot (last 10 kept, timestamped); Older restores any of them. Always/Off modes in Settings.
  • Pre-typed last command: each restored pane re-types its last command at the prompt, unsent, via bracketed paste, only after the shell's first prompt mark. Enter is always yours; a pane whose saved cwd vanished restores to $HOME and types nothing.
  • Settings: Session restore (Off / Ask / Always), Capture commands (On / Off), Delete saved sessions (N). Action rows activate on Enter/Space only.

Safety properties

  • Pre-type can never execute: bracketed-paste wrap when available, newline/CR flattening otherwise.
  • Command text is never logged; state files and archives are 0600.
  • Corrupt snapshots are quarantined (.corrupt) and launch proceeds fresh; unknown future versions are left untouched.
  • The snapshot writer is a separate thread; nothing new wakes the render loop when idle.

Testing

  • 440 workspace tests green (78 new), including scanner split/resync cases, debounce/flush timing, archive rotation and collision handling, settings semantics, split-replay geometry, and pre-type byte shaping.
  • Live scratch-env verification: capture -> quit -> relaunch -> restore -> pre-typed unsent command confirmed via the control socket, including kill -9 mid-session.
  • Known follow-ups tracked: headless end-to-end test pair, and the release throughput benchmark to be run on a quiet machine before tagging v0.6.0.

🤖 Generated with Claude Code

kingb and others added 22 commits August 31, 2026 13:42
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>
@kingb

kingb commented Sep 1, 2026

Copy link
Copy Markdown
Owner Author

The linux-smoke failure is a real regression, not a flake

Every other check passes — this one caught something unit tests can't see. Worth a look before merge.

A launch that restores a persisted session doesn't accept typed input.

It isn't Wayland-specific, even though that's the leg that went red. The failure follows the order, not the backend:

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 state still 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 both

Or 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.

kingb and others added 3 commits September 1, 2026 15:40
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>
@kingb
kingb merged commit 1b17475 into main Sep 3, 2026
13 checks passed
@kingb
kingb deleted the feat/session-restore branch September 5, 2026 04:54
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