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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions assets/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions doc/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,19 @@ 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` |
| logging | log_dir | string | `""` (→ `~/.mmterm`) |
| 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.
Expand Down
25 changes: 18 additions & 7 deletions src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub config: Config,
pub config_panel: Option<ConfigPanel>,
pub clipboard: Option<Clipboard>,
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions src/config/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)]
Expand Down
13 changes: 13 additions & 0 deletions src/config/tui_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -624,6 +633,9 @@ impl ConfigPanel {
let detect_urls = get(F_DETECT_URLS)
.parse::<bool>()
.map_err(|_| "Invalid detect_urls — use true or false")?;
let paste_confirm_lines = get(F_PASTE_CONFIRM)
.parse::<usize>()
.map_err(|_| "Invalid paste_confirm_lines")?;
let shell = {
let s = get(F_SHELL);
if s.is_empty() { None } else { Some(s) }
Expand Down Expand Up @@ -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 },
Expand Down
20 changes: 11 additions & 9 deletions src/config/tui_config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
30 changes: 25 additions & 5 deletions src/input_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading