Skip to content

Add save-state and load-state for windows - #5698

Open
fiorix wants to merge 4 commits into
tmux:masterfrom
fiorix:save-state
Open

fiorix wants to merge 4 commits into
tmux:masterfrom
fiorix:save-state

Conversation

@fiorix

@fiorix fiorix commented Oct 7, 2026

Copy link
Copy Markdown

This is the first step from #5693: tmux save-state -w - | tmux load-state -w - creates a new window that is the same as the current one, except that nothing is running in its panes. The JSON is meant to grow, with sessions next (-s), then the whole server (-a), and then the in-place restart on top of that, as discussed there.

There are four commits, and each builds and works on its own:

  1. Window structure. Name, layout (the v2 layout object, embedded as JSON), zoom, and for each pane its title, working directory and command. -t and -d on load-state are as for new-window, and - means stdout or stdin as with save-buffer and load-buffer.
  2. Pane contents. Visible lines, history and cursor, and the normal screen while the alternate screen is on. Lines are made of runs of cells with the same style.
  3. Options. The window and pane options that are set locally, hooks and user options included.
  4. Remaining terminal state. Modes, cursor style, scroll region, tab stops, title stack, OSC 7 path, the cursor saved by DECSC, hyperlinks, OSC 133 marks and the palette.

A saved window looks like this, shortened:

{"version":1,"window":{"name":"vim","layout":{"V":2,"L":{...}},"options":[...],
 "panes":[{"title":"host","cwd":"/src","command":[{"arg":"vim"},{"arg":"x.c"}],
  "options":[],"screen":{"sx":80,"sy":24,"cursor":{"x":3,"y":5},
   "modes":[{"name":"cursor"},{"name":"wrap"}],"history":[...],
   "lines":[{"c":[{"t":"int ","a":"bright"},{"t":"main","f":"red"}]},{}]}}]}}

Here is how it works. Unless you see a problem with any of it, I will build sessions on the same basis:

  • What the loader accepts. It ignores keys it does not know, and a missing key means the default. json.c has no empty strings, so empty values are left out rather than written as "". Later stages can then add keys without changing version. A file with a higher version is refused.
  • Stable names. The file uses names rather than internal values: colours, attributes, option values and modes all go through the existing string conversions.
  • Checked before creating. load-state checks the whole file before it creates anything. That includes the layout, through a new layout_check_string that shares its size checks with layout_parse, and the options, which are first applied to a scratch tree. A bad file fails with a message, and no window appears, not even for a moment.
  • Empty panes. The panes are created empty, as with new-window -E, but they keep the saved command and directory, so respawn-pane starts the original command in the original place.
  • The command. It is saved as its argv list. cmd_stringify_argv quotes a single argument, so loading its output back as one argument would break respawn-pane.
  • Names, titles and paths. These are saved as they were given, decoded from vis(3), and loaded through clean_name as rename-window does. The working directory is never expanded as a format: the spawn context has no item, so a directory named #(...) cannot run a command.
  • Combining characters. Text is split back into cells with the current width tables. The combining decision moves out of screen_write_combine into a new utf8_combine in utf8-combined.c, which both use, so the two cannot drift. The existing combine and UTF-8 tests pass unchanged.
  • Trust. Options can hold hooks and formats that run commands, so the manual page says a state file should be trusted as a configuration file would be.
  • Manual page. The new commands are under WINDOWS AND PANES. load-buffer already has a -w with another meaning, which is why they are not next to it in BUFFERS.

Not saved yet:

  • modal panes;
  • sixel images;
  • the progress bar;
  • the input parser's current state (the pen and any partial sequence).

Only the in-place restart needs the input parser's state.

Testing:

  • New tests. Four regress scripts, one per commit: save-load-state-window.sh, -screen.sh, -options.sh and -terminal.sh. They cover the round trips and save/load/save giving the same JSON, as well as wide and combining characters, cells left misplaced by erasing half of a wide character, the alternate screen, panes resized after saving, and about 70 kinds of invalid file.
  • Builds and regress. Each commit builds without warnings with the default configure and with --enable-sixel, and passes regress. The only failures are copy-mode-search-count.sh and join-pane-modal.sh, two new upstream tests that fail the same way on master.
  • Valgrind. Under valgrind the new tests show no invalid accesses or uninitialised values.

fiorix added 4 commits October 7, 2026 06:11
…opy of it.

save-state -w writes a window to a file as JSON and load-state -w creates a new window from it with the same name, layout, zoom and pane titles, and nothing running in its panes. Files go through file_write and file_read as with save-buffer and load-buffer, so "-" is stdout or stdin. -t and -d on load-state are as for new-window.

The file is {"version":1,"window":{...}}; later scopes (a session, the server) will hold the same window object. The window has "name", "layout" (the v2 object from layout_dump, the full layout if zoomed), "zoomed" and "panes"; each pane has "title", "cwd", "command", "zoomed" and "float-over-zoom". The loader ignores keys it does not know and treats a missing key as empty or false, since json.c has no empty strings.

- "command" is the pane's argv as a list. cmd_stringify_argv quotes a single argument, so loading its output as one argument would break respawn-pane.
- Names and titles are written decoded from vis(3) and loaded through clean_name, as rename-window and select-pane -T do, so they round trip.
- The working directory is the process's current one, falling back to where the pane started. It is not expanded as a format on load: the spawn context has no item, so a directory named #(...) cannot run a command.
- The panes are created empty (SPAWN_EMPTY) but keep the saved command and directory, so respawn-pane starts the original command where it was.

Everything is checked before anything is created. A new layout_check_string in layout-custom.c checks the layout fits the panes without a window, sharing the size checks with layout_parse. The first pane comes from spawn_window, the others get floating cells, which need no space, and layout_parse then places them by index. A window with a manual size gets the layout's size first. Giving the window a name turns automatic-rename off, as new-window -n does.

json.c gains json_write_string and json_decode_string, and json_parse refuses input over INT_MAX bytes because it keeps offsets in an int.

Not saved: modal panes. Screen contents and options follow.
Each pane gets a "screen" object with "sx" and "sy", "cursor", "history" (oldest first) and "lines". While the alternate screen is on, "alternate" holds the normal screen's lines, which tmux keeps aside as screen_alternate_on leaves them, and "alternate-cursor" holds the cursor to go back to, which is kept even after the alternate screen ends.

A line is {"c":[runs]} with "w":true if wrapped. A run is consecutive cells with the same style: "t" is the text and "a", "f", "b" and "u" the attributes and colours by name, left out when default. Padding after a wide character is implied by its width. A run that does not start where the widths before it put it has its column in "x"; only padding is left out, so the gap is padding again on load. A tab gets its own run with its width in "s". SELECTED and CLEARED are dropped: one is transient and the other draws exactly as the space with the same background that it becomes. NOPALETTE is only used on copy mode's own screen.

Loading splits text into cells with the current width tables and the same rules as screen_write_combine, which now asks a new utf8_combine for its decision so the two cannot drift. Cells written apart that the rules would join start a new run when saved. If a width has changed, the cell is kept and the rest of the run moves along; cells past the edge are dropped, so the grid stays well formed.

The grids are built while the file is read, so a bad screen still fails before anything is created, with the history limit of the target session (keeping the newest lines). After the layout and zoom are applied each pane's grid is swapped in and resized to the pane, which does nothing if the size is the same. The cursor may be past the last column, because the alternate screen is not reflowed when a pane gets narrower; outside the alternate screen it is moved in before reflowing.

A line can hold cells beyond the width after a pane is made narrower in the alternate screen; only those that fit are loaded.
The window and each pane get an "options" list of the options set on that object itself, hooks and user options included: {"name":...,"value":...}, with "index" for an array item and an entry with no index for an array set with no items. Values come from options_to_string, so flags, choices, colours and keys are names.

load-state applies each list to a scratch options tree first, as set-option would, so an unknown option, one of the wrong scope or a bad value fails the load with the option's name before anything is created. The new window's and panes' own options are then replaced by those and options_push_changes is run for each, as set-option does. A window saved with automatic-rename inherited keeps renaming itself; a file without options leaves automatic-rename off as before.

The options are set before the layout is applied, because pane-border-status and pane-scrollbars change pane sizes.

Hooks and formats in options can run commands, so a state file must be trusted like a configuration file; the manual page says so.
So that loading a window loses nothing a later restart needs, the screen object also gets the cursor style and colour ("cursor-style", "cursor-colour"), "modes" by name, the scroll "region", "tabs", the title stack ("titles"), the OSC 7 "path", the cursor and cell saved by DECSC ("saved"), the cell to go back to with "alternate-cursor", hyperlinks ("links", with "l" on runs) and OSC 133 marks ("m" on lines); the pane gets its "palette" (OSC 4, 10 and 11). Each is optional, and all but the modes are left out when they are what a new pane has.

- MODE_SYNC is not saved: it is ended by a timer a new pane does not have.
- The default cursor style, colour and modes are not saved because they come from the cursor-style and cursor-colour options.
- The scroll region and tab stops are set before the screen is resized to the pane, which resets them if the size changes.
- DECSC lives in the input parser, so input.c gains input_get_saved_cursor and input_set_saved_cursor. The saved position can be outside the screen after a resize; DECRC moves it in.
- screen.c gains screen_get_title to read the title stack.
- Hyperlinks are read into a set of their own and the pane's set is replaced with the grid; a run naming a link that is not in the file loads without one.

Not saved: sixel images, the progress bar, and the input parser's current state, which only an in-place restart needs.
@nicm

nicm commented Oct 9, 2026

Copy link
Copy Markdown
Member

This looks like the right kind of idea but I have a lot of stuff to get through so I'm not sure when I will be able to have a closer look. I imagine this will be a 4.0 feature not 3.9 in any case.

@fiorix

fiorix commented Oct 9, 2026

Copy link
Copy Markdown
Author

Makes sense, we can park it or even discard. The most important bits for me were the early patches with support for XDG_RUNTIME_DIR and the bug fixes. These save/load and terminal magic are nice-to-have and not blocking anything.

Take your time, let me know if you need any help.

Cheers!

@nicm

nicm commented Oct 9, 2026

Copy link
Copy Markdown
Member

OK I will leave it open but I imagine it will not be for a while.

@nicm nicm mentioned this pull request Oct 9, 2026
4 of 16 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: For Review

Development

Successfully merging this pull request may close these issues.

2 participants