Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}> '
Expand Down
2 changes: 2 additions & 0 deletions docker/bash_integration_test.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 20 additions & 9 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -900,22 +901,32 @@ 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<DrawnContent> = None;

let _ =
crate::flush_stdout!("{}", set_mode(DecPrivateModeCode::SynchronizedOutput));

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,
));
})
})
};

Expand Down
153 changes: 153 additions & 0 deletions src/app/scrollback.rs
Original file line number Diff line number Diff line change
@@ -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<B: Backend>(
terminal: &mut Terminal<B>,
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<TestBackend> {
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();
}
}
160 changes: 160 additions & 0 deletions tests/submitted_command_scrollback.py
Original file line number Diff line number Diff line change
@@ -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())