From 7c0a3fd9209452a7adbe3d024d18bcad298c3b82 Mon Sep 17 00:00:00 2001
From: Christian Dassy <18606875+inxbit@users.noreply.github.com>
Date: Fri, 5 Jun 2026 00:22:03 -0400
Subject: [PATCH] Release 1.0.10
---
CHANGELOG.md | 23 +++
Cargo.lock | 2 +-
Cargo.toml | 2 +-
README.md | 4 +-
docs/index.html | 2 +-
src/cli.rs | 8 +-
src/cli/pty.rs | 165 +++++++++++++++++-
src/cli/stream.rs | 268 ++++++++++++++++++++++++++---
src/highlight.rs | 19 ++-
tests/interactive_paste.rs | 342 +++++++++++++++++++++++++++++++++++++
10 files changed, 799 insertions(+), 36 deletions(-)
create mode 100644 tests/interactive_paste.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f542d7..651220e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,29 @@
All notable changes to PrismTTY are documented here.
+## 1.0.10 - 2026-06-05
+
+### Interactive Rendering
+
+- Surface pasted interactive input echo once the wrapped child PTY goes idle,
+ so a delimiter-less trailing token does not stay hidden until the next
+ keystroke.
+- Preserve cross-read highlighting for program output while type-ahead is
+ present, including no-echo terminal modes such as password prompts.
+
+### Security and Reliability
+
+- Fail closed when an interactive PTY descriptor cannot report terminal ECHO
+ state, and make the PTY descriptor duplication used for echo checks explicit
+ and fallible.
+- Clear recently matched echoed input after it has served the echo-tail match,
+ reducing how long typed or pasted command text remains in memory.
+
+### Tests
+
+- Add integration coverage for pasted input visibility, split program-output
+ highlighting, and concurrent no-echo input edge cases.
+
## 1.0.9 - 2026-05-30
### Security and Reliability
diff --git a/Cargo.lock b/Cargo.lock
index 1821f85..e0e6561 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -590,7 +590,7 @@ dependencies = [
[[package]]
name = "prismtty"
-version = "1.0.9"
+version = "1.0.10"
dependencies = [
"anyhow",
"assert_cmd",
diff --git a/Cargo.toml b/Cargo.toml
index fac074b..ea4b809 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "prismtty"
-version = "1.0.9"
+version = "1.0.10"
edition = "2024"
rust-version = "1.85"
license = "MIT"
diff --git a/README.md b/README.md
index f921706..58fd8c7 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ network-focused built-in profiles.
Website: [prismtty.com](https://prismtty.com/).
-Current version: `1.0.9`.
+Current version: `1.0.10`.
## Quick Demo
@@ -99,7 +99,7 @@ sudo apt-get install libpcre2-dev pkg-config
### GitHub Release
Prebuilt release archives and checksums are available on the
-[v1.0.9 release page](https://github.com/inxbit/prismtty/releases/tag/v1.0.9).
+[v1.0.10 release page](https://github.com/inxbit/prismtty/releases/tag/v1.0.10).
Each release archive contains the binaries, license/readme files, example
profiles, shell completions, and a `.tar.gz.sha256` checksum.
diff --git a/docs/index.html b/docs/index.html
index 3af7389..83c087f 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -65,7 +65,7 @@
Readable output,
live in your
Latest
- v1.0.9
+ v1.0.10
Engine
diff --git a/src/cli.rs b/src/cli.rs
index 2226ee7..a339de3 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -25,7 +25,7 @@ use args::{Action, Options, parse_args, print_help};
use profile_selection::profile_store;
use pty::run_command;
use runtime::{ReloadWatcher, RuntimeRegistration, request_reload};
-use stream::highlight_stream;
+use stream::{InputSource, highlight_stream};
use trace::IoTrace;
#[cfg(feature = "completion-generation")]
@@ -152,7 +152,11 @@ fn run_stdin(options: Options) -> Result
{
stdin.lock(),
&mut stdout,
&options,
- interactive,
+ InputSource {
+ interactive,
+ pty_fd: None,
+ recent_input: None,
+ },
reload_watcher,
trace,
None,
diff --git a/src/cli/pty.rs b/src/cli/pty.rs
index 07abcae..4fc5a6f 100644
--- a/src/cli/pty.rs
+++ b/src/cli/pty.rs
@@ -2,9 +2,10 @@ use std::ffi::{OsStr, OsString};
use std::fs::OpenOptions;
use std::io::{self, Read, Write};
use std::mem;
-use std::os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd};
+use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd};
use std::process::ExitCode;
use std::sync::{
+ Arc, Mutex,
atomic::{AtomicI32, AtomicPtr, Ordering},
mpsc::{self, SyncSender},
};
@@ -21,7 +22,7 @@ use super::CliError;
use super::args::Options;
use super::profile_selection::dynamic_profile_enabled;
use super::runtime::{ReloadWatcher, RuntimeRegistration};
-use super::stream::highlight_stream;
+use super::stream::{InputSource, highlight_stream, terminal_echo_enabled};
use super::trace::IoTrace;
const STRIPPED_ITERM_ENV: [&str; 6] = [
@@ -33,6 +34,10 @@ const STRIPPED_ITERM_ENV: [&str; 6] = [
"ITERM_PROFILE",
];
const PROFILE_INPUT_QUEUE_CAPACITY: usize = 1024;
+/// How many bytes of recently forwarded input the highlight loop keeps to match
+/// against buffered echo. A buffered token is small (≤512 bytes), so this only
+/// needs to cover the tail of the latest input; older input is dropped.
+const RECENT_INPUT_WINDOW: usize = 4096;
const TERMINATING_SIGNALS: [libc::c_int; 4] =
[libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT, libc::SIGINT];
const RAW_SIGNAL_STOP_BYTE: u8 = 0;
@@ -154,15 +159,42 @@ pub(super) fn run_command(options: Options, command: Vec) -> Result) -> Result(
local_echo: bool,
trace: IoTrace,
profile_input: Option>>,
+ recent_input: &Mutex>,
+ pty_fd: Option,
) -> io::Result<()> {
let mut buffer = [0_u8; 1024];
let mut echo_state = LocalEchoState::default();
@@ -291,6 +329,9 @@ fn forward_stdin_to_pty(
if let Some(sender) = &profile_input {
let _ = sender.try_send(input.to_vec());
}
+ // Record before writing to the PTY so an immediate line-discipline echo
+ // cannot outrun the highlight loop's suffix match.
+ record_recent_input(recent_input, input, pty_fd);
writer.write_all(input)?;
writer.flush()?;
@@ -305,6 +346,45 @@ fn forward_stdin_to_pty(
}
}
+/// Records forwarded input for the highlight loop's echo matching.
+///
+/// While the child's terminal has ECHO on, appends `input`, retaining only the
+/// last [`RECENT_INPUT_WINDOW`] bytes so the buffer cannot grow without bound.
+/// While ECHO is off (password prompts, raw-mode programs) the input never
+/// echoes back, so it is not recorded; the buffer is cleared instead, so a typed
+/// secret is not retained and cannot later be mistaken for program output.
+fn record_recent_input(recent_input: &Mutex>, input: &[u8], pty_fd: Option) {
+ let mut recent = recent_input
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if !terminal_echo_enabled(pty_fd) {
+ recent.clear();
+ return;
+ }
+ recent.extend_from_slice(input);
+ let overflow = recent.len().saturating_sub(RECENT_INPUT_WINDOW);
+ if overflow > 0 {
+ recent.drain(..overflow);
+ }
+}
+
+fn dup_master_fd(master: &dyn portable_pty::MasterPty) -> io::Result {
+ let fd = master.as_raw_fd().ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::Unsupported,
+ "PTY master file descriptor is unavailable",
+ )
+ })?;
+ // SAFETY: `fd` is the live PTY master descriptor. `dup` returns a fresh
+ // descriptor that `OwnedFd` takes sole ownership of and closes on drop.
+ let duped = unsafe { libc::dup(fd) };
+ if duped < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ // SAFETY: `duped` is a newly-created descriptor owned by this function.
+ Ok(unsafe { OwnedFd::from_raw_fd(duped) })
+}
+
#[cfg(test)]
fn local_echo_bytes(input: &[u8]) -> Vec {
LocalEchoState::default().push(input)
@@ -737,6 +817,7 @@ fn restore_signal_handler(signal: libc::c_int, previous: &libc::sigaction) {
#[cfg(test)]
mod tests {
use std::io::Cursor;
+ use std::sync::Mutex;
use nix::sys::termios::{InputFlags, LocalFlags, OutputFlags};
@@ -827,11 +908,79 @@ mod tests {
let mut input = Cursor::new(b"show version\n".to_vec());
let mut output = Vec::new();
let trace = super::IoTrace::open(None).expect("trace disabled");
-
- super::forward_stdin_to_pty(&mut input, &mut output, false, trace, Some(tx))
- .expect("stdin forwards even when profile input queue is full");
+ let recent_input = Mutex::new(Vec::new());
+
+ // pty_fd: None makes terminal_echo_enabled default to true, so input is
+ // recorded (the ECHO-off skip path is covered by the integration tests).
+ super::forward_stdin_to_pty(
+ &mut input,
+ &mut output,
+ false,
+ trace,
+ Some(tx),
+ &recent_input,
+ None,
+ )
+ .expect("stdin forwards even when profile input queue is full");
assert_eq!(output, b"show version\n");
+ assert_eq!(
+ recent_input.lock().unwrap().as_slice(),
+ b"show version\n",
+ "forwarded input is recorded for echo matching by the highlight loop"
+ );
+ }
+
+ #[test]
+ fn forwarded_input_is_recorded_before_child_can_echo_it() {
+ let source = include_str!("pty.rs");
+ let function_source = source
+ .split("fn forward_stdin_to_pty")
+ .nth(1)
+ .expect("forwarder exists")
+ .split("#[cfg(test)]")
+ .next()
+ .expect("function precedes tests");
+
+ let record_idx = function_source
+ .find("record_recent_input(recent_input, input,")
+ .expect("recent input is recorded");
+ let write_idx = function_source
+ .find("writer.write_all(input)?")
+ .expect("input is written to child PTY");
+
+ assert!(
+ record_idx < write_idx,
+ "recent input must be recorded before PTY write so immediate echo can be matched"
+ );
+ }
+
+ #[test]
+ fn record_recent_input_skips_non_echoed_input() {
+ use nix::pty::openpty;
+ use nix::sys::termios::{SetArg, tcgetattr, tcsetattr};
+ use std::os::fd::AsRawFd;
+
+ let pty = openpty(None, None).expect("openpty");
+ let master_fd = pty.master.as_raw_fd();
+ let recent = Mutex::new(Vec::new());
+
+ // ECHO on: forwarded input is recorded for echo matching.
+ let mut attrs = tcgetattr(&pty.slave).expect("tcgetattr");
+ attrs.local_flags.insert(LocalFlags::ECHO);
+ tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo on");
+ super::record_recent_input(&recent, b"show version", Some(master_fd));
+ assert_eq!(recent.lock().unwrap().as_slice(), b"show version");
+
+ // ECHO off (e.g. a password prompt): the typed secret is never recorded,
+ // and any prior tail is cleared, so it cannot be retained or later matched.
+ attrs.local_flags.remove(LocalFlags::ECHO);
+ tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo off");
+ super::record_recent_input(&recent, b"hunter2-secret-passphrase", Some(master_fd));
+ assert!(
+ recent.lock().unwrap().is_empty(),
+ "non-echoed input (password) must not be retained in recent_input"
+ );
}
#[test]
diff --git a/src/cli/stream.rs b/src/cli/stream.rs
index 416a148..9c55802 100644
--- a/src/cli/stream.rs
+++ b/src/cli/stream.rs
@@ -1,9 +1,14 @@
use std::cmp::Reverse;
use std::collections::HashMap;
use std::io::{self, Read, Write};
+use std::os::fd::{BorrowedFd, RawFd};
use std::sync::mpsc;
+use std::sync::{Arc, Mutex};
use std::time::Instant;
+use nix::libc;
+use nix::sys::termios::{LocalFlags, tcgetattr};
+
use crate::highlight::{
AnsiChunk, BenchmarkReport, Highlighter, MAX_INCOMPLETE_ESCAPE_BYTES, StreamingHighlighter,
incomplete_escape_start, strip_ansi,
@@ -23,16 +28,28 @@ use super::trace::IoTrace;
const AUTO_DETECT_SAMPLE_LIMIT: usize = 64 * 1024;
-/// Largest read still treated as interactive keystroke echo. Bulk program output
-/// arrives in much larger reads, so only tiny reads trigger an echo flush; that
-/// keeps cross-read token highlighting intact for streamed output.
-const INTERACTIVE_ECHO_FLUSH_MAX_READ: usize = 8;
+/// Describes the stream's input source for interactive echo handling. It groups
+/// the facts the read loop needs to surface buffered echo promptly without
+/// disturbing program-output highlighting:
+/// - `interactive`: whether this is an interactive terminal session (echo present),
+/// - `pty_fd`: the PTY master to poll so an echo burst's buffered trailing token
+/// is flushed once the child goes idle (a paste echoes back in one large read),
+/// - `recent_input`: the bytes the stdin forwarder recently sent to the child.
+/// The loop flushes a buffered trailing token only when it is a suffix of this
+/// (i.e. genuine input echo), so a speculatively-buffered *program-output*
+/// token is never surfaced standalone — even when the child has echo off and
+/// the user is typing while output streams.
+pub(super) struct InputSource {
+ pub(super) interactive: bool,
+ pub(super) pty_fd: Option,
+ pub(super) recent_input: Option>>>,
+}
pub(super) fn highlight_stream(
mut reader: R,
writer: &mut W,
options: &Options,
- interactive: bool,
+ input: InputSource,
mut reload_watcher: Option,
trace: IoTrace,
profile_input_rx: Option>>,
@@ -52,10 +69,10 @@ pub(super) fn highlight_stream(
input_bytes += first_chunk.bytes().len();
let store = profile_store()?;
let profile_names = select_profile_names_with_store(options, &store, &detection_sample)?;
- let mut session = HighlightSession::new(options, &store, interactive, profile_names)?;
+ let mut session = HighlightSession::new(options, &store, input.interactive, profile_names)?;
session.report_current();
let dynamic_profiles =
- dynamic_profile_enabled(options, interactive) && profile_input_rx.is_some();
+ dynamic_profile_enabled(options, input.interactive) && profile_input_rx.is_some();
let mut profile_runtime = if dynamic_profiles {
Some(ProfileRuntime::new(session.profile_names().to_vec()))
} else {
@@ -72,7 +89,12 @@ pub(super) fn highlight_stream(
session.switch_profiles(writer, &trace, next_profile_names)?;
}
session.push(writer, &trace, &first_chunk)?;
- if should_flush_input_echo(interactive, read) {
+ if should_flush_input_echo(
+ input.interactive,
+ input.pty_fd,
+ session.buffered_echo(),
+ input.recent_input.as_deref(),
+ ) {
session.flush_input_echo(writer, &trace)?;
}
writer.flush()?;
@@ -112,7 +134,12 @@ pub(super) fn highlight_stream(
session.reload(writer, &trace)?;
}
session.push(writer, &trace, &chunk)?;
- if should_flush_input_echo(interactive, read) {
+ if should_flush_input_echo(
+ input.interactive,
+ input.pty_fd,
+ session.buffered_echo(),
+ input.recent_input.as_deref(),
+ ) {
session.flush_input_echo(writer, &trace)?;
}
writer.flush()?;
@@ -238,6 +265,12 @@ impl<'a> HighlightSession<'a> {
Ok(())
}
+ /// The buffered trailing token the next echo flush would surface, for the
+ /// read loop to match against recent input before deciding to flush it.
+ fn buffered_echo(&self) -> &[u8] {
+ self.streaming.buffered_echo()
+ }
+
fn finish(&mut self, writer: &mut W, trace: &IoTrace) -> Result<(), CliError> {
write_rendered(writer, trace, self.streaming.finish())?;
Ok(())
@@ -368,12 +401,120 @@ fn prepare_chunk(input: &[u8], strip_existing_ansi: bool, strip_carry: &mut Vec<
}
}
-fn should_flush_input_echo(interactive: bool, read: usize) -> bool {
- interactive && read <= INTERACTIVE_ECHO_FLUSH_MAX_READ
+/// Decides whether to surface the buffered interactive input echo now.
+///
+/// prismtty speculatively buffers a trailing partial token so a token split
+/// across reads still highlights as one unit. For interactive input echo that
+/// buffering must not strand the last token: a keystroke or pasted line echoes
+/// back and then the child goes idle, so the token would otherwise stay hidden
+/// until the next byte. We flush it once four conditions hold:
+/// - there is something buffered (`buffered_echo` non-empty),
+/// - the echo burst has drained ([`input_source_idle`]), so we do not split a
+/// multi-read echo mid-burst, and
+/// - the child's terminal still has ECHO on, since echo only exists while ECHO
+/// is on (so with it off a buffered token can only be program output), and
+/// - the buffered token is a suffix of the recently forwarded input, i.e. it is
+/// genuine echo of what the user typed/pasted — not a speculatively-buffered
+/// token of bulk *program* output (which would lose its cross-read highlight
+/// if flushed standalone).
+///
+/// The ECHO check and the suffix match are complementary: the suffix match keeps
+/// program output the user did not type from being flushed, and the ECHO check
+/// covers the converse — a program-output token whose bytes happen to coincide
+/// with non-echoing type-ahead (echo off) is still left buffered.
+///
+/// Consumes the matched suffix from `recent_input` when it flushes. Also, when
+/// the child's terminal ECHO is off, clears all of `recent_input` (without
+/// flushing) — the forwarder is the primary guard there, this just drops any
+/// stale tail the read loop observes during an ECHO-off stretch.
+fn should_flush_input_echo(
+ interactive: bool,
+ pty_fd: Option,
+ buffered_echo: &[u8],
+ recent_input: Option<&Mutex>>,
+) -> bool {
+ if !interactive || buffered_echo.is_empty() {
+ return false;
+ }
+ let Some(recent_input) = recent_input else {
+ return false;
+ };
+ if !terminal_echo_enabled(pty_fd) {
+ recent_input
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .clear();
+ return false;
+ }
+ if !input_source_idle(pty_fd) {
+ return false;
+ }
+ let mut recent_input = recent_input
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ consume_echo_suffix(&mut recent_input, buffered_echo)
+}
+
+/// Whether the child's terminal currently has ECHO enabled. Input is only
+/// reflected back as echo while ECHO is on; with it off (password prompts,
+/// raw-mode programs) a buffered token can only be program output, never input
+/// echo — even if its bytes coincide with non-echoing type-ahead. The forwarder
+/// uses the same check to avoid recording non-echoed input at all. Defaults to
+/// `true` only when there is no descriptor; when a supplied descriptor cannot
+/// report terminal state, fail closed.
+pub(super) fn terminal_echo_enabled(pty_fd: Option) -> bool {
+ let Some(fd) = pty_fd else {
+ return true;
+ };
+ // SAFETY: `fd` is an owned descriptor for the wrapped PTY (a dup of the
+ // master), kept open by its owner for the whole session; the borrow lasts
+ // only for this `tcgetattr` call.
+ let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
+ match tcgetattr(borrowed) {
+ Ok(termios) => termios.local_flags.contains(LocalFlags::ECHO),
+ Err(_) => false,
+ }
+}
+
+/// If `recent_input` ends with `echo`, the buffered token is genuine input echo:
+/// consume that suffix (so it cannot be matched again by a later program token)
+/// and return true. Otherwise the token is program output — leave `recent_input`
+/// untouched and return false.
+fn consume_echo_suffix(recent_input: &mut Vec, echo: &[u8]) -> bool {
+ if recent_input.ends_with(echo) {
+ recent_input.clear();
+ true
+ } else {
+ false
+ }
+}
+
+/// Reports whether the PTY master has no output readable at this instant — the
+/// cue that an interactive echo burst has drained, so buffered echo can surface
+/// without waiting for the next byte. A failed/interrupted poll is treated as
+/// idle so echo still surfaces promptly; the ECHO-on check and the recent-input
+/// suffix match in [`should_flush_input_echo`] keep that from disturbing
+/// program-output buffering. Returns false when no descriptor is available
+/// (stdin mode).
+fn input_source_idle(pty_fd: Option) -> bool {
+ let Some(fd) = pty_fd else {
+ return false;
+ };
+ let mut poll_fd = libc::pollfd {
+ fd,
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ // SAFETY: `poll_fd` is one valid, initialized entry; `poll` only reads
+ // `fd`/`events` and writes `revents`. The zero timeout makes it nonblocking.
+ let ready = unsafe { libc::poll(&mut poll_fd, 1, 0) };
+ ready <= 0
}
#[cfg(test)]
mod tests {
+ use std::sync::Mutex;
+
fn sample_highlighter() -> crate::highlight::Highlighter {
let config =
crate::config::PrismConfig::from_chromaterm_yaml("rules: []\n").expect("config loads");
@@ -464,19 +605,108 @@ mod tests {
}
#[test]
- fn input_echo_flush_targets_only_small_interactive_reads() {
- // Keystroke-sized interactive reads flush buffered echo promptly.
- assert!(super::should_flush_input_echo(true, 1));
+ fn consume_echo_suffix_matches_only_genuine_echo() {
+ // A buffered token that is a suffix of recent input is genuine echo:
+ // matched and cleared so retained input cannot linger past its use.
+ let mut recent = b"update add test.example.com 3600 A 192.0.2.1".to_vec();
+ assert!(super::consume_echo_suffix(&mut recent, b"192.0.2.1"));
+ assert!(
+ recent.is_empty(),
+ "matched input is cleared after the echo tail is surfaced"
+ );
+ // The same program-output bytes no longer match once consumed.
+ assert!(!super::consume_echo_suffix(&mut recent, b"192.0.2.1"));
+
+ // A program-output token that was never typed does not match (this is the
+ // echo-off regression guard): recent input is the keystroke, not "Vlan11".
+ let mut typed = b"x".to_vec();
+ assert!(!super::consume_echo_suffix(&mut typed, b"Vlan11"));
+ assert_eq!(typed, b"x", "non-matching input is left untouched");
+
+ // Empty recent input matches nothing.
+ let mut empty = Vec::new();
+ assert!(!super::consume_echo_suffix(&mut empty, b"Vlan11"));
+ }
+
+ #[test]
+ fn should_flush_input_echo_requires_interactive_buffered_and_recent_input() {
+ // No pty_fd: terminal_echo_enabled defaults true and input_source_idle
+ // returns false (stdin mode), so a non-interactive stream never flushes.
+ assert!(!super::should_flush_input_echo(false, None, b"tok", None));
+ // Interactive but nothing buffered: never flushes.
+ let recent = Mutex::new(b"tok".to_vec());
+ assert!(!super::should_flush_input_echo(
+ true,
+ None,
+ b"",
+ Some(&recent)
+ ));
+ // Interactive with buffered echo but no recent_input source: never flushes.
+ assert!(!super::should_flush_input_echo(true, None, b"tok", None));
+ }
+
+ #[test]
+ fn terminal_echo_enabled_fails_closed_for_non_terminal_fd() {
+ let mut fds = [0 as nix::libc::c_int; 2];
+ assert_eq!(unsafe { nix::libc::pipe(fds.as_mut_ptr()) }, 0);
+ assert!(!super::terminal_echo_enabled(Some(fds[0])));
+
+ unsafe {
+ nix::libc::close(fds[0]);
+ nix::libc::close(fds[1]);
+ }
+ }
+
+ #[test]
+ fn should_flush_input_echo_flushes_only_matching_idle_echo() {
+ use nix::pty::openpty;
+ use nix::sys::termios::{SetArg, tcgetattr, tcsetattr};
+ use std::os::fd::AsRawFd;
+
+ let pty = openpty(None, None).expect("openpty");
+ let master_fd = pty.master.as_raw_fd();
+ let slave_fd = pty.slave.as_raw_fd();
+ let mut attrs = tcgetattr(&pty.slave).expect("tcgetattr");
+ attrs
+ .local_flags
+ .insert(nix::sys::termios::LocalFlags::ECHO);
+ tcsetattr(&pty.slave, SetArg::TCSANOW, &attrs).expect("echo on");
+
+ // Buffered token is a suffix of recent input + idle: flush and clear it.
+ let recent = Mutex::new(b"router# show ".to_vec());
assert!(super::should_flush_input_echo(
true,
- super::INTERACTIVE_ECHO_FLUSH_MAX_READ
+ Some(master_fd),
+ b"show ",
+ Some(&recent),
+ ));
+ assert!(
+ recent.lock().unwrap().is_empty(),
+ "matched input is cleared after the echo tail is surfaced"
+ );
+
+ // Token is not recent input (program output): do not flush, leave it.
+ let recent = Mutex::new(b"x".to_vec());
+ assert!(!super::should_flush_input_echo(
+ true,
+ Some(master_fd),
+ b"Vlan11",
+ Some(&recent),
));
- // Bulk interactive reads keep speculative token buffering for highlighting.
+ assert_eq!(recent.lock().unwrap().as_slice(), b"x");
+
+ // Pending data == not idle: wait, do not consume.
+ assert_eq!(
+ unsafe { nix::libc::write(slave_fd, b"x".as_ptr().cast(), 1) },
+ 1
+ );
+ let recent = Mutex::new(b"show ".to_vec());
assert!(!super::should_flush_input_echo(
true,
- super::INTERACTIVE_ECHO_FLUSH_MAX_READ + 1
+ Some(master_fd),
+ b"show ",
+ Some(&recent),
));
- // Noninteractive streams never force an early echo flush.
- assert!(!super::should_flush_input_echo(false, 1));
+ assert_eq!(recent.lock().unwrap().as_slice(), b"show ");
}
}
diff --git a/src/highlight.rs b/src/highlight.rs
index bee01a9..5f335af 100644
--- a/src/highlight.rs
+++ b/src/highlight.rs
@@ -598,8 +598,9 @@ impl StreamingHighlighter {
/// Flushes buffered interactive input echo that is only waiting for a token
/// boundary, while keeping an incomplete trailing escape sequence buffered.
///
- /// Interactive callers should invoke this when no more input is immediately
- /// available (for example after a keystroke-sized read) so typed characters
+ /// Interactive callers should invoke this when the input source has gone idle
+ /// and the buffered tail is genuine input echo (see
+ /// `cli::stream::should_flush_input_echo`), so typed or pasted characters
/// surface promptly instead of staying buffered until the next byte completes
/// a token. It is a no-op for noninteractive streams, where speculative
/// token buffering is required for correct highlighting.
@@ -624,6 +625,20 @@ impl StreamingHighlighter {
output
}
+ /// The raw bytes that [`Self::flush_buffered_echo`] would surface right now:
+ /// the buffered trailing token, minus any incomplete trailing escape. Empty
+ /// when nothing would flush. The read loop matches these against recently
+ /// forwarded input to confirm the tail is genuine echo before flushing it,
+ /// so a speculatively-buffered *program-output* token is never surfaced
+ /// standalone (which would split its highlight span).
+ pub(crate) fn buffered_echo(&self) -> &[u8] {
+ if !self.passthrough_single_byte_chunks || self.pending.is_empty() {
+ return &[];
+ }
+ let flush_len = incomplete_escape_start(&self.pending).unwrap_or(self.pending.len());
+ &self.pending[..flush_len]
+ }
+
fn highlight_output_chunk(&mut self, input: &AnsiChunk) -> Vec {
if self.passthrough_single_byte_chunks {
self.highlight_interactive_output_chunk(input)
diff --git a/tests/interactive_paste.rs b/tests/interactive_paste.rs
new file mode 100644
index 0000000..05a2b2d
--- /dev/null
+++ b/tests/interactive_paste.rs
@@ -0,0 +1,342 @@
+//! Integration coverage for interactive input echo (the pasted-command bug).
+//!
+//! prismtty buffers a trailing partial token of interactive echo so a token
+//! split across reads still highlights as a unit. A pasted line echoes back in
+//! a single large read, so the buffered trailing token used to stay invisible
+//! until the next keystroke surfaced it (reported against `nsupdate`, whose bare
+//! `> ` prompt is not recognized, so echo is not passed through). prismtty must
+//! instead surface it once the child goes idle.
+#![cfg(unix)]
+
+use std::io::{Read, Write};
+use std::sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ mpsc,
+};
+use std::thread;
+use std::time::{Duration, Instant};
+
+use portable_pty::{CommandBuilder, PtySize, native_pty_system};
+use prismtty::highlight::strip_ansi;
+
+/// A pasted command (no trailing newline) must become fully visible without any
+/// further input. `cat` keeps the wrapped PTY in canonical echo mode, so the
+/// tty line discipline echoes the paste exactly as `nsupdate`'s prompt would.
+#[test]
+fn pasted_line_is_fully_visible_without_extra_input() {
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .expect("openpty");
+
+ let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty"));
+ builder.arg("sh");
+ builder.arg("-c");
+ builder.arg("printf 'READY\\n'; exec cat");
+
+ let mut child = pair.slave.spawn_command(builder).expect("spawn ptty cat");
+ drop(pair.slave);
+
+ let mut reader = pair.master.try_clone_reader().expect("clone reader");
+ let mut writer = pair.master.take_writer().expect("take writer");
+
+ // Stream echoed output from a thread so a blocking read on the buggy path
+ // (token never flushed) cannot hang the test; the main thread bounds the
+ // wait. Each read publishes the current visible (ANSI-stripped) text.
+ let (tx, rx) = mpsc::channel::();
+ thread::spawn(move || {
+ let mut acc = Vec::new();
+ let mut buf = [0u8; 256];
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ acc.extend_from_slice(&buf[..n]);
+ let visible = String::from_utf8_lossy(&strip_ansi(&acc)).into_owned();
+ if tx.send(visible).is_err() {
+ break;
+ }
+ }
+ Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(_) => break,
+ }
+ }
+ });
+
+ // Wait until prismtty is forwarding child output before sending the paste.
+ let ready_deadline = Instant::now() + Duration::from_secs(5);
+ let mut visible = String::new();
+ while Instant::now() < ready_deadline {
+ match rx.recv_timeout(Duration::from_millis(200)) {
+ Ok(latest) => {
+ visible = latest;
+ if visible.contains("READY") {
+ break;
+ }
+ }
+ Err(mpsc::RecvTimeoutError::Timeout) => continue,
+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
+ }
+ }
+ assert!(
+ visible.contains("READY"),
+ "wrapped command was not ready before paste; saw: {visible:?}"
+ );
+
+ // A multi-word line whose final, delimiter-less token ("192.0.2.1") is the
+ // piece prismtty buffers. No trailing newline: the child stays at the line,
+ // exactly like a paste awaiting Enter.
+ let paste = b"update add test.example.com 3600 A 192.0.2.1";
+ writer.write_all(paste).expect("write paste");
+ writer.flush().expect("flush paste");
+
+ // Wait for the full line to surface. Crucially we send NO further bytes, so
+ // the trailing token can only appear via prismtty's idle flush.
+ let target = "update add test.example.com 3600 A 192.0.2.1";
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ match rx.recv_timeout(Duration::from_millis(200)) {
+ Ok(latest) => {
+ visible = latest;
+ if visible.contains(target) {
+ break;
+ }
+ }
+ Err(mpsc::RecvTimeoutError::Timeout) => continue,
+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
+ }
+ }
+
+ let _ = child.kill();
+ let _ = child.wait();
+
+ assert!(
+ visible.contains(target),
+ "pasted line never fully surfaced without extra input; saw: {visible:?}"
+ );
+}
+
+fn count_subslice(haystack: &[u8], needle: &[u8]) -> usize {
+ if needle.is_empty() || haystack.len() < needle.len() {
+ return 0;
+ }
+ haystack
+ .windows(needle.len())
+ .filter(|w| *w == needle)
+ .count()
+}
+
+fn contains_sgr_span(haystack: &[u8], token: &[u8]) -> bool {
+ let mut rest = haystack;
+ while let Some(esc_idx) = rest.iter().position(|byte| *byte == 0x1b) {
+ let candidate = &rest[esc_idx..];
+ let Some(m_idx) = candidate.iter().position(|byte| *byte == b'm') else {
+ return false;
+ };
+ if candidate[m_idx + 1..].starts_with(token) {
+ return true;
+ }
+ rest = &candidate[1..];
+ }
+ false
+}
+
+/// The mirror invariant: a token split across reads in pure PROGRAM output (no
+/// input echo) must keep its cross-read highlighting. The idle flush surfaces
+/// input echo, so it must NOT fire for buffered program-output tokens — there is
+/// no pending input echo. Regression guard for the bulk-output highlighting that
+/// an unconditional idle flush would break (cisco "Vlan1191" split as
+/// "...Vlan11" + "91" across two reads with an inter-write gap).
+#[test]
+fn split_program_output_token_keeps_single_highlight_span() {
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .expect("openpty");
+
+ let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty"));
+ builder.arg("-p");
+ builder.arg("cisco");
+ builder.arg("sh");
+ builder.arg("-c");
+ // First write is >8 bytes and ends mid-token; the gap lets prismtty read it
+ // (and go idle) before the rest arrives, so the token genuinely spans reads.
+ builder
+ .arg("printf 'show: Vlan11'; sleep 0.25; printf '91 New TZ GW to Internal\\n'; sleep 0.3");
+
+ let mut child = pair.slave.spawn_command(builder).expect("spawn ptty");
+ drop(pair.slave);
+
+ // Pure program output: we never write to the master, so no input echo is
+ // pending and the idle flush must leave the buffered token alone.
+ let mut reader = pair.master.try_clone_reader().expect("clone reader");
+ let (tx, rx) = mpsc::channel::>();
+ thread::spawn(move || {
+ let mut acc = Vec::new();
+ let mut buf = [0u8; 256];
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ acc.extend_from_slice(&buf[..n]);
+ if tx.send(acc.clone()).is_err() {
+ break;
+ }
+ }
+ Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(_) => break,
+ }
+ }
+ });
+
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut out = Vec::new();
+ while Instant::now() < deadline {
+ match rx.recv_timeout(Duration::from_millis(200)) {
+ Ok(latest) => {
+ out = latest;
+ if contains_sgr_span(&out, b"Vlan1191") {
+ break;
+ }
+ }
+ Err(mpsc::RecvTimeoutError::Timeout) => continue,
+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
+ }
+ }
+
+ let _ = child.kill();
+ let _ = child.wait();
+
+ assert!(
+ contains_sgr_span(&out, b"Vlan1191"),
+ "split program-output token lost its single highlight span; saw: {:?}",
+ String::from_utf8_lossy(&out)
+ );
+}
+
+/// Runs an echo-off child that streams the cisco token "Vlan1191" split across
+/// two writes per iteration, while a thread types `typed` bytes throughout, and
+/// returns the captured output. With echo off the typed bytes never echo back,
+/// so the buffered token is always program output and its span must stay intact
+/// regardless of what is typed.
+fn echo_off_split_stream_while_typing(typed: &'static [u8]) -> Vec {
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .expect("openpty");
+
+ let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty"));
+ builder.arg("-p");
+ builder.arg("cisco");
+ builder.arg("sh");
+ builder.arg("-c");
+ builder.arg(
+ "stty -echo; i=0; while [ $i -lt 12 ]; do printf 'aaaaaaaa Vlan11'; \
+ sleep 0.12; printf '91 bbbb\\n'; sleep 0.12; i=$((i+1)); done",
+ );
+
+ let mut child = pair.slave.spawn_command(builder).expect("spawn ptty");
+ drop(pair.slave);
+
+ let mut reader = pair.master.try_clone_reader().expect("clone reader");
+ let mut writer = pair.master.take_writer().expect("take writer");
+
+ // Type throughout, then stop explicitly. Linux PTYs can keep accepting
+ // master writes briefly after the child exits, so do not rely on write
+ // failure as the only thread-exit signal.
+ let stop_typing = Arc::new(AtomicBool::new(false));
+ let typer_stop = Arc::clone(&stop_typing);
+ let typer = thread::spawn(move || {
+ while !typer_stop.load(Ordering::Relaxed) {
+ if writer.write_all(typed).is_err() || writer.flush().is_err() {
+ break;
+ }
+ thread::sleep(Duration::from_millis(30));
+ }
+ });
+
+ let (tx, rx) = mpsc::channel::>();
+ thread::spawn(move || {
+ let mut acc = Vec::new();
+ let mut buf = [0u8; 256];
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ acc.extend_from_slice(&buf[..n]);
+ if tx.send(acc.clone()).is_err() {
+ break;
+ }
+ }
+ Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(_) => break,
+ }
+ }
+ });
+
+ let deadline = Instant::now() + Duration::from_secs(8);
+ let mut out = Vec::new();
+ loop {
+ match rx.recv_timeout(Duration::from_millis(200)) {
+ Ok(latest) => out = latest,
+ Err(mpsc::RecvTimeoutError::Timeout) => {
+ if Instant::now() >= deadline {
+ break;
+ }
+ }
+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
+ }
+ }
+
+ let _ = child.kill();
+ let _ = child.wait();
+ stop_typing.store(true, Ordering::Relaxed);
+ let _ = typer.join();
+ out
+}
+
+fn assert_spans_intact(out: &[u8]) {
+ let broken = count_subslice(out, b"mVlan11\x1b[39m91");
+ assert_eq!(
+ broken,
+ 0,
+ "concurrent input split {broken} program-output token span(s); saw: {:?}",
+ String::from_utf8_lossy(out)
+ );
+ assert!(
+ contains_sgr_span(out, b"Vlan1191"),
+ "expected at least one intact Vlan1191 span; saw: {:?}",
+ String::from_utf8_lossy(out)
+ );
+}
+
+/// Non-matching type-ahead must not split a streamed program token. A coarse
+/// "input happened" signal would wrongly flush the buffered token; the suffix
+/// match leaves it buffered because "x" is not the token.
+#[test]
+fn split_program_output_token_survives_concurrent_nonechoing_input() {
+ assert_spans_intact(&echo_off_split_stream_while_typing(b"x"));
+}
+
+/// The adversarial case the suffix match alone cannot catch: with echo off the
+/// user types the EXACT bytes of the streamed token. Content matching would see
+/// recent input ending in those bytes and flush the *program* token, splitting
+/// it. The ECHO-state gate closes this: with echo off there is no input echo at
+/// all, so nothing is surfaced and the span stays intact.
+#[test]
+fn split_program_output_token_survives_concurrent_input_matching_the_token() {
+ assert_spans_intact(&echo_off_split_stream_while_typing(b"Vlan11"));
+}