From 6daf4fa77c5d3ecdbf387a95e4094b876985ad82 Mon Sep 17 00:00:00 2001 From: itolkien Date: Sun, 18 Oct 2026 09:19:30 +0200 Subject: [PATCH] Render long commands in full before execution --- README.md | 2 + docker/bash_integration_test.Dockerfile | 2 + src/app/mod.rs | 29 +++-- src/app/scrollback.rs | 153 ++++++++++++++++++++++ tests/submitted_command_scrollback.py | 160 ++++++++++++++++++++++++ 5 files changed, 337 insertions(+), 9 deletions(-) create mode 100644 src/app/scrollback.rs create mode 100644 tests/submitted_command_scrollback.py diff --git a/README.md b/README.md index 91a9ec7b..fd11fcfc 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,8 @@ PS2='\e[0m> ' ## Final (transient) prompts `PS1_FINAL`, `RPS1_FINAL`, `PS1_FILL_FINAL`, and `PROMPT_RULER_FINAL` let you configure transient prompts. When a command is submitted, Flyline performs a final redraw using these environment variables instead of their standard counterparts. This keeps your terminal scrollback history clean by replacing complex, multi-line prompts with a minimal version. +While editing, commands taller than the terminal are shown in a window around the cursor. On submission, Flyline renders the entire final command so it can be browsed with the terminal's native scrollback, subject to the terminal's history limit. + [![Final prompts demo](https://github.com/HalFrgrd/flyline/releases/download/assets/demo_prompts_final.gif)](https://github.com/HalFrgrd/evp) ```bash PS1_FINAL='Ran at \D{%Y-%m-%d %H:%M:%S}> ' diff --git a/docker/bash_integration_test.Dockerfile b/docker/bash_integration_test.Dockerfile index 7c38fe84..e459bbfc 100644 --- a/docker/bash_integration_test.Dockerfile +++ b/docker/bash_integration_test.Dockerfile @@ -12,6 +12,8 @@ RUN /bin/bash --version COPY --from=built-artifact /libflyline.so / COPY tests/focus_report_handoff.py / +COPY tests/submitted_command_scrollback.py / RUN script -q -c "/bin/bash -i -c \"flyline --version && echo 'SUCCESS: Test completed'\"" /dev/null RUN python3 /focus_report_handoff.py --bash /bin/bash --flyline /libflyline.so +RUN python3 /submitted_command_scrollback.py --bash /bin/bash --flyline /libflyline.so diff --git a/src/app/mod.rs b/src/app/mod.rs index dac0c36b..ae2e0c82 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod actions; pub(crate) mod auto_close; pub(crate) mod formatted_buffer; +mod scrollback; mod ui; use crate::subshell_ipc::{self, IpcStatus, SubshellHandle}; use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid}; @@ -900,7 +901,6 @@ impl<'a> App<'a> { let reset_mode = |code| Csi::Mode(DecMode::ResetDecPrivateMode(DecPrivateMode::Code(code))); - let current_viewport_top = self.terminal.viewport_top(); let mut drawn_content: Option = None; let _ = @@ -908,14 +908,25 @@ impl<'a> App<'a> { let draw_result = { let _timer = crate::perf::PerfTimer::start("draw"); - self.terminal.draw(|f| { - drawn_content = Some(Self::ui( - f, - content, - needs_full_redraw, - show_terminal_cursor, - current_viewport_top, - )); + let prefix_result = if matches!( + self.mode, + AppRunningState::Exiting(ExitState::WithCommand(_)) + ) { + scrollback::publish_submitted_prefix(&mut self.terminal, &content) + } else { + Ok(()) + }; + prefix_result.and_then(|()| { + let current_viewport_top = self.terminal.viewport_top(); + self.terminal.draw(|f| { + drawn_content = Some(Self::ui( + f, + content, + needs_full_redraw, + show_terminal_cursor, + current_viewport_top, + )); + }) }) }; diff --git a/src/app/scrollback.rs b/src/app/scrollback.rs new file mode 100644 index 00000000..b0c3ef58 --- /dev/null +++ b/src/app/scrollback.rs @@ -0,0 +1,153 @@ +//! Preserve the hidden part of a submitted command in terminal scrollback. + +use crate::content::Contents; +use ratatui::Terminal; +use ratatui::backend::Backend; +use ratatui::layout::Position; + +pub(super) fn publish_submitted_prefix( + terminal: &mut Terminal, + content: &Contents, +) -> Result<(), B::Error> { + let area = terminal.get_frame().area(); + let size = terminal.backend().size()?; + let visible = content.get_row_range_to_show(area.height); + // The final render focuses the last row. The caller must first expand the + // inline viewport to fill the screen, so only the final tail remains visible. + if area.height == 0 + || area.height != size.height + || area.width != size.width + || visible.start == 0 + || visible.end != content.height() + { + return Ok(()); + } + + // Clear only the current viewport and invalidate Ratatui's previous frame. + // Earlier shell output remains in history. Use the final Contents, including + // transient prompts and edits, rather than a snapshot of the editing buffer. + terminal.clear()?; + let backend = terminal.backend_mut(); + for (index, row) in content.buf[..visible.start as usize].iter().enumerate() { + let screen_row = index.min(area.height as usize - 1) as u16; + backend.set_cursor_column(0)?; + backend.draw_relative_line( + row.iter() + .enumerate() + .map(|(column, cell)| (column as u16, screen_row, &cell.cell)), + )?; + backend.append_lines(1)?; + } + + // Move the prefix into native history with LF, then leave a blank viewport + // for the ordinary final draw to render the tail exactly once. + backend.append_lines(area.height - 1)?; + backend.set_cursor_position(Position::ORIGIN)?; + backend.flush()?; + terminal.reset_inline_cursor(); + terminal.set_viewport_top(0); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{Tag, TaggedSpan}; + use ratatui::backend::TestBackend; + use ratatui::text::Span; + use ratatui::{TerminalOptions, Viewport}; + + fn contents(lines: &[&str]) -> Contents { + let mut content = Contents::new(12); + for (index, line) in lines.iter().enumerate() { + if index > 0 { + content.newline(); + } + content.write_tagged_span(&TaggedSpan::new(Span::raw(*line), Tag::Command(index))); + } + content.set_focus_row(lines.len().saturating_sub(1) as u16); + content + } + + fn terminal(height: u16) -> Terminal { + Terminal::with_options( + TestBackend::new(12, 4), + TerminalOptions { + viewport: Viewport::Inline(height), + }, + ) + .unwrap() + } + + #[test] + fn publishes_hidden_rows_in_order_and_resets_final_viewport() { + let mut terminal = terminal(4); + let content = contents(&["row 1", "row 2", "row 3", "row 4", "row 5", "row 6"]); + publish_submitted_prefix(&mut terminal, &content).unwrap(); + terminal + .backend() + .assert_scrollback_lines(["row 1 ", "row 2 "]); + terminal.backend().assert_buffer_lines([" "; 4]); + assert_eq!( + terminal.backend_mut().get_cursor_position().unwrap(), + Position::ORIGIN + ); + assert_eq!(terminal.viewport_top(), Some(0)); + } + + #[test] + fn partial_screen_viewport_does_not_publish_or_clear_the_editor() { + let mut terminal = terminal(2); + let content = contents(&["row 1", "row 2", "row 3", "row 4", "row 5"]); + let before = terminal.backend().buffer().clone(); + publish_submitted_prefix(&mut terminal, &content).unwrap(); + assert_eq!(terminal.backend().buffer(), &before); + terminal.backend().assert_scrollback_empty(); + } + + #[test] + fn short_commands_and_middle_windows_leave_history_untouched() { + let mut terminal = terminal(4); + publish_submitted_prefix(&mut terminal, &contents(&["short"])).unwrap(); + let mut middle = contents(&[ + "one", "two", "three", "four", "five", "six", "seven", "eight", + ]); + middle.set_focus_row(2); + publish_submitted_prefix(&mut terminal, &middle).unwrap(); + terminal.backend().assert_scrollback_empty(); + } + + #[test] + fn preserves_existing_history_and_wide_cells() { + let mut terminal = terminal(4); + let prior = ratatui::buffer::Cell::new("P"); + terminal + .backend_mut() + .draw([(0, 0, &prior)].into_iter()) + .unwrap(); + terminal.backend_mut().append_lines(4).unwrap(); + publish_submitted_prefix( + &mut terminal, + &contents(&["界a", "e\u{301}", "three", "four", "five", "six"]), + ) + .unwrap(); + terminal.backend().assert_scrollback_lines([ + "P ", + "界a ", + "e\u{301} ", + ]); + } + + #[test] + fn empty_terminal_does_not_publish() { + let mut terminal = Terminal::with_options( + TestBackend::new(0, 0), + TerminalOptions { + viewport: Viewport::Inline(0), + }, + ) + .unwrap(); + publish_submitted_prefix(&mut terminal, &contents(&["one", "two"])).unwrap(); + terminal.backend().assert_scrollback_empty(); + } +} diff --git a/tests/submitted_command_scrollback.py b/tests/submitted_command_scrollback.py new file mode 100644 index 00000000..86e8b676 --- /dev/null +++ b/tests/submitted_command_scrollback.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Check that the final redraw emits the entire submitted command. + +Runs real Bash and Flyline behind a PTY using only Python's standard library. +The Rust unit tests cover the prefix writer separately. +""" + +import argparse +import errno +import fcntl +import os +from pathlib import Path +import pty +import select +import shlex +import signal +import struct +import tempfile +import termios +import time + + +def check_submission(bash, library, columns=80, rows=24, count=210, edit_head=False): + with tempfile.TemporaryDirectory(prefix="flyline-submit-test-") as temporary: + directory = Path(temporary) + destination = directory / "result" + rc = directory / "bashrc" + up = "+".join(["moveLineUp"] * 300) + down = "+".join(["moveLineDown"] * 300) + rc.write_text( + "PS1='$ '\nPS1_FINAL='FINAL> '\n" + f"HISTFILE={shlex.quote(str(directory / 'history'))}\n" + f"enable -f {shlex.quote(str(library))} flyline || exit\n" + "flyline --show-animations false --send-shell-integration-codes none " + "--enable-easter-eggs false --set-delayed-startup-ms 0\n" + "flyline mouse --mode disabled\n" + f"flyline key bind Ctrl+g always={up}+moveLeftStartOfLine\n" + f"flyline key bind Ctrl+t always={down}+moveRightEndOfLine\n" + ) + pid, fd = pty.fork() + if pid == 0: + environment = os.environ.copy() + for key in ("LD_PRELOAD", "BASH_ENV", "ENV", "PROMPT_COMMAND", "FLYLINE_VERSION"): + environment.pop(key, None) + environment.update( + HOME=str(directory), XDG_DATA_HOME=str(directory / "data"), + XDG_CACHE_HOME=str(directory / "cache"), XDG_STATE_HOME=str(directory / "state"), + TERM="xterm-ghostty", + ) + os.execve(str(bash), [str(bash), "--noprofile", "--rcfile", str(rc), "-i"], environment) + + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) + os.set_blocking(fd, False) + output = bytearray() + device_queries = cursor_queries = 0 + + def pump(duration, until=None): + nonlocal device_queries, cursor_queries + deadline = time.monotonic() + duration + while time.monotonic() < deadline: + if select.select([fd], [], [], .01)[0]: + try: + data = os.read(fd, 262144) + except BlockingIOError: + continue + except OSError as error: + if error.errno == errno.EIO: + raise AssertionError("Bash exited unexpectedly") from error + raise + assert data, "Bash returned EOF unexpectedly" + output.extend(data) + current = output.count(b"\x1b[>c") + for _ in range(current - device_queries): + os.write(fd, b"\x1b[>1;10;0c") + device_queries = current + current = output.count(b"\x1b[6n") + for _ in range(current - cursor_queries): + os.write(fd, b"\x1b[1;1R") + cursor_queries = current + if until is not None and until(): + return + + def send(data, settle=.1): + remaining = memoryview(data) + deadline = time.monotonic() + 5 + while remaining: + assert time.monotonic() < deadline, "PTY input write timed out" + try: + remaining = remaining[os.write(fd, remaining):] + except BlockingIOError: + pump(.01) + pump(settle) + + try: + pump(3, lambda: b"\x1b[?1004h" in output) + assert b"\x1b[?2004h" in output, "Flyline did not enable bracketed paste" + pump(.1) + lines = [f"COMMIT_{index:04d}" for index in range(1, count + 1)] + text = "\n".join(lines) + command = f"printf %s {shlex.quote(text)} > {shlex.quote(str(destination))}" + send(b"\x1b[200~" + command.encode() + b"\x1b[201~") + pump(5, lambda: lines[-1].encode() in output) + assert not destination.exists(), "paste ran without Enter" + if count > rows: + assert b"COMMIT_0001" not in output, "editing published a stale prefix into history" + + if edit_head: + send(b"\x07") + send(b"\x1b[C" * len("printf %s '") + b"EDIT_") + text = "EDIT_" + text + send(b"\x14") + # Close automatic completion so Enter submits rather than accepts a menu. + send(b"\x1b[27u") + final_start = len(output) + send(b"\r", .05) + pump(5, destination.exists) + assert destination.exists(), "command was not executed" + pump(.2) + assert destination.read_bytes() == text.encode(), "submitted command contents changed" + # Inspect the submission frame only; the next prompt may repaint + # matching history suggestions containing the same command text. + frames = bytes(output[final_start:]).split(b"\x1b[?2026l") + final_output = next((frame for frame in frames if b"FINAL>" in frame), b"") + if count > rows: + missing = [line for line in lines if line.encode() not in final_output] + assert not missing, f"final redraw omitted {len(missing)} rows; first={missing[:1]}" + assert all(final_output.count(line.encode()) == 1 for line in lines), "final redraw duplicated rows" + assert b"FINAL>" in final_output, "final redraw lost the transient prompt" + if edit_head: + assert b"EDIT_COMMIT_0001" in final_output, "final redraw contains stale text" + assert b"\x1b[3J" not in output, "terminal scrollback was erased" + finally: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + os.close(fd) + os.waitpid(pid, 0) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bash", type=Path, default=Path("/bin/bash")) + parser.add_argument("--flyline", type=Path, required=True) + args = parser.parse_args() + cases = [("long", {}), ("edited-head", dict(edit_head=True)), + ("narrow", dict(columns=40, rows=12)), ("short", dict(count=3))] + failures = 0 + for name, options in cases: + try: + check_submission(args.bash.resolve(), args.flyline.resolve(), **options) + print(f"PASS: submitted command scrollback ({name})", flush=True) + except Exception as error: + failures += 1 + print(f"FAIL: submitted command scrollback ({name}): {error}", flush=True) + return bool(failures) + + +if __name__ == "__main__": + raise SystemExit(main())