Windows: fix scrollback, working directories and WSL panes on restore - #17
Open
tjcouch-sil wants to merge 20 commits into
Open
Windows: fix scrollback, working directories and WSL panes on restore#17tjcouch-sil wants to merge 20 commits into
tjcouch-sil wants to merge 20 commits into
Conversation
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.
* 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 usesCOMSPEC). PowerShell is opt-in, viayour own config:
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_outputfired immediately while the process-restorebranch beside it already waited
process_restore_delay_seconds. ConPTY repaintsthe 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
cmdpanes as well asPowerShell 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 atevery 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 thenext thing typed swallowed into it.
cmdandbashare unbothered by the strayLF. 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 isabout multiplexer domains. WSL domains are local ptys, so
inject_outputandget_lines_as_escapeswork 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 thecdare 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 beforeanything 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 aPOSIX
cdit could not follow and whatever else the tab was running. It isreused 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:
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 aSessionEndhook, plus per-processkeys so a fresh pane 0 no longer reads the previous run's pane 0 file.
settings.jsonthat 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.
cmdalready reportscorrectly, since its
cdmoves the process working directory.) PowerShell'sSet-Locationnever moves the process working directory, and WSL shells areinvisible to Windows entirely, so those panes were saved as still sitting where
their shell started.
setup()installs OSC 7 reporting into the PowerShellprofile and into each WSL distribution, and the Claude session hook inside WSL.
powershell_integration/wsl_integrationopt out.restore_window_geometry(Windows only, off by default) — windowposition and maximized state, read and written through
GetWindowPlacement/SetWindowPlacement, since WezTerm can set both but read neither.resize_windowexposed throughsetup(), so a restore can be asked toleave the window alone. (All platforms.)
Emit event `user-defined-3`— a number that depends on registration order.Testing
test_debug/wezterm_smoke.luaruns 104 assertions inside WezTerm's own Lua viawezterm --config-file … show-keys, which also parses every module and so doublesas a syntax check. This exists because the repo notes there is no Lua runtime on
the machine; it does not replace the busted suite.
Known gaps
Windows exposes a process's main window and WezTerm gives Lua no way to say
which of its windows that is.
restore_window_geometryis Windows-only; elsewhere it is a no-op.helper declines unless the process owns exactly one window, so a headless run
cannot move somebody else's window.
cmd.exeas theprimary shell beyond what my saved states happened to contain.