diff --git a/crates/nyaterm-core/src/command_suggestion_suppression.rs b/crates/nyaterm-core/src/command_suggestion_suppression.rs index 9ed4c6b88..98a9dc64d 100644 --- a/crates/nyaterm-core/src/command_suggestion_suppression.rs +++ b/crates/nyaterm-core/src/command_suggestion_suppression.rs @@ -250,6 +250,56 @@ pub fn command_starts_interactive_input(command: &str) -> bool { }) } +/// Free Type needs a line editor, unlike command navigation which also handles +/// pagers and TUIs. Compound commands cannot establish a single editor context. +pub fn command_starts_line_editor(command: &str) -> bool { + let segments = split_command_segments(&sanitize_terminal_command(command)); + if segments.len() != 1 || command_starts_suggestion_suppressing_program(command) { + return false; + } + let tokens = unwrap_command(&tokenize_shell_like(&segments[0])); + let Some(first) = tokens.first() else { + return false; + }; + match command_name(first).as_str() { + "docker" | "podman" => { + if !tokens + .get(1) + .is_some_and(|token| token == "exec" || token == "run") + || !has_option(&tokens, "--interactive", Some('i')) + || !has_option(&tokens, "--tty", Some('t')) + { + return false; + } + let mut index = 2; + while let Some(option) = tokens.get(index).filter(|token| token.starts_with('-')) { + match option.as_str() { + "-i" | "-t" | "-it" | "-ti" | "--interactive" | "--tty" | "--rm" => index += 1, + "-u" | "--user" | "-w" | "--workdir" | "-e" | "--env" => index += 2, + _ => return false, + } + } + // Skip the container/image; do not mistake cat's arguments for a shell. + let child = &tokens[index.saturating_add(1).min(tokens.len())..]; + child.first().is_some_and(|token| { + matches!(command_name(token).as_str(), "bash" | "zsh" | "fish" | "sh") + }) && command_starts_line_editor(&child.join(" ")) + } + // A remote command may be cat, even with -t. Require its prompt rather + // than treating all ssh invocations as a line editor. + "ssh" | "telnet" | "ftp" | "sftp" => false, + _ => { + !tokens.iter().any(|token| { + matches!( + token.to_ascii_lowercase().as_str(), + "-c" | "-e" | "--execute" | "--command" | "-command" | "-file" | "/c" + ) || token.starts_with("--execute=") + || token.starts_with("--command=") + }) && command_starts_interactive_input(command) + } + } +} + pub fn is_pager_search_or_command_input(value: &str) -> bool { value.trim_start().starts_with(['/', '?', ':']) } @@ -290,6 +340,37 @@ mod tests { #[cfg(test)] mod navigation_tests { + #[test] + fn free_type_context_excludes_foreground_programs_and_noninteractive_tasks() { + use super::command_starts_line_editor; + for command in [ + "bash", + "sudo zsh -i", + "python3 -i", + "mysql -u root", + "docker exec -it box sh", + "podman run -it --rm alpine /bin/sh", + ] { + assert!(command_starts_line_editor(command), "{command}"); + } + for command in [ + "cat", + "tee log", + "less file", + "vim", + "telnet host", + "ssh host cat", + "docker exec -it box cat sh", + "docker exec -it box sh -c cat", + "python3 task.py", + "mysql -e select", + "bash; cat", + "bash | cat", + ] { + assert!(!command_starts_line_editor(command), "{command}"); + } + } + #[test] fn interpreter_detection_distinguishes_scripts_from_interactive_input() { use super::command_starts_interactive_input; diff --git a/crates/nyaterm-core/src/terminal/editing.rs b/crates/nyaterm-core/src/terminal/editing.rs index 3ca59ebda..132aebe4c 100644 --- a/crates/nyaterm-core/src/terminal/editing.rs +++ b/crates/nyaterm-core/src/terminal/editing.rs @@ -4,6 +4,7 @@ use std::ops::Range; use std::time::{Duration, Instant}; use unicode_segmentation::UnicodeSegmentation; +use zeroize::Zeroize; use super::input_tracker::{ MAX_TRACKED_INPUT_BYTES, TerminalInputState, apply_terminal_input_data_in_place, @@ -19,6 +20,17 @@ pub enum EditCapability { EchoMatched, } +impl EditCapability { + /// Echo alone is not evidence that a foreground program has a line editor. + pub fn allows_editing(self, echo_line_editor: bool) -> bool { + match self { + Self::None => false, + Self::MarkedShell => true, + Self::EchoMatched => echo_line_editor, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum EditPhase { Ready, @@ -120,6 +132,26 @@ pub struct ShellEditState { } impl ShellEditState { + /// Suggestions can use an ordinary typing prediction, but never stale input + /// after a timeout, session switch, or other loss of confidence. + pub fn assist_input(&self) -> Option<&TerminalInputState> { + (self.phase != EditPhase::Uncertain && !self.input.desynced).then_some(&self.input) + } + + pub fn clear_input(&mut self) { + self.input.value.zeroize(); + self.input = TerminalInputState::new(); + self.invalidate(); + } + + pub fn deactivate(&mut self, editable: bool) { + if self.phase != EditPhase::Ready || !editable { + self.clear_input(); + } else { + self.invalidate(); + } + } + pub fn note_input(&mut self, text: &str, now: Instant) { apply_terminal_input_data_in_place(&mut self.input, text); self.version = self.version.wrapping_add(1); @@ -141,6 +173,7 @@ impl ShellEditState { if self.phase != EditPhase::Ready || plan.input_version != self.version { return false; } + self.input.value.zeroize(); self.input = plan.predicted; self.version = self.version.wrapping_add(1); self.phase = EditPhase::Pending; @@ -176,11 +209,17 @@ impl ShellEditState { } } +impl Drop for ShellEditState { + fn drop(&mut self) { + self.input.value.zeroize(); + } +} + #[cfg(test)] mod tests { use super::{ - EDIT_CONFIRMATION_TIMEOUT, EditIntent, EditPhase, ShellEditState, plan_edit, - supports_scalar_editing, + EDIT_CONFIRMATION_TIMEOUT, EditCapability, EditIntent, EditPhase, ShellEditState, + plan_edit, supports_scalar_editing, }; use crate::terminal::input_tracker::TerminalInputState; use std::time::Instant; @@ -250,4 +289,56 @@ mod tests { state.queued_cursor = Some(1); assert_eq!(state.confirm(), Some(1)); } + + #[test] + fn echo_requires_line_editor_context_for_every_edit() { + assert!(!EditCapability::None.allows_editing(true)); + assert!(EditCapability::MarkedShell.allows_editing(false)); + assert!(!EditCapability::EchoMatched.allows_editing(false)); + assert!(EditCapability::EchoMatched.allows_editing(true)); + } + + #[test] + fn deactivation_discards_unconfirmed_and_noneditable_input() { + for (phase, editable, retained) in [ + (EditPhase::Pending, true, false), + (EditPhase::Uncertain, true, false), + (EditPhase::Ready, false, false), + (EditPhase::Ready, true, true), + ] { + let mut state = ShellEditState::default(); + state.note_input("synthetic input", Instant::now()); + state.phase = phase; + state.queued_cursor = Some(1); + state.deactivate(editable); + assert_eq!(!state.input.value.is_empty(), retained); + assert_eq!(state.phase, EditPhase::Uncertain); + assert!(state.queued_cursor.is_none()); + assert!(state.assist_input().is_none()); + } + } + + #[test] + fn suggestions_can_use_typing_prediction_but_not_expired_input() { + let mut state = ShellEditState::default(); + let now = Instant::now(); + assert!(state.assist_input().is_none()); + state.note_input("git", now); + assert_eq!(state.assist_input().unwrap().value, "git"); + state.expire(now + EDIT_CONFIRMATION_TIMEOUT); + assert!(state.assist_input().is_none()); + state.confirm(); + assert_eq!(state.assist_input().unwrap().value, "git"); + } + + #[test] + fn tracked_input_debug_redacts_unrecognized_sensitive_input() { + let input = TerminalInputState { + value: "synthetic-secret-for-test".into(), + ..TerminalInputState::default() + }; + let debug = format!("{input:?}"); + assert!(!debug.contains(&input.value)); + assert!(debug.contains("[REDACTED]")); + } } diff --git a/crates/nyaterm-core/src/terminal/input_tracker.rs b/crates/nyaterm-core/src/terminal/input_tracker.rs index a1050724d..390c7f3a4 100644 --- a/crates/nyaterm-core/src/terminal/input_tracker.rs +++ b/crates/nyaterm-core/src/terminal/input_tracker.rs @@ -2,8 +2,9 @@ use regex::Regex; use std::sync::OnceLock; +use zeroize::Zeroize; -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Clone, PartialEq, Eq, Default)] pub struct TerminalInputState { pub value: String, /// Byte cursor into `value`. @@ -15,6 +16,20 @@ pub struct TerminalInputState { pub paste_mode: bool, } +impl std::fmt::Debug for TerminalInputState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TerminalInputState") + .field("value", &"[REDACTED]") + .field("cursor", &self.cursor) + .field("desynced", &self.desynced) + .field("desync_reason", &self.desync_reason) + .field("line_rewrite_required", &self.line_rewrite_required) + .field("multiline", &self.multiline) + .field("paste_mode", &self.paste_mode) + .finish() + } +} + impl TerminalInputState { pub fn new() -> Self { Self::default() @@ -243,6 +258,7 @@ pub fn apply_terminal_input_data_in_place(state: &mut TerminalInputState, data: match data { "\r" | "\u{0003}" => { + state.value.zeroize(); *state = TerminalInputState::reset(false); return; } diff --git a/crates/nyaterm-desktop/src/features/terminal/command_suggestions/mod.rs b/crates/nyaterm-desktop/src/features/terminal/command_suggestions/mod.rs index a0b7067f9..193f43167 100644 --- a/crates/nyaterm-desktop/src/features/terminal/command_suggestions/mod.rs +++ b/crates/nyaterm-desktop/src/features/terminal/command_suggestions/mod.rs @@ -288,7 +288,7 @@ impl NyaTermApp { .interaction_command_suggestion_min_chars .max(1) as usize; let below_min_chars = - terminal_input_tracker_below_min_chars(self.terminal.editing.input(), min_chars); + terminal_input_tracker_below_min_chars(self.terminal.editing.assist_input(), min_chars); timing.min_chars = min_chars_started_at.elapsed(); if below_min_chars { let hide_started_at = Instant::now(); @@ -305,12 +305,13 @@ impl NyaTermApp { } let pattern_started_at = Instant::now(); - let pattern_chars = command_suggestion_input_candidate_chars(self.terminal.editing.input()); + let pattern_chars = + command_suggestion_input_candidate_chars(self.terminal.editing.assist_input()); timing.pattern = pattern_started_at.elapsed(); let pager_started_at = Instant::now(); let pager_input = - command_suggestion_input_obvious_pager_prefix(self.terminal.editing.input()); + command_suggestion_input_obvious_pager_prefix(self.terminal.editing.assist_input()); timing.pager = pager_started_at.elapsed(); if pager_input { let hide_started_at = Instant::now(); @@ -327,7 +328,8 @@ impl NyaTermApp { } let eligibility_started_at = Instant::now(); - let can_suggest = command_suggestion_input_can_defer_refresh(self.terminal.editing.input()); + let can_suggest = + command_suggestion_input_can_defer_refresh(self.terminal.editing.assist_input()); timing.eligibility = eligibility_started_at.elapsed(); if !can_suggest { let hide_started_at = Instant::now(); @@ -461,7 +463,7 @@ impl NyaTermApp { self.hide_command_suggestions_if_present(cx); return; }; - if !command_suggestion_input_can_defer_refresh(self.terminal.editing.input()) { + if !command_suggestion_input_can_defer_refresh(self.terminal.editing.assist_input()) { self.hide_command_suggestions_if_present(cx); return; } @@ -486,7 +488,7 @@ impl NyaTermApp { .summary() .interaction_command_suggestion_max_chars .max(min_chars as u32) as usize; - let pattern = get_tracked_command(self.terminal.editing.input()); + let pattern = get_tracked_command(self.terminal.editing.assist_input()); let pattern_chars = pattern.chars().count(); let results = if pattern.trim().is_empty() { manual_empty_command_suggestions( @@ -496,7 +498,7 @@ impl NyaTermApp { Some(min_chars), Some(max_chars), ) - } else if can_suggest_from_tracked_command(self.terminal.editing.input(), &pattern) { + } else if can_suggest_from_tracked_command(self.terminal.editing.assist_input(), &pattern) { search_command_sources( &self.commands.command_history_snapshot(), &self.commands.quick_commands_snapshot(), @@ -607,10 +609,10 @@ impl NyaTermApp { .interaction_command_suggestion_max_chars .max(min_chars as u32) as usize; let pattern_started_at = Instant::now(); - let pattern = get_tracked_command(self.terminal.editing.input()); + let pattern = get_tracked_command(self.terminal.editing.assist_input()); let pattern_chars = pattern.chars().count(); timing.pattern = pattern_started_at.elapsed(); - if !can_suggest_from_tracked_command(self.terminal.editing.input(), &pattern) { + if !can_suggest_from_tracked_command(self.terminal.editing.assist_input(), &pattern) { let hide_started_at = Instant::now(); self.hide_command_suggestions_if_present(cx); timing.hide_popup = hide_started_at.elapsed(); @@ -660,7 +662,7 @@ impl NyaTermApp { .settings .summary() .interaction_command_suggestions_enabled - || get_tracked_command(self.terminal.editing.input()) != request.pattern + || get_tracked_command(self.terminal.editing.assist_input()) != request.pattern { return; } @@ -767,12 +769,17 @@ impl NyaTermApp { outcome, byte_count, pattern_chars, - tracker_value_bytes = self.terminal.editing.input().value.len(), - tracker_cursor = self.terminal.editing.input().cursor, - tracker_desynced = self.terminal.editing.input().desynced, - tracker_desync_reason = self.terminal.editing.input().desync_reason.unwrap_or(""), - tracker_multiline = self.terminal.editing.input().multiline, - tracker_paste_mode = self.terminal.editing.input().paste_mode, + tracker_value_bytes = self.terminal.editing.predicted_input().value.len(), + tracker_cursor = self.terminal.editing.predicted_input().cursor, + tracker_desynced = self.terminal.editing.predicted_input().desynced, + tracker_desync_reason = self + .terminal + .editing + .predicted_input() + .desync_reason + .unwrap_or(""), + tracker_multiline = self.terminal.editing.predicted_input().multiline, + tracker_paste_mode = self.terminal.editing.predicted_input().paste_mode, popup_visible_at_start, popup_visible = self.terminal.assist.command_suggestions.is_some(), total_us = total_duration.as_micros(), @@ -815,9 +822,9 @@ impl NyaTermApp { result_count, command_history_count = self.commands.command_history().len(), quick_command_count = self.commands.quick_commands().len(), - tracker_value_bytes = self.terminal.editing.input().value.len(), - tracker_desynced = self.terminal.editing.input().desynced, - tracker_multiline = self.terminal.editing.input().multiline, + tracker_value_bytes = self.terminal.editing.predicted_input().value.len(), + tracker_desynced = self.terminal.editing.predicted_input().desynced, + tracker_multiline = self.terminal.editing.predicted_input().multiline, popup_visible_at_start, popup_visible = self.terminal.assist.command_suggestions.is_some(), total_us = total_duration.as_micros(), @@ -918,6 +925,10 @@ impl NyaTermApp { execute: bool, cx: &mut Context, ) -> bool { + if self.terminal.editing.assist_input().desynced { + self.hide_command_suggestions_if_present(cx); + return false; + } let Some(state) = self.terminal.assist.command_suggestions.clone() else { return false; }; @@ -1550,6 +1561,41 @@ mod tests { command_suggestion_step_selection, terminal_line_prefix_for_cell_col, }; + #[test] + fn uncertain_input_cannot_start_or_publish_a_suggestion_search() { + let dir = TestConfigDir::new("nyaterm-suggestions-confidence"); + let mut cx = TestAppContext::single(); + let app = crate::features::test_support::app_with_visible_local_session( + &mut cx, + dir.path(), + "s1", + ); + cx.update_entity(&app, |app, cx| { + app.note_shell_editing_input(b"echo", cx); + let request = app + .prepare_command_suggestion_search(1, cx) + .expect("ordinary typing predictions can request suggestions"); + app.terminal.editing.state_mut().model.invalidate(); + assert!(app.prepare_command_suggestion_search(1, cx).is_none()); + app.publish_command_suggestion_search( + request, + vec![nyaterm_core::FuzzyResult { + command: "echo example".into(), + source: "history".into(), + score: 1, + indices: vec![], + display: "echo example".into(), + }], + Duration::ZERO, + cx, + ); + assert!(app.terminal.assist.command_suggestions.is_none()); + app.terminal.assist.command_suggestions = Some(open_suggestions_popup("s1")); + assert!(!app.apply_selected_command_suggestion(true, cx)); + assert!(app.terminal.assist.pending_command_history_entry.is_none()); + }); + } + #[test] fn terminal_line_prefix_uses_terminal_cells_for_wide_chars() { assert_eq!(terminal_line_prefix_for_cell_col("界x", 0), ""); @@ -1729,6 +1775,13 @@ mod tests { let app = cx.new(|cx| NyaTermApp::new(runtime, stores, cx)); cx.update_entity(&app, |app, cx| { + // Exercise write failure with an eligible typing prediction. A stale + // popup is now rejected before any write is attempted. + app.terminal + .editing + .state_mut() + .model + .note_input("ps", Instant::now()); app.terminal.assist.command_suggestions = Some(CommandSuggestionState { session_id: "missing".to_string(), draft: "ps".to_string(), @@ -1827,13 +1880,19 @@ mod tests { // Typing inside the program neither suggests nor records history. app.note_command_suggestion_input(b"11", cx); assert!(app.terminal.assist.command_suggestions.is_none()); - assert_eq!(app.terminal.editing.input(), &TerminalInputState::new()); + assert_eq!( + app.terminal.editing.predicted_input(), + &TerminalInputState::new() + ); *app.terminal.editing.input_mut() = apply_terminal_input_data(&TerminalInputState::new(), "vim "); app.note_shell_editing_input(b"a.txt\r", cx); assert!(app.terminal.assist.pending_command_history_entry.is_none()); - assert_eq!(app.terminal.editing.input(), &TerminalInputState::new()); + assert_eq!( + app.terminal.editing.predicted_input(), + &TerminalInputState::new() + ); }); } @@ -1885,6 +1944,7 @@ mod tests { assert!(!app.active_terminal_uses_alternate_screen()); *app.terminal.editing.input_mut() = apply_terminal_input_data(&TerminalInputState::new(), "vim 1"); + app.terminal.editing.state_mut().model.confirm(); app.prepare_command_suggestion_search(1, cx) .expect("shell line should still build a request") }); diff --git a/crates/nyaterm-desktop/src/features/terminal/editing_runtime.rs b/crates/nyaterm-desktop/src/features/terminal/editing_runtime.rs index bbd4f070c..9969b0a92 100644 --- a/crates/nyaterm-desktop/src/features/terminal/editing_runtime.rs +++ b/crates/nyaterm-desktop/src/features/terminal/editing_runtime.rs @@ -1,12 +1,15 @@ //! GPUI coordination for shell input evidence and predictions. use std::time::Instant; +use zeroize::Zeroize; use gpui::Context; use nyaterm_core::command_starts_suggestion_suppressing_program; +use nyaterm_core::command_suggestion_suppression::command_starts_line_editor; use nyaterm_core::terminal::editing::{EDIT_CONFIRMATION_TIMEOUT, EditCapability, EditPhase}; use nyaterm_core::terminal::input_tracker::{ MAX_TRACKED_INPUT_BYTES, TerminalInputState, get_tracked_submission_command, + strip_terminal_command_prompt, }; use nyaterm_terminal::editing::{recover_marked_input, resolve_input}; @@ -40,7 +43,7 @@ impl NyaTermApp { return; } let submitted_for_navigation = - get_tracked_submission_command(self.terminal.editing.input()); + get_tracked_submission_command(self.terminal.editing.predicted_input()); let interactive = self .terminal .editing @@ -64,6 +67,13 @@ impl NyaTermApp { .view .frame_pipeline .note_command_input(id.clone(), eligible); + if text.contains(['\r', '\n']) { + let state = self.terminal.editing.state_mut(); + state.launched_line_editor = command_starts_line_editor(&submitted_for_navigation); + state.echo_line_editor = false; + } else if matches!(text, "\u{3}" | "\u{4}") { + self.terminal.editing.state_mut().launched_line_editor = false; + } if text == "\u{4}" || (text == "\r" && matches!( @@ -91,7 +101,7 @@ impl NyaTermApp { && self.terminal.assist.pending_command_history_entry.is_none() && text.contains(['\r', '\n']) { - let submitted = get_tracked_submission_command(self.terminal.editing.input()); + let submitted = get_tracked_submission_command(self.terminal.editing.predicted_input()); if !submitted.is_empty() { if command_starts_suggestion_suppressing_program(&submitted) { self.terminal.assist.command_suggestions_suppressed = true; @@ -131,6 +141,9 @@ impl NyaTermApp { { state.mapping = None; state.selection = None; + if this.session.active_id() == Some(id.as_str()) { + this.dismiss_command_suggestions(cx); + } cx.notify(); } }); @@ -165,6 +178,13 @@ impl NyaTermApp { return; }; let geometry = (snapshot.cols, snapshot.viewport_rows, line_id.epoch); + let shell_session = self.session.metadata(id).is_some_and(|metadata| { + matches!( + metadata.launch_config, + crate::models::SessionLaunchConfig::Local(_) + | crate::models::SessionLaunchConfig::Ssh(_) + ) + }); let state = self.terminal.editing.state_mut(); if state.awaiting_snapshot { return; @@ -214,6 +234,7 @@ impl NyaTermApp { || state.model.input.value.is_empty()) && let Some((value, cursor)) = recover_marked_input(&snapshot, MAX_TRACKED_INPUT_BYTES) { + state.model.input.value.zeroize(); state.model.input = TerminalInputState { value, cursor, @@ -223,6 +244,40 @@ impl NyaTermApp { mapping = resolve_input(&snapshot, &state.model.input.value, cursor); } let queued = if let Some(mapping) = mapping { + // A unique echo can also come from canonical TTY input (cat/tee). + // Require a shell prompt or a positively identified child line editor. + let prompt = mapping.cells.first().map(|first| { + snapshot + .row(first.row) + .into_iter() + .flat_map(|row| row.cells.iter().take(first.col)) + .filter(|cell| cell.width != 0) + .map(|cell| { + if cell.text.is_empty() { + " " + } else { + cell.text.as_ref() + } + }) + .collect::() + }); + let known_prompt = prompt.as_ref().is_some_and(|prefix| { + !prefix.trim().is_empty() + && (strip_terminal_command_prompt(prefix).is_empty() + || state.line_editor_prompt.as_ref() == Some(prefix)) + }); + let has_prompt = prompt + .as_ref() + .is_some_and(|prefix| !prefix.trim().is_empty()); + state.echo_line_editor = + shell_session && (known_prompt || (state.launched_line_editor && has_prompt)); + if state.echo_line_editor + && prompt + .as_ref() + .is_some_and(|prefix| !prefix.trim().is_empty()) + { + state.line_editor_prompt = prompt; + } if state.mapping.as_ref().is_some_and(|old| old != &mapping) { state.selection = None; state.selection_origin_version = None; @@ -268,6 +323,66 @@ mod tests { struct InputHost; + #[test] + fn foreground_echo_cannot_move_delete_or_replace_shell_input() { + let dir = TestConfigDir::new("nyaterm-shell-edit-foreground"); + let mut cx = TestAppContext::single(); + let app = app_with_visible_local_session(&mut cx, dir.path(), "s1"); + cx.update_entity(&app, |app, cx| { + app.note_shell_editing_input(b"cat", cx); + app.terminal + .seed_session_view("s1".into(), "$ cat".into(), "UTF-8"); + app.reconcile_shell_editing("s1", cx); + assert!(app.can_use_smart_cursor_selection()); + app.note_shell_editing_input(b"\r", cx); + app.note_shell_editing_input(b"abcdef", cx); + app.terminal + .seed_session_view("s1".into(), "abcdef".into(), "UTF-8"); + app.reconcile_shell_editing("s1", cx); + assert_eq!( + app.terminal.editing.state().unwrap().capability, + EditCapability::EchoMatched + ); + assert!(!app.can_use_smart_cursor_selection()); + assert!(!app.move_smart_input_cursor(1, cx)); + let range = + nyaterm_core::terminal::input_tracker::InputSelectionRange::new(1, 4).unwrap(); + assert!(!app.delete_smart_input_selection(range, cx)); + assert!(!app.replace_smart_input_selection(range, "X", cx)); + assert!(!app.replace_smart_input_selection_with_paste(range, "X", cx)); + app.terminal.editing.activate("other"); + assert!( + app.terminal.editing.sessions["s1"] + .model + .input + .value + .is_empty() + ); + }); + } + + #[test] + fn docker_line_editor_supports_custom_prompt_but_loses_permission_on_cat() { + let dir = TestConfigDir::new("nyaterm-shell-edit-docker-context"); + let mut cx = TestAppContext::single(); + let app = app_with_visible_local_session(&mut cx, dir.path(), "s1"); + cx.update_entity(&app, |app, cx| { + app.note_shell_editing_input(b"docker exec -it box sh", cx); + app.note_shell_editing_input(b"\r", cx); + app.note_shell_editing_input(b"cat", cx); + app.terminal + .seed_session_view("s1".into(), "custom> cat".into(), "UTF-8"); + app.reconcile_shell_editing("s1", cx); + assert!(app.can_use_smart_cursor_selection()); + app.note_shell_editing_input(b"\r", cx); + app.note_shell_editing_input(b"abcdef", cx); + app.terminal + .seed_session_view("s1".into(), "abcdef".into(), "UTF-8"); + app.reconcile_shell_editing("s1", cx); + assert!(!app.can_use_smart_cursor_selection()); + }); + } + impl Render for InputHost { fn render( &mut self, @@ -331,7 +446,7 @@ mod tests { assert!(app.terminal_should_defer_key_text_to_input_handler(&key)); app.replace_text_in_range(None, "中文", window, cx); assert!(app.terminal.input.ime_marked_text.is_empty()); - assert_eq!(app.terminal.editing.input().value, "a中文ef"); + assert_eq!(app.terminal.editing.predicted_input().value, "a中文ef"); assert_eq!( app.terminal.editing.state().unwrap().model.version, version + 1 @@ -377,7 +492,7 @@ mod tests { assert_eq!(state.capability, EditCapability::EchoMatched); assert!(app.can_use_smart_cursor_selection()); app.clear_command_suggestion_draft(cx); - assert_eq!(app.terminal.editing.input().value, "echo abc"); + assert_eq!(app.terminal.editing.predicted_input().value, "echo abc"); }); } @@ -401,8 +516,8 @@ mod tests { app.terminal .seed_session_view("s1".into(), "\x1b]133;C\x07$ e".into(), "UTF-8"); app.reconcile_shell_editing("s1", cx); - assert!(!app.terminal.editing.input().desynced); - assert_eq!(app.terminal.editing.input().value, "echo abc"); + assert!(!app.terminal.editing.predicted_input().desynced); + assert_eq!(app.terminal.editing.predicted_input().value, "echo abc"); assert!(!app.can_use_smart_cursor_selection()); app.terminal .seed_session_view("s1".into(), "$ echo abc".into(), "UTF-8"); @@ -447,7 +562,7 @@ mod tests { // The fixture deliberately has no live transport; a rejected write // consumes the edit gesture without sending a fallback character. assert!(app.replace_smart_input_selection(range, "X", cx)); - assert_eq!(app.terminal.editing.input().value, "abcdef"); + assert_eq!(app.terminal.editing.predicted_input().value, "abcdef"); assert_eq!(app.terminal.selection.selection, Some(selection)); assert_eq!( app.terminal.editing.state().unwrap().model.phase, @@ -484,7 +599,7 @@ mod tests { assert!(app.smart_cursor_selected_input_range().is_some()); app.note_shell_editing_input(b"d", cx); assert!(app.smart_cursor_selected_input_range().is_none()); - assert_eq!(app.terminal.editing.input().value, "abcd"); + assert_eq!(app.terminal.editing.predicted_input().value, "abcd"); app.terminal .seed_session_view("s1".into(), "$ abcd".into(), "UTF-8"); app.reconcile_shell_editing("s1", cx); @@ -513,7 +628,7 @@ mod tests { app.sync_input.toggle_broadcast_to_all(); app.terminal.assist.credential_prompt_input_until_ms = u64::MAX; app.note_shell_editing_input(b"secret", cx); - assert!(app.terminal.editing.input().value.is_empty()); + assert!(app.terminal.editing.predicted_input().value.is_empty()); assert!(!app.can_use_smart_cursor_selection()); }); } diff --git a/crates/nyaterm-desktop/src/features/terminal/editing_state.rs b/crates/nyaterm-desktop/src/features/terminal/editing_state.rs index 1731f57cc..58f0ab9ca 100644 --- a/crates/nyaterm-desktop/src/features/terminal/editing_state.rs +++ b/crates/nyaterm-desktop/src/features/terminal/editing_state.rs @@ -20,6 +20,9 @@ pub(super) struct SessionEditingState { pub(super) command_navigation_interactive: bool, pub(super) model: ShellEditState, pub(super) capability: EditCapability, + pub(super) echo_line_editor: bool, + pub(super) launched_line_editor: bool, + pub(super) line_editor_prompt: Option, pub(super) mapping: Option, pub(super) selection: Option<(TerminalSelection, u64)>, pub(super) selection_origin_version: Option, @@ -53,6 +56,15 @@ impl SnapshotEvidence { } impl SessionEditingState { + pub(super) fn allows_editing(&self) -> bool { + self.capability.allows_editing(self.echo_line_editor) + } + + fn deactivate(&mut self) { + self.model.deactivate(self.allows_editing()); + self.invalidate(); + } + pub(super) fn invalidate(&mut self) { self.model.invalidate(); self.capability = EditCapability::None; @@ -68,7 +80,7 @@ impl TerminalEditingState { pub(super) fn activate(&mut self, session_id: &str) { if self.active != session_id { if let Some(state) = self.sessions.get_mut(&self.active) { - state.invalidate(); + state.deactivate(); } self.active = session_id.to_string(); self.state_mut().invalidate(); @@ -82,7 +94,7 @@ impl TerminalEditingState { self.sessions.entry(self.active.clone()).or_default() } - pub(super) fn input(&self) -> &TerminalInputState { + pub(super) fn predicted_input(&self) -> &TerminalInputState { static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); self.state().map_or_else( || EMPTY.get_or_init(TerminalInputState::new), @@ -90,11 +102,25 @@ impl TerminalEditingState { ) } + pub(super) fn assist_input(&self) -> &TerminalInputState { + static UNAVAILABLE: std::sync::OnceLock = std::sync::OnceLock::new(); + self.state() + .and_then(|state| state.model.assist_input()) + .unwrap_or_else(|| { + UNAVAILABLE.get_or_init(|| TerminalInputState { + desynced: true, + desync_reason: Some("unconfirmed_input"), + ..TerminalInputState::new() + }) + }) + } + /// Compatibility adapter for command suggestion replacement. Invalidates any /// old mapping before handing out mutable input, so it cannot authorize edits. pub(super) fn input_mut(&mut self) -> &mut TerminalInputState { let state = self.state_mut(); state.invalidate(); + state.model.clear_input(); &mut state.model.input } @@ -115,6 +141,7 @@ impl super::state::TerminalFeatureState { pub(in crate::features) fn invalidate_shell_editing_session(&mut self, session_id: &str) { if let Some(state) = self.editing.sessions.get_mut(session_id) { state.invalidate(); + state.model.clear_input(); state.model.input.desynced = true; } } @@ -123,21 +150,26 @@ impl super::state::TerminalFeatureState { #[cfg(test)] mod tests { use super::TerminalEditingState; - use nyaterm_core::terminal::editing::EditPhase; + use nyaterm_core::terminal::editing::{EditCapability, EditPhase}; use std::time::Instant; #[test] - fn switching_sessions_keeps_one_input_per_session_and_cancels_pending_navigation() { + fn switching_sessions_retains_only_confirmed_editable_input() { let mut state = TerminalEditingState::default(); state.activate("one"); state.state_mut().model.note_input("abc", Instant::now()); + state.state_mut().model.confirm(); + state.state_mut().capability = EditCapability::MarkedShell; state.state_mut().model.queued_cursor = Some(1); state.activate("two"); state.state_mut().model.note_input("xyz", Instant::now()); state.activate("one"); - assert_eq!(state.input().value, "abc"); + assert_eq!(state.predicted_input().value, "abc"); assert_eq!(state.state().unwrap().model.phase, EditPhase::Uncertain); assert_eq!(state.state().unwrap().model.queued_cursor, None); - assert_eq!(state.sessions["two"].model.input.value, "xyz"); + assert!(state.sessions["two"].model.input.value.is_empty()); + assert!(state.assist_input().desynced); + state.state_mut().model.confirm(); + assert_eq!(state.assist_input().value, "abc"); } } diff --git a/crates/nyaterm-desktop/src/features/terminal/state.rs b/crates/nyaterm-desktop/src/features/terminal/state.rs index 15f49bf38..1ee57dbd7 100644 --- a/crates/nyaterm-desktop/src/features/terminal/state.rs +++ b/crates/nyaterm-desktop/src/features/terminal/state.rs @@ -885,7 +885,7 @@ mod tests { state.reset_assist_for_session_switch(); - assert_eq!(state.editing.input().value, "git status"); + assert_eq!(state.editing.predicted_input().value, "git status"); assert!(!state.assist.command_suggestions_suppressed); assert!(state.assist.pending_command_history_entry.is_none()); assert!(state.assist.credential_autofill_buffer.is_empty()); diff --git a/crates/nyaterm-desktop/src/features/terminal/terminal_selection_runtime/smart_input.rs b/crates/nyaterm-desktop/src/features/terminal/terminal_selection_runtime/smart_input.rs index 38b974bad..23abe6ec6 100644 --- a/crates/nyaterm-desktop/src/features/terminal/terminal_selection_runtime/smart_input.rs +++ b/crates/nyaterm-desktop/src/features/terminal/terminal_selection_runtime/smart_input.rs @@ -1,8 +1,13 @@ -use gpui::{App, Context, KeyDownEvent, MouseUpEvent, Pixels, Point}; +use gpui::{ + App, Context, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, MouseUpEvent, Pixels, Point, +}; use nyaterm_core::terminal::editing::{EditIntent, EditPhase, EditPlan, plan_edit}; use nyaterm_core::terminal::input_tracker::InputSelectionRange; use nyaterm_terminal::editing::resolve_input; use nyaterm_terminal::navigation::{NavigationKey, navigation_key_bytes}; +use nyaterm_terminal_gpui::{ + TerminalKeyMode, terminal_key_bytes_with_mode, terminal_key_release_bytes_with_mode, +}; use super::helpers::SmartSelectionEdge; use super::metrics::{ @@ -15,7 +20,7 @@ use crate::models::{TerminalBufferCellPos, TerminalSelection}; fn shell_edit_payload( plan: &EditPlan, - application_cursor: bool, + mode: TerminalKeyMode, insertion_wire: Vec, ) -> (Vec, Vec) { let key = if plan.move_right { @@ -23,14 +28,39 @@ fn shell_edit_payload( } else { NavigationKey::Left }; - let mut logical = navigation_key_bytes(key, application_cursor).repeat(plan.move_steps); + // Recording/tracking consumes legacy logical intents. The wire uses exactly + // the same press/release encoder as physical input, including Kitty flags. + let mut logical = navigation_key_bytes(key, mode.application_cursor).repeat(plan.move_steps); logical.extend(std::iter::repeat_n(0x7f, plan.delete_steps)); - let mut wire = logical.clone(); + let mut wire = shell_edit_key_bytes(if plan.move_right { "right" } else { "left" }, mode) + .repeat(plan.move_steps); + wire.extend(shell_edit_key_bytes("backspace", mode).repeat(plan.delete_steps)); wire.extend(insertion_wire); logical.extend_from_slice(plan.insert_text.as_bytes()); (wire, logical) } +fn shell_edit_key_bytes(key: &str, mode: TerminalKeyMode) -> Vec { + let keystroke = Keystroke { + key: key.into(), + key_char: None, + modifiers: Modifiers::default(), + }; + let mut bytes = terminal_key_bytes_with_mode( + &KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }, + mode, + ) + .expect("shell editing keys have a terminal encoding"); + if let Some(release) = terminal_key_release_bytes_with_mode(&KeyUpEvent { keystroke }, mode) { + bytes.extend(release); + } + bytes +} + impl NyaTermApp { pub(in crate::features) fn smart_input_line_selection_at_mouse( &self, @@ -88,6 +118,7 @@ impl NyaTermApp { && self.active_terminal_display_offset() == 0 && self.terminal.editing.state().is_some_and(|state| { !state.awaiting_snapshot + && state.allows_editing() && !state.model.input.desynced && !state.model.input.line_rewrite_required && !state.model.input.multiline @@ -250,14 +281,13 @@ impl NyaTermApp { return true; } let changes_text = plan.delete_steps > 0 || !plan.insert_text.is_empty(); - let protocol = self.terminal_protocol_state_for_session(&id); + let mode = self.terminal_key_mode_for_session(Some(&id)); let insertion_wire = if paste { self.wrap_terminal_paste_bytes_for_session(&id, &plan.insert_text) } else { self.encode_session_outgoing(&id, plan.insert_text.as_bytes()) }; - let (wire, logical) = - shell_edit_payload(&plan, protocol.application_cursor_keys, insertion_wire); + let (wire, logical) = shell_edit_payload(&plan, mode, insertion_wire); let snapshot = self.terminal_snapshot_for_session(Some(&id), 0); self.terminal.view.frame_pipeline.arm_output_event_wake(); if let Err(error) = self.write_session_wire_input_recorded_as(&id, &wire, &logical) { @@ -390,6 +420,7 @@ mod tests { use super::shell_edit_payload; use crate::features::NyaTermApp; + use nyaterm_terminal_gpui::TerminalKeyMode; #[test] fn replacement_paste_frames_only_inserted_encoded_text() { @@ -409,11 +440,47 @@ mod tests { .unwrap(); let encoded = [0xd6, 0xd0, 0xce, 0xc4]; let body = NyaTermApp::wrap_terminal_paste_wire_bytes_for_bracketed(&encoded, true); - let (wire, logical) = shell_edit_payload(&plan, true, body); + let (wire, logical) = shell_edit_payload( + &plan, + TerminalKeyMode { + application_cursor: true, + ..TerminalKeyMode::default() + }, + body, + ); assert_eq!( wire, b"\x1bOD\x1bOD\x7f\x7f\x7f\x1b[200~\xd6\xd0\xce\xc4\x1b[201~" ); assert_eq!(logical, "\x1bOD\x1bOD\x7f\x7f\x7f中文".as_bytes()); } + + #[test] + fn kitty_replacement_encodes_backspace_press_and_release_without_polluting_prediction() { + let input = TerminalInputState { + value: "abc".into(), + cursor: 3, + ..TerminalInputState::default() + }; + let plan = plan_edit( + &input, + 0, + EditIntent::Replace { + range: 1..2, + text: "X", + }, + ) + .unwrap(); + let mode = TerminalKeyMode { + application_cursor: true, + kitty_keyboard_disambiguate: true, + kitty_keyboard_report_event_types: true, + kitty_keyboard_report_all_keys_as_esc: true, + ..TerminalKeyMode::default() + }; + let (wire, logical) = shell_edit_payload(&plan, mode, b"X".to_vec()); + assert_eq!(wire, b"\x1bOD\x1b[127;1:1u\x1b[127;1:3uX"); + assert_eq!(logical, b"\x1bOD\x7fX"); + assert_eq!(plan.predicted.value, "aXc"); + } } diff --git a/docs/shell-free-type-hardening.md b/docs/shell-free-type-hardening.md new file mode 100644 index 000000000..f3e637a2c --- /dev/null +++ b/docs/shell-free-type-hardening.md @@ -0,0 +1,76 @@ +# Shell Free Type hardening + +The session-owned prediction, snapshot reconciliation, and Ready/Pending/Uncertain +state machine remain authoritative. This change tightens their consumers without +changing settings, saved sessions, encryption, or persistence formats. + +## Editing authorization + +All cursor, selection, delete, replacement, paste, and IME editing paths use +`EditCapability::allows_editing`. MarkedShell uses the explicit OSC 133 boundary. +EchoMatched additionally requires a Local/SSH session and a recognizable shell +prompt, a previously confirmed line-editor prompt, or a nonempty prompt belonging +to a positively identified child line editor. Matching the typed text and cursor +alone never authorizes editing. + +Command navigation's interactive-program detector also includes pagers and TUIs, +so Free Type uses the narrower `command_starts_line_editor` policy. It excludes +cat/tee, pagers, full-screen editors, script/command execution, and compound +commands. Docker/Podman interactive shell launches are parsed through the actual +child command; an argument named `sh` to `cat` is not a shell launch. Arbitrary +SSH commands need prompt evidence instead of blanket authorization. + +Prompt evidence is still heuristic without OSC 133. A process deliberately +imitating a shell prompt cannot be distinguished reliably from a shell by cells +alone. Unknown prompts and unsupported launch options fail conservatively. +Right prompts, decorations, complex graphemes, alternate screens, native mouse +reporting, and broadcast-input restrictions retain their existing behavior. + +## Input retention and confidence + +Session deactivation retains only Ready input authorized for editing. Pending, +Uncertain, and noneditable echo are zeroized and reset. Retained input becomes +Uncertain and needs reconciliation before reuse by command suggestions. Explicit +session invalidation, tracker resets, buffer replacement, and editor destruction +also zeroize the owned input string. TerminalInputState Debug masks its text. + +`predicted_input` is for submission tracking and redacted diagnostic metadata. +`assist_input` allows ordinary typing predictions and confirmed input, but returns +an unavailable state after confidence is lost. Suggestion scheduling, manual +search, publication, and application all use this confidence-aware accessor. +Confirmation timeout also dismisses the active session's suggestions. + +## Keyboard encoding + +Synthetic Left/Right and Backspace use the same full-mode press/release encoder +as physical keyboard input. The logical recording/tracking payload retains +navigation intents and DEL; the wire payload follows DECCKM and Kitty flags. +Bracketed-paste framing and outgoing text encoding apply only to inserted text. +The existing physical encoder currently keeps plain arrows in CSI/SS3 form under +Kitty; Backspace can use CSI-u and event-type reporting. Sharing that encoder +prevents the two paths from diverging when its protocol support changes. + +## Regression coverage + +Tests cover foreground echo refusing move/delete/replace/paste, Docker custom +prompts, line-editor launch classification, pending/uncertain deactivation, +confirmed-input retention, suggestion confidence, redacted Debug, and exact +Kitty Backspace press/release bytes. Existing fresh-snapshot, write-failure, +selection, paste-encoding, and IME tests remain in place. + +## Validation + +Validated on Windows on 2026-10-04: + +- `cargo check --workspace --locked` +- `cargo clippy --workspace --all-targets --locked` +- `cargo fmt --all -- --check` +- `cargo test --workspace --locked -- --test-threads=1` +- The ignored PowerShell/ConPTY Free Type test, executed from the compiled + `shell_free_type_windows` test binary. + +Cargo build checks used `--target-dir D:\CodexBuilds\nyaterm-free-type-20261004` +after the shared E: drive ran out of space. The shared target directory was not +cleaned. The complete serial workspace run passed; parallel runs encountered +intermittent failures in the existing ConPTY close timeout and AI proxy test's +nonblocking socket read. The ConPTY close test also passed in isolation.