diff --git a/docs-site/docs/development/backend.md b/docs-site/docs/development/backend.md index 39e9f1cc..a42589cf 100644 --- a/docs-site/docs/development/backend.md +++ b/docs-site/docs/development/backend.md @@ -69,11 +69,11 @@ src-tauri/src/cmd/ `src-tauri/src/core/session.rs` 中的 `SessionManager` 是会话中心: - 注册 / 移除活动会话 -- 向具体 session 的 I/O loop 发送 `Write` / `Resize` / `Close` / `Attach` 命令 +- 向具体 session 的 I/O loop 发送 `Write` / `Resize` / `Close` / `AttachConfirmed` 命令 - 管理命令历史与模糊搜索存储 - 发出 `sessions-changed`、`command-history-changed` 等事件 -它暴露给前端的会话元信息中,还包含 `injection_active`,用于标识当前会话是否支持终端路径跟踪等增强能力。 +它暴露给前端的会话元信息中包含 `injection_active`(命令确认能力),并通过独立的 `dynamic_title_enabled`、`dynamic_title_integration_active` 和 `trusted_initial_title` 字段描述动态标签策略与运行时集成能力。 ## 会话实现 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 90e20225..b0125cb0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4934,6 +4934,7 @@ dependencies = [ "ironrdp", "keyring", "lazy_static", + "libc", "md-5 0.10.6", "notify", "nucleo-matcher", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 64208902..881f3275 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -202,6 +202,9 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } [target.'cfg(not(windows))'.dependencies] ironrdp = { version = "0.17", default-features = false, features = ["rustls"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(windows)'.dependencies] ironrdp = { version = "0.17", default-features = false, features = ["native-tls"] } webview2-com = "0.38.2" diff --git a/src-tauri/src/cmd/connection.rs b/src-tauri/src/cmd/connection.rs index 917ad013..f825348d 100644 --- a/src-tauri/src/cmd/connection.rs +++ b/src-tauri/src/cmd/connection.rs @@ -874,6 +874,7 @@ e+JpiSq66Z6GIt0801skPh20jxOO3F52SoX1IeO5D5PXfZrfSZlw6S8c7bwyp2FHxDewRx legacy_agent_forwarding: None, agent_forwarding_config: None, encoding: String::new(), + dynamic_tab_title: false, }, group_id: None, description: None, @@ -951,6 +952,7 @@ e+JpiSq66Z6GIt0801skPh20jxOO3F52SoX1IeO5D5PXfZrfSZlw6S8c7bwyp2FHxDewRx working_dir: None, ai_execution_profile: AiExecutionProfile::Auto, encoding: String::new(), + dynamic_tab_title: false, }, group_id: None, description: None, diff --git a/src-tauri/src/cmd/local_fs.rs b/src-tauri/src/cmd/local_fs.rs index a1a3385e..de519fe7 100644 --- a/src-tauri/src/cmd/local_fs.rs +++ b/src-tauri/src/cmd/local_fs.rs @@ -560,7 +560,9 @@ async fn set_local_mode_if_supported(_path: &str, _mode: Option) -> AppR mod tests { use super::*; use crate::config::AiExecutionProfile; - use crate::core::{SessionHandle, SessionInfo, session_command_channel}; + use crate::core::{ + DynamicTitleCapabilities, SessionHandle, SessionInfo, session_command_channel, + }; use tokio::sync::Mutex; fn temp_test_dir(name: &str) -> PathBuf { @@ -796,14 +798,16 @@ mod tests { owner_window_label: None, ai_execution_profile: AiExecutionProfile::default(), injection_active: true, + dynamic_title_capabilities: DynamicTitleCapabilities::default(), remote_file_browser_enabled: true, remote_stats_enabled: true, ssh_profile: None, }, cmd_tx, + startup_input_barrier: None, ssh_config: None, ssh_handle: None, - cwd: Arc::new(Mutex::new(None)), + cwd: Arc::new(Mutex::new(Default::default())), remote_fs: None, }) .await; diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index 5020165b..52aaa5d2 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -138,6 +138,7 @@ fn normalize_temporary_ssh_config(mut config: ssh::SshConfig, encoding: &str) -> config.proxy_jump = None; config.post_login = None; config.ssh_algorithms = None; + config.dynamic_tab_title = false; if config.ssh_profile == crate::config::SshProfile::NetworkDevice && config.terminal_type == crate::config::SshTerminalType::Xterm256Color { @@ -198,6 +199,7 @@ pub async fn create_local_session( shell_path, shell_args, working_dir: saved_working_dir, + dynamic_tab_title, .. } => { let (working_dir, fail_on_missing_working_dir) = @@ -210,6 +212,7 @@ pub async fn create_local_session( fail_on_missing_working_dir, name: conn.name, encoding, + dynamic_tab_title, }) } _ => None, @@ -226,6 +229,7 @@ pub async fn create_local_session( fail_on_missing_working_dir: true, name: "Local Terminal".to_string(), encoding, + dynamic_tab_title: false, }) } else { None @@ -1004,9 +1008,13 @@ pub async fn attach_session( client_timestamp: None, }); + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); state - .send_command(&session_id, SessionCommand::Attach) + .send_command(&session_id, SessionCommand::AttachConfirmed { ack: ack_tx }) + .await?; + ack_rx .await + .map_err(|_| AppError::Channel("Session attach acknowledgement was dropped".to_string())) } #[tauri::command] @@ -1103,6 +1111,14 @@ pub async fn list_sessions( Ok(state.list_sessions().await) } +#[tauri::command] +pub async fn get_session_cwd_presentation( + state: tauri::State<'_, Arc>, + session_id: String, +) -> AppResult> { + Ok(state.session_cwd_presentation(&session_id).await) +} + #[tauri::command] pub async fn add_command_history( state: tauri::State<'_, Arc>, @@ -1125,6 +1141,17 @@ pub async fn register_command_submission( Ok(()) } +#[tauri::command] +pub async fn register_command_confirmation_candidate( + state: tauri::State<'_, Arc>, + session_id: String, + command: String, +) -> AppResult { + Ok(state + .register_confirmation_candidate(&session_id, command) + .await) +} + #[tauri::command] pub async fn get_command_history( state: tauri::State<'_, Arc>, diff --git a/src-tauri/src/cmd/stats.rs b/src-tauri/src/cmd/stats.rs index 1beb1d76..765c93a0 100644 --- a/src-tauri/src/cmd/stats.rs +++ b/src-tauri/src/cmd/stats.rs @@ -53,7 +53,7 @@ pub async fn get_terminal_cwd( }; let cached = cwd_arc.lock().await; - if let Some(cwd) = cached.as_ref() { + if let Some(cwd) = cached.operational_path.as_ref() { return Ok(cwd.clone()); } @@ -75,5 +75,5 @@ pub async fn try_get_terminal_cwd( session.cwd.clone() }; - Ok(cwd_arc.lock().await.clone()) + Ok(cwd_arc.lock().await.operational_path.clone()) } diff --git a/src-tauri/src/config/connection.rs b/src-tauri/src/config/connection.rs index 710350d2..58e8c2b9 100644 --- a/src-tauri/src/config/connection.rs +++ b/src-tauri/src/config/connection.rs @@ -541,6 +541,9 @@ pub enum ConnectionType { agent_forwarding_config: Option, #[serde(default)] encoding: String, + /// Allow sanitized remote OSC 0/2 titles to decorate this saved SSH tab. + #[serde(default, skip_serializing_if = "is_false")] + dynamic_tab_title: bool, }, LocalTerminal { #[serde(default)] @@ -553,6 +556,10 @@ pub enum ConnectionType { ai_execution_profile: AiExecutionProfile, #[serde(default)] encoding: String, + /// When true, the tab label follows the session's dynamic window + /// title (OSC 0/2) / cwd instead of the static connection name. + #[serde(default, skip_serializing_if = "is_false")] + dynamic_tab_title: bool, }, Telnet { host: String, @@ -1645,6 +1652,7 @@ mod tests { policy: SshAgentForwardingPolicy::default(), }), encoding: String::new(), + dynamic_tab_title: false, }; let error = validate_ssh_agent_settings(&config).expect_err("endpoint limit"); @@ -1695,6 +1703,7 @@ mod tests { policy: SshAgentForwardingPolicy::default(), }), encoding: String::new(), + dynamic_tab_title: false, }; assert!(validate_ssh_agent_settings(&config).is_err()); } @@ -1740,6 +1749,7 @@ mod tests { policy: SshAgentForwardingPolicy::default(), }), encoding: String::new(), + dynamic_tab_title: false, }; let error = validate_ssh_agent_settings(&config).expect_err("duplicate endpoint"); @@ -1772,6 +1782,7 @@ mod tests { policy: SshAgentForwardingPolicy::default(), }), encoding: String::new(), + dynamic_tab_title: false, }; let error = validate_ssh_agent_settings(&config).expect_err("duplicate endpoint"); @@ -1794,6 +1805,7 @@ mod tests { policy: SshAgentForwardingPolicy::Allowlist { fingerprints }, }), encoding: String::new(), + dynamic_tab_title: false, }; let duplicate = base(vec!["SHA256:test".to_string(), "SHA256:test".to_string()]); @@ -2195,6 +2207,89 @@ mod tests { assert!(!x11_forwarding); } + #[test] + fn ssh_dynamic_title_defaults_off_and_round_trips_when_enabled() { + let legacy: SavedConnection = serde_json::from_value(serde_json::json!({ + "id": "conn-legacy", + "name": "Legacy", + "type": "ssh", + "host": "example.com", + "port": 22, + "username": "root" + })) + .expect("legacy connection"); + assert!(matches!( + legacy.config, + ConnectionType::Ssh { + dynamic_tab_title: false, + .. + } + )); + + let enabled: SavedConnection = serde_json::from_value(serde_json::json!({ + "id": "conn-enabled", + "name": "Enabled", + "type": "ssh", + "host": "example.com", + "port": 22, + "username": "root", + "dynamic_tab_title": true + })) + .expect("enabled connection"); + assert!(matches!( + enabled.config, + ConnectionType::Ssh { + dynamic_tab_title: true, + .. + } + )); + let serialized = serde_json::to_value(enabled).expect("serialize connection"); + assert_eq!( + serialized.get("dynamic_tab_title"), + Some(&serde_json::json!(true)) + ); + } + + #[test] + fn local_dynamic_title_defaults_off_and_round_trips_when_enabled() { + let legacy: SavedConnection = serde_json::from_value(serde_json::json!({ + "id": "local-legacy", + "name": "Legacy Local", + "type": "local_terminal", + "shell_path": "powershell.exe" + })) + .expect("legacy local connection"); + assert!(matches!( + legacy.config, + ConnectionType::LocalTerminal { + dynamic_tab_title: false, + .. + } + )); + + let enabled: SavedConnection = serde_json::from_value(serde_json::json!({ + "id": "local-enabled", + "name": "Enabled Local", + "type": "local_terminal", + "shell_path": "powershell.exe", + "dynamic_tab_title": true + })) + .expect("enabled local connection"); + assert!(matches!( + enabled.config, + ConnectionType::LocalTerminal { + dynamic_tab_title: true, + .. + } + )); + assert_eq!( + serde_json::to_value(enabled) + .expect("serialize local connection") + .get("dynamic_tab_title"), + Some(&serde_json::json!(true)) + ); + } + #[test] fn telnet_connection_defaults_compatibility_options() { let connection: SavedConnection = serde_json::from_value(serde_json::json!({ diff --git a/src-tauri/src/core/ai/agent.rs b/src-tauri/src/core/ai/agent.rs index 2d45a8f2..fd4d16f2 100644 --- a/src-tauri/src/core/ai/agent.rs +++ b/src-tauri/src/core/ai/agent.rs @@ -201,7 +201,7 @@ async fn resolve_background_execution_target( SessionType::Local => { let cwd_arc = session.cwd.clone(); drop(sessions); - let cwd = cwd_arc.lock().await.clone(); + let cwd = cwd_arc.lock().await.safe_local_execution_cwd(); Ok(BackgroundExecutionTarget::Local { cwd }) } ref session_type => Ok(BackgroundExecutionTarget::Unsupported(session_type.clone())), @@ -1436,7 +1436,9 @@ mod tests { #[tokio::test] async fn background_execution_rejects_unsupported_session_types() { - use crate::core::{SessionHandle, SessionInfo, session_command_channel}; + use crate::core::{ + DynamicTitleCapabilities, SessionHandle, SessionInfo, session_command_channel, + }; use tokio::sync::Mutex; let manager = SessionManager::new(); @@ -1453,14 +1455,16 @@ mod tests { owner_window_label: None, ai_execution_profile: AiExecutionProfile::SendOnly, injection_active: false, + dynamic_title_capabilities: DynamicTitleCapabilities::default(), remote_file_browser_enabled: false, remote_stats_enabled: false, ssh_profile: None, }, cmd_tx, + startup_input_barrier: None, ssh_config: None, ssh_handle: None, - cwd: Arc::new(Mutex::new(None)), + cwd: Arc::new(Mutex::new(Default::default())), remote_fs: None, }) .await; diff --git a/src-tauri/src/core/importer/electerm.rs b/src-tauri/src/core/importer/electerm.rs index 2c6dc84f..9547f9de 100644 --- a/src-tauri/src/core/importer/electerm.rs +++ b/src-tauri/src/core/importer/electerm.rs @@ -148,6 +148,7 @@ fn prepare_electerm_bookmark( legacy_agent_forwarding: None, agent_forwarding_config: None, encoding: String::new(), + dynamic_tab_title: false, }, group_path, description: None, diff --git a/src-tauri/src/core/importer/merge.rs b/src-tauri/src/core/importer/merge.rs index 9be8807d..e57eea72 100644 --- a/src-tauri/src/core/importer/merge.rs +++ b/src-tauri/src/core/importer/merge.rs @@ -96,6 +96,7 @@ fn import_legacy_sessions( legacy_agent_forwarding: None, agent_forwarding_config: None, encoding: String::new(), + dynamic_tab_title: false, }, group_id, description: sess.description, diff --git a/src-tauri/src/core/importer/nyaterm_json.rs b/src-tauri/src/core/importer/nyaterm_json.rs index af02956a..47113367 100644 --- a/src-tauri/src/core/importer/nyaterm_json.rs +++ b/src-tauri/src/core/importer/nyaterm_json.rs @@ -110,6 +110,7 @@ fn prepare_nyaterm_json_session( description, sort_order, icon, + dynamic_tab_title, } => { validate_port(port, "ssh session")?; let context = format!("ssh session '{name}'"); @@ -125,6 +126,7 @@ fn prepare_nyaterm_json_session( legacy_agent_forwarding: None, agent_forwarding_config: None, encoding: String::new(), + dynamic_tab_title, }, group_path: normalize_optional_group_path(group_path, &context)?, description: normalize_optional_string(description), @@ -147,6 +149,7 @@ fn prepare_nyaterm_json_session( description, sort_order, icon, + dynamic_tab_title, } => { let context = format!("local_terminal session '{name}'"); Ok(PreparedJsonConnection { @@ -157,6 +160,7 @@ fn prepare_nyaterm_json_session( working_dir: normalize_optional_string(working_dir), ai_execution_profile: AiExecutionProfile::Auto, encoding: String::new(), + dynamic_tab_title, }, group_path: normalize_optional_group_path(group_path, &context)?, description: normalize_optional_string(description), diff --git a/src-tauri/src/core/importer/termius.rs b/src-tauri/src/core/importer/termius.rs index 13698a10..7a557dce 100644 --- a/src-tauri/src/core/importer/termius.rs +++ b/src-tauri/src/core/importer/termius.rs @@ -1077,6 +1077,7 @@ fn prepare_termius_connections( legacy_agent_forwarding: None, agent_forwarding_config: None, encoding: String::new(), + dynamic_tab_title: false, }, group_path, description: Some("Imported from Termius".to_string()), diff --git a/src-tauri/src/core/importer/tests.rs b/src-tauri/src/core/importer/tests.rs index bffec682..7b211091 100644 --- a/src-tauri/src/core/importer/tests.rs +++ b/src-tauri/src/core/importer/tests.rs @@ -30,6 +30,7 @@ mod tests { "host": "web-01.example.com", "port": 22, "username": "deploy", + "dynamic_tab_title": true, "auth": { "mode": "password", "password": "replace-me" } }, { @@ -72,7 +73,8 @@ mod tests { "type": "local_terminal", "shell_path": "pwsh.exe", "shell_args": "-NoLogo", - "working_dir": "C:\\Users\\me" + "working_dir": "C:\\Users\\me", + "dynamic_tab_title": true } ] } @@ -568,6 +570,13 @@ mod tests { let direct_auth = prepared.connections[0].auth.as_ref().expect("direct auth"); assert_eq!(direct_auth.mode, "password"); + assert!(matches!( + &prepared.connections[0].config, + ConnectionType::Ssh { + dynamic_tab_title: true, + .. + } + )); assert!(direct_auth.password_id.is_none()); assert_ne!(direct_auth.password.as_deref(), Some("replace-me")); @@ -589,6 +598,7 @@ mod tests { ConnectionType::LocalTerminal { shell_path, shell_args, + dynamic_tab_title: true, .. } if shell_path == "pwsh.exe" && shell_args == "-NoLogo" )); diff --git a/src-tauri/src/core/importer/types.rs b/src-tauri/src/core/importer/types.rs index 4b9b189e..d0bb2459 100644 --- a/src-tauri/src/core/importer/types.rs +++ b/src-tauri/src/core/importer/types.rs @@ -101,6 +101,8 @@ enum NyatermJsonSession { sort_order: i32, #[serde(default)] icon: Option, + #[serde(default)] + dynamic_tab_title: bool, }, LocalTerminal { name: String, @@ -118,6 +120,8 @@ enum NyatermJsonSession { sort_order: i32, #[serde(default)] icon: Option, + #[serde(default)] + dynamic_tab_title: bool, }, Telnet { name: String, diff --git a/src-tauri/src/core/importer/windterm.rs b/src-tauri/src/core/importer/windterm.rs index 3e6fa126..c290f0fd 100644 --- a/src-tauri/src/core/importer/windterm.rs +++ b/src-tauri/src/core/importer/windterm.rs @@ -126,6 +126,7 @@ fn parse_windterm_content_with_crypto( legacy_agent_forwarding: None, agent_forwarding_config: None, encoding: String::new(), + dynamic_tab_title: false, }, group_path, description, diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index fbc6bfce..bd06ade1 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -43,10 +43,14 @@ pub use recording::{ TerminalHistorySearchResponse, }; pub use session::{ - SessionCommand, SessionCommandReceiver, SessionCommandSender, SessionHandle, SessionInfo, - SessionManager, SessionReadyHook, SessionType, SharedCwd, + CwdPresentation, DynamicTitleCapabilities, SessionCommand, SessionCommandReceiver, + SessionCommandSender, SessionHandle, SessionInfo, SessionManager, SessionReadyHook, + SessionType, SharedCwd, +}; +pub(crate) use session::{ + SessionCwdReplacement, now_session_started_at, replace_cwd_state, session_command_channel, + update_cwd_if_changed, }; -pub(crate) use session::{now_session_started_at, session_command_channel, update_cwd_if_changed}; pub use terminal_session::local::{LocalSessionConfig, create_local_session}; pub use terminal_session::serial::{SerialConfig, create_serial_session, list_serial_ports}; pub use terminal_session::telnet::{ diff --git a/src-tauri/src/core/output.rs b/src-tauri/src/core/output.rs index 4707812a..13203001 100644 --- a/src-tauri/src/core/output.rs +++ b/src-tauri/src/core/output.rs @@ -1,5 +1,5 @@ use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; @@ -54,6 +54,9 @@ struct FlushResult { pub struct SessionOutputCoalescer { sink: Arc, flow_control_tx: Option, + /// Serializes all sink emissions so concurrent producer/attach threads + /// cannot interleave terminal output chunks out of order. + emit_lock: Mutex<()>, state: Mutex, } @@ -85,6 +88,7 @@ impl SessionOutputCoalescer { Arc::new(Self { sink: Arc::new(sink), flow_control_tx: None, + emit_lock: Mutex::new(()), state: Mutex::new(OutputState::default()), }) } @@ -96,10 +100,40 @@ impl SessionOutputCoalescer { Arc::new(Self { sink: Arc::new(sink), flow_control_tx: Some(flow_control_tx), + emit_lock: Mutex::new(()), state: Mutex::new(OutputState::default()), }) } + fn lock_emit(&self) -> MutexGuard<'_, ()> { + match self.emit_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => { + tracing::warn!("session output sink mutex was poisoned; recovering"); + let guard = poisoned.into_inner(); + self.emit_lock.clear_poison(); + guard + } + } + } + + fn emit_payload(&self, payload: TerminalOutputPayload) { + let _emit_guard = self.lock_emit(); + (self.sink)(payload); + } + + fn lock_state(&self) -> MutexGuard<'_, OutputState> { + match self.state.lock() { + Ok(state) => state, + Err(poisoned) => { + tracing::warn!("session output state mutex was poisoned; recovering"); + let state = poisoned.into_inner(); + self.state.clear_poison(); + state + } + } + } + pub fn push(self: &Arc, text: impl AsRef) { self.push_owned(text.as_ref().to_string()); } @@ -113,7 +147,7 @@ impl SessionOutputCoalescer { let mut flush_now = false; let mut close_for_overflow = false; let flow_change = { - let mut state = self.state.lock().unwrap(); + let mut state = self.lock_state(); let was_empty = state.pending.is_empty(); state.pending_bytes = state.pending_bytes.saturating_add(text.len()); state.pending.push_back(text); @@ -155,7 +189,7 @@ impl SessionOutputCoalescer { } let (flow_change, schedule_timer) = { - let mut state = self.state.lock().unwrap(); + let mut state = self.lock_state(); state.unacked_bytes = state.unacked_bytes.saturating_sub(bytes); let flow_change = update_flow_state(&mut state); let schedule_timer = if state.attached @@ -178,22 +212,42 @@ impl SessionOutputCoalescer { } } - pub fn attach(self: &Arc) { - let result = { - let mut state = self.state.lock().unwrap(); - state.attached = true; - state.unacked_bytes = 0; - state.next_flush_id = state.next_flush_id.wrapping_add(1); - state.scheduled_flush_id = None; - flush_from_state(&mut state) - }; + pub fn is_attached(&self) -> bool { + self.lock_state().attached + } - self.apply_flush_result(result); + #[cfg(test)] + pub fn attach(self: &Arc) { + let (ack, _ack_rx) = tokio::sync::oneshot::channel(); + self.attach_confirmed(ack); + } + + pub fn attach_confirmed(self: &Arc, ack: tokio::sync::oneshot::Sender<()>) { + let mut state = self.lock_state(); + state.attached = true; + state.unacked_bytes = 0; + state.next_flush_id = state.next_flush_id.wrapping_add(1); + state.scheduled_flush_id = None; + + // Attach may race PTY push. Serialize against every other sink + // emission, then hold the state lock while draining; a competing push + // queues after the detached backlog instead of overtaking it before + // the renderer settles/publishes title state. + let emit_guard = self.lock_emit(); + while let Some(payload) = take_pending_batch(&mut state) { + (self.sink)(payload); + } + drop(emit_guard); + let flow_change = update_flow_state(&mut state); + drop(state); + + self.send_flow_change(flow_change); + let _ = ack.send(()); } pub fn detach(self: &Arc) { let flow_change = { - let mut state = self.state.lock().unwrap(); + let mut state = self.lock_state(); state.attached = false; state.unacked_bytes = 0; state.next_flush_id = state.next_flush_id.wrapping_add(1); @@ -206,7 +260,7 @@ impl SessionOutputCoalescer { pub fn close(self: &Arc) { let result = { - let mut state = self.state.lock().unwrap(); + let mut state = self.lock_state(); state.next_flush_id = state.next_flush_id.wrapping_add(1); state.scheduled_flush_id = None; flush_all_from_state(&mut state) @@ -225,7 +279,7 @@ impl SessionOutputCoalescer { fn flush_if_scheduled(self: &Arc, flush_id: u64) { let result = { - let mut state = self.state.lock().unwrap(); + let mut state = self.lock_state(); if state.scheduled_flush_id != Some(flush_id) { return; } @@ -243,7 +297,7 @@ impl SessionOutputCoalescer { fn flush_pending(self: &Arc) { let result = { - let mut state = self.state.lock().unwrap(); + let mut state = self.lock_state(); if !state.attached { return; } @@ -258,12 +312,12 @@ impl SessionOutputCoalescer { fn apply_flush_result(self: &Arc, result: FlushResult) { if let Some(payload) = result.payload { - (self.sink)(payload); + self.emit_payload(payload); } self.send_flow_change(result.flow_change); if result.reschedule { let flush_id = { - let mut state = self.state.lock().unwrap(); + let mut state = self.lock_state(); if !state.attached || state.pending.is_empty() || state.scheduled_flush_id.is_some() { None @@ -474,6 +528,7 @@ mod tests { }; use crate::core::{SessionCommand, session_command_channel}; use std::sync::{Arc, Mutex}; + use std::time::Duration as StdDuration; use tokio::time::{Duration, Instant, advance, sleep}; fn collect_sink() -> ( @@ -511,6 +566,42 @@ mod tests { .sum() } + #[test] + fn every_output_state_entrypoint_recovers_from_a_poisoned_mutex() { + let (_emitted, sink) = collect_sink(); + let output = SessionOutputCoalescer::with_sink(sink); + let poison = |output: &Arc| { + let poisoned_output = output.clone(); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _guard = poisoned_output.state.lock().expect("lock output state"); + panic!("poison output state"); + })); + }; + + output.attach(); + poison(&output); + output.detach(); + assert!(!output.is_attached()); + + poison(&output); + output.attach(); + assert!(output.is_attached()); + + poison(&output); + output.ack(1); + output.detach(); + poison(&output); + output.push_owned("queued while detached".to_string()); + assert_eq!( + output.lock_state().pending_bytes, + "queued while detached".len() + ); + + poison(&output); + output.close(); + assert!(!output.state.is_poisoned()); + } + #[tokio::test] async fn timer_flush_batches_pending_output() { let (emitted, sink) = collect_sink(); @@ -654,6 +745,66 @@ mod tests { assert!(emitted.iter().all(|payload| payload.dropped_bytes == 0)); } + #[tokio::test] + async fn attach_ack_waits_until_every_detached_batch_reaches_the_sink() { + let (emitted, sink) = collect_sink(); + let output = SessionOutputCoalescer::with_sink(sink); + output.push_owned("a".repeat(OUTPUT_FLOOD_BATCH_BYTES)); + output.push_owned("b".repeat(OUTPUT_FLOOD_BATCH_BYTES)); + + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + output.attach_confirmed(ack_tx); + ack_rx.await.expect("attach acknowledgement"); + + let emitted = emitted.lock().unwrap(); + assert!(emitted.len() >= 2); + let combined = emitted + .iter() + .map(|payload| payload.data.as_str()) + .collect::(); + assert_eq!( + combined, + format!( + "{}{}", + "a".repeat(OUTPUT_FLOOD_BATCH_BYTES), + "b".repeat(OUTPUT_FLOOD_BATCH_BYTES) + ) + ); + } + + #[test] + fn attach_replay_cannot_be_overtaken_by_a_concurrent_push() { + let emitted = Arc::new(Mutex::new(Vec::::new())); + let emitted_sink = emitted.clone(); + let output = SessionOutputCoalescer::with_sink(move |payload| { + std::thread::sleep(StdDuration::from_millis(10)); + emitted_sink.lock().unwrap().push(payload.data); + }); + output.push_owned("old-title\n".to_string()); + + // Large enough to trigger an immediate size flush (no async timer), + // so its emission is fully synchronous after attach drains. + let new_payload = format!("new-title\n{}", "n".repeat(OUTPUT_NORMAL_BATCH_BYTES)); + let pusher = output.clone(); + let handle = std::thread::spawn(move || { + std::thread::sleep(StdDuration::from_millis(5)); + pusher.push_owned(new_payload); + }); + + let (ack_tx, mut ack_rx) = tokio::sync::oneshot::channel(); + output.attach_confirmed(ack_tx); + handle.join().expect("join concurrent push"); + assert!(ack_rx.try_recv().is_ok()); + + // The racing push blocks on the state lock during replay and emits + // after it, so the detached backlog stays first in sink order. + let emitted = emitted.lock().unwrap(); + let data = emitted.concat(); + let old = data.find("old-title").expect("old title"); + let new = data.find("new-title").expect("new title"); + assert!(old < new, "{data:?}"); + } + #[tokio::test] async fn detached_pending_output_is_preserved_until_attach() { let (emitted, sink) = collect_sink(); diff --git a/src-tauri/src/core/portable_snapshot/tests.rs b/src-tauri/src/core/portable_snapshot/tests.rs index 33cfecb5..c33b2b9a 100644 --- a/src-tauri/src/core/portable_snapshot/tests.rs +++ b/src-tauri/src/core/portable_snapshot/tests.rs @@ -312,6 +312,13 @@ mod tests { ); assert_eq!(shell_args, "-NoLogo"); assert_eq!(working_dir.as_deref(), Some("C:\\Users\\me")); + assert!(matches!( + &local.config, + config::ConnectionType::LocalTerminal { + dynamic_tab_title: true, + .. + } + )); let serial = sessions .connections @@ -579,6 +586,7 @@ mod tests { working_dir: Some("C:\\Users\\me".to_string()), ai_execution_profile: config::AiExecutionProfile::Auto, encoding: String::new(), + dynamic_tab_title: true, }, group_id: None, description: None, @@ -650,6 +658,7 @@ mod tests { legacy_agent_forwarding: None, agent_forwarding_config: None, encoding: String::new(), + dynamic_tab_title: true, }, group_id: None, description: None, @@ -697,6 +706,13 @@ mod tests { } fn assert_asset_metadata_preserved(sessions: &config::SessionsConfig) { + assert!(matches!( + &sessions.connections[0].config, + config::ConnectionType::Ssh { + dynamic_tab_title: true, + .. + } + )); let asset = sessions.connections[0].asset.as_ref().expect("asset"); assert_eq!(asset.device_type, Some(config::AssetDeviceType::Physical)); assert_eq!(asset.hostname.as_deref(), Some("gpu-node-01")); diff --git a/src-tauri/src/core/recording/types.rs b/src-tauri/src/core/recording/types.rs index 1783e9b4..4397d37b 100644 --- a/src-tauri/src/core/recording/types.rs +++ b/src-tauri/src/core/recording/types.rs @@ -68,6 +68,7 @@ pub enum InputOrigin { CredentialAutofill, OtpAutofill, SyncInput, + TerminalResponse, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] diff --git a/src-tauri/src/core/session.rs b/src-tauri/src/core/session.rs index c336ca4e..1603e20d 100644 --- a/src-tauri/src/core/session.rs +++ b/src-tauri/src/core/session.rs @@ -17,7 +17,7 @@ use serde_json::json; use std::any::Any; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::Duration; use tauri::Emitter; use time::format_description::well_known::Rfc3339; @@ -31,7 +31,59 @@ const COMMAND_QUEUE_CRITICAL_THRESHOLD: usize = 1000; const COMMAND_QUEUE_HIGH_RESET_THRESHOLD: usize = 50; const COMMAND_QUEUE_CRITICAL_RESET_THRESHOLD: usize = 500; -pub type SharedCwd = Arc>>; +/// Safe, backend-resolved cwd values used by the dynamic-title UI. +/// +/// This is intentionally separate from the legacy cwd projection: existing +/// `cwd-changed-*` consumers keep their released string semantics, while new +/// presentation consumers receive a validated, bounded representation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CwdPresentation { + /// Compact path/URI already formatted for a tab title. + pub title: String, + /// Full safe value shown in the tab tooltip. + pub display_path: String, + /// Exact shell-oriented path for ordinary locations, otherwise an encoded URI. + pub copy_value: String, + /// Host-native filesystem path for operational consumers. `None` means + /// the location is presentation-only (unsafe bytes or an unmappable shell path). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operational_path: Option, + /// True when `copy_value` is an encoded URI rather than a native path. + pub copy_as_uri: bool, +} + +/// Single in-memory cwd record with compatibility, operational and strict +/// presentation projections. All three are replaced under one lock. +#[derive(Debug, Default, Clone)] +pub struct SessionCwdState { + pub legacy_path: Option, + pub operational_path: Option, + pub presentation: Option, +} + +impl SessionCwdState { + pub fn safe_local_execution_cwd(&self) -> Option { + self.operational_path.clone() + } +} + +#[derive(Debug)] +pub(crate) struct SessionCwdReplacement { + pub legacy_path: Option, + pub operational_path: Option, + pub presentation: Option, +} + +#[derive(Debug)] +pub(crate) struct SessionCwdChanges { + pub operational_changed: bool, + pub operational_path: Option, + pub presentation_changed: bool, + pub presentation: Option, +} + +pub type SharedCwd = Arc>; pub type SessionReadyHook = Arc; pub(crate) fn normalize_cwd_path(path: &str) -> String { @@ -54,16 +106,32 @@ pub(crate) async fn update_cwd_if_changed(cwd: &SharedCwd, next_path: &str) -> O } let mut cached = cwd.lock().await; - let unchanged = cached - .as_deref() - .is_some_and(|current| normalize_cwd_path(current) == normalized); - - if unchanged { - return None; + let operational_changed = cached.operational_path.as_deref() != Some(normalized.as_str()); + cached.legacy_path = Some(normalized.clone()); + cached.operational_path = Some(normalized.clone()); + if operational_changed { + Some(normalized) + } else { + None } +} - *cached = Some(normalized.clone()); - Some(normalized) +pub(crate) async fn replace_cwd_state( + cwd: &SharedCwd, + replacement: SessionCwdReplacement, +) -> SessionCwdChanges { + let mut cached = cwd.lock().await; + let operational_changed = cached.operational_path != replacement.operational_path; + let presentation_changed = cached.presentation != replacement.presentation; + cached.legacy_path = replacement.legacy_path; + cached.operational_path = replacement.operational_path.clone(); + cached.presentation = replacement.presentation.clone(); + SessionCwdChanges { + operational_changed, + operational_path: replacement.operational_path, + presentation_changed, + presentation: replacement.presentation, + } } fn is_windows_drive_root(path: &str) -> bool { @@ -81,6 +149,39 @@ pub enum SessionType { Serial, } +/// Runtime-only dynamic-title policy and integration capabilities. +/// +/// This is flattened into [`SessionInfo`] so the established frontend wire +/// fields remain top-level and backward compatible. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct DynamicTitleCapabilities { + /// Whether application-provided dynamic titles may be promoted to the tab. + /// This is copied from the saved connection at session creation time. + #[serde(default, rename = "dynamic_title_enabled")] + pub enabled: bool, + /// Whether the selected shell received NyaTerm's dynamic-title/cwd hooks. + /// Passive OSC title handling may still work when this is false. + #[serde(default, rename = "dynamic_title_integration_active")] + pub integration_active: bool, + /// Trusted Windows Local executable identity used for initial ConPTY filtering. + #[serde( + default, + rename = "trusted_initial_title", + skip_serializing_if = "Option::is_none" + )] + pub trusted_initial_title: Option, +} + +impl DynamicTitleCapabilities { + pub fn new(enabled: bool, trusted_initial_title: Option) -> Self { + Self { + enabled, + integration_active: false, + trusted_initial_title, + } + } +} + /// Metadata for a session exposed to the frontend. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionInfo { @@ -97,10 +198,12 @@ pub struct SessionInfo { /// Effective AI command execution profile for this session. #[serde(default)] pub ai_execution_profile: AiExecutionProfile, - /// True when backend terminal-path tracking is available for this session. - /// Currently this is enabled for sessions that can report directory changes to the backend. + /// True when the released backend shell integration expects private + /// command-confirmation events. Dynamic-title cwd hooks are independent. #[serde(default)] pub injection_active: bool, + #[serde(flatten)] + pub dynamic_title_capabilities: DynamicTitleCapabilities, /// True when the remote file browser is enabled for this session. #[serde(default = "default_remote_file_browser_enabled")] pub remote_file_browser_enabled: bool, @@ -129,8 +232,10 @@ pub(crate) fn now_session_started_at() -> String { /// Commands sent from the frontend to a session's I/O loop. pub enum SessionCommand { - /// Frontend listener is ready — flush buffered output and start emitting. - Attach, + /// Attach and acknowledge only after the backend has flushed its buffered + /// output into the frontend event queue. Used by hibernation replay so + /// dynamic title publication resumes after the attach boundary. + AttachConfirmed { ack: oneshot::Sender<()> }, /// Frontend renderer has been hibernated; keep the session alive but stop emitting output. DetachRenderer, /// Input to send to the terminal. @@ -388,6 +493,10 @@ impl SessionCommandReceiver { command } + pub fn is_empty(&self) -> bool { + self.receiver.is_empty() + } + pub fn blocking_recv(&mut self) -> Option { let command = self.receiver.blocking_recv(); if command.is_some() { @@ -422,10 +531,145 @@ pub fn session_command_channel( ) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StartupInputBarrierPhase { + Pending, + Injected, + Cancelled, +} + +#[derive(Debug)] +struct StartupInputBarrierState { + phase: StartupInputBarrierPhase, + pending_terminal_queries: usize, + pending_terminal_writes: usize, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum StartupInjectionAttempt { + Injected(T), + WaitForTerminalResponse, + Cancelled, +} + +/// Orders Local startup injection against real input before it is enqueued. +/// The mutex is held across the short PTY source-command write, so either the +/// input cancellation wins first or the complete injection write wins first. +pub struct StartupInputBarrier { + state: StdMutex, +} + +impl StartupInputBarrier { + pub fn new() -> Self { + Self { + state: StdMutex::new(StartupInputBarrierState { + phase: StartupInputBarrierPhase::Pending, + pending_terminal_queries: 0, + pending_terminal_writes: 0, + }), + } + } + + fn lock_state(&self) -> std::sync::MutexGuard<'_, StartupInputBarrierState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn cancel_pending(&self) -> bool { + let mut state = self.lock_state(); + if state.phase != StartupInputBarrierPhase::Pending { + return false; + } + state.phase = StartupInputBarrierPhase::Cancelled; + true + } + + pub fn is_cancelled(&self) -> bool { + self.lock_state().phase == StartupInputBarrierPhase::Cancelled + } + + pub fn cancel_for_input(&self, origin: InputOrigin) -> bool { + if origin == InputOrigin::TerminalResponse { + return false; + } + let mut state = self.lock_state(); + if state.phase == StartupInputBarrierPhase::Pending { + state.phase = StartupInputBarrierPhase::Cancelled; + } + state.phase == StartupInputBarrierPhase::Cancelled + } + + pub fn note_terminal_queries(&self, count: usize) { + if count == 0 { + return; + } + let mut state = self.lock_state(); + if state.phase == StartupInputBarrierPhase::Pending { + state.pending_terminal_queries = state.pending_terminal_queries.saturating_add(count); + } + } + + pub fn note_terminal_response_enqueued(&self) { + let mut state = self.lock_state(); + if state.phase == StartupInputBarrierPhase::Pending + && state.pending_terminal_writes < state.pending_terminal_queries + { + state.pending_terminal_writes = state.pending_terminal_writes.saturating_add(1); + } + } + + pub fn resolve_terminal_response(&self) -> bool { + let mut state = self.lock_state(); + let matched_query = state.pending_terminal_queries > 0; + if matched_query { + state.pending_terminal_queries -= 1; + state.pending_terminal_writes = state.pending_terminal_writes.saturating_sub(1); + } + matched_query + } + + pub fn has_pending_terminal_query(&self) -> bool { + let state = self.lock_state(); + state.phase == StartupInputBarrierPhase::Pending + && (state.pending_terminal_queries > 0 || state.pending_terminal_writes > 0) + } + + pub fn try_inject( + &self, + inject: impl FnOnce() -> Result, + ) -> Result, E> { + let mut state = self.lock_state(); + match state.phase { + StartupInputBarrierPhase::Cancelled | StartupInputBarrierPhase::Injected => { + return Ok(StartupInjectionAttempt::Cancelled); + } + StartupInputBarrierPhase::Pending + if state.pending_terminal_queries > 0 || state.pending_terminal_writes > 0 => + { + return Ok(StartupInjectionAttempt::WaitForTerminalResponse); + } + StartupInputBarrierPhase::Pending => {} + } + match inject() { + Ok(value) => { + state.phase = StartupInputBarrierPhase::Injected; + Ok(StartupInjectionAttempt::Injected(value)) + } + Err(error) => { + state.phase = StartupInputBarrierPhase::Cancelled; + Err(error) + } + } + } +} + /// Handle to an active session; used to send commands and access SSH config for SFTP. pub struct SessionHandle { pub info: SessionInfo, pub cmd_tx: SessionCommandSender, + /// Local Bash/Zsh only: producer-side input/injection ordering barrier. + pub startup_input_barrier: Option>, /// SSH-specific: stores config for potential reconnection. #[allow(dead_code)] pub ssh_config: Option>, @@ -610,16 +854,23 @@ impl SessionManager { /// Sends a command to a session's I/O loop; errors if session not found. pub async fn send_command(&self, id: &str, cmd: SessionCommand) -> AppResult<()> { - if let SessionCommand::Write { - origin, - sensitivity, - .. - } = &cmd - { - let _ = (*origin, *sensitivity); - } let sessions = self.sessions.lock().await; if let Some(handle) = sessions.get(id) { + if let SessionCommand::Write { + origin, + sensitivity, + .. + } = &cmd + { + let _ = *sensitivity; + if let Some(barrier) = handle.startup_input_barrier.as_ref() { + if *origin == InputOrigin::TerminalResponse { + barrier.note_terminal_response_enqueued(); + } else { + barrier.cancel_for_input(*origin); + } + } + } handle .cmd_tx .send(cmd) @@ -656,7 +907,7 @@ impl SessionManager { .map(|handle| handle.cwd.clone()) .ok_or_else(|| AppError::SessionNotFound(format!("Session '{}' not found", id)))? }; - Ok(cwd.lock().await.clone()) + Ok(cwd.lock().await.legacy_path.clone()) } pub fn append_recent_output(&self, session_id: &str, text: &str) { @@ -667,6 +918,36 @@ impl SessionManager { self.recent_output.read(session_id, lines) } + /// Returns the validated dynamic-title cwd presentation for a session. + pub async fn session_cwd_presentation(&self, id: &str) -> Option { + let cwd = self.sessions.lock().await.get(id)?.cwd.clone(); + cwd.lock().await.presentation.clone() + } + + /// Updates a Local session's runtime integration capability after its + /// session-bound ready marker is observed. + pub async fn set_dynamic_title_integration_active(&self, id: &str, active: bool) { + let changed = { + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get_mut(id) else { + return; + }; + if session.info.dynamic_title_capabilities.integration_active == active { + false + } else { + session.info.dynamic_title_capabilities.integration_active = active; + true + } + }; + + if changed { + if let Some(app) = self.app_handle.get() { + let _ = app.emit("sessions-changed", ()); + crate::tray::schedule_refresh(app); + } + } + } + /// Appends a command to persistent history and schedules a coalesced save. pub async fn add_command(&self, _session_id: &str, command: String) { self.add_history_entry(command).await; @@ -711,12 +992,50 @@ impl SessionManager { } } - /// Records a shell-confirmed command emitted by the backend integration - /// channel. Duplicate confirmations are ignored once the matching pending - /// candidate has already been consumed. - pub async fn confirm_command_submission(&self, session_id: &str, command: String) { + /// Registers a candidate only when this session awaits a shell marker. + /// Used for synchronized peer input so non-shell peers retain their + /// released no-registration behavior. + pub async fn register_confirmation_candidate(&self, session_id: &str, command: String) -> bool { let Some(command) = sanitize_history_command(&command) else { - return; + return false; + }; + let overflow_flush = { + let mut submissions = self.command_submissions.lock().await; + let Some(state) = submissions.get_mut(session_id) else { + return false; + }; + if !state.awaits_shell_event || state.confirmation_overflow_degraded { + return false; + } + state.pending_candidates.push_back(command); + if state.pending_candidates.len() > MAX_PENDING_CONFIRMATIONS { + state.awaits_shell_event = false; + state.confirmation_overflow_degraded = true; + state.last_shell_event = None; + Some(state.pending_candidates.drain(..).collect::>()) + } else { + None + } + }; + + if let Some(commands) = overflow_flush { + log_confirmation_overflow(session_id, commands.len(), 0); + for command in commands { + self.record_command_transcript(session_id, &command); + self.add_history_entry(command).await; + } + return false; + } + true + } + + /// Records a shell-confirmed command emitted by the backend integration + /// channel. A marker is only authoritative when it exactly matches a + /// pending client-side candidate; unmatched remote output is ignored. + /// Returns true only when a pending candidate was consumed. + pub async fn confirm_command_submission(&self, session_id: &str, command: String) -> bool { + let Some(command) = sanitize_history_command(&command) else { + return false; }; let should_add = { @@ -724,27 +1043,24 @@ impl SessionManager { let state = submissions.entry(session_id.to_string()).or_default(); if state.confirmation_overflow_degraded { - None - } else if let Some(index) = state + return false; + } + let Some(index) = state .pending_candidates .iter() .position(|pending| pending == &command) - { - state.pending_candidates.remove(index); - state.last_shell_event = Some(command.clone()); - Some(command) - } else if state.last_shell_event.as_deref() == Some(command.as_str()) { - None - } else { - state.last_shell_event = Some(command.clone()); - Some(command) - } + else { + return false; + }; + + state.pending_candidates.remove(index); + state.last_shell_event = Some(command.clone()); + command }; - if let Some(command) = should_add { - self.record_command_transcript(session_id, &command); - self.add_history_entry(command).await; - } + self.record_command_transcript(session_id, &should_add); + self.add_history_entry(should_add).await; + true } /// Flushes any pending client candidate when a shell-capable session ends @@ -1052,8 +1368,10 @@ mod tests { use super::{ COMMAND_QUEUE_CRITICAL_RESET_THRESHOLD, COMMAND_QUEUE_CRITICAL_THRESHOLD, - COMMAND_QUEUE_HIGH_RESET_THRESHOLD, SessionCommand, SessionCommandSender, SessionHandle, - SessionInfo, SessionManager, SessionType, normalize_cwd_path, now_session_started_at, + COMMAND_QUEUE_HIGH_RESET_THRESHOLD, CwdPresentation, DynamicTitleCapabilities, InputOrigin, + SessionCommand, SessionCommandSender, SessionCwdReplacement, SessionCwdState, + SessionHandle, SessionInfo, SessionManager, SessionType, StartupInjectionAttempt, + StartupInputBarrier, normalize_cwd_path, now_session_started_at, replace_cwd_state, session_command_channel, }; use std::fs; @@ -1083,14 +1401,16 @@ mod tests { owner_window_label: None, ai_execution_profile: AiExecutionProfile::Auto, injection_active, + dynamic_title_capabilities: DynamicTitleCapabilities::default(), remote_file_browser_enabled: true, remote_stats_enabled: true, ssh_profile: None, }, cmd_tx, + startup_input_barrier: None, ssh_config: None, ssh_handle: None, - cwd: Arc::new(Mutex::new(None)), + cwd: Arc::new(Mutex::new(Default::default())), remote_fs: None, } } @@ -1103,6 +1423,153 @@ mod tests { std::env::temp_dir().join(format!("nyaterm-session-history-{name}-{nanos}.json")) } + #[test] + fn dynamic_title_capabilities_keep_the_flat_session_wire_contract() { + let mut info = test_handle("wire", SessionType::Local, false).info; + info.dynamic_title_capabilities = + DynamicTitleCapabilities::new(true, Some("pwsh.exe".to_string())); + info.dynamic_title_capabilities.integration_active = true; + + let value = serde_json::to_value(&info).expect("serialize SessionInfo"); + assert_eq!(value["dynamic_title_enabled"], true); + assert_eq!(value["dynamic_title_integration_active"], true); + assert_eq!(value["trusted_initial_title"], "pwsh.exe"); + assert!(value.get("dynamic_title_capabilities").is_none()); + + let decoded: SessionInfo = + serde_json::from_value(value).expect("deserialize flattened SessionInfo"); + assert_eq!( + decoded.dynamic_title_capabilities, + info.dynamic_title_capabilities + ); + } + + #[test] + fn startup_input_barrier_orders_cancellation_and_injection() { + let cancelled = StartupInputBarrier::new(); + assert!(cancelled.cancel_for_input(InputOrigin::Keyboard)); + assert_eq!( + cancelled.try_inject(|| Ok::<_, ()>(())).unwrap(), + StartupInjectionAttempt::Cancelled + ); + + let injected = Arc::new(StartupInputBarrier::new()); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let injection_barrier = injected.clone(); + let injection = std::thread::spawn(move || { + injection_barrier.try_inject(|| { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok::<_, ()>(()) + }) + }); + started_rx.recv().unwrap(); + let input_barrier = injected.clone(); + let input = + std::thread::spawn(move || input_barrier.cancel_for_input(InputOrigin::Keyboard)); + release_tx.send(()).unwrap(); + assert_eq!( + injection.join().unwrap().unwrap(), + StartupInjectionAttempt::Injected(()) + ); + assert!(!input.join().unwrap()); + assert!(!injected.cancel_pending()); + + let query = StartupInputBarrier::new(); + query.note_terminal_queries(1); + query.note_terminal_response_enqueued(); + assert_eq!( + query.try_inject(|| Ok::<_, ()>(())).unwrap(), + StartupInjectionAttempt::WaitForTerminalResponse + ); + assert!(query.resolve_terminal_response()); + assert_eq!( + query.try_inject(|| Ok::<_, ()>(())).unwrap(), + StartupInjectionAttempt::Injected(()) + ); + } + + #[tokio::test] + async fn manager_marks_real_input_before_enqueuing_it() { + let manager = SessionManager::new(); + let barrier = Arc::new(StartupInputBarrier::new()); + let (cmd_tx, mut cmd_rx) = session_command_channel("local-startup"); + let mut handle = + test_handle_with_sender("local-startup", SessionType::Local, false, cmd_tx); + handle.startup_input_barrier = Some(barrier.clone()); + manager.add_session(handle).await; + + manager + .send_command( + "local-startup", + SessionCommand::Write { + data: b"x".to_vec(), + automated: false, + origin: InputOrigin::Keyboard, + sensitivity: super::InputSensitivity::Normal, + }, + ) + .await + .unwrap(); + + assert!(barrier.is_cancelled()); + assert!(matches!( + cmd_rx.try_recv(), + Ok(SessionCommand::Write { .. }) + )); + } + + #[tokio::test] + async fn cwd_replacement_updates_operational_and_presentation_atomically() { + let cwd = Arc::new(Mutex::new(SessionCwdState { + legacy_path: Some("/legacy-a".to_string()), + operational_path: Some("C:\\safe-a".to_string()), + presentation: Some(CwdPresentation { + title: "safe-a".to_string(), + display_path: "C:\\safe-a".to_string(), + copy_value: "C:\\safe-a".to_string(), + operational_path: Some("C:\\safe-a".to_string()), + copy_as_uri: false, + }), + })); + let changes = replace_cwd_state( + &cwd, + SessionCwdReplacement { + legacy_path: Some("/legacy-b%2".to_string()), + operational_path: Some("/legacy-b%2".to_string()), + presentation: None, + }, + ) + .await; + assert!(changes.operational_changed); + assert!(changes.presentation_changed); + let state = cwd.lock().await; + assert_eq!(state.legacy_path.as_deref(), Some("/legacy-b%2")); + assert_eq!(state.operational_path.as_deref(), Some("/legacy-b%2")); + assert!(state.presentation.is_none()); + assert_eq!( + state.safe_local_execution_cwd().as_deref(), + Some("/legacy-b%2") + ); + } + + #[test] + fn local_execution_cwd_uses_only_host_operational_path() { + let cwd = SessionCwdState { + legacy_path: Some("/shell/path".to_string()), + operational_path: None, + presentation: Some(CwdPresentation { + title: "shell".to_string(), + display_path: "/shell/path".to_string(), + copy_value: "/shell/path".to_string(), + operational_path: None, + copy_as_uri: false, + }), + }; + assert!(cwd.safe_local_execution_cwd().is_none()); + } + #[test] fn normalizes_trailing_slashes_without_breaking_roots() { assert_eq!(normalize_cwd_path("/var/log/"), "/var/log"); @@ -1140,6 +1607,51 @@ mod tests { ); } + #[tokio::test] + async fn unmatched_shell_marker_cannot_inject_history() { + let manager = SessionManager::new(); + manager + .add_session(test_handle("ssh-untrusted", SessionType::SSH, true)) + .await; + + assert!( + !manager + .confirm_command_submission("ssh-untrusted", "forged command".to_string()) + .await + ); + assert!(manager.get_all_history().await.is_empty()); + } + + #[tokio::test] + async fn synchronized_confirmation_candidate_only_queues_shell_sessions() { + let manager = SessionManager::new(); + manager + .add_session(test_handle("ssh-peer", SessionType::SSH, true)) + .await; + manager + .add_session(test_handle("local-peer", SessionType::Local, false)) + .await; + + assert!( + manager + .register_confirmation_candidate("ssh-peer", "uptime".to_string()) + .await + ); + assert!( + !manager + .register_confirmation_candidate("local-peer", "uptime".to_string()) + .await + ); + assert!(manager.get_all_history().await.is_empty()); + + assert!( + manager + .confirm_command_submission("ssh-peer", "uptime".to_string()) + .await + ); + assert_eq!(manager.get_all_history().await, vec!["uptime".to_string()]); + } + #[tokio::test] async fn direct_session_adds_submission_immediately() { let manager = SessionManager::new(); diff --git a/src-tauri/src/core/sftp/mod.rs b/src-tauri/src/core/sftp/mod.rs index 9c1b70ce..1a28a055 100644 --- a/src-tauri/src/core/sftp/mod.rs +++ b/src-tauri/src/core/sftp/mod.rs @@ -2423,7 +2423,8 @@ mod tests { use crate::config::{AiExecutionProfile, ProxySettings, SftpSettings}; use crate::core::ssh::{SshAuth, SshConfig}; use crate::core::{ - SessionHandle, SessionInfo, SessionManager, SessionType, session_command_channel, + DynamicTitleCapabilities, SessionHandle, SessionInfo, SessionManager, SessionType, + session_command_channel, }; use std::fs; use std::path::PathBuf; @@ -2459,6 +2460,7 @@ mod tests { terminal_type: Default::default(), sftp: SftpSettings::default(), encoding: "UTF-8".to_string(), + dynamic_tab_title: false, } } @@ -2610,14 +2612,16 @@ mod tests { owner_window_label: None, ai_execution_profile: AiExecutionProfile::Posix, injection_active: true, + dynamic_title_capabilities: DynamicTitleCapabilities::default(), remote_file_browser_enabled: false, remote_stats_enabled: true, ssh_profile: None, }, cmd_tx, + startup_input_barrier: None, ssh_config: None, ssh_handle: None, - cwd: Arc::new(Mutex::new(None)), + cwd: Arc::new(Mutex::new(Default::default())), remote_fs: None, }) .await; diff --git a/src-tauri/src/core/ssh/auth.rs b/src-tauri/src/core/ssh/auth.rs index 4b3f1575..c419aacb 100644 --- a/src-tauri/src/core/ssh/auth.rs +++ b/src-tauri/src/core/ssh/auth.rs @@ -495,6 +495,13 @@ fn resolve_saved_ssh_config( ), sftp: conn.sftp.clone(), encoding, + dynamic_tab_title: matches!( + &conn.config, + crate::config::ConnectionType::Ssh { + dynamic_tab_title: true, + .. + } + ), }) } diff --git a/src-tauri/src/core/ssh/bash_persistent.sh b/src-tauri/src/core/ssh/bash_persistent.sh new file mode 100644 index 00000000..ed15f394 --- /dev/null +++ b/src-tauri/src/core/ssh/bash_persistent.sh @@ -0,0 +1,285 @@ +# nyaterm shell integration v1 +# +# Optional SSH persistent Bash integration. This file is definitions-only: the +# activation command calls __nyaterm_install_prompt after exporting the +# session-bound command marker. It never edits a user's profile by itself. + +__nyaterm_host(){ hostname 2>/dev/null || printf localhost; } + +__nyaterm_ready_failed(){ + [ -n "${__nyaterm_failure_reported:-}" ] || { + __nyaterm_failure_reported=1 + printf '%s' "${NYATERM_READY_FAILED_MARKER-}" + } +} + +__nyaterm_restore_status(){ return "$1"; } +__nyaterm_prompt_guard(){ return $?; } + +__nyaterm_prune_history(){ + [ -n "${NYATERM_PRUNE_HISTORY:-}" ] || return 0 + unset NYATERM_PRUNE_HISTORY + local hline history_number + hline="$(HISTTIMEFORMAT= history 1 2>/dev/null || true)" + case "$hline" in + *NYATERM_PRUNE_HISTORY*|*NYATERM_INJ*|*__nyaterm_install_prompt*|*NyaTermReady*) + history_number=${hline#"${hline%%[![:space:]]*}"} + history_number=${history_number%%[!0-9]*} + [ -z "$history_number" ] || history -d "$history_number" 2>/dev/null || true + ;; + esac + NYATERM_LAST_HISTCMD="${HISTCMD-}" +} + +__nyaterm_emit_command(){ + local histcmd="${HISTCMD-}" + if [ -n "$histcmd" ] && [ "${NYATERM_LAST_HISTCMD-}" != "$histcmd" ]; then + NYATERM_LAST_HISTCMD="$histcmd" + local cmd + cmd="$(fc -ln -1 2>/dev/null)" + if [ -n "$cmd" ] && command -v base64 >/dev/null 2>&1; then + local b64 + b64="$(printf '%s' "$cmd" | base64 | tr -d '\r\n')" + printf '\033]7777;NyaTermCommand:%s\007' "$b64" + fi + fi +} + +__nyaterm_prompt(){ + local status=$? + __nyaterm_prune_history + __nyaterm_emit_command + if [ -n "${NYATERM_READY_PENDING:-}" ]; then + unset NYATERM_READY_PENDING + printf '%s' "${NYATERM_READY_MARKER-}" + fi + printf '\033]7;file://%s%s\007' "$(__nyaterm_host)" "$PWD" + return "$status" +} + +__nyaterm_array_prompt_supported(){ + [ "${BASH_VERSINFO[0]:-0}" -gt 5 ] || { + [ "${BASH_VERSINFO[0]:-0}" -eq 5 ] && [ "${BASH_VERSINFO[1]:-0}" -ge 1 ] + } +} + +__nyaterm_prompt_state_writable(){ + local name decl + for name in \ + __nyaterm_saved_prompt_command \ + __nyaterm_saved_prompt_command_mirror \ + __nyaterm_extra_prompt_commands \ + __nyaterm_extra_prompt_commands_mirror \ + __nyaterm_exported_prompt_fallback; do + decl="$(declare -p "$name" 2>/dev/null || true)" + [[ ! "$decl" =~ ^declare\ -[^[:space:]]*r ]] || return 1 + done +} + +__nyaterm_state_token(){ + local marker token + marker="${NYATERM_COMMAND_MARKER-}" + case "$marker" in + 7777\;NyaTermCommand:*:) + token="${marker#7777;NyaTermCommand:}" + token="${token%:}" + [ -n "$token" ] || return 1 + printf '%s' "$token" + ;; + *) return 1 ;; + esac +} + +__nyaterm_prompt_command_is_managed(){ + local current="${PROMPT_COMMAND-}" + local canonical='__nyaterm_prompt; __nyaterm_run_saved_prompt_command; __nyaterm_repair_prompt; __nyaterm_prompt_guard' + local exported='if [ "${__nyaterm_integration_active-}" = 1 ] && declare -F __nyaterm_prompt >/dev/null 2>&1; then __nyaterm_prompt; __nyaterm_run_saved_prompt_command; __nyaterm_repair_prompt; __nyaterm_prompt_guard; else eval -- "${__nyaterm_exported_prompt_fallback-}"; fi' + local direct_exported='if declare -F __nyaterm_prompt >/dev/null 2>&1; then __nyaterm_prompt; __nyaterm_run_saved_prompt_command; __nyaterm_repair_prompt; __nyaterm_prompt_guard; else eval -- "${__nyaterm_exported_prompt_fallback-}"; fi' + [ "$current" = "$canonical" ] || [ "$current" = "$exported" ] || [ "$current" = "$direct_exported" ] +} + +__nyaterm_initialize_prompt_state(){ + local token expected nonce_decl owner_decl name decl + token="$( __nyaterm_state_token )" || return 1 + expected="nyaterm-shell-integration-v1:$token" + nonce_decl="$(declare -p __nyaterm_prompt_state_nonce 2>/dev/null || true)" + owner_decl="$(declare -p __nyaterm_prompt_state_owner 2>/dev/null || true)" + + if [ -n "$nonce_decl" ] || [ -n "$owner_decl" ]; then + [[ "$nonce_decl" =~ ^declare\ -[^[:space:]]*r ]] || return 1 + [[ "$owner_decl" =~ ^declare\ -[^[:space:]]*r ]] || return 1 + [ "${__nyaterm_prompt_state_nonce-}" = "$token" ] || return 1 + [ "${__nyaterm_prompt_state_owner-}" = "$expected" ] || return 1 + return 0 + fi + + for name in __nyaterm_saved_prompt_command __nyaterm_saved_prompt_command_mirror __nyaterm_extra_prompt_commands __nyaterm_extra_prompt_commands_mirror; do + decl="$(declare -p "$name" 2>/dev/null || true)" + [ -z "$decl" ] || return 1 + done + + __nyaterm_prompt_state_nonce="$token" || return 1 + readonly __nyaterm_prompt_state_nonce || return 1 + __nyaterm_prompt_state_owner="$expected" || return 1 + readonly __nyaterm_prompt_state_owner || return 1 + __nyaterm_extra_prompt_commands=() || return 1 +} + +__nyaterm_prompt_state_mirror_valid(){ + local nonce_decl owner_decl expected + nonce_decl="$(declare -p __nyaterm_prompt_state_nonce 2>/dev/null || true)" + owner_decl="$(declare -p __nyaterm_prompt_state_owner 2>/dev/null || true)" + [[ "$nonce_decl" =~ ^declare\ -[^[:space:]]*r ]] || return 1 + [[ "$owner_decl" =~ ^declare\ -[^[:space:]]*r ]] || return 1 + expected="nyaterm-shell-integration-v1:${__nyaterm_prompt_state_nonce}" + [ "${__nyaterm_prompt_state_owner-}" = "$expected" ] || return 1 + [[ $(declare -p __nyaterm_extra_prompt_commands_mirror 2>/dev/null) == declare\ -a* ]] +} + +__nyaterm_sync_prompt_state(){ + __nyaterm_prompt_state_writable || return 1 + __nyaterm_saved_prompt_command_mirror="${__nyaterm_saved_prompt_command-}" + __nyaterm_extra_prompt_commands_mirror=("${__nyaterm_extra_prompt_commands[@]}") +} + +__nyaterm_restore_prompt_state(){ + __nyaterm_prompt_state_mirror_valid || return 1 + __nyaterm_prompt_state_writable || return 1 + __nyaterm_saved_prompt_command="${__nyaterm_saved_prompt_command_mirror-}" + __nyaterm_extra_prompt_commands=("${__nyaterm_extra_prompt_commands_mirror[@]}") +} + +__nyaterm_prompt_state_valid(){ + __nyaterm_prompt_state_mirror_valid || return 1 + [[ $(declare -p __nyaterm_extra_prompt_commands 2>/dev/null) == declare\ -a* ]] || return 1 + [ "${__nyaterm_saved_prompt_command-}" = "${__nyaterm_saved_prompt_command_mirror-}" ] || return 1 + [ ${#__nyaterm_extra_prompt_commands[@]} -eq ${#__nyaterm_extra_prompt_commands_mirror[@]} ] || return 1 + local i + for ((i=0; i<${#__nyaterm_extra_prompt_commands[@]}; i++)); do + [ "${__nyaterm_extra_prompt_commands[i]}" = "${__nyaterm_extra_prompt_commands_mirror[i]}" ] || return 1 + done +} + +__nyaterm_rebuild_exported_prompt_fallback(){ + local command result="${__nyaterm_saved_prompt_command-}" + for command in "${__nyaterm_extra_prompt_commands[@]}"; do + if [ -n "$result" ]; then result="$result; $command"; else result="$command"; fi + done + __nyaterm_exported_prompt_fallback="$result" +} + +__nyaterm_run_saved_prompt_command(){ + local status=$? command + [ "${__nyaterm_integration_active-}" = 1 ] || return "$status" + __nyaterm_prompt_state_writable || return "$status" + if ! __nyaterm_prompt_state_valid; then + __nyaterm_ready_failed + return "$status" + fi + if [ -n "${__nyaterm_saved_prompt_command-}" ]; then + __nyaterm_restore_status "$status" + builtin eval -- "$__nyaterm_saved_prompt_command" + status=$? + fi + for command in "${__nyaterm_extra_prompt_commands[@]}"; do + __nyaterm_restore_status "$status" + builtin eval -- "$command" + status=$? + done + return "$status" +} + +__nyaterm_capture_prompt_string(){ + local current="$1" expected="$2" tail + if [ "$current" = "$expected" ]; then + return 0 + fi + case "$current" in + "$expected"*) + tail=${current#"$expected"} + while :; do + tail=${tail#"${tail%%[![:space:]]*}"} + case "$tail" in \;*|\&*) tail=${tail#?};; *) break;; esac + done + [ -z "$tail" ] || __nyaterm_extra_prompt_commands[${#__nyaterm_extra_prompt_commands[@]}]="$tail" + ;; + *"$expected"*) return 1;; + *) __nyaterm_saved_prompt_command="$current"; __nyaterm_extra_prompt_commands=();; + esac + __nyaterm_rebuild_exported_prompt_fallback + __nyaterm_sync_prompt_state +} + +__nyaterm_repair_prompt_container(){ + local decl f current canonical expected exported=0 + canonical='__nyaterm_prompt; __nyaterm_run_saved_prompt_command; __nyaterm_repair_prompt; __nyaterm_prompt_guard' + expected="$canonical" + [ "${__nyaterm_integration_active-}" = 1 ] || return 1 + __nyaterm_initialize_prompt_state || return 1 + __nyaterm_prompt_state_writable || return 1 + decl="$(declare -p PROMPT_COMMAND 2>/dev/null || true)" + [[ "$decl" =~ ^declare\ -[^[:space:]]*x ]] && exported=1 + [[ ! "$decl" =~ ^declare\ -[^[:space:]]*r ]] || return 1 + if [ "$exported" -eq 1 ]; then + expected='if [ "${__nyaterm_integration_active-}" = 1 ] && declare -F __nyaterm_prompt >/dev/null 2>&1; then __nyaterm_prompt; __nyaterm_run_saved_prompt_command; __nyaterm_repair_prompt; __nyaterm_prompt_guard; else eval -- "${__nyaterm_exported_prompt_fallback-}"; fi' + fi + if [[ "$decl" =~ ^declare\ -[^[:space:]]*a[^[:space:]]*\ PROMPT_COMMAND= ]] && __nyaterm_array_prompt_supported; then + local -a retained=() + for f in "${PROMPT_COMMAND[@]}"; do + case "$f" in __nyaterm_prompt|__nyaterm_repair_prompt) ;; *) retained+=("$f");; esac + done + PROMPT_COMMAND=(__nyaterm_prompt "${retained[@]}" __nyaterm_repair_prompt) || return 1 + else + current=${PROMPT_COMMAND-} + if [[ "$decl" =~ ^declare\ -[^[:space:]]*a[^[:space:]]*\ PROMPT_COMMAND= ]]; then unset PROMPT_COMMAND; fi + if ! __nyaterm_capture_prompt_string "$current" "$expected"; then + PROMPT_COMMAND="$expected" + return 1 + fi + PROMPT_COMMAND="$expected" || return 1 + if [ "$exported" -eq 1 ]; then + __nyaterm_rebuild_exported_prompt_fallback + export __nyaterm_exported_prompt_fallback + else + unset __nyaterm_exported_prompt_fallback + fi + fi + __nyaterm_sync_prompt_state || return 1 +} + +__nyaterm_repair_prompt(){ + local status=$? + __nyaterm_repair_prompt_container || __nyaterm_ready_failed + return "$status" +} + +__nyaterm_install_prompt(){ + local owner_decl current + owner_decl="$(declare -p __nyaterm_prompt_state_owner 2>/dev/null || true)" + __nyaterm_initialize_prompt_state || return 1 + if [ -z "$owner_decl" ]; then + __nyaterm_prompt_state_writable || return 1 + current="${PROMPT_COMMAND-}" + if __nyaterm_prompt_command_is_managed; then + current="${__nyaterm_exported_prompt_fallback-}" + [ -n "$current" ] || return 1 + fi + __nyaterm_saved_prompt_command="$current" + __nyaterm_extra_prompt_commands=() + __nyaterm_rebuild_exported_prompt_fallback + __nyaterm_sync_prompt_state || return 1 + else + __nyaterm_restore_prompt_state || return 1 + fi + __nyaterm_integration_active=1 || return 1 + NYATERM_LAST_HISTCMD="${HISTCMD-}" + __nyaterm_repair_prompt_container +} + +# A child Bash can inherit a direct-session wrapper while loading this +# definitions-only resource from .bashrc. Until activation explicitly calls +# install_prompt, restore the exported user hook so the inherited wrapper +# cannot emit metadata or suppress user prompt behavior in the child. +if [ "${__nyaterm_integration_active-}" != 1 ] && __nyaterm_prompt_command_is_managed; then + PROMPT_COMMAND="${__nyaterm_exported_prompt_fallback-}" +fi diff --git a/src-tauri/src/core/ssh/client.rs b/src-tauri/src/core/ssh/client.rs index fd2b8fef..753e3013 100644 --- a/src-tauri/src/core/ssh/client.rs +++ b/src-tauri/src/core/ssh/client.rs @@ -61,6 +61,9 @@ pub struct SshConfig { /// Character encoding for terminal I/O (e.g. "UTF-8", "GBK"). #[serde(default = "default_encoding")] pub encoding: String, + /// Allow sanitized remote OSC 0/2 titles to decorate this saved SSH tab. + #[serde(default)] + pub dynamic_tab_title: bool, } fn default_encoding() -> String { diff --git a/src-tauri/src/core/ssh/io.rs b/src-tauri/src/core/ssh/io.rs index 129a0f34..8e311f86 100644 --- a/src-tauri/src/core/ssh/io.rs +++ b/src-tauri/src/core/ssh/io.rs @@ -5,14 +5,16 @@ use crate::config::SftpCwdFollowMode; use crate::core::capture::OutputCaptureProcessor; use crate::core::input::remap_del_to_bs; use crate::core::ssh::osc::{self, OscStripper, ShellKind}; +use crate::core::terminal_session::local::split_startup_passthrough; use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input}; use crate::core::zmodem::{ ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemDownloadOoDrain, ZmodemEvent, ZmodemTransfer, ZmodemUploadDrain, start_zmodem_transfer, }; use crate::core::{ - RecordingManager, SessionCommand, SessionCommandReceiver, SessionCommandSender, SessionManager, - SessionOutputCoalescer, SharedCwd, update_cwd_if_changed, + RecordingManager, SessionCommand, SessionCommandReceiver, SessionCommandSender, + SessionCwdReplacement, SessionManager, SessionOutputCoalescer, SharedCwd, replace_cwd_state, + update_cwd_if_changed, }; use crate::error::{AppError, AppResult}; use russh::{ChannelMsg, client}; @@ -577,6 +579,8 @@ enum InjectionEvent { None, Inject, Ready { visible_after_ready: String }, + Failed { visible_after_ready: String }, + RuntimeFailed { visible: String }, } #[derive(Debug, PartialEq, Eq)] @@ -637,6 +641,12 @@ fn handle_injection_result(phase: &mut IoPhase, result: &osc::OscResult) -> Inje *phase = IoPhase::Suppressing; InjectionEvent::Inject } + IoPhase::Suppressing if result.ready_failed => { + *phase = IoPhase::Normal; + InjectionEvent::Failed { + visible_after_ready: result.visible_after_ready.clone(), + } + } IoPhase::Suppressing if result.ready => { *phase = IoPhase::Normal; InjectionEvent::Ready { @@ -644,6 +654,9 @@ fn handle_injection_result(phase: &mut IoPhase, result: &osc::OscResult) -> Inje } } IoPhase::Suppressing => InjectionEvent::None, + IoPhase::Normal if result.ready_failed => InjectionEvent::RuntimeFailed { + visible: result.visible.clone(), + }, IoPhase::Normal => InjectionEvent::None, } } @@ -658,18 +671,17 @@ fn handle_injection_timeout(phase: &mut IoPhase) -> InjectionTimeoutEvent { } } -fn append_suppressed_visible(buffer: &mut String, visible: &str) { - if visible.is_empty() { - return; - } - +fn append_suppressed_visible_and_take_passthrough(buffer: &mut String, visible: &str) -> String { buffer.push_str(visible); - while buffer.len() > SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES { - let Some(first_char) = buffer.chars().next() else { + let (mut suppressed, passthrough) = split_startup_passthrough(buffer); + while suppressed.len() > SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES { + let Some(first_char) = suppressed.chars().next() else { break; }; - buffer.drain(..first_char.len_utf8()); + suppressed.drain(..first_char.len_utf8()); } + *buffer = suppressed; + passthrough } fn discard_suppressed_output(buffer: &mut String, flushed_osc_buffer: String) -> usize { @@ -696,7 +708,21 @@ async fn handle_osc_result( shell_kind: Option, injection_sent_at: &mut Option, ) { - match handle_injection_result(phase, result) { + let was_suppressing = *phase == IoPhase::Suppressing; + let injection_event = handle_injection_result(phase, result); + let suppressed_passthrough = if was_suppressing { + let before_ready_len = result + .visible + .len() + .saturating_sub(result.visible_after_ready.len()); + append_suppressed_visible_and_take_passthrough( + suppressed_visible_fallback, + &result.visible[..before_ready_len], + ) + } else { + String::new() + }; + match injection_event { InjectionEvent::Inject => { tracing::info!( session_id = %session_id, @@ -751,10 +777,58 @@ async fn handle_osc_result( phase_transition = "Suppressing -> Normal", "SSH shell integration ready marker received" ); + // Exact OSC 0/2 and terminal queries retain their original order + // while source-command echo remains suppressed. + let mut release_output = suppressed_passthrough; + release_output.push_str(&visible_after_ready); emit_metadata(app, cwd_event, cwd, manager, session_id, result).await; - if !visible_after_ready.is_empty() { - emit_visible_text(output, recording_mgr, session_id, &visible_after_ready); + emit_visible_text(output, recording_mgr, session_id, &release_output); + } + InjectionEvent::Failed { + visible_after_ready, + } => { + let suppressed_visible_bytes = result + .visible + .len() + .saturating_sub(visible_after_ready.len()) + + suppressed_visible_fallback.len(); + suppressed_visible_fallback.clear(); + let mut release_output = suppressed_passthrough; + release_output.push_str(&visible_after_ready); + tracing::warn!( + session_id = %session_id, + shell = ?shell_kind, + elapsed_ms = injection_sent_at + .as_ref() + .map(|started| started.elapsed().as_millis() as u64) + .unwrap_or(0), + suppressed_visible_bytes, + phase_transition = "Suppressing -> Normal", + "SSH shell integration reported installation failure; continuing in passive mode" + ); + emit_metadata(app, cwd_event, cwd, manager, session_id, result).await; + emit_visible_text(output, recording_mgr, session_id, &release_output); + } + InjectionEvent::RuntimeFailed { visible } => { + tracing::warn!( + session_id = %session_id, + shell = ?shell_kind, + "SSH shell integration failed after startup; clearing stale cwd" + ); + emit_metadata(app, cwd_event, cwd, manager, session_id, result).await; + let changes = replace_cwd_state( + cwd, + SessionCwdReplacement { + legacy_path: None, + operational_path: None, + presentation: None, + }, + ) + .await; + if changes.operational_changed { + let _ = app.emit(cwd_event, ""); } + emit_visible_text(output, recording_mgr, session_id, &visible); } InjectionEvent::None if *phase == IoPhase::Normal => { emit_output( @@ -770,7 +844,9 @@ async fn handle_osc_result( .await; } InjectionEvent::None => { - append_suppressed_visible(suppressed_visible_fallback, &result.visible); + // Transport terminal queries and bounded OSC 0/2 in their exact + // source order while all remaining startup text stays hidden. + emit_visible_text(output, recording_mgr, session_id, &suppressed_passthrough); emit_metadata(app, cwd_event, cwd, manager, session_id, result).await; } } @@ -874,8 +950,8 @@ pub(super) async fn ssh_io_loop( cmd = cmd_rx.recv() => { match cmd { - Some(SessionCommand::Attach) => { - output.attach(); + Some(SessionCommand::AttachConfirmed { ack }) => { + output.attach_confirmed(ack); } Some(SessionCommand::DetachRenderer) => { output.detach(); @@ -1295,16 +1371,18 @@ async fn emit_metadata( } for command in &result.accepted_commands { - manager + let accepted = manager .confirm_command_submission(session_id, command.clone()) .await; - let _ = app.emit( - "session-command-accepted", - serde_json::json!({ - "sessionId": session_id, - "command": command, - }), - ); + if accepted { + let _ = app.emit( + "session-command-accepted", + serde_json::json!({ + "sessionId": session_id, + "command": command, + }), + ); + } } } @@ -1330,12 +1408,12 @@ mod tests { use super::{ INITIAL_INJECT_DELAY_MS, INJECT_TIMEOUT_SECS, InjectionEvent, InjectionTimeoutEvent, IoPhase, PendingStartupCommand, SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES, - append_suppressed_visible, build_startup_command_input, discard_suppressed_output, - handle_injection_result, handle_injection_timeout, open_shell_channel, - should_send_initial_injection, + append_suppressed_visible_and_take_passthrough, build_startup_command_input, + discard_suppressed_output, handle_injection_result, handle_injection_timeout, + open_shell_channel, should_send_initial_injection, }; use crate::config::SftpCwdFollowMode; - use crate::core::ssh::osc::OscResult; + use crate::core::ssh::osc::{OscResult, OscStripper, build_ready_marker}; use russh::{Channel, ChannelId, Disconnect, client, server}; use std::pin::Pin; use std::sync::Arc; @@ -1495,7 +1573,11 @@ mod tests { visible: "suppressed output".to_string(), visible_after_ready: visible_after_ready.to_string(), cwd_paths: Vec::new(), + cwd_payloads: Vec::new(), + cwd_payload_invalidated: false, + cwd_payload_events: Vec::new(), ready, + ready_failed: false, accepted_commands: Vec::new(), } } @@ -1525,6 +1607,162 @@ mod tests { ); } + #[test] + fn failure_marker_in_suppressing_enters_normal_and_preserves_prompt() { + let mut phase = IoPhase::Suppressing; + let mut result = osc_result(false, "prompt-after-failure"); + result.ready_failed = true; + + let event = handle_injection_result(&mut phase, &result); + + assert_eq!(phase, IoPhase::Normal); + assert_eq!( + event, + InjectionEvent::Failed { + visible_after_ready: "prompt-after-failure".to_string() + } + ); + } + + #[test] + fn failure_marker_after_startup_preserves_visible_output_and_reports_runtime_failure() { + let mut phase = IoPhase::Normal; + let mut result = osc_result(false, "prompt-after-failure"); + result.visible = "beforeprompt-after-failure".to_string(); + result.ready_failed = true; + + let event = handle_injection_result(&mut phase, &result); + + assert_eq!(phase, IoPhase::Normal); + assert_eq!( + event, + InjectionEvent::RuntimeFailed { + visible: "beforeprompt-after-failure".to_string() + } + ); + } + + #[test] + fn suppression_transport_preserves_interleaved_title_query_order() { + let mut buffer = String::new(); + let controls = concat!( + "\x1b]2;first\x07", + "\x1b[c", + "\x1b]0;final\x1b\\", + "\x1b]11;?\x07" + ); + + let passthrough = append_suppressed_visible_and_take_passthrough( + &mut buffer, + &format!("hidden{controls}tail"), + ); + + assert_eq!(passthrough, controls); + assert_eq!(buffer, "hiddentail"); + } + + #[test] + fn oversized_startup_queries_and_titles_remain_suppressed() { + let mut buffer = String::new(); + let huge_dcs = format!("\x1bP$q{}\x1b\\", "x".repeat(17 * 1024)); + let huge_title = format!("\x1b]2;{}\x07", "x".repeat(17 * 1024)); + + let passthrough = append_suppressed_visible_and_take_passthrough( + &mut buffer, + &format!("{huge_dcs}{huge_title}"), + ); + + assert!(passthrough.is_empty()); + assert!(buffer.len() <= SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES); + } + + #[test] + fn suppression_title_transport_is_exactly_once_across_split_ready_and_normal_phases() { + let ready_marker = build_ready_marker("session-1"); + let mut stripper = OscStripper::new(&ready_marker); + let mut phase = IoPhase::Suppressing; + let mut buffer = String::new(); + let mut emitted = String::new(); + + let incomplete = stripper.push("hidden source\x1b]2;split"); + let _ = handle_injection_result(&mut phase, &incomplete); + emitted.push_str(&append_suppressed_visible_and_take_passthrough( + &mut buffer, + &incomplete.visible, + )); + + let completed = stripper.push(" title\x07hidden tail"); + let _ = handle_injection_result(&mut phase, &completed); + emitted.push_str(&append_suppressed_visible_and_take_passthrough( + &mut buffer, + &completed.visible, + )); + + let ready = stripper.push(&format!("{ready_marker}[user@host]$ ")); + let event = handle_injection_result(&mut phase, &ready); + let before_ready_len = ready.visible.len() - ready.visible_after_ready.len(); + emitted.push_str(&append_suppressed_visible_and_take_passthrough( + &mut buffer, + &ready.visible[..before_ready_len], + )); + assert!(matches!(event, InjectionEvent::Ready { .. })); + emitted.push_str(&ready.visible_after_ready); + buffer.clear(); + + let normal = stripper.push("\x1b]2;normal title\x07prompt"); + let event = handle_injection_result(&mut phase, &normal); + assert_eq!(event, InjectionEvent::None); + emitted.push_str(&normal.visible); + + assert_eq!(phase, IoPhase::Normal); + assert_eq!(emitted.matches("\x1b]2;split title\x07").count(), 1); + assert_eq!(emitted.matches("\x1b]2;normal title\x07").count(), 1); + assert!(!emitted.contains("hidden source")); + assert!(!emitted.contains("hidden tail")); + assert!( + emitted.find("\x1b]2;split title\x07").unwrap() + < emitted.find("[user@host]$ ").unwrap() + ); + assert!( + emitted.find("[user@host]$ ").unwrap() + < emitted.find("\x1b]2;normal title\x07").unwrap() + ); + } + + #[test] + fn title_transport_cap_is_per_sequence_and_keeps_final_semantics() { + let first = format!("\x1b]2;{}\x07", "x".repeat(16_395)); + let final_title = "\x1b]2;final\x07"; + let mut buffer = String::new(); + + let passthrough = append_suppressed_visible_and_take_passthrough( + &mut buffer, + &format!("{first}{final_title}"), + ); + + assert_eq!(passthrough, format!("{first}{final_title}")); + assert!(passthrough.ends_with(final_title)); + assert!(buffer.is_empty()); + } + + #[test] + fn delivered_suppression_title_survives_timeout_fallback() { + let mut buffer = String::new(); + let delivered = append_suppressed_visible_and_take_passthrough( + &mut buffer, + "\x1b]2;delivered\x07hidden", + ); + let mut phase = IoPhase::Suppressing; + + assert_eq!( + handle_injection_timeout(&mut phase), + InjectionTimeoutEvent::FallbackToNormal + ); + assert_eq!(delivered, "\x1b]2;delivered\x07"); + assert_eq!(buffer, "hidden"); + assert_eq!(phase, IoPhase::Normal); + } + #[test] fn injection_timeout_is_30s_and_falls_back_to_normal() { assert_eq!(INJECT_TIMEOUT_SECS, 30); @@ -1545,23 +1783,32 @@ mod tests { } #[test] - fn suppressed_visible_fallback_keeps_recent_visible_text() { + fn suppressed_visible_fallback_keeps_recent_text_and_extracts_split_passthrough() { let mut buffer = String::new(); - append_suppressed_visible(&mut buffer, "prompt-before-ready"); + assert!( + append_suppressed_visible_and_take_passthrough(&mut buffer, "hidden\x1b[").is_empty() + ); + let passthrough = + append_suppressed_visible_and_take_passthrough(&mut buffer, "cstill-hidden"); - assert_eq!(buffer, "prompt-before-ready"); + assert_eq!(passthrough, "\x1b[c"); + assert_eq!(buffer, "hiddenstill-hidden"); } #[test] - fn suppressed_visible_fallback_is_bounded() { + fn suppressed_visible_fallback_is_bounded_after_passthrough_extraction() { let mut buffer = String::new(); - append_suppressed_visible( + let passthrough = append_suppressed_visible_and_take_passthrough( &mut buffer, - &"x".repeat(SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES + 16), + &format!( + "{}\x1b]11;?\x07", + "x".repeat(SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES + 16) + ), ); + assert_eq!(passthrough, "\x1b]11;?\x07"); assert_eq!(buffer.len(), SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES); } diff --git a/src-tauri/src/core/ssh/osc.rs b/src-tauri/src/core/ssh/osc.rs index 22cbf8bc..f6bd0b2f 100644 --- a/src-tauri/src/core/ssh/osc.rs +++ b/src-tauri/src/core/ssh/osc.rs @@ -38,6 +38,7 @@ impl ShellKind { // --------------------------------------------------------------------------- const READY_MARKER_PREFIX: &str = "7777;NyaTermReady:"; +const READY_FAILED_MARKER_PREFIX: &str = "7777;NyaTermReadyFailed:"; const COMMAND_MARKER_PREFIX: &str = "7777;NyaTermCommand:"; const LEGACY_READY_MARKER_PREFIX: &str = "7777;DflyReady:"; const LEGACY_COMMAND_MARKER_PREFIX: &str = "7777;DflyCommand:"; @@ -47,6 +48,29 @@ pub fn build_ready_marker(session_id: &str) -> String { format!("\x1b]{}{}\x07", READY_MARKER_PREFIX, session_id) } +/// Build the session-bound inner marker used for shell command confirmation. +/// The marker intentionally contains no escape terminator; shell scripts add +/// the OSC framing around the base64 payload. +pub fn build_command_marker(session_id: &str) -> String { + format!("{COMMAND_MARKER_PREFIX}{session_id}:") +} + +fn command_marker_for_ready(ready_marker: &str) -> String { + let inner = marker_inner(ready_marker); + inner + .strip_prefix(READY_MARKER_PREFIX) + .map(build_command_marker) + .unwrap_or_else(|| COMMAND_MARKER_PREFIX.to_string()) +} + +fn ready_failed_marker_for_ready(ready_marker: &str) -> String { + let inner = marker_inner(ready_marker); + inner + .strip_prefix(READY_MARKER_PREFIX) + .map(|session_id| format!("\x1b]{READY_FAILED_MARKER_PREFIX}{session_id}\x07")) + .unwrap_or_default() +} + // --------------------------------------------------------------------------- // Injection scripts (per shell) // --------------------------------------------------------------------------- @@ -58,6 +82,10 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option let ready_osc = ready_marker .replace('\x1b', "\\033") .replace('\x07', "\\007"); + let ready_failed_osc = ready_failed_marker_for_ready(ready_marker) + .replace('\x1b', "\\033") + .replace('\x07', "\\007"); + let command_marker = command_marker_for_ready(ready_marker); match shell { ShellKind::Bash => Some(format!( @@ -65,20 +93,21 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " NYATERM_PRUNE_HISTORY=1;", " NYATERM_READY_PENDING=1;", " export NYATERM_INJ=1;", + " NYATERM_COMMAND_MARKER=\"{}\";", + " NYATERM_READY_FAILED_MARKER=\"$(printf '{}')\";", " NYATERM_LAST_HISTCMD=\"${{HISTCMD-}}\";", " __nyaterm_host(){{ hostname 2>/dev/null || printf localhost; }};", + " __nyaterm_ready_failed(){{ [ -n \"${{__nyaterm_failure_reported:-}}\" ] || {{ __nyaterm_failure_reported=1; printf '%s' \"${{NYATERM_READY_FAILED_MARKER-}}\"; }}; }};", + " __nyaterm_restore_status(){{ return \"$1\"; }};", + " __nyaterm_prompt_guard(){{ return $?; }};", " __nyaterm_prune_history(){{", " [ -n \"${{NYATERM_PRUNE_HISTORY:-}}\" ] || return 0;", " unset NYATERM_PRUNE_HISTORY;", - " local hline;", + " local hline history_number;", " hline=\"$(HISTTIMEFORMAT= history 1 2>/dev/null || true)\";", - " case \"$hline\" in", - " (*NYATERM_PRUNE_HISTORY*|*NYATERM_INJ*|*__nyaterm_prompt*|*NyaTermReady*)", - " if [[ \"$hline\" =~ ^[[:space:]]*([0-9]+) ]]; then", - " history -d \"${{BASH_REMATCH[1]}}\" 2>/dev/null || true;", - " fi", - " ;;", - " esac;", + " case \"$hline\" in *NYATERM_PRUNE_HISTORY*|*NYATERM_INJ*|*__nyaterm_prompt*|*NyaTermReady*)", + " history_number=${{hline#\"${{hline%%[![:space:]]*}}\"}}; history_number=${{history_number%%[!0-9]*}};", + " [ -z \"$history_number\" ] || history -d \"$history_number\" 2>/dev/null || true;; esac;", " NYATERM_LAST_HISTCMD=\"${{HISTCMD-}}\";", " }};", " __nyaterm_emit_command(){{", @@ -88,36 +117,51 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " local cmd; cmd=\"$(fc -ln -1 2>/dev/null)\";", " if [ -n \"$cmd\" ] && command -v base64 >/dev/null 2>&1; then", " local b64; b64=\"$(printf '%s' \"$cmd\" | base64 | tr -d '\\r\\n')\";", - " printf '\\033]7777;NyaTermCommand:%s\\007' \"$b64\";", + " printf '\\033]%s%s\\007' \"$NYATERM_COMMAND_MARKER\" \"$b64\";", " fi;", " fi;", " }};", " __nyaterm_prompt(){{", - " __nyaterm_prune_history;", - " __nyaterm_emit_command;", + " local status=$?; __nyaterm_prune_history; __nyaterm_emit_command;", " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\";", + " return \"$status\";", " }};", - " __nyaterm_install_prompt(){{", - " local decl;", + " __nyaterm_prompt_state_writable(){{ local name decl; for name in __nyaterm_saved_prompt_command __nyaterm_extra_prompt_commands __nyaterm_exported_prompt_fallback; do decl=\"$(declare -p \"$name\" 2>/dev/null || true)\"; [[ ! \"$decl\" =~ ^declare\\ -[^[:space:]]*r ]] || return 1; done; }};", + " if __nyaterm_prompt_state_writable; then __nyaterm_extra_prompt_commands=(); fi;", + " __nyaterm_run_saved_prompt_command(){{", + " local status=$? command;", + " if [ -n \"${{__nyaterm_saved_prompt_command-}}\" ]; then __nyaterm_restore_status \"$status\"; builtin eval -- \"$__nyaterm_saved_prompt_command\"; status=$?; fi;", + " for command in \"${{__nyaterm_extra_prompt_commands[@]}}\"; do __nyaterm_restore_status \"$status\"; builtin eval -- \"$command\"; status=$?; done;", + " return \"$status\";", + " }};", + " __nyaterm_rebuild_exported_prompt_fallback(){{ local command result=\"${{__nyaterm_saved_prompt_command-}}\"; for command in \"${{__nyaterm_extra_prompt_commands[@]}}\"; do if [ -n \"$result\" ]; then result=\"$result; $command\"; else result=\"$command\"; fi; done; __nyaterm_exported_prompt_fallback=\"$result\"; }};", + " __nyaterm_capture_prompt_string(){{", + " local current=\"$1\" expected=\"$2\" tail; [ \"$current\" = \"$expected\" ] && return 0;", + " case \"$current\" in \"$expected\"\\;*|\"$expected\"\\&*|\"$expected \"*|\"$expected\"$'\\t'*|\"$expected\"$'\\n'*)", + " tail=${{current#\"$expected\"}}; while :; do tail=${{tail#\"${{tail%%[![:space:]]*}}\"}}; case \"$tail\" in \\;*|\\&*) tail=${{tail#?}};; *) break;; esac; done;", + " [ -z \"$tail\" ] || __nyaterm_extra_prompt_commands[${{#__nyaterm_extra_prompt_commands[@]}}]=\"$tail\";;", + " *\"$expected\"*) return 1;;", + " *) __nyaterm_saved_prompt_command=\"$current\"; __nyaterm_extra_prompt_commands=();; esac; __nyaterm_rebuild_exported_prompt_fallback; return 0;", + " }};", + " __nyaterm_array_prompt_supported(){{ [ \"${{BASH_VERSINFO[0]:-0}}\" -gt 5 ] || {{ [ \"${{BASH_VERSINFO[0]:-0}}\" -eq 5 ] && [ \"${{BASH_VERSINFO[1]:-0}}\" -ge 1 ]; }}; }};", + " __nyaterm_repair_prompt_container(){{", + " local decl f current exported=0 expected='if declare -F __nyaterm_prompt >/dev/null 2>&1; then __nyaterm_prompt; __nyaterm_run_saved_prompt_command; __nyaterm_repair_prompt; __nyaterm_prompt_guard; else eval -- \"${{__nyaterm_exported_prompt_fallback-}}\"; fi';", " decl=\"$(declare -p PROMPT_COMMAND 2>/dev/null || true)\";", - " if [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*a[^[:space:]]*\\ PROMPT_COMMAND= ]]; then", - " local f;", - " for f in \"${{PROMPT_COMMAND[@]}}\"; do", - " [ \"$f\" = __nyaterm_prompt ] && return 0;", - " done;", - " PROMPT_COMMAND=(__nyaterm_prompt \"${{PROMPT_COMMAND[@]}}\");", - " else", - " case \"${{PROMPT_COMMAND-}}\" in (*__nyaterm_prompt*) ;; (*)", - " PROMPT_COMMAND=\"__nyaterm_prompt${{PROMPT_COMMAND:+; $PROMPT_COMMAND}}\" ;; esac;", - " fi;", + " __nyaterm_prompt_state_writable || return 1;", + " [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*x ]] && exported=1;", + " [[ ! \"$decl\" =~ ^declare\\ -[^[:space:]]*r ]] || return 1;", + " if [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*a[^[:space:]]*\\ PROMPT_COMMAND= ]] && __nyaterm_array_prompt_supported; then", + " local -a retained=(); for f in \"${{PROMPT_COMMAND[@]}}\"; do case \"$f\" in __nyaterm_prompt|__nyaterm_repair_prompt) ;; *) retained+=(\"$f\");; esac; done;", + " PROMPT_COMMAND=(__nyaterm_prompt \"${{retained[@]}}\" __nyaterm_repair_prompt) || return 1;", + " else current=${{PROMPT_COMMAND-}}; if [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*a[^[:space:]]*\\ PROMPT_COMMAND= ]]; then unset PROMPT_COMMAND; fi;", + " if ! __nyaterm_capture_prompt_string \"$current\" \"$expected\"; then PROMPT_COMMAND=\"$expected\"; return 1; fi; PROMPT_COMMAND=\"$expected\" || return 1; if [ \"$exported\" -eq 1 ]; then export __nyaterm_exported_prompt_fallback; else unset __nyaterm_exported_prompt_fallback; fi; fi;", + " return 0;", " }};", - " __nyaterm_install_prompt;", - " if [ -n \"${{NYATERM_READY_PENDING:-}}\" ]; then", - " unset NYATERM_READY_PENDING;", - " printf '{}';", - " fi\n", + " __nyaterm_repair_prompt(){{ local status=$?; __nyaterm_repair_prompt_container || __nyaterm_ready_failed; return \"$status\"; }};", + " __nyaterm_install_prompt(){{ __nyaterm_repair_prompt_container; }};", + " if __nyaterm_install_prompt; then if [ -n \"${{NYATERM_READY_PENDING:-}}\" ]; then unset NYATERM_READY_PENDING; printf '{}'; fi; else unset NYATERM_READY_PENDING; __nyaterm_ready_failed; fi\n", ), - ready_osc, + command_marker, ready_failed_osc, ready_osc, )), ShellKind::Zsh => Some(format!( @@ -125,27 +169,36 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " fc -p /dev/null 2>/dev/null\n", " NYATERM_READY_PENDING=1;", " export NYATERM_INJ=1;", + " NYATERM_COMMAND_MARKER=\"{}\";", + " NYATERM_READY_FAILED_MARKER=\"$(printf '{}')\";", " __nyaterm_host(){{ hostname 2>/dev/null || printf localhost; }};", + " __nyaterm_ready_failed(){{ [ -n \"${{__nyaterm_failure_reported:-}}\" ] || {{ __nyaterm_failure_reported=1; printf '%s' \"${{NYATERM_READY_FAILED_MARKER-}}\"; }}; }};", " __nyaterm_emit(){{", - " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\";", + " local saved_status=$?; printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\"; return \"$saved_status\";", " }};", " __nyaterm_preexec(){{", - " if [ -n \"$1\" ] && command -v base64 >/dev/null 2>&1; then", + " local saved_status=$?; if [ -n \"$1\" ]; then", + " if command -v base64 >/dev/null 2>&1; then", " local b64; b64=\"$(printf '%s' \"$1\" | base64 | tr -d '\\r\\n')\";", - " printf '\\033]7777;NyaTermCommand:%s\\007' \"$b64\";", + " printf '\\033]%s%s\\007' \"$NYATERM_COMMAND_MARKER\" \"$b64\";", " fi;", + " fi; return \"$saved_status\";", " }};", - " autoload -Uz add-zsh-hook 2>/dev/null || true;", - " typeset -ga precmd_functions preexec_functions;", - " [[ \" ${{precmd_functions[*]}} \" == *\" __nyaterm_emit \"* ]] || precmd_functions+=(__nyaterm_emit);", - " [[ \" ${{preexec_functions[*]}} \" == *\" __nyaterm_preexec \"* ]] || preexec_functions+=(__nyaterm_preexec);", + " __nyaterm_repair_prompt_container(){{", + " [[ ${{parameters[precmd_functions]-}} == *readonly* ]] && return 1;", + " [[ ${{parameters[preexec_functions]-}} == *readonly* ]] && return 1;", + " typeset -ga precmd_functions preexec_functions || return 1;", + " local -a retained=(); local f; for f in \"${{precmd_functions[@]}}\"; do case \"$f\" in (__nyaterm_emit|__nyaterm_repair_prompt) ;; (*) retained+=(\"$f\");; esac; done;", + " precmd_functions=(\"${{retained[@]}}\" __nyaterm_emit __nyaterm_repair_prompt) || return 1;", + " retained=(); for f in \"${{preexec_functions[@]}}\"; do [ \"$f\" = __nyaterm_preexec ] || retained+=(\"$f\"); done;", + " preexec_functions=(\"${{retained[@]}}\" __nyaterm_preexec) || return 1; return 0;", + " }};", + " __nyaterm_repair_prompt(){{ local saved_status=$?; __nyaterm_repair_prompt_container || __nyaterm_ready_failed; return \"$saved_status\"; }};", + " __nyaterm_install_prompt(){{ __nyaterm_repair_prompt_container; }};", " fc -P 2>/dev/null\n", - " if [ -n \"${{NYATERM_READY_PENDING:-}}\" ]; then", - " unset NYATERM_READY_PENDING;", - " printf '{}';", - " fi\n", + " if __nyaterm_install_prompt; then if [ -n \"${{NYATERM_READY_PENDING:-}}\" ]; then unset NYATERM_READY_PENDING; printf '{}'; fi; else unset NYATERM_READY_PENDING; __nyaterm_ready_failed; fi\n", ), - ready_osc, + command_marker, ready_failed_osc, ready_osc, )), ShellKind::Fish => Some(format!( @@ -153,24 +206,27 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " set fish_private_mode 1 2>/dev/null\n", " set -g NYATERM_READY_PENDING 1;", " set -gx NYATERM_INJ 1;", + " set -g NYATERM_COMMAND_MARKER \"{}\";", + " set -g NYATERM_READY_FAILED_MARKER (printf '{}');", " function __nyaterm_emit --on-event fish_prompt;", - " printf '\\033]7;file://%s%s\\007' (hostname) $PWD;", + " set -l saved_status $status; printf '\\033]7;file://%s%s\\007' (hostname) $PWD; return $saved_status;", " end;", " function __nyaterm_preexec --on-event fish_preexec;", - " if test -n \"$argv[1]\"; and command -sq base64;", + " set -l saved_status $status; if test -n \"$argv[1]\";", + " if command -sq base64;", " set -l b64 (printf '%s' \"$argv[1]\" | base64 | tr -d '\\r\\n');", " if test -n \"$b64\";", - " printf '\\033]7777;NyaTermCommand:%s\\007' \"$b64\";", + " printf '\\033]%s%s\\007' \"$NYATERM_COMMAND_MARKER\" \"$b64\";", " end;", " end;", + " end; return $saved_status;", " end;", " set -e fish_private_mode 2>/dev/null\n", - " if set -q NYATERM_READY_PENDING;", - " set -e NYATERM_READY_PENDING;", - " printf '{}';", - " end\n", + " if functions -q __nyaterm_emit; and functions -q __nyaterm_preexec;", + " set -e NYATERM_READY_PENDING; printf '{}';", + " else; set -e NYATERM_READY_PENDING; printf '%s' \"$NYATERM_READY_FAILED_MARKER\"; end\n", ), - ready_osc, + command_marker, ready_failed_osc, ready_osc, )), ShellKind::PosixSh | ShellKind::Unknown => None, @@ -183,19 +239,25 @@ pub fn activation_script(shell: ShellKind, ready_marker: &str) -> Option .replace('\x1b', "\\033") .replace('\x07', "\\007") .replace('\'', "'\\''"); + let ready_failed_printf = ready_failed_marker_for_ready(ready_marker) + .replace('\\', "\\\\") + .replace('\x1b', "\\033") + .replace('\x07', "\\007") + .replace('\'', "'\\''"); + let command_marker = command_marker_for_ready(ready_marker); match shell { ShellKind::Bash => Some(format!( - " NYATERM_PRUNE_HISTORY=1; NYATERM_READY_PENDING=1; export NYATERM_INJ=1; export NYATERM_READY_MARKER=\"$(printf '{}')\"; [ -r \"$HOME/.config/nyaterm/shell-integration.bash\" ] && . \"$HOME/.config/nyaterm/shell-integration.bash\"; __nyaterm_install_prompt 2>/dev/null; if [ -n \"${{NYATERM_READY_PENDING:-}}\" ]; then unset NYATERM_READY_PENDING; printf '%s' \"${{NYATERM_READY_MARKER-}}\"; fi\n", - ready_printf + " NYATERM_PRUNE_HISTORY=1; NYATERM_READY_PENDING=1; export NYATERM_INJ=1; export NYATERM_READY_MARKER=\"$(printf '{}')\"; export NYATERM_READY_FAILED_MARKER=\"$(printf '{}')\"; export NYATERM_COMMAND_MARKER=\"{}\"; if [ -r \"$HOME/.config/nyaterm/shell-integration.bash\" ] && . \"$HOME/.config/nyaterm/shell-integration.bash\" && __nyaterm_install_prompt 2>/dev/null; then unset NYATERM_READY_PENDING; printf '%s' \"${{NYATERM_READY_MARKER-}}\"; else unset NYATERM_READY_PENDING; printf '%s' \"${{NYATERM_READY_FAILED_MARKER-}}\"; fi\n", + ready_printf, ready_failed_printf, command_marker )), ShellKind::Zsh => Some(format!( - " fc -p /dev/null 2>/dev/null\n NYATERM_READY_PENDING=1; export NYATERM_INJ=1; export NYATERM_READY_MARKER=\"$(printf '{}')\"; [ -r \"$HOME/.config/nyaterm/shell-integration.zsh\" ] && . \"$HOME/.config/nyaterm/shell-integration.zsh\"; __nyaterm_install_prompt 2>/dev/null; fc -P 2>/dev/null\n if [ -n \"${{NYATERM_READY_PENDING:-}}\" ]; then unset NYATERM_READY_PENDING; printf '%s' \"${{NYATERM_READY_MARKER-}}\"; fi\n", - ready_printf + " fc -p /dev/null 2>/dev/null\n NYATERM_READY_PENDING=1; export NYATERM_INJ=1; export NYATERM_READY_MARKER=\"$(printf '{}')\"; export NYATERM_READY_FAILED_MARKER=\"$(printf '{}')\"; export NYATERM_COMMAND_MARKER=\"{}\"; if [ -r \"$HOME/.config/nyaterm/shell-integration.zsh\" ] && . \"$HOME/.config/nyaterm/shell-integration.zsh\" && __nyaterm_install_prompt 2>/dev/null; then __nyaterm_install_ok=1; else __nyaterm_install_ok=0; fi; fc -P 2>/dev/null\n if [ \"$__nyaterm_install_ok\" = 1 ]; then unset NYATERM_READY_PENDING; printf '%s' \"${{NYATERM_READY_MARKER-}}\"; else unset NYATERM_READY_PENDING; printf '%s' \"${{NYATERM_READY_FAILED_MARKER-}}\"; fi; unset __nyaterm_install_ok\n", + ready_printf, ready_failed_printf, command_marker )), ShellKind::Fish => Some(format!( - " set fish_private_mode 1 2>/dev/null\n set -g NYATERM_READY_PENDING 1; set -gx NYATERM_INJ 1; set -gx NYATERM_READY_MARKER (printf '{}'); if test -r \"$HOME/.config/nyaterm/shell-integration.fish\"; source \"$HOME/.config/nyaterm/shell-integration.fish\"; end; __nyaterm_install_prompt 2>/dev/null; set -e fish_private_mode 2>/dev/null\n if set -q NYATERM_READY_PENDING; set -e NYATERM_READY_PENDING; printf '%s' \"$NYATERM_READY_MARKER\"; end\n", - ready_printf + " set fish_private_mode 1 2>/dev/null\n set -g NYATERM_READY_PENDING 1; set -gx NYATERM_INJ 1; set -gx NYATERM_READY_MARKER (printf '{}'); set -gx NYATERM_READY_FAILED_MARKER (printf '{}'); set -gx NYATERM_COMMAND_MARKER \"{}\"; if test -r \"$HOME/.config/nyaterm/shell-integration.fish\"; and source \"$HOME/.config/nyaterm/shell-integration.fish\"; and __nyaterm_install_prompt 2>/dev/null; set -e NYATERM_READY_PENDING; printf '%s' \"$NYATERM_READY_MARKER\"; else; set -e NYATERM_READY_PENDING; printf '%s' \"$NYATERM_READY_FAILED_MARKER\"; end; set -e fish_private_mode 2>/dev/null\n", + ready_printf, ready_failed_printf, command_marker )), ShellKind::PosixSh | ShellKind::Unknown => None, } @@ -282,88 +344,61 @@ pub fn replace_managed_block(existing: &str, block: &str) -> String { pub const MANAGED_BLOCK_START: &str = "# >>> nyaterm shell integration >>>"; pub const MANAGED_BLOCK_END: &str = "# <<< nyaterm shell integration <<<"; -const BASH_PERSISTENT_SCRIPT: &str = concat!( - "# nyaterm shell integration v1\n", - "__nyaterm_host(){ hostname 2>/dev/null || printf localhost; }\n", - "__nyaterm_prune_history(){\n", - " [ -n \"${NYATERM_PRUNE_HISTORY:-}\" ] || return 0\n", - " unset NYATERM_PRUNE_HISTORY\n", - " local hline\n", - " hline=\"$(HISTTIMEFORMAT= history 1 2>/dev/null || true)\"\n", - " case \"$hline\" in\n", - " (*NYATERM_PRUNE_HISTORY*|*NYATERM_INJ*|*__nyaterm_install_prompt*|*NyaTermReady*)\n", - " if [[ \"$hline\" =~ ^[[:space:]]*([0-9]+) ]]; then history -d \"${BASH_REMATCH[1]}\" 2>/dev/null || true; fi\n", - " ;;\n", - " esac\n", - "}\n", - "__nyaterm_emit_command(){\n", - " local histcmd=\"${HISTCMD-}\"\n", - " if [ -n \"$histcmd\" ] && [ \"${NYATERM_LAST_HISTCMD-}\" != \"$histcmd\" ]; then\n", - " NYATERM_LAST_HISTCMD=\"$histcmd\"\n", - " local cmd; cmd=\"$(fc -ln -1 2>/dev/null)\"\n", - " if [ -n \"$cmd\" ] && command -v base64 >/dev/null 2>&1; then\n", - " local b64; b64=\"$(printf '%s' \"$cmd\" | base64 | tr -d '\\r\\n')\"\n", - " printf '\\033]7777;NyaTermCommand:%s\\007' \"$b64\"\n", - " fi\n", - " fi\n", - "}\n", - "__nyaterm_prompt(){\n", - " __nyaterm_prune_history\n", - " __nyaterm_emit_command\n", - " if [ -n \"${NYATERM_READY_PENDING:-}\" ]; then unset NYATERM_READY_PENDING; printf '%s' \"${NYATERM_READY_MARKER-}\"; fi\n", - " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\"\n", - "}\n", - "__nyaterm_install_prompt(){\n", - " NYATERM_LAST_HISTCMD=\"${HISTCMD-}\"\n", - " local decl\n", - " decl=\"$(declare -p PROMPT_COMMAND 2>/dev/null || true)\"\n", - " if [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*a[^[:space:]]*\\ PROMPT_COMMAND= ]]; then\n", - " local f\n", - " for f in \"${PROMPT_COMMAND[@]}\"; do [ \"$f\" = __nyaterm_prompt ] && return 0; done\n", - " PROMPT_COMMAND=(__nyaterm_prompt \"${PROMPT_COMMAND[@]}\")\n", - " else\n", - " case \"${PROMPT_COMMAND-}\" in (*__nyaterm_prompt*) ;; (*) PROMPT_COMMAND=\"__nyaterm_prompt${PROMPT_COMMAND:+; $PROMPT_COMMAND}\" ;; esac\n", - " fi\n", - "}\n" -); +const BASH_PERSISTENT_SCRIPT: &str = include_str!("bash_persistent.sh"); const ZSH_PERSISTENT_SCRIPT: &str = concat!( "# nyaterm shell integration v1\n", "__nyaterm_host(){ hostname 2>/dev/null || printf localhost; }\n", + "__nyaterm_ready_failed(){ [ -n \"${__nyaterm_failure_reported:-}\" ] || { __nyaterm_failure_reported=1; printf '%s' \"${NYATERM_READY_FAILED_MARKER-}\"; }; }\n", "__nyaterm_emit(){\n", + " local saved_status=$?\n", " if [ -n \"${NYATERM_READY_PENDING:-}\" ]; then unset NYATERM_READY_PENDING; printf '%s' \"${NYATERM_READY_MARKER-}\"; fi\n", " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\"\n", + " return \"$saved_status\"\n", "}\n", "__nyaterm_preexec(){\n", + " local saved_status=$?\n", " if [ -n \"$1\" ] && command -v base64 >/dev/null 2>&1; then\n", " local b64; b64=\"$(printf '%s' \"$1\" | base64 | tr -d '\\r\\n')\"\n", " printf '\\033]7777;NyaTermCommand:%s\\007' \"$b64\"\n", " fi\n", + " return \"$saved_status\"\n", "}\n", - "__nyaterm_install_prompt(){\n", - " autoload -Uz add-zsh-hook 2>/dev/null || true\n", - " typeset -ga precmd_functions preexec_functions\n", - " [[ \" ${precmd_functions[*]} \" == *\" __nyaterm_emit \"* ]] || precmd_functions+=(__nyaterm_emit)\n", - " [[ \" ${preexec_functions[*]} \" == *\" __nyaterm_preexec \"* ]] || preexec_functions+=(__nyaterm_preexec)\n", - "}\n" + "__nyaterm_repair_prompt_container(){\n", + " [[ ${parameters[precmd_functions]-} == *readonly* ]] && return 1\n", + " [[ ${parameters[preexec_functions]-} == *readonly* ]] && return 1\n", + " typeset -ga precmd_functions preexec_functions || return 1\n", + " local -a retained=(); local f\n", + " for f in \"${precmd_functions[@]}\"; do case \"$f\" in (__nyaterm_emit|__nyaterm_repair_prompt) ;; (*) retained+=(\"$f\");; esac; done\n", + " precmd_functions=(\"${retained[@]}\" __nyaterm_emit __nyaterm_repair_prompt) || return 1\n", + " retained=()\n", + " for f in \"${preexec_functions[@]}\"; do [ \"$f\" = __nyaterm_preexec ] || retained+=(\"$f\"); done\n", + " preexec_functions=(\"${retained[@]}\" __nyaterm_preexec) || return 1\n", + "}\n", + "__nyaterm_repair_prompt(){ local saved_status=$?; __nyaterm_repair_prompt_container || __nyaterm_ready_failed; return \"$saved_status\"; }\n", + "__nyaterm_install_prompt(){ __nyaterm_repair_prompt_container; }\n" ); const FISH_PERSISTENT_SCRIPT: &str = concat!( "# nyaterm shell integration v1\n", "function __nyaterm_emit\n", + " set -l saved_status $status\n", " if set -q NYATERM_READY_PENDING\n", " set -e NYATERM_READY_PENDING\n", " printf '%s' \"$NYATERM_READY_MARKER\"\n", " end\n", " printf '\\033]7;file://%s%s\\007' (hostname) $PWD\n", + " return $saved_status\n", "end\n", "function __nyaterm_preexec\n", + " set -l saved_status $status\n", " if test -n \"$argv[1]\"; and command -sq base64\n", " set -l b64 (printf '%s' \"$argv[1]\" | base64 | tr -d '\\r\\n')\n", " if test -n \"$b64\"\n", " printf '\\033]7777;NyaTermCommand:%s\\007' \"$b64\"\n", " end\n", " end\n", + " return $saved_status\n", "end\n", "function __nyaterm_install_prompt\n", " functions -e __nyaterm_emit_event 2>/dev/null\n", @@ -374,6 +409,7 @@ const FISH_PERSISTENT_SCRIPT: &str = concat!( " function __nyaterm_preexec_event --on-event fish_preexec\n", " __nyaterm_preexec $argv\n", " end\n", + " functions -q __nyaterm_emit_event; and functions -q __nyaterm_preexec_event\n", "end\n" ); @@ -382,6 +418,119 @@ const FISH_PERSISTENT_SCRIPT: &str = concat!( // --------------------------------------------------------------------------- const MAX_OSC_BUF: usize = 64 * 1024; +const MAX_CWD_OSC_PAYLOAD: usize = MAX_OSC_BUF; +const MAX_COMMAND_OSC_PAYLOAD: usize = 64 * 1024; + +/// Control-string families understood by the startup transport filter. +/// +/// xterm.js accepts both the traditional 7-bit `ESC` introducers and the +/// equivalent 8-bit C1 controls. Rust does not interpret application OSC 0/2 +/// payloads; it only finds complete frames so they can be forwarded intact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ControlSequenceKind { + Csi, + Osc, + Dcs, +} + +/// Find the next recognized 7-bit or C1 control-sequence introducer. +/// Returns the byte offset, family, and byte length of the introducer. +pub(crate) fn find_control_sequence_start( + value: &str, + from: usize, +) -> Option<(usize, ControlSequenceKind, usize)> { + let tail = value.get(from..)?; + let mut chars = tail.char_indices().peekable(); + while let Some((offset, character)) = chars.next() { + let index = from + offset; + match character { + '\x1b' => match chars.peek().map(|(_, next)| *next) { + Some('[') => return Some((index, ControlSequenceKind::Csi, 2)), + Some(']') => return Some((index, ControlSequenceKind::Osc, 2)), + Some('P') => return Some((index, ControlSequenceKind::Dcs, 2)), + _ => {} + }, + '\u{009b}' => return Some((index, ControlSequenceKind::Csi, character.len_utf8())), + '\u{009d}' => return Some((index, ControlSequenceKind::Osc, character.len_utf8())), + '\u{0090}' => return Some((index, ControlSequenceKind::Dcs, character.len_utf8())), + _ => {} + } + } + None +} + +/// Find the next OSC introducer, ignoring unrelated CSI/DCS sequences. +pub(crate) fn find_osc_start(value: &str, from: usize) -> Option<(usize, usize)> { + let mut cursor = from; + while let Some((index, kind, opener_len)) = find_control_sequence_start(value, cursor) { + if kind == ControlSequenceKind::Osc { + return Some((index, opener_len)); + } + cursor = index.saturating_add(opener_len.max(1)); + } + None +} + +/// Return `(content_end, terminator_len)` for a complete control sequence. +/// For CSI, `content_end` is already exclusive and `terminator_len` is zero. +pub(crate) fn find_control_sequence_end( + value: &str, + start: usize, + kind: ControlSequenceKind, + opener_len: usize, +) -> Option<(usize, usize)> { + let payload_start = start.checked_add(opener_len)?; + match kind { + ControlSequenceKind::Csi => { + value + .get(payload_start..)? + .char_indices() + .find_map(|(offset, character)| { + (('\x40'..='\x7e').contains(&character)) + .then_some((payload_start + offset + character.len_utf8(), 0)) + }) + } + ControlSequenceKind::Osc | ControlSequenceKind::Dcs => { + find_control_string_terminator(value, payload_start) + } + } +} + +fn find_control_string_terminator(value: &str, from: usize) -> Option<(usize, usize)> { + let tail = value.get(from..)?; + let bel = tail.find('\x07').map(|index| (from + index, 1)); + let st_7bit = tail.find("\x1b\\").map(|index| (from + index, 2)); + let st_c1 = tail + .find('\u{009c}') + .map(|index| (from + index, '\u{009c}'.len_utf8())); + [bel, st_7bit, st_c1] + .into_iter() + .flatten() + .min_by_key(|(index, _)| *index) +} + +pub(crate) fn control_sequence_payload<'a>( + value: &'a str, + start: usize, + opener_len: usize, + content_end: usize, + terminator_len: usize, +) -> Option<&'a str> { + let payload_start = start.checked_add(opener_len)?; + // `content_end` is the terminator start for OSC/DCS and the sequence end + // for CSI; the terminator length is only needed by the caller to advance. + let _ = terminator_len; + value.get(payload_start..content_end) +} + +/// Ordered Local cwd transport event. A valid payload and an oversized +/// invalidation must be applied in source order; otherwise a cwd update can +/// be erased by an event processed out of order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CwdPayloadEvent { + Payload(String), + Invalidated, +} /// Result returned by [`OscStripper::push`]. pub struct OscResult { @@ -389,10 +538,22 @@ pub struct OscResult { pub visible: String, /// Visible text that appeared after the ready marker in this chunk. pub visible_after_ready: String, - /// CWD paths extracted from OSC 7 sequences in this chunk. + /// Released cwd path projection extracted from valid `file://` OSC 7. pub cwd_paths: Vec, + /// Bounded raw OSC 7 payloads used by Local compatibility tests. Runtime + /// consumers use `cwd_payload_events` to preserve invalidation order. + #[allow(dead_code)] + pub cwd_payloads: Vec, + /// Compatibility summary used by parser tests; ordered runtime handling is + /// represented by `cwd_payload_events`. + #[allow(dead_code)] + pub cwd_payload_invalidated: bool, + /// Ordered Local-only payload/invalidation events for projection updates. + pub(crate) cwd_payload_events: Vec, /// Whether the ready marker was detected in this chunk. pub ready: bool, + /// Whether this session's explicit integration-failure marker was detected. + pub ready_failed: bool, /// Shell-confirmed commands extracted from private NyaTerm OSC markers. pub accepted_commands: Vec, } @@ -402,20 +563,40 @@ pub struct OscResult { pub struct OscStripper { buf: String, ready_inner: String, + ready_failed_inner: Option, legacy_ready_inner: Option, + command_marker_inner: Option, + collect_cwd_payloads: bool, + discard_oversized_metadata: bool, + discard_oversized_metadata_pending_st: bool, } impl OscStripper { pub fn new(ready_marker: &str) -> Self { + Self::with_cwd_payloads(ready_marker, false) + } + + pub fn with_cwd_payloads(ready_marker: &str, collect_cwd_payloads: bool) -> Self { let ready_inner = marker_inner(ready_marker); + let ready_failed_inner = ready_inner + .strip_prefix(READY_MARKER_PREFIX) + .map(|session_id| format!("{READY_FAILED_MARKER_PREFIX}{session_id}")); let legacy_ready_inner = ready_inner .strip_prefix(READY_MARKER_PREFIX) .map(|session_id| format!("{LEGACY_READY_MARKER_PREFIX}{session_id}")); + let command_marker_inner = ready_inner + .strip_prefix(READY_MARKER_PREFIX) + .map(build_command_marker); Self { buf: String::new(), ready_inner, + ready_failed_inner, legacy_ready_inner, + command_marker_inner, + collect_cwd_payloads, + discard_oversized_metadata: false, + discard_oversized_metadata_pending_st: false, } } @@ -423,16 +604,40 @@ impl OscStripper { /// sequences stripped, any CWD paths found, and whether the ready /// marker appeared. pub fn push(&mut self, chunk: &str) -> OscResult { + let chunk = if self.discard_oversized_metadata { + if self.discard_oversized_metadata_pending_st && chunk.starts_with('\\') { + self.discard_oversized_metadata = false; + self.discard_oversized_metadata_pending_st = false; + &chunk[1..] + } else { + self.discard_oversized_metadata_pending_st = false; + let Some((end_idx, term_len)) = find_control_string_terminator(chunk, 0) else { + self.discard_oversized_metadata_pending_st = chunk.ends_with('\x1b'); + return empty_osc_result(); + }; + self.discard_oversized_metadata = false; + &chunk[end_idx + term_len..] + } + } else { + chunk + }; self.buf.push_str(chunk); // Safety valve: if the buffer is enormous without any ESC, just // flush everything as visible to avoid unbounded memory growth. - if self.buf.len() > MAX_OSC_BUF && !self.buf.contains('\x1b') { + if self.buf.len() > MAX_OSC_BUF + && !self.buf.contains('\x1b') + && !self.buf.contains('\u{009d}') + { return OscResult { visible: std::mem::take(&mut self.buf), visible_after_ready: String::new(), cwd_paths: Vec::new(), + cwd_payloads: Vec::new(), + cwd_payload_invalidated: false, + cwd_payload_events: Vec::new(), ready: false, + ready_failed: false, accepted_commands: Vec::new(), }; } @@ -440,43 +645,68 @@ impl OscStripper { let mut visible = String::new(); let mut visible_after_ready = String::new(); let mut paths = Vec::new(); + let mut cwd_payloads = Vec::new(); + let mut cwd_payload_invalidated = false; + let mut cwd_payload_events = Vec::new(); let mut ready = false; + let mut ready_failed = false; let mut after_ready = false; let mut commands = Vec::new(); loop { - let esc_pos = match self.buf.find("\x1b]") { - Some(i) => i, - None => { - // No ESC] left — everything is visible text. - if after_ready { - visible_after_ready.push_str(&self.buf); - } - visible.push_str(&self.buf); - self.buf.clear(); - break; + let Some((osc_pos, opener_len)) = find_osc_start(&self.buf, 0) else { + // Retain a trailing ESC or C1 OSC opener so a split opener is + // recognized on the next push. C1 controls are represented as + // two UTF-8 bytes in this decoded String. + let keep = if self.buf.ends_with('\x1b') { + 1 + } else if self.buf.ends_with('\u{009d}') { + '\u{009d}'.len_utf8() + } else { + 0 + }; + let visible_end = self.buf.len().saturating_sub(keep); + if after_ready { + visible_after_ready.push_str(&self.buf[..visible_end]); } + visible.push_str(&self.buf[..visible_end]); + if keep == 0 { + self.buf.clear(); + } else { + self.buf = self.buf[visible_end..].to_string(); + } + break; }; - // Text before the ESC is always visible. + // Text before the OSC introducer is always visible. if after_ready { - visible_after_ready.push_str(&self.buf[..esc_pos]); + visible_after_ready.push_str(&self.buf[..osc_pos]); } - visible.push_str(&self.buf[..esc_pos]); - let rest = self.buf[esc_pos..].to_string(); + visible.push_str(&self.buf[..osc_pos]); + let rest = self.buf[osc_pos..].to_string(); - // Find the terminator: BEL (\x07) or ST (\x1b\\). - let end = rest.find('\x07').map(|i| (i, 1)).or_else(|| { - // Make sure we don't match the opening \x1b] as \x1b\\. - rest[2..].find("\x1b\\").map(|i| (i + 2, 2)) - }); - - let Some((end_idx, term_len)) = end else { + // Find the terminator: BEL (\x07), 7-bit ST (ESC \\), or C1 ST. + let Some((end_idx, term_len)) = find_control_string_terminator(&rest, opener_len) + else { // Incomplete sequence — keep in buffer for next chunk. self.buf = rest; - // But if buffer is already huge, give up and flush. - if self.buf.len() > MAX_OSC_BUF { + if let Some(limit) = recognized_metadata_limit(&self.buf) { + if self.buf.len() > limit { + if self + .buf + .get(opener_len..) + .is_some_and(|value| value.starts_with("7;")) + { + cwd_payload_invalidated = true; + cwd_payload_events.push(CwdPayloadEvent::Invalidated); + } + self.discard_oversized_metadata_pending_st = self.buf.ends_with('\x1b'); + self.buf.clear(); + self.discard_oversized_metadata = true; + } + } else if self.buf.len() > MAX_OSC_BUF { + // Ordinary application OSC remains xterm.js-owned. visible.push_str(&self.buf); self.buf.clear(); } @@ -484,29 +714,53 @@ impl OscStripper { }; let seq = &rest[..end_idx + term_len]; - let inner = &rest[2..end_idx]; // between \x1b] and terminator + let inner = &rest[opener_len..end_idx]; // between opener and terminator if inner.starts_with("7;") { - // OSC 7 — extract CWD path. - if let Some(path) = parse_osc7_payload(&inner[2..]) { - paths.push(path); + // Preserve released `file://` path extraction while exposing a + // bounded raw payload to the stricter Local-only parser. + let payload = &inner[2..]; + if payload.len() <= MAX_CWD_OSC_PAYLOAD { + if self.collect_cwd_payloads { + let payload_value = payload.to_string(); + cwd_payloads.push(payload_value.clone()); + cwd_payload_events.push(CwdPayloadEvent::Payload(payload_value)); + } + if let Some(path) = parse_legacy_osc7_payload(payload) { + paths.push(path); + } + } else { + cwd_payload_invalidated = true; + cwd_payload_events.push(CwdPayloadEvent::Invalidated); } } else if self.is_current_ready_marker(inner) { ready = true; after_ready = true; + } else if self.is_current_ready_failed_marker(inner) { + ready_failed = true; + after_ready = true; } else if inner.starts_with(READY_MARKER_PREFIX) + || inner.starts_with(READY_FAILED_MARKER_PREFIX) || inner.starts_with(LEGACY_READY_MARKER_PREFIX) { // Private marker for another session; strip it without // treating this session as ready. - } else if let Some(command) = inner - .strip_prefix(COMMAND_MARKER_PREFIX) - .or_else(|| inner.strip_prefix(LEGACY_COMMAND_MARKER_PREFIX)) - .and_then(parse_command_payload) + } else if inner.starts_with(COMMAND_MARKER_PREFIX) + || inner.starts_with(LEGACY_COMMAND_MARKER_PREFIX) { - commands.push(command); + if inner.len() <= MAX_COMMAND_OSC_PAYLOAD { + if let Some(command) = + parse_command_marker(inner, self.command_marker_inner.as_deref()) + { + commands.push(command); + } + } + // Recognized private marker: always strip it, even when the + // session nonce/payload is invalid. } else { - // Not ours — pass through to the terminal. + // OSC 0/1/2 and all other application sequences belong to the + // terminal emulator. Keep them byte-for-byte visible to the + // downstream xterm.js parser. if after_ready { visible_after_ready.push_str(seq); } @@ -520,7 +774,11 @@ impl OscStripper { visible, visible_after_ready, cwd_paths: paths, + cwd_payloads, + cwd_payload_invalidated, + cwd_payload_events, ready, + ready_failed, accepted_commands: commands, } } @@ -533,6 +791,47 @@ impl OscStripper { fn is_current_ready_marker(&self, inner: &str) -> bool { inner == self.ready_inner || self.legacy_ready_inner.as_deref() == Some(inner) } + + fn is_current_ready_failed_marker(&self, inner: &str) -> bool { + self.ready_failed_inner.as_deref() == Some(inner) + } +} + +fn empty_osc_result() -> OscResult { + OscResult { + visible: String::new(), + visible_after_ready: String::new(), + cwd_paths: Vec::new(), + cwd_payloads: Vec::new(), + cwd_payload_invalidated: false, + cwd_payload_events: Vec::new(), + ready: false, + ready_failed: false, + accepted_commands: Vec::new(), + } +} + +fn recognized_metadata_limit(value: &str) -> Option { + let opener_len = if value.starts_with("\x1b]") { + 2 + } else if value.starts_with('\u{009d}') { + '\u{009d}'.len_utf8() + } else { + return None; + }; + let inner = value.get(opener_len..)?; + if inner.starts_with("7;") { + Some(MAX_CWD_OSC_PAYLOAD + opener_len + 2) + } else if inner.starts_with("7777;NyaTermCommand:") + || inner.starts_with("7777;DflyCommand:") + || inner.starts_with("7777;NyaTermReady:") + || inner.starts_with("7777;NyaTermReadyFailed:") + || inner.starts_with("7777;DflyReady:") + { + Some(MAX_COMMAND_OSC_PAYLOAD + opener_len) + } else { + None + } } fn marker_inner(marker: &str) -> String { @@ -549,34 +848,56 @@ fn marker_inner(marker: &str) -> String { } } -/// Parse the payload of an OSC 7 sequence (`file://host/path`). -fn parse_osc7_payload(payload: &str) -> Option { +/// Released OSC 7 path projection. Keep this byte-for-byte compatible for +/// existing SSH, File Explorer and `cwd-changed-*` consumers. +pub(crate) fn parse_legacy_osc7_payload(payload: &str) -> Option { let after_scheme = payload.strip_prefix("file://")?; let path = if after_scheme.starts_with('/') { - after_scheme.to_string() + after_scheme } else { let slash = after_scheme.find('/')?; - after_scheme[slash..].to_string() + &after_scheme[slash..] }; - if path.is_empty() { None } else { Some(path) } + (!path.is_empty()).then(|| path.to_string()) } -fn parse_command_payload(payload: &str) -> Option { +fn parse_command_marker(inner: &str, expected_marker: Option<&str>) -> Option { + if let Some(expected) = expected_marker { + if let Some(rest) = inner.strip_prefix(COMMAND_MARKER_PREFIX) { + // Legacy markers have base64 immediately after the prefix. Only + // reject a mismatched marker when the nonce separator is present. + if rest.contains(':') && !inner.starts_with(expected) { + return None; + } + } + } + + let payload = if let Some(marker) = expected_marker { + inner.strip_prefix(marker) + } else { + None + } + .or_else(|| inner.strip_prefix(COMMAND_MARKER_PREFIX)) + .or_else(|| inner.strip_prefix(LEGACY_COMMAND_MARKER_PREFIX))?; + + // A new-format marker for another session contains a nonce separator and + // is never accepted by the legacy fallback. This prevents cross-session + // marker confusion while preserving old scripts until they are refreshed. + if payload.contains(':') { + return None; + } + let decoded = BASE64_STANDARD.decode(payload).ok()?; let command = String::from_utf8(decoded).ok()?; - if command.is_empty() { - None - } else { - Some(command) - } + (!command.is_empty()).then_some(command) } #[cfg(test)] mod tests { use super::{ - MANAGED_BLOCK_END, MANAGED_BLOCK_START, OscStripper, ShellKind, activation_script, - build_ready_marker, injection_script, persistent_script, rc_managed_block, - replace_managed_block, + CwdPayloadEvent, MANAGED_BLOCK_END, MANAGED_BLOCK_START, OscStripper, ShellKind, + activation_script, build_ready_marker, injection_script, persistent_script, + rc_managed_block, replace_managed_block, }; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; @@ -587,16 +908,38 @@ mod tests { .expect("bash injection script"); assert!(script.contains("NYATERM_PRUNE_HISTORY=1;")); - assert!(script.contains("history -d \"${BASH_REMATCH[1]}\" 2>/dev/null || true;")); - assert!( - script.contains( - "PROMPT_COMMAND=\"__nyaterm_prompt${PROMPT_COMMAND:+; $PROMPT_COMMAND}\"" - ) - ); + assert!(script.contains("history_number=${hline#")); + assert!(script.contains("history -d \"$history_number\" 2>/dev/null || true;")); + assert!(!script.contains("BASH_REMATCH")); + assert!(script.contains("if declare -F __nyaterm_prompt")); + assert!(script.contains("__nyaterm_exported_prompt_fallback")); + assert!(script.contains( + "PROMPT_COMMAND=(__nyaterm_prompt \"${retained[@]}\" __nyaterm_repair_prompt)" + )); assert!(!script.contains("set +o history")); assert!(!script.contains("set -o history")); } + #[test] + fn bash_scripts_keep_legacy_scalar_semantics_without_extglob_syntax() { + let ready = build_ready_marker("session-1"); + let direct = injection_script(ShellKind::Bash, &ready).expect("Bash injection"); + let persistent = persistent_script(ShellKind::Bash).expect("Bash persistent script"); + let activation = activation_script(ShellKind::Bash, &ready).expect("Bash activation"); + + for script in [&direct, persistent, &activation] { + assert!(!script.contains(" in (")); + assert!(!script.contains("BASH_REMATCH")); + } + for script in [&direct, persistent] { + assert!(script.contains("array_prompt_supported")); + assert!(script.contains("capture_prompt_string")); + assert!(script.contains("BASH_VERSINFO[1]")); + assert!(script.contains("__nyaterm_prompt_guard(){ return $?; }")); + assert!(script.contains("__nyaterm_repair_prompt; __nyaterm_prompt_guard")); + } + } + #[test] fn managed_block_is_added_replaced_and_deduplicated() { let block = rc_managed_block(ShellKind::Bash).expect("bash block"); @@ -626,6 +969,190 @@ mod tests { assert!(script[install_pos..].contains("--on-event fish_preexec")); } + #[test] + fn persistent_hooks_preserve_status_and_repair_mutated_hook_containers() { + let bash = persistent_script(ShellKind::Bash).expect("bash persistent script"); + assert!(bash.contains("local status=$?")); + assert!(bash.contains("declare -p __nyaterm_extra_prompt_commands")); + assert!(bash.contains("__nyaterm_prompt_state_writable")); + assert!(bash.contains("__nyaterm_state_token")); + assert!(bash.contains("__nyaterm_prompt_state_nonce")); + assert!(bash.contains("direct_exported")); + assert!(bash.contains("${__nyaterm_integration_active-}")); + assert!(bash.contains("__nyaterm_prompt_state_mirror_valid")); + assert!(bash.contains("__nyaterm_restore_prompt_state")); + assert!(bash.contains("__nyaterm_run_saved_prompt_command")); + assert!(bash.contains("__nyaterm_repair_prompt_container")); + assert!(bash.contains("NYATERM_READY_FAILED_MARKER")); + assert!(bash.contains("__nyaterm_failure_reported")); + + let zsh = persistent_script(ShellKind::Zsh).expect("zsh persistent script"); + assert!(zsh.contains("local saved_status=$?")); + assert!(zsh.contains("__nyaterm_repair_prompt_container")); + assert!(zsh.contains("NYATERM_READY_FAILED_MARKER")); + assert!(zsh.contains("__nyaterm_failure_reported")); + + let fish = persistent_script(ShellKind::Fish).expect("fish persistent script"); + assert!(fish.contains("set -l saved_status $status")); + assert!(fish.contains("return $saved_status")); + assert!(fish.contains("functions -q __nyaterm_emit_event")); + } + + #[cfg(unix)] + #[test] + fn native_bash_persistent_resource_preserves_captured_prompt_mutations() { + use std::process::Command; + + let integration = persistent_script(ShellKind::Bash).expect("bash persistent script"); + let script = format!( + concat!( + "NYATERM_COMMAND_MARKER='7777;NyaTermCommand:test-session:'\n", + "PROMPT_COMMAND=':'; hook=0\n", + "{0}\n", + "__nyaterm_install_prompt\n", + "PROMPT_COMMAND+='; hook=$((hook+1))'\n", + "eval \"$PROMPT_COMMAND\"\n", + "eval \"$PROMPT_COMMAND\"\n", + "printf 'before=%s\\n' \"$hook\"\n", + "{0}\n", + "__nyaterm_install_prompt\n", + "eval \"$PROMPT_COMMAND\"\n", + "printf 'after=%s\\n' \"$hook\"\n", + "collision=0\n", + "__nyaterm_saved_prompt_command='collision=$((collision+1))'\n", + "__nyaterm_extra_prompt_commands=('collision=$((collision+10))')\n", + "{0}\n", + "__nyaterm_install_prompt\n", + "eval \"$PROMPT_COMMAND\"\n", + "printf 'restored=%s collision=%s\\n' \"$hook\" \"$collision\"\n", + "readonly __nyaterm_saved_prompt_command='collision=$((collision+1))'\n", + "{0}\n", + "if __nyaterm_install_prompt; then readonly_install=unexpected; else readonly_install=failed; fi\n", + "eval \"$PROMPT_COMMAND\"\n", + "printf 'readonly_install=%s collision=%s\\n' \"$readonly_install\" \"$collision\"\n" + ), + integration + ); + let output = Command::new("/bin/bash") + .args(["--noprofile", "--norc", "-c", &script]) + .output() + .expect("run Bash persistent re-source smoke"); + let mut combined = output.stdout; + combined.extend_from_slice(&output.stderr); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"before=2".len()) + .any(|value| value == b"before=2"), + "{}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"after=3".len()) + .any(|value| value == b"after=3"), + "{}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"restored=4 collision=0".len()) + .any(|value| value == b"restored=4 collision=0"), + "{}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"readonly_install=failed collision=0".len()) + .any(|value| value == b"readonly_install=failed collision=0"), + "{}", + String::from_utf8_lossy(&combined) + ); + } + + #[cfg(unix)] + #[test] + fn direct_bash_rejects_readonly_prompt_state_before_ready() { + use std::process::Command; + + let integration = injection_script(ShellKind::Bash, &build_ready_marker("session-1")) + .expect("bash injection script"); + let script = format!( + r#"readonly __nyaterm_extra_prompt_commands=collision +{0} +"#, + integration + ); + let output = Command::new("/bin/bash") + .args(["--noprofile", "--norc", "-c", &script]) + .output() + .expect("run readonly direct-injection smoke"); + let mut combined = output.stdout; + combined.extend_from_slice(&output.stderr); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"NyaTermReadyFailed:session-1".len()) + .any(|value| value == b"NyaTermReadyFailed:session-1") + ); + assert!( + !combined + .windows(b"NyaTermReady:session-1".len()) + .any(|value| value == b"NyaTermReady:session-1") + ); + } + + #[cfg(unix)] + #[test] + fn persistent_bash_rejects_preseeded_prompt_state() { + use std::process::Command; + + let integration = persistent_script(ShellKind::Bash).expect("bash persistent script"); + let script = format!( + r#"NYATERM_COMMAND_MARKER='7777;NyaTermCommand:test-session:' +PROMPT_COMMAND='__nyaterm_prompt; __nyaterm_run_saved_prompt_command; __nyaterm_repair_prompt; __nyaterm_prompt_guard' +__nyaterm_prompt_state_owner=nyaterm-shell-integration-v1 +readonly __nyaterm_prompt_state_owner +__nyaterm_saved_prompt_command_mirror='printf __SPOOFED__' +declare -a __nyaterm_extra_prompt_commands_mirror=() +{0} +if __nyaterm_install_prompt; then printf 'UNEXPECTED\n'; else printf 'FAILED\n'; fi +eval "$PROMPT_COMMAND" 2>/dev/null || true +"#, + integration + ); + let output = Command::new("/bin/bash") + .args(["--noprofile", "--norc", "-c", &script]) + .output() + .expect("run prompt-state provenance smoke"); + let mut combined = output.stdout; + combined.extend_from_slice(&output.stderr); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"FAILED".len()) + .any(|value| value == b"FAILED") + ); + assert!( + !combined + .windows(b"__SPOOFED__".len()) + .any(|value| value == b"__SPOOFED__") + ); + } + #[test] fn interactive_shell_ready_marker_is_emitted_after_hook_installation() { let ready_marker = build_ready_marker("session-1"); @@ -668,9 +1195,30 @@ mod tests { assert!(fish.contains("printf '\\033]7;file://%s%s\\007'")); assert_no_empty_tail_printf(&fish); - assert!(bash.contains("NyaTermCommand:%s")); - assert!(zsh.contains("NyaTermCommand:%s")); - assert!(fish.contains("NyaTermCommand:%s")); + assert!(bash.contains("NYATERM_COMMAND_MARKER")); + assert!(zsh.contains("NYATERM_COMMAND_MARKER")); + assert!(fish.contains("NYATERM_COMMAND_MARKER")); + for script in [&bash, &zsh, &fish] { + assert!(!script.contains("\\033]2;")); + } + for script in [ + persistent_script(ShellKind::Bash).unwrap(), + persistent_script(ShellKind::Zsh).unwrap(), + persistent_script(ShellKind::Fish).unwrap(), + ] { + assert!(!script.contains("\\033]2;")); + } + assert!(bash.contains("NyaTermCommand:session-1:")); + assert!(zsh.contains("NyaTermCommand:session-1:")); + assert!(fish.contains("NyaTermCommand:session-1:")); + for script in [&bash, &zsh, &fish] { + assert!(script.contains("NyaTermReadyFailed:session-1")); + } + assert!(bash.contains("local status=$?")); + assert!(bash.contains("__nyaterm_run_saved_prompt_command")); + assert!(bash.contains("__nyaterm_repair_prompt")); + assert!(zsh.contains("__nyaterm_repair_prompt_container")); + assert!(fish.contains("return $saved_status")); } #[test] @@ -681,6 +1229,7 @@ mod tests { let script = activation_script(shell, &ready_marker).expect("activation script"); assert!(script.contains("NyaTermReady:session-1")); + assert!(script.contains("NyaTermReadyFailed:session-1")); assert!(!script.contains("printf '' 2>/dev/null")); } } @@ -726,6 +1275,23 @@ mod tests { assert_eq!(result.visible_after_ready, "[user@host ~]$ "); } + #[test] + fn session_bound_failure_marker_is_stripped_and_reported() { + let payload = concat!( + "before", + "\x1b]7777;NyaTermReadyFailed:session-1\x07", + "after", + "\x1b]7777;NyaTermReadyFailed:session-2\x07", + ); + + let result = OscStripper::new(&build_ready_marker("session-1")).push(payload); + + assert!(!result.ready); + assert!(result.ready_failed); + assert_eq!(result.visible, "beforeafter"); + assert_eq!(result.visible_after_ready, "after"); + } + #[test] fn ready_marker_for_other_session_does_not_mark_ready() { let payload = "before\x1b]7777;NyaTermReady:session-2\x07after"; @@ -779,4 +1345,222 @@ mod tests { assert_eq!(result.accepted_commands, vec!["docker ps".to_string()]); assert!(result.ready); } + + #[test] + fn parses_osc_when_the_opener_is_split_after_escape() { + let mut stripper = OscStripper::with_cwd_payloads(&build_ready_marker("s"), true); + let first = stripper.push("before\x1b"); + assert_eq!(first.visible, "before"); + let second = stripper.push("]7;file://localhost/home/user\x07after"); + assert_eq!(second.visible, "after"); + assert_eq!(second.cwd_paths, vec!["/home/user".to_string()]); + assert_eq!( + second.cwd_payloads, + vec!["file://localhost/home/user".to_string()] + ); + } + + #[test] + fn passthrough_mode_keeps_osc_title_sequences_visible() { + let mut stripper = OscStripper::new(&build_ready_marker("session-1")); + let payload = "before\x1b]2;My Title\x07after"; + + let result = stripper.push(payload); + assert_eq!(result.visible, payload); + } + + #[test] + fn c1_osc_title_and_st_are_kept_for_xterm() { + let mut stripper = OscStripper::new(&build_ready_marker("session-1")); + let payload = "before\u{009d}2;C1 title\u{009c}after"; + + let result = stripper.push(payload); + assert_eq!(result.visible, payload); + } + + #[test] + fn c1_osc_cwd_payload_is_extracted_without_interpreting_title() { + let mut stripper = OscStripper::with_cwd_payloads(&build_ready_marker("s"), true); + let first = stripper.push("before\u{009d}"); + assert_eq!(first.visible, "before"); + + let second = stripper.push("7;file://localhost/home/user\u{009c}after"); + assert_eq!(second.visible, "after"); + assert_eq!(second.cwd_paths, vec!["/home/user".to_string()]); + assert_eq!( + second.cwd_payloads, + vec!["file://localhost/home/user".to_string()] + ); + } + + #[test] + fn title_osc_stays_exact_and_ordered_around_ready() { + let before = "\x1b]2;Remote title\x07"; + let after = "\x1b]0;Prompt title\x1b\\"; + let payload = + format!("hidden{before}\x1b]7777;NyaTermReady:session-1\x07{after}[user@host]$ "); + + let result = OscStripper::new(&build_ready_marker("session-1")).push(&payload); + + assert!(result.ready); + assert_eq!( + result.visible, + format!("hidden{before}{after}[user@host]$ ") + ); + assert_eq!(result.visible_after_ready, format!("{after}[user@host]$ ")); + } + + #[test] + fn split_title_osc_is_reassembled_once_without_interpreting_payload() { + let mut stripper = OscStripper::new(&build_ready_marker("session-1")); + let first = stripper.push("hidden\x1b]2;split"); + assert_eq!(first.visible, "hidden"); + + let second = stripper.push(" title\x1b\\tail"); + assert_eq!(second.visible, "\x1b]2;split title\x1b\\tail"); + + let ordinary_icon = stripper.push("\x1b]1;icon only\x07"); + assert_eq!(ordinary_icon.visible, "\x1b]1;icon only\x07"); + } + + #[test] + fn oversized_title_osc_remains_normal_visible_output() { + let payload = format!("\x1b]2;{}\x07", "x".repeat(17 * 1024)); + let result = OscStripper::new(&build_ready_marker("session-1")).push(&payload); + + assert_eq!(result.visible, payload); + } + + #[test] + fn session_bound_command_marker_is_parsed_and_wrong_session_is_ignored() { + let command = BASE64_STANDARD.encode("docker ps"); + let marker = crate::core::ssh::osc::build_command_marker("session-1"); + let payload = format!( + "before\x1b]{marker}{command}\x07wrong\x1b]7777;NyaTermCommand:session-2:{command}\x07after" + ); + + let result = OscStripper::new(&build_ready_marker("session-1")).push(&payload); + assert_eq!(result.visible, "beforewrongafter"); + assert_eq!(result.accepted_commands, vec!["docker ps".to_string()]); + } + + #[test] + fn osc7_keeps_released_path_projection_and_exposes_raw_payload() { + let mut stripper = OscStripper::with_cwd_payloads(&build_ready_marker("s"), true); + let result = stripper.push( + "\x1b]7;file://remote.example.com/opt/a%20b\x07\x1b]7;kitty-shell-cwd://host/home/a%20b\x07", + ); + assert_eq!(result.cwd_paths, vec!["/opt/a%20b".to_string()]); + assert_eq!( + result.cwd_payloads, + vec![ + "file://remote.example.com/opt/a%20b".to_string(), + "kitty-shell-cwd://host/home/a%20b".to_string(), + ] + ); + assert!(result.visible.is_empty()); + } + + #[test] + fn local_cwd_events_preserve_valid_and_invalid_source_order() { + let huge = "x".repeat(70 * 1024); + let mut stripper = OscStripper::with_cwd_payloads(&build_ready_marker("s"), true); + let result = stripper.push(&format!( + "\x1b]7;file://localhost/{huge}\x07\x1b]7;file://localhost/final\x07" + )); + assert_eq!( + result.cwd_payload_events, + vec![ + CwdPayloadEvent::Invalidated, + CwdPayloadEvent::Payload("file://localhost/final".to_string()), + ] + ); + + let mut stripper = OscStripper::with_cwd_payloads(&build_ready_marker("s"), true); + let result = stripper.push(&format!( + "\x1b]7;file://localhost/final\x07\x1b]7;file://localhost/{huge}\x07" + )); + assert_eq!( + result.cwd_payload_events, + vec![ + CwdPayloadEvent::Payload("file://localhost/final".to_string()), + CwdPayloadEvent::Invalidated, + ] + ); + } + + #[test] + fn oversized_recognized_metadata_is_stripped_without_decoding() { + let huge_cwd = format!("\x1b]7;file://localhost/{}\x07", "x".repeat(70 * 1024)); + let result = OscStripper::new(&build_ready_marker("s")).push(&huge_cwd); + assert!(result.visible.is_empty()); + assert!(result.cwd_paths.is_empty()); + assert!(result.cwd_payloads.is_empty()); + assert!(result.cwd_payload_invalidated); + + let huge_command = format!("\x1b]7777;NyaTermCommand:s:{}\x07", "A".repeat(70 * 1024)); + let result = OscStripper::new(&build_ready_marker("s")).push(&huge_command); + assert!(result.visible.is_empty()); + assert!(result.accepted_commands.is_empty()); + } + + #[test] + fn split_oversized_metadata_discards_through_its_terminator() { + let mut stripper = OscStripper::new(&build_ready_marker("s")); + let first = stripper.push(&format!( + "before\x1b]7;file://localhost/{}", + "x".repeat(70 * 1024) + )); + assert_eq!(first.visible, "before"); + assert!(first.cwd_payloads.is_empty()); + assert!(first.cwd_payload_invalidated); + + let second = stripper.push("discarded-tail\x07after"); + assert_eq!(second.visible, "after"); + assert!(second.cwd_paths.is_empty()); + assert!(second.cwd_payloads.is_empty()); + assert!(!second.cwd_payload_invalidated); + + let mut stripper = OscStripper::new(&build_ready_marker("s")); + let first = stripper.push(&format!( + "\x1b]7;file://localhost/{}\x1b", + "x".repeat(70 * 1024) + )); + assert!(first.visible.is_empty()); + assert!(first.cwd_payload_invalidated); + let second = stripper.push("\\after-st"); + assert_eq!(second.visible, "after-st"); + } + + /// Guard against unbalanced shell control structures in the generated + /// bash/zsh scripts. Every `if ...; then` must be closed by a matching + /// `fi` — a missing `fi` turns the whole injected script into a syntax + /// error that breaks shell integration silently. + #[test] + fn generated_bash_zsh_scripts_are_if_fi_balanced() { + fn count_token(script: &str, needle: &str) -> usize { + script + .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .filter(|word| *word == needle) + .count() + } + + let marker = build_ready_marker("session-1"); + for shell in [ShellKind::Bash, ShellKind::Zsh] { + let script = injection_script(shell, &marker).expect("injection script"); + assert_eq!( + count_token(&script, "then"), + count_token(&script, "fi"), + "unbalanced if/fi in {shell:?} injection script" + ); + + if let Some(persist) = persistent_script(shell) { + assert_eq!( + count_token(persist, "then"), + count_token(persist, "fi"), + "unbalanced if/fi in {shell:?} persistent script" + ); + } + } + } } diff --git a/src-tauri/src/core/ssh/session.rs b/src-tauri/src/core/ssh/session.rs index 88b53d36..2777f604 100644 --- a/src-tauri/src/core/ssh/session.rs +++ b/src-tauri/src/core/ssh/session.rs @@ -11,8 +11,8 @@ use crate::config::{ SshRuntimeMode, effective_cwd_follow_mode_for_runtime, }; use crate::core::{ - SessionHandle, SessionInfo, SessionManager, SessionReadyHook, SessionType, SharedCwd, - session_command_channel, + DynamicTitleCapabilities, SessionHandle, SessionInfo, SessionManager, SessionReadyHook, + SessionType, SharedCwd, session_command_channel, }; use crate::error::{AppError, AppResult}; use std::future::Future; @@ -542,12 +542,13 @@ async fn create_ssh_session_inner( owner_window_label, ai_execution_profile: AiExecutionProfile::Posix, injection_active, + dynamic_title_capabilities: DynamicTitleCapabilities::new(config.dynamic_tab_title, None), remote_file_browser_enabled: capabilities.remote_file_browser_enabled, remote_stats_enabled: capabilities.remote_stats_enabled, ssh_profile: Some(config.ssh_profile.clone()), }; - let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(None)); + let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(Default::default())); let ssh_config_arc: Arc = Arc::new(config.clone()); let ssh_handle_arc: Arc = ssh_connection.clone(); let output_control_tx = cmd_tx.clone(); @@ -555,6 +556,7 @@ async fn create_ssh_session_inner( let session_handle = SessionHandle { info: session_info.clone(), cmd_tx, + startup_input_barrier: None, ssh_config: Some(ssh_config_arc), ssh_handle: Some(ssh_handle_arc), cwd: cwd.clone(), @@ -738,12 +740,13 @@ pub async fn create_multiplexed_ssh_session( owner_window_label, ai_execution_profile: AiExecutionProfile::Posix, injection_active, + dynamic_title_capabilities: DynamicTitleCapabilities::new(config.dynamic_tab_title, None), remote_file_browser_enabled: capabilities.remote_file_browser_enabled, remote_stats_enabled: capabilities.remote_stats_enabled, ssh_profile: Some(config.ssh_profile.clone()), }; - let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(None)); + let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(Default::default())); let ssh_config_arc: Arc = Arc::new(config.clone()); let ssh_handle_arc: Arc = ssh_connection.clone(); let output_control_tx = cmd_tx.clone(); @@ -751,6 +754,7 @@ pub async fn create_multiplexed_ssh_session( let session_handle = SessionHandle { info: session_info.clone(), cmd_tx, + startup_input_barrier: None, ssh_config: Some(ssh_config_arc), ssh_handle: Some(ssh_handle_arc), cwd: cwd.clone(), @@ -884,6 +888,7 @@ mod tests { terminal_type: SshTerminalType::default(), sftp: SftpSettings::default(), encoding: "UTF-8".to_string(), + dynamic_tab_title: false, } } diff --git a/src-tauri/src/core/terminal_session/local/config.rs b/src-tauri/src/core/terminal_session/local/config.rs index 73f3c9ee..2740a34e 100644 --- a/src-tauri/src/core/terminal_session/local/config.rs +++ b/src-tauri/src/core/terminal_session/local/config.rs @@ -7,10 +7,21 @@ pub struct LocalSessionConfig { pub fail_on_missing_working_dir: bool, pub name: String, pub encoding: String, + /// When true, enable Local dynamic-title/cwd integration for this + /// connection. Command-history confirmation remains an independent policy. + pub dynamic_tab_title: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShellResolutionSource { + Direct, + WindowsTerminalProfile, + WindowsTerminalFallback, } #[derive(Debug, Clone, PartialEq, Eq)] struct ShellCommandSpec { program: String, args: Vec, + resolution_source: ShellResolutionSource, } diff --git a/src-tauri/src/core/terminal_session/local/cwd.rs b/src-tauri/src/core/terminal_session/local/cwd.rs new file mode 100644 index 00000000..f6cc6265 --- /dev/null +++ b/src-tauri/src/core/terminal_session/local/cwd.rs @@ -0,0 +1,1299 @@ +use crate::core::{CwdPresentation, SessionCwdReplacement}; +use crate::core::ssh::osc::parse_legacy_osc7_payload; +use std::collections::HashSet; + +const MAX_LOCAL_CWD_PAYLOAD_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CwdPresentationUpdate { + Ignore, + Clear, + Set(CwdPresentation), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OperationalCwdUpdate { + Ignore, + Clear, + Set(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalCwdReport { + /// Released projection consumed by existing cwd/file-explorer features. + pub legacy_path: Option, + /// True when the complete OSC 7 payload fits the operational safety cap. + payload_within_operational_limit: bool, + /// True when a standard OSC 7 location resolves to a UNC path without a + /// session-bound producer. Such paths remain presentation-only. + unauthenticated_unc: bool, + /// Strict projection used to validate operational cwd and dynamic-title presentation. + pub presentation: CwdPresentationUpdate, +} + +#[derive(Debug, Clone)] +pub struct LocalCwdContext { + session_token: String, + local_host_aliases: HashSet, + home_paths: Vec, + windows_host: bool, +} + +impl LocalCwdContext { + pub fn new(session_token: &str) -> Self { + Self { + session_token: session_token.to_string(), + local_host_aliases: collect_local_host_aliases(), + home_paths: collect_home_paths(), + windows_host: cfg!(target_os = "windows"), + } + } + + #[cfg(test)] + fn for_test( + session_token: &str, + aliases: &[&str], + home_paths: &[&str], + windows_host: bool, + ) -> Self { + Self { + session_token: session_token.to_string(), + local_host_aliases: aliases + .iter() + .map(|value| normalize_host(value)) + .collect(), + home_paths: home_paths.iter().map(|value| value.to_string()).collect(), + windows_host, + } + } +} + +pub fn operational_cwd_update( + report: &LocalCwdReport, + managed_integration: bool, +) -> OperationalCwdUpdate { + if !report.payload_within_operational_limit { + return OperationalCwdUpdate::Clear; + } + + if managed_integration { + match &report.presentation { + CwdPresentationUpdate::Set(presentation) => { + return presentation + .operational_path + .clone() + .map(OperationalCwdUpdate::Set) + .unwrap_or(OperationalCwdUpdate::Clear); + } + CwdPresentationUpdate::Clear | CwdPresentationUpdate::Ignore => { + return OperationalCwdUpdate::Clear; + } + } + } + + match &report.presentation { + CwdPresentationUpdate::Clear => return OperationalCwdUpdate::Clear, + CwdPresentationUpdate::Set(presentation) + if presentation.copy_as_uri || report.unauthenticated_unc => + { + return OperationalCwdUpdate::Clear; + } + CwdPresentationUpdate::Set(_) | CwdPresentationUpdate::Ignore => {} + } + + match report.legacy_path.as_ref() { + Some(path) + if path.len() <= MAX_LOCAL_CWD_PAYLOAD_BYTES + && !path.chars().any(is_forbidden_path_scalar) => + { + OperationalCwdUpdate::Set(path.clone()) + } + Some(_) => OperationalCwdUpdate::Clear, + None => OperationalCwdUpdate::Ignore, + } +} + +pub fn cwd_state_replacement( + report: &LocalCwdReport, + managed_integration: bool, +) -> SessionCwdReplacement { + let operational_path = match operational_cwd_update(report, managed_integration) { + OperationalCwdUpdate::Set(path) => Some(path), + OperationalCwdUpdate::Ignore | OperationalCwdUpdate::Clear => None, + }; + let presentation = match &report.presentation { + CwdPresentationUpdate::Set(presentation) => Some(presentation.clone()), + // Invalid strict data must clear the previous strict snapshot so an + // operational fallback cannot be paired with stale presentation. + CwdPresentationUpdate::Ignore | CwdPresentationUpdate::Clear => None, + }; + SessionCwdReplacement { + legacy_path: report.legacy_path.clone(), + operational_path, + presentation, + } +} + +pub fn parse_local_cwd_report(payload: &str, context: &LocalCwdContext) -> LocalCwdReport { + let legacy_path = parse_legacy_osc7_payload(payload); + let payload_within_operational_limit = payload.len() <= MAX_LOCAL_CWD_PAYLOAD_BYTES; + let unauthenticated_unc = standard_uri_is_windows_unc(payload, context); + let presentation = if !payload_within_operational_limit { + CwdPresentationUpdate::Ignore + } else if payload == format!("nyaterm-clear://{}", context.session_token) { + CwdPresentationUpdate::Clear + } else if let Some(raw_path) = payload.strip_prefix(&format!( + "nyaterm-cmd://{}/", + context.session_token + )) { + parse_cmd_path(raw_path, context) + .map(CwdPresentationUpdate::Set) + .unwrap_or(CwdPresentationUpdate::Ignore) + } else { + parse_standard_cwd_uri(payload, context) + .map(CwdPresentationUpdate::Set) + .unwrap_or(CwdPresentationUpdate::Ignore) + }; + + LocalCwdReport { + legacy_path, + payload_within_operational_limit, + unauthenticated_unc, + presentation, + } +} + +fn standard_uri_is_windows_unc(payload: &str, context: &LocalCwdContext) -> bool { + if !context.windows_host { + return false; + } + let Some(after_scheme) = payload + .strip_prefix("file://") + .or_else(|| payload.strip_prefix("kitty-shell-cwd://")) + else { + return false; + }; + if after_scheme.starts_with('/') { + return after_scheme.starts_with("//") + || percent_decode_strict(after_scheme).is_some_and(|path| path.starts_with(b"//")); + } + let Some((authority, encoded_path)) = after_scheme.split_once('/') else { + return false; + }; + (!authority.is_empty() && !is_local_authority(authority, context)) + || encoded_path.starts_with('/') + || percent_decode_strict(&format!("/{encoded_path}")) + .is_some_and(|path| path.starts_with(b"//")) +} + +fn parse_standard_cwd_uri( + payload: &str, + context: &LocalCwdContext, +) -> Option { + let after_scheme = if let Some(rest) = payload.strip_prefix("file://") { + rest + } else if let Some(rest) = payload.strip_prefix("kitty-shell-cwd://") { + rest + } else { + return None; + }; + + let (authority, encoded_path) = if after_scheme.starts_with('/') { + ("", after_scheme) + } else { + let slash = after_scheme.find('/')?; + (&after_scheme[..slash], &after_scheme[slash..]) + }; + + if encoded_path.is_empty() + || encoded_path.contains('?') + || encoded_path.contains('#') + || !valid_authority(authority) + { + return None; + } + + let decoded = percent_decode_strict(encoded_path)?; + if decoded.is_empty() || decoded.contains(&0) { + return None; + } + let local_authority = is_local_authority(authority, context); + let normalized_authority = normalize_host(authority); + + let location = if context.windows_host && !local_authority && !authority.is_empty() { + decoded_unc_location(&normalized_authority, &decoded)? + } else { + if !local_authority { + // A foreign Unix authority is a remote URI, not a local cwd. + return None; + } + decoded_local_location(&decoded, context.windows_host)? + }; + + let unauthenticated_unc = location.flavor == PathFlavor::WindowsUnc; + let mut presentation = build_presentation( + location, + if authority.is_empty() { + "localhost" + } else { + authority + }, + context, + ); + // Standard OSC 7 has no session authentication. Keep valid UNC values for + // title/display/copy compatibility, but never let terminal output trigger + // an SMB access through File Explorer, AI, or another operational consumer. + // Session-bound `nyaterm-cmd` reports remain eligible for UNC operations. + if unauthenticated_unc { + presentation.operational_path = None; + } + Some(presentation) +} + +#[derive(Debug)] +struct DecodedLocation { + native_utf8: Option, + title_bytes: Vec, + encoded_path_bytes: Vec, + uri_authority: String, + flavor: PathFlavor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PathFlavor { + Posix, + WindowsDrive, + WindowsUnc, +} + +fn decoded_local_location(decoded: &[u8], windows_host: bool) -> Option { + if windows_host && decoded.starts_with(b"//") { + let utf8 = std::str::from_utf8(decoded).ok()?; + let mut parts = utf8[2..].split('/'); + let server = parts.next()?; + let share = parts.next()?; + if !valid_unc_component(server) || !valid_unc_component(share) { + return None; + } + let native = format!("\\\\{}", utf8[2..].replace('/', "\\")); + let authority_end = 2 + server.len(); + return Some(DecodedLocation { + native_utf8: Some(native), + title_bytes: decoded[authority_end..].to_vec(), + encoded_path_bytes: decoded[authority_end..].to_vec(), + uri_authority: server.to_string(), + flavor: PathFlavor::WindowsUnc, + }); + } + + if windows_host && is_slash_prefixed_drive(decoded) { + let without_leading = &decoded[1..]; + return Some(DecodedLocation { + native_utf8: std::str::from_utf8(without_leading) + .ok() + .map(|value| value.replace('/', "\\")), + title_bytes: without_leading.to_vec(), + encoded_path_bytes: decoded.to_vec(), + uri_authority: String::new(), + flavor: PathFlavor::WindowsDrive, + }); + } + + if !decoded.starts_with(b"/") { + return None; + } + + Some(DecodedLocation { + native_utf8: std::str::from_utf8(decoded).ok().map(str::to_string), + title_bytes: decoded.to_vec(), + encoded_path_bytes: decoded.to_vec(), + uri_authority: String::new(), + flavor: PathFlavor::Posix, + }) +} + +fn decoded_unc_location(authority: &str, decoded: &[u8]) -> Option { + if authority.is_empty() || !decoded.starts_with(b"/") { + return None; + } + let utf8_path = std::str::from_utf8(decoded).ok()?; + let share = utf8_path[1..].split('/').next()?; + if !valid_unc_component(authority) || !valid_unc_component(share) { + return None; + } + + Some(DecodedLocation { + native_utf8: Some(format!( + "\\\\{}\\{}", + authority, + utf8_path[1..].replace('/', "\\") + )), + title_bytes: decoded.to_vec(), + encoded_path_bytes: decoded.to_vec(), + uri_authority: authority.to_string(), + flavor: PathFlavor::WindowsUnc, + }) +} + +fn parse_cmd_path(raw_path: &str, context: &LocalCwdContext) -> Option { + if raw_path.is_empty() || raw_path.contains('\0') { + return None; + } + + let (flavor, encoded_path, authority) = if is_windows_drive_path(raw_path) { + ( + PathFlavor::WindowsDrive, + format!("/{}", raw_path.replace('\\', "/")).into_bytes(), + String::new(), + ) + } else if let Some(rest) = raw_path.strip_prefix("\\\\") { + let mut parts = rest.split('\\'); + let server = parts.next()?; + let share = parts.next()?; + if !valid_unc_component(server) || !valid_unc_component(share) { + return None; + } + let path_without_server = rest + .strip_prefix(server)? + .strip_prefix('\\')?; + ( + PathFlavor::WindowsUnc, + format!("/{}", path_without_server.replace('\\', "/")).into_bytes(), + server.to_string(), + ) + } else { + return None; + }; + + let title_bytes = if flavor == PathFlavor::WindowsUnc { + encoded_path.clone() + } else { + raw_path.replace('\\', "/").into_bytes() + }; + Some(build_presentation( + DecodedLocation { + native_utf8: Some(raw_path.to_string()), + title_bytes, + encoded_path_bytes: encoded_path, + uri_authority: authority.clone(), + flavor, + }, + if authority.is_empty() { + "localhost" + } else { + &authority + }, + context, + )) +} + +fn build_presentation( + location: DecodedLocation, + source_authority: &str, + context: &LocalCwdContext, +) -> CwdPresentation { + let authority = if location.flavor == PathFlavor::WindowsUnc { + location.uri_authority.as_str() + } else if source_authority.is_empty() { + "localhost" + } else { + source_authority + }; + let encoded_path = percent_encode_path(&location.encoded_path_bytes); + let encoded_uri = format!("file://{authority}{encoded_path}"); + + let safe_native = location.native_utf8.as_deref().is_some_and(|path| { + !path.chars().any(is_forbidden_path_scalar) + && match location.flavor { + PathFlavor::WindowsDrive | PathFlavor::WindowsUnc => { + validate_windows_native_path(path, location.flavor) + } + PathFlavor::Posix => true, + } + }); + + if safe_native { + let native = location.native_utf8.expect("checked native path"); + let title = compact_path_title(&native, location.flavor, &context.home_paths); + let operational_path = host_operational_path( + &native, + location.flavor, + context.windows_host, + ); + CwdPresentation { + title, + display_path: native.clone(), + copy_value: native, + operational_path, + copy_as_uri: false, + } + } else { + let encoded_title_source = std::str::from_utf8(&location.title_bytes) + .ok() + .map(|value| percent_encode_path(value.as_bytes())) + .unwrap_or_else(|| percent_encode_path(&location.title_bytes)); + CwdPresentation { + title: compact_encoded_title( + &encoded_title_source, + authority, + location.flavor, + ), + display_path: encoded_uri.clone(), + copy_value: encoded_uri, + operational_path: None, + copy_as_uri: true, + } + } +} + +fn compact_path_title(path: &str, flavor: PathFlavor, homes: &[String]) -> String { + let slash_path = path.replace('\\', "/"); + let home = homes.iter().find_map(|home| { + let slash_home = home.replace('\\', "/"); + strip_path_prefix(&slash_path, &slash_home, flavor == PathFlavor::Posix) + .map(|suffix| format!("~{suffix}")) + }); + let value = home.as_deref().unwrap_or(&slash_path); + compact_slash_path(value, flavor) +} + +fn strip_path_prefix(path: &str, prefix: &str, case_sensitive: bool) -> Option { + let path_cmp = if case_sensitive { + path.to_string() + } else { + path.to_ascii_lowercase() + }; + let prefix_cmp = if case_sensitive { + prefix.trim_end_matches('/').to_string() + } else { + prefix.trim_end_matches('/').to_ascii_lowercase() + }; + if path_cmp == prefix_cmp { + return Some(String::new()); + } + let rest = path_cmp.strip_prefix(&prefix_cmp)?; + if !rest.starts_with('/') { + return None; + } + Some(path[prefix_cmp.len()..].to_string()) +} + +fn compact_slash_path(value: &str, flavor: PathFlavor) -> String { + if value == "/" || value == "~" || is_drive_root(value) { + return value.to_string(); + } + + let (root, body) = match flavor { + _ if value.starts_with("~/") => ("~/", value.trim_start_matches("~/")), + PathFlavor::WindowsUnc if value.starts_with("//") => { + let mut segments = value[2..].split('/'); + let server = segments.next().unwrap_or_default(); + let share = segments.next().unwrap_or_default(); + let root_len = 2 + server.len() + 1 + share.len(); + (&value[..root_len], value[root_len..].trim_start_matches('/')) + } + PathFlavor::WindowsDrive if value.len() >= 3 => { + (&value[..3], value[3..].trim_start_matches('/')) + } + PathFlavor::Posix if value.starts_with('/') => ("/", value.trim_start_matches('/')), + _ => ("", value), + }; + + let parts: Vec<&str> = body.split('/').filter(|part| !part.is_empty()).collect(); + if parts.len() <= 2 { + return value.to_string(); + } + let separator = if root.is_empty() || root.ends_with('/') { + "" + } else { + "/" + }; + format!( + "{root}{separator}…/{}", + parts[parts.len() - 2..].join("/") + ) +} + +fn compact_encoded_title( + encoded_path: &str, + authority: &str, + flavor: PathFlavor, +) -> String { + let value = if flavor == PathFlavor::WindowsUnc { + format!("//{authority}{encoded_path}") + } else if flavor == PathFlavor::WindowsDrive { + encoded_path.trim_start_matches('/').to_string() + } else { + encoded_path.to_string() + }; + compact_slash_path(&value, flavor) +} + +fn percent_decode_strict(input: &str) -> Option> { + let bytes = input.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() { + return None; + } + let high = hex_value(bytes[index + 1])?; + let low = hex_value(bytes[index + 2])?; + output.push(high * 16 + low); + index += 3; + } else { + if bytes[index].is_ascii_control() { + return None; + } + // Mature Bash/Zsh integrations commonly emit raw UTF-8 despite + // URI recommendations. Preserve those bytes for compatibility; + // managed NyaTerm producers still percent-encode them. + output.push(bytes[index]); + index += 1; + } + } + Some(output) +} + +fn percent_encode_path(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len()); + for &byte in bytes { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') + { + output.push(byte as char); + } else { + use std::fmt::Write as _; + let _ = write!(output, "%{byte:02X}"); + } + } + output +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn is_forbidden_path_scalar(ch: char) -> bool { + matches!( + ch, + '\u{0000}'..='\u{001F}' + | '\u{007F}'..='\u{009F}' + | '\u{061C}' + | '\u{200E}' + | '\u{200F}' + | '\u{2028}' + | '\u{2029}' + | '\u{202A}'..='\u{202E}' + | '\u{2066}'..='\u{206F}' + | '\u{FFFD}' + ) +} + +fn is_windows_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + matches!(bytes, [drive, b':', slash, ..] if drive.is_ascii_alphabetic() && matches!(slash, b'/' | b'\\')) +} + +fn is_slash_prefixed_drive(path: &[u8]) -> bool { + matches!(path, [b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic()) +} + +fn is_drive_root(path: &str) -> bool { + let bytes = path.as_bytes(); + matches!(bytes, [drive, b':', b'/'] if drive.is_ascii_alphabetic()) +} + +fn host_operational_path( + path: &str, + flavor: PathFlavor, + windows_host: bool, +) -> Option { + if !windows_host { + return (flavor == PathFlavor::Posix).then(|| path.to_string()); + } + + match flavor { + PathFlavor::WindowsDrive | PathFlavor::WindowsUnc => { + validate_windows_native_path(path, flavor).then(|| path.to_string()) + } + PathFlavor::Posix => map_msys_path_to_windows(path), + } +} + +fn map_msys_path_to_windows(path: &str) -> Option { + let slash_path = path.replace('\\', "/"); + let (drive, rest) = if let Some(rest) = slash_path.strip_prefix("/cygdrive/") { + let bytes = rest.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() { + return None; + } + (bytes[0] as char, &rest[1..]) + } else { + let bytes = slash_path.as_bytes(); + if bytes.len() < 2 || bytes[0] != b'/' || !bytes[1].is_ascii_alphabetic() { + return None; + } + (bytes[1] as char, &slash_path[2..]) + }; + if !rest.is_empty() && !rest.starts_with('/') { + return None; + } + let suffix = rest.trim_start_matches('/').replace('/', "\\"); + let native = if suffix.is_empty() { + format!("{}:\\", drive.to_ascii_uppercase()) + } else { + format!("{}:\\{suffix}", drive.to_ascii_uppercase()) + }; + validate_windows_native_path(&native, PathFlavor::WindowsDrive).then_some(native) +} + +fn validate_windows_native_path(path: &str, flavor: PathFlavor) -> bool { + let slash_path = path.replace('\\', "/"); + if slash_path.starts_with("//?/") || slash_path.starts_with("//./") { + return false; + } + + let components: Vec<&str> = match flavor { + PathFlavor::WindowsDrive => { + let bytes = slash_path.as_bytes(); + if bytes.len() < 3 + || !bytes[0].is_ascii_alphabetic() + || bytes[1] != b':' + || bytes[2] != b'/' + { + return false; + } + slash_path[3..].split('/').filter(|part| !part.is_empty()).collect() + } + PathFlavor::WindowsUnc => { + let Some(rest) = slash_path.strip_prefix("//") else { + return false; + }; + let parts: Vec<&str> = rest.split('/').collect(); + if parts.len() < 2 + || !valid_unc_component(parts[0]) + || !valid_unc_component(parts[1]) + { + return false; + } + parts[2..].to_vec() + } + PathFlavor::Posix => return false, + }; + components.into_iter().all(valid_windows_component) +} + +fn valid_windows_component(value: &str) -> bool { + if value.is_empty() + || matches!(value, "." | "..") + || value.ends_with(' ') + || value.ends_with('.') + || value.chars().any(|ch| { + is_forbidden_path_scalar(ch) + || matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') + }) + { + return false; + } + + let stem = value + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + !matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + && !matches!(stem.as_bytes(), [b'C', b'O', b'M', b'1'..=b'9']) + && !matches!(stem.as_bytes(), [b'L', b'P', b'T', b'1'..=b'9']) +} + +fn valid_authority(authority: &str) -> bool { + if authority.is_empty() { + return true; + } + if matches!(authority, "." | "..") { + return false; + } + if authority.starts_with('[') && authority.ends_with(']') { + return authority[1..authority.len() - 1] + .parse::() + .is_ok(); + } + !authority.contains(['@', ':', '%']) + && authority + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) +} + +fn valid_unc_component(value: &str) -> bool { + !value.is_empty() + && !matches!(value, "." | "..") + && !value.chars().any(|ch| { + is_forbidden_path_scalar(ch) + || matches!(ch, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*') + }) +} + +fn is_local_authority(authority: &str, context: &LocalCwdContext) -> bool { + let host = normalize_host(authority); + host.is_empty() + || is_loopback_host(&host) + || context.local_host_aliases.contains(&host) +} + +fn is_loopback_host(host: &str) -> bool { + matches!( + normalize_host(host).as_str(), + "localhost" | "127.0.0.1" | "::1" + ) +} + +fn normalize_host(host: &str) -> String { + host.trim() + .trim_end_matches('\0') + .trim_start_matches('[') + .trim_end_matches(']') + .trim_end_matches('.') + .to_ascii_lowercase() +} + +fn collect_local_host_aliases() -> HashSet { + let mut aliases = HashSet::new(); + for key in ["COMPUTERNAME", "HOSTNAME"] { + if let Ok(value) = std::env::var(key) { + add_host_alias(&mut aliases, &value); + } + } + + for value in os_hostnames() { + add_host_alias(&mut aliases, &value); + } + aliases +} + +fn add_host_alias(aliases: &mut HashSet, value: &str) { + let normalized = normalize_host(value); + if normalized.is_empty() { + return; + } + aliases.insert(normalized.clone()); + if let Some((short, _)) = normalized.split_once('.') { + aliases.insert(short.to_string()); + } +} + +#[cfg(target_os = "windows")] +fn os_hostnames() -> Vec { + use windows::Win32::System::SystemInformation::{ + COMPUTER_NAME_FORMAT, ComputerNameDnsFullyQualified, ComputerNameDnsHostname, + GetComputerNameExW, + }; + use windows::core::PWSTR; + + fn query(format: COMPUTER_NAME_FORMAT) -> Option { + let mut len = 0_u32; + // The first call reports the required UTF-16 capacity. + let _ = unsafe { GetComputerNameExW(format, None, &mut len) }; + if len == 0 { + return None; + } + let mut buffer = vec![0_u16; len as usize]; + unsafe { GetComputerNameExW(format, Some(PWSTR(buffer.as_mut_ptr())), &mut len) }.ok()?; + String::from_utf16(&buffer[..len as usize]).ok() + } + + [ComputerNameDnsHostname, ComputerNameDnsFullyQualified] + .into_iter() + .filter_map(query) + .collect() +} + +#[cfg(unix)] +fn os_hostnames() -> Vec { + let mut buffer = [0_u8; 256]; + let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) }; + if result != 0 { + return Vec::new(); + } + let end = buffer + .iter() + .position(|byte| *byte == 0) + .unwrap_or(buffer.len()); + std::str::from_utf8(&buffer[..end]) + .ok() + .filter(|value| !value.is_empty()) + .map(|value| vec![value.to_string()]) + .unwrap_or_default() +} + +#[cfg(not(any(target_os = "windows", unix)))] +fn os_hostnames() -> Vec { + Vec::new() +} + +fn collect_home_paths() -> Vec { + let mut paths = Vec::new(); + for key in ["HOME", "USERPROFILE"] { + if let Ok(value) = std::env::var(key) { + let value = value.trim(); + if !value.is_empty() && !paths.iter().any(|existing| existing == value) { + paths.push(value.to_string()); + } + } + } + paths +} + +#[cfg(test)] +mod cwd_tests { + use super::{ + CwdPresentationUpdate, LocalCwdContext, MAX_LOCAL_CWD_PAYLOAD_BYTES, + OperationalCwdUpdate, cwd_state_replacement, operational_cwd_update, + parse_local_cwd_report, + }; + + fn unix_context() -> LocalCwdContext { + LocalCwdContext::for_test("session-1", &["workstation"], &["/home/alice"], false) + } + + fn windows_context() -> LocalCwdContext { + LocalCwdContext::for_test( + "session-1", + &["workstation"], + &[r"C:\Users\Alice"], + true, + ) + } + + #[test] + fn mature_raw_utf8_osc7_remains_compatible() { + let report = parse_local_cwd_report( + "kitty-shell-cwd://workstation/home/alice/项目", + &unix_context(), + ); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("raw UTF-8 presentation"); + }; + assert_eq!(presentation.copy_value, "/home/alice/项目"); + assert_eq!(presentation.title, "~/项目"); + } + + #[test] + fn managed_safe_paths_feed_canonical_operational_consumers() { + let report = parse_local_cwd_report( + "file:///C:/Users/Alice/my%20project/%252F", + &windows_context(), + ); + assert_eq!( + operational_cwd_update(&report, true), + OperationalCwdUpdate::Set(r"C:\Users\Alice\my project\%2F".to_string()) + ); + assert_eq!( + operational_cwd_update(&report, false), + OperationalCwdUpdate::Set( + "/C:/Users/Alice/my%20project/%252F".to_string() + ) + ); + + let cmd = parse_local_cwd_report( + r"nyaterm-cmd://session-1/C:\Users\Alice\project", + &windows_context(), + ); + assert_eq!( + operational_cwd_update(&cmd, true), + OperationalCwdUpdate::Set(r"C:\Users\Alice\project".to_string()) + ); + assert_eq!( + operational_cwd_update(&cmd, false), + OperationalCwdUpdate::Ignore + ); + + let trusted_cmd_unc = parse_local_cwd_report( + r"nyaterm-cmd://session-1/\\server\share\project", + &windows_context(), + ); + assert_eq!( + operational_cwd_update(&trusted_cmd_unc, true), + OperationalCwdUpdate::Set(r"\\server\share\project".to_string()) + ); + } + + #[test] + fn unsafe_and_clear_locations_never_feed_operational_cwd() { + for uri in [ + "file://localhost/home/alice/a%1Bb", + "file://localhost/home/alice/a%E2%80%AEb", + "file://localhost/home/alice/a%FFb", + "file://localhost/home/alice/a\u{FFFD}b", + ] { + let unsafe_path = parse_local_cwd_report(uri, &unix_context()); + assert_eq!( + operational_cwd_update(&unsafe_path, true), + OperationalCwdUpdate::Clear, + "managed {uri}" + ); + assert_eq!( + operational_cwd_update(&unsafe_path, false), + OperationalCwdUpdate::Clear, + "passive {uri}" + ); + } + + let oversized = format!( + "file:///{}", + "a".repeat(MAX_LOCAL_CWD_PAYLOAD_BYTES - "file:///".len() + 1) + ); + assert_eq!(oversized.len(), MAX_LOCAL_CWD_PAYLOAD_BYTES + 1); + let oversized = parse_local_cwd_report(&oversized, &unix_context()); + for managed in [false, true] { + assert_eq!( + operational_cwd_update(&oversized, managed), + OperationalCwdUpdate::Clear, + "oversized, managed={managed}" + ); + } + + let clear = parse_local_cwd_report( + "nyaterm-clear://session-1", + &windows_context(), + ); + assert_eq!( + operational_cwd_update(&clear, true), + OperationalCwdUpdate::Clear + ); + } + + #[test] + fn strict_invalid_replacement_clears_stale_presentation_atomically() { + let malformed = parse_local_cwd_report( + "file://localhost/home/alice/a%2", + &unix_context(), + ); + let replacement = cwd_state_replacement(&malformed, true); + assert_eq!(replacement.legacy_path.as_deref(), Some("/home/alice/a%2")); + assert!(replacement.operational_path.is_none()); + assert!(replacement.presentation.is_none()); + } + + #[test] + fn windows_invalid_native_components_are_presentation_only_uris() { + for uri in [ + "file:///C:/Temp/bad%3Fname", + "file:///C:/Temp/file%3Astream", + "file:///C:/Temp/CON.txt", + "file:///C:/Temp/trailing.%20", + ] { + let report = parse_local_cwd_report(uri, &windows_context()); + let CwdPresentationUpdate::Set(ref presentation) = report.presentation else { + panic!("presentation for {uri}"); + }; + assert!(presentation.copy_as_uri, "{uri}"); + assert!(presentation.operational_path.is_none(), "{uri}"); + assert_eq!( + operational_cwd_update(&report, true), + OperationalCwdUpdate::Clear + ); + } + } + + #[test] + fn windows_msys_paths_have_separate_shell_copy_and_host_operational_paths() { + for (uri, copy, operational) in [ + ( + "file://localhost/c/Users/Alice/project", + "/c/Users/Alice/project", + r"C:\Users\Alice\project", + ), + ( + "file://localhost/cygdrive/d/work/tree", + "/cygdrive/d/work/tree", + r"D:\work\tree", + ), + ] { + let report = parse_local_cwd_report(uri, &windows_context()); + let CwdPresentationUpdate::Set(ref presentation) = report.presentation else { + panic!("presentation for {uri}"); + }; + assert_eq!(presentation.copy_value, copy); + assert_eq!(presentation.operational_path.as_deref(), Some(operational)); + assert_eq!( + operational_cwd_update(&report, true), + OperationalCwdUpdate::Set(operational.to_string()) + ); + } + + let unmappable = parse_local_cwd_report( + "file://localhost/usr/local/bin", + &windows_context(), + ); + let CwdPresentationUpdate::Set(ref presentation) = unmappable.presentation else { + panic!("unmappable presentation"); + }; + assert_eq!(presentation.copy_value, "/usr/local/bin"); + assert!(presentation.operational_path.is_none()); + assert_eq!( + operational_cwd_update(&unmappable, true), + OperationalCwdUpdate::Clear + ); + } + + #[test] + fn managed_invalid_strict_signal_clears_operational_state_but_passive_keeps_legacy() { + let malformed = parse_local_cwd_report( + "file://localhost/home/alice/a%2", + &unix_context(), + ); + assert_eq!(malformed.presentation, CwdPresentationUpdate::Ignore); + assert_eq!( + operational_cwd_update(&malformed, true), + OperationalCwdUpdate::Clear + ); + assert_eq!( + operational_cwd_update(&malformed, false), + OperationalCwdUpdate::Set("/home/alice/a%2".to_string()) + ); + } + + #[test] + fn standard_uri_round_trips_percent_and_unicode() { + let report = parse_local_cwd_report( + "file://localhost/home/alice/a%252F/%E9%A1%B9%E7%9B%AE", + &unix_context(), + ); + assert_eq!(report.legacy_path.as_deref(), Some("/home/alice/a%252F/%E9%A1%B9%E7%9B%AE")); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("presentation"); + }; + assert_eq!(presentation.display_path, "/home/alice/a%2F/项目"); + assert_eq!(presentation.copy_value, "/home/alice/a%2F/项目"); + assert!(!presentation.copy_as_uri); + assert_eq!(presentation.title, "~/a%2F/项目"); + } + + #[test] + fn dangerous_and_non_utf8_paths_are_uri_only() { + for uri in [ + "file://localhost/home/alice/a%07b", + "file://localhost/home/alice/a%1Bb", + "file://localhost/home/alice/a%E2%80%AEb", + "file://localhost/home/alice/%FF", + ] { + let report = parse_local_cwd_report(uri, &unix_context()); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("presentation for {uri}"); + }; + assert!(presentation.copy_as_uri); + assert_eq!(presentation.copy_value, uri); + assert!(!presentation.display_path.contains('\u{1b}')); + assert!(!presentation.display_path.contains('\u{202e}')); + } + } + + #[test] + fn malformed_uri_is_ignored_without_changing_legacy_projection() { + for uri in [ + "file://localhost/home/%2", + "file://localhost/home/a?query", + "file://localhost/home/a#fragment", + "https://localhost/home/alice", + ] { + assert!(matches!( + parse_local_cwd_report(uri, &unix_context()).presentation, + CwdPresentationUpdate::Ignore + )); + } + } + + #[test] + fn unix_foreign_host_is_not_promoted_but_legacy_path_remains() { + let report = parse_local_cwd_report( + "file://remote.example/home/remote", + &unix_context(), + ); + assert_eq!(report.legacy_path.as_deref(), Some("/home/remote")); + assert_eq!(report.presentation, CwdPresentationUpdate::Ignore); + } + + #[test] + fn unix_double_slash_remains_a_posix_path() { + let report = parse_local_cwd_report( + "file:////srv/share/project", + &unix_context(), + ); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("posix presentation"); + }; + assert_eq!(presentation.display_path, "//srv/share/project"); + assert!(!presentation.copy_as_uri); + } + + #[test] + fn posix_drive_shaped_path_remains_posix() { + let report = parse_local_cwd_report( + "file://localhost/C:/project", + &unix_context(), + ); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("posix presentation"); + }; + assert_eq!(presentation.display_path, "/C:/project"); + assert_eq!(presentation.copy_value, "/C:/project"); + } + + #[test] + fn malformed_ipv6_and_windows_device_namespaces_are_rejected() { + for uri in [ + "file://[]/tmp", + "file://[not-ipv6]/tmp", + "file://./pipe/name", + "file:////./pipe/name", + "file:////../share/name", + ] { + assert_eq!( + parse_local_cwd_report(uri, &windows_context()).presentation, + CwdPresentationUpdate::Ignore, + "{uri}" + ); + } + } + + #[test] + fn ipv6_loopback_is_local_but_foreign_ipv6_is_not_unc() { + assert!(matches!( + parse_local_cwd_report("file://[::1]/home/alice", &unix_context()).presentation, + CwdPresentationUpdate::Set(_) + )); + assert_eq!( + parse_local_cwd_report("file://[2001:db8::1]/share", &windows_context()) + .presentation, + CwdPresentationUpdate::Ignore + ); + } + + #[test] + fn invalid_windows_unc_components_are_not_promoted() { + for uri in [ + "file://server/bad%3Ashare/path", + "file://server/bad%2Ashare/path", + "file://server/bad%7Cshare/path", + ] { + assert_eq!( + parse_local_cwd_report(uri, &windows_context()).presentation, + CwdPresentationUpdate::Ignore + ); + } + } + + #[test] + fn unauthenticated_standard_osc7_unc_is_presentation_only() { + for uri in [ + "file://server/share/project/src", + "file:////server/share/project/src", + "file://localhost//server/share/project/src", + "kitty-shell-cwd:////server/share/project/src", + "kitty-shell-cwd://localhost//server/share/project/src", + ] { + let report = parse_local_cwd_report(uri, &windows_context()); + let CwdPresentationUpdate::Set(ref presentation) = report.presentation else { + panic!("unc presentation for {uri}"); + }; + assert_eq!(presentation.display_path, r"\\server\share\project\src"); + assert_eq!(presentation.copy_value, r"\\server\share\project\src"); + assert_eq!(presentation.title, "//server/share/project/src"); + assert!(presentation.operational_path.is_none()); + for managed in [false, true] { + assert_eq!( + operational_cwd_update(&report, managed), + OperationalCwdUpdate::Clear, + "{uri}, managed={managed}" + ); + } + } + } + + #[test] + fn deep_windows_unc_title_keeps_separator_after_share_root() { + let report = parse_local_cwd_report( + "file://server/share/project/src/module", + &windows_context(), + ); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("deep unc presentation"); + }; + + assert_eq!(presentation.title, "//server/share/…/src/module"); + assert_eq!( + presentation.display_path, + r"\\server\share\project\src\module" + ); + assert!(presentation.operational_path.is_none()); + } + + #[test] + fn windows_drive_and_home_are_normalized_only_for_presentation() { + let report = parse_local_cwd_report( + "file:///C:/Users/Alice/projects/nyaterm/src", + &windows_context(), + ); + assert_eq!( + report.legacy_path.as_deref(), + Some("/C:/Users/Alice/projects/nyaterm/src") + ); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("drive presentation"); + }; + assert_eq!( + presentation.display_path, + r"C:\Users\Alice\projects\nyaterm\src" + ); + assert_eq!(presentation.title, "~/…/nyaterm/src"); + } + + #[test] + fn dangerous_cmd_unc_path_uses_a_non_duplicated_encoded_authority() { + let context = windows_context(); + let report = parse_local_cwd_report( + "nyaterm-cmd://session-1/\\\\server\\share\\a\u{202e}b", + &context, + ); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("cmd unc presentation"); + }; + assert!(presentation.copy_as_uri); + assert_eq!( + presentation.copy_value, + "file://server/share/a%E2%80%AEb" + ); + assert_eq!(presentation.title, "//server/share/a%E2%80%AEb"); + } + + #[test] + fn cmd_and_clear_signals_are_session_bound() { + let context = windows_context(); + let report = parse_local_cwd_report( + "nyaterm-cmd://session-1/C:\\Users\\Alice\\project", + &context, + ); + let CwdPresentationUpdate::Set(presentation) = report.presentation else { + panic!("cmd presentation"); + }; + assert_eq!(presentation.title, "~/project"); + assert!(matches!( + parse_local_cwd_report("nyaterm-clear://session-1", &context).presentation, + CwdPresentationUpdate::Clear + )); + assert!(matches!( + parse_local_cwd_report( + "nyaterm-cmd://other/C:\\Users\\Alice", + &context + ) + .presentation, + CwdPresentationUpdate::Ignore + )); + } +} diff --git a/src-tauri/src/core/terminal_session/local/environment.rs b/src-tauri/src/core/terminal_session/local/environment.rs index a58b72b8..fc870d87 100644 --- a/src-tauri/src/core/terminal_session/local/environment.rs +++ b/src-tauri/src/core/terminal_session/local/environment.rs @@ -1,19 +1,5 @@ fn platform_default_shell() -> (CommandBuilder, String) { - #[cfg(target_os = "windows")] - { - let shell = resolve_program_for_spawn("powershell.exe"); - (CommandBuilder::new(&shell), shell) - } - #[cfg(not(target_os = "windows"))] - { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); - let mut builder = CommandBuilder::new(&shell); - let default_args = default_local_shell_args(&shell); - if !default_args.is_empty() { - builder.args(default_args.iter().map(String::as_str)); - } - (builder, shell) - } + build_shell_command_from_spec(&default_shell_spec()) } #[cfg(target_os = "macos")] diff --git a/src-tauri/src/core/terminal_session/local/mod.rs b/src-tauri/src/core/terminal_session/local/mod.rs index 0bdd2d43..df07a0a4 100644 --- a/src-tauri/src/core/terminal_session/local/mod.rs +++ b/src-tauri/src/core/terminal_session/local/mod.rs @@ -5,14 +5,15 @@ use crate::config::AiExecutionProfile; use crate::core::SessionOutputCoalescer; use crate::core::capture::OutputCaptureProcessor; -use crate::core::recording::RecordingManager; +use crate::core::recording::{InputOrigin, RecordingManager}; +use crate::core::replace_cwd_state; use crate::core::session::{ - SessionCommand, SessionCommandReceiver, SessionCommandSender, SessionHandle, SessionInfo, - SessionManager, SessionReadyHook, SessionType, SharedCwd, session_command_channel, + DynamicTitleCapabilities, SessionCommand, SessionCommandReceiver, SessionCommandSender, + SessionHandle, SessionInfo, SessionManager, SessionReadyHook, SessionType, SharedCwd, + StartupInjectionAttempt, StartupInputBarrier, session_command_channel, }; -use crate::core::ssh::osc::{OscStripper, build_ready_marker}; +use crate::core::ssh::osc::{CwdPayloadEvent, OscStripper, build_ready_marker}; use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input}; -use crate::core::update_cwd_if_changed; use crate::core::zmodem::{ ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent, ZmodemTransfer, start_zmodem_transfer, @@ -20,11 +21,13 @@ use crate::core::zmodem::{ use crate::error::AppResult; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use std::io::{Read, Write}; -use std::path::Path; -#[cfg(target_os = "windows")] -use std::path::PathBuf; -use std::sync::{Arc, Mutex as StdMutex, mpsc as std_mpsc}; -use std::time::Duration; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, Mutex as StdMutex, + atomic::{AtomicBool, Ordering}, + mpsc as std_mpsc, +}; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::mpsc; @@ -33,6 +36,7 @@ include!("shell.rs"); include!("windows_terminal.rs"); include!("args.rs"); include!("environment.rs"); +include!("cwd.rs"); include!("startup.rs"); include!("session.rs"); include!("tests.rs"); diff --git a/src-tauri/src/core/terminal_session/local/session.rs b/src-tauri/src/core/terminal_session/local/session.rs index a55b7810..a21d5ad4 100644 --- a/src-tauri/src/core/terminal_session/local/session.rs +++ b/src-tauri/src/core/terminal_session/local/session.rs @@ -7,7 +7,15 @@ pub async fn create_local_session( session_ready_hook: Option, ) -> AppResult { tracing::info!("Creating local PTY session"); - validate_working_dir_before_spawn(config.as_ref())?; + let resolved_shell_spec = match &config { + Some(cfg) if !cfg.shell_path.trim().is_empty() => resolve_shell_command( + &cfg.shell_path, + &cfg.shell_args, + ) + .map_err(crate::error::AppError::Config)?, + _ => default_shell_spec(), + }; + validate_working_dir_before_spawn(config.as_ref(), &resolved_shell_spec)?; let session_id = uuid::Uuid::new_v4().to_string(); let (cmd_tx, cmd_rx) = session_command_channel(session_id.clone()); @@ -17,17 +25,55 @@ pub async fn create_local_session( .as_ref() .map_or("Local Terminal".to_string(), |c| c.name.clone()); - let (_, shell_name) = match &config { - Some(cfg) if !cfg.shell_path.trim().is_empty() => { - build_shell_command(&cfg.shell_path, &cfg.shell_args) - .map_err(crate::error::AppError::Config)? - } - _ => platform_default_shell(), - }; + let shell_name = resolved_shell_spec.program.clone(); + let resolved_shell_args_empty = resolved_shell_spec.args.is_empty(); + let resolution_source = resolved_shell_spec.resolution_source; let ai_execution_profile = infer_local_ai_execution_profile(&shell_name); let ready_marker = build_ready_marker(&session_id); - let startup_script = build_local_startup_script(&shell_name, &ready_marker); - let injection_active = startup_script.shell_integration_active; + let dynamic_title_enabled = config + .as_ref() + .is_some_and(|cfg| cfg.dynamic_tab_title); + let allow_injection = config.as_ref().is_none_or(|cfg| { + should_allow_local_injection( + &cfg.shell_path, + &shell_name, + &cfg.shell_args, + resolved_shell_args_empty, + resolution_source, + ) + }); + let startup = build_local_startup_script( + &shell_name, + &ready_marker, + dynamic_title_enabled, + allow_injection, + ); + if dynamic_title_enabled && !startup.dynamic_title_integration_requested { + tracing::info!( + shell = %shell_name, + custom_argv = config + .as_ref() + .is_some_and(|cfg| !cfg.shell_args.is_empty()), + "Local dynamic-title hook unavailable; continuing in passive title mode" + ); + } + let LocalStartupScript { + script: startup_script, + shell_init_args, + pwsh_init_args, + cmd_prompt, + dynamic_title_integration_requested, + } = startup; + let startup_input_barrier = startup_script + .as_ref() + .map(|_| Arc::new(StartupInputBarrier::new())); + // Dynamic-title opt-in must not change HEAD's Local command-history timing. + let injection_active = false; + let trusted_initial_title = if cfg!(target_os = "windows") && dynamic_title_enabled { + Some(shell_name.clone()) + } else { + None + }; let session_info = SessionInfo { id: session_id.clone(), @@ -39,15 +85,21 @@ pub async fn create_local_session( owner_window_label, ai_execution_profile, injection_active, + // Integration becomes active only after a session-bound ready marker. + dynamic_title_capabilities: DynamicTitleCapabilities::new( + dynamic_title_enabled, + trusted_initial_title, + ), remote_file_browser_enabled: false, remote_stats_enabled: false, ssh_profile: None, }; - let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(None)); + let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(Default::default())); let session_handle = SessionHandle { info: session_info.clone(), cmd_tx, + startup_input_barrier: startup_input_barrier.clone(), ssh_config: None, ssh_handle: None, cwd: cwd.clone(), @@ -79,8 +131,14 @@ pub async fn create_local_session( output_control_tx, rt_handle, cwd, + resolved_shell_spec, + shell_init_args, + pwsh_init_args, + cmd_prompt, config, - startup_script.script, + startup_script, + startup_input_barrier, + dynamic_title_integration_requested, ready_marker, encoding, ); @@ -89,6 +147,252 @@ pub async fn create_local_session( Ok(session_id) } +type LocalStartupPipeline = (OscStripper, StartupOutputGate); + +fn managed_cwd_runtime_ready( + managed_requested: bool, + integration_available: bool, + integration_ready: bool, + ready_in_chunk: bool, +) -> bool { + !managed_requested || (integration_available && (integration_ready || ready_in_chunk)) +} + +/// The Bash/Zsh cwd hook runs before the PS1-embedded ready marker, and PTY +/// packet boundaries may split those sequences. Retain only the latest cwd +/// emitted while our source-output gate is active, then release it when the +/// session-bound marker arrives. Profile/application OSC 7 emitted before +/// injection remains untrusted and is not staged. +#[allow(clippy::too_many_arguments)] +fn reconcile_managed_cwd_events( + events: &mut Vec, + pending: &mut Option, + managed_requested: bool, + integration_available: bool, + integration_ready: bool, + ready_in_chunk: bool, + source_gate_was_active: bool, + integration_failed: bool, +) { + if !managed_requested { + // Passive and disabled Local sessions still need the bounded event + // stream for safe legacy cwd consumers; only managed pre-ready state + // is held back by this reconciliation step. + *pending = None; + return; + } + if !integration_available || integration_failed { + *pending = None; + events.clear(); + return; + } + if integration_ready || ready_in_chunk { + if events.is_empty() { + if let Some(event) = pending.take() { + events.push(event); + } + } else { + *pending = None; + } + return; + } + + if source_gate_was_active { + *pending = events.last().cloned(); + } + events.clear(); +} + +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +fn reconcile_managed_cwd_payloads( + payloads: &mut Vec, + pending: &mut Option, + managed_requested: bool, + integration_available: bool, + integration_ready: bool, + ready_in_chunk: bool, + source_gate_was_active: bool, + integration_failed: bool, +) { + let mut events: Vec = payloads + .drain(..) + .map(CwdPayloadEvent::Payload) + .collect(); + let mut pending_event = pending.take().map(CwdPayloadEvent::Payload); + reconcile_managed_cwd_events( + &mut events, + &mut pending_event, + managed_requested, + integration_available, + integration_ready, + ready_in_chunk, + source_gate_was_active, + integration_failed, + ); + *payloads = events + .into_iter() + .filter_map(|event| match event { + CwdPayloadEvent::Payload(payload) => Some(payload), + CwdPayloadEvent::Invalidated => None, + }) + .collect(); + *pending = pending_event.and_then(|event| match event { + CwdPayloadEvent::Payload(payload) => Some(payload), + CwdPayloadEvent::Invalidated => None, + }); +} + +fn detect_local_zmodem( + detector: &mut ZmodemDetector, + raw: &[u8], +) -> ZmodemDetectResult { + detector.feed(raw) +} + +fn input_cancels_startup_injection(origin: InputOrigin) -> bool { + origin != InputOrigin::TerminalResponse +} + +fn cancel_startup_for_pending_zmodem( + barrier: Option<&Arc>, +) -> bool { + barrier.is_some_and(|barrier| barrier.cancel_pending()) +} + +fn discard_startup_pipeline(pipeline: &Arc>) -> bool { + let mut pipeline = lock_or_recover(pipeline, "local_startup_pipeline"); + let (stripper, gate) = &mut *pipeline; + stripper.flush(); + gate.discard() +} + +fn clear_managed_cwd_runtime( + runtime: &tokio::runtime::Handle, + cwd: &SharedCwd, + app: &AppHandle, + presentation_event: &str, +) { + let changes = runtime.block_on(async { + replace_cwd_state( + cwd, + SessionCwdReplacement { + legacy_path: None, + operational_path: None, + presentation: None, + }, + ) + .await + }); + if changes.presentation_changed { + let _ = app.emit(presentation_event, &changes.presentation); + } +} + +fn disable_managed_integration( + runtime: &tokio::runtime::Handle, + manager: &Arc, + session_id: &str, + availability: &AtomicBool, + cwd: &SharedCwd, + app: &AppHandle, + presentation_event: &str, +) -> bool { + if !availability.swap(false, Ordering::AcqRel) { + return false; + } + runtime.block_on(manager.set_dynamic_title_integration_active(session_id, false)); + clear_managed_cwd_runtime(runtime, cwd, app, presentation_event); + true +} + +fn drain_startup_pipeline_on_cancel( + pipeline: &Arc>, +) -> String { + let mut pipeline = lock_or_recover(pipeline, "local_startup_pipeline"); + let (stripper, gate) = &mut *pipeline; + if gate.is_active() { + stripper.flush(); + gate.discard(); + String::new() + } else { + // Before injection, buffered bytes are ordinary shell output (for + // example one half of an OSC color query), never source echo. + stripper.flush() + } +} + +fn cancel_startup_pipeline( + pipeline: &Arc>, + output: &Arc, + recording: Option<&Arc>, + session_id: &str, +) { + let visible = drain_startup_pipeline_on_cancel(pipeline); + if visible.is_empty() { + return; + } + if let Some(recording) = recording { + recording.write_output(session_id, &visible); + } + output.push_owned(visible); +} + +#[allow(clippy::too_many_arguments)] +fn cancel_pending_startup_injection( + pending_script: &mut Option, + schedule: &mut Option, + pipeline: Option<&Arc>>, + output_order: Option<&Arc>>, + startup_file: Option<&Arc>, + output: &Arc, + recording: Option<&Arc>, + runtime: &tokio::runtime::Handle, + manager: &Arc, + session_id: &str, + availability: &AtomicBool, + cwd: &SharedCwd, + app: &AppHandle, + presentation_event: &str, +) -> bool { + // Serialize cancellation drain with the reader's parse-and-publish step. + // Without this guard, a trailing half of an escape sequence can be flushed + // before the visible prefix that the reader already parsed. + let _output_order_guard = + output_order.map(|order| lock_or_recover(order, "local_startup_output_order")); + let was_pending = pending_script.take().is_some() || schedule.take().is_some(); + *schedule = None; + let was_suppressing = pipeline.is_some_and(|pipeline| { + lock_or_recover(pipeline, "local_startup_pipeline") + .1 + .is_active() + }); + if !was_pending && !was_suppressing { + return false; + } + if let Some(pipeline) = pipeline { + cancel_startup_pipeline(pipeline, output, recording, session_id); + } + // A pending command was never written, so its source file is unused. Once + // suppression is active the short source command may already be queued; + // keep the file until its marker, timeout or reader teardown. + if was_pending { + if let Some(file) = startup_file { + file.remove(); + } + } + disable_managed_integration( + runtime, + manager, + session_id, + availability, + cwd, + app, + presentation_event, + ); + true +} + fn pty_session_thread( app: AppHandle, session_id: String, @@ -97,11 +401,38 @@ fn pty_session_thread( output_control_tx: SessionCommandSender, rt_handle: tokio::runtime::Handle, cwd: SharedCwd, + resolved_shell_spec: ShellCommandSpec, + shell_init_args: Option>, + pwsh_init_args: Option>, + cmd_prompt: Option, config: Option, startup_script: Option, + startup_input_barrier: Option>, + dynamic_title_integration_requested: bool, ready_marker: String, encoding: String, ) { + let mut dynamic_title_integration_requested = dynamic_title_integration_requested; + let (startup_script, startup_script_file) = match startup_script { + Some(source) => match prepare_local_startup_injection(source) { + Ok(prepared) => prepared, + Err(error) => { + dynamic_title_integration_requested = false; + if let Some(barrier) = startup_input_barrier.as_ref() { + barrier.cancel_pending(); + } + tracing::warn!( + session_id = %session_id, + error = %error, + "Could not prepare Local shell integration; continuing in passive mode" + ); + (String::new(), None) + } + }, + None => (String::new(), None), + }; + let startup_script = (!startup_script.is_empty()).then_some(startup_script); + let pty_system = native_pty_system(); let pair = match pty_system.openpty(PtySize { rows: 24, @@ -124,26 +455,10 @@ fn pty_session_thread( } }; - let (mut cmd, _) = match &config { - Some(cfg) if !cfg.shell_path.trim().is_empty() => { - match build_shell_command(&cfg.shell_path, &cfg.shell_args) { - Ok(command) => command, - Err(error) => { - tracing::error!("Failed to build shell command: {}", error); - let _ = app.emit( - &format!("session-error-{}", session_id), - format!("Failed to build shell command: {}", error), - ); - let _ = app.emit(&format!("session-closed-{}", session_id), ()); - rt_handle.block_on(async { - manager.remove_session(&session_id).await; - }); - return; - } - } - } - _ => platform_default_shell(), - }; + // Use the exact command spec that authorized integration. Re-resolving a + // Windows Terminal profile here could turn a profile into a fallback (or + // vice versa) after startup hooks and metadata have already been chosen. + let (mut cmd, _) = build_shell_command_from_spec(&resolved_shell_spec); if let Some(ref cfg) = config { if let Some(ref dir) = cfg.working_dir { @@ -185,6 +500,27 @@ fn pty_session_thread( #[cfg(any(target_os = "linux", target_os = "macos"))] configure_local_pty_environment(&mut cmd); + // Structured shell init arguments are used only when the saved argv is + // empty. Fish `--init-command` runs after user config and before the first + // prompt, avoiding PTY echo/query races entirely. + if let Some(args) = shell_init_args.as_ref() { + if !args.is_empty() { + cmd.args(args.iter().map(String::as_str)); + } + } + + // PowerShell prompt injection rides on spawn args (PTY-written scripts are + // unreliable for interactive pwsh on Windows). Silently skipped when the + // user supplied custom shell arguments — we never mutate their command. + if let Some(args) = pwsh_init_args.as_ref() { + if !args.is_empty() { + cmd.args(args.iter().map(String::as_str)); + } + } + if let Some(prompt) = cmd_prompt.as_deref() { + cmd.env("PROMPT", prompt); + } + let mut child = match pair.slave.spawn_command(cmd) { Ok(c) => c, Err(e) => { @@ -200,6 +536,7 @@ fn pty_session_thread( return; } }; + let shell_process_id = child.process_id(); drop(pair.slave); let mut writer = match pair.master.take_writer() { @@ -246,6 +583,8 @@ fn pty_session_thread( let zmodem_state: Arc>> = Arc::new(StdMutex::new(None)); let zmodem_state_reader = zmodem_state.clone(); + let zmodem_input_blocked = Arc::new(AtomicBool::new(false)); + let zmodem_input_blocked_reader = zmodem_input_blocked.clone(); let zmodem_event_name = format!("zmodem-event-{session_id}"); let zmodem_event_reader = zmodem_event_name.clone(); let (zmodem_out_tx, mut zmodem_out_rx) = mpsc::unbounded_channel::>(); @@ -253,6 +592,11 @@ fn pty_session_thread( let app_read = app.clone(); let sid_read = session_id.clone(); let cwd_event = format!("cwd-changed-{}", session_id); + let cwd_presentation_event = format!("cwd-presentation-changed-{}", session_id); + // Operational cwd consumers are always security-sensitive, even when + // dynamic title presentation is disabled. Parse every Local OSC 7 through + // the bounded strict projection before preserving any safe legacy spelling. + let cwd_context = LocalCwdContext::new(&session_id); let rt_for_reader = rt_handle.clone(); let recording_mgr_reader: Option> = app .try_state::>() @@ -260,14 +604,42 @@ fn pty_session_thread( let sid_for_rec_reader = session_id.clone(); let output_reader = output.clone(); let manager_reader = manager.clone(); - let suppress_startup_output = startup_script.is_some(); + let integration_available = Arc::new(AtomicBool::new(dynamic_title_integration_requested)); + let integration_available_reader = integration_available.clone(); + let integration_ready = Arc::new(AtomicBool::new(false)); + let integration_ready_reader = integration_ready.clone(); + let startup_pipeline = startup_script.as_ref().map(|_| { + Arc::new(StdMutex::new(( + OscStripper::with_cwd_payloads(&ready_marker, true), + StartupOutputGate::new_inactive(), + ))) + }); + let startup_pipeline_reader = startup_pipeline.clone(); + let startup_output_order = startup_script + .as_ref() + .map(|_| Arc::new(StdMutex::new(()))); + let startup_output_order_reader = startup_output_order.clone(); + let startup_script_file_reader = startup_script_file.clone(); + let startup_input_barrier_reader = startup_input_barrier.clone(); + let startup_terminal_sequence_pending = Arc::new(AtomicBool::new(false)); + let startup_terminal_sequence_pending_reader = + startup_terminal_sequence_pending.clone(); + let cwd_reader = cwd.clone(); + let cwd_presentation_event_reader = cwd_presentation_event.clone(); let encoding_for_reader = encoding.clone(); + let startup_activity = startup_script + .as_ref() + .map(|_| Arc::new(StdMutex::new(None::))); + let startup_activity_reader = startup_activity.clone(); let (reader_done_tx, reader_done_rx) = std_mpsc::channel::<()>(); std::thread::spawn(move || { let mut raw_buf = [0u8; 4096]; - let mut stripper = OscStripper::new(&ready_marker); - let mut suppress_visible = suppress_startup_output; + let mut direct_stripper = startup_pipeline_reader + .is_none() + .then(|| OscStripper::with_cwd_payloads(&ready_marker, true)); let mut zmodem_detector = ZmodemDetector::new(); + let mut terminal_query_detector = TerminalQueryDetector::new(); + let mut pending_managed_cwd_event: Option = None; let mut output_decoder = TerminalOutputDecoder::new(&encoding_for_reader); loop { { @@ -280,6 +652,10 @@ fn pty_session_thread( match reader.read(&mut raw_buf) { Ok(0) => break, Ok(n) => { + if let Some(activity) = startup_activity_reader.as_ref() { + *lock_or_recover(activity, "local_startup_activity") = + Some(Instant::now()); + } let raw = &raw_buf[..n]; // ZMODEM: if active, route raw bytes to the transfer. @@ -299,21 +675,52 @@ fn pty_session_thread( } if transfer.is_done() { *zm = None; + zmodem_input_blocked_reader.store(false, Ordering::Release); zmodem_detector.reset(); } continue; } } - // ZMODEM: detect header in raw bytes. - let process_raw = if !suppress_visible { - match zmodem_detector.feed(raw) { + // ZMODEM detection always runs, including while startup + // output is gated. Otherwise a profile-started transfer or + // a ready+header packet can be irrecoverably decoded as text. + let startup_suppressed = startup_pipeline_reader.as_ref().is_some_and( + |pipeline| { + lock_or_recover(pipeline, "local_startup_pipeline") + .1 + .is_active() + }, + ); + let process_raw = match detect_local_zmodem(&mut zmodem_detector, raw) { ZmodemDetectResult::Detected { direction, passthrough, initial_bytes, } => { - if !passthrough.is_empty() { + // Publish the protocol-input barrier before any + // potentially blocking upload lookup/bootstrap. + zmodem_input_blocked_reader.store(true, Ordering::Release); + if let Some(barrier) = startup_input_barrier_reader.as_ref() { + barrier.cancel_pending(); + } + if let Some(file) = startup_script_file_reader.as_ref() { + file.remove(); + } + if startup_suppressed { + if let Some(pipeline) = startup_pipeline_reader.as_ref() { + discard_startup_pipeline(pipeline); + } + disable_managed_integration( + &rt_for_reader, + &manager_reader, + &sid_for_rec_reader, + &integration_available_reader, + &cwd_reader, + &app_read, + &cwd_presentation_event_reader, + ); + } else if !passthrough.is_empty() { if let Some(rec) = recording_mgr_reader.as_ref() { rec.write_raw_output(&sid_for_rec_reader, &passthrough); } @@ -322,6 +729,7 @@ fn pty_session_thread( output_reader.push_owned(pre); } } + let prepared_upload = if direction == ZmodemDirection::Upload { rt_for_reader.block_on(async { manager_reader.take_pending_zmodem_upload(&sid_read).await @@ -352,37 +760,161 @@ fn pty_session_thread( continue; } ZmodemDetectResult::NoMatch { passthrough } => { + if zmodem_detector.has_pending_prefix() { + let cancelled_pending_startup = + cancel_startup_for_pending_zmodem( + startup_input_barrier_reader.as_ref(), + ); + if cancelled_pending_startup { + if let Some(file) = startup_script_file_reader.as_ref() { + file.remove(); + } + } + if cancelled_pending_startup && startup_suppressed { + if let Some(pipeline) = startup_pipeline_reader.as_ref() { + discard_startup_pipeline(pipeline); + } + disable_managed_integration( + &rt_for_reader, + &manager_reader, + &sid_for_rec_reader, + &integration_available_reader, + &cwd_reader, + &app_read, + &cwd_presentation_event_reader, + ); + // `passthrough` may contain the hidden + // source-command echo that preceded this + // partial header. Never re-run it through + // the now-inactive gate. + continue; + } + } if passthrough.is_empty() { continue; } + if !startup_suppressed { if let Some(rec) = recording_mgr_reader.as_ref() { rec.write_raw_output(&sid_for_rec_reader, &passthrough); } - passthrough } + passthrough } - } else { - raw.to_vec() }; let text = output_decoder.decode(&process_raw); + let _startup_output_order_guard = startup_output_order_reader + .as_ref() + .map(|order| lock_or_recover(order, "local_startup_output_order")); + if let Some(barrier) = startup_input_barrier_reader.as_ref() { + barrier.note_terminal_queries(terminal_query_detector.push(&text)); + startup_terminal_sequence_pending_reader.store( + terminal_query_detector.has_pending_sequence(), + Ordering::Release, + ); + } + let (mut result, gated_output, source_gate_was_active) = + if let Some(pipeline) = startup_pipeline_reader.as_ref() { + let mut pipeline = + lock_or_recover(pipeline, "local_startup_pipeline"); + let (stripper, gate) = &mut *pipeline; + let source_gate_was_active = gate.is_active(); let mut result = stripper.push(&text); + let gated = gate.consume( + std::mem::take(&mut result.visible), + std::mem::take(&mut result.visible_after_ready), + result.ready || result.ready_failed, + ); + if matches!(gated, StartupGateOutput::SuppressedOverflow(_)) { + stripper.flush(); + } + (result, Some(gated), source_gate_was_active) + } else { + ( + direct_stripper + .as_mut() + .expect("direct Local OSC parser") + .push(&text), + None, + false, + ) + }; - for path in &result.cwd_paths { - let cwd_ev = cwd_event.clone(); - let app_ref = app_read.clone(); - let next_cwd = rt_for_reader - .block_on(async { update_cwd_if_changed(&cwd, path).await }); - if let Some(next_cwd) = next_cwd { - let _ = app_ref.emit(&cwd_ev, &next_cwd); + let ready_seen = result.ready; + if result.ready || result.ready_failed { + if let Some(file) = startup_script_file_reader.as_ref() { + file.remove(); + } + } + let integration_available_now = + integration_available_reader.load(Ordering::Acquire); + let integration_ready_now = + integration_ready_reader.load(Ordering::Acquire); + let integration_failed = result.ready_failed + || matches!( + &gated_output, + Some(StartupGateOutput::SuppressedOverflow(_)) + ); + reconcile_managed_cwd_events( + &mut result.cwd_payload_events, + &mut pending_managed_cwd_event, + dynamic_title_integration_requested, + integration_available_now, + integration_ready_now, + ready_seen, + source_gate_was_active, + integration_failed, + ); + let managed_runtime_ready = managed_cwd_runtime_ready( + dynamic_title_integration_requested, + integration_available_now, + integration_ready_now, + ready_seen, + ); + // Every Local OSC 7 is strictly parsed before it can feed + // operational consumers. Managed hooks additionally wait + // for their session-bound ready marker; passive sessions + // retain safe legacy spelling only. + { + debug_assert!(managed_runtime_ready || result.cwd_payload_events.is_empty()); + let cwd_events = result.cwd_payload_events.clone(); + for event in cwd_events { + let replacement = match event { + CwdPayloadEvent::Payload(payload) => { + let report = parse_local_cwd_report(&payload, &cwd_context); + cwd_state_replacement( + &report, + dynamic_title_integration_requested, + ) + } + CwdPayloadEvent::Invalidated => SessionCwdReplacement { + legacy_path: None, + operational_path: None, + presentation: None, + }, + }; + let changes = rt_for_reader.block_on(async { + replace_cwd_state(&cwd_reader, replacement).await + }); + if changes.operational_changed { + let next_cwd = changes.operational_path.as_deref().unwrap_or(""); + let _ = app_read.emit(&cwd_event, next_cwd); + } + if changes.presentation_changed { + let _ = app_read.emit( + &cwd_presentation_event_reader, + &changes.presentation, + ); + } } } for command in &result.accepted_commands { - rt_for_reader.block_on( + let accepted = rt_for_reader.block_on( manager_reader .confirm_command_submission(&sid_for_rec_reader, command.clone()), ); + if accepted { let _ = app_read.emit( "session-command-accepted", serde_json::json!({ @@ -391,9 +923,62 @@ fn pty_session_thread( }), ); } + } - if !should_emit_visible_output(&mut suppress_visible, result.ready) { - continue; + result.visible = match gated_output.unwrap_or_else(|| { + StartupGateOutput::Pass(std::mem::take(&mut result.visible)) + }) { + StartupGateOutput::Buffered(visible) + | StartupGateOutput::Ready(visible) + | StartupGateOutput::Pass(visible) => visible, + StartupGateOutput::SuppressedOverflow(visible) => { + disable_managed_integration( + &rt_for_reader, + &manager_reader, + &sid_for_rec_reader, + &integration_available_reader, + &cwd_reader, + &app_read, + &cwd_presentation_event_reader, + ); + tracing::warn!( + session_id = %sid_read, + max_bytes = MAX_STARTUP_OUTPUT_BUFFER, + "Local shell integration output exceeded its startup buffer; suppressing injection and continuing in passive mode" + ); + visible + } + }; + + if result.ready_failed + && disable_managed_integration( + &rt_for_reader, + &manager_reader, + &sid_for_rec_reader, + &integration_available_reader, + &cwd_reader, + &app_read, + &cwd_presentation_event_reader, + ) + { + tracing::warn!( + session_id = %sid_read, + "Local shell rejected dynamic-title integration; continuing in passive mode" + ); + } + + let ready_accepted = ready_seen + && !result.ready_failed + && integration_available_reader.load(Ordering::Acquire); + if ready_accepted + && !integration_ready_reader.swap(true, Ordering::AcqRel) + { + rt_for_reader.block_on( + manager_reader.set_dynamic_title_integration_active( + &sid_for_rec_reader, + true, + ), + ); } if let Ok(mut proc) = capture_for_reader.lock() { @@ -419,6 +1004,13 @@ fn pty_session_thread( } } } + integration_available_reader.store(false, Ordering::Release); + if let Some(file) = startup_script_file_reader.as_ref() { + file.remove(); + } + if let Some(pipeline) = startup_pipeline_reader.as_ref() { + discard_startup_pipeline(pipeline); + } output_reader.close(); let _ = reader_done_tx.send(()); }); @@ -426,14 +1018,44 @@ fn pty_session_thread( let recording_mgr: Option> = app .try_state::>() .map(|s| s.inner().clone()); - if let Some(script) = startup_script.as_deref() { - if let Err(error) = write_to_pty(&mut *writer, script.as_bytes()) { + let has_unix_startup_script = startup_script.is_some(); + let mut pending_startup_script = startup_script; + let mut startup_injection_schedule = pending_startup_script.as_ref().map(|_| { + StartupInjectionSchedule::new( + Instant::now(), + LOCAL_SHELL_STARTUP_IDLE, + LOCAL_SHELL_STARTUP_TIMEOUT, + ) + }); + + if dynamic_title_integration_requested && !has_unix_startup_script { + let availability = integration_available.clone(); + let ready = integration_ready.clone(); + let watchdog_runtime = rt_handle.clone(); + let watchdog_manager = manager.clone(); + let watchdog_cwd = cwd.clone(); + let watchdog_app = app.clone(); + let watchdog_presentation_event = cwd_presentation_event.clone(); + let watchdog_session_id = session_id.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(10)); + if !ready.load(Ordering::Acquire) + && disable_managed_integration( + &watchdog_runtime, + &watchdog_manager, + &watchdog_session_id, + &availability, + &watchdog_cwd, + &watchdog_app, + &watchdog_presentation_event, + ) + { tracing::warn!( - session_id = %session_id, - error = %error, - "Failed to write local PTY startup script" + session_id = %watchdog_session_id, + "Local shell dynamic-title hook did not report ready within 10 seconds; continuing in passive mode" ); } + }); } loop { match reader_done_rx.try_recv() { @@ -467,6 +1089,192 @@ fn pty_session_thread( } } + if startup_injection_schedule.is_some() { + if startup_input_barrier + .as_ref() + .is_some_and(|barrier| barrier.is_cancelled()) + || lock_or_recover(&zmodem_state, "local_zmodem_state").is_some() + { + cancel_pending_startup_injection( + &mut pending_startup_script, + &mut startup_injection_schedule, + startup_pipeline.as_ref(), + startup_output_order.as_ref(), + startup_script_file.as_ref(), + &output, + recording_mgr.as_ref(), + &rt_handle, + &manager, + &session_id, + &integration_available, + &cwd, + &app, + &cwd_presentation_event, + ); + } + if let Some(activity) = startup_activity.as_ref() { + if let Some(activity_at) = + lock_or_recover(activity, "local_startup_activity").take() + { + if let Some(schedule) = startup_injection_schedule.as_mut() { + schedule.note_activity(activity_at); + } + } + } + + let scheduled_decision = startup_injection_schedule + .as_ref() + .map(|schedule| schedule.decision(Instant::now())) + .unwrap_or(StartupInjectionDecision::Wait); + let decision = if startup_injection_schedule.is_none() { + StartupInjectionDecision::Wait + } else if scheduled_decision == StartupInjectionDecision::Timeout { + StartupInjectionDecision::Timeout + } else if !output.is_attached() + || !local_shell_line_editor_ready(&*master, shell_process_id) + || startup_input_barrier + .as_ref() + .is_some_and(|barrier| barrier.has_pending_terminal_query()) + || startup_terminal_sequence_pending.load(Ordering::Acquire) + || !cmd_rx.is_empty() + { + // Wait for an attached xterm.js renderer, the shell's actual + // foreground line editor (not an idle gap inside a slow rc), + // and a drained terminal-response queue before injecting. + StartupInjectionDecision::Wait + } else { + scheduled_decision + }; + match decision { + StartupInjectionDecision::Wait => {} + StartupInjectionDecision::Timeout => { + if let Some(barrier) = startup_input_barrier.as_ref() { + barrier.cancel_pending(); + } + cancel_pending_startup_injection( + &mut pending_startup_script, + &mut startup_injection_schedule, + startup_pipeline.as_ref(), + startup_output_order.as_ref(), + startup_script_file.as_ref(), + &output, + recording_mgr.as_ref(), + &rt_handle, + &manager, + &session_id, + &integration_available, + &cwd, + &app, + &cwd_presentation_event, + ); + tracing::warn!( + session_id = %session_id, + "Local shell did not reach the safe renderer/line-editor startup barrier before timeout; continuing in passive mode" + ); + } + StartupInjectionDecision::Inject => { + let script = pending_startup_script + .as_ref() + .expect("scheduled Local startup script") + .clone(); + let injection_result = if let Some(barrier) = startup_input_barrier.as_ref() { + barrier.try_inject(|| { + if let Some(pipeline) = startup_pipeline.as_ref() { + lock_or_recover(pipeline, "local_startup_pipeline") + .1 + .activate(); + } + write_to_pty(&mut *writer, script.as_bytes()) + }) + } else { + Ok(StartupInjectionAttempt::Cancelled) + }; + match injection_result { + Ok(StartupInjectionAttempt::WaitForTerminalResponse) => {} + Ok(StartupInjectionAttempt::Cancelled) => { + cancel_pending_startup_injection( + &mut pending_startup_script, + &mut startup_injection_schedule, + startup_pipeline.as_ref(), + startup_output_order.as_ref(), + startup_script_file.as_ref(), + &output, + recording_mgr.as_ref(), + &rt_handle, + &manager, + &session_id, + &integration_available, + &cwd, + &app, + &cwd_presentation_event, + ); + } + Ok(StartupInjectionAttempt::Injected(())) => { + startup_injection_schedule = None; + pending_startup_script = None; + if let Some(pipeline) = startup_pipeline.clone() { + let availability = integration_available.clone(); + let startup_file_watchdog = startup_script_file.clone(); + let watchdog_runtime = rt_handle.clone(); + let watchdog_manager = manager.clone(); + let watchdog_cwd = cwd.clone(); + let watchdog_app = app.clone(); + let watchdog_presentation_event = + cwd_presentation_event.clone(); + let watchdog_session_id = session_id.clone(); + std::thread::spawn(move || { + std::thread::sleep(LOCAL_SHELL_STARTUP_TIMEOUT); + if let Some(file) = startup_file_watchdog.as_ref() { + file.remove(); + } + let discarded = discard_startup_pipeline(&pipeline); + if discarded { + disable_managed_integration( + &watchdog_runtime, + &watchdog_manager, + &watchdog_session_id, + &availability, + &watchdog_cwd, + &watchdog_app, + &watchdog_presentation_event, + ); + tracing::warn!( + session_id = %watchdog_session_id, + "Local shell integration did not become ready within 10 seconds; continuing in passive mode" + ); + } + }); + } + } + Err(error) => { + startup_injection_schedule = None; + pending_startup_script = None; + if let Some(pipeline) = startup_pipeline.as_ref() { + discard_startup_pipeline(pipeline); + } + if let Some(file) = startup_script_file.as_ref() { + file.remove(); + } + disable_managed_integration( + &rt_handle, + &manager, + &session_id, + &integration_available, + &cwd, + &app, + &cwd_presentation_event, + ); + tracing::warn!( + session_id = %session_id, + error = %error, + "Failed to write Local dynamic-title startup script; continuing without integration" + ); + } + } + } + } + } + // Drain any ZMODEM outgoing data first (non-blocking). while let Ok(data) = zmodem_out_rx.try_recv() { let _ = write_to_pty(&mut *writer, &data); @@ -481,18 +1289,54 @@ fn pty_session_thread( } }; match cmd { - SessionCommand::Attach => { - output.attach(); + SessionCommand::AttachConfirmed { ack } => { + output.attach_confirmed(ack); } SessionCommand::DetachRenderer => { output.detach(); } - SessionCommand::Write { data, .. } => { - if zmodem_state.lock().unwrap().is_some() { + SessionCommand::Write { data, origin, .. } => { + if input_cancels_startup_injection(origin) + && startup_input_barrier + .as_ref() + .is_some_and(|barrier| barrier.cancel_for_input(origin)) + { + cancel_pending_startup_injection( + &mut pending_startup_script, + &mut startup_injection_schedule, + startup_pipeline.as_ref(), + startup_output_order.as_ref(), + startup_script_file.as_ref(), + &output, + recording_mgr.as_ref(), + &rt_handle, + &manager, + &session_id, + &integration_available, + &cwd, + &app, + &cwd_presentation_event, + ); + } + if zmodem_input_blocked.load(Ordering::Acquire) { continue; } let send_data = encode_terminal_input(&data, &encoding); - if let Err(error) = write_to_pty(&mut *writer, &send_data) { + let write_started_at = Instant::now(); + match write_to_pty(&mut *writer, &send_data) { + Ok(()) => { + if origin == InputOrigin::TerminalResponse { + let resolved_startup_query = startup_input_barrier + .as_ref() + .is_some_and(|barrier| barrier.resolve_terminal_response()); + if resolved_startup_query { + if let Some(schedule) = startup_injection_schedule.as_mut() { + schedule.require_output_after_terminal_response(write_started_at); + } + } + } + } + Err(error) => { tracing::warn!( session_id = %session_id, error = %error, @@ -500,6 +1344,7 @@ fn pty_session_thread( ); } } + } SessionCommand::CaptureExec { marker_id, wrapped_command, @@ -562,6 +1407,7 @@ fn pty_session_thread( } if transfer.is_done() { *zm = None; + zmodem_input_blocked.store(false, Ordering::Release); } } } @@ -586,6 +1432,7 @@ fn pty_session_thread( } if transfer.is_done() { *zm = None; + zmodem_input_blocked.store(false, Ordering::Release); } } } @@ -608,6 +1455,7 @@ fn pty_session_thread( } } *zm = None; + zmodem_input_blocked.store(false, Ordering::Release); } SessionCommand::Close => { break; @@ -638,7 +1486,10 @@ fn pty_session_thread( let _ = app.emit(&format!("session-closed-{}", session_id), ()); } -fn validate_working_dir_before_spawn(config: Option<&LocalSessionConfig>) -> AppResult<()> { +fn validate_working_dir_before_spawn( + config: Option<&LocalSessionConfig>, + resolved_shell_spec: &ShellCommandSpec, +) -> AppResult<()> { let Some(cfg) = config else { return Ok(()); }; @@ -654,16 +1505,7 @@ fn validate_working_dir_before_spawn(config: Option<&LocalSessionConfig>) -> App )); } - let (mut cmd, _) = match cfg { - LocalSessionConfig { - shell_path, - shell_args, - .. - } if !shell_path.trim().is_empty() => { - build_shell_command(shell_path, shell_args).map_err(crate::error::AppError::Config)? - } - _ => platform_default_shell(), - }; + let (mut cmd, _) = build_shell_command_from_spec(resolved_shell_spec); if apply_working_dir_to_command(&mut cmd, dir) == WorkingDirectoryOutcome::MissingLocalDirectory { diff --git a/src-tauri/src/core/terminal_session/local/shell.rs b/src-tauri/src/core/terminal_session/local/shell.rs index ae9cca70..aaf85486 100644 --- a/src-tauri/src/core/terminal_session/local/shell.rs +++ b/src-tauri/src/core/terminal_session/local/shell.rs @@ -1,13 +1,9 @@ -fn build_shell_command( - shell_path: &str, - shell_args: &str, -) -> Result<(CommandBuilder, String), String> { - let spec = resolve_shell_command(shell_path, shell_args)?; +fn build_shell_command_from_spec(spec: &ShellCommandSpec) -> (CommandBuilder, String) { let mut builder = CommandBuilder::new(&spec.program); if !spec.args.is_empty() { builder.args(spec.args.iter().map(String::as_str)); } - Ok((builder, spec.program)) + (builder, spec.program.clone()) } fn default_local_shell_args(program: &str) -> Vec { @@ -27,6 +23,26 @@ fn default_local_shell_args(program: &str) -> Vec { } } +fn default_shell_spec() -> ShellCommandSpec { + #[cfg(target_os = "windows")] + { + ShellCommandSpec { + program: resolve_program_for_spawn("powershell.exe"), + args: Vec::new(), + resolution_source: ShellResolutionSource::Direct, + } + } + #[cfg(not(target_os = "windows"))] + { + let program = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + ShellCommandSpec { + args: default_local_shell_args(&program), + program, + resolution_source: ShellResolutionSource::Direct, + } + } +} + fn resolve_shell_command(shell_path: &str, shell_args: &str) -> Result { let raw_program = shell_path.trim(); let program = trim_wrapping_quotes(raw_program); @@ -40,6 +56,7 @@ fn resolve_shell_command(shell_path: &str, shell_args: &str) -> Result Result Result Result, - shell_integration_active: bool, +// Local PTY dynamic-title integration. Remote SSH integration remains in +// `core::ssh::osc` so Local title policy cannot change released SSH/history behavior. + +use crate::core::ssh::osc::{ + ControlSequenceKind, ShellKind, control_sequence_payload, find_control_sequence_end, + find_control_sequence_start, +}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +const MAX_STARTUP_OUTPUT_BUFFER: usize = 64 * 1024; +const LOCAL_SHELL_STARTUP_IDLE: Duration = Duration::from_millis(250); +const LOCAL_SHELL_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); + +/// Outcome of building startup integration for a spawned Local shell. +pub struct LocalStartupScript { + /// Bash/Zsh source stored in a private temporary file after spawn. + pub script: Option, + /// Shell-specific structured init arguments (currently Fish `--init-command`). + pub shell_init_args: Option>, + /// PowerShell spawn arguments appended only for an unmodified default argv. + pub pwsh_init_args: Option>, + /// Process-local cmd.exe PROMPT value; never persisted or written globally. + pub cmd_prompt: Option, + /// True when a session-bound ready marker is expected at runtime. + pub dynamic_title_integration_requested: bool, } -fn build_local_startup_script(_shell_name: &str, _ready_marker: &str) -> LocalStartupScript { - build_local_startup_script_for_platform( - _shell_name, - _ready_marker, - cfg!(not(target_os = "windows")), - ) +pub struct LocalStartupScriptFile { + path: PathBuf, + removed: AtomicBool, } -fn build_local_startup_script_for_platform( - _shell_name: &str, - _ready_marker: &str, - _allow_unix_prelude: bool, +impl LocalStartupScriptFile { + pub fn remove(&self) { + if self.removed.swap(true, Ordering::AcqRel) { + return; + } + if let Err(error) = std::fs::remove_file(&self.path) { + if error.kind() != std::io::ErrorKind::NotFound { + self.removed.store(false, Ordering::Release); + tracing::warn!( + path = %self.path.display(), + error = %error, + "Failed to remove temporary Local shell integration file" + ); + } + } + } +} + +impl Drop for LocalStartupScriptFile { + fn drop(&mut self) { + self.remove(); + } +} + +#[cfg(unix)] +pub fn prepare_local_startup_injection( + source: String, +) -> std::io::Result<(String, Option>)> { + for _ in 0..8 { + let nonce = uuid::Uuid::new_v4().simple().to_string(); + let path = PathBuf::from(format!("/tmp/.nt-{}", &nonce[..16])); + let mut file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + if let Err(error) = file.write_all(source.as_bytes()).and_then(|_| file.flush()) { + let _ = std::fs::remove_file(&path); + return Err(error); + } + let history_token = format!("nt_{nonce}"); + let command = format!( + "NYATERM_PRUNE_HISTORY={history_token}; . {}\n", + path.display() + ); + return Ok(( + command, + Some(Arc::new(LocalStartupScriptFile { + path, + removed: AtomicBool::new(false), + })), + )); + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not allocate a unique Local shell integration file", + )) +} + +#[cfg(not(unix))] +pub fn prepare_local_startup_injection( + source: String, +) -> std::io::Result<(String, Option>)> { + Ok((source, None)) +} + +impl LocalStartupScript { + fn none() -> Self { + Self { + script: None, + shell_init_args: None, + pwsh_init_args: None, + cmd_prompt: None, + dynamic_title_integration_requested: false, + } + } +} + +/// Build Local-only dynamic-title hooks. `allow_injection` must be false for +/// every non-empty user argv and unresolved wrapper shell. +pub fn build_local_startup_script( + shell_name: &str, + ready_marker: &str, + enabled: bool, + allow_injection: bool, ) -> LocalStartupScript { + if !enabled || !allow_injection { + return LocalStartupScript::none(); + } + + let Some(session_token) = ready_marker_session_token(ready_marker) else { + return LocalStartupScript::none(); + }; + let basename = shell_name + .rsplit(['/', '\\']) + .next() + .unwrap_or(shell_name) + .to_ascii_lowercase(); + let basename = basename.strip_suffix(".exe").unwrap_or(&basename); + + if matches!(basename, "powershell" | "pwsh") { + return LocalStartupScript { + script: None, + shell_init_args: None, + pwsh_init_args: Some(pwsh_prompt_init_args(session_token)), + cmd_prompt: None, + dynamic_title_integration_requested: true, + }; + } + + if cfg!(target_os = "windows") && basename == "cmd" { + return LocalStartupScript { + script: None, + shell_init_args: None, + pwsh_init_args: None, + cmd_prompt: Some(cmd_prompt_value(session_token)), + dynamic_title_integration_requested: true, + }; + } + + if basename == "fish" { + let Some(script) = local_unix_injection_script(ShellKind::Fish, ready_marker) else { + return LocalStartupScript::none(); + }; + return LocalStartupScript { + script: None, + shell_init_args: Some(vec!["--init-command".to_string(), script]), + pwsh_init_args: None, + cmd_prompt: None, + dynamic_title_integration_requested: true, + }; + } + + let shell = match basename { + "bash" => ShellKind::Bash, + "zsh" => ShellKind::Zsh, + _ => return LocalStartupScript::none(), + }; + let Some(script) = local_unix_injection_script(shell, ready_marker) else { + return LocalStartupScript::none(); + }; + LocalStartupScript { - script: None, - shell_integration_active: false, + script: Some(script), + shell_init_args: None, + pwsh_init_args: None, + cmd_prompt: None, + dynamic_title_integration_requested: true, } } -fn should_emit_visible_output(suppress_visible: &mut bool, ready: bool) -> bool { - if !*suppress_visible { - return true; - } - - if !ready { +/// Wrapper executables hide or delegate to another shell whose startup +/// language/argv cannot be safely inferred. Keep them passive for this release. +fn should_allow_local_injection( + configured_shell_path: &str, + resolved_shell_name: &str, + shell_args: &str, + resolved_shell_args_empty: bool, + resolution_source: ShellResolutionSource, +) -> bool { + if !shell_args.is_empty() || is_unresolved_shell_wrapper(resolved_shell_name) { return false; } - *suppress_visible = false; + // `wt.exe` is a configuration alias, not the process we spawn. Only a + // successfully resolved profile is eligible; the compatibility PowerShell + // fallback must remain passive because it is not the configured profile. + if is_windows_terminal_alias(configured_shell_path.trim()) { + return resolution_source == ShellResolutionSource::WindowsTerminalProfile + && resolved_shell_args_empty; + } + + should_treat_as_literal_program(configured_shell_path) +} + +pub fn is_unresolved_shell_wrapper(shell_path: &str) -> bool { + let basename = shell_path + .trim() + .trim_matches('"') + .rsplit(['/', '\\']) + .next() + .unwrap_or(shell_path) + .to_ascii_lowercase(); + matches!(basename.as_str(), "wsl" | "wsl.exe" | "wt" | "wt.exe") +} + +fn ready_marker_session_token(ready_marker: &str) -> Option<&str> { + ready_marker + .strip_prefix("\x1b]7777;NyaTermReady:")? + .strip_suffix('\x07') + .or_else(|| { + ready_marker + .strip_prefix("\x1b]7777;NyaTermReady:")? + .strip_suffix("\x1b\\") + }) +} + +fn local_unix_injection_script(shell: ShellKind, ready_marker: &str) -> Option { + let ready_osc = ready_marker + .replace('\x1b', "\\033") + .replace('\x07', "\\007"); + let ready_payload = ready_marker + .strip_prefix("\x1b]")? + .strip_suffix('\x07')?; + let session_id = ready_payload.strip_prefix("7777;NyaTermReady:")?; + let ready_failed_payload = format!("7777;NyaTermReadyFailed:{session_id}"); + + match shell { + ShellKind::Bash => Some(format!( + concat!( + " NYATERM_PRUNE_HISTORY=\"${{NYATERM_PRUNE_HISTORY:-1}}\";", + " __nyaterm_local_prune_history(){{", + " [ -n \"${{NYATERM_PRUNE_HISTORY:-}}\" ] || return 0;", + " local history_marker=\"$NYATERM_PRUNE_HISTORY\"; unset NYATERM_PRUNE_HISTORY;", + " local hline history_number; hline=\"$(HISTTIMEFORMAT= history 1 2>/dev/null || true)\";", + " case \"$hline\" in *\"NYATERM_PRUNE_HISTORY=$history_marker\"*)", + " history_number=${{hline#\"${{hline%%[![:space:]]*}}\"}}; history_number=${{history_number%%[!0-9]*}};", + " [ -z \"$history_number\" ] || history -d \"$history_number\" 2>/dev/null || true;; esac;", + " }};", + " __nyaterm_local_uri_encode(){{", + " local LC_ALL=C input=\"$1\" output= ch hex code; local -i i;", + " for ((i=0; i<${{#input}}; i++)); do ch=${{input:i:1}};", + " case \"$ch\" in [-A-Za-z0-9._~/]) output+=\"$ch\";; *) printf -v code '%d' \"'$ch\"; printf -v hex '%%%02X' \"$((code & 255))\"; output+=\"$hex\";; esac;", + " done; printf '%s' \"$output\";", + " }};", + " __nyaterm_local_dynamic_prompt(){{", + " local status=$?; __nyaterm_local_prune_history; local encoded; encoded=\"$(__nyaterm_local_uri_encode \"$PWD\")\";", + " printf '\\033]2;\\033\\\\';", + " printf '\\033]7;file://localhost%s\\033\\\\' \"$encoded\";", + " return \"$status\";", + " }};", + " __nyaterm_local_ready_failed(){{ [ -n \"${{__nyaterm_local_failure_reported:-}}\" ] || {{ __nyaterm_local_failure_reported=1; printf '\\033]{}\\007'; }}; }};", + " __nyaterm_local_restore_status(){{ return \"$1\"; }};", + " __nyaterm_local_prompt_guard(){{ return $?; }};", + " __nyaterm_local_prompt_container_valid(){{ local current=\"${{PROMPT_COMMAND[*]-}}\"; case \"$current\" in *__nyaterm_local_dynamic_prompt*__nyaterm_local_ready_prompt*) return 0;; *) return 1;; esac; }};", + " __nyaterm_local_prompt_liveness(){{ local status=$?; if __nyaterm_local_prompt_container_valid; then printf '\\033]{}\\007'; else __nyaterm_local_ready_failed; fi; return \"$status\"; }};", + " __nyaterm_local_prompt_state_writable(){{ local name decl; for name in __nyaterm_local_saved_prompt_command __nyaterm_local_extra_prompt_commands __nyaterm_local_exported_prompt_fallback; do decl=\"$(declare -p \"$name\" 2>/dev/null || true)\"; [[ ! \"$decl\" =~ ^declare\\ -[^[:space:]]*r ]] || return 1; done; }};", + " if __nyaterm_local_prompt_state_writable; then __nyaterm_local_extra_prompt_commands=(); fi;", + " __nyaterm_local_run_saved_prompt_command(){{", + " local status=$? command;", + " if [ -n \"${{__nyaterm_local_saved_prompt_command-}}\" ]; then __nyaterm_local_restore_status \"$status\"; builtin eval -- \"$__nyaterm_local_saved_prompt_command\"; status=$?; fi;", + " for command in \"${{__nyaterm_local_extra_prompt_commands[@]}}\"; do __nyaterm_local_restore_status \"$status\"; builtin eval -- \"$command\"; status=$?; done;", + " return \"$status\";", + " }};", + " __nyaterm_local_rebuild_exported_prompt_fallback(){{ local command result=\"${{__nyaterm_local_saved_prompt_command-}}\"; for command in \"${{__nyaterm_local_extra_prompt_commands[@]}}\"; do if [ -n \"$result\" ]; then result=\"$result; $command\"; else result=\"$command\"; fi; done; __nyaterm_local_exported_prompt_fallback=\"$result\"; }};", + " __nyaterm_local_capture_prompt_string(){{", + " local current=\"$1\" expected=\"$2\" tail; [ \"$current\" = \"$expected\" ] && return 0;", + " case \"$current\" in \"$expected\"\\;*|\"$expected\"\\&*|\"$expected \"*|\"$expected\"$'\\t'*|\"$expected\"$'\\n'*)", + " tail=${{current#\"$expected\"}}; while :; do tail=${{tail#\"${{tail%%[![:space:]]*}}\"}}; case \"$tail\" in \\;*|\\&*) tail=${{tail#?}};; *) break;; esac; done;", + " [ -z \"$tail\" ] || __nyaterm_local_extra_prompt_commands[${{#__nyaterm_local_extra_prompt_commands[@]}}]=\"$tail\";;", + " *\"$expected\"*) return 1;;", + " *) __nyaterm_local_saved_prompt_command=\"$current\"; __nyaterm_local_extra_prompt_commands=();; esac; __nyaterm_local_rebuild_exported_prompt_fallback; return 0;", + " }};", + " __nyaterm_local_array_prompt_supported(){{ [ \"${{BASH_VERSINFO[0]:-0}}\" -gt 5 ] || {{ [ \"${{BASH_VERSINFO[0]:-0}}\" -eq 5 ] && [ \"${{BASH_VERSINFO[1]:-0}}\" -ge 1 ]; }}; }};", + " __nyaterm_local_repair_prompt(){{", + " local decl f current expected canonical exported=0;", + " canonical='__nyaterm_local_dynamic_prompt; __nyaterm_local_run_saved_prompt_command; __nyaterm_local_ready_prompt; __nyaterm_local_prompt_guard'; expected=\"$canonical\";", + " decl=\"$(declare -p PROMPT_COMMAND 2>/dev/null || true)\";", + " __nyaterm_local_prompt_state_writable || return 1;", + " [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*x ]] && exported=1;", + " [[ ! \"$decl\" =~ ^declare\\ -[^[:space:]]*r ]] || return 1;", + " if [ \"$exported\" -eq 1 ]; then expected='if declare -F __nyaterm_local_dynamic_prompt >/dev/null 2>&1; then __nyaterm_local_dynamic_prompt; __nyaterm_local_run_saved_prompt_command; __nyaterm_local_ready_prompt; __nyaterm_local_prompt_guard; else eval -- \"${{__nyaterm_local_exported_prompt_fallback-}}\"; fi'; fi;", + " if [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*a[^[:space:]]*\\ PROMPT_COMMAND= ]] && __nyaterm_local_array_prompt_supported; then", + " local -a retained=(); for f in \"${{PROMPT_COMMAND[@]}}\"; do case \"$f\" in __nyaterm_local_dynamic_prompt|__nyaterm_local_ready_prompt) ;; *) retained+=(\"$f\");; esac; done;", + " PROMPT_COMMAND=(__nyaterm_local_dynamic_prompt \"${{retained[@]}}\" __nyaterm_local_ready_prompt) || return 1;", + " else current=${{PROMPT_COMMAND-}}; if [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*a[^[:space:]]*\\ PROMPT_COMMAND= ]]; then unset PROMPT_COMMAND; fi;", + " if ! __nyaterm_local_capture_prompt_string \"$current\" \"$expected\"; then PROMPT_COMMAND=\"$expected\"; return 1; fi; PROMPT_COMMAND=\"$expected\" || return 1; if [ \"$exported\" -eq 1 ]; then export __nyaterm_local_exported_prompt_fallback; else unset __nyaterm_local_exported_prompt_fallback; fi; fi;", + " return 0;", + " }};", + " __nyaterm_local_ready_prompt(){{", + " local status=$?; local decl marker='\\[$(__nyaterm_local_prompt_liveness)\\]';", + " if ! __nyaterm_local_repair_prompt; then __nyaterm_local_ready_failed; return \"$status\"; fi;", + " decl=\"$(declare -p PS1 2>/dev/null || true)\";", + " if [[ \"$decl\" =~ ^declare\\ -[^[:space:]]*r ]]; then __nyaterm_local_ready_failed; return \"$status\"; fi;", + " case \"$PS1\" in *\"$marker\"*) ;; *) PS1=\"${{PS1}}${{marker}}\" || __nyaterm_local_ready_failed;; esac;", + " return \"$status\";", + " }};", + " __nyaterm_local_install_prompt(){{ __nyaterm_local_repair_prompt; }};", + " __nyaterm_local_prompt_markable(){{ local decl; decl=\"$(declare -p PS1 2>/dev/null || true)\"; [[ ! \"$decl\" =~ ^declare\\ -[^[:space:]]*r ]] || return 1; PS1=$PS1; }};", + // The sourced startup command is already in interactive Bash + // history now. Prune it before any readonly/failing hook path. + " __nyaterm_local_prune_history;", + " if __nyaterm_local_prompt_markable && __nyaterm_local_install_prompt; then :; else __nyaterm_local_ready_failed; fi;\n", + ), + ready_failed_payload, + ready_payload, + )), + ShellKind::Zsh => Some(format!( + concat!( + " unset NYATERM_PRUNE_HISTORY 2>/dev/null; fc -p /dev/null 2>/dev/null\n", + " __nyaterm_local_uri_encode(){{", + " emulate -L zsh; unsetopt multibyte; local input=\"$1\" output='' ch hex; local -i i;", + " for ((i=1; i<=${{#input}}; i++)); do ch=${{input[i]}};", + " case \"$ch\" in ([-A-Za-z0-9._~/]) output+=\"$ch\";; (*) printf -v hex '%%%02X' \"'$ch\"; output+=\"$hex\";; esac;", + " done; print -rn -- \"$output\";", + " }};", + " __nyaterm_local_dynamic_emit(){{", + " local saved_status=$?; local encoded=\"$(__nyaterm_local_uri_encode \"$PWD\")\";", + " printf '\\033]2;\\033\\\\';", + " printf '\\033]7;file://localhost%s\\033\\\\' \"$encoded\";", + " return \"$saved_status\";", + " }};", + " __nyaterm_local_ready_failed(){{ [ -n \"${{__nyaterm_local_failure_reported:-}}\" ] || {{ __nyaterm_local_failure_reported=1; printf '\\033]{}\\007'; }}; }};", + " __nyaterm_local_repair_prompt(){{", + " [[ ${{parameters[precmd_functions]-}} == *readonly* ]] && return 1;", + " typeset -ga precmd_functions || return 1;", + " local -a retained=(); local f; for f in \"${{precmd_functions[@]}}\"; do case \"$f\" in (__nyaterm_local_dynamic_emit|__nyaterm_local_ready_prompt) ;; (*) retained+=(\"$f\");; esac; done;", + " precmd_functions=(__nyaterm_local_dynamic_emit \"${{retained[@]}}\" __nyaterm_local_ready_prompt) || return 1;", + " return 0;", + " }};", + " __nyaterm_local_ready_prompt(){{", + " local saved_status=$?; local marker=$'%{{\\e]{}\\a%}}';", + " if ! __nyaterm_local_repair_prompt; then __nyaterm_local_ready_failed; return \"$saved_status\"; fi;", + " if [[ ${{parameters[PS1]-}} == *readonly* ]]; then __nyaterm_local_ready_failed; return \"$saved_status\"; fi;", + " if [[ \"$PS1\" != *\"$marker\"* ]]; then PS1=\"${{PS1}}${{marker}}\" || __nyaterm_local_ready_failed; fi;", + " return \"$saved_status\";", + " }};", + " __nyaterm_local_install_prompt(){{ __nyaterm_local_repair_prompt; }};", + " __nyaterm_local_prompt_markable(){{ [[ ${{parameters[PS1]-}} != *readonly* ]] || return 1; PS1=$PS1; }};", + " fc -P 2>/dev/null\n if __nyaterm_local_prompt_markable && __nyaterm_local_install_prompt; then :; else __nyaterm_local_ready_failed; fi;\n", + ), + ready_failed_payload, + ready_payload, + )), + ShellKind::Fish => Some(format!( + concat!( + " functions -e __nyaterm_local_report_cwd 2>/dev/null;", + " functions -e __nyaterm_local_dynamic_postexec 2>/dev/null;", + " function __nyaterm_local_report_cwd;", + " set -l encoded (string escape --style=url -- $PWD);", + " printf '\\033]7;file://localhost%s\\033\\\\' $encoded;", + " end;", + // fish_title/user prompt titles run after fish_postexec, so + // reset the previous application title before theme output. + " function __nyaterm_local_dynamic_postexec --on-event fish_postexec;", + " set -l saved_status $status;", + " printf '\\033]2;\\033\\\\';", + " __nyaterm_local_report_cwd;", + " return $saved_status;", + " end;", + " printf '{}'; __nyaterm_local_report_cwd;\n", + ), + ready_osc, + )), + ShellKind::PosixSh | ShellKind::Unknown => None, + } +} + +fn build_cmd_prompt(existing: Option<&str>, session_token: &str) -> String { + let original = existing.filter(|value| !value.is_empty()).unwrap_or("$P$G"); + format!( + "$E]2;$E\\$E]7777;NyaTermReady:{session_token}$E\\$E]7;nyaterm-cmd://{session_token}/$P$E\\{original}" + ) +} + +fn cmd_prompt_value(session_token: &str) -> String { + let existing = std::env::var("PROMPT").ok(); + build_cmd_prompt(existing.as_deref(), session_token) +} + +/// Install a wrapper without touching the user's profile. Metadata is emitted +/// before the original prompt, then the incoming `$?`/`$LASTEXITCODE` state is +/// restored so prompt themes keep both status and deliberate OSC-title behavior. +fn pwsh_prompt_init_args(session_token: &str) -> Vec { + let init = format!( + concat!( + "$__nt_existing_wrapper = $global:__nyaterm_local_prompt_wrapper; $__nt_current_prompt = $function:prompt; if ($__nt_existing_wrapper -and [object]::ReferenceEquals($__nt_current_prompt, $__nt_existing_wrapper)) {{ $__nt_current_prompt = $global:__nyaterm_local_prev_prompt }}; $global:__nyaterm_local_prev_prompt = $__nt_current_prompt; ", + "$global:__nyaterm_local_failure_reported = $false; ", + "$global:__nyaterm_local_report = {{ ", + "$__nt_location = $executionContext.SessionState.Path.CurrentLocation; $__nt_esc = [char]27; $__nt_st = $__nt_esc + '\\'; ", + "[Console]::Out.Write($__nt_esc + ']2;' + $__nt_st); [Console]::Out.Write($__nt_esc + ']7777;NyaTermReady:{0}' + $__nt_st); ", + "if ($__nt_location.Provider.Name -eq 'FileSystem') {{ try {{ $__nt_provider_path = $__nt_location.ProviderPath; ", + "if ([IO.Path]::DirectorySeparatorChar -eq '\\') {{ $__nt_uri = ([Uri]::new($__nt_provider_path)).AbsoluteUri }} else {{ $__nt_builder = [UriBuilder]::new('file','localhost'); $__nt_builder.Path = $__nt_provider_path.Replace('%','%25'); $__nt_uri = $__nt_builder.Uri.AbsoluteUri }}; ", + "[Console]::Out.Write($__nt_esc + ']7;' + $__nt_uri + $__nt_st) }} catch {{ [Console]::Out.Write($__nt_esc + ']7;nyaterm-clear://{0}' + $__nt_st) }} }} else {{ [Console]::Out.Write($__nt_esc + ']7;nyaterm-clear://{0}' + $__nt_st) }} ", + "}}; ", + "$global:__nyaterm_local_ready_failed = {{ if (-not $global:__nyaterm_local_failure_reported) {{ $global:__nyaterm_local_failure_reported = $true; $__nt_esc = [char]27; [Console]::Out.Write($__nt_esc + ']7777;NyaTermReadyFailed:{0}' + $__nt_esc + '\\') }} }}; ", + "$global:__nyaterm_local_prompt_wrapper = {{ ", + "$__nt_success = $?; $__nt_had_last_exit = Test-Path variable:global:LASTEXITCODE; $__nt_last_exit = if ($__nt_had_last_exit) {{ $global:LASTEXITCODE }} else {{ $null }}; & $global:__nyaterm_local_report; ", + "if ($__nt_had_last_exit) {{ $global:LASTEXITCODE = $__nt_last_exit }} else {{ Remove-Variable LASTEXITCODE -Scope Global -ErrorAction Ignore }}; if ($__nt_success) {{ & {{}} }} else {{ Write-Error 'NyaTerm prompt status restore' -ErrorAction Ignore }}; ", + "$__nt_result = $null; try {{ if ($global:__nyaterm_local_prev_prompt) {{ if ($__nt_success) {{ & {{}} }} else {{ Write-Error 'NyaTerm prompt status restore' -ErrorAction Ignore }}; $__nt_result = & $global:__nyaterm_local_prev_prompt }} else {{ $__nt_result = 'PS ' + $executionContext.SessionState.Path.CurrentLocation.Path + ('>' * ($nestedPromptLevel + 1)) + ' ' }} }} finally {{ ", + "$__nt_current_prompt = $function:prompt; if (-not [object]::ReferenceEquals($__nt_current_prompt, $global:__nyaterm_local_prompt_wrapper)) {{ $global:__nyaterm_local_prev_prompt = $__nt_current_prompt; try {{ Set-Item Function:\\global:prompt -Value $global:__nyaterm_local_prompt_wrapper -Force -ErrorAction Stop }} catch {{ & $global:__nyaterm_local_ready_failed }} }} }}; $__nt_result ", + "}}; ", + "try {{ Set-Item Function:\\global:prompt -Value $global:__nyaterm_local_prompt_wrapper -Force -ErrorAction Stop }} catch {{ & $global:__nyaterm_local_ready_failed }}; ", + "try {{ if ($global:__nyaterm_local_prompt_guard) {{ $__nt_old_guard_id = $global:__nyaterm_local_prompt_guard.Id; foreach ($__nt_subscriber in @(Microsoft.PowerShell.Utility\\Get-EventSubscriber -SourceIdentifier PowerShell.OnIdle -ErrorAction Ignore)) {{ if ([object]::ReferenceEquals($__nt_subscriber.Action, $global:__nyaterm_local_prompt_guard)) {{ Microsoft.PowerShell.Utility\\Unregister-Event -SubscriptionId $__nt_subscriber.SubscriptionId -Force -ErrorAction Ignore; break }} }}; Microsoft.PowerShell.Core\\Remove-Job -Id $__nt_old_guard_id -Force -ErrorAction Ignore; $global:__nyaterm_local_prompt_guard = $null }}; $global:__nyaterm_local_prompt_guard = Microsoft.PowerShell.Utility\\Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -Action {{ $__nt_current_prompt = $function:prompt; if (-not [object]::ReferenceEquals($__nt_current_prompt, $global:__nyaterm_local_prompt_wrapper)) {{ $global:__nyaterm_local_prev_prompt = $__nt_current_prompt; try {{ Set-Item Function:\\global:prompt -Value $global:__nyaterm_local_prompt_wrapper -Force -ErrorAction Stop; & $global:__nyaterm_local_report }} catch {{ & $global:__nyaterm_local_ready_failed }} }} }} }} catch {{ & $global:__nyaterm_local_ready_failed }}" + ), + session_token + ); + + vec![ + "-NoExit".to_string(), + "-Command".to_string(), + init, + ] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartupInjectionDecision { + Wait, + Inject, + Timeout, +} + +pub struct StartupInjectionSchedule { + started_at: Instant, + last_activity: Option, + activity_not_before: Option, + idle: Duration, + timeout: Duration, +} + +impl StartupInjectionSchedule { + pub fn new(started_at: Instant, idle: Duration, timeout: Duration) -> Self { + Self { + started_at, + last_activity: None, + activity_not_before: None, + idle, + timeout, + } + } + + pub fn note_activity(&mut self, at: Instant) { + if self + .activity_not_before + .is_some_and(|not_before| at <= not_before) + { + return; + } + self.activity_not_before = None; + self.last_activity = Some(at); + } + + /// A terminal response has reached the PTY writer. Require subsequent + /// shell output (normally the completed startup/prompt) before injection; + /// queue drain alone does not prove the shell consumed the response. + pub fn require_output_after_terminal_response(&mut self, written_at: Instant) { + self.last_activity = None; + self.activity_not_before = Some(written_at); + } + + pub fn decision(&self, now: Instant) -> StartupInjectionDecision { + if now.saturating_duration_since(self.started_at) >= self.timeout { + return StartupInjectionDecision::Timeout; + } + if self + .last_activity + .is_some_and(|last| now.saturating_duration_since(last) >= self.idle) + { + return StartupInjectionDecision::Inject; + } + StartupInjectionDecision::Wait + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum StartupGateOutput { + Buffered(String), + Ready(String), + SuppressedOverflow(String), + Pass(String), +} + +const MAX_STARTUP_PASSTHROUGH_SEQUENCE_BYTES: usize = 16 * 1024 + 16; + +pub(crate) fn split_startup_passthrough(value: &str) -> (String, String) { + let mut buffered = String::with_capacity(value.len()); + let mut passthrough = String::new(); + let mut cursor = 0; + + while cursor < value.len() { + let Some((start, kind, opener_len)) = find_control_sequence_start(value, cursor) else { + buffered.push_str(&value[cursor..]); + break; + }; + buffered.push_str(&value[cursor..start]); + + let Some((content_end, terminator_len)) = + find_control_sequence_end(value, start, kind, opener_len) + else { + buffered.push_str(&value[start..]); + break; + }; + let end = content_end + terminator_len; + let sequence = &value[start..end]; + let is_passthrough = sequence.len() <= MAX_STARTUP_PASSTHROUGH_SEQUENCE_BYTES + && match kind { + ControlSequenceKind::Csi => is_csi_passthrough(sequence.as_bytes()), + ControlSequenceKind::Osc => control_sequence_payload( + value, + start, + opener_len, + content_end, + terminator_len, + ) + .is_some_and(|payload| { + let payload = payload.as_bytes(); + is_osc_query(payload) || matches!(osc_selector(payload), Some(0 | 2)) + }), + ControlSequenceKind::Dcs => control_sequence_payload( + value, + start, + opener_len, + content_end, + terminator_len, + ) + .is_some_and(|payload| { + let payload = payload.as_bytes(); + payload.starts_with(b"$q") || payload.starts_with(b"+q") + }), + }; + if is_passthrough { + passthrough.push_str(sequence); + } else { + buffered.push_str(sequence); + } + cursor = end; + } + + (buffered, passthrough) +} + +fn osc_selector(payload: &[u8]) -> Option { + let selector = payload.split(|byte| *byte == b';').next()?; + if selector.is_empty() || !selector.iter().all(u8::is_ascii_digit) { + return None; + } + selector.iter().try_fold(0_u32, |value, digit| { + value.checked_mul(10)?.checked_add(u32::from(digit - b'0')) + }) +} + +fn is_osc_query(payload: &[u8]) -> bool { + if !payload.ends_with(b"?") { + return false; + } + matches!( + osc_selector(payload), + Some(4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 52) + ) +} + +fn csi_query_number(sequence: &[u8]) -> Option<&[u8]> { + let body = sequence.get(2..sequence.len().saturating_sub(1))?; + (sequence.last().copied() == Some(b't')) + .then(|| body.split(|byte| *byte == b';').next().unwrap_or_default()) +} + +fn is_csi_passthrough(sequence: &[u8]) -> bool { + let body = sequence.get(2..sequence.len().saturating_sub(1)).unwrap_or_default(); + match sequence.last().copied() { + Some(b'c' | b'n') => true, + Some(b't') => matches!( + body.split(|byte| *byte == b';').next().unwrap_or_default(), + b"11" | b"13" | b"14" | b"15" | b"16" | b"18" | b"19" | b"20" | b"21" + ), + Some(b'u') => body.starts_with(b"?"), + Some(b'p') => body.contains(&b'$'), + Some(b'q') => body.starts_with(b">") || body.contains(&b'$'), + _ => false, + } +} + +/// CSI queries that xterm.js actually answers with the current secure window +/// options. CSI 20t/21t are still passed through, but title replies are +/// deliberately disabled and therefore must not hold the startup barrier. +fn is_csi_startup_query(sequence: &[u8]) -> bool { + if csi_query_number(sequence).is_some_and(|number| number == b"20" || number == b"21") { + return false; + } + is_csi_passthrough(sequence) +} + +pub struct TerminalQueryDetector { + pending: String, +} + +impl TerminalQueryDetector { + pub fn new() -> Self { + Self { + pending: String::new(), + } + } + + pub fn push(&mut self, chunk: &str) -> usize { + self.pending.push_str(chunk); + let value = std::mem::take(&mut self.pending); + let mut cursor = 0; + let mut count = 0; + + while cursor < value.len() { + let Some((start, kind, opener_len)) = find_control_sequence_start(&value, cursor) + else { + break; + }; + let Some((content_end, terminator_len)) = + find_control_sequence_end(&value, start, kind, opener_len) + else { + self.pending.push_str(&value[start..]); + break; + }; + let is_query = match kind { + ControlSequenceKind::Csi => { + is_csi_startup_query(&value.as_bytes()[start..content_end]) + } + ControlSequenceKind::Osc => control_sequence_payload( + &value, + start, + opener_len, + content_end, + terminator_len, + ) + .is_some_and(|payload| is_osc_query(payload.as_bytes())), + ControlSequenceKind::Dcs => control_sequence_payload( + &value, + start, + opener_len, + content_end, + terminator_len, + ) + .is_some_and(|payload| { + let payload = payload.as_bytes(); + payload.starts_with(b"$q") || payload.starts_with(b"+q") + }), + }; + count += usize::from(is_query); + cursor = content_end + terminator_len; + } + + if self.pending.len() > 4096 { + self.pending.clear(); + } + count + } + + pub fn has_pending_sequence(&self) -> bool { + !self.pending.is_empty() + } +} + +#[derive(Debug)] +struct StartupGateState { + active: bool, + buffer: String, +} + +/// Bounded gate used only while hiding a Unix injected command echo. +pub struct StartupOutputGate { + state: StdMutex, +} + +pub fn lock_or_recover<'a, T>( + mutex: &'a StdMutex, + label: &'static str, +) -> std::sync::MutexGuard<'a, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => { + tracing::warn!(lock = label, "Recovering poisoned Local terminal mutex"); + poisoned.into_inner() + } + } +} + +impl StartupOutputGate { + #[cfg(test)] + pub fn new() -> Self { + Self::with_active(true) + } + + pub fn new_inactive() -> Self { + Self::with_active(false) + } + + fn with_active(active: bool) -> Self { + Self { + state: StdMutex::new(StartupGateState { + active, + buffer: String::new(), + }), + } + } + + pub fn activate(&self) { + let mut state = lock_or_recover(&self.state, "startup_output_gate"); + state.buffer.clear(); + state.active = true; + } + + pub fn is_active(&self) -> bool { + lock_or_recover(&self.state, "startup_output_gate").active + } + + pub fn consume( + &self, + visible: String, + _visible_after_ready: String, + ready: bool, + ) -> StartupGateOutput { + let mut state = lock_or_recover(&self.state, "startup_output_gate"); + if !state.active { + return StartupGateOutput::Pass(visible); + } + + state.buffer.push_str(&visible); + let (buffered, passthrough) = split_startup_passthrough(&state.buffer); + state.buffer = buffered; + + if ready { + state.active = false; + state.buffer.clear(); + // Startup output is visible while this gate is inactive. Once the + // short source command is written, suppress every non-query byte + // through the session-bound marker: Zsh can redraw arbitrary + // command tails/RPROMPT fragments that are not safely separable + // from a genuine late banner. The first rendered prompt remains. + return StartupGateOutput::Ready(passthrough); + } + if state.buffer.len() > MAX_STARTUP_OUTPUT_BUFFER { + // PTY output is ordered: after more than 64 KiB, the short source + // command echo is already inside the discarded prefix. End the + // gate immediately so passive shell output cannot remain hidden + // until the watchdog fires. + state.active = false; + state.buffer.clear(); + return StartupGateOutput::SuppressedOverflow(passthrough); + } + StartupGateOutput::Buffered(passthrough) + } + + pub fn discard(&self) -> bool { + let mut state = lock_or_recover(&self.state, "startup_output_gate"); + if !state.active { + return false; + } + state.active = false; + state.buffer.clear(); + true + } + +} + +#[cfg(unix)] +pub fn local_shell_line_editor_ready( + master: &dyn portable_pty::MasterPty, + shell_process_id: Option, +) -> bool { + let (Some(fd), Some(shell_process_id)) = (master.as_raw_fd(), shell_process_id) else { + return false; + }; + if unsafe { libc::tcgetpgrp(fd) } != shell_process_id as libc::pid_t { + return false; + } + + let mut attributes = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(fd, attributes.as_mut_ptr()) } != 0 { + return false; + } + let local_flags = unsafe { attributes.assume_init() }.c_lflag; + local_flags & libc::ICANON == 0 && local_flags & libc::ECHO == 0 +} + +#[cfg(not(unix))] +pub fn local_shell_line_editor_ready( + _master: &dyn portable_pty::MasterPty, + _shell_process_id: Option, +) -> bool { true } -fn write_to_pty(writer: &mut dyn Write, data: &[u8]) -> std::io::Result<()> { +pub fn write_to_pty(writer: &mut dyn Write, data: &[u8]) -> std::io::Result<()> { writer.write_all(data)?; writer.flush() } - diff --git a/src-tauri/src/core/terminal_session/local/tests.rs b/src-tauri/src/core/terminal_session/local/tests.rs index 3fb755cb..04a3d3c1 100644 --- a/src-tauri/src/core/terminal_session/local/tests.rs +++ b/src-tauri/src/core/terminal_session/local/tests.rs @@ -1,16 +1,26 @@ #[cfg(test)] mod tests { #[cfg(any(target_os = "linux", target_os = "macos"))] - use super::configure_local_pty_environment; + use super::{configure_local_pty_environment, local_shell_line_editor_ready}; + #[cfg(unix)] + use super::prepare_local_startup_injection; #[cfg(target_os = "macos")] use super::is_utf8_locale; use super::{ - LocalSessionConfig, WorkingDirectoryOutcome, apply_working_dir_to_command, - build_local_startup_script_for_platform, parse_shell_args, resolve_shell_command, - should_emit_visible_output, validate_working_dir_before_spawn, + InputOrigin, LocalSessionConfig, LocalStartupPipeline, OscStripper, ShellResolutionSource, + StartupGateOutput, StartupInjectionAttempt, StartupInjectionDecision, StartupInjectionSchedule, + StartupInputBarrier, StartupOutputGate, StdMutex, TerminalQueryDetector, WorkingDirectoryOutcome, ZmodemDetectResult, ZmodemDetector, + ZmodemDirection, apply_working_dir_to_command, build_cmd_prompt, + build_local_startup_script, cancel_startup_for_pending_zmodem, detect_local_zmodem, + drain_startup_pipeline_on_cancel, input_cancels_startup_injection, + is_unresolved_shell_wrapper, lock_or_recover, managed_cwd_runtime_ready, + parse_shell_args, reconcile_managed_cwd_payloads, resolve_shell_command, + should_allow_local_injection, + validate_working_dir_before_spawn, }; use crate::core::ssh::osc::build_ready_marker; use portable_pty::CommandBuilder; + use std::sync::Arc; fn ready_marker() -> String { build_ready_marker("session-1") @@ -127,6 +137,63 @@ mod tests { spec.program ); assert_eq!(spec.args, vec!["-NoLogo", "-NoExit"]); + assert_eq!( + spec.resolution_source, + ShellResolutionSource::WindowsTerminalProfile + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn windows_terminal_fallback_is_marked_as_passive() { + let spec = super::fallback_windows_terminal_shell(Vec::new()); + + assert_eq!( + spec.resolution_source, + ShellResolutionSource::WindowsTerminalFallback + ); + assert!(!should_allow_local_injection( + "wt.exe", + &spec.program, + "", + spec.args.is_empty(), + spec.resolution_source, + )); + } + + #[test] + #[cfg(target_os = "windows")] + fn windows_terminal_dynamic_powershell_core_profile_resolves_pwsh() { + let profile = serde_json::json!({ + "guid": "{574e775e-4f2a-5b96-ac1e-a2962a402336}", + "name": "PowerShell", + "source": "Windows.Terminal.PowershellCore" + }); + + let commandline = super::windows_terminal_profile_commandline(&profile) + .expect("PowerShell Core commandline"); + assert_eq!(commandline, "pwsh.exe"); + let spec = super::shell_spec_from_windows_commandline(&commandline, Vec::new()) + .expect("PowerShell Core shell spec"); + assert!( + spec.program.to_ascii_lowercase().ends_with("pwsh.exe"), + "PowerShell Core profile resolved to {}", + spec.program + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn windows_terminal_dynamic_wsl_source_stays_passive() { + let profile = serde_json::json!({ + "name": "PowerShell-looking Linux", + "source": "Windows.Terminal.Wsl" + }); + + assert_eq!( + super::windows_terminal_profile_commandline(&profile).as_deref(), + Some("wsl.exe") + ); } #[test] @@ -235,9 +302,14 @@ mod tests { fail_on_missing_working_dir: true, name: "Local Terminal".to_string(), encoding: "UTF-8".to_string(), + dynamic_tab_title: false, }; - assert!(validate_working_dir_before_spawn(Some(&config)).is_err()); + let shell_spec = resolve_shell_command(&config.shell_path, &config.shell_args) + .expect("shell command spec"); + assert!( + validate_working_dir_before_spawn(Some(&config), &shell_spec).is_err() + ); } #[test] @@ -257,9 +329,14 @@ mod tests { fail_on_missing_working_dir: false, name: "Saved Local".to_string(), encoding: "UTF-8".to_string(), + dynamic_tab_title: false, }; - assert!(validate_working_dir_before_spawn(Some(&config)).is_ok()); + let shell_spec = resolve_shell_command(&config.shell_path, &config.shell_args) + .expect("shell command spec"); + assert!( + validate_working_dir_before_spawn(Some(&config), &shell_spec).is_ok() + ); } #[test] @@ -307,66 +384,1425 @@ mod tests { } #[test] - fn startup_suppression_hides_chunks_until_ready_marker() { - let mut suppress_visible = true; - - assert!(!should_emit_visible_output(&mut suppress_visible, false)); - assert!(suppress_visible); + fn unix_injection_waits_for_initial_output_to_become_idle() { + let start = std::time::Instant::now(); + let mut schedule = StartupInjectionSchedule::new( + start, + std::time::Duration::from_millis(25), + std::time::Duration::from_millis(250), + ); + assert_eq!(schedule.decision(start), StartupInjectionDecision::Wait); + schedule.note_activity(start + std::time::Duration::from_millis(10)); + assert_eq!( + schedule.decision(start + std::time::Duration::from_millis(34)), + StartupInjectionDecision::Wait + ); + assert_eq!( + schedule.decision(start + std::time::Duration::from_millis(35)), + StartupInjectionDecision::Inject + ); } #[test] - fn startup_suppression_emits_visible_text_from_ready_chunk() { - let mut suppress_visible = true; - - assert!(should_emit_visible_output(&mut suppress_visible, true)); - assert!(!suppress_visible); + fn terminal_response_requires_newer_shell_output_before_injection() { + let start = std::time::Instant::now(); + let mut schedule = StartupInjectionSchedule::new( + start, + std::time::Duration::from_millis(25), + std::time::Duration::from_millis(250), + ); + schedule.note_activity(start + std::time::Duration::from_millis(10)); + schedule.require_output_after_terminal_response( + start + std::time::Duration::from_millis(20), + ); + schedule.note_activity(start + std::time::Duration::from_millis(19)); + assert_eq!( + schedule.decision(start + std::time::Duration::from_millis(100)), + StartupInjectionDecision::Wait + ); + schedule.note_activity(start + std::time::Duration::from_millis(21)); + assert_eq!( + schedule.decision(start + std::time::Duration::from_millis(46)), + StartupInjectionDecision::Inject + ); } #[test] - fn startup_suppression_emits_subsequent_chunks_after_ready() { - let mut suppress_visible = true; - - assert!(should_emit_visible_output(&mut suppress_visible, true)); - assert!(should_emit_visible_output(&mut suppress_visible, false)); - assert!(!suppress_visible); + fn unix_injection_timeout_fails_passive_without_output() { + let start = std::time::Instant::now(); + let schedule = StartupInjectionSchedule::new( + start, + std::time::Duration::from_millis(10), + std::time::Duration::from_millis(20), + ); + assert_eq!( + schedule.decision(start + std::time::Duration::from_millis(20)), + StartupInjectionDecision::Timeout + ); } #[test] - fn local_startup_does_not_inject_supported_unix_shells() { - let marker = ready_marker(); + fn terminal_query_detector_handles_split_csi_osc_and_dcs() { + let mut detector = TerminalQueryDetector::new(); + assert_eq!(detector.push("banner\x1b["), 0); + assert_eq!(detector.push("c\x1b]11;?\x1b"), 1); + assert_eq!(detector.push("\\\x1bP+q544e\x1b\\"), 2); + assert_eq!(detector.push("\x1b]2;?\x07\x1b[8;24;80t"), 0); + } - for shell in ["/bin/bash", "/bin/zsh", "/usr/local/bin/fish", "/bin/sh"] { - let startup = build_local_startup_script_for_platform(shell, &marker, true); + #[test] + fn disabled_title_queries_do_not_hold_startup_barrier() { + let mut detector = TerminalQueryDetector::new(); + assert_eq!(detector.push("\x1b[20t\x1b[21t"), 0); + assert!(!detector.has_pending_sequence()); + } - assert!(!startup.shell_integration_active); - assert!( - startup.script.is_none(), - "{shell} should not receive a startup script" - ); + #[test] + fn inactive_startup_gate_passes_terminal_queries_until_injection() { + let gate = StartupOutputGate::new_inactive(); + let query = "\u{1b}[c\u{1b}]11;?\u{1b}\\".to_string(); + assert_eq!( + gate.consume(query.clone(), String::new(), false), + StartupGateOutput::Pass(query) + ); + gate.activate(); + assert_eq!( + gate.consume("injected echo\x1b[".to_string(), String::new(), false), + StartupGateOutput::Buffered(String::new()) + ); + assert_eq!( + gate.consume( + "c\x1b]11;?\x1b\\\x1bP+q544e\x1b\\hidden".to_string(), + String::new(), + false, + ), + StartupGateOutput::Buffered( + "\x1b[c\x1b]11;?\x1b\\\x1bP+q544e\x1b\\".to_string() + ) + ); + } + + #[test] + fn startup_gate_passes_zero_padded_osc_title_selectors() { + let gate = StartupOutputGate::new(); + let title = "\x1b]02;zero padded\x07"; + assert_eq!( + gate.consume(title.to_string(), String::new(), false), + StartupGateOutput::Buffered(title.to_string()) + ); + } + + #[test] + fn startup_gate_passes_c1_osc_titles_without_interpreting_payload() { + let gate = StartupOutputGate::new(); + let title = "\u{009d}2;C1 title\u{009c}"; + assert_eq!( + gate.consume(title.to_string(), String::new(), false), + StartupGateOutput::Buffered(title.to_string()) + ); + } + + #[test] + fn only_terminal_responses_may_cross_the_pre_injection_barrier() { + assert!(!input_cancels_startup_injection( + InputOrigin::TerminalResponse + )); + for origin in [ + InputOrigin::Keyboard, + InputOrigin::QuickCommand, + InputOrigin::StartupCommand, + InputOrigin::PostLogin, + InputOrigin::AiAgent, + InputOrigin::CredentialAutofill, + InputOrigin::OtpAutofill, + InputOrigin::SyncInput, + ] { + assert!(input_cancels_startup_injection(origin), "{origin:?}"); } } #[test] - fn local_startup_does_not_inject_unknown_or_windows_style_shells() { + fn cancelling_before_gate_activation_releases_partial_terminal_query() { let marker = ready_marker(); + let pipeline: Arc> = Arc::new(StdMutex::new(( + OscStripper::new(&marker), + StartupOutputGate::new_inactive(), + ))); + { + let mut locked = pipeline.lock().unwrap(); + let parsed = locked.0.push("\x1b]11;?"); + assert!(parsed.visible.is_empty()); + } + assert_eq!( + drain_startup_pipeline_on_cancel(&pipeline), + "\x1b]11;?" + ); + } - for shell in ["nu", "powershell.exe", r"C:\Windows\System32\cmd.exe"] { - let startup = build_local_startup_script_for_platform(shell, &marker, true); + #[test] + fn managed_cwd_split_before_ready_is_released_with_the_marker() { + let initial = "file://localhost/Users/alice/project".to_string(); + let mut payloads = vec![initial.clone()]; + let mut pending = None; - assert!(!startup.shell_integration_active); - assert!( - startup.script.is_none(), - "{shell} should not receive a startup script" - ); + reconcile_managed_cwd_payloads( + &mut payloads, + &mut pending, + true, + true, + false, + false, + true, + false, + ); + assert!(payloads.is_empty()); + assert_eq!(pending.as_deref(), Some(initial.as_str())); + + reconcile_managed_cwd_payloads( + &mut payloads, + &mut pending, + true, + true, + false, + true, + true, + false, + ); + assert_eq!(payloads, vec![initial]); + assert!(pending.is_none()); + } + + #[test] + fn unmanaged_or_pre_injection_cwd_is_never_staged_as_managed() { + let mut payloads = vec!["file://localhost/untrusted".to_string()]; + let mut pending = None; + reconcile_managed_cwd_payloads( + &mut payloads, + &mut pending, + true, + true, + false, + false, + false, + false, + ); + assert!(payloads.is_empty()); + assert!(pending.is_none()); + } + + #[test] + fn managed_cwd_waits_for_ready_and_stops_after_runtime_failure() { + assert!(!managed_cwd_runtime_ready(true, true, false, false)); + assert!(managed_cwd_runtime_ready(true, true, false, true)); + assert!(managed_cwd_runtime_ready(true, true, true, false)); + assert!(!managed_cwd_runtime_ready(true, false, true, false)); + assert!(managed_cwd_runtime_ready(false, false, false, false)); + } + + #[test] + fn single_zpad_does_not_cancel_an_injection_that_already_won() { + let barrier = Arc::new(StartupInputBarrier::new()); + assert_eq!( + barrier.try_inject(|| Ok::<_, ()>(())).unwrap(), + StartupInjectionAttempt::Injected(()) + ); + assert!(!cancel_startup_for_pending_zmodem(Some(&barrier))); + } + + #[test] + fn zmodem_detection_runs_when_ready_and_header_share_a_chunk() { + let mut detector = ZmodemDetector::new(); + let marker = ready_marker(); + let mut payload = marker.as_bytes().to_vec(); + payload.extend_from_slice(b"**\x18B00payload"); + match detect_local_zmodem(&mut detector, &payload) { + ZmodemDetectResult::Detected { + direction, + passthrough, + initial_bytes, + } => { + assert_eq!(direction, ZmodemDirection::Download); + assert_eq!(passthrough, marker.as_bytes()); + assert_eq!(initial_bytes, b"**\x18B00payload"); + } + ZmodemDetectResult::NoMatch { .. } => panic!("expected ZMODEM detection"), } } #[test] - fn local_startup_does_not_inject_when_unix_prelude_is_disabled() { - let marker = ready_marker(); - let startup = build_local_startup_script_for_platform("/bin/zsh", &marker, false); + fn startup_mutex_recovers_from_poison_without_a_second_panic() { + let mutex = StdMutex::new(1_u8); + let _ = std::panic::catch_unwind(|| { + let _guard = mutex.lock().expect("initial lock"); + panic!("poison test mutex"); + }); + *lock_or_recover(&mutex, "test_startup_mutex") = 2; + assert_eq!(*lock_or_recover(&mutex, "test_startup_mutex"), 2); + } - assert!(!startup.shell_integration_active); + #[test] + fn startup_gate_passes_banners_before_activation_but_never_republishes_redraws() { + let gate = StartupOutputGate::new_inactive(); + assert_eq!( + gate.consume( + "startup banner\r\n[~]> ".to_string(), + String::new(), + false, + ), + StartupGateOutput::Pass("startup banner\r\n[~]> ".to_string()) + ); + + gate.activate(); + assert_eq!( + gate.consume( + concat!( + "NYATERM_PRUNE_HISTORY=nt_test; . /tmp/.nt-test", + "\x1b[?2004l\r\r\n", + "\x1b[1m\x1b[7m%\x1b[27m\x1b[0m", + " . /tmp/.nt-test\r ", + "\x1b]2;\x1b\\[~]> ", + ) + .to_string(), + String::new(), + true, + ), + StartupGateOutput::Ready("\x1b]2;\x1b\\".to_string()) + ); + } + + #[test] + fn startup_gate_keeps_original_prompt_when_no_late_banner_exists() { + let gate = StartupOutputGate::new_inactive(); + gate.activate(); + assert_eq!( + gate.consume( + ". /tmp/.nt-test\r\n\x1b]2;\x1b\\line 1\r\nline 2$ ".to_string(), + String::new(), + true, + ), + StartupGateOutput::Ready("\x1b]2;\x1b\\".to_string()) + ); + } + + #[cfg(unix)] + #[test] + fn unix_startup_source_file_is_private_and_removed() { + use std::os::unix::fs::PermissionsExt; + + let (command, file) = + prepare_local_startup_injection("printf hook".to_string()).expect("temp hook"); + let file = file.expect("Unix temp file"); + assert!(command.starts_with("NYATERM_PRUNE_HISTORY=nt_")); + assert!(command.contains("; . /tmp/.nt-")); + assert_eq!(std::fs::read_to_string(&file.path).unwrap(), "printf hook"); + assert_eq!( + std::fs::metadata(&file.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let path = file.path.clone(); + file.remove(); + assert!(!path.exists()); + } + + #[cfg(unix)] + #[test] + fn native_bash_source_injection_hides_echo_and_multiline_prompt() { + use std::io::{ErrorKind, Read, Write}; + + fn read_available(reader: &mut dyn Read) -> Vec { + let mut output = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(count) => output.extend_from_slice(&chunk[..count]), + Err(error) if error.kind() == ErrorKind::WouldBlock => break, + Err(error) => panic!("PTY read failed: {error}"), + } + } + output + } + + let system = super::native_pty_system(); + let pair = system + .openpty(super::PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("open PTY"); + let mut command = super::CommandBuilder::new("/bin/bash"); + command.args(["--noprofile", "--norc", "-i"]); + command.env("TERM", "xterm-256color"); + let mut child = pair.slave.spawn_command(command).expect("spawn Bash"); + let shell_pid = child.process_id(); + drop(pair.slave); + + let fd = pair.master.as_raw_fd().expect("Unix PTY fd"); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!( + unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + let mut reader = pair.master.try_clone_reader().expect("PTY reader"); + let mut writer = pair.master.take_writer().expect("PTY writer"); + + let prompt_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while !local_shell_line_editor_ready(&*pair.master, shell_pid) { + assert!(std::time::Instant::now() < prompt_deadline, "Bash prompt timeout"); + let _ = read_available(&mut *reader); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = read_available(&mut *reader); + + writer + .write_all( + b"__nyaterm_test_theme(){ __nyaterm_test_theme_status=$?; printf '\\033]2;BASH THEME\\007'; PS1=$'line 1\\nline 2$ '; PROMPT_COMMAND=(__nyaterm_test_theme); }; PROMPT_COMMAND=(__nyaterm_test_theme)\n", + ) + .expect("set theme-rewritten multiline prompt"); + writer.flush().expect("flush prompt command"); + std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = read_available(&mut *reader); + assert!(local_shell_line_editor_ready(&*pair.master, shell_pid)); + + let marker = ready_marker(); + let source = build_local_startup_script("/bin/bash", &marker, true, true) + .script + .expect("Bash source"); + let (source_command, source_file) = + prepare_local_startup_injection(source).expect("prepare source file"); + let source_file = source_file.expect("Unix source file"); + let mut stripper = OscStripper::with_cwd_payloads(&marker, true); + let gate = StartupOutputGate::new_inactive(); + gate.activate(); + writer + .write_all(source_command.as_bytes()) + .expect("inject source command"); + writer.flush().expect("flush source command"); + + let mut published = String::new(); + let mut cwd_payloads = Vec::new(); + let mut ready = false; + let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !ready { + assert!(std::time::Instant::now() < ready_deadline, "ready marker timeout"); + let raw = read_available(&mut *reader); + if raw.is_empty() { + std::thread::sleep(std::time::Duration::from_millis(10)); + continue; + } + let parsed = stripper.push(&String::from_utf8_lossy(&raw)); + ready = parsed.ready; + cwd_payloads.extend(parsed.cwd_payloads.iter().cloned()); + let gated = gate.consume( + parsed.visible, + parsed.visible_after_ready, + parsed.ready || parsed.ready_failed, + ); + match gated { + StartupGateOutput::Buffered(value) + | StartupGateOutput::Ready(value) + | StartupGateOutput::SuppressedOverflow(value) + | StartupGateOutput::Pass(value) => published.push_str(&value), + } + } + + source_file.remove(); + assert!( + published.contains("\x1b]2;BASH THEME\x07"), + "Bash theme title did not cross the startup gate: {published:?}" + ); + assert!(!published.contains("/tmp/.nt-")); + assert!(!published.contains("line 1") && !published.contains("line 2$")); + assert!( + cwd_payloads + .iter() + .any(|payload| payload.starts_with("file://localhost/")), + "Bash hook did not publish its initial managed cwd: {cwd_payloads:?}" + ); + + writer + .write_all(b"false\n") + .expect("set failing command status"); + writer.flush().expect("flush failing command"); + let false_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(3); + while !local_shell_line_editor_ready(&*pair.master, shell_pid) { + assert!( + std::time::Instant::now() < false_deadline, + "Bash false prompt timeout" + ); + let _ = read_available(&mut *reader); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = read_available(&mut *reader); + + writer + .write_all( + b"printf '__NYATERM_THEME_STATUS=%s__\\n' \"$__nyaterm_test_theme_status\"\n", + ) + .expect("query theme status"); + writer.flush().expect("flush theme status query"); + let status_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut status_output = Vec::new(); + while !status_output + .windows(b"__NYATERM_THEME_STATUS=1__".len()) + .any(|window| window == b"__NYATERM_THEME_STATUS=1__") + { + assert!( + std::time::Instant::now() < status_deadline, + "Bash theme did not observe the incoming failure status: {}", + String::from_utf8_lossy(&status_output) + ); + status_output.extend(read_available(&mut *reader)); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + writer.write_all(b"history 5\n").expect("request Bash history"); + writer.flush().expect("flush history command"); + let history_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut history_output = Vec::new(); + while std::time::Instant::now() < history_deadline { + history_output.extend(read_available(&mut *reader)); + if history_output.windows(b"history 5".len()).any(|window| window == b"history 5") + && local_shell_line_editor_ready(&*pair.master, shell_pid) + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let source_history_line = source_command.trim(); + assert!( + !String::from_utf8_lossy(&history_output).contains(source_history_line), + "NyaTerm startup source command leaked into Bash history: {}", + String::from_utf8_lossy(&history_output) + ); + + child.kill().expect("kill Bash"); + let _ = child.wait(); + } + + #[cfg(unix)] + #[test] + fn native_bash_string_prompt_command_edge_cases_still_reach_ready() { + use std::io::Write; + use std::process::{Command, Stdio}; + + for existing in [ + "history -a;", + "jobs &", + ] { + let marker = ready_marker(); + let source = build_local_startup_script("/bin/bash", &marker, true, true) + .script + .expect("Bash source"); + let (source_command, source_file) = + prepare_local_startup_injection(source).expect("prepare source file"); + let source_file = source_file.expect("Unix source file"); + let mut child = Command::new("/bin/bash") + .args(["--noprofile", "--norc", "-i"]) + .env("TERM", "dumb") + .env("HISTFILE", "/dev/null") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn Bash"); + child + .stdin + .as_mut() + .expect("Bash stdin") + .write_all( + format!( + "PS1='term> '; PROMPT_COMMAND='{}'\n{}FUNCNEST=20\nPROMPT_COMMAND+='; printf __NYATERM_MUTATED__'\nPROMPT_COMMAND+='& printf __NYATERM_BACKGROUND__'\nprintf '__PROMPT_VALUE=%s__\\n' \"$PROMPT_COMMAND\"\nfalse\nprintf '__AFTER_STATUS=%s__\\n' \"$?\"\nexit\n", + existing.replace('\'', "'\\''"), + source_command + ) + .as_bytes(), + ) + .expect("write Bash smoke commands"); + let output = child.wait_with_output().expect("wait for Bash"); + source_file.remove(); + + let mut combined = output.stdout; + combined.extend_from_slice(&output.stderr); + assert!( + combined + .windows(b"NyaTermReady:session-1".len()) + .any(|window| window == b"NyaTermReady:session-1"), + "Bash string PROMPT_COMMAND did not reach ready for {existing:?}: {}", + String::from_utf8_lossy(&combined) + ); + assert!( + !combined.windows(b"syntax error".len()).any(|window| window == b"syntax error"), + "Bash string PROMPT_COMMAND became invalid for {existing:?}: {}", + String::from_utf8_lossy(&combined) + ); + assert!( + !combined + .windows(b"maximum function nesting".len()) + .any(|window| window == b"maximum function nesting"), + "Bash wrapper recursively captured itself for {existing:?}: {}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"__PROMPT_VALUE=__nyaterm_local_dynamic_prompt; __nyaterm_local_run_saved_prompt_command; __nyaterm_local_ready_prompt; __nyaterm_local_prompt_guard__".len()) + .any(|window| window == b"__PROMPT_VALUE=__nyaterm_local_dynamic_prompt; __nyaterm_local_run_saved_prompt_command; __nyaterm_local_ready_prompt; __nyaterm_local_prompt_guard__"), + "Bash background mutation was not repaired in the parent shell for {existing:?}: {}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"__AFTER_STATUS=1__".len()) + .any(|window| window == b"__AFTER_STATUS=1__"), + "Bash prompt mutation lost incoming status for {existing:?}: {}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"__NYATERM_MUTATED__".len()) + .filter(|window| *window == b"__NYATERM_MUTATED__") + .count() + >= 2, + "Bash prompt mutation was not retained for {existing:?}: {}", + String::from_utf8_lossy(&combined) + ); + } + } + + #[cfg(unix)] + #[test] + fn native_bash_full_prompt_command_replacement_emits_failure() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let source = build_local_startup_script( + "/bin/bash", + &ready_marker(), + true, + true, + ) + .script + .expect("Bash source"); + let mut child = Command::new("/bin/bash") + .args(["--noprofile", "--norc", "-i"]) + .env("TERM", "dumb") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn Bash"); + child + .stdin + .as_mut() + .expect("Bash stdin") + .write_all( + format!( + "PS1='term> '\n{source}\nPROMPT_COMMAND=:\nprintf 'TRIGGER\\n'\nexit\n" + ) + .as_bytes(), + ) + .expect("write Bash replacement smoke"); + let output = child.wait_with_output().expect("wait for Bash"); + let mut combined = output.stdout; + combined.extend_from_slice(&output.stderr); + + assert!( + combined + .windows(b"NyaTermReady:session-1".len()) + .any(|window| window == b"NyaTermReady:session-1"), + "{}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"NyaTermReadyFailed:session-1".len()) + .any(|window| window == b"NyaTermReadyFailed:session-1"), + "{}", + String::from_utf8_lossy(&combined) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_macos_bash_accepts_generated_script_without_extglob() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let script = build_local_startup_script("/bin/bash", &ready_marker(), true, true) + .script + .expect("Bash source"); + let mut child = Command::new("/bin/bash") + .arg("-n") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn macOS Bash syntax check"); + child + .stdin + .as_mut() + .expect("Bash stdin") + .write_all(script.as_bytes()) + .expect("write generated Bash script"); + let output = child.wait_with_output().expect("wait for Bash syntax check"); + + assert!( + output.status.success(), + "macOS Bash rejected generated integration: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_zsh_source_command_and_prompt_redraw_remain_hidden() { + use std::io::{ErrorKind, Read, Write}; + + fn read_available(reader: &mut dyn Read) -> Vec { + let mut output = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(count) => output.extend_from_slice(&chunk[..count]), + Err(error) if error.kind() == ErrorKind::WouldBlock => break, + Err(error) => panic!("PTY read failed: {error}"), + } + } + output + } + + let system = super::native_pty_system(); + let pair = system + .openpty(super::PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("open PTY"); + let mut command = super::CommandBuilder::new("/bin/zsh"); + command.args(["-f", "-i"]); + command.env("TERM", "xterm-256color"); + command.env("HISTFILE", "/dev/null"); + let mut child = pair.slave.spawn_command(command).expect("spawn Zsh"); + let shell_pid = child.process_id(); + drop(pair.slave); + + let fd = pair.master.as_raw_fd().expect("Unix PTY fd"); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!( + unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + let mut reader = pair.master.try_clone_reader().expect("PTY reader"); + let mut writer = pair.master.take_writer().expect("PTY writer"); + + let initial_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(3); + while !local_shell_line_editor_ready(&*pair.master, shell_pid) { + assert!( + std::time::Instant::now() < initial_deadline, + "initial Zsh prompt timeout" + ); + let _ = read_available(&mut *reader); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = read_available(&mut *reader); + + writer + .write_all( + b"__nyaterm_test_theme(){ __nyaterm_test_theme_status=$?; printf '\\033]2;ZSH THEME\\007'; PS1='[~]> '; RPROMPT='RIGHT'; precmd_functions=(__nyaterm_test_theme); }; precmd_functions=(__nyaterm_test_theme)\n", + ) + .expect("set theme-rewritten Zsh prompts"); + writer.flush().expect("flush prompt setup"); + std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = read_available(&mut *reader); + + let marker = ready_marker(); + let source = build_local_startup_script("/bin/zsh", &marker, true, true) + .script + .expect("Zsh source"); + let (source_command, source_file) = + prepare_local_startup_injection(source).expect("prepare source file"); + let source_file = source_file.expect("Unix source file"); + let mut stripper = OscStripper::with_cwd_payloads(&marker, true); + let gate = StartupOutputGate::new_inactive(); + gate.activate(); + writer + .write_all(source_command.as_bytes()) + .expect("inject source command"); + writer.flush().expect("flush source command"); + + let mut raw_output = Vec::new(); + let mut published = String::new(); + let mut ready = false; + let ready_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(5); + while !ready { + assert!( + std::time::Instant::now() < ready_deadline, + "Zsh ready marker timeout" + ); + let raw = read_available(&mut *reader); + if raw.is_empty() { + std::thread::sleep(std::time::Duration::from_millis(10)); + continue; + } + raw_output.extend_from_slice(&raw); + let parsed = stripper.push(&String::from_utf8_lossy(&raw)); + ready = parsed.ready; + match gate.consume( + parsed.visible, + parsed.visible_after_ready, + parsed.ready || parsed.ready_failed, + ) { + StartupGateOutput::Buffered(value) + | StartupGateOutput::Ready(value) + | StartupGateOutput::SuppressedOverflow(value) + | StartupGateOutput::Pass(value) => published.push_str(&value), + } + } + + assert!( + String::from_utf8_lossy(&raw_output).contains(source_command.trim()), + "test did not observe Zsh's source-command redraw" + ); + assert!( + published.contains("\x1b]2;ZSH THEME\x07"), + "Zsh theme title did not cross the startup gate: {published:?}" + ); + assert!( + !published.contains("/tmp/.nt-"), + "Zsh source command leaked through the startup gate: {published:?}" + ); + assert!( + !published.contains("[~]>") && !published.contains("RIGHT"), + "Zsh replacement prompt leaked through the startup gate: {published:?}" + ); + + writer + .write_all(b"false\n") + .expect("set failing Zsh command status"); + writer.flush().expect("flush failing Zsh command"); + std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = read_available(&mut *reader); + writer + .write_all( + b"print -r -- '__NYATERM_ZSH_THEME_STATUS='${__nyaterm_test_theme_status}'__'\n", + ) + .expect("query Zsh theme status"); + writer.flush().expect("flush Zsh theme status query"); + let status_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut status_output = Vec::new(); + while !status_output + .windows(b"__NYATERM_ZSH_THEME_STATUS=1__".len()) + .any(|window| window == b"__NYATERM_ZSH_THEME_STATUS=1__") + { + assert!( + std::time::Instant::now() < status_deadline, + "Zsh theme did not observe the incoming failure status: {}", + String::from_utf8_lossy(&status_output) + ); + status_output.extend(read_available(&mut *reader)); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + source_file.remove(); + child.kill().expect("kill Zsh"); + let _ = child.wait(); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_zsh_readonly_prompt_or_hook_array_emits_failure_immediately() { + use std::io::Write; + use std::process::{Command, Stdio}; + + fn run_readonly_failure(setup: &str) { + let marker = ready_marker(); + let source = build_local_startup_script("/bin/zsh", &marker, true, true) + .script + .expect("Zsh source"); + let (source_command, source_file) = + prepare_local_startup_injection(source).expect("prepare source file"); + let source_file = source_file.expect("Unix source file"); + let mut child = Command::new("/bin/zsh") + .args(["-f", "-i"]) + .env("TERM", "dumb") + .env("HISTFILE", "/dev/null") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn Zsh"); + child + .stdin + .as_mut() + .expect("Zsh stdin") + .write_all(format!("{setup}\n{source_command}exit\n").as_bytes()) + .expect("write Zsh smoke commands"); + let output = child.wait_with_output().expect("wait for Zsh"); + source_file.remove(); + + let mut combined = output.stdout; + combined.extend_from_slice(&output.stderr); + assert!( + combined + .windows(b"NyaTermReadyFailed:session-1".len()) + .any(|window| window == b"NyaTermReadyFailed:session-1"), + "readonly Zsh hook omitted its immediate failure marker: {}", + String::from_utf8_lossy(&combined) + ); + assert!( + !combined + .windows(b"\x1b]7777;NyaTermReady:session-1\x07".len()) + .any(|window| window == b"\x1b]7777;NyaTermReady:session-1\x07"), + "readonly Zsh hook emitted a success marker" + ); + } + + run_readonly_failure("PS1='readonly> '; readonly PS1"); + run_readonly_failure( + "typeset -ga precmd_functions=(); readonly precmd_functions; PS1='readonly> '", + ); + } + + #[cfg(unix)] + #[test] + fn native_bash_failed_hook_install_still_prunes_source_history() { + use std::io::{ErrorKind, Read, Write}; + + fn read_available(reader: &mut dyn Read) -> Vec { + let mut output = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(count) => output.extend_from_slice(&chunk[..count]), + Err(error) if error.kind() == ErrorKind::WouldBlock => break, + Err(error) => panic!("PTY read failed: {error}"), + } + } + output + } + + fn run_readonly_failure(setup: &[u8]) { + const HISTORY_DONE: &[u8] = b"__NYATERM_HISTORY_DONE__"; + let system = super::native_pty_system(); + let pair = system + .openpty(super::PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("open PTY"); + let mut command = super::CommandBuilder::new("/bin/bash"); + command.args(["--noprofile", "--norc", "-i"]); + command.env("TERM", "dumb"); + command.env("HISTFILE", "/dev/null"); + command.env("HISTCONTROL", ""); + let mut child = pair.slave.spawn_command(command).expect("spawn Bash"); + let shell_pid = child.process_id(); + drop(pair.slave); + + let fd = pair.master.as_raw_fd().expect("Unix PTY fd"); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + assert!(flags >= 0); + assert_eq!( + unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }, + 0 + ); + let mut reader = pair.master.try_clone_reader().expect("PTY reader"); + let mut writer = pair.master.take_writer().expect("PTY writer"); + + let initial_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(3); + while !local_shell_line_editor_ready(&*pair.master, shell_pid) { + assert!( + std::time::Instant::now() < initial_deadline, + "initial Bash prompt timeout" + ); + let _ = read_available(&mut *reader); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = read_available(&mut *reader); + + writer.write_all(setup).expect("configure readonly prompt"); + writer.flush().expect("flush readonly setup"); + std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = read_available(&mut *reader); + + let marker = ready_marker(); + let source = build_local_startup_script("/bin/bash", &marker, true, true) + .script + .expect("Bash source"); + let (source_command, source_file) = + prepare_local_startup_injection(source).expect("prepare source file"); + let source_file = source_file.expect("Unix source file"); + writer + .write_all(source_command.as_bytes()) + .expect("inject source command"); + writer.flush().expect("flush source command"); + + let source_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut source_output = Vec::new(); + while !(source_output + .windows(b"NyaTermReadyFailed:session-1".len()) + .any(|window| window == b"NyaTermReadyFailed:session-1") + && local_shell_line_editor_ready(&*pair.master, shell_pid)) + { + assert!( + std::time::Instant::now() < source_deadline, + "failed Bash hook did not emit its failure marker and return to its prompt" + ); + source_output.extend(read_available(&mut *reader)); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + source_output.extend(read_available(&mut *reader)); + assert!( + source_output + .windows(b"NyaTermReadyFailed:session-1".len()) + .any(|window| window == b"NyaTermReadyFailed:session-1"), + "failed Bash hook omitted its immediate session marker: {}", + String::from_utf8_lossy(&source_output) + ); + + writer + .write_all(b"history 8; printf '__NYATERM_HISTORY_DONE__\\n'\n") + .expect("request history"); + writer.flush().expect("flush history command"); + let history_deadline = + std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut history_output = Vec::new(); + while !(history_output + .windows(HISTORY_DONE.len()) + .filter(|window| *window == HISTORY_DONE) + .count() + >= 2 + && local_shell_line_editor_ready(&*pair.master, shell_pid)) + { + assert!( + std::time::Instant::now() < history_deadline, + "Bash history listing did not complete" + ); + history_output.extend(read_available(&mut *reader)); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let history = String::from_utf8_lossy(&history_output); + let setup_history_line = String::from_utf8_lossy(setup); + assert!( + history.contains(setup_history_line.trim()), + "known setup entry was absent from the completed history listing: {history}" + ); + assert!( + !history.contains(source_command.trim()), + "failed Bash hook leaked startup command into history: {history}" + ); + + source_file.remove(); + child.kill().expect("kill Bash"); + let _ = child.wait(); + } + + run_readonly_failure( + b"PROMPT_COMMAND=''; readonly PROMPT_COMMAND; PS1='failure$ '\n", + ); + run_readonly_failure(b"PROMPT_COMMAND=''; PS1='failure$ '; readonly PS1\n"); + } + + #[test] + fn startup_gate_transports_pre_ready_application_titles_but_not_prompt_echo() { + let marker = ready_marker(); + let mut stripper = OscStripper::new(&marker); + let gate = StartupOutputGate::new(); + let parsed = stripper.push(&format!( + "source echo\x1b[c\x1b]2;theme title\x07{marker}replacement prompt" + )); + let mut published = String::new(); + let gated = gate.consume( + parsed.visible, + parsed.visible_after_ready, + parsed.ready || parsed.ready_failed, + ); + match gated { + StartupGateOutput::Buffered(value) + | StartupGateOutput::Ready(value) + | StartupGateOutput::SuppressedOverflow(value) + | StartupGateOutput::Pass(value) => published.push_str(&value), + } + + assert_eq!(published, "\x1b[c\x1b]2;theme title\x07"); + } + + #[test] + fn startup_gate_discards_echo_and_replacement_prompt() { + let gate = StartupOutputGate::new(); + assert_eq!( + gate.consume("echoed integration".to_string(), String::new(), false), + StartupGateOutput::Buffered(String::new()) + ); + assert_eq!( + gate.consume( + "before markerprompt".to_string(), + "prompt".to_string(), + true, + ), + StartupGateOutput::Ready(String::new()) + ); + assert_eq!( + gate.consume("next".to_string(), String::new(), false), + StartupGateOutput::Pass("next".to_string()) + ); + } + + #[test] + fn startup_gate_discards_hidden_injection_once() { + let gate = StartupOutputGate::new(); + assert_eq!( + gate.consume("echoed hook source".to_string(), String::new(), false), + StartupGateOutput::Buffered(String::new()) + ); + assert!(gate.discard()); + assert!(!gate.discard()); + assert_eq!( + gate.consume("next".to_string(), String::new(), false), + StartupGateOutput::Pass("next".to_string()) + ); + } + + #[test] + fn startup_gate_buffer_limit_discards_all_injection_bytes() { + let gate = StartupOutputGate::new(); + let oversized = "x".repeat(64 * 1024 + 1); + assert_eq!( + gate.consume(oversized, String::new(), false), + StartupGateOutput::SuppressedOverflow(String::new()) + ); + assert!(!gate.is_active()); + assert_eq!( + gate.consume("normal passive output".to_string(), String::new(), false), + StartupGateOutput::Pass("normal passive output".to_string()) + ); + } + + #[test] + fn local_startup_injects_title_only_hooks_for_supported_unix_shells() { + let marker = ready_marker(); + + for shell in ["/bin/bash", "/bin/zsh"] { + let startup = build_local_startup_script(shell, &marker, true, true); + assert!(startup.dynamic_title_integration_requested); + let script = startup.script.expect("unix startup script"); + assert!(script.contains("NyaTermReady:session-1")); + assert!(script.contains("NyaTermReadyFailed:session-1")); + assert!(script.contains("]2;")); + assert!(script.contains("]7;file://localhost")); + assert!(script.contains("uri_encode")); + assert!(script.contains("PS1=")); + assert!(!script.contains("NyaTermCommand")); + assert!(!script.contains("base64")); + assert!(startup.shell_init_args.is_none()); + assert!(startup.pwsh_init_args.is_none()); + assert!(startup.cmd_prompt.is_none()); + assert!(script.contains("return 1")); + if shell.ends_with("bash") { + assert!(script.contains("code & 255")); + assert!(script.contains("NYATERM_PRUNE_HISTORY=$history_marker")); + assert!(script.contains("__nyaterm_local_array_prompt_supported")); + assert!(script.contains("__nyaterm_local_capture_prompt_string")); + assert!(!script.contains("BASH_REMATCH")); + assert!(!script.contains(" in (")); + assert!(!script.contains("|*__nyaterm_local_")); + assert!(!script.contains("|*NyaTermReady")); + assert!(script.contains("__nyaterm_local_prompt_container_valid")); + assert!(script.contains("__nyaterm_local_prompt_liveness")); + assert!(script.contains("\\[$(__nyaterm_local_prompt_liveness)\\]")); + assert!(script.contains( + "PROMPT_COMMAND=(__nyaterm_local_dynamic_prompt \"${retained[@]}\" __nyaterm_local_ready_prompt)" + )); + } else { + assert!(script.contains("%{\\e]7777;NyaTermReady")); + assert!(script.contains( + "precmd_functions=(__nyaterm_local_dynamic_emit \"${retained[@]}\" __nyaterm_local_ready_prompt)" + )); + } + } + + let fish = build_local_startup_script("/opt/homebrew/bin/fish", &marker, true, true); + assert!(fish.dynamic_title_integration_requested); + assert!(fish.script.is_none()); + let fish_args = fish.shell_init_args.expect("Fish init args"); + assert_eq!(fish_args.first().map(String::as_str), Some("--init-command")); + let fish_script = fish_args.last().expect("Fish init script"); + assert!(fish_script.contains("NyaTermReady:session-1")); + assert!(fish_script.contains("--on-event fish_postexec")); + assert!(!fish_script.contains("--on-event fish_prompt")); + assert!(!fish_script.contains("fish_private_mode")); + + for shell in ["/bin/sh", "nu", "/opt/bin/my-bash-wrapper"] { + let startup = build_local_startup_script(shell, &marker, true, true); + assert!(!startup.dynamic_title_integration_requested); + assert!(startup.script.is_none()); + assert!(startup.shell_init_args.is_none()); + } + + let custom = build_local_startup_script("/bin/bash", &marker, true, false); + assert!(!custom.dynamic_title_integration_requested); + assert!(custom.script.is_none()); + assert!(custom.shell_init_args.is_none()); + } + + #[test] + fn local_startup_injects_pwsh_via_spawn_args_only() { + let marker = ready_marker(); + let pwsh = build_local_startup_script("powershell.exe", &marker, true, true); + assert!(pwsh.dynamic_title_integration_requested); + assert!(pwsh.script.is_none()); + assert!(pwsh.cmd_prompt.is_none()); + let args = pwsh.pwsh_init_args.expect("pwsh init args"); + assert!(!args.contains(&"-NoLogo".to_string())); + assert!(args.contains(&"-NoExit".to_string())); + assert!(args.contains(&"-Command".to_string())); + let init = args.last().expect("init script"); + assert!(init.contains("global:prompt")); + assert!(init.contains("Provider.Name -eq 'FileSystem'")); + assert!(init.contains("ProviderPath")); + assert!(init.contains("nyaterm-clear://session-1")); + assert!(init.contains("NyaTermReady:session-1")); + assert!(init.contains("$__nt_success = $?")); + assert!(init.contains("$__nt_had_last_exit = Test-Path")); + assert!(init.contains("$global:LASTEXITCODE = $__nt_last_exit")); + assert!(init.contains("Remove-Variable LASTEXITCODE")); + assert!(init.contains("if ($__nt_success) { & {} } else { Write-Error")); + assert!( + init.find("NyaTermReady:session-1").unwrap() + < init.find("']7;' + $__nt_uri").unwrap() + ); + assert!( + init.find("]2;").unwrap() + < init + .find("& $global:__nyaterm_local_prev_prompt") + .unwrap() + ); + assert!(init.contains("ReferenceEquals")); + assert!(init.contains("Microsoft.PowerShell.Utility\\Register-EngineEvent")); + assert!(init.contains("Microsoft.PowerShell.Utility\\Get-EventSubscriber -SourceIdentifier PowerShell.OnIdle")); + assert!(init.contains("Microsoft.PowerShell.Utility\\Unregister-Event -SubscriptionId")); + assert!(init.contains("Microsoft.PowerShell.Core\\Remove-Job -Id")); + assert!(init.contains("PowerShell.OnIdle")); + assert!(init.contains("__nyaterm_local_report")); + assert!(init.contains("__nyaterm_local_failure_reported")); + assert!(init.contains("NyaTermReadyFailed:session-1")); + + let no_inject = build_local_startup_script("powershell.exe", &marker, true, false); + assert!(!no_inject.dynamic_title_integration_requested); + assert!(no_inject.pwsh_init_args.is_none()); + } + + #[cfg(target_os = "windows")] + #[test] + fn native_powershell_repeated_init_remains_single_and_non_recursive() { + use std::process::Command; + + let marker = ready_marker(); + let init = build_local_startup_script("powershell.exe", &marker, true, true) + .pwsh_init_args + .expect("PowerShell init args") + .pop() + .expect("PowerShell init script"); + let harness = format!( + "$ErrorActionPreference='Stop'; function global:prompt {{ 'USER> ' }}; Start-Job {{ 1 }} | Out-Null; $user = Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -Action {{ 'USER-IDLE' }}; {init}; {init}; $value = & prompt; Write-Output ('VALUE=' + $value); Write-Output ('SUBSCRIBERS=' + @($ExecutionContext.Events.Subscribers | Where-Object SourceIdentifier -eq 'PowerShell.OnIdle').Count); if (@(Get-EventSubscriber -SourceIdentifier PowerShell.OnIdle | Where-Object {{ [object]::ReferenceEquals($_.Action, $user) }}).Count -ne 1) {{ throw 'user subscriber removed' }}" + ); + let output = Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-Command", &harness]) + .output() + .expect("run repeated PowerShell init smoke"); + let mut combined = output.stdout; + combined.extend_from_slice(&output.stderr); + + assert!(output.status.success(), "{}", String::from_utf8_lossy(&combined)); + assert!( + combined + .windows(b"VALUE=USER> ".len()) + .any(|window| window == b"VALUE=USER> "), + "{}", + String::from_utf8_lossy(&combined) + ); + assert!( + combined + .windows(b"SUBSCRIBERS=2".len()) + .any(|window| window == b"SUBSCRIBERS=2"), + "{}", + String::from_utf8_lossy(&combined) + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn native_powershell_prompt_wrapper_repairs_self_replacement() { + use std::process::Command; + + let marker = ready_marker(); + let init = build_local_startup_script("powershell.exe", &marker, true, true) + .pwsh_init_args + .expect("PowerShell init args") + .pop() + .expect("PowerShell init script"); + let harness = format!( + "function global:prompt {{ function global:prompt {{ 'replacement> ' }}; 'first> ' }}; {init}; $null = prompt; $null = prompt" + ); + let output = Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-Command", &harness]) + .output() + .expect("run Windows PowerShell prompt smoke"); + + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!( + output + .stdout + .windows(b"NyaTermReady:session-1".len()) + .filter(|window| *window == b"NyaTermReady:session-1") + .count(), + 2, + "PowerShell wrapper was not restored: {}", + String::from_utf8_lossy(&output.stdout) + ); + } + + #[test] + fn cmd_prompt_preserves_existing_prompt_and_uses_session_bound_cwd() { + let prompt = build_cmd_prompt(Some("$T $P$G"), "session-1"); + assert_eq!( + prompt, + "$E]2;$E\\$E]7777;NyaTermReady:session-1$E\\$E]7;nyaterm-cmd://session-1/$P$E\\$T $P$G" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn local_startup_configures_cmd_prompt_without_mutating_argv() { + let marker = ready_marker(); + let cmd = build_local_startup_script( + r"C:\Windows\System32\cmd.exe", + &marker, + true, + true, + ); + assert!(cmd.dynamic_title_integration_requested); + assert!(cmd.script.is_none()); + assert!(cmd.pwsh_init_args.is_none()); + let prompt = cmd.cmd_prompt.expect("cmd prompt integration"); + assert!(prompt.starts_with( + "$E]2;$E\\$E]7777;NyaTermReady:session-1$E\\$E]7;nyaterm-cmd://session-1/$P$E\\" + )); + + let custom = build_local_startup_script("cmd.exe", &marker, true, false); + assert!(!custom.dynamic_title_integration_requested); + assert!(custom.cmd_prompt.is_none()); + } + + #[test] + fn wrappers_fallbacks_and_disabled_policy_remain_passive() { + assert!(is_unresolved_shell_wrapper("wsl.exe")); + assert!(is_unresolved_shell_wrapper(r"C:\Windows\System32\wsl.exe")); + assert!(is_unresolved_shell_wrapper("wt")); + assert!(!is_unresolved_shell_wrapper("bash.exe")); + assert!(should_allow_local_injection( + "bash.exe", + "/bin/bash", + "", + false, + ShellResolutionSource::Direct, + )); + assert!(!should_allow_local_injection( + "bash.exe", + "/bin/bash", + " ", + true, + ShellResolutionSource::Direct, + )); + assert!(!should_allow_local_injection( + "pwsh.exe -NoLogo", + "pwsh.exe", + "", + false, + ShellResolutionSource::Direct, + )); + assert!(!should_allow_local_injection( + "wsl.exe", + "wsl.exe", + "", + true, + ShellResolutionSource::Direct, + )); + + let wt_resolved_shell = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; + assert!(should_allow_local_injection( + "wt.exe", + wt_resolved_shell, + "", + true, + ShellResolutionSource::WindowsTerminalProfile, + )); + assert!(should_allow_local_injection( + "wt", + r"C:\Windows\System32\cmd.exe", + "", + true, + ShellResolutionSource::WindowsTerminalProfile, + )); + assert!(!should_allow_local_injection( + "wt.exe", + wt_resolved_shell, + "", + true, + ShellResolutionSource::WindowsTerminalFallback, + )); + assert!(!should_allow_local_injection( + "wt.exe", + "wsl.exe", + "", + true, + ShellResolutionSource::WindowsTerminalProfile, + )); + assert!(!should_allow_local_injection( + "wt.exe", + r"C:\Program Files\PowerShell\7\pwsh.exe", + "", + false, + ShellResolutionSource::WindowsTerminalProfile, + )); + + let wt_startup = build_local_startup_script( + wt_resolved_shell, + &ready_marker(), + true, + should_allow_local_injection( + "wt.exe", + wt_resolved_shell, + "", + true, + ShellResolutionSource::WindowsTerminalProfile, + ), + ); + assert!(wt_startup.dynamic_title_integration_requested); + assert!(wt_startup.pwsh_init_args.is_some()); + + let startup = build_local_startup_script("/bin/zsh", &ready_marker(), false, true); + assert!(!startup.dynamic_title_integration_requested); assert!(startup.script.is_none()); + assert!(startup.pwsh_init_args.is_none()); + assert!(startup.cmd_prompt.is_none()); } } diff --git a/src-tauri/src/core/terminal_session/local/windows_terminal.rs b/src-tauri/src/core/terminal_session/local/windows_terminal.rs index b0a4c8fe..20ed4d0c 100644 --- a/src-tauri/src/core/terminal_session/local/windows_terminal.rs +++ b/src-tauri/src/core/terminal_session/local/windows_terminal.rs @@ -29,6 +29,7 @@ fn fallback_windows_terminal_shell(args: Vec) -> ShellCommandSpec { ShellCommandSpec { program: resolve_program_for_spawn("powershell.exe"), args, + resolution_source: ShellResolutionSource::WindowsTerminalFallback, } } @@ -97,13 +98,18 @@ fn windows_terminal_profile_commandline(profile: &serde_json::Value) -> Option { - output.attach(); + SessionCommand::AttachConfirmed { ack } => { + output.attach_confirmed(ack); } SessionCommand::DetachRenderer => { output.detach(); diff --git a/src-tauri/src/core/terminal_session/telnet/manager.rs b/src-tauri/src/core/terminal_session/telnet/manager.rs index 1c7285bd..ada25ff8 100644 --- a/src-tauri/src/core/terminal_session/telnet/manager.rs +++ b/src-tauri/src/core/terminal_session/telnet/manager.rs @@ -39,15 +39,17 @@ pub async fn create_telnet_session( owner_window_label, ai_execution_profile: AiExecutionProfile::SendOnly, injection_active: false, + dynamic_title_capabilities: DynamicTitleCapabilities::default(), remote_file_browser_enabled: false, remote_stats_enabled: false, ssh_profile: None, }; - let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(None)); + let cwd: SharedCwd = Arc::new(tokio::sync::Mutex::new(Default::default())); let session_handle = SessionHandle { info: session_info.clone(), cmd_tx, + startup_input_barrier: None, ssh_config: None, ssh_handle: None, cwd, diff --git a/src-tauri/src/core/terminal_session/telnet/mod.rs b/src-tauri/src/core/terminal_session/telnet/mod.rs index e9d9630a..e634e158 100644 --- a/src-tauri/src/core/terminal_session/telnet/mod.rs +++ b/src-tauri/src/core/terminal_session/telnet/mod.rs @@ -4,8 +4,9 @@ use crate::config::AiExecutionProfile; use crate::core::capture::OutputCaptureProcessor; use crate::core::input::remap_del_to_bs; use crate::core::session::{ - SessionCommand, SessionCommandReceiver, SessionCommandSender, SessionHandle, SessionInfo, - SessionManager, SessionReadyHook, SessionType, SharedCwd, session_command_channel, + DynamicTitleCapabilities, SessionCommand, SessionCommandReceiver, SessionCommandSender, + SessionHandle, SessionInfo, SessionManager, SessionReadyHook, SessionType, SharedCwd, + session_command_channel, }; use crate::core::terminal_session::{TerminalOutputDecoder, encode_terminal_input}; use crate::core::zmodem::{ diff --git a/src-tauri/src/core/terminal_session/telnet/session.rs b/src-tauri/src/core/terminal_session/telnet/session.rs index c1f42fc4..0357edf6 100644 --- a/src-tauri/src/core/terminal_session/telnet/session.rs +++ b/src-tauri/src/core/terminal_session/telnet/session.rs @@ -401,8 +401,8 @@ async fn telnet_session_task( } cmd = cmd_rx.recv() => { match cmd { - Some(SessionCommand::Attach) => { - output.attach(); + Some(SessionCommand::AttachConfirmed { ack }) => { + output.attach_confirmed(ack); } Some(SessionCommand::DetachRenderer) => { output.detach(); diff --git a/src-tauri/src/core/zmodem/detector.rs b/src-tauri/src/core/zmodem/detector.rs index 79b51b96..ce4fe3fa 100644 --- a/src-tauri/src/core/zmodem/detector.rs +++ b/src-tauri/src/core/zmodem/detector.rs @@ -51,6 +51,10 @@ impl ZmodemDetector { ZmodemDetectResult::NoMatch { passthrough } } + pub fn has_pending_prefix(&self) -> bool { + !self.pending.is_empty() + } + pub fn reset(&mut self) { self.pending.clear(); self.pending_starts_at_line_start = true; @@ -183,4 +187,3 @@ fn hex_digit(b: u8) -> Option { // --------------------------------------------------------------------------- // Transfer state machine // --------------------------------------------------------------------------- - diff --git a/src-tauri/src/core/zmodem/tests.rs b/src-tauri/src/core/zmodem/tests.rs index fac4a3e5..ffe1c6b2 100644 --- a/src-tauri/src/core/zmodem/tests.rs +++ b/src-tauri/src/core/zmodem/tests.rs @@ -332,6 +332,16 @@ mod tests { } } + #[test] + fn reports_single_line_start_zpad_as_pending_startup_risk() { + let mut detector = ZmodemDetector::new(); + match detector.feed(b"*") { + ZmodemDetectResult::NoMatch { passthrough } => assert!(passthrough.is_empty()), + ZmodemDetectResult::Detected { .. } => panic!("unexpected early detection"), + } + assert!(detector.has_pending_prefix()); + } + #[test] fn detects_rz_zhex_upload_header_split_after_prompt_text() { let mut detector = ZmodemDetector::new(); @@ -341,6 +351,7 @@ mod tests { } ZmodemDetectResult::Detected { .. } => panic!("unexpected early detection"), } + assert!(detector.has_pending_prefix()); assert_eq!( detected_direction(detector.feed(b"\x18B01")), @@ -355,6 +366,7 @@ mod tests { ZmodemDetectResult::NoMatch { passthrough } => assert!(passthrough.is_empty()), ZmodemDetectResult::Detected { .. } => panic!("unexpected early detection"), } + assert!(detector.has_pending_prefix()); assert_eq!( detected_direction(detector.feed(b"1rest")), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6754170..3d0f5347 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -234,8 +234,10 @@ pub fn run() { cmd::session::detach_session_renderer, cmd::session::close_session, cmd::session::list_sessions, + cmd::session::get_session_cwd_presentation, cmd::session::add_command_history, cmd::session::register_command_submission, + cmd::session::register_command_confirmation_candidate, cmd::session::get_command_history, cmd::session::delete_command_history, cmd::session::fuzzy_search_history, diff --git a/src-tauri/src/storage/tests.rs b/src-tauri/src/storage/tests.rs index 0b24ad22..5667e43d 100644 --- a/src-tauri/src/storage/tests.rs +++ b/src-tauri/src/storage/tests.rs @@ -44,6 +44,7 @@ fn sample_connection(id: &str, group_id: Option<&str>, sort_order: i32) -> Saved legacy_agent_forwarding: None, agent_forwarding_config: None, encoding: String::new(), + dynamic_tab_title: false, }, group_id: group_id.map(str::to_string), description: None, diff --git a/src/App.tsx b/src/App.tsx index d80243d7..7dc747d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -143,9 +143,14 @@ import { findSessionPaneById, findTabBySessionId, getActivePane, + getActiveSessionTabDisplayName, getReleasedSessionIds, - getTabDisplayName, } from "./lib/workspaceTabs"; +import { + getDynamicTitle, + startDynamicTitles, + useDynamicTitles, +} from "./lib/dynamicTabTitles"; import type { AppSettings, AssetMetadata, @@ -176,6 +181,12 @@ function eventTargetsCurrentWindow(targetWindowLabel?: string | null) { /** Root layout: header, activity bars, sidebars, terminal area, dialogs. */ function App() { useMacSelectionGuard(); + // Keep dynamic session titles (local PTY shell integration) flowing for the + // main window's tab labels and window title. + const dynamicTitles = useDynamicTitles(); + useEffect(() => { + startDynamicTitles(); + }, []); const { tabs, @@ -775,7 +786,9 @@ function App() { }, [handleOpenPanel]); const activeTab = tabs.find((t) => t.id === activeTabId) ?? null; - const activeTabName = activeTab ? getTabDisplayName(activeTab).trim() : ""; + const activeTabName = activeTab + ? getActiveSessionTabDisplayName(activeTab, getDynamicTitle).trim() + : ""; const windowTitle = activeTabName ? `${activeTabName} - NyaTerm` : "NyaTerm"; const activePane = activeTab ? getActivePane(activeTab) : null; const activeConnection = activePane?.connectionId @@ -1500,7 +1513,7 @@ function App() { const handleUpdateWindowSplitRatio = useCallback((splitId: string, ratio: number) => { setTerminalWindows((current) => current ? updateTerminalWindowSplitRatio(current, splitId, ratio) : current, - ); + ); }, []); const handleActivatePane = useCallback( @@ -2987,7 +3000,12 @@ function App() { owner_window_label: session?.owner_window_label ?? null, ai_execution_profile: session?.ai_execution_profile ?? "auto", injection_active: session?.injection_active ?? false, - remote_file_browser_enabled: session?.remote_file_browser_enabled ?? false, + dynamic_title_enabled: session?.dynamic_title_enabled ?? false, + dynamic_title_integration_active: + session?.dynamic_title_integration_active ?? false, + trusted_initial_title: session?.trusted_initial_title ?? null, + remote_file_browser_enabled: + session?.remote_file_browser_enabled ?? false, remote_stats_enabled: session?.remote_stats_enabled ?? false, ssh_profile: session?.ssh_profile ?? null, }); @@ -3220,7 +3238,11 @@ function App() { targetsById.set(pane.sessionId, { id: pane.sessionId, name: pane.name, - tabName: getTabDisplayName(tab), + tabName: getActiveSessionTabDisplayName( + tab, + (sessionId) => + dynamicTitles.get(sessionId ?? "")?.effectiveTitle ?? null, + ), type: pane.type, ownerWindowLabel: currentWindowLabel, }); @@ -3244,7 +3266,7 @@ function App() { } return [...targetsById.values()]; - }, [liveSessionsById, tabsById, terminalWindows]); + }, [liveSessionsById, tabsById, terminalWindows, dynamicTitles]); const activeBottomPanel = uiConfig.show_serial_send_panel ? "serialSend" @@ -3270,14 +3292,18 @@ function App() { name: pane.name, sessionType: pane.type, connectionName: connection?.name, - tabName: getTabDisplayName(tab), + tabName: getActiveSessionTabDisplayName( + tab, + (sessionId) => + dynamicTitles.get(sessionId ?? "")?.effectiveTitle ?? null, + ), connecting: pane.connecting, connectError: pane.connectError, }); } } return sessions; - }, [savedConnections, tabs]); + }, [savedConnections, tabs, dynamicTitles]); const handleCloseSessionQuickSwitcher = useCallback(() => { setShowSessionQuickSwitcher(false); diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 4df7defc..42cbc5c5 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -92,7 +92,11 @@ import { increaseTerminalFontSizeDelta, resetTerminalFontSizeDelta, } from "@/lib/terminalFontSize"; -import { getActivePane, getTabDisplayName } from "@/lib/workspaceTabs"; +import { + getActivePane, + getActiveSessionTabDisplayName, +} from "@/lib/workspaceTabs"; +import { getDynamicTitle, useDynamicTitles } from "@/lib/dynamicTabTitles"; import type { AppearanceSettings, RemoteGpuOverview, @@ -759,6 +763,7 @@ export default function Header({ ); const [hardwarePage, setHardwarePage] = useState({ gpu: 0, npu: 0 }); const { t, i18n } = useTranslation(); + useDynamicTitles(); const { handleExport, passwordAlert } = useConfigTransfer(); const lastMacosMenuSpecRef = useRef(""); const nativeMenuActionRef = useRef<(actionId: string) => void>(() => {}); @@ -767,7 +772,9 @@ export default function Header({ const activeConnection = activePane?.connectionId ? savedConnections?.find((c) => c.id === activePane.connectionId) : undefined; - const activeDisplayName = activeTab ? getTabDisplayName(activeTab) : "NyaTerm"; + const activeDisplayName = activeTab + ? getActiveSessionTabDisplayName(activeTab, getDynamicTitle) + : "NyaTerm"; const terminalZoomEnabled = appSettings.interaction.terminal_zoom_enabled; const headerStatusMode = normalizeHeaderStatusMode(appSettings.ui.header_status_mode); const headerStatusVisible = appSettings.ui.header_status_visible !== false; diff --git a/src/components/panel/ActiveSessions.tsx b/src/components/panel/ActiveSessions.tsx index f298fa53..59bf1f29 100644 --- a/src/components/panel/ActiveSessions.tsx +++ b/src/components/panel/ActiveSessions.tsx @@ -28,7 +28,8 @@ import { import { Input } from "@/components/ui/input"; import { useApp } from "@/context/AppContext"; import { invoke } from "@/lib/invoke"; -import { findTabBySessionId, getTabDisplayName } from "@/lib/workspaceTabs"; +import { findTabBySessionId, getSessionRowDisplayName } from "@/lib/workspaceTabs"; +import { useDynamicTitles } from "@/lib/dynamicTabTitles"; import type { SessionInfo } from "@/types/global"; interface ActiveSessionsProps { @@ -46,6 +47,7 @@ function ActiveSessions({ canReconnect, }: ActiveSessionsProps) { const { t } = useTranslation(); + const dynamicTitles = useDynamicTitles(); const { tabs, updateTab } = useApp(); const [sessions, setSessions] = useState([]); const [search, setSearch] = useState(""); @@ -104,10 +106,16 @@ function ActiveSessions({ return { session, tab, - displayName: tab ? getTabDisplayName(tab) : session.name, + displayName: tab + ? getSessionRowDisplayName( + tab, + session.name, + dynamicTitles.get(session.id), + ) + : session.name, }; }), - [sessions, tabs], + [sessions, tabs, dynamicTitles], ); const query = search.trim().toLowerCase(); diff --git a/src/components/sessions/LocalTerminal.tsx b/src/components/sessions/LocalTerminal.tsx index e5a3786c..77f51352 100644 --- a/src/components/sessions/LocalTerminal.tsx +++ b/src/components/sessions/LocalTerminal.tsx @@ -15,6 +15,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Switch } from "@/components/ui/switch"; import type { RecordingMode } from "@/types/global"; interface LocalTerminalProps { @@ -32,6 +33,8 @@ interface LocalTerminalProps { setRecordingMode: (v: RecordingMode) => void; encoding: string; setEncoding: (v: string) => void; + dynamicTabTitle: boolean; + setDynamicTabTitle: (v: boolean) => void; } const BUILTIN_SHELL_PATHS = ["powershell.exe", "cmd.exe", "bash", "wsl.exe", "wt.exe"] as const; @@ -51,6 +54,8 @@ export function LocalTerminal({ setRecordingMode, encoding, setEncoding, + dynamicTabTitle, + setDynamicTabTitle, }: LocalTerminalProps) { const { t } = useTranslation(); const [advancedOpen, setAdvancedOpen] = useState(false); @@ -154,6 +159,25 @@ export function LocalTerminal({ onChange={(e) => setWorkingDir(e.target.value)} /> +
+
+
{t("dialog.dynamicTabTitle")}
+

+ {t("dialog.dynamicTabTitleDesc")} +

+ {dynamicTabTitle && shellArgs.trim() ? ( +

+ {t("dialog.dynamicTabTitleCustomArgsHint")} +

+ ) : null} +
+ +
void; sftpSettings: SftpSettings; setSftpSettings: (v: SftpSettings) => void; + remoteDynamicTabTitle: boolean; + setRemoteDynamicTabTitle: (v: boolean) => void; recordingUseGlobal: boolean; setRecordingUseGlobal: (v: boolean) => void; recordingAutoStart: boolean; @@ -492,6 +494,8 @@ export function SshForm({ setSshTerminalType, sftpSettings, setSftpSettings, + remoteDynamicTabTitle, + setRemoteDynamicTabTitle, recordingUseGlobal, setRecordingUseGlobal, recordingAutoStart, @@ -1192,6 +1196,21 @@ export function SshForm({ +
+
+
{t("dialog.remoteDynamicTabTitle")}
+

+ {t("dialog.remoteDynamicTabTitleDesc")} +

+
+ +
+ { - setRenameValue(getTabDisplayName(tab)); + setRenameValue(getActiveSessionTabDisplayName(tab, getDynamicTitle)); setRenameTab(tab); }, []); @@ -629,7 +637,9 @@ function TabBar({ const handleCopyTabName = useCallback( async (tab: Tab) => { try { - await navigator.clipboard.writeText(getTabDisplayName(tab)); + await navigator.clipboard.writeText( + getActiveSessionTabDisplayName(tab, getDynamicTitle), + ); toast.success(t("tabCtx.nameCopied")); } catch { toast.error(t("tabCtx.copyFailed")); @@ -1180,12 +1190,22 @@ function TabBar({ : showUnreadIndicator ? "var(--df-link)" : undefined; - const displayName = getTabDisplayName(tab); + const activePane = getActivePane(tab); + const activeSessionId = getTabActiveSessionId(tab); + const dynamicSnapshot = getDynamicTitleSnapshot(activeSessionId); + const displayName = getTabDisplayName( + tab, + dynamicSnapshot?.effectiveTitle ?? null, + ); + const cwdPresentation = + activePane?.paneKind === "terminal" && dynamicSnapshot?.enabled + ? dynamicSnapshot.cwd + : null; const isDirty = collectSessionPanes(tab.root).some( (pane) => pane.paneKind === "file" && fileDocumentStates.get(pane.id)?.dirty, ); - const activePaneIsFile = getActivePane(tab)?.paneKind === "file"; + const activePaneIsFile = activePane?.paneKind === "file"; const accentColor = tab.tabColor; const conn = getTabConnection(tab, savedConnections); const canCopyIp = !!getTabServerIp(tab, savedConnections); @@ -1209,6 +1229,7 @@ function TabBar({ value: string, label: string, copiedMessage: string, + copyValue = value, ) => (
{value} @@ -1219,7 +1240,7 @@ function TabBar({ onClick={(event) => { event.stopPropagation(); navigator.clipboard - .writeText(value) + .writeText(copyValue) .then(() => toast.success(copiedMessage)) .catch(() => toast.error(t("tabCtx.copyFailed"))); }} @@ -1236,7 +1257,8 @@ function TabBar({ groupPath || isDisconnected || showUnreadIndicator || - isDirty ? ( + isDirty || + cwdPresentation ? (
{isDirty && (
@@ -1288,6 +1310,13 @@ function TabBar({ t("tabCtx.copySshAddress"), t("tabCtx.sshAddressCopied"), )} + {cwdPresentation && + renderTooltipCopyRow( + cwdPresentation.displayPath, + t("tabCtx.copyWorkingDirectory"), + t("tabCtx.workingDirectoryCopied"), + cwdPresentation.copyValue, + )}
) : undefined; @@ -1529,7 +1558,7 @@ function TabBar({ const renderOpenTabMenuItem = (tab: Tab, index: number) => { const isActive = activeTabId === tab.id; - const displayName = getTabDisplayName(tab); + const displayName = getActiveSessionTabDisplayName(tab, getDynamicTitle); const isDirty = collectSessionPanes(tab.root).some( (pane) => pane.paneKind === "file" && fileDocumentStates.get(pane.id)?.dirty, diff --git a/src/components/terminal/TabContextMenu.tsx b/src/components/terminal/TabContextMenu.tsx index 92b0ba36..64600fe3 100644 --- a/src/components/terminal/TabContextMenu.tsx +++ b/src/components/terminal/TabContextMenu.tsx @@ -178,6 +178,18 @@ export default function TabContextMenu({ } }, [t, tab.id, tab.locked, updateTab]); + const handleRestoreAutomaticTitle = useCallback(async () => { + try { + await updateTab( + tab.id, + { customName: undefined }, + { immediatePersist: true }, + ); + } catch { + toast.error(t("tabCtx.restoreAutomaticTitleFailed")); + } + }, [t, tab.id, updateTab]); + const handleOpenAI = useCallback( (action: "explain_output" | "analyze_error") => { onActivateTab(tab.id); @@ -246,6 +258,13 @@ export default function TabContextMenu({ {t("tabCtx.rename")} + {tab.customName ? ( + void handleRestoreAutomaticTitle()}> + + {t("tabCtx.restoreAutomaticTitle")} + + ) : null} + void handleToggleLocked()}> {tab.locked ? ( diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index 2b941280..64095f80 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -117,7 +117,20 @@ import type { PendingWakeEvent, XTermInternalTrimSource, } from "./xterminalInternalTypes"; -import { isSessionNotFoundError } from "./xterminalKeyboardInput"; +import { + createXTerminalDataOriginTracker, + isSessionNotFoundError, + resolveXTerminalDataOrigin, +} from "./xterminalKeyboardInput"; +import { + markTerminalUserInput, + registerTerminalUserInputMarker, +} from "@/lib/terminalControlInput"; +import { XTERM_SECURE_WINDOW_OPTIONS } from "@/lib/xtermSecurity"; +import { + publishApplicationTitle, + resumeDynamicTitlePublication, +} from "@/lib/dynamicTabTitles"; import { serializeTerminalSnapshot, writeTextInFrames, @@ -489,6 +502,7 @@ export default function XTerminal({ detachedHibernateEpochRef.current = null; } hibernationPhaseRef.current = "idle"; + resumeDynamicTitlePublication(sessionIdRef.current); logHibernation( "rollback", "Rolled back detached renderer on component unmount", @@ -500,6 +514,7 @@ export default function XTerminal({ }) .catch((error) => { hibernationPhaseRef.current = "failed"; + resumeDynamicTitlePublication(sessionIdRef.current); logHibernation( "fail", "Failed to roll back detached renderer on component unmount", @@ -740,6 +755,7 @@ export default function XTerminal({ theme: { ...terminalThemeColors }, allowTransparency: terminalTransparencyEnabled, allowProposedApi: true, + windowOptions: XTERM_SECURE_WINDOW_OPTIONS, vtExtensions: { kittyKeyboard: true }, }); @@ -795,6 +811,84 @@ export default function XTerminal({ installTerminalImageAddon(terminal, { sessionId, sessionType }); terminal.open(containerRef.current); + const coreService = (terminal as Terminal & XTermInternalTrimSource)._core + ?.coreService; + const canDistinguishTerminalResponses = + typeof coreService?.onUserInput === "function"; + const inputOriginTracker = createXTerminalDataOriginTracker(); + const userInputDisposable = coreService?.onUserInput?.(() => { + inputOriginTracker.markUserInput(); + }); + const unregisterUserInputMarker = registerTerminalUserInputMarker( + terminal, + () => inputOriginTracker.markUserInput(), + ); + let fallbackFocusReport: "\x1b[I" | "\x1b[O" | null = null; + const fallbackUserInputEvents = [ + "keydown", + "beforeinput", + "paste", + "compositionend", + "pointerdown", + "pointermove", + "pointerup", + "wheel", + "drop", + ] as const; + const markFallbackUserInput = (event: Event) => { + if ( + typeof PointerEvent !== "undefined" && + event instanceof PointerEvent && + event.type === "pointermove" && + event.buttons === 0 + ) { + return; + } + if ( + event.type === "compositionend" || + (typeof KeyboardEvent !== "undefined" && + event instanceof KeyboardEvent && + event.keyCode === 229) + ) { + // xterm defers IME data with setTimeout(0), beyond this DOM event. + inputOriginTracker.markDeferredUserInput(); + } + inputOriginTracker.beginUserInputEvent(); + queueMicrotask(() => inputOriginTracker.endUserInputEvent()); + }; + const markFallbackFocusReport = (event: Event) => { + fallbackFocusReport = event.type === "focusin" ? "\x1b[I" : "\x1b[O"; + queueMicrotask(() => { + fallbackFocusReport = null; + }); + }; + if (!canDistinguishTerminalResponses) { + for (const eventName of fallbackUserInputEvents) { + containerRef.current.addEventListener( + eventName, + markFallbackUserInput, + true, + ); + } + containerRef.current.addEventListener( + "focusin", + markFallbackFocusReport, + true, + ); + containerRef.current.addEventListener( + "focusout", + markFallbackFocusReport, + true, + ); + } + + const flushPendingDynamicTitle = () => { + resumeDynamicTitlePublication(sessionId); + }; + const titleDisposable = terminal.onTitleChange((title) => { + publishApplicationTitle(sessionId, title); + }); + const trimDisposable = ( terminal as Terminal & XTermInternalTrimSource )._core?._bufferService?.buffers?.normal?.lines?.onTrim?.((amount) => { @@ -914,19 +1008,24 @@ export default function XTerminal({ } }; - const sendRawInput = (data: string, command: string | null) => { + const sendRawInput = ( + data: string, + command: string | null, + origin: "keyboard" | "terminal_response" = "keyboard", + ) => { const peers = syncPeerSessionIdsRef.current; - if (peers && peers.length > 0) { + if (origin === "keyboard" && peers && peers.length > 0) { return sendSessionInputWithSync(sessionId, data, peers, { preview: null, registerSubmission: command, + origin, }).catch(() => {}); - } else { + } return sendSessionInput(sessionId, data, { preview: null, registerSubmission: command, + origin, }).catch(() => {}); - } }; const canReconnectDisconnectedSession = () => @@ -1182,6 +1281,7 @@ export default function XTerminal({ } const runPaste = () => { + markTerminalUserInput(terminal); terminal.paste(text); terminal.focus(); requestAnimationFrame(() => { @@ -1887,8 +1987,20 @@ export default function XTerminal({ logHibernation, zmodemHandler, replayPendingWakeEvents, + settleOutputAfterAttach: () => + flushFrameGateAndDrain("dynamic_title_attach"), + flushPendingDynamicTitle, + }); + void sessionEvents.setup().catch((error) => { + resumeDynamicTitlePublication(sessionId); + logger.error({ + domain: "session.lifecycle", + event: "session_listener_setup_failed", + message: "Failed to initialize terminal session listeners", + ids: { session_id: sessionId }, + error, + }); }); - void sessionEvents.setup(); const removePreviewListener = listenSessionInputPreview( sessionId, @@ -1896,6 +2008,18 @@ export default function XTerminal({ ); const dataDisposable = terminal.onData((data) => { + const trackedOrigin = inputOriginTracker.consume(); + const origin = resolveXTerminalDataOrigin( + trackedOrigin, + data, + canDistinguishTerminalResponses, + fallbackFocusReport, + ); + if (data === fallbackFocusReport) fallbackFocusReport = null; + if (origin === "terminal_response") { + void sendRawInput(data, null, origin); + return; + } if (aiCapturingRef.current) return; if (hibernationPhaseRef.current !== "idle") { requestWake("input"); @@ -2163,6 +2287,7 @@ export default function XTerminal({ detachedHibernateEpochRef.current = null; } hibernationPhaseRef.current = "idle"; + resumeDynamicTitlePublication(sessionId); logHibernation( "rollback", "Rolled back detached renderer during cleanup", @@ -2174,6 +2299,7 @@ export default function XTerminal({ }) .catch((error) => { hibernationPhaseRef.current = "failed"; + resumeDynamicTitlePublication(sessionId); logHibernation( "fail", "Failed to roll back detached renderer during cleanup", @@ -2193,6 +2319,28 @@ export default function XTerminal({ pasteTextRef.current = () => {}; resetCredentialAutofill(); + if (!canDistinguishTerminalResponses) { + for (const eventName of fallbackUserInputEvents) { + containerEl.removeEventListener( + eventName, + markFallbackUserInput, + true, + ); + } + containerEl.removeEventListener( + "focusin", + markFallbackFocusReport, + true, + ); + containerEl.removeEventListener( + "focusout", + markFallbackFocusReport, + true, + ); + } + unregisterUserInputMarker(); + userInputDisposable?.dispose(); + titleDisposable.dispose(); oscDisposable.dispose(); remoteColorOscGuardDisposable.dispose(); clipboardOscDisposable.dispose(); @@ -2235,6 +2383,9 @@ export default function XTerminal({ ) { preservedReconnectContentRef.current = captureReconnectSnapshot(); } + if (!isHibernateRendererCleanup) { + resumeDynamicTitlePublication(sessionId); + } terminal.dispose(); terminalRef.current = null; setTerminalInstance(null); diff --git a/src/components/terminal/terminalOutputDrain.test.ts b/src/components/terminal/terminalOutputDrain.test.ts index 45ee06fb..64769bfd 100644 --- a/src/components/terminal/terminalOutputDrain.test.ts +++ b/src/components/terminal/terminalOutputDrain.test.ts @@ -538,4 +538,19 @@ describe("TerminalOutputDrain", () => { await expect(drain.waitForIdle(100)).resolves.toBe(true); expect(writes.join("")).toBe("\x1b[?25lxx\x1b[?25h"); }); + + it("waitForIdle has a hard timeout when xterm never calls back", async () => { + const { advance, drain, pendingWriteCallbacks } = createHarness({ + autoCompleteWrites: false, + }); + + drain.setMode("hibernating"); + drain.enqueue({ data: "blocked", bytes: 7 }); + const idle = drain.waitForIdle(100); + await settle(); + expect(pendingWriteCallbacks).toHaveLength(1); + + advance(100); + await expect(idle).resolves.toBe(false); + }); }); diff --git a/src/components/terminal/terminalOutputDrain.ts b/src/components/terminal/terminalOutputDrain.ts index f6ea54c4..b92ceda4 100644 --- a/src/components/terminal/terminalOutputDrain.ts +++ b/src/components/terminal/terminalOutputDrain.ts @@ -179,14 +179,26 @@ export class TerminalOutputDrain { waitForIdle(timeoutMs: number) { const deadline = this.timers.now() + timeoutMs; return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + this.timers.clearTimeout(hardTimeout); + resolve(value); + }; + const hardTimeout = this.timers.setTimeout( + () => finish(false), + Math.max(0, timeoutMs), + ); const poll = () => { + if (settled) return; if (this.disposed) { - resolve(false); + finish(false); return; } - if (this.timers.now() > deadline) { - resolve(false); + if (this.timers.now() >= deadline) { + finish(false); return; } @@ -194,6 +206,7 @@ export class TerminalOutputDrain { const payload = dequeueOutputChunk(this.queue, this.options.getWriteChunkBytes()); if (payload) { void this.writePayload(payload).then(() => { + if (settled) return; this.flushPendingAck(true); poll(); }); @@ -207,7 +220,7 @@ export class TerminalOutputDrain { } this.flushPendingAck(true); - resolve(this.queue.bytes === 0 && this.writingBytes === 0); + finish(this.queue.bytes === 0 && this.writingBytes === 0); }; this.cancelForeground(); diff --git a/src/components/terminal/xterminalHibernationController.ts b/src/components/terminal/xterminalHibernationController.ts index 0efda2b6..411d3500 100644 --- a/src/components/terminal/xterminalHibernationController.ts +++ b/src/components/terminal/xterminalHibernationController.ts @@ -1,4 +1,8 @@ import type { Terminal } from "@xterm/xterm"; +import { + pauseDynamicTitlePublication, + resumeDynamicTitlePublication, +} from "@/lib/dynamicTabTitles"; import { invoke } from "@/lib/invoke"; import type { TerminalReconnectSnapshot } from "@/lib/terminalReconnectHistory"; import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; @@ -153,13 +157,18 @@ export function createXTerminalHibernationController({ await invoke("attach_session", { sessionId }); detachedHibernateEpochRef.current = null; hibernationPhaseRef.current = "idle"; + resumeDynamicTitlePublication(sessionId); logHibernation("rollback", "Rolled back detached terminal renderer", { reason, epoch, }); } catch (error) { - if (enterDisconnectedStateIfAttachSessionMissing(error)) return; + if (enterDisconnectedStateIfAttachSessionMissing(error)) { + resumeDynamicTitlePublication(sessionId); + return; + } hibernationPhaseRef.current = "failed"; + resumeDynamicTitlePublication(sessionId); logHibernation( "fail", "Failed to roll back detached terminal renderer", @@ -228,6 +237,7 @@ export function createXTerminalHibernationController({ hibernationPhaseRef.current = "preparing"; hibernationPendingRef.current = true; + pauseDynamicTitlePublication(sessionId); logHibernation("start", "Starting terminal renderer hibernation", { epoch, }); @@ -265,6 +275,7 @@ export function createXTerminalHibernationController({ } if (!drainedBeforeDetach) { hibernationPhaseRef.current = "idle"; + resumeDynamicTitlePublication(sessionId); logHibernation( "drain_timeout", "Timed out draining terminal output before hibernation", @@ -369,6 +380,9 @@ export function createXTerminalHibernationController({ ) { hibernationPhaseRef.current = "idle"; } + if (hibernationPhaseRef.current === "idle") { + resumeDynamicTitlePublication(sessionId); + } } }; @@ -398,6 +412,7 @@ export function createXTerminalHibernationController({ if (phase === "preparing") { hibernationPhaseRef.current = "idle"; hibernationPendingRef.current = false; + resumeDynamicTitlePublication(sessionId); updateOutputDrainMode(); scheduleHibernate(); return; diff --git a/src/components/terminal/xterminalInputOrigin.integration.test.ts b/src/components/terminal/xterminalInputOrigin.integration.test.ts new file mode 100644 index 00000000..ae977acc --- /dev/null +++ b/src/components/terminal/xterminalInputOrigin.integration.test.ts @@ -0,0 +1,38 @@ +import type { Terminal as XTermTerminal } from "@xterm/xterm"; +import { describe, expect, it, vi } from "vitest"; +import { createXTerminalDataOriginTracker } from "./xterminalKeyboardInput"; +import type { XTermInternalTrimSource } from "./xterminalInternalTypes"; + +describe("xterm input-origin compatibility adapter", () => { + it("fires onUserInput for real input but not a DA terminal response", async () => { + const canvas = vi + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ + measureText: () => ({ width: 10 }), + } as unknown as CanvasRenderingContext2D); + const { Terminal } = await import("@xterm/xterm"); + const terminal = new Terminal(); + const coreService = (terminal as XTermTerminal & XTermInternalTrimSource) + ._core?.coreService; + expect(typeof coreService?.onUserInput).toBe("function"); + + const tracker = createXTerminalDataOriginTracker(); + const origins: string[] = []; + const userDisposable = coreService?.onUserInput?.(() => { + tracker.markUserInput(); + }); + const dataDisposable = terminal.onData(() => { + origins.push(tracker.consume()); + }); + + terminal.input("x", true); + await new Promise((resolve) => terminal.write("\x1b[c", resolve)); + + expect(origins).toEqual(["keyboard", "terminal_response"]); + + dataDisposable.dispose(); + userDisposable?.dispose(); + terminal.dispose(); + canvas.mockRestore(); + }); +}); diff --git a/src/components/terminal/xterminalInternalTypes.ts b/src/components/terminal/xterminalInternalTypes.ts index 0f16dc62..b4e5999b 100644 --- a/src/components/terminal/xterminalInternalTypes.ts +++ b/src/components/terminal/xterminalInternalTypes.ts @@ -3,6 +3,11 @@ import type { ZmodemEventPayload } from "./zmodemTerminalEvents"; export interface XTermInternalTrimSource { _core?: { + coreService?: { + onUserInput?: (listener: () => void) => { + dispose: () => void; + }; + }; _bufferService?: { buffers?: { normal?: { diff --git a/src/components/terminal/xterminalKeyboardController.ts b/src/components/terminal/xterminalKeyboardController.ts index 45fcf3a6..c20b2d4d 100644 --- a/src/components/terminal/xterminalKeyboardController.ts +++ b/src/components/terminal/xterminalKeyboardController.ts @@ -7,7 +7,10 @@ import { matchesKeyEvent, resolveIndexedKeys, } from "@/lib/shortcutRegistry"; -import { sendTerminalClearInput } from "@/lib/terminalControlInput"; +import { + markTerminalUserInput, + sendTerminalClearInput, +} from "@/lib/terminalControlInput"; import { applyTerminalInputData, type TerminalInputState, @@ -91,6 +94,11 @@ export function installXTerminalKeyboardController({ syncSuggestionsWithInputState, lastSelectionRef, }: InstallXTerminalKeyboardControllerParams) { + const inputFromKeyboardController = (data: string) => { + markTerminalUserInput(terminal); + terminal.input(data, true); + }; + const getDirectInputDataFromKeyEvent = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey || e.altKey) return null; if (e.key === "Dead" || e.key === "Process" || e.key === "Unidentified") @@ -264,7 +272,7 @@ export function installXTerminalKeyboardController({ if (terminal.hasSelection() && !getSmartCursorSelectedInputRange()) { if (directInputData) { e.preventDefault(); - terminal.input(directInputData, false); + inputFromKeyboardController(directInputData); return false; } if ( @@ -274,12 +282,12 @@ export function installXTerminalKeyboardController({ !e.altKey ) { e.preventDefault(); - terminal.input("\x7f", false); + inputFromKeyboardController("\x7f"); return false; } if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); - terminal.input("\r", false); + inputFromKeyboardController("\r"); return false; } if ( @@ -290,7 +298,7 @@ export function installXTerminalKeyboardController({ !e.shiftKey ) { e.preventDefault(); - terminal.input("\x1b[D", false); + inputFromKeyboardController("\x1b[D"); return false; } if ( @@ -301,7 +309,7 @@ export function installXTerminalKeyboardController({ !e.shiftKey ) { e.preventDefault(); - terminal.input("\x1b[C", false); + inputFromKeyboardController("\x1b[C"); return false; } if ( @@ -312,7 +320,7 @@ export function installXTerminalKeyboardController({ !e.shiftKey ) { e.preventDefault(); - terminal.input("\x1b[A", false); + inputFromKeyboardController("\x1b[A"); return false; } if ( @@ -323,7 +331,7 @@ export function installXTerminalKeyboardController({ !e.shiftKey ) { e.preventDefault(); - terminal.input("\x1b[B", false); + inputFromKeyboardController("\x1b[B"); return false; } if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { @@ -358,65 +366,65 @@ export function installXTerminalKeyboardController({ const keyLower = e.key.toLowerCase(); if (ctrlCharMap[keyLower]) { e.preventDefault(); - terminal.input(ctrlCharMap[keyLower], false); + inputFromKeyboardController(ctrlCharMap[keyLower]); return false; } if (e.key === "ArrowLeft") { e.preventDefault(); - terminal.input("\x1b[1;5D", false); + inputFromKeyboardController("\x1b[1;5D"); return false; } if (e.key === "ArrowRight") { e.preventDefault(); - terminal.input("\x1b[1;5C", false); + inputFromKeyboardController("\x1b[1;5C"); return false; } if (e.key === "ArrowUp") { e.preventDefault(); - terminal.input("\x1b[1;5A", false); + inputFromKeyboardController("\x1b[1;5A"); return false; } if (e.key === "ArrowDown") { e.preventDefault(); - terminal.input("\x1b[1;5B", false); + inputFromKeyboardController("\x1b[1;5B"); return false; } } if ((e.altKey || e.metaKey) && !e.ctrlKey && !e.shiftKey) { if (e.key === "ArrowLeft") { e.preventDefault(); - terminal.input("\x1b[1;3D", false); + inputFromKeyboardController("\x1b[1;3D"); return false; } if (e.key === "ArrowRight") { e.preventDefault(); - terminal.input("\x1b[1;3C", false); + inputFromKeyboardController("\x1b[1;3C"); return false; } if (e.key === "ArrowUp") { e.preventDefault(); - terminal.input("\x1b[1;3A", false); + inputFromKeyboardController("\x1b[1;3A"); return false; } if (e.key === "ArrowDown") { e.preventDefault(); - terminal.input("\x1b[1;3B", false); + inputFromKeyboardController("\x1b[1;3B"); return false; } const keyLower = e.key.toLowerCase(); if (keyLower === "b") { e.preventDefault(); - terminal.input("\x1bb", false); + inputFromKeyboardController("\x1bb"); return false; } if (keyLower === "f") { e.preventDefault(); - terminal.input("\x1bf", false); + inputFromKeyboardController("\x1bf"); return false; } if (keyLower === "d") { e.preventDefault(); - terminal.input("\x1bd", false); + inputFromKeyboardController("\x1bd"); return false; } } @@ -500,7 +508,7 @@ export function installXTerminalKeyboardController({ const ctrlPrintableInput = getCtrlPrintableCsiuInput(e); if (ctrlPrintableInput) { e.preventDefault(); - terminal.input(ctrlPrintableInput, false); + inputFromKeyboardController(ctrlPrintableInput); return false; } diff --git a/src/components/terminal/xterminalKeyboardInput.test.ts b/src/components/terminal/xterminalKeyboardInput.test.ts new file mode 100644 index 00000000..84217a9b --- /dev/null +++ b/src/components/terminal/xterminalKeyboardInput.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createXTerminalDataOriginTracker, + resolveXTerminalDataOrigin, +} from "./xterminalKeyboardInput"; + +describe("xterm data origin tracking", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("distinguishes xterm terminal responses from observed user input", () => { + const tracker = createXTerminalDataOriginTracker(); + + expect(tracker.consume()).toBe("terminal_response"); + tracker.markUserInput(); + expect(tracker.consume()).toBe("keyboard"); + expect(tracker.consume()).toBe("terminal_response"); + }); + + it("clears a cancelled deferred IME marker before a later response", async () => { + vi.useFakeTimers(); + const tracker = createXTerminalDataOriginTracker(); + + tracker.markDeferredUserInput(); + await vi.runAllTimersAsync(); + expect(tracker.consume()).toBe("terminal_response"); + + tracker.markDeferredUserInput(); + expect(tracker.consume()).toBe("keyboard"); + await vi.runAllTimersAsync(); + expect(tracker.consume()).toBe("terminal_response"); + }); + + it("does not override explicitly marked paste bytes that resemble focus reports", () => { + expect(resolveXTerminalDataOrigin("keyboard", "\x1b[I", false, null)).toBe( + "keyboard", + ); + expect( + resolveXTerminalDataOrigin("keyboard", "\x1b[I", false, "\x1b[I"), + ).toBe("terminal_response"); + }); + + it("uses a DOM-event scope as the compatibility fallback", () => { + const tracker = createXTerminalDataOriginTracker(); + + expect(tracker.consume()).toBe("terminal_response"); + tracker.beginUserInputEvent(); + expect(tracker.consume()).toBe("keyboard"); + expect(tracker.consume()).toBe("keyboard"); + tracker.endUserInputEvent(); + expect(tracker.consume()).toBe("terminal_response"); + }); +}); diff --git a/src/components/terminal/xterminalKeyboardInput.ts b/src/components/terminal/xterminalKeyboardInput.ts index 3722e234..4d79acb1 100644 --- a/src/components/terminal/xterminalKeyboardInput.ts +++ b/src/components/terminal/xterminalKeyboardInput.ts @@ -2,6 +2,57 @@ import type { SessionType } from "@/types/global"; const LEGACY_CTRL_KEYS = new Set([" ", "@", "[", "\\", "]", "^", "_", "?"]); +export type XTerminalDataOrigin = "keyboard" | "terminal_response"; + +export function resolveXTerminalDataOrigin( + trackedOrigin: XTerminalDataOrigin, + data: string, + canObserveUserInput: boolean, + expectedFocusReport: "\x1b[I" | "\x1b[O" | null, +): XTerminalDataOrigin { + return !canObserveUserInput && data === expectedFocusReport + ? "terminal_response" + : trackedOrigin; +} + +export function createXTerminalDataOriginTracker() { + let nextDataWasUserInput = false; + let userEventDepth = 0; + let deferredGeneration = 0; + return { + markUserInput() { + deferredGeneration += 1; + nextDataWasUserInput = true; + }, + markDeferredUserInput() { + const generation = ++deferredGeneration; + nextDataWasUserInput = true; + // xterm schedules IME extraction with setTimeout(0) from the target + // handler. A nested timer runs after that extraction, but still clears a + // cancelled composition before unrelated terminal responses arrive. + setTimeout(() => { + setTimeout(() => { + if (deferredGeneration === generation) { + nextDataWasUserInput = false; + } + }, 0); + }, 0); + }, + beginUserInputEvent() { + userEventDepth += 1; + }, + endUserInputEvent() { + userEventDepth = Math.max(0, userEventDepth - 1); + }, + consume(): XTerminalDataOrigin { + const wasUserInput = nextDataWasUserInput || userEventDepth > 0; + nextDataWasUserInput = false; + deferredGeneration += 1; + return wasUserInput ? "keyboard" : "terminal_response"; + }, + }; +} + export function getCtrlPrintableCsiuInput(e: KeyboardEvent): string | null { if (!e.ctrlKey || e.metaKey || e.altKey || e.key.length !== 1) return null; diff --git a/src/components/terminal/xterminalSessionEvents.test.ts b/src/components/terminal/xterminalSessionEvents.test.ts new file mode 100644 index 00000000..08ff6d6d --- /dev/null +++ b/src/components/terminal/xterminalSessionEvents.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn().mockResolvedValue(undefined), + listen: vi.fn(), +})); + +vi.mock("@/lib/invoke", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +import { createXTerminalSessionEvents } from "./xterminalSessionEvents"; + +function params(overrides: Record = {}) { + return { + sessionId: "ssh-1", + terminal: { focus: vi.fn() }, + frameGate: { enqueue: vi.fn() }, + sessionIdRef: { current: "ssh-1" }, + visibleRef: { current: false }, + lastErrorNoticeAtRef: { current: 0 }, + aiCapturingRef: { current: false }, + zmodemActiveRef: { current: false }, + inputStateRef: { current: {} }, + alternateScreenTrackerRef: { current: { ingest: vi.fn() } }, + hibernationPhaseRef: { current: "waking" }, + detachedHibernateEpochRef: { current: 1 }, + onConnectionErrorRef: { current: undefined }, + tRef: { current: (key: string) => key }, + isTerminalAlive: () => true, + requestWake: vi.fn(), + enterDisconnectedState: vi.fn(), + enterDisconnectedStateIfAttachSessionMissing: vi.fn(() => false), + noteSkippedOutput: vi.fn(), + noteOutputActivity: vi.fn(), + updateCredentialPromptInputMode: vi.fn(), + feedCredentialOutput: vi.fn(), + maybeRecoverPerformanceMode: vi.fn(), + refreshOutputPressureMode: vi.fn(), + noteShellCommand: vi.fn(), + clearCredentialPromptInputMode: vi.fn(), + dismissSuggestions: vi.fn(), + writeTerminalTextAfterOutputQueue: vi.fn().mockResolvedValue(undefined), + initialReplayPromise: Promise.resolve(), + updateOutputDrainMode: vi.fn(), + logHibernation: vi.fn(), + zmodemHandler: { handle: vi.fn() }, + replayPendingWakeEvents: vi.fn(), + settleOutputAfterAttach: vi.fn().mockResolvedValue(true), + flushPendingDynamicTitle: vi.fn(), + ...overrides, + }; +} + +describe("xterminalSessionEvents listener setup failure", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.invoke.mockResolvedValue(undefined); + }); + + it("keeps backend output detached until a slow listener retry succeeds", async () => { + const disposeOutput = vi.fn(); + let listenerFailures = 0; + mocks.listen.mockImplementation(async (eventName: string) => { + if (eventName === "terminal-output-ssh-1") return disposeOutput; + if (listenerFailures < 3) { + listenerFailures += 1; + throw new Error("listener unavailable"); + } + return vi.fn(); + }); + const options = params(); + const events = createXTerminalSessionEvents(options as never); + + const setup = events.setup(); + await vi.advanceTimersByTimeAsync(300); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "attach_session", + expect.anything(), + ); + await vi.advanceTimersByTimeAsync(5_000); + await setup; + + expect(disposeOutput).toHaveBeenCalledTimes(3); + expect(mocks.invoke).toHaveBeenCalledWith("attach_session", { + sessionId: "ssh-1", + }); + expect(options.detachedHibernateEpochRef.current).toBeNull(); + expect(options.hibernationPhaseRef.current).toBe("idle"); + expect(options.flushPendingDynamicTitle).toHaveBeenCalledTimes(1); + }); + + it("keeps publication paused when post-attach output drain times out", async () => { + mocks.listen.mockResolvedValue(vi.fn()); + const options = params({ + settleOutputAfterAttach: vi.fn().mockResolvedValue(false), + }); + const events = createXTerminalSessionEvents(options as never); + + await events.setup(); + + expect(options.flushPendingDynamicTitle).not.toHaveBeenCalled(); + expect(options.hibernationPhaseRef.current).toBe("failed"); + }); + + it("cancels retry work and resumes publication when disposed", async () => { + mocks.listen.mockRejectedValue(new Error("listener unavailable")); + const options = params(); + const events = createXTerminalSessionEvents(options as never); + + const setup = events.setup(); + await Promise.resolve(); + events.dispose(); + await vi.runAllTimersAsync(); + await setup; + + expect(options.flushPendingDynamicTitle).toHaveBeenCalled(); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "attach_session", + expect.anything(), + ); + }); +}); diff --git a/src/components/terminal/xterminalSessionEvents.ts b/src/components/terminal/xterminalSessionEvents.ts index 9257404c..e0e39df6 100644 --- a/src/components/terminal/xterminalSessionEvents.ts +++ b/src/components/terminal/xterminalSessionEvents.ts @@ -75,6 +75,8 @@ interface CreateXTerminalSessionEventsParams { ) => void; zmodemHandler: ZmodemHandler; replayPendingWakeEvents: () => void; + settleOutputAfterAttach: () => Promise; + flushPendingDynamicTitle: () => void; } export function createXTerminalSessionEvents({ @@ -111,8 +113,12 @@ export function createXTerminalSessionEvents({ logHibernation, zmodemHandler, replayPendingWakeEvents, + settleOutputAfterAttach, + flushPendingDynamicTitle, }: CreateXTerminalSessionEventsParams) { let disposed = false; + let retryTimer: number | null = null; + let resolveRetryWait: (() => void) | null = null; const unlisteners: UnlistenFn[] = []; const addUnlistener = (unlisten: UnlistenFn) => { @@ -124,7 +130,13 @@ export function createXTerminalSessionEvents({ return true; }; - const setup = async () => { + const disposeRegisteredListeners = () => { + for (const unlisten of unlisteners.splice(0)) { + unlisten(); + } + }; + + const setupOnce = async () => { const nextOutputUnlisten = await listen( `terminal-output-${sessionId}`, (event) => { @@ -283,6 +295,13 @@ export function createXTerminalSessionEvents({ try { await initialReplayPromise.catch(() => {}); await invoke("attach_session", { sessionId }); + // The backend acknowledgement means its pending payload was emitted, + // not that xterm has parsed it yet. Drain the renderer queue before + // publishing the settled title/cwd snapshot. + const replaySettled = await settleOutputAfterAttach(); + if (!replaySettled) { + throw new Error("Timed out draining terminal output after attach"); + } detachedHibernateEpochRef.current = null; if ( hibernationPhaseRef.current === "waking" || @@ -293,6 +312,7 @@ export function createXTerminalSessionEvents({ }); } hibernationPhaseRef.current = "idle"; + flushPendingDynamicTitle(); updateOutputDrainMode(); } catch (error) { hibernationPhaseRef.current = "failed"; @@ -306,13 +326,63 @@ export function createXTerminalSessionEvents({ } }; + const setup = async () => { + const maxAttempts = 3; + let recoveryDelayMs = 5_000; + for (let attempt = 1; !disposed; attempt += 1) { + try { + await setupOnce(); + return; + } catch (error) { + disposeRegisteredListeners(); + if (disposed) return; + + const burstAttempt = ((attempt - 1) % maxAttempts) + 1; + const exhaustedBurst = burstAttempt === maxAttempts; + if (exhaustedBurst) { + hibernationPhaseRef.current = "failed"; + logHibernation( + "fail", + "Failed to register terminal session listeners; retrying slowly while backend output remains detached", + { + reason: "listener_setup", + attempts: attempt, + retry_delay_ms: recoveryDelayMs, + }, + error, + ); + enterDisconnectedStateIfAttachSessionMissing(error); + } + + const delayMs = exhaustedBurst ? recoveryDelayMs : 100 * burstAttempt; + if (exhaustedBurst) { + recoveryDelayMs = Math.min(recoveryDelayMs * 2, 30_000); + } + await new Promise((resolve) => { + resolveRetryWait = resolve; + retryTimer = window.setTimeout(() => { + retryTimer = null; + resolveRetryWait = null; + resolve(); + }, delayMs); + }); + if (disposed) return; + } + } + }; + return { setup, dispose: () => { disposed = true; - for (const unlisten of unlisteners.splice(0)) { - unlisten(); + if (retryTimer !== null) { + window.clearTimeout(retryTimer); + retryTimer = null; } + resolveRetryWait?.(); + resolveRetryWait = null; + disposeRegisteredListeners(); + flushPendingDynamicTitle(); }, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 526190f0..3e680c5f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -529,6 +529,9 @@ "descriptionPlaceholder": "e.g. Web server for project X", "directPassword": "Direct input", "done": "Done", + "dynamicTabTitle": "Automatically update the tab name", + "dynamicTabTitleCustomArgsHint": "Custom shell options may prevent automatic folder tracking. App names and existing folder information can still be shown.", + "dynamicTabTitleDesc": "Show the current app or folder as the tab name and NyaTerm window title. The name updates automatically as you work. Changes take effect the next time you connect.", "editConnection": "Edit Conn.", "enabled": "Enabled", "encodingSettings": "Terminal", @@ -619,6 +622,8 @@ "recordingModeTranscript": "Transcript", "recordingUseGlobal": "Use global settings", "refresh": "Refresh", + "remoteDynamicTabTitle": "Allow remote apps to update the tab name", + "remoteDynamicTabTitleDesc": "Remote apps can update the tab name and NyaTerm window title. Your saved connection name remains visible so you can always identify the connection. Changes take effect the next time you connect.", "removeCustomIcon": "Remove custom icon", "save": "Save", "savedPassword": "Saved password", @@ -2642,6 +2647,7 @@ "copyIp": "Copy IP Address", "copyName": "Copy Tab Name", "copySshAddress": "Copy SSH Address", + "copyWorkingDirectory": "Copy Working Directory", "disconnect": "Disconnect", "disconnectFailed": "Disconnect failed", "disconnectSuccess": "Disconnected", @@ -2670,6 +2676,8 @@ "renameFailed": "Failed to rename tab", "renameTitle": "Rename Tab", "resetColor": "Reset Color", + "restoreAutomaticTitle": "Restore Automatic Title", + "restoreAutomaticTitleFailed": "Failed to restore automatic title", "runCommandTitle": "Run Command in Duplicated Session", "sessionInfo": "Session Info", "setColor": "Set Tab Color", @@ -2681,7 +2689,8 @@ "sshAdvanced": "SSH Advanced", "unlockTab": "Unlock Tab", "unreadOutput": "New output", - "unsplit": "Merge All Panes" + "unsplit": "Merge All Panes", + "workingDirectoryCopied": "Working directory copied" }, "temporarySsh": { "baudRatePlaceholder": "Baud rate, e.g. 115200", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index a4a353b1..27847b73 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -528,6 +528,9 @@ "descriptionPlaceholder": "예: 프로젝트 X용 웹 서버", "directPassword": "직접 입력", "done": "완료", + "dynamicTabTitle": "탭 이름 자동 업데이트", + "dynamicTabTitleCustomArgsHint": "사용자 지정 터미널 옵션을 사용하면 현재 폴더를 자동으로 추적하지 못할 수 있습니다. 앱 이름과 기존 폴더 정보는 계속 표시할 수 있습니다.", + "dynamicTabTitleDesc": "현재 사용하는 앱이나 폴더를 탭 이름과 NyaTerm 창 제목으로 표시합니다. 작업 중 이름이 자동으로 업데이트되며, 변경 사항은 다음 연결부터 적용됩니다.", "editConnection": "연결 편집", "enabled": "사용함", "encodingSettings": "터미널", @@ -618,6 +621,8 @@ "recordingModeTranscript": "트랜스크립트", "recordingUseGlobal": "전역 설정 사용", "refresh": "새로고침", + "remoteDynamicTabTitle": "원격 앱의 탭 이름 업데이트 허용", + "remoteDynamicTabTitleDesc": "원격 앱이 탭 이름과 NyaTerm 창 제목을 업데이트하도록 허용합니다. 쉽게 구분할 수 있도록 저장된 연결 이름은 계속 표시됩니다. 변경 사항은 다음 연결부터 적용됩니다.", "removeCustomIcon": "사용자 지정 아이콘 제거", "save": "저장", "savedPassword": "저장된 비밀번호", @@ -2633,6 +2638,7 @@ "copyIp": "IP 주소 복사", "copyName": "탭 이름 복사", "copySshAddress": "SSH 주소 복사", + "copyWorkingDirectory": "작업 디렉터리 복사", "disconnect": "연결 끊기", "disconnectFailed": "연결 끊기 실패", "disconnectSuccess": "연결이 끊어졌습니다", @@ -2661,6 +2667,8 @@ "renameFailed": "탭 이름을 바꾸지 못했습니다", "renameTitle": "탭 이름 바꾸기", "resetColor": "색상 재설정", + "restoreAutomaticTitle": "자동 제목 복원", + "restoreAutomaticTitleFailed": "자동 제목을 복원하지 못했습니다", "runCommandTitle": "복제된 세션에서 명령 실행", "sessionInfo": "세션 정보", "setColor": "탭 색상 설정", @@ -2672,7 +2680,8 @@ "sshAdvanced": "SSH 고급", "unlockTab": "탭 잠금 해제", "unreadOutput": "새 출력", - "unsplit": "모든 창 병합" + "unsplit": "모든 창 병합", + "workingDirectoryCopied": "작업 디렉터리가 복사되었습니다" }, "temporarySsh": { "baudRatePlaceholder": "보드레이트, 예: 115200", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 31e60fa8..19b82a15 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -528,6 +528,9 @@ "descriptionPlaceholder": "例如:项目 X 的 Web 服务器", "directPassword": "直接输入", "done": "完成", + "dynamicTabTitle": "自动更新标签名称", + "dynamicTabTitleCustomArgsHint": "使用自定义终端参数时,当前文件夹可能无法自动跟随;应用名称和已有的目录信息仍可显示。", + "dynamicTabTitleDesc": "将当前应用或所在文件夹显示为标签名称和 NyaTerm 窗口标题。名称会随操作自动更新,修改将在下次连接时生效。", "editConnection": "编辑连接", "enabled": "启用", "encodingSettings": "终端", @@ -618,6 +621,8 @@ "recordingModeTranscript": "转写日志", "recordingUseGlobal": "使用全局设置", "refresh": "刷新", + "remoteDynamicTabTitle": "允许远端应用更新标签名称", + "remoteDynamicTabTitleDesc": "允许远端应用更新标签名称和 NyaTerm 窗口标题;已保存的连接名称会保留,方便识别。修改将在下次连接时生效。", "removeCustomIcon": "移除自定义图标", "save": "保存", "savedPassword": "已保存密码", @@ -2641,6 +2646,7 @@ "copyIp": "复制服务器 IP", "copyName": "复制标签名称", "copySshAddress": "复制 SSH 地址", + "copyWorkingDirectory": "复制工作目录", "disconnect": "断开连接", "disconnectFailed": "断开连接失败", "disconnectSuccess": "连接已断开", @@ -2669,6 +2675,8 @@ "renameFailed": "标签名称修改失败", "renameTitle": "重命名标签", "resetColor": "恢复默认颜色", + "restoreAutomaticTitle": "恢复自动标题", + "restoreAutomaticTitleFailed": "恢复自动标题失败", "runCommandTitle": "在复制的会话中执行命令", "sessionInfo": "会话信息设置", "setColor": "设置标签页颜色", @@ -2680,7 +2688,8 @@ "sshAdvanced": "SSH 高级", "unlockTab": "解除锁定标签", "unreadOutput": "有新输出", - "unsplit": "合并所有窗格" + "unsplit": "合并所有窗格", + "workingDirectoryCopied": "工作目录已复制" }, "temporarySsh": { "baudRatePlaceholder": "波特率,例如 115200", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 7af6b76a..b9cbf739 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -528,6 +528,9 @@ "descriptionPlaceholder": "例如:專案 X 的 Web 伺服器", "directPassword": "直接輸入", "done": "完成", + "dynamicTabTitle": "自動更新標籤名稱", + "dynamicTabTitleCustomArgsHint": "使用自訂終端參數時,目前資料夾可能無法自動跟隨;應用程式名稱與既有的目錄資訊仍可顯示。", + "dynamicTabTitleDesc": "將目前的應用程式或所在資料夾顯示為標籤名稱與 NyaTerm 視窗標題。名稱會隨操作自動更新,修改將於下次連線時生效。", "editConnection": "編輯連線", "enabled": "啟用", "encodingSettings": "終端", @@ -618,6 +621,8 @@ "recordingModeTranscript": "轉寫日誌", "recordingUseGlobal": "使用全域設定", "refresh": "重新整理", + "remoteDynamicTabTitle": "允許遠端應用程式更新標籤名稱", + "remoteDynamicTabTitleDesc": "允許遠端應用程式更新標籤名稱與 NyaTerm 視窗標題;已儲存的連線名稱會保留,方便辨識。修改將於下次連線時生效。", "removeCustomIcon": "移除自訂圖示", "save": "儲存", "savedPassword": "已儲存密碼", @@ -2636,6 +2641,7 @@ "copyIp": "複製伺服器 IP", "copyName": "複製標籤名稱", "copySshAddress": "複製 SSH 位址", + "copyWorkingDirectory": "複製工作目錄", "disconnect": "斷開連線", "disconnectFailed": "斷開連線失敗", "disconnectSuccess": "連線已斷開", @@ -2664,6 +2670,8 @@ "renameFailed": "標籤名稱修改失敗", "renameTitle": "重新命名標籤", "resetColor": "恢復預設顏色", + "restoreAutomaticTitle": "恢復自動標題", + "restoreAutomaticTitleFailed": "恢復自動標題失敗", "runCommandTitle": "在複製的工作階段中執行命令", "sessionInfo": "工作階段資訊設定", "setColor": "設定標籤頁顏色", @@ -2675,7 +2683,8 @@ "sshAdvanced": "SSH 高階", "unlockTab": "解除鎖定標籤", "unreadOutput": "有新輸出", - "unsplit": "合併所有窗格" + "unsplit": "合併所有窗格", + "workingDirectoryCopied": "工作目錄已複製" }, "temporarySsh": { "baudRatePlaceholder": "波特率,例如 115200", diff --git a/src/lib/dynamicTabTitles.test.ts b/src/lib/dynamicTabTitles.test.ts new file mode 100644 index 00000000..825b463a --- /dev/null +++ b/src/lib/dynamicTabTitles.test.ts @@ -0,0 +1,742 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + SessionCwdPresentation, + SessionInfo, +} from "@/types/global"; + +const mocks = vi.hoisted(() => { + const eventListeners = new Map void>>(); + const invoke = vi.fn(); + const listen = vi.fn( + async (eventName: string, callback: (event: { payload: unknown }) => void) => { + const callbacks = eventListeners.get(eventName) ?? new Set(); + callbacks.add(callback); + eventListeners.set(eventName, callbacks); + return () => { + callbacks.delete(callback); + }; + }, + ); + return { eventListeners, invoke, listen }; +}); + +vi.mock("@/lib/invoke", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); + +import { + getDynamicTitle, + getDynamicTitleStoreStatsForTests, + getDynamicTitlesSnapshot, + pauseDynamicTitlePublication, + publishApplicationTitle, + publishSessionCwdPresentation, + publishSessionMetadata, + resetDynamicTitlesForTests, + resumeDynamicTitlePublication, + sanitizeDynamicTitle, + startDynamicTitles, +} from "@/lib/dynamicTabTitles"; + +const cwd = ( + title = "~/project", + displayPath = "/home/alice/project", +): SessionCwdPresentation => ({ + title, + displayPath, + copyValue: displayPath, + copyAsUri: false, +}); + +const localSession = ( + enabled = true, + overrides: Partial = {}, +): SessionInfo => ({ + id: "local-1", + name: "Local Terminal", + session_type: "Local", + started_at: "", + connection_id: "conn-1", + connected: true, + owner_window_label: null, + ai_execution_profile: "auto", + injection_active: false, + dynamic_title_enabled: enabled, + dynamic_title_integration_active: enabled, + trusted_initial_title: null, + remote_file_browser_enabled: false, + remote_stats_enabled: false, + ssh_profile: null, + ...overrides, +}); + +const sshSession = (id = "ssh-1"): SessionInfo => ({ + ...localSession(true), + id, + name: "Production SSH", + session_type: "SSH", + injection_active: false, + dynamic_title_integration_active: false, + trusted_initial_title: null, + remote_file_browser_enabled: true, + remote_stats_enabled: true, +}); + +function emit(eventName: string, payload?: unknown) { + for (const callback of mocks.eventListeners.get(eventName) ?? []) { + callback({ payload }); + } +} + +function installDefaultListenMock() { + mocks.listen.mockImplementation(async (eventName: string, callback) => { + const callbacks = mocks.eventListeners.get(eventName) ?? new Set(); + callbacks.add(callback); + mocks.eventListeners.set(eventName, callbacks); + return () => { + callbacks.delete(callback); + }; + }); +} + +async function settle() { + await Promise.resolve(); + await Promise.resolve(); + await vi.runAllTimersAsync(); +} + +describe("dynamic title sanitizer", () => { + beforeEach(() => { + vi.useRealTimers(); + resetDynamicTitlesForTests(); + mocks.eventListeners.clear(); + vi.clearAllMocks(); + installDefaultListenMock(); + }); + + it("normalizes Unicode, removes control/bidi characters, and folds whitespace", () => { + expect( + sanitizeDynamicTitle("e\u0301\u0000\tA\u0085\u001b\u007f\u202E B\nC"), + ).toBe("é A B C"); + }); + + it("removes deprecated bidi formatting controls", () => { + expect(sanitizeDynamicTitle("prod\u206Aadmin\u206Bops\u206C")).toBe( + "prodadminops", + ); + }); + + it("preserves normal emoji and bounds huge input at grapheme boundaries", () => { + expect(sanitizeDynamicTitle("👩‍💻 · 项目")).toBe("👩‍💻 · 项目"); + const result = sanitizeDynamicTitle("👩‍💻".repeat(200)); + expect(Array.from(result ?? "").length).toBeLessThanOrEqual(256); + expect(result?.endsWith("…")).toBe(true); + expect(sanitizeDynamicTitle("x".repeat(1_000_000))?.length).toBeLessThanOrEqual( + 256, + ); + }); + + it("does not split a combining sequence when Intl.Segmenter is unavailable", () => { + const intl = globalThis.Intl as typeof Intl & { + Segmenter?: unknown; + }; + const original = intl.Segmenter; + Object.defineProperty(intl, "Segmenter", { + configurable: true, + value: undefined, + }); + try { + const value = `${"x".repeat(254)}q\u0327\u0301tail`; + const result = sanitizeDynamicTitle(value); + expect(result).toBe(`${"x".repeat(254)}…`); + expect(Array.from(result ?? "").length).toBeLessThanOrEqual(256); + } finally { + Object.defineProperty(intl, "Segmenter", { + configurable: true, + value: original, + }); + } + }); + + it("keeps ZWJ families and regional-indicator flags intact without Intl.Segmenter", () => { + const intl = globalThis.Intl as typeof Intl & { + Segmenter?: unknown; + }; + const original = intl.Segmenter; + Object.defineProperty(intl, "Segmenter", { + configurable: true, + value: undefined, + }); + try { + const joined = sanitizeDynamicTitle( + `${"x".repeat(252)}👨‍👩‍👧‍👦tail`, + ); + expect(joined).toBe(`${"x".repeat(252)}…`); + expect(joined).not.toContain("\u200D…"); + + const flag = sanitizeDynamicTitle(`${"x".repeat(254)}🇺🇸tail`); + expect(flag).toBe(`${"x".repeat(254)}…`); + expect(flag).not.toContain("🇺…"); + + const conjunct = sanitizeDynamicTitle(`${"x".repeat(253)}क्षtail`); + expect(conjunct).toBe(`${"x".repeat(253)}…`); + expect(conjunct).not.toContain("क्…"); + } finally { + Object.defineProperty(intl, "Segmenter", { + configurable: true, + value: original, + }); + } + }); + + it("does not claim heuristic secret redaction", () => { + expect(sanitizeDynamicTitle("token=secret-value")).toBe( + "token=secret-value", + ); + }); +}); + +describe("dynamic title state", () => { + beforeEach(() => { + vi.useFakeTimers(); + resetDynamicTitlesForTests(); + mocks.eventListeners.clear(); + vi.clearAllMocks(); + installDefaultListenMock(); + mocks.invoke.mockImplementation(async (command: string) => { + if (command === "list_sessions") return []; + if (command === "get_session_cwd_presentation") return null; + return null; + }); + }); + + it("coalesces updates and falls back to backend cwd on empty OSC title", () => { + publishSessionMetadata(localSession()); + publishSessionCwdPresentation("local-1", cwd()); + publishApplicationTitle("local-1", "Build"); + publishApplicationTitle("local-1", ""); + vi.advanceTimersByTime(75); + + const snapshot = getDynamicTitlesSnapshot().get("local-1"); + expect(snapshot?.effectiveTitle).toBe("~/project"); + expect(snapshot?.applicationTitle).toBeNull(); + expect(snapshot?.cwd?.displayPath).toBe("/home/alice/project"); + }); + + it("uses compatible cwd reports and explicit titles while Local integration is passive", () => { + publishSessionMetadata( + localSession(true, { dynamic_title_integration_active: false }), + ); + publishSessionCwdPresentation("local-1", cwd()); + vi.advanceTimersByTime(75); + + expect(getDynamicTitle("local-1")).toBe("~/project"); + expect( + getDynamicTitlesSnapshot().get("local-1")?.cwd?.displayPath, + ).toBe("/home/alice/project"); + + publishApplicationTitle("local-1", "Editor"); + vi.advanceTimersByTime(75); + expect(getDynamicTitle("local-1")).toBe("Editor"); + + publishApplicationTitle("local-1", ""); + vi.advanceTimersByTime(75); + expect(getDynamicTitle("local-1")).toBe("~/project"); + }); + + it("keeps a trusted local prefix on opt-in SSH titles and ignores cwd", () => { + publishSessionMetadata(sshSession()); + publishSessionCwdPresentation("ssh-1", cwd("remote", "/remote")); + publishApplicationTitle("ssh-1", " htop\u202E "); + vi.advanceTimersByTime(75); + + const snapshot = getDynamicTitlesSnapshot().get("ssh-1"); + expect(snapshot?.effectiveTitle).toBe("Production SSH · htop"); + expect(snapshot?.cwd).toBeNull(); + }); + + it("reserves room for a remote title after a maximum-length SSH prefix", () => { + publishSessionMetadata({ + ...sshSession(), + name: "P".repeat(256), + }); + publishApplicationTitle("ssh-1", "REMOTE"); + vi.advanceTimersByTime(75); + + const title = getDynamicTitle("ssh-1") ?? ""; + expect(title).toContain(" · REMOTE"); + expect(title.startsWith("P")).toBe(true); + expect(Array.from(title).length).toBeLessThanOrEqual(256); + }); + + it("uses the remaining SSH title budget for a long remote title", () => { + publishSessionMetadata({ ...sshSession(), name: "Prod" }); + publishApplicationTitle("ssh-1", "R".repeat(512)); + vi.advanceTimersByTime(75); + + const title = getDynamicTitle("ssh-1") ?? ""; + expect(title.startsWith("Prod · R")).toBe(true); + expect(title.endsWith("…")).toBe(true); + expect(Array.from(title).length).toBeLessThanOrEqual(256); + }); + + it("budgets both SSH title sides without splitting ZWJ graphemes", () => { + const prefixGrapheme = "👨‍👩‍👧‍👦"; + const remoteGrapheme = "👩‍💻"; + publishSessionMetadata({ + ...sshSession(), + name: prefixGrapheme.repeat(80), + }); + publishApplicationTitle("ssh-1", remoteGrapheme.repeat(100)); + vi.advanceTimersByTime(75); + + const title = getDynamicTitle("ssh-1") ?? ""; + const [prefixPart = "", remotePart = ""] = title.split(" · "); + expect(prefixPart.endsWith("…")).toBe(true); + expect(remotePart.endsWith("…")).toBe(true); + expect(Array.from(prefixPart.slice(0, -1)).length % 7).toBe(0); + expect(Array.from(remotePart.slice(0, -1)).length % 3).toBe(0); + expect(Array.from(title).length).toBeLessThanOrEqual(256); + }); + + it("does not count an empty pre-metadata reset as a distinct identity", () => { + const executable = "C:\\Windows\\System32\\cmd.exe"; + publishApplicationTitle("local-1", ""); + publishApplicationTitle("local-1", executable); + publishSessionMetadata( + localSession(true, { trusted_initial_title: executable }), + ); + publishSessionCwdPresentation("local-1", cwd()); + vi.advanceTimersByTime(75); + + expect(getDynamicTitle("local-1")).toBe("~/project"); + expect( + getDynamicTitlesSnapshot().get("local-1")?.applicationTitle, + ).toBeNull(); + }); + + it("suppresses only the initial exact ConPTY executable identity", () => { + const executable = + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + publishApplicationTitle("local-1", executable); + publishSessionMetadata( + localSession(true, { trusted_initial_title: executable }), + ); + publishSessionCwdPresentation("local-1", cwd()); + vi.advanceTimersByTime(75); + expect(getDynamicTitle("local-1")).toBe("~/project"); + + // The same string is accepted after the one-time startup decision. + publishApplicationTitle("local-1", executable.replace(/\\/g, "/")); + vi.advanceTimersByTime(75); + expect(getDynamicTitle("local-1")).toBe(executable.replace(/\\/g, "/")); + + publishApplicationTitle("local-1", "Editor"); + vi.advanceTimersByTime(75); + expect(getDynamicTitle("local-1")).toBe("Editor"); + }); + + it("does not filter a later executable title after a distinct pre-metadata title", () => { + const executable = "C:\\Windows\\System32\\cmd.exe"; + publishApplicationTitle("local-1", "Editor"); + publishApplicationTitle("local-1", executable); + publishSessionMetadata( + localSession(true, { trusted_initial_title: executable }), + ); + vi.advanceTimersByTime(75); + + expect(getDynamicTitle("local-1")).toBe(executable); + }); + + it("requires normalized identity equality without trimming whitespace", () => { + const executable = "C:\\Windows\\System32\\cmd.exe"; + publishApplicationTitle("local-1", ` ${executable} `); + publishSessionMetadata( + localSession(true, { trusted_initial_title: executable }), + ); + vi.advanceTimersByTime(75); + + expect(getDynamicTitle("local-1")).toBe(executable); + }); + + it("does not confuse distinct long executable identities after display truncation", () => { + const prefix = `C:\\${"a".repeat(300)}`; + const trusted = `${prefix}\\powershell.exe`; + const distinct = `${prefix}\\different.exe`; + publishApplicationTitle("local-1", distinct); + publishSessionMetadata( + localSession(true, { trusted_initial_title: trusted }), + ); + publishSessionCwdPresentation("local-1", cwd()); + vi.advanceTimersByTime(75); + + expect( + getDynamicTitlesSnapshot().get("local-1")?.applicationTitle, + ).not.toBeNull(); + expect(getDynamicTitle("local-1")).not.toBe("~/project"); + }); + + it("holds title and cwd together until renderer replay resumes", () => { + publishSessionMetadata(localSession()); + publishSessionCwdPresentation("local-1", cwd()); + vi.advanceTimersByTime(75); + expect(getDynamicTitle("local-1")).toBe("~/project"); + + pauseDynamicTitlePublication("local-1"); + publishSessionCwdPresentation( + "local-1", + cwd("~/next", "/home/alice/next"), + ); + publishApplicationTitle("local-1", "Editor"); + vi.advanceTimersByTime(500); + expect(getDynamicTitle("local-1")).toBe("~/project"); + + resumeDynamicTitlePublication("local-1"); + vi.advanceTimersByTime(75); + expect(getDynamicTitle("local-1")).toBe("Editor"); + expect( + getDynamicTitlesSnapshot().get("local-1")?.cwd?.displayPath, + ).toBe("/home/alice/next"); + }); + + it("does not promote title events while policy is disabled", () => { + publishSessionMetadata(localSession(false)); + publishApplicationTitle("local-1", "Ignored for tab title"); + vi.advanceTimersByTime(75); + expect(getDynamicTitle("local-1")).toBeNull(); + }); + + it("rejects unsafe or oversized frontend cwd payloads defensively", () => { + publishSessionMetadata(localSession()); + publishSessionCwdPresentation("local-1", { + title: "bad", + displayPath: "/tmp/a\u001bb", + copyValue: "/tmp/a\u001bb", + copyAsUri: false, + }); + vi.advanceTimersByTime(75); + expect(getDynamicTitlesSnapshot().get("local-1")?.cwd).toBeNull(); + }); +}); + +describe("dynamic title listener lifecycle", () => { + beforeEach(() => { + vi.useFakeTimers(); + resetDynamicTitlesForTests(); + mocks.eventListeners.clear(); + vi.clearAllMocks(); + installDefaultListenMock(); + }); + + it("retries the global listener with a bounded backoff", async () => { + let globalAttempts = 0; + mocks.listen.mockImplementation(async (eventName: string, callback) => { + if (eventName === "sessions-changed") { + globalAttempts += 1; + if (globalAttempts < 3) throw new Error("not ready"); + } + const callbacks = mocks.eventListeners.get(eventName) ?? new Set(); + callbacks.add(callback); + mocks.eventListeners.set(eventName, callbacks); + return () => callbacks.delete(callback); + }); + mocks.invoke.mockResolvedValueOnce([]); + + startDynamicTitles(); + await settle(); + + expect(globalAttempts).toBe(3); + expect(mocks.invoke).toHaveBeenCalledWith("list_sessions"); + expect( + getDynamicTitleStoreStatsForTests().globalListenerRetrying, + ).toBe(false); + }); + + it("keeps a slow recovery retry after the first bounded global-listener burst", async () => { + let globalAttempts = 0; + mocks.listen.mockImplementation(async (eventName: string, callback) => { + if (eventName === "sessions-changed") { + globalAttempts += 1; + if (globalAttempts <= 3) throw new Error("bridge unavailable"); + } + const callbacks = mocks.eventListeners.get(eventName) ?? new Set(); + callbacks.add(callback); + mocks.eventListeners.set(eventName, callbacks); + return () => callbacks.delete(callback); + }); + mocks.invoke.mockResolvedValueOnce([]); + + startDynamicTitles(); + await settle(); + + expect(globalAttempts).toBe(4); + expect(mocks.invoke).toHaveBeenCalledWith("list_sessions"); + expect( + getDynamicTitleStoreStatsForTests().globalListenerRetrying, + ).toBe(false); + }); + + it("retries a failed initial session snapshot without another lifecycle event", async () => { + let snapshots = 0; + mocks.invoke.mockImplementation(async (command: string) => { + if (command === "list_sessions") { + snapshots += 1; + if (snapshots <= 3) throw new Error("backend not ready"); + return [localSession()]; + } + if (command === "get_session_cwd_presentation") return cwd(); + return null; + }); + + startDynamicTitles(); + await settle(); + + expect(snapshots).toBe(4); + expect(getDynamicTitleStoreStatsForTests().sessionsRefreshRetrying).toBe( + false, + ); + expect(getDynamicTitlesSnapshot().get("local-1")?.enabled).toBe(true); + }); + + it("registers sessions-changed before taking the initial snapshot", async () => { + let resolveGlobalListen!: (dispose: () => void) => void; + mocks.listen.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveGlobalListen = resolve; + }), + ); + mocks.invoke.mockResolvedValueOnce([localSession()]); + + startDynamicTitles(); + await Promise.resolve(); + expect(mocks.invoke).not.toHaveBeenCalled(); + + resolveGlobalListen(() => {}); + await settle(); + expect(mocks.invoke).toHaveBeenCalledWith("list_sessions"); + }); + + it("takes a passive structured cwd snapshot only after listeners are installed", async () => { + mocks.invoke + .mockResolvedValueOnce([ + localSession(true, { dynamic_title_integration_active: false }), + ]) + .mockResolvedValueOnce(cwd()); + + startDynamicTitles(); + await settle(); + + expect(mocks.listen).toHaveBeenCalledWith( + "cwd-presentation-changed-local-1", + expect.any(Function), + ); + expect(getDynamicTitle("local-1")).toBe("~/project"); + }); + + it("slowly recovers initial cwd snapshot failures without treating them as null", async () => { + let cwdAttempts = 0; + mocks.invoke.mockImplementation(async (command: string) => { + if (command === "list_sessions") return [localSession()]; + if (command === "get_session_cwd_presentation") { + cwdAttempts += 1; + if (cwdAttempts <= 3) throw new Error("snapshot unavailable"); + return cwd("~/recovered", "/home/alice/recovered"); + } + return null; + }); + + startDynamicTitles(); + await settle(); + + expect(cwdAttempts).toBe(4); + expect(getDynamicTitle("local-1")).toBe("~/recovered"); + }); + + it("does not let a stale startup snapshot overwrite a newer cwd event", async () => { + let resolveSnapshot!: (value: SessionCwdPresentation) => void; + const snapshotPromise = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + mocks.invoke.mockImplementation(async (command: string) => { + if (command === "list_sessions") return [localSession()]; + if (command === "get_session_cwd_presentation") return snapshotPromise; + return null; + }); + + startDynamicTitles(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + emit( + "cwd-presentation-changed-local-1", + cwd("~/new", "/home/alice/new"), + ); + resolveSnapshot(cwd("~/stale", "/home/alice/stale")); + await settle(); + + expect( + getDynamicTitlesSnapshot().get("local-1")?.cwd?.displayPath, + ).toBe("/home/alice/new"); + }); + + it("does not add cwd listeners or snapshots for default-off Local sessions", async () => { + mocks.invoke.mockResolvedValueOnce([localSession(false)]); + + startDynamicTitles(); + await settle(); + + expect(mocks.listen).not.toHaveBeenCalledWith( + "cwd-presentation-changed-local-1", + expect.any(Function), + ); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "get_session_cwd_presentation", + expect.anything(), + ); + }); + + it("retries a failed session listener without an external lifecycle event", async () => { + let cwdAttempts = 0; + mocks.invoke + .mockResolvedValueOnce([localSession()]) + .mockResolvedValue(cwd()); + mocks.listen.mockImplementation(async (eventName: string, callback) => { + if (eventName === "cwd-presentation-changed-local-1") { + cwdAttempts += 1; + if (cwdAttempts === 1) throw new Error("temporary failure"); + } + const callbacks = mocks.eventListeners.get(eventName) ?? new Set(); + callbacks.add(callback); + mocks.eventListeners.set(eventName, callbacks); + return () => callbacks.delete(callback); + }); + + startDynamicTitles(); + await settle(); + + expect(cwdAttempts).toBe(2); + expect(getDynamicTitleStoreStatsForTests().sessionListenerRetries).toBe(0); + expect( + mocks.eventListeners.get("cwd-presentation-changed-local-1")?.size, + ).toBe(1); + }); + + it("disposes partial registrations and slowly recovers after a failed burst", async () => { + const cwdDispose = vi.fn(); + let closedAttempts = 0; + mocks.invoke.mockResolvedValueOnce([localSession()]); + mocks.listen.mockImplementation(async (eventName: string, callback) => { + if (eventName === "cwd-presentation-changed-local-1") { + const callbacks = mocks.eventListeners.get(eventName) ?? new Set(); + callbacks.add(callback); + mocks.eventListeners.set(eventName, callbacks); + return cwdDispose; + } + if (eventName === "session-closed-local-1") { + closedAttempts += 1; + if (closedAttempts <= 3) throw new Error("closed listener failed"); + } + return () => {}; + }); + + startDynamicTitles(); + await settle(); + + expect(closedAttempts).toBe(4); + expect(cwdDispose).toHaveBeenCalledTimes(3); + expect(getDynamicTitleStoreStatsForTests().sessionListeners).toBe(1); + expect(getDynamicTitleStoreStatsForTests().sessionListenerRetries).toBe(0); + }); + + it("an old listener rejection cannot delete a newer same-id setup", async () => { + let liveSessions: SessionInfo[] = [localSession()]; + let rejectFirstCwd!: (error: unknown) => void; + const firstCwdPromise = new Promise<() => void>((_, reject) => { + rejectFirstCwd = reject; + }); + let cwdRegistrations = 0; + + mocks.invoke.mockImplementation(async (command: string) => { + if (command === "list_sessions") return liveSessions; + if (command === "get_session_cwd_presentation") return cwd(); + return null; + }); + mocks.listen.mockImplementation((eventName: string, callback) => { + const callbacks = mocks.eventListeners.get(eventName) ?? new Set(); + callbacks.add(callback); + mocks.eventListeners.set(eventName, callbacks); + const dispose = () => callbacks.delete(callback); + if (eventName === "cwd-presentation-changed-local-1") { + cwdRegistrations += 1; + if (cwdRegistrations === 1) return firstCwdPromise; + } + return Promise.resolve(() => { + dispose(); + }); + }); + + startDynamicTitles(); + await Promise.resolve(); + await Promise.resolve(); + + liveSessions = []; + emit("sessions-changed"); + await settle(); + liveSessions = [localSession()]; + emit("sessions-changed"); + await settle(); + expect(cwdRegistrations).toBe(2); + + rejectFirstCwd(new Error("stale setup failed")); + await settle(); + expect(getDynamicTitleStoreStatsForTests().sessionListeners).toBe(1); + }); + + it("ignores a late close callback from an older same-id generation", async () => { + let liveSessions: SessionInfo[] = [localSession()]; + mocks.invoke.mockImplementation(async (command: string) => { + if (command === "list_sessions") return liveSessions; + if (command === "get_session_cwd_presentation") return cwd(); + return null; + }); + + startDynamicTitles(); + await settle(); + const oldClosed = [ + ...(mocks.eventListeners.get("session-closed-local-1") ?? []), + ][0]; + expect(oldClosed).toBeTypeOf("function"); + + liveSessions = []; + emit("sessions-changed"); + await settle(); + liveSessions = [localSession()]; + emit("sessions-changed"); + await settle(); + expect(getDynamicTitle("local-1")).toBe("~/project"); + + oldClosed?.({ payload: undefined }); + await settle(); + expect(getDynamicTitle("local-1")).toBe("~/project"); + }); + + it("bounds tombstones during sustained session churn", async () => { + const sessions = Array.from({ length: 1100 }, (_, index) => + sshSession(`ssh-${index}`), + ); + mocks.invoke.mockResolvedValueOnce(sessions); + + startDynamicTitles(); + await settle(); + for (const session of sessions) { + emit(`session-closed-${session.id}`); + } + await settle(); + + const stats = getDynamicTitleStoreStatsForTests(); + expect(stats.tombstones).toBeLessThanOrEqual(1024); + expect(stats.tombstoneQueue).toBeLessThanOrEqual(2048); + expect(stats.states).toBe(0); + expect(stats.snapshots).toBe(0); + }); +}); diff --git a/src/lib/dynamicTabTitles.ts b/src/lib/dynamicTabTitles.ts new file mode 100644 index 00000000..eff1be30 --- /dev/null +++ b/src/lib/dynamicTabTitles.ts @@ -0,0 +1,883 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useSyncExternalStore } from "react"; +import { invoke } from "@/lib/invoke"; +import type { + SessionCwdPresentation, + SessionInfo, + SessionTitleSnapshot, + WorkspaceSessionType, +} from "@/types/global"; + +const TITLE_UPDATE_DEBOUNCE_MS = 75; +const MAX_TITLE_CODE_POINTS = 256; +const MAX_RAW_TITLE_UTF16_UNITS = 4096; +const MAX_EXECUTABLE_IDENTITY_UTF16_UNITS = 32 * 1024; +const MAX_CWD_PRESENTATION_UTF16_UNITS = 16 * 1024; +const MAX_SESSION_TOMBSTONES = 1024; +const MAX_LISTENER_SETUP_ATTEMPTS = 3; +const LISTENER_RETRY_BASE_MS = 100; +const LISTENER_RECOVERY_MS = 5_000; +const SSH_TITLE_DELIMITER = " · "; +const ELLIPSIS = "…"; + +const BIDI_FORMATTING_CONTROLS = + /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u206F]/gu; +const TITLE_WHITESPACE_CONTROLS = /[\u0009\u000A\u000D]/gu; +const NON_PRINTING_CONTROLS = + /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu; +const UNSAFE_PATH_PRESENTATION = + /[\u0000-\u001F\u007F-\u009F\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u206F]/u; + +type GraphemeSegmenter = new ( + locales?: string | string[], + options?: { granularity: "grapheme" }, +) => { segment: (value: string) => Iterable<{ segment: string }> }; + +interface DynamicTitleState { + generation: number; + sessionType?: WorkspaceSessionType; + connectionName: string | null; + enabled: boolean; + metadataReady: boolean; + trustedInitialTitle: string | null; + initialApplicationTitleHandled: boolean; + preMetadataTitleSeen: boolean; + preMetadataFirstIdentity: string | null; + preMetadataCurrentIdentity: string | null; + preMetadataSawDistinctIdentity: boolean; + applicationTitle: string | null; + cwd: SessionCwdPresentation | null; + effectiveTitle: string | null; + timer: ReturnType | null; + publicationPaused: boolean; +} + +type TombstoneReason = "closed" | "missing"; +interface SessionTombstone { + reason: TombstoneReason; + generation: number; +} + +const states = new Map(); +const snapshots = new Map(); +const tombstones = new Map(); +const tombstoneQueue: Array<{ id: string; generation: number }> = []; +const listeners = new Set<() => void>(); +const sessionListenerPromises = new Map void>>(); +const sessionListenerRetryTimers = new Map< + string, + ReturnType +>(); +const sessionListenerRetryAttempts = new Map(); +let sessionsChangedListener: Promise | null = null; +let globalListenerRetryTimer: ReturnType | null = null; +let sessionsRefreshRetryTimer: ReturnType | null = null; +let sessionsRefreshRetryAttempt = 0; +let globalListenerGeneration = 0; +let refreshGeneration = 0; +let stateGeneration = 0; +let tombstoneGeneration = 0; +let started = false; +let snapshotMap: ReadonlyMap = new Map(); + +function notify() { + snapshotMap = new Map(snapshots); + for (const listener of listeners) listener(); +} + +function codePointCount(value: string): number { + return Array.from(value).length; +} + +function truncateTitle( + value: string, + maxCodePoints = MAX_TITLE_CODE_POINTS, +): string { + if (maxCodePoints <= 0) return ""; + if (codePointCount(value) <= maxCodePoints) return value; + + const ellipsisLength = codePointCount(ELLIPSIS); + if (maxCodePoints <= ellipsisLength) return ELLIPSIS; + const maxContent = maxCodePoints - ellipsisLength; + const Segmenter = ( + globalThis.Intl as typeof Intl & { Segmenter?: GraphemeSegmenter } + ).Segmenter; + if (Segmenter) { + const segmenter = new Segmenter(undefined, { granularity: "grapheme" }); + let result = ""; + for (const { segment } of segmenter.segment(value)) { + if (codePointCount(result) + codePointCount(segment) > maxContent) break; + result += segment; + } + return `${result}${ELLIPSIS}`; + } + + const codePoints = Array.from(value); + const end = fallbackTruncationEnd(codePoints, maxContent); + return `${codePoints.slice(0, end).join("")}${ELLIPSIS}`; +} + +function isGraphemeContinuation(value: string | undefined): boolean { + if (!value) return false; + return ( + value === "\u200D" || + /\p{M}/u.test(value) || + /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}\u{1F3FB}-\u{1F3FF}]/u.test( + value, + ) || + /[\u{E0020}-\u{E007F}]/u.test(value) + ); +} + +function isRegionalIndicator(value: string | undefined): boolean { + return value != null && /[\u{1F1E6}-\u{1F1FF}]/u.test(value); +} + +function fallbackTruncationEnd( + codePoints: string[], + proposedEnd: number, +): number { + let end = Math.min(proposedEnd, codePoints.length); + if (end >= codePoints.length) return end; + + // Regional indicators form pairs. If the proposed boundary divides a pair, + // drop the first half rather than render a misleading lone flag letter. + if (isRegionalIndicator(codePoints[end])) { + let precedingIndicators = 0; + for (let index = end - 1; index >= 0; index -= 1) { + if (!isRegionalIndicator(codePoints[index])) break; + precedingIndicators += 1; + } + if (precedingIndicators % 2 === 1) end -= 1; + } + + // Conservatively remove the whole joined/extended cluster around the cut. + // This intentionally over-truncates when Intl.Segmenter is unavailable. + while ( + end > 0 && + (isGraphemeContinuation(codePoints[end]) || + isGraphemeContinuation(codePoints[end - 1]) || + codePoints[end - 1] === "\u200D" || + (codePoints[end - 1] === "\r" && codePoints[end] === "\n")) + ) { + end -= 1; + } + return end; +} + +function boundUtf16(value: string, limit: number): string { + if (value.length <= limit) return value; + let end = limit; + const last = value.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end -= 1; + return value.slice(0, end); +} + +function normalizeTitleCharacters(value: string): string { + return boundUtf16(value, MAX_RAW_TITLE_UTF16_UNITS) + .normalize("NFC") + .replace(BIDI_FORMATTING_CONTROLS, "") + .replace(TITLE_WHITESPACE_CONTROLS, " ") + .replace(NON_PRINTING_CONTROLS, "") + .replace(/\s+/gu, " ") + .trim(); +} + +/** Normalize untrusted OSC 0/2 text before any tab/native-title sink. */ +export function sanitizeDynamicTitle( + value: string | null | undefined, +): string | null { + if (value == null) return null; + const normalized = normalizeTitleCharacters(value); + if (!normalized) return null; + return truncateTitle(normalized) || null; +} + +function validateCwdPresentation( + value: SessionCwdPresentation | null | undefined, +): SessionCwdPresentation | null { + if (value == null) return null; + if ( + typeof value.title !== "string" || + typeof value.displayPath !== "string" || + typeof value.copyValue !== "string" || + (value.operationalPath != null && + typeof value.operationalPath !== "string") || + typeof value.copyAsUri !== "boolean" || + !value.displayPath || + !value.copyValue || + value.displayPath.length > MAX_CWD_PRESENTATION_UTF16_UNITS || + value.copyValue.length > MAX_CWD_PRESENTATION_UTF16_UNITS || + (value.operationalPath?.length ?? 0) > + MAX_CWD_PRESENTATION_UTF16_UNITS || + UNSAFE_PATH_PRESENTATION.test(value.displayPath) || + UNSAFE_PATH_PRESENTATION.test(value.copyValue) || + (value.operationalPath != null && + UNSAFE_PATH_PRESENTATION.test(value.operationalPath)) + ) { + return null; + } + const title = sanitizeDynamicTitle(value.title); + if (!title) return null; + return { + title, + displayPath: value.displayPath, + copyValue: value.copyValue, + operationalPath: value.operationalPath ?? null, + copyAsUri: value.copyAsUri, + }; +} + +function sameCwdPresentation( + left: SessionCwdPresentation | null, + right: SessionCwdPresentation | null, +): boolean { + return ( + left === right || + (left !== null && + right !== null && + left.title === right.title && + left.displayPath === right.displayPath && + left.copyValue === right.copyValue && + left.operationalPath === right.operationalPath && + left.copyAsUri === right.copyAsUri) + ); +} + +function stateFor(sessionId: string): DynamicTitleState { + const existing = states.get(sessionId); + if (existing) return existing; + + const created: DynamicTitleState = { + generation: ++stateGeneration, + connectionName: null, + enabled: false, + metadataReady: false, + trustedInitialTitle: null, + initialApplicationTitleHandled: false, + preMetadataTitleSeen: false, + preMetadataFirstIdentity: null, + preMetadataCurrentIdentity: null, + preMetadataSawDistinctIdentity: false, + applicationTitle: null, + cwd: null, + effectiveTitle: null, + timer: null, + publicationPaused: false, + }; + states.set(sessionId, created); + snapshots.set(sessionId, { + applicationTitle: null, + cwd: null, + effectiveTitle: null, + enabled: false, + }); + return created; +} + +function composeSshDynamicTitle( + connectionName: string | null, + applicationTitle: string, +): string | null { + const prefix = sanitizeDynamicTitle(connectionName) ?? "SSH"; + const remote = sanitizeDynamicTitle(applicationTitle); + if (!remote) return null; + + const delimiterLength = codePointCount(SSH_TITLE_DELIMITER); + const contentBudget = MAX_TITLE_CODE_POINTS - delimiterLength; + const prefixLength = codePointCount(prefix); + const remoteLength = codePointCount(remote); + if (prefixLength + remoteLength <= contentBudget) { + return sanitizeDynamicTitle(`${prefix}${SSH_TITLE_DELIMITER}${remote}`); + } + + // A short side keeps its complete identity; when both are long, split the + // remaining budget so untrusted remote text can neither erase the trusted + // connection prefix nor disappear behind it. + const balancedPrefixBudget = Math.ceil(contentBudget / 2); + const balancedRemoteBudget = contentBudget - balancedPrefixBudget; + let prefixBudget: number; + let remoteBudget: number; + if (remoteLength <= balancedRemoteBudget) { + remoteBudget = remoteLength; + prefixBudget = contentBudget - remoteBudget; + } else if (prefixLength <= balancedPrefixBudget) { + prefixBudget = prefixLength; + remoteBudget = contentBudget - prefixBudget; + } else { + prefixBudget = balancedPrefixBudget; + remoteBudget = balancedRemoteBudget; + } + + const composed = `${truncateTitle(prefix, prefixBudget)}${SSH_TITLE_DELIMITER}${truncateTitle(remote, remoteBudget)}`; + return sanitizeDynamicTitle(composed); +} + +function displayableLocalCwd( + state: DynamicTitleState, +): SessionCwdPresentation | null { + if (!state.enabled || state.sessionType !== "Local") return null; + + // Managed integration identifies who installed the producer, not whether a + // validated OSC 7 report is displayable. Passive shells and profiles may + // provide compatible cwd reports themselves. + return state.cwd; +} + +function effectiveTitleFor(state: DynamicTitleState): string | null { + if (!state.enabled) return null; + + const applicationTitle = state.applicationTitle; + if (state.sessionType === "SSH") { + return applicationTitle + ? composeSshDynamicTitle(state.connectionName, applicationTitle) + : null; + } + + return sanitizeDynamicTitle( + applicationTitle ?? displayableLocalCwd(state)?.title, + ); +} + +function publishNow(sessionId: string) { + const state = states.get(sessionId); + if (!state) return; + state.timer = null; + if (state.publicationPaused) return; + + const nextEffective = effectiveTitleFor(state); + const nextCwd = displayableLocalCwd(state); + const previous = snapshots.get(sessionId); + if ( + nextEffective === state.effectiveTitle && + previous?.applicationTitle === state.applicationTitle && + sameCwdPresentation(previous?.cwd ?? null, nextCwd) && + previous?.enabled === state.enabled + ) { + return; + } + state.effectiveTitle = nextEffective; + + snapshots.set(sessionId, { + applicationTitle: state.applicationTitle, + cwd: nextCwd, + effectiveTitle: nextEffective, + enabled: state.enabled, + }); + // Disabled sessions keep bounded lifecycle state but do not repaint for + // every ignored application title. + if (state.enabled || previous?.enabled !== state.enabled) notify(); +} + +function schedulePublish(sessionId: string) { + const state = stateFor(sessionId); + if (state.publicationPaused || state.timer !== null) return; + state.timer = setTimeout( + () => publishNow(sessionId), + TITLE_UPDATE_DEBOUNCE_MS, + ); +} + +function normalizeExecutableIdentity(value: string): string | null { + if (value.length > MAX_EXECUTABLE_IDENTITY_UTF16_UNITS) return null; + return value + .replace(/^\\\\\?\\/u, "") + .replace(/\\/gu, "/") + .toLocaleLowerCase("en-US"); +} + +function isTrustedInitialTitle( + state: DynamicTitleState, + rawTitle: string, +): boolean { + if ( + state.sessionType !== "Local" || + state.initialApplicationTitleHandled || + !state.trustedInitialTitle + ) { + return false; + } + const incomingIdentity = normalizeExecutableIdentity(rawTitle); + const trustedIdentity = normalizeExecutableIdentity( + state.trustedInitialTitle, + ); + return incomingIdentity !== null && incomingIdentity === trustedIdentity; +} + +function addTombstone(sessionId: string, reason: TombstoneReason) { + const generation = ++tombstoneGeneration; + tombstones.set(sessionId, { reason, generation }); + tombstoneQueue.push({ id: sessionId, generation }); + + while ( + tombstones.size > MAX_SESSION_TOMBSTONES || + tombstoneQueue.length > MAX_SESSION_TOMBSTONES * 2 + ) { + const stale = tombstoneQueue.shift(); + if (!stale) break; + if (tombstones.get(stale.id)?.generation === stale.generation) { + tombstones.delete(stale.id); + } + } +} + +export function publishSessionMetadata(session: SessionInfo): void { + const tombstone = tombstones.get(session.id); + if (tombstone?.reason === "closed") return; + if (tombstone?.reason === "missing") { + tombstones.delete(session.id); + const staleState = states.get(session.id); + if (staleState?.timer != null) clearTimeout(staleState.timer); + states.delete(session.id); + if (snapshots.delete(session.id)) notify(); + } + + const state = stateFor(session.id); + state.sessionType = session.session_type; + state.connectionName = sanitizeDynamicTitle(session.name) ?? "Session"; + state.enabled = + session.dynamic_title_enabled === true && + (session.session_type === "Local" || session.session_type === "SSH"); + state.trustedInitialTitle = + typeof session.trusted_initial_title === "string" && + session.trusted_initial_title.length <= + MAX_EXECUTABLE_IDENTITY_UTF16_UNITS + ? session.trusted_initial_title + : null; + state.metadataReady = true; + + if (!state.enabled) { + state.applicationTitle = null; + state.cwd = null; + state.initialApplicationTitleHandled = false; + state.preMetadataTitleSeen = false; + state.preMetadataFirstIdentity = null; + state.preMetadataCurrentIdentity = null; + state.preMetadataSawDistinctIdentity = false; + } else if (state.preMetadataTitleSeen) { + if ( + state.applicationTitle && + !state.preMetadataSawDistinctIdentity && + state.preMetadataCurrentIdentity !== null && + state.trustedInitialTitle !== null && + state.preMetadataCurrentIdentity === + normalizeExecutableIdentity(state.trustedInitialTitle) + ) { + state.applicationTitle = null; + } + state.initialApplicationTitleHandled = true; + state.preMetadataTitleSeen = false; + state.preMetadataFirstIdentity = null; + state.preMetadataCurrentIdentity = null; + state.preMetadataSawDistinctIdentity = false; + } + schedulePublish(session.id); +} + +function removeSession(sessionId: string, reason: TombstoneReason) { + addTombstone(sessionId, reason); + + const state = states.get(sessionId); + if (state?.timer != null) clearTimeout(state.timer); + states.delete(sessionId); + if (snapshots.delete(sessionId)) notify(); + + const listenerPromise = sessionListenerPromises.get(sessionId); + sessionListenerPromises.delete(sessionId); + void listenerPromise?.then((dispose) => dispose()); + const retryTimer = sessionListenerRetryTimers.get(sessionId); + if (retryTimer !== undefined) clearTimeout(retryTimer); + sessionListenerRetryTimers.delete(sessionId); + sessionListenerRetryAttempts.delete(sessionId); +} + +export function subscribeDynamicTitles(callback: () => void): () => void { + listeners.add(callback); + return () => listeners.delete(callback); +} + +export function getDynamicTitlesSnapshot(): ReadonlyMap< + string, + SessionTitleSnapshot +> { + return snapshotMap; +} + +export function getDynamicTitle( + sessionId: string | null | undefined, +): string | null { + if (!sessionId) return null; + return snapshots.get(sessionId)?.effectiveTitle ?? null; +} + +export function getDynamicTitleSnapshot( + sessionId: string | null | undefined, +): SessionTitleSnapshot | null { + if (!sessionId) return null; + return snapshots.get(sessionId) ?? null; +} + +function rememberPreMetadataTitle( + state: DynamicTitleState, + rawTitle: string, +) { + const identity = normalizeExecutableIdentity(rawTitle); + if (!state.preMetadataTitleSeen) { + state.preMetadataTitleSeen = true; + state.preMetadataFirstIdentity = identity; + } else if (identity !== state.preMetadataFirstIdentity) { + state.preMetadataSawDistinctIdentity = true; + } + state.preMetadataCurrentIdentity = identity; +} + +/** Called only after xterm.js parses OSC 0/2. */ +export function publishApplicationTitle( + sessionId: string, + rawTitle: string, +): void { + if (tombstones.has(sessionId)) return; + const state = stateFor(sessionId); + if (state.metadataReady && !state.enabled) return; + const title = sanitizeDynamicTitle(rawTitle); + if (!state.metadataReady && title) { + rememberPreMetadataTitle(state, rawTitle); + } + if (!title) { + state.applicationTitle = null; + schedulePublish(sessionId); + return; + } + + if (state.metadataReady && !state.initialApplicationTitleHandled) { + const trustedInitialTitle = isTrustedInitialTitle(state, rawTitle); + state.initialApplicationTitleHandled = true; + if (trustedInitialTitle) { + state.applicationTitle = null; + schedulePublish(sessionId); + return; + } + } + + state.applicationTitle = title; + schedulePublish(sessionId); +} + +/** Called by the backend-authoritative structured cwd event bridge. */ +export function publishSessionCwdPresentation( + sessionId: string, + presentation: SessionCwdPresentation | null, +): void { + if (tombstones.has(sessionId)) return; + const state = stateFor(sessionId); + state.cwd = validateCwdPresentation(presentation); + schedulePublish(sessionId); +} + +/** Hold title/cwd publication while renderer replay is unsettled. */ +export function pauseDynamicTitlePublication(sessionId: string): void { + if (tombstones.has(sessionId)) return; + const state = stateFor(sessionId); + state.publicationPaused = true; + if (state.timer !== null) { + clearTimeout(state.timer); + state.timer = null; + } +} + +/** Publish only the final cached title/cwd after renderer attach/replay settles. */ +export function resumeDynamicTitlePublication(sessionId: string): void { + const state = states.get(sessionId); + if (!state || tombstones.has(sessionId)) return; + if (!state.publicationPaused) return; + state.publicationPaused = false; + schedulePublish(sessionId); +} + +async function setupSessionListeners( + session: SessionInfo, + generation: number, +): Promise<() => void> { + const sessionId = session.id; + const isCurrentGeneration = () => + states.get(sessionId)?.generation === generation; + let disposed = false; + let cwdEventObserved = false; + let cwdSnapshotRetryTimer: ReturnType | null = null; + const disposers: UnlistenFn[] = []; + + const requestCwdSnapshot = (attempt: number) => { + if (disposed || cwdEventObserved || !isCurrentGeneration()) return; + void invoke( + "get_session_cwd_presentation", + { sessionId }, + ).then( + (currentCwd) => { + if (!disposed && !cwdEventObserved && isCurrentGeneration()) { + publishSessionCwdPresentation(sessionId, currentCwd); + } + }, + () => { + if ( + disposed || + cwdEventObserved || + !isCurrentGeneration() + ) { + return; + } + const exhaustedBurst = attempt >= MAX_LISTENER_SETUP_ATTEMPTS; + cwdSnapshotRetryTimer = setTimeout(() => { + cwdSnapshotRetryTimer = null; + requestCwdSnapshot(exhaustedBurst ? 1 : attempt + 1); + }, exhaustedBurst ? LISTENER_RECOVERY_MS : LISTENER_RETRY_BASE_MS * attempt); + }, + ); + }; + + try { + const trackLocalCwd = + session.session_type === "Local" && session.dynamic_title_enabled === true; + if (trackLocalCwd) { + const cwdUnlisten = await listen( + `cwd-presentation-changed-${sessionId}`, + (event) => { + if (!disposed && isCurrentGeneration()) { + cwdEventObserved = true; + publishSessionCwdPresentation(sessionId, event.payload); + } + }, + ); + if (disposed) cwdUnlisten(); + else disposers.push(cwdUnlisten); + } + + const closedUnlisten = await listen( + `session-closed-${sessionId}`, + () => { + if (!disposed && isCurrentGeneration()) { + removeSession(sessionId, "closed"); + } + }, + ); + if (disposed) closedUnlisten(); + else disposers.push(closedUnlisten); + + if (trackLocalCwd) { + // Do not keep listener disposal waiting on snapshot IPC. Errors are not + // authoritative null cwd values: retry at a bounded rate while the + // already-live event listener prevents stale snapshot overwrites. + requestCwdSnapshot(1); + } + } catch (error) { + disposed = true; + if (cwdSnapshotRetryTimer !== null) { + clearTimeout(cwdSnapshotRetryTimer); + cwdSnapshotRetryTimer = null; + } + for (const dispose of disposers.splice(0)) dispose(); + throw error; + } + + return () => { + disposed = true; + if (cwdSnapshotRetryTimer !== null) { + clearTimeout(cwdSnapshotRetryTimer); + cwdSnapshotRetryTimer = null; + } + for (const dispose of disposers.splice(0)) dispose(); + }; +} + +function ensureSessionListeners(session: SessionInfo) { + if ( + sessionListenerPromises.has(session.id) || + sessionListenerRetryTimers.has(session.id) + ) { + return; + } + + const generation = stateFor(session.id).generation; + const attempt = (sessionListenerRetryAttempts.get(session.id) ?? 0) + 1; + sessionListenerRetryAttempts.set(session.id, attempt); + let promise: Promise<() => void>; + promise = setupSessionListeners(session, generation).then( + (dispose) => { + sessionListenerRetryAttempts.delete(session.id); + return dispose; + }, + () => { + if (sessionListenerPromises.get(session.id) === promise) { + sessionListenerPromises.delete(session.id); + } + if ( + states.get(session.id)?.generation === generation && + !tombstones.has(session.id) + ) { + const exhaustedBurst = attempt >= MAX_LISTENER_SETUP_ATTEMPTS; + if (exhaustedBurst) sessionListenerRetryAttempts.delete(session.id); + const timer = setTimeout(() => { + sessionListenerRetryTimers.delete(session.id); + if ( + states.get(session.id)?.generation === generation && + !tombstones.has(session.id) + ) { + ensureSessionListeners(session); + } + }, exhaustedBurst ? LISTENER_RECOVERY_MS : LISTENER_RETRY_BASE_MS * attempt); + sessionListenerRetryTimers.set(session.id, timer); + } else { + sessionListenerRetryAttempts.delete(session.id); + } + return () => {}; + }, + ); + sessionListenerPromises.set(session.id, promise); +} + +function scheduleSessionsRefreshRetry(generation: number) { + if ( + sessionsRefreshRetryTimer !== null || + !started || + generation !== refreshGeneration + ) { + return; + } + const attempt = ++sessionsRefreshRetryAttempt; + const exhaustedBurst = attempt >= MAX_LISTENER_SETUP_ATTEMPTS; + if (exhaustedBurst) sessionsRefreshRetryAttempt = 0; + sessionsRefreshRetryTimer = setTimeout(() => { + sessionsRefreshRetryTimer = null; + if (started && generation === refreshGeneration) { + void refreshSessions(); + } + }, exhaustedBurst ? LISTENER_RECOVERY_MS : LISTENER_RETRY_BASE_MS * attempt); +} + +async function refreshSessions(): Promise { + const generation = ++refreshGeneration; + if (sessionsRefreshRetryTimer !== null) { + clearTimeout(sessionsRefreshRetryTimer); + sessionsRefreshRetryTimer = null; + } + try { + const sessions = await invoke("list_sessions"); + if (generation !== refreshGeneration) return; + sessionsRefreshRetryAttempt = 0; + + const ids = new Set(sessions.map((session) => session.id)); + for (const id of [...states.keys()]) { + if (!ids.has(id)) removeSession(id, "missing"); + } + + for (const session of sessions) { + if (tombstones.get(session.id)?.reason === "closed") continue; + publishSessionMetadata(session); + ensureSessionListeners(session); + } + } catch { + // Listener registration can succeed before the invoke bridge/backend is + // ready. Keep one bounded-rate retry chain so an otherwise idle existing + // session cannot remain metadata-less forever. + scheduleSessionsRefreshRetry(generation); + } +} + +async function startGlobalDynamicTitleListener( + generation: number, + attempt: number, +): Promise { + try { + const unlisten = await listen("sessions-changed", () => { + void refreshSessions(); + }); + if (!started || generation !== globalListenerGeneration) { + unlisten(); + return; + } + sessionsChangedListener = Promise.resolve(unlisten); + globalListenerRetryTimer = null; + // Install the global listener before the initial snapshot so creation + // cannot land between list_sessions and listener registration. + await refreshSessions(); + } catch { + if (!started || generation !== globalListenerGeneration) return; + const exhaustedBurst = attempt >= MAX_LISTENER_SETUP_ATTEMPTS; + globalListenerRetryTimer = setTimeout(() => { + globalListenerRetryTimer = null; + void startGlobalDynamicTitleListener( + generation, + exhaustedBurst ? 1 : attempt + 1, + ); + }, exhaustedBurst ? LISTENER_RECOVERY_MS : LISTENER_RETRY_BASE_MS * attempt); + } +} + +/** Start metadata/cwd lifecycle listeners. Safe to call repeatedly. */ +export function startDynamicTitles(): void { + if (started) return; + started = true; + const generation = ++globalListenerGeneration; + void startGlobalDynamicTitleListener(generation, 1); +} + +/** Test/HMR cleanup; production keeps this store for app lifetime. */ +export function resetDynamicTitlesForTests(): void { + for (const state of states.values()) { + if (state.timer !== null) clearTimeout(state.timer); + } + for (const promise of sessionListenerPromises.values()) { + void promise.then((dispose) => dispose()); + } + sessionListenerPromises.clear(); + for (const timer of sessionListenerRetryTimers.values()) { + clearTimeout(timer); + } + sessionListenerRetryTimers.clear(); + sessionListenerRetryAttempts.clear(); + if (globalListenerRetryTimer !== null) { + clearTimeout(globalListenerRetryTimer); + globalListenerRetryTimer = null; + } + if (sessionsRefreshRetryTimer !== null) { + clearTimeout(sessionsRefreshRetryTimer); + sessionsRefreshRetryTimer = null; + } + sessionsRefreshRetryAttempt = 0; + globalListenerGeneration += 1; + states.clear(); + snapshots.clear(); + tombstones.clear(); + tombstoneQueue.length = 0; + tombstoneGeneration = 0; + stateGeneration = 0; + snapshotMap = new Map(); + listeners.clear(); + if (sessionsChangedListener) { + void sessionsChangedListener.then((dispose) => dispose()); + sessionsChangedListener = null; + } + started = false; + refreshGeneration += 1; +} + +/** Bounded-state diagnostics used by regression tests only. */ +export function getDynamicTitleStoreStatsForTests() { + return { + states: states.size, + snapshots: snapshots.size, + tombstones: tombstones.size, + tombstoneQueue: tombstoneQueue.length, + sessionListeners: sessionListenerPromises.size, + sessionListenerRetries: sessionListenerRetryTimers.size, + globalListenerRetrying: globalListenerRetryTimer !== null, + sessionsRefreshRetrying: sessionsRefreshRetryTimer !== null, + }; +} + +export function useDynamicTitles(): ReadonlyMap { + return useSyncExternalStore( + subscribeDynamicTitles, + getDynamicTitlesSnapshot, + getDynamicTitlesSnapshot, + ); +} diff --git a/src/lib/sessionInput.test.ts b/src/lib/sessionInput.test.ts new file mode 100644 index 00000000..9a237350 --- /dev/null +++ b/src/lib/sessionInput.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("./invoke", () => ({ invoke: mocks.invoke })); + +import { sendSessionInput, sendSessionInputWithSync } from "./sessionInput"; + +describe("sendSessionInputWithSync command confirmation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards terminal responses without previews or sync fan-out", async () => { + await sendSessionInput("primary", "\x1b[?1;2c", { + preview: null, + origin: "terminal_response", + }); + + expect(mocks.invoke).toHaveBeenCalledTimes(1); + expect(mocks.invoke).toHaveBeenCalledWith("write_to_session", { + sessionId: "primary", + data: "\x1b[?1;2c", + origin: "terminal_response", + sensitivity: undefined, + }); + }); + + it("registers each peer candidate before synchronized command input", async () => { + await sendSessionInputWithSync( + "primary", + "uptime\r", + ["peer-a", "peer-b"], + { + registerSubmission: "uptime", + origin: "keyboard", + }, + ); + + for (const sessionId of ["primary", "peer-a", "peer-b"]) { + const expectedRegistration = + sessionId === "primary" + ? "register_command_submission" + : "register_command_confirmation_candidate"; + const registerIndex = mocks.invoke.mock.calls.findIndex( + ([command, payload]) => + command === expectedRegistration && payload.sessionId === sessionId, + ); + const writeIndex = mocks.invoke.mock.calls.findIndex( + ([command, payload]) => + command === "write_to_session" && payload.sessionId === sessionId, + ); + expect(registerIndex).toBeGreaterThanOrEqual(0); + expect(writeIndex).toBeGreaterThan(registerIndex); + } + + expect(mocks.invoke).toHaveBeenCalledWith("write_to_session", { + sessionId: "peer-a", + data: "uptime\r", + origin: "sync_input", + sensitivity: undefined, + }); + }); + + it("does not block peer input when auxiliary registration fails", async () => { + mocks.invoke.mockImplementation(async (command: string, payload) => { + if ( + command === "register_command_confirmation_candidate" && + payload.sessionId === "peer" + ) { + throw new Error("registration unavailable"); + } + }); + + await sendSessionInputWithSync("primary", "uptime\r", ["peer"], { + registerSubmission: "uptime", + }); + + expect(mocks.invoke).toHaveBeenCalledWith("write_to_session", { + sessionId: "peer", + data: "uptime\r", + origin: "sync_input", + sensitivity: undefined, + }); + }); + + it("keeps non-command synchronized input registration-free", async () => { + await sendSessionInputWithSync("primary", "x", ["peer"], { + origin: "keyboard", + }); + + expect(mocks.invoke).not.toHaveBeenCalledWith( + "register_command_submission", + expect.anything(), + ); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "register_command_confirmation_candidate", + expect.anything(), + ); + }); +}); diff --git a/src/lib/sessionInput.ts b/src/lib/sessionInput.ts index e64f8603..bafe762d 100644 --- a/src/lib/sessionInput.ts +++ b/src/lib/sessionInput.ts @@ -36,7 +36,8 @@ export type InputOrigin = | "ai_agent" | "credential_autofill" | "otp_autofill" - | "sync_input"; + | "sync_input" + | "terminal_response"; export type InputSensitivity = "normal" | "secret"; @@ -176,7 +177,9 @@ export async function sendSessionInput( /** * Send input to a session and broadcast to all sync-group peers. - * Peers receive raw `write_to_session` only (no preview / history registration). + * Peers do not emit frontend preview/history UI, but an executable command + * candidate is registered with the backend before write so session-bound shell + * confirmation remains exact-match and synchronized SSH history is not lost. */ export async function sendSessionInputWithSync( sessionId: string, @@ -188,14 +191,23 @@ export async function sendSessionInputWithSync( if (peerSessionIds.length > 0) { await Promise.allSettled( - peerSessionIds.map((sid) => - invoke("write_to_session", { + peerSessionIds.map(async (sid) => { + if (options.registerSubmission) { + await invoke("register_command_confirmation_candidate", { + sessionId: sid, + command: options.registerSubmission, + }).catch(() => { + // History confirmation is auxiliary; never block synchronized + // input to an otherwise live peer when registration is unavailable. + }); + } + await invoke("write_to_session", { sessionId: sid, data, origin: "sync_input", sensitivity: options.sensitivity, + }); }), - ), ); } } diff --git a/src/lib/terminalControlInput.test.ts b/src/lib/terminalControlInput.test.ts new file mode 100644 index 00000000..3126be89 --- /dev/null +++ b/src/lib/terminalControlInput.test.ts @@ -0,0 +1,27 @@ +import type { Terminal } from "@xterm/xterm"; +import { describe, expect, it, vi } from "vitest"; +import { + registerTerminalUserInputMarker, + sendTerminalClearInput, +} from "./terminalControlInput"; + +describe("terminal control input origin", () => { + it("marks programmatic user input before emitting terminal data", () => { + const order: string[] = []; + const terminal = { + clearSelection: vi.fn(), + input: vi.fn(() => order.push("input")), + focus: vi.fn(), + } as unknown as Terminal; + const unregister = registerTerminalUserInputMarker(terminal, () => { + order.push("mark"); + }); + + sendTerminalClearInput(terminal, { focus: true }); + + expect(order).toEqual(["mark", "input"]); + expect(terminal.input).toHaveBeenCalledWith("\x0c", true); + expect(terminal.focus).toHaveBeenCalledOnce(); + unregister(); + }); +}); diff --git a/src/lib/terminalControlInput.ts b/src/lib/terminalControlInput.ts index 67725d00..f4ebc86f 100644 --- a/src/lib/terminalControlInput.ts +++ b/src/lib/terminalControlInput.ts @@ -1,10 +1,29 @@ import type { Terminal } from "@xterm/xterm"; const CTRL_L_INPUT = "\x0c"; +const terminalUserInputMarkers = new WeakMap void>(); -export function sendTerminalClearInput(terminal: Terminal, options: { focus?: boolean } = {}) { +export function registerTerminalUserInputMarker( + terminal: Terminal, + marker: () => void, +): () => void { + terminalUserInputMarkers.set(terminal, marker); + return () => { + terminalUserInputMarkers.delete(terminal); + }; +} + +export function markTerminalUserInput(terminal: Terminal): void { + terminalUserInputMarkers.get(terminal)?.(); +} + +export function sendTerminalClearInput( + terminal: Terminal, + options: { focus?: boolean } = {}, +) { terminal.clearSelection(); - terminal.input(CTRL_L_INPUT, false); + markTerminalUserInput(terminal); + terminal.input(CTRL_L_INPUT, true); if (options.focus) { terminal.focus(); } diff --git a/src/lib/workspaceTabs.test.ts b/src/lib/workspaceTabs.test.ts index 91d0bb63..c6e0cea5 100644 --- a/src/lib/workspaceTabs.test.ts +++ b/src/lib/workspaceTabs.test.ts @@ -7,7 +7,10 @@ import { createSessionPane, createWorkspaceTab, findOpenFileDocument, + getActiveSessionTabDisplayName, getReleasedSessionIds, + getSessionRowDisplayName, + getTabDisplayName, replaceSessionReferences, restoreTabFromPersistence, serializeTabsForPersistence, @@ -396,3 +399,82 @@ describe("workspaceTabs temporary session metadata", () => { expect(serialized.root?.kind === "leaf" && serialized.root.connection_id).toBeUndefined(); }); }); + +describe("workspaceTabs dynamic titles", () => { + const localTab = () => + createWorkspaceTab(createSessionPane("Local Terminal", "Local"), 0); + + it("prefers custom name over a resolved dynamic title", () => { + const tab = { ...localTab(), customName: "pinned" }; + expect(getTabDisplayName(tab, "vim — /home/user")).toBe("pinned"); + }); + + it("uses the active session's dynamic title through the shared resolver", () => { + const tab = localTab(); + const activeSessionId = + tab.root.kind === "leaf" ? tab.root.sessionId : null; + expect( + getActiveSessionTabDisplayName(tab, (sessionId) => + sessionId === activeSessionId ? "cargo build" : null, + ), + ).toBe("cargo build"); + }); + + it("falls back to the static name when no resolved title exists", () => { + expect(getTabDisplayName(localTab(), null)).toBe("Local Terminal"); + expect(getTabDisplayName(localTab(), " ")).toBe("Local Terminal"); + }); + + it("uses each enabled session's own title in session-identity rows", () => { + const first = createSessionPane("First", "Local", undefined, { + id: "pane-first", + sessionId: "session-first", + }); + const second = createSessionPane("Second", "Local", undefined, { + id: "pane-second", + sessionId: "session-second", + }); + const tab = createWorkspaceTab(first, 0); + tab.root = splitSessionPane(tab.root, first.id, "vertical", second); + tab.activePaneId = first.id; + + expect( + getSessionRowDisplayName(tab, "Second", { + applicationTitle: null, + cwd: null, + effectiveTitle: "~/second-project", + enabled: true, + }), + ).toBe("~/second-project"); + expect( + getSessionRowDisplayName(tab, "Second", { + applicationTitle: null, + cwd: null, + effectiveTitle: null, + enabled: false, + }), + ).toBe("First"); + }); + + it("does not persist dynamic title policy in workspace tabs", () => { + const [serialized] = serializeTabsForPersistence([localTab()]); + expect(serialized).not.toHaveProperty("dynamic_title"); + }); + + it("ignores a legacy per-tab dynamic title field when restoring", () => { + const restored = restoreTabFromPersistence( + { + title: "Local Terminal", + session_type: "Local", + dynamic_title: true, + root: { + kind: "leaf", + title: "Local Terminal", + session_type: "Local", + }, + } as RestorableTab & { dynamic_title?: boolean }, + 0, + ); + expect(restored).not.toHaveProperty("dynamicTitle"); + }); +}); diff --git a/src/lib/workspaceTabs.ts b/src/lib/workspaceTabs.ts index 701a66da..1d86d7af 100644 --- a/src/lib/workspaceTabs.ts +++ b/src/lib/workspaceTabs.ts @@ -7,6 +7,7 @@ import type { RestorablePaneNode, RestorableTab, SessionPane, + SessionTitleSnapshot, SessionType, SplitPane, Tab, @@ -298,14 +299,42 @@ export function getActivePane(tab: Tab): SessionPane | null { ); } -export function getTabDisplayName(tab: Tab): string { - return tab.customName || getActivePane(tab)?.name || "Session"; +export function getTabDisplayName( + tab: Tab, + dynamicTitle?: string | null, +): string { + if (tab.customName) return tab.customName; + const activePane = getActivePane(tab); + const dynamic = dynamicTitle?.trim(); + if (activePane?.paneKind === "terminal" && dynamic) return dynamic; + return activePane?.name || "Session"; } export function getTabActiveSessionId(tab: Tab) { return getActivePane(tab)?.sessionId ?? null; } +/** Resolve the display name from the active pane's dynamic-title snapshot. */ +export function getActiveSessionTabDisplayName( + tab: Tab, + getDynamicTitle: (sessionId: string | null) => string | null | undefined, +): string { + return getTabDisplayName(tab, getDynamicTitle(getTabActiveSessionId(tab))); +} + +/** Resolve a row whose identity is a session, without changing legacy rows. */ +export function getSessionRowDisplayName( + tab: Tab, + sessionName: string, + dynamicSnapshot: SessionTitleSnapshot | null | undefined, +): string { + if (tab.customName) return tab.customName; + if (dynamicSnapshot?.enabled) { + return dynamicSnapshot.effectiveTitle || sessionName; + } + return getTabDisplayName(tab); +} + export function getTabActiveConnectionId(tab: Tab) { return getActivePane(tab)?.connectionId; } diff --git a/src/lib/xtermSecurity.test.ts b/src/lib/xtermSecurity.test.ts new file mode 100644 index 00000000..ed93f765 --- /dev/null +++ b/src/lib/xtermSecurity.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { XTERM_SECURE_WINDOW_OPTIONS } from "./xtermSecurity"; + +describe("xterm window option trust boundary", () => { + it("keeps application-controlled title queries disabled", () => { + expect(XTERM_SECURE_WINDOW_OPTIONS.getWinTitle).toBe(false); + expect(XTERM_SECURE_WINDOW_OPTIONS.getIconTitle).toBe(false); + expect(Object.isFrozen(XTERM_SECURE_WINDOW_OPTIONS)).toBe(true); + }); +}); diff --git a/src/lib/xtermSecurity.ts b/src/lib/xtermSecurity.ts new file mode 100644 index 00000000..372e7508 --- /dev/null +++ b/src/lib/xtermSecurity.ts @@ -0,0 +1,10 @@ +import type { IWindowOptions } from "@xterm/xterm"; + +/** + * Do not reflect application-controlled icon/window titles back into the PTY. + * Keep this explicit instead of depending on xterm.js defaults. + */ +export const XTERM_SECURE_WINDOW_OPTIONS: IWindowOptions = Object.freeze({ + getIconTitle: false, + getWinTitle: false, +}); diff --git a/src/pages/NewSessionPage.tsx b/src/pages/NewSessionPage.tsx index 927b83fe..7c11b695 100644 --- a/src/pages/NewSessionPage.tsx +++ b/src/pages/NewSessionPage.tsx @@ -220,6 +220,7 @@ export default function NewSessionPage() { const [sshProfile, setSshProfile] = useState("standard"); const [sshTerminalType, setSshTerminalType] = useState("default"); const [sftpSettings, setSftpSettings] = useState(DEFAULT_SFTP_SETTINGS); + const [remoteDynamicTabTitle, setRemoteDynamicTabTitle] = useState(false); // Serial Settings States const [serialPortName, setSerialPortName] = useState(""); @@ -235,6 +236,7 @@ export default function NewSessionPage() { const [shellPath, setShellPath] = useState("powershell.exe"); const [shellArgs, setShellArgs] = useState(""); const [workingDir, setWorkingDir] = useState(""); + const [dynamicTabTitle, setDynamicTabTitle] = useState(false); const [serialBackspaceMode, setSerialBackspaceMode] = useState("ctrl_h"); const [telnetBackspaceMode, setTelnetBackspaceMode] = useState("del"); const [telnetRawTcpCli, setTelnetRawTcpCli] = useState(false); @@ -294,6 +296,8 @@ export default function NewSessionPage() { setRecordingUseGlobal(!found.recording); setRecordingAutoStart(found.recording?.auto_start ?? appSettings.recording.auto_start); setRecordingMode(found.recording?.mode ?? appSettings.recording.default_mode); + if (found.type !== "ssh") setRemoteDynamicTabTitle(false); + if (found.type !== "local_terminal") setDynamicTabTitle(false); if (found.type === "ssh") { setHost(found.host || ""); @@ -320,6 +324,7 @@ export default function NewSessionPage() { setSshProfile(found.ssh_profile || "standard"); setSshTerminalType(found.terminal_type || "default"); setSftpSettings(normalizeSftpSettings(found.sftp)); + setRemoteDynamicTabTitle(found.dynamic_tab_title ?? false); } else if (found.type === "telnet") { setHost(found.host || ""); setTelnetPort(found.port || 23); @@ -339,6 +344,7 @@ export default function NewSessionPage() { setShellPath(found.shell_path || "powershell.exe"); setShellArgs(found.shell_args || ""); setWorkingDir(found.working_dir || ""); + setDynamicTabTitle(found.dynamic_tab_title ?? false); } else if (found.type === "serial") { setSerialPortName(found.port_name || ""); setBaudRate(String(found.baud_rate || 115200)); @@ -448,6 +454,8 @@ export default function NewSessionPage() { setShellPath("powershell.exe"); setShellArgs(""); setWorkingDir(""); + setDynamicTabTitle(false); + setRemoteDynamicTabTitle(false); setSerialBackspaceMode("ctrl_h"); setTelnetBackspaceMode("del"); setTelnetRawTcpCli(false); @@ -1001,6 +1009,7 @@ export default function NewSessionPage() { x11_forwarding: x11Forwarding, auth_agent_endpoint: authType === "agent" ? authAgentEndpoint : undefined, agent_forwarding_config: agentForwardingConfig, + dynamic_tab_title: remoteDynamicTabTitle, } : {}), ...(currentTab === "telnet" @@ -1024,6 +1033,7 @@ export default function NewSessionPage() { shell_path: normalizedShellPath, shell_args: normalizedShellArgs, working_dir: normalizedWorkingDir || undefined, + dynamic_tab_title: dynamicTabTitle, } : {}), ...(currentTab === "serial" @@ -1503,6 +1513,8 @@ export default function NewSessionPage() { setSshTerminalType={setSshTerminalType} sftpSettings={sftpSettings} setSftpSettings={setSftpSettings} + remoteDynamicTabTitle={remoteDynamicTabTitle} + setRemoteDynamicTabTitle={setRemoteDynamicTabTitle} recordingUseGlobal={recordingUseGlobal} setRecordingUseGlobal={setRecordingUseGlobal} recordingAutoStart={recordingAutoStart} @@ -1526,6 +1538,8 @@ export default function NewSessionPage() { setShellArgs={setShellArgs} workingDir={workingDir} setWorkingDir={setWorkingDir} + dynamicTabTitle={dynamicTabTitle} + setDynamicTabTitle={setDynamicTabTitle} recordingUseGlobal={recordingUseGlobal} setRecordingUseGlobal={setRecordingUseGlobal} recordingAutoStart={recordingAutoStart} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index fc19d4a4..7193b31b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,5 +1,27 @@ /** Type of terminal session. */ export type SessionType = "SSH" | "Local" | "Telnet" | "Serial"; + +/** Backend-authoritative, runtime-only cwd values safe for presentation/copy. */ +export type SessionCwdPresentation = { + title: string; + displayPath: string; + copyValue: string; + /** Host-native path for filesystem consumers; absent for presentation-only locations. */ + operationalPath?: string | null; + copyAsUri: boolean; +}; + +export type SessionTitleSnapshot = { + /** Sanitized application-provided OSC 0/2 title. */ + applicationTitle: string | null; + /** Validated Local cwd presentation; SSH cwd is never promoted. */ + cwd: SessionCwdPresentation | null; + /** Final policy-resolved title, or null when static fallback should win. */ + effectiveTitle: string | null; + /** Saved-connection policy copied into this runtime session. */ + enabled: boolean; +}; + export type WorkspaceSessionType = SessionType | "RDP" | "VNC"; export type WorkspacePaneKind = "terminal" | "remote-desktop" | "file"; export type PersistedWorkspacePaneKind = WorkspacePaneKind | "rdp"; @@ -73,8 +95,14 @@ export interface SessionInfo { connected: boolean; owner_window_label?: string | null; ai_execution_profile: AIExecutionProfile; - /** True when backend terminal-path tracking is available for this session. */ + /** True when backend shell command-confirmation integration is active. */ injection_active: boolean; + /** Whether application/remote dynamic titles may be promoted. */ + dynamic_title_enabled: boolean; + /** Whether the selected shell received NyaTerm dynamic-title/cwd hooks. */ + dynamic_title_integration_active: boolean; + /** Trusted resolved Windows executable identity for initial ConPTY filtering. */ + trusted_initial_title?: string | null; /** True when the remote file browser is enabled for this session. */ remote_file_browser_enabled: boolean; /** True when Linux-style remote resource stats are enabled for this session. */ @@ -213,6 +241,7 @@ export interface SshConfig { terminal_type?: SshTerminalType; sftp?: SftpSettings; encoding?: string; + dynamic_tab_title?: boolean; } /** SSH authentication: none, password, private key (PEM content), or SSH Agent. */ @@ -497,6 +526,8 @@ export interface SavedConnection { shell_path?: string; shell_args?: string; working_dir?: string; + /** Show the session's live window title / cwd as the tab label. */ + dynamic_tab_title?: boolean; /** Legacy saved value; runtime sessions now resolve the effective AI execution profile automatically. */ ai_execution_profile?: AIExecutionProfile; /** Serial fields (present when type === "serial"). */