From 0815bc692f52746f49200edd9988778fd1e77b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Thu, 16 Jul 2026 18:12:23 -0400 Subject: [PATCH] feat(config,input): confirm multi-line clipboard pastes Add window.paste_confirm_lines (default 0 = off). When set to N > 0, pasting clipboard text whose newline count is >= N first shows a modal confirmation overlay instead of writing to the PTY. Both the keyboard paste and middle-click paste paths route through a shared paste_text helper; the raw text is stashed and re-encoded on confirm so the pane's bracketed-paste state is read at send time. --- CHANGELOG.md | 1 + README.md | 1 + assets/config.toml | 2 + doc/SPEC.md | 6 ++ src/app_event.rs | 25 +++++--- src/app_state.rs | 3 + src/config/config_test.rs | 37 +++++++++++ src/config/mod.rs | 5 ++ src/config/tui_config.rs | 13 ++++ src/config/tui_config_test.rs | 20 +++--- src/input_ops.rs | 30 +++++++-- src/input_ops_test.rs | 113 ++++++++++++++++++++++++++++++++++ src/renderer/overlays.rs | 24 ++++++++ src/renderer/overlays_test.rs | 18 ++++++ src/renderer/render_ops.rs | 4 ++ 15 files changed, 281 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 048f545..e083e3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - add `--maximized` and `--fullscreen` flags to start the window in that mode +- add `window.paste_confirm_lines` to confirm before pasting multi-line clipboard content ### Changed - input mode (normal/insert/visual/search) is now tracked per tab; switching tabs restores each tab's own mode diff --git a/README.md b/README.md index c55a473..f0abb6e 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ width = 800 height = 600 title = "mmterm" cursor_blink_ms = 500 +paste_confirm_lines = 0 # confirm before pasting clipboard text with N+ lines (0 = off) [shell] # program = "/bin/zsh" # defaults to $SHELL diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..7fa5fdb 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -11,6 +11,8 @@ title = "mmterm" cursor_blink_ms = 500 inactive_dim = 0.55 detect_urls = true +# Confirm before pasting clipboard content with this many or more lines (0 = off) +paste_confirm_lines = 0 [shell] # program = "/bin/zsh" diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..051aa61 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -221,6 +221,7 @@ Screenshot capture is a two-step flow: region selection followed by a name promp | window | cursor_blink_ms | uint | `500` | | window | inactive_dim | float | `0.55` | | window | detect_urls | bool | `true` | +| window | paste_confirm_lines | uint | `0` | | terminal | scrollback_lines | uint | `10000` (min 100) | | shell | program | string? | `$SHELL` | | logging | auto_log | bool | `false` | @@ -228,6 +229,11 @@ Screenshot capture is a two-step flow: region selection followed by a name promp | status_bar | right | string | `""` | | theme | name | string | `"default"` | +`window.paste_confirm_lines` guards against accidentally executing pasted +multi-line content: when set to `N > 0`, pasting clipboard text whose newline +count is `>= N` first shows a confirmation overlay (`[y]` paste, `[n]`/`Esc` +cancel). The default `0` disables the check, so pastes go through unchanged. + ### Themes Themes define all terminal and UI colors in a single `.toml` file. diff --git a/src/app_event.rs b/src/app_event.rs index cf483e7..7e2f7b6 100644 --- a/src/app_event.rs +++ b/src/app_event.rs @@ -541,6 +541,22 @@ impl App { } return true; } + if let Some(text) = self.state.pending_paste.take() { + let confirmed = matches!( + event.logical_key, + Key::Character(ref s) if s.eq_ignore_ascii_case("y") + ); + if confirmed { + let active = self.tab().active; + if let Some(entry) = self.tab_mut().panes.get_mut(&active) { + let bracketed = entry.pane.grid_read().is_some_and(|g| g.bracketed_paste); + let data = crate::input_ops::bracketed_paste_encode(&text, bracketed); + let _ = entry.pty.write_input(&data); + } + } + self.request_redraw(); + return true; + } if self.state.config_panel.is_some() { self.handle_config_key(event); self.request_redraw(); @@ -751,13 +767,8 @@ impl App { .and_then(|cb| cb.get_text().ok()) .or_else(|| Clipboard::new().ok()?.get_text().ok()); if let Some(text) = text { - let active = self.tab().active; - if let Some(entry) = self.tab_mut().panes.get_mut(&active) { - let mut data = b"\x1b[200~".to_vec(); - data.extend_from_slice(text.as_bytes()); - data.extend_from_slice(b"\x1b[201~"); - let _ = entry.pty.write_input(&data); - } + // Middle-click paste always wraps in bracketed-paste markers. + self.paste_text(text, true); } } diff --git a/src/app_state.rs b/src/app_state.rs index 84caecf..87f1133 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -85,6 +85,8 @@ pub struct AppState { pub blink_last: Instant, pub ctrl_w_pending: bool, pub quit_pending: bool, + /// Raw clipboard text awaiting a multi-line paste confirmation. + pub pending_paste: Option, pub config: Config, pub config_panel: Option, pub clipboard: Option, @@ -120,6 +122,7 @@ impl AppState { blink_last: Instant::now(), ctrl_w_pending: false, quit_pending: false, + pending_paste: None, config_panel: None, clipboard: Clipboard::new().ok(), mouse_pos: None, diff --git a/src/config/config_test.rs b/src/config/config_test.rs index 4bd6877..1a28cea 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -157,6 +157,43 @@ palette = [] assert!(cfg.window.detect_urls); } +#[test] +fn default_paste_confirm_lines_value() { + assert_eq!(default_paste_confirm_lines(), 0); +} + +#[test] +fn paste_confirm_lines_default_applied_when_missing() { + let toml = r###" +[font] +family = "Mono" +size = 14.0 +[window] +width = 800 +height = 600 +title = "t" +cursor_blink_ms = 500 +[shell] +[colors] +background = "#000000" +foreground = "#ffffff" +cursor = "#ffffff" +selection = "#333333" +palette = [] +"###; + let cfg: Config = toml::from_str(toml).expect("parse failed"); + assert_eq!(cfg.window.paste_confirm_lines, 0); +} + +#[test] +fn paste_confirm_lines_round_trips_through_toml() { + let mut cfg = Config::default(); + cfg.window.paste_confirm_lines = 4; + let s = toml::to_string(&cfg).expect("serialize failed"); + let back: Config = toml::from_str(&s).expect("parse failed"); + assert_eq!(back.window.paste_confirm_lines, 4); +} + #[test] fn save_does_not_panic() { Config::default().save(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 571c12a..aacea9a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -147,6 +147,9 @@ fn default_inactive_dim() -> f32 { fn default_detect_urls() -> bool { true } +fn default_paste_confirm_lines() -> usize { + 0 +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WindowConfig { @@ -158,6 +161,8 @@ pub struct WindowConfig { pub inactive_dim: f32, #[serde(default = "default_detect_urls")] pub detect_urls: bool, + #[serde(default = "default_paste_confirm_lines")] + pub paste_confirm_lines: usize, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..c504cd3 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -33,6 +33,7 @@ const F_AUTO_UPDATE_CHECK: usize = 37; const F_AUTO_UPDATE_INSTALL: usize = 38; const F_SHELL_INTEGRATION: usize = 39; const F_DESKTOP_NOTIFICATIONS: usize = 40; +const F_PASTE_CONFIRM: usize = 41; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -302,6 +303,14 @@ impl ConfigPanel { section: None, }); + fields.push(Field { + label: "Paste Confirm Lines", + hint: "confirm pasting N+ lines (0 = off)", + value: cfg.window.paste_confirm_lines.to_string(), + kind: FieldKind::UInt, + section: None, + }); + let mut collapsed = HashSet::new(); collapsed.insert("Palette"); @@ -624,6 +633,9 @@ impl ConfigPanel { let detect_urls = get(F_DETECT_URLS) .parse::() .map_err(|_| "Invalid detect_urls — use true or false")?; + let paste_confirm_lines = get(F_PASTE_CONFIRM) + .parse::() + .map_err(|_| "Invalid paste_confirm_lines")?; let shell = { let s = get(F_SHELL); if s.is_empty() { None } else { Some(s) } @@ -687,6 +699,7 @@ impl ConfigPanel { cursor_blink_ms: blink_ms, inactive_dim, detect_urls, + paste_confirm_lines, }, shell: ShellConfig { program: shell }, terminal: TerminalConfig { scrollback_lines }, diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index 5f8381b..bc236d4 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -10,8 +10,8 @@ fn make_panel() -> ConfigPanel { #[test] fn from_config_has_correct_field_count() { let panel = make_panel(); - // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify = 41 - assert_eq!(panel.fields.len(), 41); + // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify + 1 paste-confirm = 42 + assert_eq!(panel.fields.len(), 42); } #[test] @@ -305,6 +305,7 @@ fn distinct_config() -> Config { cursor_blink_ms: 523, inactive_dim: 0.42, detect_urls: true, + paste_confirm_lines: 7, }, shell: ShellConfig { program: Some("/bin/xyzsh".into()), @@ -375,6 +376,7 @@ fn field_index_sanity() { F_AUTO_UPDATE_INSTALL, F_SHELL_INTEGRATION, F_DESKTOP_NOTIFICATIONS, + F_PASTE_CONFIRM, ]; occupied.extend((0..16).map(|i| F_PALETTE + i)); occupied.sort_unstable(); @@ -680,8 +682,8 @@ fn palette_collapsed_by_default() { #[test] fn visible_indices_hides_palette_body() { let panel = make_panel(); - // 41 total - 15 palette body fields = 26 visible - assert_eq!(panel.visible_indices().len(), 26); + // 42 total - 15 palette body fields = 27 visible + assert_eq!(panel.visible_indices().len(), 27); } #[test] @@ -690,7 +692,7 @@ fn toggle_on_palette_header_expands() { panel.selected = F_PALETTE; panel.toggle_collapse(); assert!(!panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 41); + assert_eq!(panel.visible_indices().len(), 42); } #[test] @@ -700,7 +702,7 @@ fn toggle_twice_restores_collapsed() { panel.toggle_collapse(); panel.toggle_collapse(); assert!(panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 26); + assert_eq!(panel.visible_indices().len(), 27); } #[test] @@ -755,10 +757,10 @@ fn move_up_skips_collapsed_palette() { #[test] fn move_down_at_last_visible_clamps() { let mut panel = make_panel(); - // F_DESKTOP_NOTIFICATIONS is the last field and is always visible - panel.selected = F_DESKTOP_NOTIFICATIONS; + // F_PASTE_CONFIRM is the last field and is always visible + panel.selected = F_PASTE_CONFIRM; panel.handle_down(); - assert_eq!(panel.selected, F_DESKTOP_NOTIFICATIONS); + assert_eq!(panel.selected, F_PASTE_CONFIRM); } #[test] diff --git a/src/input_ops.rs b/src/input_ops.rs index 1c9791a..ee81f44 100644 --- a/src/input_ops.rs +++ b/src/input_ops.rs @@ -171,16 +171,36 @@ impl App { .or_else(|| Clipboard::new().ok()?.get_text().ok()); if let Some(text) = text { let active = self.tab().active; - if let Some(entry) = self.tab_mut().panes.get_mut(&active) { - let bracketed = entry.pane.grid_read().is_some_and(|g| g.bracketed_paste); - let data = bracketed_paste_encode(&text, bracketed); - let _ = entry.pty.write_input(&data); - } + let bracketed = self + .tab() + .panes + .get(&active) + .is_some_and(|e| e.pane.grid_read().is_some_and(|g| g.bracketed_paste)); + self.paste_text(text, bracketed); } else { log::warn!("Clipboard read failed"); } } + /// Send clipboard text to the active pane, first gating multi-line pastes + /// behind a confirmation overlay when `window.paste_confirm_lines` is set. + /// + /// The raw text is stashed in `pending_paste` and re-encoded at confirm + /// time so the pane's bracketed-paste state is read when the bytes are sent. + pub(crate) fn paste_text(&mut self, text: String, bracketed: bool) { + let threshold = self.state.config.window.paste_confirm_lines; + if threshold > 0 && text.matches('\n').count() >= threshold { + self.state.pending_paste = Some(text); + self.request_redraw(); + return; + } + let active = self.tab().active; + if let Some(entry) = self.tab_mut().panes.get_mut(&active) { + let data = bracketed_paste_encode(&text, bracketed); + let _ = entry.pty.write_input(&data); + } + } + pub(crate) fn do_toggle_fullscreen(&mut self) { if let Some(w) = &self.window { let fs = if w.fullscreen().is_some() { diff --git a/src/input_ops_test.rs b/src/input_ops_test.rs index 4ef5a77..047b3a1 100644 --- a/src/input_ops_test.rs +++ b/src/input_ops_test.rs @@ -1,8 +1,121 @@ +use std::collections::HashMap; + +use super::App; use super::bracketed_paste_encode; +use crate::app_state::{AppState, TabState}; +use crate::config::Config; +use crate::dpi::Logical; +use crate::renderer::FontMetrics; +use crate::ui::layout::Layout; const PASTE_START: &[u8] = b"\x1b[200~"; const PASTE_END: &[u8] = b"\x1b[201~"; +/// Builds an EventLoop usable from a test thread; None when no display exists. +/// Mirrors `pane_ops_test.rs`. +fn make_event_loop() -> Option> { + #[cfg(target_os = "linux")] + { + use winit::event_loop::EventLoopBuilder; + use winit::platform::x11::EventLoopBuilderExtX11; + EventLoopBuilder::new().with_any_thread(true).build().ok() + } + #[cfg(not(target_os = "linux"))] + { + winit::event_loop::EventLoop::new().ok() + } +} + +fn make_app(cfg: Config) -> Option { + let el = make_event_loop()?; + let proxy = el.create_proxy(); + std::mem::forget(el); + Some(App::new(cfg, proxy, None, None)) +} + +fn metrics() -> FontMetrics { + FontMetrics { + font_px: 16.0, + cell_width: 8, + cell_height: 16, + baseline: 13, + } +} + +/// Pushes a one-pane tab (id 1) with a real PTY so the immediate-send path has a +/// valid active pane to write to. +fn seed_one_pane_tab(app: &mut App) { + let mut tab = TabState { + panes: HashMap::new(), + layout: Layout::new(1, 800, 600), + active: 1, + name: None, + zoomed: false, + has_activity: false, + bell_flash_start: None, + bell_flash_until: None, + bell_cooldown_until: None, + passthrough: false, + mode: crate::input::InputMode::Insert, + }; + tab.panes + .insert(1, AppState::test_pane_entry(Logical(16.0), metrics())); + app.state.tabs.push(tab); + app.state.active_tab = app.state.tabs.len() - 1; + app.state.next_pane_id = 2; +} + +#[test] +fn paste_text_gates_multiline_paste_when_threshold_met() { + let mut cfg = Config::default(); + cfg.window.paste_confirm_lines = 2; + let Some(mut app) = make_app(cfg) else { + return; + }; + // 3 lines => 2 newlines >= threshold 2: must stash, not send. + app.paste_text("a\nb\nc".to_string(), true); + assert_eq!(app.state.pending_paste.as_deref(), Some("a\nb\nc")); +} + +#[test] +fn paste_text_disabled_by_default_sends_immediately() { + // Default threshold is 0 (disabled): even a multi-line paste sends directly. + let Some(mut app) = make_app(Config::default()) else { + return; + }; + seed_one_pane_tab(&mut app); + app.paste_text("a\nb\nc".to_string(), true); + assert!(app.state.pending_paste.is_none()); +} + +#[test] +fn paste_text_below_threshold_sends_immediately() { + let mut cfg = Config::default(); + cfg.window.paste_confirm_lines = 5; + let Some(mut app) = make_app(cfg) else { + return; + }; + seed_one_pane_tab(&mut app); + // 2 newlines < threshold 5: sends without confirmation. + app.paste_text("a\nb\nc".to_string(), false); + assert!(app.state.pending_paste.is_none()); +} + +#[test] +fn pending_paste_is_cleared_when_taken() { + let mut cfg = Config::default(); + cfg.window.paste_confirm_lines = 2; + let Some(mut app) = make_app(cfg) else { + return; + }; + app.paste_text("x\ny\nz".to_string(), true); + assert!(app.state.pending_paste.is_some()); + // The overlay confirm/cancel branch takes the text, leaving None. + let taken = app.state.pending_paste.take(); + assert_eq!(taken.as_deref(), Some("x\ny\nz")); + assert!(app.state.pending_paste.is_none()); +} + #[test] fn bracketed_paste_wraps_text_in_markers() { let out = bracketed_paste_encode("hi", true); diff --git a/src/renderer/overlays.rs b/src/renderer/overlays.rs index 659d1d0..e94b50a 100644 --- a/src/renderer/overlays.rs +++ b/src/renderer/overlays.rs @@ -816,6 +816,30 @@ impl Renderer { ); } + pub fn draw_paste_confirm( + &mut self, + buf: &mut [u32], + bw: u32, + bh: u32, + lines: usize, + theme: &ResolvedTheme, + ) { + dim_buffer(buf); + let prompt = format!("Paste {lines} lines?"); + let controls = "[y] Paste [n/Esc] Cancel".to_string(); + let text_lines = [prompt.as_str(), controls.as_str()]; + let fg = [color_u32(theme.foreground), color_u32(theme.palette[8])]; + self.draw_confirm_dialog( + buf, + bw, + bh, + &text_lines, + &fg, + color_u32(theme.background), + color_u32(theme.palette[3]), + ); + } + pub fn draw_save_session_confirm( &mut self, buf: &mut [u32], diff --git a/src/renderer/overlays_test.rs b/src/renderer/overlays_test.rs index 1a7782d..166681c 100644 --- a/src/renderer/overlays_test.rs +++ b/src/renderer/overlays_test.rs @@ -279,6 +279,24 @@ fn draw_screenshot_selector_zero_size_does_not_panic() { r.draw_screenshot_selector(&mut buf, 800, 600, 400, 300, 0, 0); } +#[test] +fn draw_paste_confirm_does_not_panic() { + let mut r = make_renderer(); + let mut buf = vec![0u32; 800 * 600]; + let theme = crate::theme::default_theme(); + r.draw_paste_confirm(&mut buf, 800, 600, 12, &theme); + assert!(buf.iter().any(|&p| p != 0)); +} + +#[test] +fn draw_paste_confirm_dims_background() { + let mut r = make_renderer(); + let theme = crate::theme::default_theme(); + let mut buf = vec![0xff_80_80_80u32; 800 * 600]; + r.draw_paste_confirm(&mut buf, 800, 600, 3, &theme); + assert!(buf.iter().any(|&p| ((p >> 16) & 0xFF) < 0x80)); +} + #[test] fn draw_save_session_confirm_does_not_panic() { let mut r = make_renderer(); diff --git a/src/renderer/render_ops.rs b/src/renderer/render_ops.rs index 3ea50ee..29d8306 100644 --- a/src/renderer/render_ops.rs +++ b/src/renderer/render_ops.rs @@ -51,6 +51,10 @@ pub(super) fn draw_overlays( if state.quit_pending { renderer.draw_quit_confirm(pixels, w, h, &state.theme); } + if let Some(text) = &state.pending_paste { + let lines = text.matches('\n').count() + 1; + renderer.draw_paste_confirm(pixels, w, h, lines, &state.theme); + } if matches!(state.mode(), InputMode::QuitSave) { renderer.draw_save_session_confirm(pixels, w, h, &state.theme); }