mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-11-06 16:07:55 +00:00
fix(terminal): harden shell free type editing
Require line-editor context for echo-based editing and zeroize unconfirmed input when sessions deactivate. Share the physical keyboard press/release encoder for synthetic navigation and backspace, and reject stale command suggestion inputs. Add regression tests for foreground echo, Docker prompts, input retention, suggestion confidence, redacted debug output, and Kitty keyboard encoding. Validated on Windows: workspace check, clippy --all-targets, fmt --check, workspace tests with --test-threads=1, and the PowerShell/ConPTY Free Type smoke test. Parallel test runs encountered intermittent existing ConPTY close timeouts and AI proxy nonblocking socket failures; details are in docs/shell-free-type-hardening.md.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Self>,
|
||||
) -> 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")
|
||||
});
|
||||
|
||||
@@ -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::<String>()
|
||||
});
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub(super) mapping: Option<InputMapping>,
|
||||
pub(super) selection: Option<(TerminalSelection, u64)>,
|
||||
pub(super) selection_origin_version: Option<u64>,
|
||||
@@ -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<TerminalInputState> = 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<TerminalInputState> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
+75
-8
@@ -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<u8>,
|
||||
) -> (Vec<u8>, Vec<u8>) {
|
||||
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<u8> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user