diff --git a/crates/noa-app/src/app/config.rs b/crates/noa-app/src/app/config.rs index 2782ca8..c0d53bb 100644 --- a/crates/noa-app/src/app/config.rs +++ b/crates/noa-app/src/app/config.rs @@ -166,6 +166,9 @@ pub struct AppConfig { /// `auto-approve`: seed new tabs with agent-CLI auto approval enabled. /// Runtime toggles are still per-tab. pub auto_approve: bool, + /// `send-selection-send-enter`: follow the send-selection picker's paste + /// with an Enter write so the pasted text is submitted immediately. + pub send_selection_send_enter: bool, /// Raw `keybind = ...` entries from config. Parsed into the runtime /// [`crate::commands::KeybindEngine`] by `App::new` and live reload. pub keybinds: Vec, @@ -247,6 +250,7 @@ impl AppConfig { audible_bell_when_unfocused: config.audible_bell_when_unfocused, audible_bell_dock_bounce: config.audible_bell_dock_bounce, auto_approve: config.auto_approve, + send_selection_send_enter: config.send_selection_send_enter, keybinds: config.keybinds, server_enable: config.server_enable, server_port: config.server_port, diff --git a/crates/noa-app/src/app/input_ops/clipboard_confirm.rs b/crates/noa-app/src/app/input_ops/clipboard_confirm.rs index aa955b1..51f5da4 100644 --- a/crates/noa-app/src/app/input_ops/clipboard_confirm.rs +++ b/crates/noa-app/src/app/input_ops/clipboard_confirm.rs @@ -61,7 +61,7 @@ impl App { pane_id: PaneId, text: String, ) { - self.paste_text_to_pane_with_confirm_window(window_id, window_id, pane_id, text); + self.paste_text_to_pane_with_confirm_window(window_id, window_id, pane_id, text, false); } pub(in crate::app) fn paste_text_to_pane_with_confirm_window( @@ -70,6 +70,7 @@ impl App { window_id: WindowId, pane_id: PaneId, text: String, + then_enter: bool, ) { let bracketed_paste = self.bracketed_paste(window_id, pane_id); // Paste protection: confirm before sending content that could run a @@ -87,11 +88,12 @@ impl App { window_id, pane_id, text, + then_enter, }, ); return; } - self.write_paste_text_to_pane(window_id, pane_id, &text); + self.write_paste_text_to_pane(window_id, pane_id, &text, then_enter); } pub(in crate::app) fn write_paste_text_to_pane( @@ -99,15 +101,42 @@ impl App { window_id: WindowId, pane_id: PaneId, text: &str, + then_enter: bool, ) { let bracketed_paste = self.bracketed_paste(window_id, pane_id); - if let Some(bytes) = input::encode_paste(text, bracketed_paste) { + if let Some(mut bytes) = input::encode_paste(text, bracketed_paste) { + // The trailing Enter is appended after the paste encoding — outside + // the bracketed-paste wrapper, where a newline is inert data — so + // the pair travels in one queue element and is admitted or dropped + // atomically. Written separately, a backpressured input queue could + // drop the large paste yet admit the tiny Enter, executing whatever + // already sits on the prompt line. It is encoded under the target + // pane's Kitty flags: a report-all-keys client reads Enter as + // `CSI 13 u`, not a legacy CR. Skipped entirely when nothing was + // pasted, for the same run-the-stale-prompt-line reason. + if then_enter { + bytes.extend_from_slice(&input::encode_enter_key( + self.pane_kitty_keyboard_flags(window_id, pane_id), + )); + } self.mark_pane_paste_input(window_id, pane_id); self.snap_pane_viewport_to_bottom(window_id, pane_id); self.write_pane_pty_bytes(window_id, pane_id, bytes); } } + /// The target pane's active Kitty keyboard progressive-enhancement flags + /// (`0` when the pane is gone). Per-pane counterpart of the focused-surface + /// `App::kitty_keyboard_flags` — the send-selection target need not be the + /// focused pane. + fn pane_kitty_keyboard_flags(&self, window_id: WindowId, pane_id: PaneId) -> u8 { + self.windows + .get(&window_id) + .and_then(|state| state.surfaces.get(&pane_id)) + .map(|surface| surface.terminal.lock().kitty_keyboard_flags()) + .unwrap_or(0) + } + pub(in crate::app) fn bracketed_paste(&self, window_id: WindowId, pane_id: PaneId) -> bool { self.windows .get(&window_id) @@ -207,7 +236,8 @@ impl App { window_id, pane_id, text, - } => self.write_paste_text_to_pane(window_id, pane_id, &text), + then_enter, + } => self.write_paste_text_to_pane(window_id, pane_id, &text, then_enter), ConfirmAction::ClipboardRead { window_id, pane_id, diff --git a/crates/noa-app/src/app/input_ops/overlays.rs b/crates/noa-app/src/app/input_ops/overlays.rs index 87e809f..62fd5b2 100644 --- a/crates/noa-app/src/app/input_ops/overlays.rs +++ b/crates/noa-app/src/app/input_ops/overlays.rs @@ -233,6 +233,7 @@ impl App { target.window_id, target.pane_id, session.selected_text, + self.config.send_selection_send_enter, ); } diff --git a/crates/noa-app/src/app/input_ops/theme_settings.rs b/crates/noa-app/src/app/input_ops/theme_settings.rs index a7b5dd2..990c2bd 100644 --- a/crates/noa-app/src/app/input_ops/theme_settings.rs +++ b/crates/noa-app/src/app/input_ops/theme_settings.rs @@ -219,6 +219,7 @@ impl App { sidebar_font_size: self.config.sidebar_font_size, quick_terminal_size: quick_terminal_height_fraction(self.config.quick_terminal_size), confirm_quit: self.config.confirm_quit, + send_selection_send_enter: self.config.send_selection_send_enter, font_family, available_font_families, scrollback_limit: self.config.scrollback_limit, @@ -966,9 +967,9 @@ impl App { /// Mirror the just-committed runtime rows (font-size, background-opacity, /// background-blur-radius, background-image settings, cursor-style, - /// sidebar-preview-lines, quick-terminal-size, confirm-quit) into - /// `self.config` so a future reopen of the overlay, or the next quick- - /// terminal toggle, shows the new value. + /// sidebar-preview-lines, quick-terminal-size, confirm-quit, + /// send-selection-send-enter) into `self.config` so a future reopen of + /// the overlay, or the next quick-terminal toggle, shows the new value. /// The restart-only rows are deliberately excluded: nothing on screen /// actually changes for them until a restart, so leaving `self.config` at /// its pre-commit value keeps it truthful to what the user still sees, even @@ -1040,6 +1041,9 @@ impl App { RowDraft::ConfirmQuit(v) => { self.config.confirm_quit = *v; } + RowDraft::SendSelectionSendEnter(v) => { + self.config.send_selection_send_enter = *v; + } // Commit-only rows: intentionally not mirrored (see the doc // comment above). RowDraft::FontFamily(_) @@ -1224,9 +1228,10 @@ fn resolve_current_theme(config: &AppConfig, appearance: winit::window::Theme) - } /// TSV2-1 (judge, CONFIRMED): [`sync_config_from_committed_live_rows`] -/// mirrors `confirm-quit` and (via [`sync_quick_terminal_size_from_committed_rows`]) +/// mirrors `confirm-quit`, `send-selection-send-enter`, and (via +/// [`sync_quick_terminal_size_from_committed_rows`]) /// `quick-terminal-size` into `self.config` on a successful commit, because -/// both are read back out of `self.config` at runtime rather than applied +/// all are read back out of `self.config` at runtime rather than applied /// live like the R-8 `is_live` rows — an Undo that only rewrote the config /// *file* left the running session's `self.config` still holding the /// committed (unwanted) value. Standalone (rather than inlined into @@ -1244,6 +1249,7 @@ fn sync_reverted_confirm_quit_and_quick_terminal_size( revert: &crate::theme_settings::RevertValues, ) { config.confirm_quit = revert.confirm_quit; + config.send_selection_send_enter = revert.send_selection_send_enter; config.quick_terminal_size = quick_terminal_size_from_height_fraction(revert.quick_terminal_size); } @@ -1470,6 +1476,7 @@ mod commit_theme_settings_tests { sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, confirm_quit: true, + send_selection_send_enter: false, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, @@ -1523,8 +1530,10 @@ mod commit_theme_settings_tests { noa_config::ConfigOverrides::default(), ); // Simulate the post-commit state a real session would have left - // `self.config` in: confirm-quit flipped off, quick-terminal grown. + // `self.config` in: confirm-quit flipped off, send-selection Enter + // flipped on, quick-terminal grown. config.confirm_quit = false; + config.send_selection_send_enter = true; config.quick_terminal_size = quick_terminal_size_from_height_fraction(0.9); let revert = crate::theme_settings::RevertValues { @@ -1547,6 +1556,7 @@ mod commit_theme_settings_tests { window_padding_y: 2.0, macos_titlebar_style: noa_config::MacosTitlebarStyle::Native, confirm_quit: true, + send_selection_send_enter: false, font_family: "Menlo".to_string(), }; sync_reverted_confirm_quit_and_quick_terminal_size(&mut config, &revert); @@ -1555,6 +1565,10 @@ mod commit_theme_settings_tests { config.confirm_quit, "confirm-quit must revert to the pre-open value" ); + assert!( + !config.send_selection_send_enter, + "send-selection-send-enter must revert to the pre-open value" + ); assert!( (quick_terminal_height_fraction(config.quick_terminal_size) - 0.4).abs() < 0.001, "quick-terminal-size must revert to the pre-open value" @@ -1605,6 +1619,7 @@ mod commit_theme_settings_tests { sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, confirm_quit: true, + send_selection_send_enter: false, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, diff --git a/crates/noa-app/src/app/state.rs b/crates/noa-app/src/app/state.rs index 49d4859..b380961 100644 --- a/crates/noa-app/src/app/state.rs +++ b/crates/noa-app/src/app/state.rs @@ -654,6 +654,7 @@ mod theme_settings_session_tests { sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, confirm_quit: true, + send_selection_send_enter: false, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, @@ -857,6 +858,10 @@ pub(super) enum ConfirmAction { window_id: WindowId, pane_id: PaneId, text: String, + /// Follow the paste with an Enter (`\r`) write — the send-selection + /// picker's `send-selection-send-enter` behavior, deferred with the + /// paste itself. + then_enter: bool, }, /// Fulfill an OSC 52 clipboard read: read the clipboard now and write the /// base64 reply to the pane's pty. diff --git a/crates/noa-app/src/cli.rs b/crates/noa-app/src/cli.rs index 13b92ea..1f0454f 100644 --- a/crates/noa-app/src/cli.rs +++ b/crates/noa-app/src/cli.rs @@ -457,6 +457,11 @@ fn show_config_output(config: &StartupConfig) -> String { &config.audible_bell_dock_bounce.to_string(), ); push_line(&mut out, "auto-approve", &config.auto_approve.to_string()); + push_line( + &mut out, + "send-selection-send-enter", + &config.send_selection_send_enter.to_string(), + ); push_repeatable_lines( &mut out, "keybind", @@ -716,6 +721,7 @@ mod tests { assert!(output.contains("audible-bell-when-unfocused = false\n")); assert!(output.contains("audible-bell-dock-bounce = false\n")); assert!(output.contains("auto-approve = false\n")); + assert!(output.contains("send-selection-send-enter = false\n")); assert!( output.lines().all(|line| line.contains(" = ")), "every line must be `key = value`" @@ -751,6 +757,7 @@ mod tests { audible_bell_when_unfocused: true, audible_bell_dock_bounce: true, auto_approve: true, + send_selection_send_enter: true, font: noa_config::FontConfig { families: vec!["JetBrains Mono".to_string(), "Menlo".to_string()], features: vec![noa_config::FontFeature { @@ -791,6 +798,7 @@ mod tests { assert!(output.contains("audible-bell-when-unfocused = true\n")); assert!(output.contains("audible-bell-dock-bounce = true\n")); assert!(output.contains("auto-approve = true\n")); + assert!(output.contains("send-selection-send-enter = true\n")); assert!(output.contains("font-family = JetBrains Mono\n")); assert!(output.contains("font-family = Menlo\n")); assert!(output.contains("font-feature = -liga\n")); diff --git a/crates/noa-app/src/input.rs b/crates/noa-app/src/input.rs index 932f6da..3578f4d 100644 --- a/crates/noa-app/src/input.rs +++ b/crates/noa-app/src/input.rs @@ -14,7 +14,7 @@ mod text; pub(crate) use ime::ImeState; #[cfg(test)] use key::encode_key; -pub(crate) use key::encode_key_with_modes; +pub(crate) use key::{encode_enter_key, encode_key_with_modes}; pub use paste::encode_paste; pub(crate) use paste::{applescript_input_bytes, paste_is_unsafe, raw_input_bytes}; #[cfg(test)] diff --git a/crates/noa-app/src/input/key.rs b/crates/noa-app/src/input/key.rs index e5db2b3..75e75b7 100644 --- a/crates/noa-app/src/input/key.rs +++ b/crates/noa-app/src/input/key.rs @@ -150,6 +150,27 @@ pub fn encode_key_with_modes( } } +/// Bytes an unmodified Enter press writes to the pty under the given Kitty +/// keyboard flags: `CSI 13 u` once report-all-keys is in effect, legacy CR +/// otherwise. Delegates to [`encode_key_with_modes`] so a synthesized Enter +/// (the send-selection trailing Enter) can never diverge from a typed one. +pub(crate) fn encode_enter_key(kitty_flags: u8) -> Vec { + encode_key_with_modes( + &Key::Named(NamedKey::Enter), + None, + None, + None, + ModifiersState::empty(), + true, + false, + false, + kitty_flags, + true, + false, + ) + .expect("an unmodified Enter press always encodes") +} + /// The C0 byte for Ctrl+`c` under the legacy encoding: letters map to /// 0x01..0x1a, plus the classic xterm symbol and digit mappings. fn ctrl_c0_byte(c: char) -> Option { diff --git a/crates/noa-app/src/input/tests.rs b/crates/noa-app/src/input/tests.rs index 8c583a4..13d2d8a 100644 --- a/crates/noa-app/src/input/tests.rs +++ b/crates/noa-app/src/input/tests.rs @@ -1309,3 +1309,19 @@ fn kitty_keypad_uses_dedicated_codes_under_report_all() { Some(b"\x1b[57400u".to_vec()) ); } + +// The send-selection trailing Enter must match what a typed Enter sends: a +// report-all-keys client reads `CSI 13 u`, everything else the legacy CR. +#[test] +fn encode_enter_key_follows_kitty_flags() { + assert_eq!(encode_enter_key(0), b"\r".to_vec()); + assert_eq!(encode_enter_key(KITTY_DISAMBIGUATE), b"\r".to_vec()); + assert_eq!( + encode_enter_key(KITTY_REPORT_ALL_KEYS), + b"\x1b[13u".to_vec() + ); + assert_eq!( + encode_enter_key(KITTY_REPORT_ALL_KEYS | KITTY_REPORT_EVENT_TYPES), + b"\x1b[13u".to_vec() + ); +} diff --git a/crates/noa-app/src/macos_overlay/tests.rs b/crates/noa-app/src/macos_overlay/tests.rs index 4d4fcec..bb18e65 100644 --- a/crates/noa-app/src/macos_overlay/tests.rs +++ b/crates/noa-app/src/macos_overlay/tests.rs @@ -30,6 +30,7 @@ fn settings_init() -> ThemeSettingsInit { sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, confirm_quit: true, + send_selection_send_enter: false, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, @@ -185,6 +186,7 @@ fn test_theme_settings_init() -> ThemeSettingsInit { sidebar_font_size: noa_config::DEFAULT_SIDEBAR_FONT_SIZE, quick_terminal_size: 0.4, confirm_quit: true, + send_selection_send_enter: false, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, diff --git a/crates/noa-app/src/theme_settings/rows.rs b/crates/noa-app/src/theme_settings/rows.rs index d761add..3e3ab37 100644 --- a/crates/noa-app/src/theme_settings/rows.rs +++ b/crates/noa-app/src/theme_settings/rows.rs @@ -64,6 +64,11 @@ pub(crate) enum SettingsRowKind { SidebarFontSize, QuickTerminalHeight, ConfirmQuit, + /// `send-selection-send-enter`. Same reload-exempt classification as + /// `ConfirmQuit`: no live-preview path, but `commit_theme_settings` + /// mirrors it into `self.config` the moment the overlay saves (the + /// send-selection picker reads it from there at commit time). + SendSelectionSendEnter, /// R-9: `scrollback-limit`. `is_live() == false` (no runtime-apply path /// from this row directly), but reload-exempt (`Liveness::OnSave`, /// `RestartReason::None`) — `ConfigWatcher` re-applies it within 500ms @@ -126,7 +131,7 @@ pub(crate) enum SettingsRowKind { } impl SettingsRowKind { - pub(crate) const COUNT: usize = 28; + pub(crate) const COUNT: usize = 29; pub(crate) const ALL: [SettingsRowKind; Self::COUNT] = [ Self::FontSize, Self::BackgroundOpacity, @@ -146,6 +151,7 @@ impl SettingsRowKind { Self::SidebarFontSize, Self::QuickTerminalHeight, Self::ConfirmQuit, + Self::SendSelectionSendEnter, Self::ScrollbackLimit, Self::CursorStyleBlink, Self::MinimumContrast, @@ -204,6 +210,7 @@ impl SettingsRowKind { Self::SidebarFontSize => "Sidebar Font Size", Self::QuickTerminalHeight => "Quick Terminal Height", Self::ConfirmQuit => "Confirm Quit", + Self::SendSelectionSendEnter => "Send Selection Enter", Self::ScrollbackLimit => "Scrollback Limit", Self::CursorStyleBlink => "Cursor Blink", Self::MinimumContrast => "Minimum Contrast", @@ -249,6 +256,9 @@ impl SettingsRowKind { "Drop-down quick terminal's height as a fraction of the screen." } Self::ConfirmQuit => "Ask for confirmation before quitting the app.", + Self::SendSelectionSendEnter => { + "Send Enter after the send-selection picker pastes. Applies on save." + } Self::ScrollbackLimit => { "Total scrollback storage retained per pane, in bytes. Applies on save." } @@ -362,6 +372,7 @@ pub(crate) enum RowDraft { SidebarFontSize(f32), QuickTerminalHeight(f32), ConfirmQuit(bool), + SendSelectionSendEnter(bool), ScrollbackLimit(usize), /// Normalizes `noa_config::StartupConfig::cursor_style_blink`'s /// `Option` (`None` = terminal default) to a plain `bool` the row @@ -456,6 +467,13 @@ impl RowDraft { "Off".to_string() } } + RowDraft::SendSelectionSendEnter(send_enter) => { + if *send_enter { + "On".to_string() + } else { + "Off".to_string() + } + } RowDraft::ScrollbackLimit(bytes) => scrollback_limit_display_value(*bytes), RowDraft::CursorStyleBlink(blink) => { if *blink { @@ -548,6 +566,9 @@ impl RowDraft { RowDraft::QuickTerminalHeight(fraction) } SettingsRowKind::ConfirmQuit => RowDraft::ConfirmQuit(d.confirm_quit), + SettingsRowKind::SendSelectionSendEnter => { + RowDraft::SendSelectionSendEnter(d.send_selection_send_enter) + } SettingsRowKind::ScrollbackLimit => RowDraft::ScrollbackLimit(d.scrollback_limit), SettingsRowKind::CursorStyleBlink => { RowDraft::CursorStyleBlink(d.cursor_style_blink.unwrap_or(true)) @@ -657,6 +678,7 @@ pub(crate) struct RevertValues { pub(crate) window_padding_y: f32, pub(crate) macos_titlebar_style: MacosTitlebarStyle, pub(crate) confirm_quit: bool, + pub(crate) send_selection_send_enter: bool, pub(crate) font_family: String, } @@ -748,6 +770,7 @@ pub(crate) struct ThemeSettingsInit { pub(crate) sidebar_font_size: f32, pub(crate) quick_terminal_size: f32, pub(crate) confirm_quit: bool, + pub(crate) send_selection_send_enter: bool, pub(crate) font_family: String, pub(crate) available_font_families: Vec, /// R-9. diff --git a/crates/noa-app/src/theme_settings/state.rs b/crates/noa-app/src/theme_settings/state.rs index 86b5628..a9054db 100644 --- a/crates/noa-app/src/theme_settings/state.rs +++ b/crates/noa-app/src/theme_settings/state.rs @@ -284,6 +284,7 @@ impl ThemeSettings { window_padding_y: init.window_padding_y, macos_titlebar_style: init.macos_titlebar_style, confirm_quit: init.confirm_quit, + send_selection_send_enter: init.send_selection_send_enter, font_family: init.font_family.clone(), }, [ @@ -364,6 +365,10 @@ impl ThemeSettings { draft: RowDraft::ConfirmQuit(init.confirm_quit), touched: false, }, + SettingsRow { + draft: RowDraft::SendSelectionSendEnter(init.send_selection_send_enter), + touched: false, + }, SettingsRow { draft: RowDraft::ScrollbackLimit(init.scrollback_limit), touched: false, @@ -1160,6 +1165,15 @@ impl ThemeSettings { self.rows[idx].touched = true; RowEffect::None } + SettingsRowKind::SendSelectionSendEnter => { + let RowDraft::SendSelectionSendEnter(current) = self.rows[idx].draft else { + return RowEffect::None; + }; + let new = !current; + self.rows[idx].draft = RowDraft::SendSelectionSendEnter(new); + self.rows[idx].touched = true; + RowEffect::None + } // R-9: all four rows are persist-only (no runtime-apply path // from this row directly — the reload-exempt three still show // `Liveness::OnSave` because `ConfigWatcher` re-applies them @@ -1858,6 +1872,12 @@ impl ThemeSettings { RowDraft::ConfirmQuit(confirm) => { updates.push(("confirm-quit".to_string(), confirm.to_string())); } + RowDraft::SendSelectionSendEnter(send_enter) => { + updates.push(( + "send-selection-send-enter".to_string(), + send_enter.to_string(), + )); + } RowDraft::ScrollbackLimit(bytes) => { updates.push(("scrollback-limit".to_string(), bytes.to_string())); } @@ -2103,6 +2123,10 @@ pub(crate) fn revert_updates( macos_titlebar_style_config_value(revert.macos_titlebar_style).to_string(), )); updates.push(("confirm-quit".to_string(), revert.confirm_quit.to_string())); + updates.push(( + "send-selection-send-enter".to_string(), + revert.send_selection_send_enter.to_string(), + )); updates.push(("font-family".to_string(), revert.font_family.clone())); updates } @@ -2128,6 +2152,7 @@ fn is_reload_exempt(row: SettingsRowKind) -> bool { | SettingsRowKind::BackgroundImageRepeat | SettingsRowKind::BackgroundImageInterval | SettingsRowKind::ConfirmQuit + | SettingsRowKind::SendSelectionSendEnter | SettingsRowKind::QuickTerminalHeight // R-9/Addendum D-1's FM-01 correction: these three are picked up // by `ConfigWatcher`'s 500ms poll (`app/config_reload.rs`'s @@ -2208,7 +2233,9 @@ fn hash_row_draft_value(draft: &RowDraft, hasher: &mut impl Hasher) { background_image_position_value(*position).hash(hasher); } RowDraft::BackgroundImageFit(fit) => background_image_fit_value(*fit).hash(hasher), - RowDraft::BackgroundImageRepeat(v) | RowDraft::ConfirmQuit(v) => v.hash(hasher), + RowDraft::BackgroundImageRepeat(v) + | RowDraft::ConfirmQuit(v) + | RowDraft::SendSelectionSendEnter(v) => v.hash(hasher), RowDraft::BackgroundImageInterval(v) => v.hash(hasher), RowDraft::CursorStyle(shape) => cursor_shape_config_value(*shape).hash(hasher), RowDraft::WindowPadding(x, y) => { diff --git a/crates/noa-app/src/theme_settings/tests.rs b/crates/noa-app/src/theme_settings/tests.rs index 72ae224..8a0c9d2 100644 --- a/crates/noa-app/src/theme_settings/tests.rs +++ b/crates/noa-app/src/theme_settings/tests.rs @@ -36,6 +36,7 @@ fn init() -> ThemeSettingsInit { // `quick_terminal_height_fraction` at the `App` layer). quick_terminal_size: 0.4, confirm_quit: true, + send_selection_send_enter: false, font_family: "Menlo".to_string(), available_font_families: vec![ "Menlo".to_string(), @@ -945,6 +946,28 @@ fn confirm_quit_row_toggles_and_commits_without_restart_note() { ); } +#[test] +fn send_selection_send_enter_row_toggles_and_commits_without_restart_note() { + let mut settings = ThemeSettings::open(settings_init()); + move_to_row(&mut settings, SettingsRowKind::SendSelectionSendEnter); + + let effect = settings.adjust(1, Instant::now()); + assert_eq!(effect, RowEffect::None); + assert_eq!( + settings.rows()[row_index(SettingsRowKind::SendSelectionSendEnter)].draft, + RowDraft::SendSelectionSendEnter(true) + ); + assert!(!settings.restart_note(SettingsRowKind::SendSelectionSendEnter)); + + let updates = settings.commit_updates(); + assert_eq!( + updates + .iter() + .find(|(k, _)| k == "send-selection-send-enter"), + Some(&("send-selection-send-enter".to_string(), "true".to_string())) + ); +} + // R-17/NFR-6, Theme mode: `commit_updates` can only ever contain the // `theme` key now — the settings section doesn't exist in this mode, so no // row can ever become `touched` (an untouched row's draft can equal the @@ -1787,6 +1810,10 @@ fn default_for_maps_every_row_kind_to_its_documented_startup_default() { RowDraft::default_for(SettingsRowKind::ConfirmQuit), RowDraft::ConfirmQuit(true) ); + assert_eq!( + RowDraft::default_for(SettingsRowKind::SendSelectionSendEnter), + RowDraft::SendSelectionSendEnter(false) + ); // R-9. assert_eq!( RowDraft::default_for(SettingsRowKind::ScrollbackLimit), @@ -1833,11 +1860,12 @@ fn default_for_maps_every_row_kind_to_its_documented_startup_default() { // the token-copy action row brings it to 24 (+1), the read-only status row // (settings-panel-server-status) brings it to 25 (+1), the LAN bind- // address row (server-bind) brings it to 26 (+1), the sidebar-width row -// brings it to 27 (+1), and the sidebar-font-size row brings it to 28 (+1). +// brings it to 27 (+1), the sidebar-font-size row brings it to 28 (+1), +// and the send-selection-send-enter row brings it to 29 (+1). #[test] -fn settings_row_kind_count_is_twenty_eight_after_sidebar_font_size_row() { - assert_eq!(SettingsRowKind::COUNT, 28); - assert_eq!(SettingsRowKind::ALL.len(), 28); +fn settings_row_kind_count_is_twenty_nine_after_send_selection_send_enter_row() { + assert_eq!(SettingsRowKind::COUNT, 29); + assert_eq!(SettingsRowKind::ALL.len(), 29); } // settings-panel-server-status: the status row is read-only (mirrors @@ -2583,6 +2611,7 @@ fn restart_reason_never_reports_commit_only_for_the_reload_exempt_rows_even_when (SettingsRowKind::BackgroundImageRepeat, 1), (SettingsRowKind::BackgroundImageInterval, 1), (SettingsRowKind::ConfirmQuit, 1), + (SettingsRowKind::SendSelectionSendEnter, 1), (SettingsRowKind::QuickTerminalHeight, 1), ]; for (kind, delta) in adjust_exempt { @@ -2662,6 +2691,7 @@ fn liveness_reports_on_save_for_every_reload_exempt_row() { SettingsRowKind::BackgroundImageRepeat, SettingsRowKind::BackgroundImageInterval, SettingsRowKind::ConfirmQuit, + SettingsRowKind::SendSelectionSendEnter, SettingsRowKind::QuickTerminalHeight, // R-9 (Addendum D-1): the three reload-applied keys join the same // OnSave class as the pre-existing 8. @@ -3269,6 +3299,7 @@ fn sample_revert(theme_name: &str) -> RevertValues { window_padding_y: 2.0, macos_titlebar_style: MacosTitlebarStyle::Native, confirm_quit: true, + send_selection_send_enter: false, font_family: "Menlo".to_string(), } } @@ -3330,6 +3361,13 @@ fn revert_updates_restores_all_five_commit_only_rows() { Some(&("confirm-quit".to_string(), "true".to_string())), "confirm-quit must revert" ); + assert_eq!( + updates + .iter() + .find(|(k, _)| k == "send-selection-send-enter"), + Some(&("send-selection-send-enter".to_string(), "false".to_string())), + "send-selection-send-enter must revert" + ); } // TSV2-1: `macos-titlebar-style`'s serializer must agree with diff --git a/crates/noa-config/src/lib.rs b/crates/noa-config/src/lib.rs index c3d0134..be9fe99 100644 --- a/crates/noa-config/src/lib.rs +++ b/crates/noa-config/src/lib.rs @@ -607,6 +607,11 @@ pub struct StartupConfig { /// `auto-approve`: seed new tabs with agent-CLI auto approval enabled. /// Runtime use is still per-tab opt-in; default off. pub auto_approve: bool, + /// `send-selection-send-enter`: after the send-selection picker pastes + /// the selection into the target pane, also send Enter so the pasted + /// text is submitted immediately. Default off. noa-specific key (no + /// Ghostty analog). + pub send_selection_send_enter: bool, /// `keybind`: repeatable in-app keybinding edits applied to the default /// [`noa-app`] keybinding engine in config order. pub keybinds: Vec, @@ -689,6 +694,7 @@ impl Default for StartupConfig { audible_bell_when_unfocused: false, audible_bell_dock_bounce: false, auto_approve: false, + send_selection_send_enter: false, keybinds: Vec::new(), server_enable: false, server_port: DEFAULT_SERVER_PORT, @@ -756,6 +762,7 @@ pub struct ConfigOverrides { pub audible_bell_when_unfocused: Option, pub audible_bell_dock_bounce: Option, pub auto_approve: Option, + pub send_selection_send_enter: Option, pub keybinds: Vec, pub server_enable: Option, pub server_port: Option, @@ -873,6 +880,9 @@ impl ConfigOverrides { .audible_bell_dock_bounce .or(self.audible_bell_dock_bounce), auto_approve: higher_priority.auto_approve.or(self.auto_approve), + send_selection_send_enter: higher_priority + .send_selection_send_enter + .or(self.send_selection_send_enter), keybinds, server_enable: higher_priority.server_enable.or(self.server_enable), server_port: higher_priority.server_port.or(self.server_port), @@ -978,6 +988,9 @@ impl ConfigOverrides { .audible_bell_dock_bounce .unwrap_or(base.audible_bell_dock_bounce), auto_approve: self.auto_approve.unwrap_or(base.auto_approve), + send_selection_send_enter: self + .send_selection_send_enter + .unwrap_or(base.send_selection_send_enter), keybinds, server_enable: self.server_enable.unwrap_or(base.server_enable), server_port: self.server_port.unwrap_or(base.server_port), @@ -1216,6 +1229,7 @@ mod tests { audible_bell_when_unfocused: false, audible_bell_dock_bounce: false, auto_approve: false, + send_selection_send_enter: false, keybinds: Vec::new(), server_enable: false, server_port: DEFAULT_SERVER_PORT, @@ -1226,6 +1240,22 @@ mod tests { ); } + #[test] + fn send_selection_send_enter_defaults_off_and_cli_wins_over_file() { + assert!(!StartupConfig::default().send_selection_send_enter); + + let file = ConfigOverrides { + send_selection_send_enter: Some(false), + ..Default::default() + }; + let cli = ConfigOverrides { + send_selection_send_enter: Some(true), + ..Default::default() + }; + let config = file.merge(cli).apply_to(StartupConfig::default()); + assert!(config.send_selection_send_enter); + } + #[test] fn quick_terminal_hotkey_defaults_to_cmd_grave() { assert_eq!( diff --git a/crates/noa-config/src/parser/overrides.rs b/crates/noa-config/src/parser/overrides.rs index b301cb1..b452985 100644 --- a/crates/noa-config/src/parser/overrides.rs +++ b/crates/noa-config/src/parser/overrides.rs @@ -73,6 +73,7 @@ pub(crate) fn build_overrides( let mut audible_bell_when_unfocused = None; let mut audible_bell_dock_bounce = None; let mut auto_approve = None; + let mut send_selection_send_enter = None; let mut keybinds = Vec::new(); let mut server_enable = None; let mut server_port = None; @@ -328,6 +329,9 @@ pub(crate) fn build_overrides( "auto-approve" => { auto_approve = parse_bool_directive(path, directive, &mut diagnostics); } + "send-selection-send-enter" => { + send_selection_send_enter = parse_bool_directive(path, directive, &mut diagnostics); + } "keybind" => { if let Some(keybind) = parse_keybind_config(path, directive, &mut diagnostics) { keybinds.push(keybind); @@ -422,6 +426,7 @@ pub(crate) fn build_overrides( audible_bell_when_unfocused, audible_bell_dock_bounce, auto_approve, + send_selection_send_enter, keybinds, server_enable, server_port, @@ -522,5 +527,6 @@ pub(crate) fn is_supported_scalar_key(key: &str) -> bool { | "audible-bell-when-unfocused" | "audible-bell-dock-bounce" | "auto-approve" + | "send-selection-send-enter" ) } diff --git a/crates/noa-config/src/parser/tests.rs b/crates/noa-config/src/parser/tests.rs index c739bc4..c8bb057 100644 --- a/crates/noa-config/src/parser/tests.rs +++ b/crates/noa-config/src/parser/tests.rs @@ -1364,6 +1364,15 @@ fn bell_keys_parse_and_are_supported_scalar_keys_for_import() { } } +#[test] +fn send_selection_send_enter_parses_and_is_supported_scalar_key_for_import() { + let (overrides, diagnostics) = parse_overrides(path(), "send-selection-send-enter = true"); + + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert_eq!(overrides.send_selection_send_enter, Some(true)); + assert!(is_supported_scalar_key("send-selection-send-enter")); +} + #[test] fn invalid_values_warn_and_fall_back() { let (overrides, diagnostics) = parse_overrides(path(), "font-size = not-a-number"); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1241379..f67bc28 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -124,6 +124,7 @@ or fails to decode, a diagnostic is shown and the background image is disabled. | `audible-bell-when-unfocused` | `true`, `false` | `false` | Only sound the audible bell when unfocused | | `audible-bell-dock-bounce` | `true`, `false` | `false` | Trigger Dock attention on an unfocused audible BEL. macOS only | | `auto-approve` | `true`, `false` | `false` | Initial value for agent CLI auto approval in new tabs | +| `send-selection-send-enter` | `true`, `false` | `false` | Send Enter after the send-selection picker pastes into the target pane | ## Quick Terminal and sidebar diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f3fe622..74843f7 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -76,7 +76,7 @@ For the type, allowed values, defaults, and clamp/fallback rules of every key, s | Background image | `background-image`, `background-image-opacity/-position/-fit/-repeat/-interval` | | Cursor | `cursor-style`, `cursor-style-blink` | | Bell | `visual-bell`, `audible-bell`, `audible-bell-dock-bounce`, `audible-bell-when-unfocused` | -| Behavior | `scrollback-limit`, `clipboard-read`, `clipboard-paste-protection`, `confirm-quit`, `alpha-blending`, `title-report`, `resize-overlay`, `auto-approve` | +| Behavior | `scrollback-limit`, `clipboard-read`, `clipboard-paste-protection`, `confirm-quit`, `alpha-blending`, `title-report`, `resize-overlay`, `auto-approve`, `send-selection-send-enter` | | macOS | `macos-option-as-alt`, `macos-titlebar-style`, `macos-non-native-fullscreen`, `macos-titlebar-proxy-icon` | | Quick Terminal | `quick-terminal-hotkey/-size/-autohide` | | Sidebar | `sidebar-enabled/-width/-hotkey/-preview-lines` |