feat: allow opting-in to reading paste buffer (default false) (#5472)

* feat: allow opting-in to reading paste buffer (default false)

* add pr
This commit is contained in:
Aram Drevekenin
2026-08-13 10:19:13 +02:00
committed by GitHub
parent a9db5664be
commit 98a0837077
34 changed files with 933 additions and 23 deletions
+1
View File
@@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* fix: properly unbundle plugin wasm executables when stripping them (to assist distro packagers) (https://github.com/zellij-org/zellij/pull/5433)
* feat: support OSC133, triple-mouse-click to mark whole command, configurable double-click word boundaries (https://github.com/zellij-org/zellij/pull/5460)
* fix(scroll): keep a pane's scroll position when leaving scroll mode, and re-enter scroll mode when focusing a scrolled pane (https://github.com/zellij-org/zellij/pull/5299)
* feat: allow opting-in to reading paste buffer (default false) (https://github.com/zellij-org/zellij/pull/5472)
## [0.44.3] - 2026-05-13
* fix(windows): bump windows-sys to 0.59 to align manifest with code, fixing source builds via `cargo install`/`cargo binstall` (https://github.com/zellij-org/zellij/pull/5139)
+58 -3
View File
@@ -186,6 +186,7 @@ pub(crate) enum ClientInstruction {
ForwardQueryToHost {
token: u32,
query_bytes: Vec<u8>,
resolve_async: bool,
},
EmitNestedSessionFrame(Vec<u8>),
}
@@ -210,8 +211,14 @@ impl From<ServerToClientMsg> for ClientInstruction {
ServerToClientMsg::StartWebServer => ClientInstruction::StartWebServer,
ServerToClientMsg::RenamedSession { name } => ClientInstruction::RenamedSession(name),
ServerToClientMsg::ConfigFileUpdated => ClientInstruction::ConfigFileUpdated,
ServerToClientMsg::ForwardQueryToHost { token, query_bytes } => {
ClientInstruction::ForwardQueryToHost { token, query_bytes }
ServerToClientMsg::ForwardQueryToHost {
token,
query_bytes,
resolve_async,
} => ClientInstruction::ForwardQueryToHost {
token,
query_bytes,
resolve_async,
},
ServerToClientMsg::EmitNestedSessionFrame { payload_bytes } => {
ClientInstruction::EmitNestedSessionFrame(payload_bytes)
@@ -1435,7 +1442,55 @@ pub fn start_client(
},
}
},
ClientInstruction::ForwardQueryToHost { token, query_bytes } => {
ClientInstruction::ForwardQueryToHost {
token,
query_bytes,
resolve_async: true,
} => {
{
let mut stdin_ansi_parser = stdin_ansi_parser.lock().unwrap();
if let Some((stale_token, stale_reply_bytes)) =
stdin_ansi_parser.take_active_clipboard_forward()
{
log::warn!(
"clipboard forward slot for token {} was still open when token {} was \
dispatched ({} accumulated bytes); closing it out",
stale_token,
token,
stale_reply_bytes.len(),
);
let _ = send_input_instructions.send(
InputInstruction::ForwardedReplyFromHostComplete {
token: stale_token,
reply_bytes: stale_reply_bytes,
},
);
}
stdin_ansi_parser.open_clipboard_forward(token);
}
let runtime = stdin_ansi_parser::forward_timeout_runtime();
let parser_for_timer = stdin_ansi_parser.clone();
let sender_for_timer = send_input_instructions.clone();
stdin_ansi_parser::schedule_clipboard_forward_timeout(
runtime.handle(),
parser_for_timer,
token,
std::time::Duration::from_millis(
stdin_ansi_parser::CLIENT_CLIPBOARD_FORWARD_TIMEOUT_MS,
),
move |token, reply_bytes| {
let _ = sender_for_timer.send(
InputInstruction::ForwardedReplyFromHostComplete { token, reply_bytes },
);
},
);
let mut out = os_input.get_stdout_writer();
let _ = out.write_all(&query_bytes);
let _ = out.flush();
},
ClientInstruction::ForwardQueryToHost {
token, query_bytes, ..
} => {
// 1. Open a forwarding window on the parser so any reply
// events that arrive before the barrier are captured.
// A slot may still be open here: the server's backstop
+90 -1
View File
@@ -187,6 +187,13 @@ pub struct ForwardSlot {
pub reply_bytes: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct ClipboardForwardSlot {
pub token: u32,
}
pub const CLIENT_CLIPBOARD_FORWARD_TIMEOUT_MS: u64 = 30_000;
/// Return value of `feed()`.
#[derive(Debug, Clone, Default)]
pub struct ParseOutput {
@@ -197,6 +204,7 @@ pub struct ParseOutput {
/// single feed would indicate the host emitted two barriers, in which
/// case only the first is honored.
pub completed_forward: Option<(u32, Vec<u8>)>,
pub completed_clipboard_forward: Option<(u32, Vec<u8>)>,
/// OSC 99 notification-response payloads (one per OSC 99 found in
/// the chunk). Routed by the caller as
/// `InputInstruction::DesktopNotificationResponse`. Lives here, not
@@ -215,6 +223,15 @@ pub struct ParseOutput {
pub has_partial_state: bool,
}
fn is_user_input(event: &InputEvent) -> bool {
match event {
InputEvent::Key(_) | InputEvent::Paste(_) => true,
InputEvent::Mouse(mouse_event) => !mouse_event.mouse_buttons.is_empty(),
InputEvent::PixelMouse(mouse_event) => !mouse_event.mouse_buttons.is_empty(),
_ => false,
}
}
/// Cap on the size of an in-flight partial OSC/CSI buffer. Sized to
/// pass legitimate OSC 52 clipboard payloads, which carry the entire
/// clipboard base64-encoded and have no protocol-level limit (images,
@@ -285,6 +302,7 @@ pub struct StdinAnsiParser {
/// Active forwarding slot: `Some` while a forwarded query is in
/// flight, `None` otherwise.
active_forward: Option<ForwardSlot>,
active_clipboard_forward: Option<ClipboardForwardSlot>,
/// Bytes of an OSC sequence whose terminator hasn't arrived yet.
/// Carried across feed() calls so the next chunk can complete it.
partial_osc: Vec<u8>,
@@ -312,6 +330,7 @@ impl StdinAnsiParser {
StdinAnsiParser {
inner: InputParser::new(),
active_forward: None,
active_clipboard_forward: None,
partial_osc: Vec::new(),
partial_csi: Vec::new(),
partial_paste: Vec::new(),
@@ -383,6 +402,37 @@ impl StdinAnsiParser {
.map(|slot| (slot.token, slot.reply_bytes))
}
pub fn open_clipboard_forward(&mut self, token: u32) {
debug_assert!(
self.active_clipboard_forward.is_none(),
"open_clipboard_forward({}) called while slot for token {:?} is still active",
token,
self.active_clipboard_forward.as_ref().map(|s| s.token),
);
self.active_clipboard_forward = Some(ClipboardForwardSlot { token });
}
pub fn close_clipboard_forward_on_timeout(&mut self, token: u32) -> Option<(u32, Vec<u8>)> {
match &self.active_clipboard_forward {
Some(slot) if slot.token == token => {
let slot = self.active_clipboard_forward.take().unwrap();
Some((slot.token, Vec::new()))
},
_ => None,
}
}
pub fn take_active_clipboard_forward(&mut self) -> Option<(u32, Vec<u8>)> {
self.active_clipboard_forward
.take()
.map(|slot| (slot.token, Vec::new()))
}
#[cfg(test)]
pub fn active_clipboard_forward_token(&self) -> Option<u32> {
self.active_clipboard_forward.as_ref().map(|s| s.token)
}
/// Currently-open slot's token, if any. Test-only inspector;
/// production code drives slot lifecycle through `open_forward`,
/// `close_forward_on_timeout`, and `feed()` directly.
@@ -428,6 +478,15 @@ impl StdinAnsiParser {
} else if let Some(reply) = HostReply::from_osc_payload(&payload) {
out.replies.push(reply);
}
if payload.starts_with(b"52;") {
if let Some(slot) = self.active_clipboard_forward.take() {
let mut bytes = b"\x1b]".to_vec();
bytes.extend_from_slice(&payload);
bytes.extend_from_slice(b"\x1b\\");
out.completed_clipboard_forward = Some((slot.token, bytes));
continue;
}
}
if let Some(slot) = self.active_forward.as_mut() {
// Re-serialize so the pane's pty sees a legal OSC.
// Terminators vary by host; ST (ESC \) is always safe.
@@ -479,7 +538,16 @@ impl StdinAnsiParser {
// input bytes that are NOT part of a classified reply. To
// produce that residue deterministically, we re-scan the
// buffer a second time below.
_ => {},
other => {
if self.active_clipboard_forward.is_some()
&& is_user_input(&other)
&& out.completed_clipboard_forward.is_none()
{
if let Some(slot) = self.active_clipboard_forward.take() {
out.completed_clipboard_forward = Some((slot.token, Vec::new()));
}
}
},
}
}
// Produce the residue: replay the input through a scratch parser
@@ -877,6 +945,27 @@ pub fn schedule_forward_timeout<F>(
});
}
pub fn schedule_clipboard_forward_timeout<F>(
runtime: &tokio::runtime::Handle,
parser: Arc<Mutex<StdinAnsiParser>>,
token: u32,
deadline: std::time::Duration,
on_timeout: F,
) where
F: FnOnce(u32, Vec<u8>) + Send + 'static,
{
runtime.spawn(async move {
tokio::time::sleep(deadline).await;
let payload = parser
.lock()
.unwrap()
.close_clipboard_forward_on_timeout(token);
if let Some((t, bytes)) = payload {
on_timeout(t, bytes);
}
});
}
#[cfg(test)]
#[path = "stdin_ansi_parser_tests.rs"]
mod tests;
@@ -1512,3 +1512,114 @@ fn two_nested_frames_in_one_chunk_are_both_extracted() {
assert_eq!(out.nested_frames, vec![payload.clone(), payload]);
assert!(out.residue.is_empty());
}
#[test]
fn clipboard_forward_is_closed_by_the_hosts_osc_52_reply() {
let mut parser = StdinAnsiParser::new();
parser.open_clipboard_forward(9);
let out = parser.feed(b"\x1b]52;c;aGVsbG8=\x1b\\");
assert_eq!(
out.completed_clipboard_forward,
Some((9, b"\x1b]52;c;aGVsbG8=\x1b\\".to_vec())),
"the host's reply must be relayed verbatim (re-serialized with ST)"
);
assert!(out.residue.is_empty(), "clipboard data must never be typed");
assert!(parser.active_clipboard_forward_token().is_none());
}
#[test]
fn clipboard_forward_is_closed_by_the_first_key_press() {
let mut parser = StdinAnsiParser::new();
parser.open_clipboard_forward(4);
let out = parser.feed(b"a");
assert_eq!(out.completed_clipboard_forward, Some((4, Vec::new())));
assert_eq!(out.residue, b"a", "the key itself must still reach the app");
assert!(parser.active_clipboard_forward_token().is_none());
}
#[test]
fn clipboard_forward_survives_host_reports_and_resizes() {
let mut parser = StdinAnsiParser::new();
parser.open_clipboard_forward(11);
let out = parser.feed(b"\x1b[4;720;1280t");
assert!(out.completed_clipboard_forward.is_none());
let out = parser.feed(b"\x1b]11;rgb:0000/0000/0000\x1b\\");
assert!(out.completed_clipboard_forward.is_none());
let out = parser.feed(b"\x1b[?62;52c");
assert!(
out.completed_clipboard_forward.is_none(),
"the Primary-DA barrier must not close a clipboard window"
);
assert_eq!(parser.active_clipboard_forward_token(), Some(11));
}
#[test]
fn a_reply_arriving_before_the_keypress_wins() {
let mut parser = StdinAnsiParser::new();
parser.open_clipboard_forward(2);
let mut input = Vec::new();
input.extend_from_slice(b"\x1b]52;c;d29ybGQ=\x1b\\");
input.extend_from_slice(b"x");
let out = parser.feed(&input);
assert_eq!(
out.completed_clipboard_forward,
Some((2, b"\x1b]52;c;d29ybGQ=\x1b\\".to_vec()))
);
assert_eq!(out.residue, b"x");
}
#[test]
fn clipboard_and_barrier_forwards_are_independent() {
let mut parser = StdinAnsiParser::new();
parser.open_clipboard_forward(1);
parser.open_forward(2);
let out = parser.feed(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\\x1b[?62;52c");
assert_eq!(
out.completed_forward,
Some((2, b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\".to_vec()))
);
assert!(out.completed_clipboard_forward.is_none());
assert_eq!(parser.active_clipboard_forward_token(), Some(1));
}
#[test]
fn clipboard_reply_is_not_accumulated_into_a_barrier_slot() {
let mut parser = StdinAnsiParser::new();
parser.open_clipboard_forward(1);
parser.open_forward(2);
let out = parser.feed(b"\x1b]52;c;aGk=\x1b\\");
assert_eq!(
out.completed_clipboard_forward,
Some((1, b"\x1b]52;c;aGk=\x1b\\".to_vec()))
);
let out = parser.feed(b"\x1b[?62;52c");
assert_eq!(
out.completed_forward,
Some((2, Vec::new())),
"the clipboard payload must not leak into the barrier slot"
);
}
#[test]
fn taking_the_clipboard_slot_hands_the_token_over() {
let mut parser = StdinAnsiParser::new();
parser.open_clipboard_forward(6);
assert_eq!(
parser.take_active_clipboard_forward(),
Some((6, Vec::new()))
);
assert!(parser.active_clipboard_forward_token().is_none());
assert_eq!(parser.take_active_clipboard_forward(), None);
}
#[test]
fn clipboard_timeout_only_fires_for_the_open_token() {
let mut parser = StdinAnsiParser::new();
parser.open_clipboard_forward(3);
assert_eq!(parser.close_clipboard_forward_on_timeout(4), None);
assert_eq!(
parser.close_clipboard_forward_on_timeout(3),
Some((3, Vec::new()))
);
assert_eq!(parser.close_clipboard_forward_on_timeout(3), None);
}
+9
View File
@@ -142,6 +142,15 @@ pub(crate) fn stdin_loop(
},
);
}
if let Some((token, reply_bytes)) = parse_output.completed_clipboard_forward
{
let _ = send_input_instructions.send(
InputInstruction::ForwardedReplyFromHostComplete {
token,
reply_bytes,
},
);
}
for payload in parse_output.desktop_notifications {
let _ = send_input_instructions
.send(InputInstruction::DesktopNotificationResponse(payload));
+6 -2
View File
@@ -77,7 +77,9 @@ fn search_input_jumps_to_a_match() {
zellij.send_stdin(b"NEEDLE");
let grid_snapshot = zellij.wait_until("scrolled to the typed match", |grid_snapshot| {
grid_snapshot.contains("line05") && grid_snapshot.contains("ENTERING SEARCH TERM")
grid_snapshot.contains("line05")
&& grid_snapshot.contains("ENTERING SEARCH TERM")
&& grid_snapshot.contains("SCROLL 15/19")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
@@ -94,7 +96,9 @@ fn search_up_to_previous_match() {
let grid_snapshot =
zellij.wait_until("upper match revealed by searching up", |grid_snapshot| {
grid_snapshot.contains("NEEDLE upper") && grid_snapshot.contains("PgDn|PgUp")
grid_snapshot.contains("NEEDLE upper")
&& grid_snapshot.contains("PgDn|PgUp")
&& grid_snapshot.contains("SCROLL 15/19")
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
@@ -2,7 +2,7 @@
source: zellij-integration-tests/tests/search.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1  +  [ SCROLL 0/19 ]
Zellij (test)  Tab #1  +  [ SCROLL 15/19 ]
line05 NEEDLE
line06
line07
@@ -2,7 +2,7 @@
source: zellij-integration-tests/tests/search.rs
expression: normalized(&grid_snapshot)
---
Zellij (test)  Tab #1  +  [ SCROLL 0/19 ]
Zellij (test)  Tab #1  +  [ SCROLL 15/19 ]
line05 NEEDLE upper
line06
line07
+76
View File
@@ -56,6 +56,10 @@ pub enum HostQuery {
index: u8,
terminator: OscTerminator,
},
ClipboardContent {
selection: char,
terminator: OscTerminator,
},
/// `CSI ? 996 n` — query the host terminal's color-palette theme
/// mode (light or dark). NOT forwarded to the host. Zellij has
/// already queried the host once at startup (via the client's
@@ -90,6 +94,14 @@ impl HostQuery {
v.extend_from_slice(terminator.as_bytes());
v
},
HostQuery::ClipboardContent {
selection,
terminator,
} => {
let mut v = format!("\x1b]52;{};?", selection).into_bytes();
v.extend_from_slice(terminator.as_bytes());
v
},
// `ColorPaletteMode` is answered locally by Zellij and never
// sent on the wire. Returning empty bytes keeps the call
// total without dirtying the wire format. Callers that
@@ -98,6 +110,20 @@ impl HostQuery {
HostQuery::ColorPaletteMode => Vec::new(),
}
}
pub fn empty_reply_bytes(&self) -> Vec<u8> {
match self {
HostQuery::ClipboardContent {
selection,
terminator,
} => {
let mut v = format!("\x1b]52;{};", selection).into_bytes();
v.extend_from_slice(terminator.as_bytes());
v
},
_ => Vec::new(),
}
}
}
#[cfg(test)]
@@ -166,6 +192,56 @@ mod tests {
);
}
#[test]
fn clipboard_content_carries_selection_and_terminator() {
assert_eq!(
HostQuery::ClipboardContent {
selection: 'c',
terminator: OscTerminator::Bel,
}
.to_query_bytes(),
b"\x1b]52;c;?\x07",
);
assert_eq!(
HostQuery::ClipboardContent {
selection: 'p',
terminator: OscTerminator::St,
}
.to_query_bytes(),
b"\x1b]52;p;?\x1b\\",
);
}
#[test]
fn clipboard_content_empty_reply_mirrors_the_query_shape() {
assert_eq!(
HostQuery::ClipboardContent {
selection: 'c',
terminator: OscTerminator::Bel,
}
.empty_reply_bytes(),
b"\x1b]52;c;\x07",
);
assert_eq!(
HostQuery::ClipboardContent {
selection: 'p',
terminator: OscTerminator::St,
}
.empty_reply_bytes(),
b"\x1b]52;p;\x1b\\",
);
}
#[test]
fn non_clipboard_queries_have_no_empty_reply() {
assert!(HostQuery::TextAreaPixelSize.empty_reply_bytes().is_empty());
assert!(HostQuery::DefaultBackground {
terminator: OscTerminator::St,
}
.empty_reply_bytes()
.is_empty());
}
#[test]
fn from_bell_terminated_maps_bool_to_variant() {
assert_eq!(
+11 -4
View File
@@ -138,8 +138,7 @@ pub enum ServerInstruction {
ClearMouseHelpText(ClientId),
/// Relay a forwarded-query dispatch from Screen to the server main
/// loop. The main loop writes `ServerToClientMsg::ForwardQueryToHost`
/// to any connected regular client.
ForwardQueryToHost(u32, Vec<u8>),
ForwardQueryToHost(u32, Vec<u8>, bool),
KeyPassthroughChanged(ClientId, PaneId, PaneId, bool, Option<Direction>, bool),
EmitNestedSessionFrameToClient(ClientId, Vec<u8>),
}
@@ -475,6 +474,10 @@ impl SessionMetaData {
.options
.osc133_command_selection
.unwrap_or(true),
dangerously_enable_paste_buffer_read: new_config
.options
.dangerously_enable_paste_buffer_read
.unwrap_or(false),
word_separators: new_config
.options
.word_separators
@@ -1892,7 +1895,7 @@ pub fn start_server_impl(
.send_to_screen(ScreenInstruction::ClearMouseHelpText(client_id))
.unwrap();
},
ServerInstruction::ForwardQueryToHost(token, query_bytes) => {
ServerInstruction::ForwardQueryToHost(token, query_bytes, resolve_async) => {
// Pick a regular (non-watcher) client to carry the
// forward. Preference is the most recently active
// client (whichever last sent input); falls back to
@@ -1912,7 +1915,11 @@ pub fn start_server_impl(
send_to_client!(
client_id,
os_input,
ServerToClientMsg::ForwardQueryToHost { token, query_bytes },
ServerToClientMsg::ForwardQueryToHost {
token,
query_bytes,
resolve_async
},
session_state,
session_data
);
+55 -3
View File
@@ -731,6 +731,7 @@ pub struct Grid {
pub pending_nested_session_messages: Vec<NestedSessionMessage>,
ui_component_bytes: Option<Vec<u8>>,
nested_frame_bytes: Option<Vec<u8>>,
xtgettcap_bytes: Option<Vec<u8>>,
style: Style,
debug: bool,
arrow_fonts: bool,
@@ -806,6 +807,30 @@ fn osc_color_reply_body((r, g, b): (u8, u8, u8)) -> String {
format!("rgb:{:04x}/{:04x}/{:04x}", expand(r), expand(g), expand(b))
}
fn decode_hex_ascii(hex: &[u8]) -> Option<String> {
if hex.is_empty() || hex.len() % 2 != 0 {
return None;
}
let mut out = String::with_capacity(hex.len() / 2);
for pair in hex.chunks(2) {
let high = (pair[0] as char).to_digit(16)?;
let low = (pair[1] as char).to_digit(16)?;
let byte = (high * 16 + low) as u8;
if !byte.is_ascii_graphic() {
return None;
}
out.push(byte as char);
}
Some(out)
}
fn encode_hex_ascii(value: &str) -> String {
value
.bytes()
.map(|byte| format!("{:02X}", byte))
.collect::<String>()
}
/// A compiled highlight entry for one plugin/pattern combination.
#[derive(Clone)]
pub struct CompiledHighlight {
@@ -1085,6 +1110,7 @@ impl Grid {
pending_nested_session_messages: Vec::new(),
ui_component_bytes: None,
nested_frame_bytes: None,
xtgettcap_bytes: None,
style,
debug,
arrow_fonts,
@@ -3906,6 +3932,19 @@ impl Grid {
self.pending_messages_to_pty
.push(format!("\u{1b}[?997;{}n", code).into_bytes());
}
fn answer_xtgettcap(&mut self, payload: &[u8]) {
for name_hex in payload.split(|byte| *byte == b';') {
let reply = match decode_hex_ascii(name_hex).as_deref() {
Some("Ms") => format!(
"\u{1b}P1+r{}={}\u{1b}\\",
encode_hex_ascii("Ms"),
encode_hex_ascii("\u{1b}]52;%p1%s;%p2%s\u{7}"),
),
_ => "\u{1b}P0+r\u{1b}\\".to_owned(),
};
self.pending_messages_to_pty.push(reply.into_bytes());
}
}
pub fn lock_renders(&mut self) {
self.lock_renders = true;
}
@@ -4100,7 +4139,9 @@ impl Perform for Grid {
}
fn hook(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, c: char) {
if c == 'q' {
if c == 'q' && intermediates.get(0) == Some(&b'+') {
self.xtgettcap_bytes = Some(vec![]);
} else if c == 'q' && intermediates.is_empty() {
// we only process sixel images if we know the pixel size of each character cell,
// otherwise we can't reliably display them
if self.current_cursor_pixel_coordinates().is_some() {
@@ -4138,6 +4179,8 @@ impl Perform for Grid {
ui_component_bytes.push(byte);
} else if let Some(nested_frame_bytes) = self.nested_frame_bytes.as_mut() {
nested_frame_bytes.push(byte);
} else if let Some(xtgettcap_bytes) = self.xtgettcap_bytes.as_mut() {
xtgettcap_bytes.push(byte);
}
}
@@ -4157,6 +4200,8 @@ impl Perform for Grid {
{
self.pending_nested_session_messages.push(message);
}
} else if let Some(xtgettcap_bytes) = self.xtgettcap_bytes.take() {
self.answer_xtgettcap(&xtgettcap_bytes);
}
self.mark_for_rerender();
}
@@ -4354,10 +4399,17 @@ impl Perform for Grid {
return;
}
let _clipboard = params[1].get(0).unwrap_or(&b'c');
let clipboard = *params[1].get(0).unwrap_or(&b'c');
match params[2] {
b"?" => {
// TBD: paste from own clipboard - currently unsupported
self.pending_forwarded_queries.push(
crate::host_query::HostQuery::ClipboardContent {
selection: clipboard as char,
terminator: crate::host_query::OscTerminator::from_bell_terminated(
bell_terminated,
),
},
);
},
base64 => {
if let Ok(bytes) = BASE64_DECODER.decode(base64) {
@@ -8674,3 +8674,101 @@ fn xtsmgraphics_geometry_reports_failure_when_host_does_not_support_sixel() {
vte_parser.advance(&mut grid, b"\x1b[?2;1S");
assert_eq!(grid.pending_messages_to_pty, vec![b"\x1b[?2;3;0S".to_vec()]);
}
#[test]
fn osc_52_read_is_forwarded_to_the_host() {
let mut grid = new_grid_for_forwarding_test();
let mut vte_parser = vte::Parser::new();
vte_parser.advance(&mut grid, b"\x1b]52;c;?\x07");
assert!(
grid.pending_messages_to_pty.is_empty(),
"clipboard reads must never be answered locally"
);
assert_eq!(
grid.pending_forwarded_queries,
vec![crate::host_query::HostQuery::ClipboardContent {
selection: 'c',
terminator: crate::host_query::OscTerminator::Bel,
}]
);
}
#[test]
fn osc_52_read_keeps_the_selection_and_terminator_of_the_query() {
let mut grid = new_grid_for_forwarding_test();
let mut vte_parser = vte::Parser::new();
vte_parser.advance(&mut grid, b"\x1b]52;p;?\x1b\\");
assert_eq!(
grid.pending_forwarded_queries,
vec![crate::host_query::HostQuery::ClipboardContent {
selection: 'p',
terminator: crate::host_query::OscTerminator::St,
}]
);
}
#[test]
fn osc_52_write_is_not_forwarded() {
let mut grid = new_grid_for_forwarding_test();
let mut vte_parser = vte::Parser::new();
vte_parser.advance(&mut grid, b"\x1b]52;c;aGVsbG8=\x07");
assert!(
grid.pending_forwarded_queries.is_empty(),
"copying to the clipboard must not go through the forward path"
);
assert_eq!(grid.pending_clipboard_update.as_deref(), Some("hello"));
}
#[test]
fn xtgettcap_answers_the_ms_capability() {
let mut grid = create_grid_with_content("");
let mut vte_parser = vte::Parser::new();
vte_parser.advance(&mut grid, b"\x1bP+q4d73\x1b\\");
assert_eq!(
grid.pending_messages_to_pty,
vec![b"\x1bP1+r4D73=1B5D35323B25703125733B257032257307\x1b\\".to_vec()]
);
}
#[test]
fn xtgettcap_rejects_unknown_capabilities() {
let mut grid = create_grid_with_content("");
let mut vte_parser = vte::Parser::new();
vte_parser.advance(&mut grid, b"\x1bP+q62656c\x1b\\");
assert_eq!(
grid.pending_messages_to_pty,
vec![b"\x1bP0+r\x1b\\".to_vec()]
);
}
#[test]
fn xtgettcap_answers_each_requested_capability() {
let mut grid = create_grid_with_content("");
let mut vte_parser = vte::Parser::new();
vte_parser.advance(&mut grid, b"\x1bP+q62656c;4d73\x1b\\");
assert_eq!(grid.pending_messages_to_pty.len(), 2);
assert_eq!(grid.pending_messages_to_pty[0], b"\x1bP0+r\x1b\\".to_vec());
assert!(String::from_utf8_lossy(&grid.pending_messages_to_pty[1]).starts_with("\x1bP1+r4D73="));
}
#[test]
fn xtgettcap_rejects_malformed_hex() {
let mut grid = create_grid_with_content("");
let mut vte_parser = vte::Parser::new();
vte_parser.advance(&mut grid, b"\x1bP+q4d7\x1b\\");
assert_eq!(
grid.pending_messages_to_pty,
vec![b"\x1bP0+r\x1b\\".to_vec()]
);
}
#[test]
fn sixel_dcs_is_not_confused_with_xtgettcap() {
let mut grid = new_grid_for_forwarding_test();
let mut vte_parser = vte::Parser::new();
vte_parser.advance(&mut grid, b"\x1bPq#0;2;0;0;0\x1b\\");
assert!(
grid.pending_messages_to_pty.is_empty(),
"a sixel image must not produce an XTGETTCAP reply"
);
}
+122 -1
View File
@@ -790,6 +790,7 @@ pub enum ScreenInstruction {
osc133_command_selection: bool,
word_separators: String,
nested_session_handling: NestedSessionHandling,
dangerously_enable_paste_buffer_read: bool,
},
RerunCommandPane(u32, Option<NotificationEnd>), // u32 - terminal pane id
ResizePaneWithId(ResizeStrategy, PaneId),
@@ -1543,6 +1544,10 @@ pub(crate) struct Screen {
pending_forwarded_queries: HashMap<u32, PendingForwardEntry>,
forward_queue: VecDeque<PendingForward>,
forward_in_flight_token: Option<u32>,
pending_clipboard_forwards: HashMap<u32, PendingForwardEntry>,
clipboard_forward_queue: VecDeque<PendingForward>,
clipboard_forward_in_flight_token: Option<u32>,
paste_buffer_read_enabled: bool,
nested_guest_tracker: NestedGuestTracker,
nested_ancestry: Vec<String>,
nested_via_client_id: Option<ClientId>,
@@ -1609,6 +1614,8 @@ const STARTUP_SENTINEL_TOKEN: u32 = 0;
/// network-pathological cases ever see this fire.
const SERVER_FORWARD_TIMEOUT_MS: u64 = 1000;
const SERVER_CLIPBOARD_FORWARD_TIMEOUT_MS: u64 = 35_000;
impl Screen {
/// Creates and returns a new [`Screen`].
pub fn new(
@@ -1727,6 +1734,10 @@ impl Screen {
pending_forwarded_queries: HashMap::new(),
forward_queue: VecDeque::new(),
forward_in_flight_token: None,
pending_clipboard_forwards: HashMap::new(),
clipboard_forward_queue: VecDeque::new(),
clipboard_forward_in_flight_token: None,
paste_buffer_read_enabled: false,
nested_guest_tracker: NestedGuestTracker::default(),
nested_ancestry: vec![],
nested_via_client_id: None,
@@ -2667,6 +2678,13 @@ impl Screen {
self.answer_color_palette_mode_query_locally(pane_id);
return STARTUP_SENTINEL_TOKEN; // sentinel: no real forward happened
}
if let crate::host_query::HostQuery::ClipboardContent { .. } = query {
if !self.paste_buffer_read_enabled {
let _ = self.resume_pane_after_forward(pane_id, Vec::new());
return STARTUP_SENTINEL_TOKEN;
}
return self.enqueue_clipboard_forward(pane_id, query);
}
let token = self.next_forward_token;
// Skip over the reserved sentinel (0) on wrap; allocate a fresh
// u32 for every forward.
@@ -2686,6 +2704,85 @@ impl Screen {
token
}
fn enqueue_clipboard_forward(
&mut self,
pane_id: PaneId,
query: crate::host_query::HostQuery,
) -> u32 {
let token = self.next_forward_token;
self.next_forward_token = self.next_forward_token.wrapping_add(1);
if self.next_forward_token == STARTUP_SENTINEL_TOKEN {
self.next_forward_token = 1;
}
if self.clipboard_forward_in_flight_token.is_some() {
self.clipboard_forward_queue.push_back(PendingForward {
token,
pane_id,
query,
});
} else {
self.dispatch_clipboard_forward(token, pane_id, query);
}
token
}
fn dispatch_clipboard_forward(
&mut self,
token: u32,
pane_id: PaneId,
query: crate::host_query::HostQuery,
) {
let query_bytes = query.to_query_bytes();
self.pending_clipboard_forwards
.insert(token, PendingForwardEntry { pane_id, query });
self.clipboard_forward_in_flight_token = Some(token);
let _ = self
.bus
.senders
.send_to_server(ServerInstruction::ForwardQueryToHost(
token,
query_bytes,
true,
));
let senders = self.bus.senders.clone();
crate::global_async_runtime::get_tokio_runtime().spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(
SERVER_CLIPBOARD_FORWARD_TIMEOUT_MS,
))
.await;
let _ = senders.send_to_screen(ScreenInstruction::ForwardedReplyFromHost {
token,
reply_bytes: Vec::new(),
});
});
}
fn handle_clipboard_reply(&mut self, token: u32, reply_bytes: Vec<u8>) -> Result<()> {
if let Some(PendingForwardEntry { pane_id, query }) =
self.pending_clipboard_forwards.remove(&token)
{
let payload = if reply_bytes.is_empty() {
query.empty_reply_bytes()
} else {
reply_bytes
};
self.resume_pane_after_forward(pane_id, payload)?;
}
self.clipboard_forward_in_flight_token = None;
while let Some(next) = self.clipboard_forward_queue.pop_front() {
if !self.pane_exists(&next.pane_id) {
continue;
}
self.dispatch_clipboard_forward(next.token, next.pane_id, next.query);
break;
}
Ok(())
}
fn pane_exists(&self, pane_id: &PaneId) -> bool {
self.tabs.values().any(|tab| tab.has_pane_with_pid(pane_id))
}
fn answer_color_palette_mode_query_locally(&mut self, pane_id: PaneId) {
if matches!(pane_id, PaneId::Plugin(_)) {
return;
@@ -2729,7 +2826,11 @@ impl Screen {
let _ = self
.bus
.senders
.send_to_server(ServerInstruction::ForwardQueryToHost(token, query_bytes));
.send_to_server(ServerInstruction::ForwardQueryToHost(
token,
query_bytes,
false,
));
let senders = self.bus.senders.clone();
crate::global_async_runtime::get_tokio_runtime().spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(SERVER_FORWARD_TIMEOUT_MS)).await;
@@ -2757,6 +2858,17 @@ impl Screen {
token: u32,
reply_bytes: Vec<u8>,
) -> Result<()> {
if self.clipboard_forward_in_flight_token == Some(token) {
return self.handle_clipboard_reply(token, reply_bytes);
}
if self.pending_clipboard_forwards.contains_key(&token) {
log::debug!(
"Dropping stale clipboard reply (token={}, len={} bytes)",
token,
reply_bytes.len(),
);
return Ok(());
}
// Stale-reply guard. Both the real `ForwardedReplyFromHost`
// path and the server-side timeout path land here. If a real
// reply landed first (releasing the slot AND dispatching the
@@ -3882,6 +3994,7 @@ impl Screen {
Vec::new()
}
},
HostQuery::ClipboardContent { .. } => query.empty_reply_bytes(),
// Should not reach here: ColorPaletteMode short-circuits in
// `forward_host_query` before any cache-fallback path runs.
HostQuery::ColorPaletteMode => match self.effective_host_terminal_theme_mode() {
@@ -6415,6 +6528,7 @@ impl Screen {
osc133_command_selection: bool,
word_separators: String,
nested_session_handling: NestedSessionHandling,
dangerously_enable_paste_buffer_read: bool,
client_id: ClientId,
) -> Result<()> {
let should_support_arrow_fonts = !simplified_ui;
@@ -6444,6 +6558,7 @@ impl Screen {
self.osc133_command_selection = osc133_command_selection;
self.word_separators = word_separators;
self.nested_session_handling = nested_session_handling;
self.paste_buffer_read_enabled = dangerously_enable_paste_buffer_read;
self.default_mode_info
.update_arrow_fonts(should_support_arrow_fonts);
self.default_mode_info
@@ -7627,6 +7742,9 @@ pub(crate) fn screen_thread_main(
let focus_follows_mouse = config_options.focus_follows_mouse.unwrap_or(false);
let mouse_click_through = config_options.mouse_click_through.unwrap_or(false);
let nested_session_handling = config_options.nested_session_handling.unwrap_or_default();
let dangerously_enable_paste_buffer_read = config_options
.dangerously_enable_paste_buffer_read
.unwrap_or(false);
let thread_senders = bus.senders.clone();
let mut screen = Screen::new(
@@ -7679,6 +7797,7 @@ pub(crate) fn screen_thread_main(
);
screen.host_theme_dark_styling = host_theme_dark_styling;
screen.host_theme_light_styling = host_theme_light_styling;
screen.paste_buffer_read_enabled = dangerously_enable_paste_buffer_read;
let mut pending_tab_ids: HashSet<usize> = HashSet::new();
let mut pending_tab_switches: HashSet<(usize, ClientId)> = HashSet::new(); // usize is the
@@ -10987,6 +11106,7 @@ pub(crate) fn screen_thread_main(
osc133_command_selection,
word_separators,
nested_session_handling,
dangerously_enable_paste_buffer_read,
} => {
screen.host_theme_dark_styling = host_theme_dark;
screen.host_theme_light_styling = host_theme_light;
@@ -11016,6 +11136,7 @@ pub(crate) fn screen_thread_main(
osc133_command_selection,
word_separators,
nested_session_handling,
dangerously_enable_paste_buffer_read,
client_id,
)
.non_fatal();
+183 -1
View File
@@ -8687,13 +8687,23 @@ impl ForwardCapture {
fn drain_forward_queries(&self) -> Vec<(u32, Vec<u8>)> {
let mut out = Vec::new();
while let Ok((instr, _ctx)) = self.server_rx.try_recv() {
if let ServerInstruction::ForwardQueryToHost(token, bytes) = instr {
if let ServerInstruction::ForwardQueryToHost(token, bytes, _) = instr {
out.push((token, bytes));
}
}
out
}
fn drain_forward_queries_with_async(&self) -> Vec<(u32, Vec<u8>, bool)> {
let mut out = Vec::new();
while let Ok((instr, _ctx)) = self.server_rx.try_recv() {
if let ServerInstruction::ForwardQueryToHost(token, bytes, resolve_async) = instr {
out.push((token, bytes, resolve_async));
}
}
out
}
/// Drain every pending `PtyWriteInstruction::Write`, returning
/// `(bytes, terminal_id)` — the two fields the reply path sets.
fn drain_pty_writes(&self) -> Vec<(Vec<u8>, u32)> {
@@ -8942,6 +8952,178 @@ fn handle_reply_dispatches_next_queued_forward() {
);
}
fn clipboard_query() -> HostQuery {
HostQuery::ClipboardContent {
selection: 'c',
terminator: OscTerminator::St,
}
}
#[test]
fn clipboard_read_is_dropped_when_the_option_is_off() {
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
screen.forward_host_query(PaneId::Terminal(3), clipboard_query());
assert!(
capture.drain_forward_queries().is_empty(),
"an opted-out clipboard read must never reach the host terminal"
);
assert!(screen.clipboard_forward_in_flight_token.is_none());
assert!(screen.pending_clipboard_forwards.is_empty());
}
#[test]
fn clipboard_read_is_forwarded_asynchronously_when_enabled() {
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
screen.paste_buffer_read_enabled = true;
let pane_id = PaneId::Terminal(5);
let token = screen.forward_host_query(pane_id, clipboard_query());
let forwards = capture.drain_forward_queries_with_async();
assert_eq!(
forwards,
vec![(token, b"\x1b]52;c;?\x1b\\".to_vec(), true)],
"the query must go out marked as async so the client omits its barrier"
);
assert_eq!(screen.clipboard_forward_in_flight_token, Some(token));
assert_eq!(
screen
.pending_clipboard_forwards
.get(&token)
.map(|e| e.pane_id),
Some(pane_id)
);
assert!(
screen.forward_in_flight_token.is_none(),
"the barrier-serialized slot must stay free"
);
}
#[test]
fn a_pending_clipboard_read_does_not_block_other_host_queries() {
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
screen.paste_buffer_read_enabled = true;
let clipboard_token = screen.forward_host_query(PaneId::Terminal(1), clipboard_query());
let colour_token = screen.forward_host_query(PaneId::Terminal(2), bg_query());
let forwards = capture.drain_forward_queries();
assert_eq!(
forwards.len(),
2,
"a colour query must dispatch while a clipboard prompt is pending"
);
assert_eq!(forwards[0].0, clipboard_token);
assert_eq!(forwards[1].0, colour_token);
assert_eq!(screen.forward_in_flight_token, Some(colour_token));
assert_eq!(
screen.clipboard_forward_in_flight_token,
Some(clipboard_token)
);
}
#[test]
fn a_real_clipboard_reply_is_written_to_the_pane_verbatim() {
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
screen.paste_buffer_read_enabled = true;
let token = screen.forward_host_query(PaneId::Terminal(8), clipboard_query());
let _ = capture.drain_forward_queries();
let reply = b"\x1b]52;c;aGVsbG8=\x1b\\".to_vec();
screen
.handle_forwarded_reply_from_host(token, reply.clone())
.expect("handler must not fail");
assert_eq!(capture.drain_pty_writes(), vec![(reply, 8)]);
assert!(screen.clipboard_forward_in_flight_token.is_none());
assert!(screen.pending_clipboard_forwards.is_empty());
}
#[test]
fn an_unanswered_clipboard_read_resolves_as_an_empty_clipboard() {
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
screen.paste_buffer_read_enabled = true;
let token = screen.forward_host_query(PaneId::Terminal(9), clipboard_query());
let _ = capture.drain_forward_queries();
screen
.handle_forwarded_reply_from_host(token, Vec::new())
.expect("handler must not fail");
assert_eq!(
capture.drain_pty_writes(),
vec![(b"\x1b]52;c;\x1b\\".to_vec(), 9)]
);
}
#[test]
fn a_late_clipboard_reply_is_discarded() {
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
screen.paste_buffer_read_enabled = true;
let token = screen.forward_host_query(PaneId::Terminal(9), clipboard_query());
let _ = capture.drain_forward_queries();
screen
.handle_forwarded_reply_from_host(token, Vec::new())
.expect("ok");
let _ = capture.drain_pty_writes();
screen
.handle_forwarded_reply_from_host(token, b"\x1b]52;c;bGF0ZQ==\x1b\\".to_vec())
.expect("ok");
assert!(
capture.drain_pty_writes().is_empty(),
"clipboard data arriving after the window closed must never reach the pane"
);
}
#[test]
fn a_second_clipboard_read_waits_for_the_first() {
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
screen.paste_buffer_read_enabled = true;
let first_token = screen.forward_host_query(PaneId::Terminal(1), clipboard_query());
let second_token = screen.forward_host_query(PaneId::Terminal(2), clipboard_query());
let forwards = capture.drain_forward_queries();
assert_eq!(
forwards.len(),
1,
"a host can only show one consent prompt at a time"
);
assert_eq!(forwards[0].0, first_token);
assert_eq!(screen.clipboard_forward_queue.len(), 1);
assert_eq!(screen.clipboard_forward_queue[0].token, second_token);
}
#[test]
fn a_queued_clipboard_read_for_a_closed_pane_is_skipped() {
let size = Size { cols: 80, rows: 20 };
let (mut screen, capture) = create_new_screen_with_forward_capture(size);
screen.paste_buffer_read_enabled = true;
let first_token = screen.forward_host_query(PaneId::Terminal(1), clipboard_query());
screen.forward_host_query(PaneId::Terminal(2), clipboard_query());
let _ = capture.drain_forward_queries();
screen
.handle_forwarded_reply_from_host(first_token, Vec::new())
.expect("ok");
assert!(
capture.drain_forward_queries().is_empty(),
"the queued read belongs to a pane no tab owns"
);
assert!(screen.clipboard_forward_queue.is_empty());
assert!(screen.clipboard_forward_in_flight_token.is_none());
}
#[test]
fn clear_nested_guest_does_not_notify_guest_focus_lost_on_teardown() {
let size = Size { cols: 80, rows: 20 };
+8
View File
@@ -523,6 +523,14 @@ load_plugins {
//
// stacked_resize false
// Whether to let programs running inside panes read the paste buffer (clipboard)
// with the OSC 52 escape sequence. When enabled, any program in any pane -
// including one running on a remote machine over SSH - can read the clipboard
// without the user being asked.
// Default: false
//
// dangerously_enable_paste_buffer_read true
// Whether stacked panes display as a list with the expanded pane pinned to the bottom
// Default: true
//
@@ -2030,6 +2030,8 @@ pub struct Options {
pub osc133_command_selection: ::core::option::Option<bool>,
#[prost(string, optional, tag="57")]
pub word_separators: ::core::option::Option<::prost::alloc::string::String>,
#[prost(bool, optional, tag="58")]
pub dangerously_enable_paste_buffer_read: ::core::option::Option<bool>,
}
/// Pane-targeting action messages
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -3424,6 +3426,8 @@ pub struct ForwardQueryToHostMsg {
pub token: u32,
#[prost(bytes="vec", tag="2")]
pub query_bytes: ::prost::alloc::vec::Vec<u8>,
#[prost(bool, tag="3")]
pub resolve_async: bool,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -1211,6 +1211,7 @@ message Options {
optional bool support_kitty_graphics_protocol = 55;
optional bool osc133_command_selection = 56;
optional string word_separators = 57;
optional bool dangerously_enable_paste_buffer_read = 58;
}
enum OnForceClose {
@@ -96,6 +96,7 @@ message SubscribedPaneClosedMsg {
message ForwardQueryToHostMsg {
uint32 token = 1;
bytes query_bytes = 2;
bool resolve_async = 3;
}
message SetSoftKeyboardMsg {
+12
View File
@@ -400,6 +400,10 @@ pub struct Options {
#[clap(long, value_enum, hide_possible_values = true, value_parser)]
#[serde(default)]
pub nested_session_handling: Option<NestedSessionHandling>,
#[clap(long, value_parser)]
#[serde(default)]
pub dangerously_enable_paste_buffer_read: Option<bool>,
}
#[derive(ValueEnum, Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
@@ -521,6 +525,9 @@ impl Options {
let nested_session_handling = other
.nested_session_handling
.or(self.nested_session_handling);
let dangerously_enable_paste_buffer_read = other
.dangerously_enable_paste_buffer_read
.or(self.dangerously_enable_paste_buffer_read);
Options {
simplified_ui,
@@ -577,6 +584,7 @@ impl Options {
post_command_discovery_hook,
client_async_worker_tasks,
nested_session_handling,
dangerously_enable_paste_buffer_read,
}
}
@@ -678,6 +686,9 @@ impl Options {
let nested_session_handling = other
.nested_session_handling
.or(self.nested_session_handling);
let dangerously_enable_paste_buffer_read = other
.dangerously_enable_paste_buffer_read
.or(self.dangerously_enable_paste_buffer_read);
Options {
simplified_ui,
@@ -734,6 +745,7 @@ impl Options {
post_command_discovery_hook,
client_async_worker_tasks,
nested_session_handling,
dangerously_enable_paste_buffer_read,
}
}
+1
View File
@@ -291,6 +291,7 @@ pub enum ServerToClientMsg {
ForwardQueryToHost {
token: u32,
query_bytes: Vec<u8>,
resolve_async: bool,
},
EmitNestedSessionFrame {
payload_bytes: Vec<u8>,
+12 -6
View File
@@ -428,12 +428,15 @@ impl From<ServerToClientMsg> for ProtoServerToClientMsg {
pane_id: Some(pane_id.into()),
})
},
ServerToClientMsg::ForwardQueryToHost { token, query_bytes } => {
server_to_client_msg::Message::ForwardQueryToHost(ForwardQueryToHostMsg {
token,
query_bytes,
})
},
ServerToClientMsg::ForwardQueryToHost {
token,
query_bytes,
resolve_async,
} => server_to_client_msg::Message::ForwardQueryToHost(ForwardQueryToHostMsg {
token,
query_bytes,
resolve_async,
}),
ServerToClientMsg::SetSoftKeyboard { on } => {
server_to_client_msg::Message::SetSoftKeyboard(SetSoftKeyboardMsg { on })
},
@@ -674,6 +677,7 @@ impl TryFrom<ProtoServerToClientMsg> for ServerToClientMsg {
Ok(ServerToClientMsg::ForwardQueryToHost {
token: msg.token,
query_bytes: msg.query_bytes,
resolve_async: msg.resolve_async,
})
},
Some(server_to_client_msg::Message::SetSoftKeyboard(msg)) => {
@@ -921,6 +925,7 @@ impl From<crate::input::options::Options>
}),
stacked_resize: options.stacked_resize,
stacked_pane_list: options.stacked_pane_list,
dangerously_enable_paste_buffer_read: options.dangerously_enable_paste_buffer_read,
show_startup_tips: options.show_startup_tips,
show_release_notes: options.show_release_notes,
advanced_mouse_actions: options.advanced_mouse_actions,
@@ -1046,6 +1051,7 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options>
.transpose()?,
stacked_resize: options.stacked_resize,
stacked_pane_list: options.stacked_pane_list,
dangerously_enable_paste_buffer_read: options.dangerously_enable_paste_buffer_read,
show_startup_tips: options.show_startup_tips,
show_release_notes: options.show_release_notes,
advanced_mouse_actions: options.advanced_mouse_actions,
@@ -499,6 +499,7 @@ fn test_client_messages() {
osc133_command_selection: Some(false),
word_separators: Some("[]{}<>():".to_owned()),
nested_session_handling: Some(NestedSessionHandling::Fullscreen),
dangerously_enable_paste_buffer_read: Some(true),
}),
layout: None,
terminal_window_size: Size { rows: 80, cols: 42 },
@@ -3871,14 +3872,22 @@ fn test_server_messages() {
test_server_roundtrip!(ServerToClientMsg::ForwardQueryToHost {
token: 0,
query_bytes: vec![],
resolve_async: false,
});
test_server_roundtrip!(ServerToClientMsg::ForwardQueryToHost {
token: 7,
query_bytes: b"\x1b[14t".to_vec(),
resolve_async: false,
});
test_server_roundtrip!(ServerToClientMsg::ForwardQueryToHost {
token: u32::MAX,
query_bytes: (0u8..=255u8).collect(),
resolve_async: false,
});
test_server_roundtrip!(ServerToClientMsg::ForwardQueryToHost {
token: 12,
query_bytes: b"\x1b]52;c;?\x1b\\".to_vec(),
resolve_async: true,
});
test_server_roundtrip!(ServerToClientMsg::EmitNestedSessionFrame {
payload_bytes: vec![],
+40
View File
@@ -2934,6 +2934,11 @@ impl Options {
},
None => None,
};
let dangerously_enable_paste_buffer_read = kdl_property_first_arg_as_bool_or_error!(
kdl_options,
"dangerously_enable_paste_buffer_read"
)
.map(|(v, _)| v);
Ok(Options {
simplified_ui,
@@ -2990,6 +2995,7 @@ impl Options {
post_command_discovery_hook,
client_async_worker_tasks,
nested_session_handling,
dangerously_enable_paste_buffer_read,
})
}
pub fn from_string(stringified_keybindings: &String) -> Result<Self, ConfigError> {
@@ -4548,6 +4554,35 @@ impl Options {
None
}
}
fn dangerously_enable_paste_buffer_read_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
let comment_text = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
" ",
"// Whether to let programs running inside panes read the paste buffer",
"// (clipboard) with the OSC 52 escape sequence. When enabled, any program",
"// in any pane - including one running on a remote machine over SSH - can",
"// read the clipboard without the user being asked.",
"// Default: false",
);
let create_node = |node_value: bool| -> KdlNode {
let mut node = KdlNode::new("dangerously_enable_paste_buffer_read");
node.push(KdlValue::Bool(node_value));
node
};
if let Some(value) = self.dangerously_enable_paste_buffer_read {
let mut node = create_node(value);
if add_comments {
node.set_leading(format!("{}\n", comment_text));
}
Some(node)
} else if add_comments {
let mut node = create_node(false);
node.set_leading(format!("{}\n// ", comment_text));
Some(node)
} else {
None
}
}
fn client_async_worker_tasks_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
let comment_text = r#"
// Number of async worker tasks to spawn per active client.
@@ -4746,6 +4781,11 @@ impl Options {
{
nodes.push(client_async_worker_tasks);
}
if let Some(dangerously_enable_paste_buffer_read) =
self.dangerously_enable_paste_buffer_read_to_kdl(add_comments)
{
nodes.push(dangerously_enable_paste_buffer_read);
}
if let Some(nested_session_handling) = self.nested_session_handling_to_kdl(add_comments) {
nodes.push(nested_session_handling);
}
@@ -619,6 +619,13 @@ web_client {
// Note: This only applies to web clients at the moment.
// client_async_worker_tasks 4
// Whether to let programs running inside panes read the paste buffer
// (clipboard) with the OSC 52 escape sequence. When enabled, any program
// in any pane - including one running on a remote machine over SSH - can
// read the clipboard without the user being asked.
// Default: false
// dangerously_enable_paste_buffer_read false
// How to handle a nested Zellij session detected inside a pane.
// Options:
// - "ask" (Default — prompt with a modal)
@@ -334,6 +334,13 @@ web_sharing "disabled"
// Note: This only applies to web clients at the moment.
// client_async_worker_tasks 4
// Whether to let programs running inside panes read the paste buffer
// (clipboard) with the OSC 52 escape sequence. When enabled, any program
// in any pane - including one running on a remote machine over SSH - can
// read the clipboard without the user being asked.
// Default: false
// dangerously_enable_paste_buffer_read false
// How to handle a nested Zellij session detected inside a pane.
// Options:
// - "ask" (Default — prompt with a modal)
@@ -59,4 +59,5 @@ Options {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
}
@@ -59,4 +59,5 @@ Options {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
}
@@ -57,4 +57,5 @@ Options {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
}
@@ -6082,6 +6082,7 @@ Config {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
},
themes: {},
plugins: PluginAliases {
@@ -6082,6 +6082,7 @@ Config {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
},
themes: {},
plugins: PluginAliases {
@@ -144,6 +144,7 @@ Config {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
},
themes: {},
plugins: PluginAliases {
@@ -59,4 +59,5 @@ Options {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
}
@@ -6082,6 +6082,7 @@ Config {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
},
themes: {
"other-theme-from-config": Theme {
@@ -6082,6 +6082,7 @@ Config {
post_command_discovery_hook: None,
client_async_worker_tasks: None,
nested_session_handling: None,
dangerously_enable_paste_buffer_read: None,
},
themes: {},
plugins: PluginAliases {