Skip to content

Windows: fix scrollback, working directories and WSL panes on restore - #17

Open
tjcouch-sil wants to merge 20 commits into
YedPool:mainfrom
tjcouch-sil:fix/win-scrollback-and-wsl-restore
Open

Windows: fix scrollback, working directories and WSL panes on restore#17
tjcouch-sil wants to merge 20 commits into
YedPool:mainfrom
tjcouch-sil:fix/win-scrollback-and-wsl-restore

Conversation

@tjcouch-sil

@tjcouch-sil tjcouch-sil commented Aug 6, 2026

Copy link
Copy Markdown

Please read before spending any time on this.

These are personal changes I made to get the plugin doing what I wanted on my
own machine. I'm opening the PR in case any of it is useful to you — please
feel free to close it outright
if it isn't the direction you want.

  • All of it was written by AI (Claude), driven by me.
  • There are likely extraneous changes and mistakes. It grew organically
    from one bug into a fairly wide diff. I have not reviewed every line against
    your intentions for the project.
  • I realistically don't have time to iterate on review feedback. Please
    take whatever is useful, cherry-pick it, rewrite it, or close this — no hard
    feelings, and no need to wait on me.

Everything below is offered as information, not as an argument for merging.

Which shell this affects

Worth stating up front so none of the below is oversold: WezTerm's default
shell on Windows is cmd.exe
(it uses COMSPEC). PowerShell is opt-in, via
your own config:

config.default_prog = { "powershell" }

I run PowerShell and WSL, which is where most of what follows was found. Each
item below is tagged with where it actually applies. Nothing here was reproduced
on macOS or Linux, though a few of the fixes are platform-independent by
inspection.

Fixes

Restored scrollback was almost always erased. (Windows — any shell,
including cmd.)
inject_output fired immediately while the process-restore
branch beside it already waited process_restore_delay_seconds. ConPTY repaints
the whole visible screen when the shell first draws, so the injected text was
overwritten before anyone saw it — measured across 8 consecutive saved instances,
it survived once. My saved states show it happening in cmd panes as well as
PowerShell ones. Scrollback is now padded by a viewport so it lands in the
scrollback buffer, out of reach of that repaint, with the shell's first prompt
beneath it.

Startup froze for ~30 seconds on a large session. (All platforms — nothing
shell- or OS-specific about it.)
Restoring a pane trimmed its scrollback with
gsub("%s+$", ""). Lua patterns have no anchor optimisation, so it is retried at
every position and each retry walks the whitespace run it finds. Captured
scrollback is the worst possible input: every line is padded to the pane width.
On a real 534 KB capture that one call took 15.1 seconds; a backwards byte
loop takes 0 and returns a byte-identical string. It ran once per restored pane,
on the GUI thread. End to end, restoring the tab holding that capture went
21731 ms → 72 ms. This needs only a large scrollback to reproduce anywhere.

Restore commands left the shell mid-command. (PowerShell only.) They were
submitted with "\r\n". PSReadLine runs the line on the CR and takes the LF as
"insert a newline", leaving the shell at its >> continuation prompt with the
next thing typed swallowed into it. cmd and bash are unbothered by the stray
LF. Enter is a bare carriage return now, which is correct for all three.

WSL panes saved nothing at all. (WSL only.) The capture branch was gated on
domain == "local", citing MLFlexer#41 — but that issue is
about multiplexer domains. WSL domains are local ptys, so inject_output and
get_lines_as_escapes work on them.

Restoring a WSL pane typed a Windows path into bash. (WSL only.) Produced
-bash: cd: C:/Users/...: No such file or directory. Spawn arguments and the
cd are domain-aware now.

A saved WSL tab in first position was restored as PowerShell. (WSL only, and
only the first tab.)
A restore reuses the window it was launched from for the
first saved tab, and that window runs default_prog, since it is spawned before
anything is known about what it will hold. The first tab was handed to it
regardless of domain, so a WSL tab came back as a cmd/PowerShell pane, sent a
POSIX cd it could not follow and whatever else the tab was running. It is
reused now only when its pane is already in the domain the saved tab wants;
otherwise a tab is spawned in the right domain and the unusable one is closed.
Restoring a synthetic session with a WSL tab first:

before   tab 1 domain=local             cwd=/C:/Users/tj_co
         tab 2 domain=local             cwd=/C:/Users/tj_co
after    tab 1 domain=WSL:Ubuntu-22.04  cwd=/home/tj_co
         tab 2 domain=local             cwd=/C:/Users/tj_co

The second tab was wrong too — it inherited the reused pane's domain rather than
its own, so with a WSL tab first, every tab came back local.

Restoring a session lost the session it restored. (All platforms.) The
instance being read is tombstoned, and the only thing saved under the new
process's id is the blank window recorded a second earlier at startup. Closing
before the next save left that blank window as the newest instance. A restore now
adopts what it restored.

A pane stayed marked as a Claude pane for as long as it lived. (Claude Code
users, all platforms.)
The pane-session file was written when Claude started and
never removed, so closing Claude and carrying on in the same pane still saved it
as claude --resume <old-id>. Fixed with a SessionEnd hook, plus per-process
keys so a fresh pane 0 no longer reads the previous run's pane 0 file.

settings.json that failed to parse was treated as empty and rewritten,
discarding everything in it. (Claude Code users, all platforms.) It now refuses
to rewrite what it cannot read.

Features

  • auto_restore = "prompt" | "latest" | false. (All platforms.) "latest"
    skips the instance the current process is itself writing, which otherwise sorts
    to the top as the most recently saved thing there is. A second WezTerm launched
    while one is open gets the selector rather than a duplicate of what is already
    on screen.
  • Working directories. (PowerShell and WSL only — cmd already reports
    correctly, since its cd moves the process working directory.)
    PowerShell's
    Set-Location never moves the process working directory, and WSL shells are
    invisible to Windows entirely, so those panes were saved as still sitting where
    their shell started. setup() installs OSC 7 reporting into the PowerShell
    profile and into each WSL distribution, and the Claude session hook inside WSL.
    powershell_integration / wsl_integration opt out.
  • restore_window_geometry (Windows only, off by default) — window
    position and maximized state, read and written through GetWindowPlacement /
    SetWindowPlacement, since WezTerm can set both but read neither.
  • resize_window exposed through setup(), so a restore can be asked to
    leave the window alone. (All platforms.)
  • Command palette entries are readable. (All platforms.) They were all
    Emit event `user-defined-3` — a number that depends on registration order.

Testing

test_debug/wezterm_smoke.lua runs 104 assertions inside WezTerm's own Lua via
wezterm --config-file … show-keys, which also parses every module and so doubles
as a syntax check. This exists because the repo notes there is no Lua runtime on
the machine; it does not replace the busted suite.

wezterm --config-file test_debug/wezterm_smoke.lua show-keys

Known gaps

  • The WSL and PowerShell integrations affect newly opened panes only.
  • Window geometry captures nothing when more than one window is open, since
    Windows exposes a process's main window and WezTerm gives Lua no way to say
    which of its windows that is.
  • restore_window_geometry is Windows-only; elsewhere it is a no-op.
  • The suite assumes nothing about other WezTerms running, and the geometry
    helper declines unless the process owns exactly one window, so a headless run
    cannot move somebody else's window.
  • Not tested on macOS or Linux at all, and not tested with cmd.exe as the
    primary shell beyond what my saved states happened to contain.

Three separate defects meant a restored session came back mostly empty on
Windows.

1. Scrollback restore raced the shell. The process branch of
   default_on_pane_restore already waited process_restore_delay_seconds
   because "commands sent during gui-startup get swallowed by the shell's
   init sequence", but inject_output fired immediately. ConPTY repaints the
   whole visible screen when the shell first draws, so the injected
   scrollback was overwritten before it was ever seen -- measured across 8
   consecutive saved instances, the injection survived once. All writes into
   a restored pane now happen in a single delayed callback, in a
   deterministic order.

2. WSL panes saved nothing at all. The capture branch was gated on
   domain == "local", citing MLFlexer#41, but that issue is
   about multiplexer domains: WSL domains are local ptys, so inject_output
   and get_lines_as_escapes work on them. They now capture like local panes.

3. Restoring a WSL pane typed a Windows path into bash. WezTerm reports the
   Windows directory wsl.exe was launched from, and restore_tab fed it
   straight to cd, producing "-bash: cd: C:/Users/...: No such file or
   directory". Spawn/split args and the cd are now domain-aware, and WSL
   panes cd themselves to a POSIX path rather than relying on a spawn cwd
   WezTerm resolves in Windows terms.

Fixing (3) properly needs a working directory from inside the VM, which only
the shell can report, so setup() now installs that: a new wsl_integration
module writes an OSC 7 snippet into each distro, sources it from .bashrc, and
registers the Claude Code pane-session hook there. Those are keyed by a
per-shell id rather than WEZTERM_PANE, which does not cross the WSL boundary
(WezTerm's WSLENV forwards only TERM/COLORTERM/TERM_PROGRAM/
TERM_PROGRAM_VERSION). Installation is idempotent, marker-guarded, deferred
off the startup path, and disabled with wsl_integration = false.

Also generalises the pane-session key from a numeric pane id to any
alphanumeric key, and extracts the Claude hook command so the Windows and WSL
installs share one code path.
…l snippet

The repo has no standalone Lua runtime, so add a config file that WezTerm's own
Lua evaluates via `show-keys`: it asserts the WSL path helpers, the hook command
builder, the spawn-target rules and the UNC path construction, and optionally
performs a real install into a live distro and reads back what landed. Because
`show-keys` loads the config, it also parses every module and doubles as a
syntax check.

That last check caught a real portability bug: git's autocrlf (on by default on
Windows) would hand a fresh clone a CRLF copy of wsl_integration.lua, and the
CRLF would be carried into the shell snippet written into the distro -- where a
CRLF script does not run. write_wsl_file now normalises to LF, and the test
seeds a CRLF copy first so a pass means the install actually rewrote it.
Restoring a pane put the history on screen but drew the new prompt at the top
of the window, on top of it, as though the history were not there.

inject_output is invisible to the process driving the pty. On Windows both
PowerShell and WSL run behind ConPTY, which paints the visible screen at
absolute positions of its own choosing, so anything left on screen is doomed
twice: the first paint overwrites it, and the opening prompt lands at row 0
rather than below what was injected. Delaying the injection past that first
paint (the previous fix) stopped the overwrite but could not move the prompt.

Pad the injected text with exactly enough newlines to lift its last row above
the top of the viewport. The history lands in the scrollback buffer, which
ConPTY neither knows about nor touches, and the shell gets a blank screen to
draw into -- putting its prompt immediately below the restored history in
buffer order. Injection moves back to pane creation, where the pane is still
blank; only keystrokes still wait for the shell to be ready.
Two problems with the padding that lifts restored scrollback out of the
visible screen.

It was sized by the row count of the history, which is only correct when the
history already fills the screen. A terminal scrolls only once the cursor is
on the bottom row, so writing R rows and then N newlines scrolls exactly
max(0, R + N - viewport) rows: padding a 5-row history by 5 newlines in a
43-row pane scrolls nothing at all, leaving the history on screen for ConPTY's
first repaint to erase. The padding is now a full viewport regardless of R,
which is both necessary and sufficient -- and because those rows fill the
screen the shell is about to draw over, none of them reach the scrollback
buffer. Scrolling up from a restored prompt lands on the last line of the
restored history with no blank gap.

The injection now also homes the cursor afterwards, so a shell writing
relative to the cursor (bash behind a pty, rather than ConPTY's absolute
repaints) starts at the top of the blank screen instead of the bottom row,
where it would scroll blank lines into that gap.

Second, a restored pane looked like a fresh one: the history was there but a
page out of sight, with nothing to suggest it. Restored panes are now parked a
page back, on their own history. Typing scrolls to the prompt as usual, so it
costs nothing; scroll_to_history = false opts out.
…per process

A pane-session file was written when Claude started and never removed, so a
pane stayed marked as a Claude pane for as long as it lived. Close Claude,
keep using the pane as a shell, and it was still saved as
`claude --resume <old-id>` -- coming back running Claude, its real scrollback
discarded, because process panes do not save text.

Two independent leaks, so two fixes.

Within one WezTerm run, nothing ever deleted the file. A SessionEnd hook now
removes it. Its matcher deliberately skips `clear` and `resume`, which end one
session only to start another immediately, where deleting would race the
SessionStart that follows.

Across runs, pane ids restart from 0 in every WezTerm process, so a fresh
pane 0 read the file left by the previous run's pane 0. Keys are now prefixed
with the instance id, exported to child processes as RESURRECT_INSTANCE so the
hook builds the same key the save path looks for. That also stops two WezTerm
processes running side by side from overwriting each other's files. A crash
still strands a file, so old ones are swept by age -- the directory is shared
between processes, which makes "not mine" unsafe to delete, but age is fine.

Because the key expression is part of the hook command, hooks must now be
replaced on install rather than added only when missing: an existing user's
stale hook would keep writing under the old key and silently stop matching.
Other hooks in the file are preserved.

Doing that rewrite exposed a data-loss path worth closing: settings.json that
failed to parse was treated as an empty object and then written back out,
discarding everything in it. It now refuses to rewrite what it cannot read.
auto_restore decides what the first window does when saved instances exist:
"prompt" (the default, and the previous behaviour), "latest" to restore the
most recent without asking, or false to do nothing. auto_restore_prompt =
false keeps working as the older spelling of false.

"latest" skips the instance this WezTerm is itself writing. That is not a
detail: by the time the startup restore runs, event-driven save has already
recorded the new window's single blank tab, and it sorts to the top as the most
recently saved thing there is -- so the obvious implementation restores a blank
window, every time.

Only the first WezTerm restores automatically. Without a mux server each launch
is its own GUI process with its own gui-startup, so a second one would reopen
the session already on screen in the first; it gets the selector instead.
Counting wezterm-gui processes is the only signal available that early, since
the state files cannot tell a running instance from one that crashed.

Separately, every command this plugin bound showed up in the command palette as
"Emit event `user-defined-3`" -- a number that depends on registration order and
tells the reader nothing, leaving the key combo as the only clue to what an
entry does. The commands are now named events, and augment-command-palette
gives each one a real label and icon.

Also stop rewriting Claude's settings.json when nothing would change. setup()
runs on every launch and config reload, and the previous commit made it write
unconditionally; a stale read written back could undo an edit Claude Code made
to the file in the meantime.
Restoring a session left nothing saved that could restore it again. The
instance we read from is tombstoned on restore, and the only thing under this
process's own id is the blank window that event-driven save recorded a second
earlier at startup. Close WezTerm before the next save -- 5 minutes later by
default, or whenever the tab structure next changes -- and that blank window is
the newest instance there is.

The saved state showed it plainly: the two most recent instances were both a
single tab at the default directory whose entire scrollback was the PowerShell
banner, while the real six-tab session sat below them. auto_restore = "latest"
was working correctly and restoring exactly that, which also accounts for a
restored first tab arriving at the default directory with a "history" three
lines long.

Restoring now saves what it restored under this process's id, so closing at any
point after leaves the session recoverable, and saves again once the restore has
settled so the state reflects what is on screen. The settle delay clears the
restore commands and gives a resumed Claude Code time to report its session,
which would otherwise be saved as plain scrollback.

WezTerm exposes no shutdown event in this version, so saving on close is not
available; periodic_interval is the lever for how much recent work a crash can
cost.

Also collapse the command palette entries back to one each. Adding labelled
entries via augment-command-palette did not replace the ones WezTerm derives
from the key assignments, it added a second set -- and entries added that way
never show their key binding, so the readable one was the one without the
shortcut. The palette labels an EmitEvent from its event name, so the names are
now the labels: "Emit event `Resurrect: Save everything now`", with ALT+s on it.
… work

Three defects, found in that order because each was hiding the next.

Restore commands were submitted with "\r\n". PSReadLine runs the line on the CR
and then takes the LF as a second keypress meaning "insert a newline", so every
pane sent a command was left at PowerShell's `>>` continuation prompt with the
next thing typed there swallowed into it. The saved scrollback shows it plainly:
`cd C:/Users/tj_co/` followed by `>>`, over and over, generation after
generation. Only the reused first pane is affected, since every other tab is
spawned with its directory and is never sent a cd -- which is exactly the tab
that looked like it was "moving back" to the default directory. Enter is a bare
carriage return.

Adopting a restored session never worked. The snapshot aliased the very tables
restore_workspace mutates, and restoring hangs live Pane objects off each
pane_tree node; json_encode throws on the first one it meets, taking the
adoption, the settle-save scheduled after it, and any remaining instances with
it. It is deep-copied before the restore now, and the save is guarded so a
future failure is logged rather than silently swallowing the rest of the work.

Parking a restored pane scrolled a whole page, which puts the last row of
history on the bottom edge and the live prompt one row past it -- a pane that
appears to have no prompt. It now stops six rows short, and never scrolls
further than the history is long, which is what made a short one jump to the
top of the buffer.
A restored pane went back to the directory its shell was launched in rather
than the one it was saved in, and only ever the first tab -- which made it look
like a restore bug. It is not. PowerShell's Set-Location moves only its own
provider location; the process working directory, which is all Windows reports
and all WezTerm can read unaided, never moves:

    PS> cd C:\Windows
    provider = C:\Windows
    process  = C:\Users\tj_co\source\repos\paranext-core-2

So that is what gets saved, and restoring faithfully returns the pane to it.
Every other tab escapes this because WezTerm spawns it in the right directory
to begin with; the one a restore reuses has to be told, and can only be told
what was recorded.

The fix is the one already applied to WSL, for the same reason: install OSC 7,
the escape sequence a shell uses to report where it is. A snippet in
~/.config/wezterm-resurrect/integration.ps1 is dot-sourced from a guarded block
at the end of $PROFILE.CurrentUserAllHosts, for each PowerShell found -- asked
for by name rather than guessed at, since Documents is routinely relocated. It
wraps whatever prompt function is already defined instead of replacing it, and
guards against being sourced twice. Set powershell_integration = false to opt
out, or if you already have shell integration configured.
WezTerm can set both -- set_position, maximize -- but can read neither. A
GuiWindow reports pixel_width, pixel_height, dpi and is_full_screen, and there
is no get_position at all, so the values have to come from Windows itself via
GetWindowPlacement. That costs a subprocess, which shapes the whole design:

- Off by default, restore_window_geometry = true to enable.
- Captured only on the periodic save and on Alt+S, never on the event-driven
  saves that fire whenever a tab opens.
- The interop is compiled once and cached as an assembly beside the state, which
  takes a capture from ~950ms to ~550ms, and one capture is memoised across a
  save cycle so a save walking several windows still pays once.

Only ever one window. Windows exposes a process's main window handle, and
WezTerm gives Lua no way to say which of its windows that is; with two open
there is no telling which handle belongs to which, so geometry is skipped rather
than guessed at.

Size is deliberately left to WezTerm's own pixel dimensions rather than the rect
Windows reports: rcNormalPosition is the outer window, set_inner_size takes the
inner one, and feeding the first into the second would grow the window by its
frame on every save/restore cycle. Geometry is applied before any tab is
restored, since scrollback is injected to fill the pane it lands in.
Only the periodic save and Alt+S pay to read the window's position; every other
save left the field empty. Since each save rewrites the whole state, opening a
tab was enough to erase the position the last periodic save had recorded -- and
closing before the next one lost it entirely, which is every short session.

The last geometry seen is now remembered for the life of the process and written
by the saves that do not capture, so the value only ever moves forward. Restoring
seeds it too, so the saves before this process's first capture carry through what
it was restored with rather than dropping it. A capture that declines -- two
windows open, so there is no telling which handle is which -- leaves the
remembered value alone, since it says nothing about where the window was.
Restoring a pane trimmed its scrollback with gsub("%s+$", ""). Lua patterns have
no anchor optimisation, so that is retried at every position in the string and
each retry walks the whitespace run it finds before discovering it is not at the
end. Captured scrollback is the worst input imaginable for it: every line is
padded out to the pane width, so the text is mostly whitespace runs.

Measured on a real 534 KB capture, that one call took 15.1 seconds. A byte loop
walking backwards from the end takes 0 and returns a byte-identical string. It
ran once per restored pane, on the GUI thread, which is why Windows greyed the
window out as not responding.

End to end on the same session, restoring the tab holding that capture:
21731 ms before, 72 ms after.

The volume was never the problem, which is what made this hard to see from the
outside: inject_output swallows the same 534 KB in 68 ms, and the 329 escape
sequences in it are irrelevant. It was purely the trim.

Also expose resize_window through setup(). Sizing a restored window to its saved
size is long-standing behaviour that runs independently of
restore_window_geometry, so there was no way to ask for a restore that leaves the
window alone; now there is.
…ng it

A restored window landed on the right monitor but floated across it, down and to
the right, looking maximized without covering the screen.

The rect comes out of GetWindowPlacement in Windows' virtual desktop
coordinates -- the window this was measured on sat at x=2144, on the second
monitor -- and was being applied with WezTerm's set_position, which scales
positions its own way. Close enough to reach the right monitor, wrong enough to
be offset across it, and a maximize following a position Windows disagreed with
did not snap.

Nothing about that is fixable by adjusting the numbers, because the two
coordinate spaces differ per monitor and per DPI. So the placement is handed
straight back to Windows through SetWindowPlacement, the exact struct it was
read from. Windows resolves which monitor the rect belongs to and how it scales
there; the plugin never reasons about either. Verified round-tripping a real
window: read 2144 260 2942 863 3, applied, read back identical.

SetWindowPlacement carries size as well, so the separate set_inner_size is now
skipped whenever geometry is present rather than only when maximized -- it was
sizing the window twice, to two different answers.

A window saved minimized is restored normal. It really was minimized, but
reopening to nothing on screen reads as a failure to open.
It led with why WezTerm cannot read a window's position and which API calls
stand in for it, which is of no use to someone deciding whether to switch the
option on. Now it says Windows only, up front; what a restored window comes back
as; and what it will not do -- one window, minimized comes back normal, a move
made after the last save can be missed -- stated as facts rather than justified.

Corrects a claim while it is there: size was described as restored regardless of
this setting, which was never quite true and is less so now. Size is the separate
resize_window option, which is on by default and works everywhere, and geometry
brings size back with the position when it is on. resize_window was also missing
from the options block.
Sixty lines explaining that PowerShell's Set-Location does not move the process
working directory, that WSL runs in a VM Windows cannot see into, what OSC 7 is,
and why WEZTERM_PANE does not cross the WSL boundary. None of it helps anyone
decide anything; it is the reasoning behind the implementation, not a description
of the feature.

What they carried that nothing else did is kept:

- Working directories are restored, including in PowerShell, cmd and WSL panes.
  Now a bullet in Features, where it belonged -- it was not stated anywhere.
- setup() writes to the user's PowerShell profile and into their WSL
  distributions. That is worth saying plainly, so it joins the list of things
  setup() configures, next to the Claude hook it already disclosed, with the two
  options that turn it off.
- The integration only affects panes opened after it is installed. That is what
  someone needs when a directory looks wrong, so it moved into the FAQ answer
  about exactly that.

Net 66 lines out, 5 in.
The Windows CWD question sat above the plugin-update one despite being the
narrower of the two; it now follows it. The setup options block called
restore_window_geometry "(see below)" without saying it is Windows-only, which
the section below leads with.
…ndows

The CWD answer named PowerShell and WSL without saying which shell a Windows
user actually gets, which reads as though the shell integration is something
everyone on Windows needs. cmd.exe is the default -- WezTerm takes it from
COMSPEC -- and it needs none of this, because its cd moves the process working
directory. PowerShell is opt-in through default_prog, so the answer now says so
and shows the line.
tjcouch-sil and others added 3 commits August 6, 2026 11:15
* fix: restore scrollback reliably on Windows and support WSL panes

Three separate defects meant a restored session came back mostly empty on
Windows.

1. Scrollback restore raced the shell. The process branch of
   default_on_pane_restore already waited process_restore_delay_seconds
   because "commands sent during gui-startup get swallowed by the shell's
   init sequence", but inject_output fired immediately. ConPTY repaints the
   whole visible screen when the shell first draws, so the injected
   scrollback was overwritten before it was ever seen -- measured across 8
   consecutive saved instances, the injection survived once. All writes into
   a restored pane now happen in a single delayed callback, in a
   deterministic order.

2. WSL panes saved nothing at all. The capture branch was gated on
   domain == "local", citing MLFlexer#41, but that issue is
   about multiplexer domains: WSL domains are local ptys, so inject_output
   and get_lines_as_escapes work on them. They now capture like local panes.

3. Restoring a WSL pane typed a Windows path into bash. WezTerm reports the
   Windows directory wsl.exe was launched from, and restore_tab fed it
   straight to cd, producing "-bash: cd: C:/Users/...: No such file or
   directory". Spawn/split args and the cd are now domain-aware, and WSL
   panes cd themselves to a POSIX path rather than relying on a spawn cwd
   WezTerm resolves in Windows terms.

Fixing (3) properly needs a working directory from inside the VM, which only
the shell can report, so setup() now installs that: a new wsl_integration
module writes an OSC 7 snippet into each distro, sources it from .bashrc, and
registers the Claude Code pane-session hook there. Those are keyed by a
per-shell id rather than WEZTERM_PANE, which does not cross the WSL boundary
(WezTerm's WSLENV forwards only TERM/COLORTERM/TERM_PROGRAM/
TERM_PROGRAM_VERSION). Installation is idempotent, marker-guarded, deferred
off the startup path, and disabled with wsl_integration = false.

Also generalises the pane-session key from a numeric pane id to any
alphanumeric key, and extracts the Claude hook command so the Windows and WSL
installs share one code path.

* test: add a WezTerm-native smoke suite and force LF on installed shell snippet

The repo has no standalone Lua runtime, so add a config file that WezTerm's own
Lua evaluates via `show-keys`: it asserts the WSL path helpers, the hook command
builder, the spawn-target rules and the UNC path construction, and optionally
performs a real install into a live distro and reads back what landed. Because
`show-keys` loads the config, it also parses every module and doubles as a
syntax check.

That last check caught a real portability bug: git's autocrlf (on by default on
Windows) would hand a fresh clone a CRLF copy of wsl_integration.lua, and the
CRLF would be carried into the shell snippet written into the distro -- where a
CRLF script does not run. write_wsl_file now normalises to LF, and the test
seeds a CRLF copy first so a pass means the install actually rewrote it.

* fix: land restored scrollback above the shell's first prompt

Restoring a pane put the history on screen but drew the new prompt at the top
of the window, on top of it, as though the history were not there.

inject_output is invisible to the process driving the pty. On Windows both
PowerShell and WSL run behind ConPTY, which paints the visible screen at
absolute positions of its own choosing, so anything left on screen is doomed
twice: the first paint overwrites it, and the opening prompt lands at row 0
rather than below what was injected. Delaying the injection past that first
paint (the previous fix) stopped the overwrite but could not move the prompt.

Pad the injected text with exactly enough newlines to lift its last row above
the top of the viewport. The history lands in the scrollback buffer, which
ConPTY neither knows about nor touches, and the shell gets a blank screen to
draw into -- putting its prompt immediately below the restored history in
buffer order. Injection moves back to pane creation, where the pane is still
blank; only keystrokes still wait for the shell to be ready.

* fix: pad restored scrollback by a full viewport, and open panes on it

Two problems with the padding that lifts restored scrollback out of the
visible screen.

It was sized by the row count of the history, which is only correct when the
history already fills the screen. A terminal scrolls only once the cursor is
on the bottom row, so writing R rows and then N newlines scrolls exactly
max(0, R + N - viewport) rows: padding a 5-row history by 5 newlines in a
43-row pane scrolls nothing at all, leaving the history on screen for ConPTY's
first repaint to erase. The padding is now a full viewport regardless of R,
which is both necessary and sufficient -- and because those rows fill the
screen the shell is about to draw over, none of them reach the scrollback
buffer. Scrolling up from a restored prompt lands on the last line of the
restored history with no blank gap.

The injection now also homes the cursor afterwards, so a shell writing
relative to the cursor (bash behind a pty, rather than ConPTY's absolute
repaints) starts at the top of the blank screen instead of the bottom row,
where it would scroll blank lines into that gap.

Second, a restored pane looked like a fresh one: the history was there but a
page out of sight, with nothing to suggest it. Restored panes are now parked a
page back, on their own history. Typing scrolls to the prompt as usual, so it
costs nothing; scroll_to_history = false opts out.

* fix: forget a pane's Claude session when Claude exits, and key files per process

A pane-session file was written when Claude started and never removed, so a
pane stayed marked as a Claude pane for as long as it lived. Close Claude,
keep using the pane as a shell, and it was still saved as
`claude --resume <old-id>` -- coming back running Claude, its real scrollback
discarded, because process panes do not save text.

Two independent leaks, so two fixes.

Within one WezTerm run, nothing ever deleted the file. A SessionEnd hook now
removes it. Its matcher deliberately skips `clear` and `resume`, which end one
session only to start another immediately, where deleting would race the
SessionStart that follows.

Across runs, pane ids restart from 0 in every WezTerm process, so a fresh
pane 0 read the file left by the previous run's pane 0. Keys are now prefixed
with the instance id, exported to child processes as RESURRECT_INSTANCE so the
hook builds the same key the save path looks for. That also stops two WezTerm
processes running side by side from overwriting each other's files. A crash
still strands a file, so old ones are swept by age -- the directory is shared
between processes, which makes "not mine" unsafe to delete, but age is fine.

Because the key expression is part of the hook command, hooks must now be
replaced on install rather than added only when missing: an existing user's
stale hook would keep writing under the old key and silently stop matching.
Other hooks in the file are preserved.

Doing that rewrite exposed a data-loss path worth closing: settings.json that
failed to parse was treated as an empty object and then written back out,
discarding everything in it. It now refuses to rewrite what it cannot read.

* feat: add auto_restore, and label the plugin's command palette entries

auto_restore decides what the first window does when saved instances exist:
"prompt" (the default, and the previous behaviour), "latest" to restore the
most recent without asking, or false to do nothing. auto_restore_prompt =
false keeps working as the older spelling of false.

"latest" skips the instance this WezTerm is itself writing. That is not a
detail: by the time the startup restore runs, event-driven save has already
recorded the new window's single blank tab, and it sorts to the top as the most
recently saved thing there is -- so the obvious implementation restores a blank
window, every time.

Only the first WezTerm restores automatically. Without a mux server each launch
is its own GUI process with its own gui-startup, so a second one would reopen
the session already on screen in the first; it gets the selector instead.
Counting wezterm-gui processes is the only signal available that early, since
the state files cannot tell a running instance from one that crashed.

Separately, every command this plugin bound showed up in the command palette as
"Emit event `user-defined-3`" -- a number that depends on registration order and
tells the reader nothing, leaving the key combo as the only clue to what an
entry does. The commands are now named events, and augment-command-palette
gives each one a real label and icon.

Also stop rewriting Claude's settings.json when nothing would change. setup()
runs on every launch and config reload, and the previous commit made it write
unconditionally; a stale read written back could undo an edit Claude Code made
to the file in the meantime.

* fix: adopt the restored session, instead of losing it on the next launch

Restoring a session left nothing saved that could restore it again. The
instance we read from is tombstoned on restore, and the only thing under this
process's own id is the blank window that event-driven save recorded a second
earlier at startup. Close WezTerm before the next save -- 5 minutes later by
default, or whenever the tab structure next changes -- and that blank window is
the newest instance there is.

The saved state showed it plainly: the two most recent instances were both a
single tab at the default directory whose entire scrollback was the PowerShell
banner, while the real six-tab session sat below them. auto_restore = "latest"
was working correctly and restoring exactly that, which also accounts for a
restored first tab arriving at the default directory with a "history" three
lines long.

Restoring now saves what it restored under this process's id, so closing at any
point after leaves the session recoverable, and saves again once the restore has
settled so the state reflects what is on screen. The settle delay clears the
restore commands and gives a resumed Claude Code time to report its session,
which would otherwise be saved as plain scrollback.

WezTerm exposes no shutdown event in this version, so saving on close is not
available; periodic_interval is the lever for how much recent work a crash can
cost.

Also collapse the command palette entries back to one each. Adding labelled
entries via augment-command-palette did not replace the ones WezTerm derives
from the key assignments, it added a second set -- and entries added that way
never show their key binding, so the readable one was the one without the
shortcut. The palette labels an EmitEvent from its event name, so the names are
now the labels: "Emit event `Resurrect: Save everything now`", with ALT+s on it.

* fix: stop leaving PowerShell mid-command, and make adopting a restore work

Three defects, found in that order because each was hiding the next.

Restore commands were submitted with "\r\n". PSReadLine runs the line on the CR
and then takes the LF as a second keypress meaning "insert a newline", so every
pane sent a command was left at PowerShell's `>>` continuation prompt with the
next thing typed there swallowed into it. The saved scrollback shows it plainly:
`cd C:/Users/tj_co/` followed by `>>`, over and over, generation after
generation. Only the reused first pane is affected, since every other tab is
spawned with its directory and is never sent a cd -- which is exactly the tab
that looked like it was "moving back" to the default directory. Enter is a bare
carriage return.

Adopting a restored session never worked. The snapshot aliased the very tables
restore_workspace mutates, and restoring hangs live Pane objects off each
pane_tree node; json_encode throws on the first one it meets, taking the
adoption, the settle-save scheduled after it, and any remaining instances with
it. It is deep-copied before the restore now, and the save is guarded so a
future failure is logged rather than silently swallowing the rest of the work.

Parking a restored pane scrolled a whole page, which puts the last row of
history on the bottom edge and the live prompt one row past it -- a pane that
appears to have no prompt. It now stops six rows short, and never scrolls
further than the history is long, which is what made a short one jump to the
top of the buffer.

* fix: teach PowerShell to report its working directory

A restored pane went back to the directory its shell was launched in rather
than the one it was saved in, and only ever the first tab -- which made it look
like a restore bug. It is not. PowerShell's Set-Location moves only its own
provider location; the process working directory, which is all Windows reports
and all WezTerm can read unaided, never moves:

    PS> cd C:\Windows
    provider = C:\Windows
    process  = C:\Users\tj_co\source\repos\paranext-core-2

So that is what gets saved, and restoring faithfully returns the pane to it.
Every other tab escapes this because WezTerm spawns it in the right directory
to begin with; the one a restore reuses has to be told, and can only be told
what was recorded.

The fix is the one already applied to WSL, for the same reason: install OSC 7,
the escape sequence a shell uses to report where it is. A snippet in
~/.config/wezterm-resurrect/integration.ps1 is dot-sourced from a guarded block
at the end of $PROFILE.CurrentUserAllHosts, for each PowerShell found -- asked
for by name rather than guessed at, since Documents is routinely relocated. It
wraps whatever prompt function is already defined instead of replacing it, and
guards against being sourced twice. Set powershell_integration = false to opt
out, or if you already have shell integration configured.

* feat: optionally restore window position and maximized state

WezTerm can set both -- set_position, maximize -- but can read neither. A
GuiWindow reports pixel_width, pixel_height, dpi and is_full_screen, and there
is no get_position at all, so the values have to come from Windows itself via
GetWindowPlacement. That costs a subprocess, which shapes the whole design:

- Off by default, restore_window_geometry = true to enable.
- Captured only on the periodic save and on Alt+S, never on the event-driven
  saves that fire whenever a tab opens.
- The interop is compiled once and cached as an assembly beside the state, which
  takes a capture from ~950ms to ~550ms, and one capture is memoised across a
  save cycle so a save walking several windows still pays once.

Only ever one window. Windows exposes a process's main window handle, and
WezTerm gives Lua no way to say which of its windows that is; with two open
there is no telling which handle belongs to which, so geometry is skipped rather
than guessed at.

Size is deliberately left to WezTerm's own pixel dimensions rather than the rect
Windows reports: rcNormalPosition is the outer window, set_inner_size takes the
inner one, and feeding the first into the second would grow the window by its
frame on every save/restore cycle. Geometry is applied before any tab is
restored, since scrollback is injected to fill the pane it lands in.

* fix: stop event-driven saves from erasing the window's geometry

Only the periodic save and Alt+S pay to read the window's position; every other
save left the field empty. Since each save rewrites the whole state, opening a
tab was enough to erase the position the last periodic save had recorded -- and
closing before the next one lost it entirely, which is every short session.

The last geometry seen is now remembered for the life of the process and written
by the saves that do not capture, so the value only ever moves forward. Restoring
seeds it too, so the saves before this process's first capture carry through what
it was restored with rather than dropping it. A capture that declines -- two
windows open, so there is no telling which handle is which -- leaves the
remembered value alone, since it says nothing about where the window was.

* fix: stop a backtracking pattern freezing startup for half a minute

Restoring a pane trimmed its scrollback with gsub("%s+$", ""). Lua patterns have
no anchor optimisation, so that is retried at every position in the string and
each retry walks the whitespace run it finds before discovering it is not at the
end. Captured scrollback is the worst input imaginable for it: every line is
padded out to the pane width, so the text is mostly whitespace runs.

Measured on a real 534 KB capture, that one call took 15.1 seconds. A byte loop
walking backwards from the end takes 0 and returns a byte-identical string. It
ran once per restored pane, on the GUI thread, which is why Windows greyed the
window out as not responding.

End to end on the same session, restoring the tab holding that capture:
21731 ms before, 72 ms after.

The volume was never the problem, which is what made this hard to see from the
outside: inject_output swallows the same 534 KB in 68 ms, and the 329 escape
sequences in it are irrelevant. It was purely the trim.

Also expose resize_window through setup(). Sizing a restored window to its saved
size is long-standing behaviour that runs independently of
restore_window_geometry, so there was no way to ask for a restore that leaves the
window alone; now there is.

* fix: restore window placement through Windows instead of reconstructing it

A restored window landed on the right monitor but floated across it, down and to
the right, looking maximized without covering the screen.

The rect comes out of GetWindowPlacement in Windows' virtual desktop
coordinates -- the window this was measured on sat at x=2144, on the second
monitor -- and was being applied with WezTerm's set_position, which scales
positions its own way. Close enough to reach the right monitor, wrong enough to
be offset across it, and a maximize following a position Windows disagreed with
did not snap.

Nothing about that is fixable by adjusting the numbers, because the two
coordinate spaces differ per monitor and per DPI. So the placement is handed
straight back to Windows through SetWindowPlacement, the exact struct it was
read from. Windows resolves which monitor the rect belongs to and how it scales
there; the plugin never reasons about either. Verified round-tripping a real
window: read 2144 260 2942 863 3, applied, read back identical.

SetWindowPlacement carries size as well, so the separate set_inner_size is now
skipped whenever geometry is present rather than only when maximized -- it was
sizing the window twice, to two different answers.

A window saved minimized is restored normal. It really was minimized, but
reopening to nothing on screen reads as a failure to open.

* docs: rewrite the window geometry section for the person configuring it

It led with why WezTerm cannot read a window's position and which API calls
stand in for it, which is of no use to someone deciding whether to switch the
option on. Now it says Windows only, up front; what a restored window comes back
as; and what it will not do -- one window, minimized comes back normal, a move
made after the last save can be missed -- stated as facts rather than justified.

Corrects a claim while it is there: size was described as restored regardless of
this setting, which was never quite true and is less so now. Size is the separate
resize_window option, which is on by default and works everywhere, and geometry
brings size back with the position when it is on. resize_window was also missing
from the options block.

* docs: drop the two Windows shell-integration sections

Sixty lines explaining that PowerShell's Set-Location does not move the process
working directory, that WSL runs in a VM Windows cannot see into, what OSC 7 is,
and why WEZTERM_PANE does not cross the WSL boundary. None of it helps anyone
decide anything; it is the reasoning behind the implementation, not a description
of the feature.

What they carried that nothing else did is kept:

- Working directories are restored, including in PowerShell, cmd and WSL panes.
  Now a bullet in Features, where it belonged -- it was not stated anywhere.
- setup() writes to the user's PowerShell profile and into their WSL
  distributions. That is worth saying plainly, so it joins the list of things
  setup() configures, next to the Claude hook it already disclosed, with the two
  options that turn it off.
- The integration only affects panes opened after it is installed. That is what
  someone needs when a directory looks wrong, so it moved into the FAQ answer
  about exactly that.

Net 66 lines out, 5 in.

* docs: reorder the FAQ and mark restore_window_geometry as Windows-only

The Windows CWD question sat above the plugin-update one despite being the
narrower of the two; it now follows it. The setup options block called
restore_window_geometry "(see below)" without saying it is Windows-only, which
the section below leads with.

* docs: note that cmd, not PowerShell, is WezTerm's default shell on Windows

The CWD answer named PowerShell and WSL without saying which shell a Windows
user actually gets, which reads as though the shell integration is something
everyone on Windows needs. cmd.exe is the default -- WezTerm takes it from
COMSPEC -- and it needs none of this, because its cd moves the process working
directory. PowerShell is opt-in through default_prog, so the answer now says so
and shows the line.
…starts from

A restore reuses the window it was launched from for the first saved tab. That
window is spawned before anything is known about what it will hold, so it runs
default_prog -- cmd or PowerShell on Windows. When the first saved tab was a WSL
tab, it was restored into that pane regardless: a shell that is not the one the
tab claims to be, then sent a POSIX cd it cannot follow, and whatever else the
tab was running -- Claude Code, in the report that found this.

The tab is reused only when its pane is already in the domain the saved tab
wants. Otherwise a tab is spawned in the right domain and the one we could not
use is closed, so no stray shell is left behind. A state with no domain recorded
reuses as before.

Restoring a synthetic session whose first tab is WSL and second is local, in an
isolated state directory:

  before   tab 1 domain=local             cwd=/C:/Users/tj_co
           tab 2 domain=local             cwd=/C:/Users/tj_co
  after    tab 1 domain=WSL:Ubuntu-22.04  cwd=/home/tj_co
           tab 2 domain=local             cwd=/C:/Users/tj_co

Note the second tab was wrong too: it inherited the reused pane's domain rather
than its own.
apply() acts through the helper that asks Windows for this process's main
window, and it had no guard on there being one. In a headless run -- which is
how the test suite executes -- the process has no window of its own, so the
handle that comes back belongs to whatever other WezTerm is open, and applying a
placement would move it.

Nothing hit this: the only test that reaches apply() passes a geometry with no
size in it, which returns earlier. That is safe by accident, and would stop
being safe the moment someone completed the fixture.

apply() now declines unless this process owns exactly one window, which is the
same guard capture() already used and for the same reason. That makes it inert
from a headless run by construction rather than by luck, and there is now a test
that calls it with a complete geometry to prove it.
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