From c35abe0f03051533583b39f8ef2559c6f277912b Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 23:26:38 +0200 Subject: [PATCH] fix: retain alternate screen across executable restart --- docs/architecture.md | 6 +- docs/configuration.md | 2 +- docs/editing.md | 2 +- src/main.rs | 54 ++++++++---- src/tui.rs | 191 +++++++++++++++++++++++------------------- tests/commands.rs | 121 ++++++++++++++++++++++++++ 6 files changed, 273 insertions(+), 103 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 76944af..8c21349 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,7 +24,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency. - `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Focused diff text supports native LSP navigation, hover, and diagnostics; read-only buffers still reject asynchronous editing effects, and selectors/file lists are not document-action targets. - `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. -- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. On restart, it restores the terminal and drops the entire `EditorSession`, including workers and plugin hosts, before relaunching the same executable with the original CLI arguments. Unix replaces the process with `exec`; other platforms spawn a fresh process and return. Startup rereads configuration and the original startup file, or creates an unnamed buffer; current buffers, tabs, views, and session state are not restored. +- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI inside a terminal guard covering startup, rendering, session teardown, and executable handoff. On restart, it drops the entire `EditorSession`, including workers and plugin hosts, then briefly disables raw mode and input reporting without leaving the alternate screen before relaunching the same executable with the original CLI arguments. Unix replaces the process with `exec`; other platforms spawn a fresh process and wait for it. Startup rereads configuration and the original startup file, or creates an unnamed buffer; current buffers, tabs, views, and session state are not restored. ## Frontend interface @@ -59,7 +59,9 @@ Frontends borrow `snippet_selections()` for the active argument's end-exclusive ## Terminal lifecycle and layout -The terminal adapter owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, DEC 2031 color-scheme notifications, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. +The terminal guard owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, DEC 2031 color-scheme notifications, and its panic hook across startup, rendering, session teardown, and relaunch. An inherited-screen restart installs cleanup and its panic hook before argument, file, or configuration parsing, resumes raw mode and input reporting without entering the alternate screen again, and clears and repaints inherited contents in its first synchronized frame. A private first-argument marker identifies this handoff without changing the original CLI arguments. Final quit, startup or I/O errors, failed relaunch, and unwinding panics restore normal input modes, the normal screen, cursor visibility, and the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while the guard is active. + +`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen, and the guard stays armed if relaunch fails. Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam. diff --git a/docs/configuration.md b/docs/configuration.md index c3e870f..8e955c8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -188,7 +188,7 @@ Live clean buffers reload external changes automatically, including inactive tab `core.restart` and `core.force_restart` have no default keybindings. Their no-argument commands are `:restart` and `:force_restart`, with `:restart!` as an alias for force restart. Normal restart closes clean buffers across every tab, pane, and historical view; any dirty buffer blocks the relaunch with a warning, retains its text and undo/redo history, and leaves the editor interactive without restoring the clean buffers already closed. Force restart discards unsaved text without writing it. The palette confirms force restart, while a configured `config.core.force_restart` binding executes directly. -Restart relaunches the same executable with the original CLI arguments after terminal restoration and full session teardown. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. +Restart relaunches the same executable with the original CLI arguments after full session teardown. Raw mode and input reporting briefly reset, but the alternate screen remains active across the process handoff; the new process resumes input modes and clears and repaints the screen atomically in its first frame. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. Final quit, errors, and panics restore normal input modes, the normal screen, cursor visibility, and the prior panic hook. ## Binding overrides and failures diff --git a/docs/editing.md b/docs/editing.md index 86978dc..486f7c0 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -174,7 +174,7 @@ Commands: `:save [path]`, `:force_save [path]`, `:quit`, `:force_quit`, `:restar `:restart` closes clean buffers across all tabs, panes, and historical views. If any dirty buffer remains, it preserves that buffer's unsaved text and undo/redo history, warns, and keeps the editor interactive; clean buffers already closed stay closed. With no dirty buffers, it restarts the executable. `:force_restart` (alias `:restart!`) restarts regardless of dirty buffers, discarding unsaved text without writing it. Both commands take no arguments; their Normal-mode actions are `core.restart` and `core.force_restart`, with no default keybindings. Selecting force restart in the command palette requires confirmation; an explicitly configured force-restart binding executes directly. -Restart restores the terminal and tears down the entire editor session, including workers and plugins, before relaunching the same executable with its original CLI arguments. The new process rereads configuration and reopens the original startup file from disk, or starts with an unnamed buffer if no file was supplied. Current buffers, tabs, views, and other session state are not restored. +Restart tears down the entire editor session, including workers and plugins, before relaunching the same executable with its original CLI arguments. It briefly resets raw mode and input reporting but retains the alternate screen across the process handoff; the new process resumes input modes and clears and repaints that screen atomically in its first frame. The new process rereads configuration and reopens the original startup file from disk, or starts with an unnamed buffer if no file was supplied. Current buffers, tabs, views, and other session state are not restored. Final quit, errors, and panics still restore normal input modes, the normal screen, cursor visibility, and the prior panic hook. All saves use a same-directory temporary file and rename and preserve existing permissions. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode. diff --git a/src/main.rs b/src/main.rs index af87fc1..7bba7fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ -use std::{env, path::PathBuf}; +use std::{env, io, path::PathBuf}; use vamp::{session::EditorSession, tui}; +// Only the first argument can be this process-to-process terminal handoff marker. +const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen"; + const HELP: &str = "vamp — modal editor with isolated Ruby plugins Usage: vamp [--config PATH | --no-config] [FILE] @@ -86,8 +89,8 @@ struct Options { config: Option, } -fn options() -> Result, String> { - let mut args = env::args_os().skip(1); +fn options(inherited_screen: bool) -> Result, String> { + let mut args = env::args_os().skip(1 + usize::from(inherited_screen)); let mut file = None; let mut config = None; let mut config_selected = false; @@ -129,7 +132,25 @@ fn options() -> Result, String> { } fn run() -> Result<(), String> { - let Some(options) = options()? else { + let inherited_screen = env::args_os() + .nth(1) + .is_some_and(|arg| arg == INHERITED_SCREEN); + if inherited_screen { + return tui::TerminalSession::run(true, || { + run_editor(options(true).map_err(io::Error::other)?, true) + }) + .map_err(|error| error.to_string()); + } + let options = options(false)?; + if options.is_none() { + return Ok(()); + } + tui::TerminalSession::run(false, || run_editor(options, false)) + .map_err(|error| error.to_string()) +} + +fn run_editor(options: Option, inherited_screen: bool) -> io::Result<()> { + let Some(options) = options else { return Ok(()); }; let mut session = EditorSession::open( @@ -137,34 +158,37 @@ fn run() -> Result<(), String> { tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(), options.config.as_deref(), ) - .map_err(|error| format!("cannot open file: {error}"))?; - tui::run(&mut session).map_err(|error| format!("terminal error: {error}"))?; + .map_err(|error| io::Error::other(format!("cannot open file: {error}")))?; + tui::run(&mut session, inherited_screen)?; let restart = session.editor().should_restart(); drop(session); if restart { - restart_editor()?; + tui::TerminalSession::prepare_restart()?; + restart_editor(inherited_screen) + .map_err(|error| io::Error::other(format!("Restart failed: {error}")))?; } Ok(()) } -fn restart_editor() -> Result<(), String> { - let executable = env::current_exe().map_err(|error| format!("Restart failed: {error}"))?; +fn restart_editor(inherited_screen: bool) -> io::Result<()> { + let executable = env::current_exe()?; let mut command = std::process::Command::new(executable); - command.args(env::args_os().skip(1)); + command.arg(INHERITED_SCREEN); + command.args(env::args_os().skip(1 + usize::from(inherited_screen))); #[cfg(unix)] { use std::os::unix::process::CommandExt; - Err(format!("Restart failed: {}", command.exec())) + Err(command.exec()) } #[cfg(not(unix))] { - let status = command - .status() - .map_err(|error| format!("Restart failed: {error}"))?; + let status = command.status()?; if status.success() { Ok(()) } else { - Err(format!("Restarted editor exited with {status}")) + Err(io::Error::other(format!( + "Restarted editor exited with {status}" + ))) } } } diff --git a/src/tui.rs b/src/tui.rs index 5d491cb..e81ee64 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -59,7 +59,8 @@ struct HookState { active: bool, } -struct TerminalSession { +/// Owns terminal modes and panic cleanup through application startup and shutdown. +pub struct TerminalSession { hook: Arc>, } @@ -91,10 +92,10 @@ fn restore_terminal() { } impl TerminalSession { - fn run(body: impl FnOnce() -> io::Result<()>) -> io::Result<()> { + pub fn run(inherited_screen: bool, body: impl FnOnce() -> io::Result<()>) -> io::Result<()> { let hook = Arc::new(Mutex::new(HookState { previous: Some(panic::take_hook()), - active: false, + active: inherited_screen, })); let during_session = Arc::clone(&hook); panic::set_hook(Box::new(move |info| { @@ -119,9 +120,11 @@ impl TerminalSession { .lock() .unwrap_or_else(|error| error.into_inner()) .active = true; + if !inherited_screen { + execute!(io::stdout(), EnterAlternateScreen)?; + } execute!( io::stdout(), - EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture, EnableFocusChange, @@ -135,6 +138,20 @@ impl TerminalSession { Err(payload) => panic::resume_unwind(payload), } } + + /// Restores process-local raw/input state without exposing the normal screen. + /// The guard remains armed if launching the replacement process fails. + pub fn prepare_restart() -> io::Result<()> { + disable_raw_mode()?; + execute!( + io::stdout(), + EndSynchronizedUpdate, + DisableBracketedPaste, + DisableMouseCapture, + DisableFocusChange, + ColorSchemeNotifications(false) + ) + } } impl Drop for TerminalSession { @@ -737,95 +754,101 @@ fn wheel_kind(mouse: &MouseEvent) -> Option { fn draw_frame( terminal: &mut Terminal>>, output: &mut W, + clear_screen: bool, render: impl FnOnce(&mut Frame), ) -> io::Result<()> { // Do not suspend the terminal's presentation while computing the next frame. terminal.backend_mut().writer_mut().clear(); crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?; // Terminals without synchronized updates still hide the painting cursor. - let result = terminal - .hide_cursor() - .and_then(|()| terminal.draw(render).map(|_| ())); + let result = terminal.hide_cursor().and_then(|()| { + if clear_screen { + // Fresh Ratatui buffers are already empty; avoid its blocking cursor query. + crossterm::queue!( + terminal.backend_mut(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::All) + )?; + } + terminal.draw(render).map(|_| ()) + }); let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate); result.and(finish)?; output.write_all(terminal.backend().writer())?; output.flush() } -/// Renders session state and translates terminal input; the session owns plugin -/// execution and retirement, so the adapter needs no lifecycle callback. -pub fn run(session: &mut EditorSession) -> io::Result<()> { - TerminalSession::run(|| { - let mut output: Stdout = io::stdout(); - let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?; - let mut viewports = BufferViewports::default(); - let mut colors = TerminalColors::default(); - let mut query_theme = true; - let mut redraw = true; - let mut pending = None; - while !session.editor().should_quit() { - redraw |= session.poll(); - colors.expire(Instant::now()); - if viewports.theme != colors.colors { - viewports.set_theme(colors.colors); - redraw = true; - } - if redraw { - draw_frame(&mut terminal, &mut output, |frame| { - viewports.draw_session(frame, session) - })?; - redraw = false; - } - // Start the reply deadline after painting, not before expensive syntax initialization. - if query_theme || viewports.diff_visible { - let now = Instant::now(); - if query_theme || colors.refresh_due(now) { - colors.query(&mut output, now)?; - query_theme = false; - } - } - let input = if let Some(input) = pending.take() { - input - } else { - let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))? - else { - continue; - }; - input - }; - match input { - Event::Key(key) if key.kind != KeyEventKind::Release => { - if session.editor().mode() == Mode::Insert && typing_key(&key) { - viewports.panels.follow_files(); - pending = typing_ready(session, key, || { - next_terminal_event(&mut colors, Duration::ZERO) - })?; - redraw = true; - } else if let Some(input) = translate(key) { - viewports.panels.follow_files(); - session.handle(input); - redraw = true; - } - } - Event::Paste(text) => { - viewports.panels.follow_files(); - session.handle(Input::Paste(text)); - redraw = true; - } - Event::Mouse(mouse) => { - let (changed, following) = viewports.scroll_ready(session, mouse, || { - next_terminal_event(&mut colors, Duration::ZERO) - })?; - redraw |= changed; - pending = following; - } - Event::Resize(_, _) => redraw = true, - Event::FocusGained | Event::ColorSchemeChanged => query_theme = true, - _ => {} +/// Renders inside an active `TerminalSession`; inherited contents are cleared +/// inside the first synchronized frame rather than exposing a blank screen. +pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()> { + let mut output: Stdout = io::stdout(); + let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?; + let mut viewports = BufferViewports::default(); + let mut colors = TerminalColors::default(); + let mut query_theme = true; + let mut redraw = true; + let mut pending = None; + while !session.editor().should_quit() { + redraw |= session.poll(); + colors.expire(Instant::now()); + if viewports.theme != colors.colors { + viewports.set_theme(colors.colors); + redraw = true; + } + if redraw { + draw_frame(&mut terminal, &mut output, clear_screen, |frame| { + viewports.draw_session(frame, session) + })?; + redraw = false; + clear_screen = false; + } + // Start the reply deadline after painting, not before expensive syntax initialization. + if query_theme || viewports.diff_visible { + let now = Instant::now(); + if query_theme || colors.refresh_due(now) { + colors.query(&mut output, now)?; + query_theme = false; } } - Ok(()) - }) + let input = if let Some(input) = pending.take() { + input + } else { + let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))? else { + continue; + }; + input + }; + match input { + Event::Key(key) if key.kind != KeyEventKind::Release => { + if session.editor().mode() == Mode::Insert && typing_key(&key) { + viewports.panels.follow_files(); + pending = typing_ready(session, key, || { + next_terminal_event(&mut colors, Duration::ZERO) + })?; + redraw = true; + } else if let Some(input) = translate(key) { + viewports.panels.follow_files(); + session.handle(input); + redraw = true; + } + } + Event::Paste(text) => { + viewports.panels.follow_files(); + session.handle(Input::Paste(text)); + redraw = true; + } + Event::Mouse(mouse) => { + let (changed, following) = viewports.scroll_ready(session, mouse, || { + next_terminal_event(&mut colors, Duration::ZERO) + })?; + redraw |= changed; + pending = following; + } + Event::Resize(_, _) => redraw = true, + Event::FocusGained | Event::ColorSchemeChanged => query_theme = true, + _ => {} + } + } + Ok(()) } fn next_terminal_event( @@ -13927,7 +13950,7 @@ end ) .unwrap(); for text in ["first frame", "second frame", "third frame"] { - draw_frame(&mut terminal, &mut output, |frame| { + draw_frame(&mut terminal, &mut output, false, |frame| { assert!( !synchronized.get(), "terminal presentation suspended during frame computation" @@ -13958,7 +13981,7 @@ end let mut viewports = BufferViewports::default(); for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) { editor.handle(Input::Char(key)); - draw_frame(&mut terminal, &mut output, |frame| { + draw_frame(&mut terminal, &mut output, false, |frame| { viewports.draw(frame, &mut editor) }) .unwrap(); @@ -14023,10 +14046,10 @@ end })); if mode == "entry" { let mut editor = EditorSession::open(None, vec![], None).unwrap(); - assert!(run(&mut editor).is_err()); + assert!(TerminalSession::run(false, || run(&mut editor, false)).is_err()); } else { let result = panic::catch_unwind(AssertUnwindSafe(|| { - TerminalSession::run(|| { + TerminalSession::run(false, || { assert!(crossterm::terminal::is_raw_mode_enabled().unwrap()); match mode.as_str() { "normal" => Ok(()), @@ -14038,7 +14061,7 @@ end viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), }, )?; - draw_frame(&mut terminal, &mut io::stdout(), |_| { + draw_frame(&mut terminal, &mut io::stdout(), false, |_| { panic::panic_any(73_u64) }) } diff --git a/tests/commands.rs b/tests/commands.rs index cd53b93..332bfbb 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -314,3 +314,124 @@ fn builtin_canonical_and_alias_collisions_reject_the_entire_feature() { assert_eq!(invocation.plugin, "example"); } } + +#[cfg(unix)] +#[test] +fn executable_restart_keeps_the_alternate_screen_and_restores_terminal_on_exit() { + for scenario in ["quit", "startup-error"] { + let fixture = Fixture::new(); + let path = fixture.0.join("startup file.txt"); + fs::write(&path, "SCREEN_HANDOFF_DOCUMENT\n").unwrap(); + let output = + std::process::Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into())) + .args([ + "-rpty", + "-rtimeout", + "-rio/console", + "-e", + r#" +scenario, binary, path = ARGV +captured = +"".b +status = nil +PTY.spawn({"TERM" => "xterm-256color"}, binary, "--no-config", "--", path) do |output, input, pid| + phase = 0 + begin + input.winsize = [24, 100] + Timeout.timeout(20) do + loop do + if IO.select([output], nil, nil, 0.05) + begin + chunk = output.read_nonblock(65536) + captured << chunk + STDOUT.write(chunk) + rescue IO::WaitReadable + rescue EOFError, Errno::EIO + end + end + unless status + waited = Process.waitpid2(pid, Process::WNOHANG) + status = waited[1] if waited + end + if status + # waitpid can win the race with the final output read. + loop do + begin + chunk = output.read_nonblock(65536) + captured << chunk + STDOUT.write(chunk) + rescue IO::WaitReadable, EOFError, Errno::EIO + break + end + end + break + end + + # A fresh process re-enables input reporting before its first repaint. + # Command-overlay frames must not be mistaken for a relaunched editor. + sessions = captured.split("\e[?2004h", -1) + next unless sessions.length == phase + 2 + frame = sessions.last + next unless frame.include?("\e[?2026l") + visible = frame.gsub(/\e\[[0-?]*[ -\/]*[@-~]/, "") + next unless visible.include?("SCREEN_HANDOFF_DOCUMENT") + raise "alternate screen re-entered during restart" unless captured.scan("\e[?1049h").length == 1 + raise "normal screen exposed during restart" if captured.include?("\e[?1049l") + + if scenario == "startup-error" + File.binwrite(path, "\xff".b) + input.write(":restart\r") + else + input.write([":restart\r", ":restart!\r", ":q\r"].fetch(phase)) + end + input.flush + phase += 1 + end + + expected_phases = scenario == "quit" ? 3 : 1 + raise "exited before requested restart/quit" unless phase == expected_phases + raise "unexpected exit status: #{status.inspect}" unless status.success? == (scenario == "quit") + # Inspect the master: macOS revokes slave descriptors when their session exits. + raise "terminal echo was not restored" unless input.echo? + settings = IO.popen(["stty", "-a"], in: input, &:read) + raise "terminal remained noncanonical: #{settings}" unless settings.match?(/(?:^|[;\s])icanon(?:[;\s]|$)/) + end + ensure + unless status + begin + Process.kill("KILL", pid) + rescue Errno::ESRCH + end + begin + Process.waitpid(pid) + rescue Errno::ECHILD + end + end + input.close + end +end +"#, + "--", + scenario, + env!("CARGO_BIN_EXE_vamp"), + ]) + .arg(&path) + .output() + .expect("Ruby with stdlib PTY and io/console is required (set VAMP_RUBY)"); + let captured = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "{scenario}: {}\nterminal output: {captured:?}", + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!( + captured.matches("\x1b[?1049h").count(), + 1, + "{scenario}: {captured:?}", + ); + assert_eq!( + captured.matches("\x1b[?1049l").count(), + 1, + "{scenario}: {captured:?}", + ); + } +}