Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,54 @@
#[cfg_attr(unix, path = "unix.rs")]
#[cfg_attr(windows, path = "windows.rs")]
mod imp;
use crate::terminal::model::session::{BootstrapSessionType, SessionInfo};
use crate::terminal::shell::ShellType;
pub use imp::TempBootstrapFile;
/// Creates a `NamedTempFile` with the given bootstrap contents
///
/// Return `None` if any part of the operation fails
#[cfg(feature = "local_fs")]
pub fn create_bootstrap_file<C, S>(
contents: C,
shell_type: ShellType,
_wsl_distribution: Option<S>,
) -> Option<TempBootstrapFile>
where
C: AsRef<[u8]>,
S: AsRef<str>,
{
let mut builder = tempfile::Builder::new();
// PowerShell will only source a file with the "ps1" extension.
if shell_type == ShellType::PowerShell {
builder.suffix(".ps1");
}
match TempBootstrapFile::new(
builder,
contents,
#[cfg(windows)]
_wsl_distribution,
) {
Ok(bootstrap_file) => Some(bootstrap_file),
Err(err) => {
log::warn!("Error when creating temporary bootstrap file: {err:#}");
None
}
}
}
/// Checks to see if we should be using a permanent bootstrap file given the
/// session info. If so, returns a path to the permanent bootstrap file for the
/// given shell type in bytes, if it exists.
pub fn permanent_bootstrap_file(
shell_type: ShellType,
pending_session_info: &SessionInfo,
) -> Option<Vec<u8>> {
if pending_session_info.session_type != BootstrapSessionType::Local {
return None;
}
imp::path_to_permanent_bootstrap_file(shell_type)
}
@@ -0,0 +1,45 @@
use std::io::Write;
use tempfile::NamedTempFile;
use crate::terminal::shell::ShellType;
/// Represents a temporary file used for bootstrap.
pub struct TempBootstrapFile {
temp_file: NamedTempFile,
}
impl TempBootstrapFile {
pub fn new<C>(builder: tempfile::Builder, contents: C) -> std::io::Result<Self>
where
C: AsRef<[u8]>,
{
let mut file = builder.tempfile()?;
file.write_all(contents.as_ref())?;
file.flush()?;
Ok(Self { temp_file: file })
}
pub fn path_as_bytes(&self) -> Option<Vec<u8>> {
use std::os::unix::ffi::OsStringExt;
Some(
self.temp_file
.path()
.to_path_buf()
.into_os_string()
.into_vec(),
)
}
}
impl TryFrom<NamedTempFile> for TempBootstrapFile {
type Error = tempfile::PersistError;
fn try_from(temp_file: NamedTempFile) -> Result<Self, Self::Error> {
Ok(Self { temp_file })
}
}
/// We don't currently create a permanent bootstrap file on Unix-like systems.
pub fn path_to_permanent_bootstrap_file(_shell_type: ShellType) -> Option<Vec<u8>> {
None
}
@@ -0,0 +1,122 @@
#[cfg(feature = "local_fs")]
use std::io::Write;
use std::path::PathBuf;
use typed_path::TypedPath;
use warp_util::path::{convert_wsl_to_windows_host_path, WSLPathConversionError};
use crate::terminal::shell::ShellType;
/// Represents a temporary file used for bootstrap.
/// This is necessary because [`tempfile`] requires us to hold on to the file handle. However,
/// this causes Windows to prevent the shell from reading the file (since it is being used by
/// another process).
#[derive(Debug)]
pub struct TempBootstrapFile {
file_path: FilePath,
}
#[derive(Debug)]
enum FilePath {
Direct(PathBuf),
Wsl(PathBuf),
}
#[derive(thiserror::Error, Debug)]
pub enum TempBootstrapFileError {
#[error(transparent)]
ConvertPath(#[from] WSLPathConversionError),
#[error("could not create temporary file")]
Create(std::io::Error),
#[error("could not write bootstrap script to temporary file")]
Write(std::io::Error),
#[error("could not flush to temporary file")]
Flush(std::io::Error),
#[error("could not persist temporary file")]
Persist(#[from] tempfile::PersistError),
}
impl TempBootstrapFile {
pub fn new<C, S>(
builder: tempfile::Builder,
contents: C,
wsl_distribution: Option<S>,
) -> Result<Self, TempBootstrapFileError>
where
C: AsRef<[u8]>,
S: AsRef<str>,
{
let mut file = match &wsl_distribution {
Some(distro_name) => {
let wsl_temp_dir = convert_wsl_to_windows_host_path(
&TypedPath::from("/tmp"),
distro_name.as_ref(),
)?;
builder
.tempfile_in(wsl_temp_dir)
.map_err(TempBootstrapFileError::Create)?
}
None => builder.tempfile().map_err(TempBootstrapFileError::Create)?,
};
file.write_all(contents.as_ref())
.map_err(TempBootstrapFileError::Write)?;
file.flush().map_err(TempBootstrapFileError::Flush)?;
// We persist the file here because there are issues with using NamedTempFile
// as-is. If we hold on to the file handle without persisting, Windows
// does not let the shell access the file.
file.keep()
.map(|(_file, path)| Self {
file_path: match wsl_distribution.is_some() {
true => FilePath::Wsl(path),
false => FilePath::Direct(path),
},
})
.map_err(TempBootstrapFileError::from)
}
pub fn path_as_bytes(&self) -> Option<Vec<u8>> {
match &self.file_path {
FilePath::Direct(path) => path
.as_os_str()
.to_str()
.map(|path_str| path_str.as_bytes().to_vec()),
FilePath::Wsl(path) => {
let file_name = path
.file_name()
.map(|file_name| file_name.to_string_lossy())?;
// We need to source the path from the Linux distribution's perspective here.
let path = format!("/tmp/{file_name}");
Some(path.as_bytes().to_vec())
}
}
}
}
impl Drop for TempBootstrapFile {
fn drop(&mut self) {
if let Err(err) = match &self.file_path {
FilePath::Direct(path) => std::fs::remove_file(path),
FilePath::Wsl(path) => std::fs::remove_file(path),
} {
log::warn!("Unable to remove temporary bootstrap file: {err:?}");
}
}
}
/// Returns the path to the permanent bootstrap file in bytes, if it exists.
///
/// Currently we only create a permanent bootstrap file for PowerShell, located
/// alongside the Warp executable.
pub fn path_to_permanent_bootstrap_file(shell_type: ShellType) -> Option<Vec<u8>> {
if shell_type != ShellType::PowerShell {
return None;
}
let install_dir = crate::util::windows::install_dir().ok()?;
let path = install_dir.join("pwsh.ps1");
path.is_file()
.then(|| path.as_os_str().to_str().map(|s| s.as_bytes().to_vec()))
.flatten()
}
@@ -0,0 +1,76 @@
use std::sync::mpsc::SyncSender;
use std::sync::Arc;
use parking_lot::FairMutex;
use warpui::{AppContext, ModelHandle, SingletonEntity};
use crate::persistence::StartedCommandMetadata;
use crate::terminal::{view::ExecuteCommandEvent, TerminalModel};
use crate::terminal::{History, HistoryEntry};
use crate::{persistence::ModelEvent, terminal::model::session::Sessions};
pub fn update_command_history(
event: &ExecuteCommandEvent,
model: &Arc<FairMutex<TerminalModel>>,
model_event_sender: Option<&SyncSender<ModelEvent>>,
sessions: &ModelHandle<Sessions>,
ctx: &mut AppContext,
) {
let model = model.lock();
let active_block = model.block_list().active_block();
let session_id = event.session_id;
let Some(session) = sessions.as_ref(ctx).get(session_id) else {
return;
};
let shell = session.shell();
if !shell.should_add_command_to_history(&event.command) {
return;
}
let is_agent_executed = event.source.is_ai_command();
let session_ref = &*session;
History::handle(ctx).update(ctx, move |history, _| {
history.append_commands(
session_id,
vec![HistoryEntry::for_session_command(
event.command.to_string(),
active_block,
session_ref,
event.workflow_id.to_owned(),
event.workflow_command.to_owned(),
is_agent_executed,
)],
)
});
if let Some(sender) = model_event_sender {
let sender_clone = sender.clone();
let insert_command_event = ModelEvent::InsertCommand {
metadata: StartedCommandMetadata {
command: event.command.to_owned(),
start_ts: active_block.start_ts().copied(),
pwd: active_block.pwd().map(|pwd| pwd.to_owned()),
shell: Some(session.shell().shell_type().name().to_owned()),
username: Some(session.user().to_owned()),
hostname: Some(session.hostname().to_owned()),
session_id: Some(session_id),
cloud_workflow_id: event.workflow_id.to_owned(),
workflow_command: event.workflow_command.to_owned(),
git_branch: active_block
.git_branch()
.map(|git_branch| git_branch.to_owned()),
is_agent_executed,
},
};
ctx.background_executor()
.spawn(async move {
// Sending over a sync sender can block the current thread, so we do this async.
if let Err(e) = sender_clone.send(insert_command_event) {
log::error!("Error sending ModelEvent: {e:?}");
}
})
.detach();
}
}
+22
View File
@@ -0,0 +1,22 @@
use crate::terminal::SizeInfo;
use std::borrow::Cow;
/// Messages that may be sent to the `EventLoop`.
#[derive(Debug)]
pub enum Message {
/// Data that should be written to the PTY.
Input(Cow<'static, [u8]>),
/// Indicates that the `EventLoop` should be shut down.
Shutdown,
/// Indicates that the child process has exited.
///
/// Only used on Windows, as we need to pass this information to the
/// event loop via the channel (and cannot use the child event token).
#[cfg_attr(not(windows), allow(dead_code))]
ChildExited,
/// Instruction to resize the PTY.
Resize(SizeInfo),
}
+11
View File
@@ -0,0 +1,11 @@
#[cfg(not(target_family = "wasm"))]
mod bootstrap_file;
pub mod command_history;
mod message;
pub mod pty_controller;
#[cfg(not(target_family = "wasm"))]
pub mod remote_server_controller;
pub mod terminal_manager_util;
pub use message::Message;
pub use pty_controller::{PtyController, PtyControllerEvent};
@@ -0,0 +1,856 @@
use std::{borrow::Cow, collections::VecDeque, sync::Arc};
use async_channel::{Receiver, Sender};
use parking_lot::FairMutex;
use thiserror::Error;
use warpui::r#async::block_on;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::agent::AIAgentPtyWriteMode;
use crate::terminal::input::CommandExecutionSource;
use crate::terminal::model::completions::ShellCompletion;
use crate::terminal::model::session::{
ExecutorCommandEvent, InBandCommandCancelledEvent, Sessions,
};
use crate::terminal::model::tmux::commands::TmuxCommand;
use crate::terminal::model_events::AnsiHandlerEvent;
use crate::terminal::view::LINEFEED_REGEX;
#[cfg(not(target_family = "wasm"))]
use crate::terminal::writeable_pty::bootstrap_file::{permanent_bootstrap_file, TempBootstrapFile};
use crate::terminal::{
bootstrap,
line_editor_status::{LineEditorStatus, LineEditorStatusEvent},
model::{ansi::Handler, escape_sequences, session::SessionInfo},
model_events::{ModelEvent, ModelEventDispatcher},
shell::ShellType,
SizeUpdate, TerminalModel,
};
use crate::SessionSettings;
use super::Message;
/// Byte sequence to emulate the user pressing ENTER, used to execute a command in the shell.
const COMMAND_ENTER: &[u8] = &[escape_sequences::C0::CR, escape_sequences::C0::LF];
/// Used to let the shell know we are switching to the PS1 prompt via a bindkey \ep. This will
/// restore the PS1 from the saved PS1 value (we had unset the PS1 for Warp prompt).
const SWITCH_TO_PS1_ESCAPE_SEQUENCE: &[u8] = &[escape_sequences::C0::ESC, b'p'];
/// Used to let the shell know we are switching to the Warp prompt via a bindkey \ew. This will
/// unset the PS1 to ensure we don't have a double prompt (PS1 and Warp prompt).
const SWITCH_TO_WARP_PROMPT_ESCAPE_SEQUENCE: &[u8] = &[escape_sequences::C0::ESC, b'w'];
/// Represents a single call to write bytes to the PTY asynchronously.
enum PtyWrite {
Command {
command: String,
shell_type: ShellType,
/// The id if the command is an in-band command or `None` if the command is not an in-band
/// command.
in_band_command_id: Option<String>,
/// If 'some', the given callback is called right before the bytes are written to the PTY.
before_write_fn: Option<Box<dyn Fn() + Send + 'static>>,
},
Bytes {
/// The bytes to be written.
bytes: Cow<'static, [u8]>,
},
AgentInput {
/// The bytes to be written.
bytes: Cow<'static, [u8]>,
/// The `mode` for the agent's write.
mode: AIAgentPtyWriteMode,
},
TmuxCommand(TmuxCommand),
RunNativeShellCompletions(NativeShellCompletionsState),
}
enum NativeShellCompletionsState {
AwaitingPrompt {
buffer_text: String,
results_tx: async_channel::Sender<Vec<ShellCompletion>>,
},
AwaitingResults {
results_tx: async_channel::Sender<Vec<ShellCompletion>>,
},
}
impl NativeShellCompletionsState {
fn is_awaiting_prompt(&self) -> bool {
matches!(self, Self::AwaitingPrompt { .. })
}
}
enum TmuxControlMode {
/// Tmux control mode is started, but we don't have the primary pane yet.
Pending { buffer: Vec<u8> },
/// Tmux control mode is active.
Active { primary_pane: u32 },
}
/// Controller for writes to the PTY.
///
/// This is responsible for coordinating writes to the PTY amongst input like user commands, non-command user
/// input, and in-band commands in conjunction with line editor status.
pub struct PtyController<T: EventLoopSender> {
/// `Sender` for the main PTY event loop channel.
event_loop_tx: T,
terminal_model: Arc<FairMutex<TerminalModel>>,
line_editor_status: ModelHandle<LineEditorStatus>,
sessions: ModelHandle<Sessions>,
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
pending_writes: VecDeque<PtyWrite>,
is_user_command_executing: bool,
is_bracketed_paste_enabled: bool,
/// If we're bootstrapping the shell by sourcing a file with the bootstrap
/// script, this will hold the handle to the file. Once bootstrapping is
/// complete, it will be dropped to clean up the temporary file.
#[cfg(not(target_family = "wasm"))]
bootstrap_file: Option<TempBootstrapFile>,
tmux_control_mode: Option<TmuxControlMode>,
in_flight_native_completions_state: Option<NativeShellCompletionsState>,
}
impl<T: EventLoopSender> PtyController<T> {
pub fn new(
event_loop_tx: T,
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
line_editor_status: ModelHandle<LineEditorStatus>,
sessions: ModelHandle<Sessions>,
executor_command_rx: Receiver<ExecutorCommandEvent>,
terminal_model: Arc<FairMutex<TerminalModel>>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&model_event_dispatcher, |me, event, ctx| match event {
ModelEvent::Handler(AnsiHandlerEvent::UserCommandFinished) => {
me.is_user_command_executing = false;
}
ModelEvent::Handler(AnsiHandlerEvent::InitShell {
pending_session_info,
}) => {
me.initialize_shell(pending_session_info.as_ref(), ctx);
}
ModelEvent::Handler(AnsiHandlerEvent::Bootstrapped { is_subshell, .. }) => {
me.shell_bootstrapped(*is_subshell);
}
ModelEvent::Handler(AnsiHandlerEvent::SetBracketedPaste) => {
me.is_bracketed_paste_enabled = true;
}
ModelEvent::Handler(AnsiHandlerEvent::UnsetBracketedPaste) => {
me.is_bracketed_paste_enabled = false;
}
ModelEvent::Handler(AnsiHandlerEvent::StartTmuxControlMode) => {
me.tmux_control_mode = Some(TmuxControlMode::Pending {
buffer: Default::default(),
});
}
ModelEvent::Handler(AnsiHandlerEvent::RunTmuxCommand(command)) => {
me.send_write_to_event_loop(PtyWrite::TmuxCommand(command.to_owned()), ctx);
}
ModelEvent::Handler(AnsiHandlerEvent::EndTmuxControlMode) => {
me.tmux_control_mode = None;
}
ModelEvent::HonorPS1OutOfSync => {
// We force re-sync the PS1 state of Warp settings with the shell's environment variable, $WARP_HONOR_PS1, via
// a bindkey (which triggers a shell function).
let honor_ps1 = *SessionSettings::as_ref(ctx).honor_ps1;
if honor_ps1 {
me.send_switch_to_ps1_bindkey(ctx);
} else {
me.send_switch_to_warp_prompt_bindkey(ctx);
}
}
ModelEvent::Handler(AnsiHandlerEvent::TmuxControlModeReady { primary_pane }) => {
let previous_control_mode_state = me.tmux_control_mode.replace(TmuxControlMode::Active {
primary_pane: *primary_pane,
});
if let Some(TmuxControlMode::Pending { buffer }) = previous_control_mode_state {
me.send_write_to_event_loop(
PtyWrite::Bytes {
bytes: Cow::Owned(buffer),
},
ctx,
);
}
}
ModelEvent::CompletionsFinished(data) => {
let Some(NativeShellCompletionsState::AwaitingResults { results_tx }) = me.in_flight_native_completions_state.take() else {
log::warn!("Received CompletionsFinished event but didn't have a channel to send results over!");
return;
};
let _ = block_on(results_tx.send(data.clone()));
}
ModelEvent::SendCompletionsPrompt => {
let Some(NativeShellCompletionsState::AwaitingPrompt {
buffer_text,
results_tx,
}) = me.in_flight_native_completions_state.take() else {
log::warn!("Received SendCompletionsPrompt event but didn't have a prompt to send!");
return;
};
me.in_flight_native_completions_state = Some(NativeShellCompletionsState::AwaitingResults { results_tx });
let mut bytes = buffer_text.into_bytes();
// We use the EOT character to signal the end of the prompt.
bytes.push(escape_sequences::C0::EOT);
// We send the write directly to the event loop without
// queueing, as we currently have exclusive control over pty
// writes.
me.send_write_to_event_loop(
PtyWrite::Bytes {
bytes: bytes.into(),
},
ctx,
);
// Now that we've provided the prompt, we can start executing
// other queued writes.
me.execute_next_queued_write(ctx);
}
_ => (),
});
ctx.subscribe_to_model(&line_editor_status, |me, event, ctx| {
if let LineEditorStatusEvent::Active = event {
let input_reporting_seq = me
.model_event_dispatcher
.as_ref(ctx)
.active_session_id()
.and_then(|id| me.sessions.as_ref(ctx).get(id))
.and_then(|session| session.shell().input_reporting_sequence());
if let Some(bytes) = input_reporting_seq {
me.pending_writes.push_front(PtyWrite::Bytes {
bytes: Cow::Owned(bytes.to_vec()),
});
}
me.execute_next_queued_write(ctx);
}
});
let _ = ctx.spawn_stream_local(
executor_command_rx,
|me, event, ctx| match event {
ExecutorCommandEvent::ExecuteCommand { command, cancel_tx } => {
me.queue_in_band_command(
command.command.as_str(),
command.shell_type,
command.command_id,
cancel_tx,
ctx,
);
}
ExecutorCommandEvent::CancelCommand { id } => {
me.cancel_in_band_command(id.as_str());
}
ExecutorCommandEvent::ExecuteTmuxCommand(command) => {
me.send_write_to_event_loop(PtyWrite::TmuxCommand(command), ctx);
}
},
|_, _| (),
);
Self {
event_loop_tx,
terminal_model,
line_editor_status,
sessions,
model_event_dispatcher,
pending_writes: VecDeque::new(),
is_user_command_executing: false,
is_bracketed_paste_enabled: false,
#[cfg(not(target_family = "wasm"))]
bootstrap_file: None,
tmux_control_mode: None,
in_flight_native_completions_state: None,
}
}
/// Sends bindkey to notify shell process to switch to PS1 logic for prompt
/// with the combined prompt/command grid (we restore the saved PS1 value).
pub fn send_switch_to_ps1_bindkey(&mut self, ctx: &mut ModelContext<Self>) {
self.pending_writes.push_back(PtyWrite::Bytes {
bytes: SWITCH_TO_PS1_ESCAPE_SEQUENCE.into(),
});
self.execute_next_queued_write(ctx);
let is_bash_shell = self
.model_event_dispatcher
.as_ref(ctx)
.active_session_id()
.and_then(|id| self.sessions.as_ref(ctx).get(id))
.map(|session| session.shell().shell_type() == ShellType::Bash)
.unwrap_or(false);
if is_bash_shell {
// We cannot repaint via shell command in bash, so we must execute an empty command to force refresh the prompt instantly
// (avoid a 1 block delay since the current prompt has technically already been sent).
self.pending_writes.push_back(PtyWrite::Bytes {
bytes: COMMAND_ENTER.into(),
});
self.execute_next_queued_write(ctx);
}
}
/// Sends bindkey to notify shell process to switch to Warp prompt logic for prompt
/// with the combined prompt/command grid (we unset the PS1, but save the value for potential
/// future restoration).
pub fn send_switch_to_warp_prompt_bindkey(&mut self, ctx: &mut ModelContext<Self>) {
self.pending_writes.push_back(PtyWrite::Bytes {
bytes: SWITCH_TO_WARP_PROMPT_ESCAPE_SEQUENCE.into(),
});
self.execute_next_queued_write(ctx);
let is_bash_shell = self
.model_event_dispatcher
.as_ref(ctx)
.active_session_id()
.and_then(|id| self.sessions.as_ref(ctx).get(id))
.map(|session| session.shell().shell_type() == ShellType::Bash)
.unwrap_or(false);
if is_bash_shell {
// We cannot repaint via shell command in bash, so we must execute an empty command to force refresh the prompt instantly
// (avoid a 1 block delay since the current prompt has technically already been sent).
self.pending_writes.push_back(PtyWrite::Bytes {
bytes: COMMAND_ENTER.into(),
});
self.execute_next_queued_write(ctx);
}
}
fn cancel_in_band_command(&mut self, command_id: &str) {
self.pending_writes.retain(|pty_write| {
!matches!(pty_write, PtyWrite::Command {
in_band_command_id, ..
} if in_band_command_id.as_deref() == Some(command_id))
});
}
/// Queues an in-band command to be written to the PTY, either immediately, or when the line
/// editor next becomes active.
///
/// If a user command is currently executing, this short-circuits since the in-band command
/// request is likely stale. However, we still need to signal that the command will not be
/// executed so the executor knows to clear it.
fn queue_in_band_command(
&mut self,
command: &str,
shell_type: ShellType,
command_id: String,
cancel_tx: Sender<InBandCommandCancelledEvent>,
ctx: &mut ModelContext<Self>,
) {
if self.is_user_command_executing {
// Send blocking should be okay b/c this is an unbound channel
if let Err(err) = block_on(cancel_tx.send(InBandCommandCancelledEvent { command_id })) {
log::warn!("Pty Controller failed to cancel in band command: {err:?}");
}
return;
}
let terminal_model = self.terminal_model.clone();
self.pending_writes.push_back(PtyWrite::Command {
command: command.to_owned(),
shell_type,
in_band_command_id: Some(command_id),
before_write_fn: Some(Box::new(move || {
let mut terminal_model = terminal_model.lock();
terminal_model
.block_list_mut()
.start_active_block_for_in_band_command();
})),
});
self.execute_next_queued_write(ctx);
}
/// Returns whether we can currently write to the pty, or if we need to
/// enqueue writes for later.
fn can_write_to_pty(&self, ctx: &mut ModelContext<Self>) -> bool {
self.line_editor_status.as_ref(ctx).is_line_editor_active()
// If we're in the middle of a native completions request, we should not send any more
// writes to the shell until we've sent the string to complete.
&& !self.in_flight_native_completions_state.as_ref().is_some_and(|state| state.is_awaiting_prompt())
}
/// Executes the next queued `PtyWrite`, if able.
///
/// This is a no-op if the line editor is currently inactive; in the constructor of
/// PtyController, a subscription is registered on `LineEditorStatus` which calls this function
/// when the line editor becomes active.
fn execute_next_queued_write(&mut self, ctx: &mut ModelContext<Self>) {
if !self.can_write_to_pty(ctx) {
return;
}
if let Some(write) = self.pending_writes.pop_front() {
let is_command = matches!(write, PtyWrite::Command { .. });
self.send_write_to_event_loop(write, ctx);
if !is_command {
self.execute_next_queued_write(ctx);
}
}
}
/// Writes a set of bytes to the PTY to begin bootstrapping a shell.
pub(super) fn initialize_shell(
&mut self,
pending_session_info: &SessionInfo,
ctx: &mut ModelContext<Self>,
) {
let shell_type = pending_session_info.shell.shell_type();
#[cfg(feature = "local_fs")]
if let Some(path) = permanent_bootstrap_file(shell_type, pending_session_info) {
// If there is a permanent bootstrap file, source it directly. We
// currently only do this for local PowerShell sessions on Windows.
self.source_bootstrap_script(path, shell_type, ctx);
return;
}
let bootstrap = bootstrap::script_for_shell(shell_type, &crate::ASSETS);
self.write_bootstrap_script_to_shell(pending_session_info, ctx, shell_type, bootstrap);
}
/// Writes the bytes to to terminate and run the bootstrap script.
#[cfg(feature = "local_fs")]
fn write_terminating_bootstrap_bytes(&mut self, ctx: &mut ModelContext<PtyController<T>>) {
cfg_if::cfg_if! {
if #[cfg(unix)] {
self.write_bytes(&b"\n"[..], ctx);
} else if #[cfg(target_os = "windows")] {
self.write_bytes(&b"\r"[..], ctx);
}
}
}
#[cfg(feature = "local_fs")]
fn write_bootstrap_script_to_shell(
&mut self,
pending_session_info: &SessionInfo,
ctx: &mut ModelContext<PtyController<T>>,
shell_type: ShellType,
bootstrap: Cow<'static, [u8]>,
) {
use super::bootstrap_file::create_bootstrap_file;
use crate::terminal::ShellLaunchData;
if bootstrap::should_use_rc_file_bootstrap_method(shell_type, pending_session_info) {
let wsl_distribution = match (
&pending_session_info.launch_data,
pending_session_info.wsl_name.as_ref(),
) {
(_, Some(wsl_name)) => Some(wsl_name),
(Some(ShellLaunchData::WSL { distro }), _) => Some(distro),
(
Some(ShellLaunchData::Executable { .. })
| Some(ShellLaunchData::MSYS2 { .. })
| Some(ShellLaunchData::DockerSandbox { .. })
| None,
_,
) => None,
};
// If creating the temporary file fails for any reason, we fall
// back to the existing bracketed paste logic. Using bracketed paste
// reduces the amount of reformatting that Fish tries to do and so improves
// bootstrap speed. We need to add an explicit leading space, since Fish
// automatically trims the input when performing a bracketed paste.
if let Some(file) = create_bootstrap_file(&bootstrap, shell_type, wsl_distribution) {
if let Some(path) = file.path_as_bytes() {
self.source_bootstrap_script(path, shell_type, ctx);
} else {
self.write_terminating_bootstrap_bytes(ctx);
log::error!("Could not convert bootstrap script file path to str");
}
self.bootstrap_file = Some(file);
} else {
self.write_bytes(&b" "[..], ctx);
self.write_bytes(escape_sequences::BRACKETED_PASTE_START, ctx);
self.write_bytes(bootstrap, ctx);
self.write_bytes(escape_sequences::BRACKETED_PASTE_END, ctx);
self.write_terminating_bootstrap_bytes(ctx);
}
} else {
self.write_bytes(bootstrap, ctx);
}
}
#[cfg(feature = "local_fs")]
/// Sources the bootstrap script at the given path. Assumes that the path
/// contains a valid file.
fn source_bootstrap_script(
&mut self,
path_to_script: Vec<u8>,
shell_type: ShellType,
ctx: &mut ModelContext<Self>,
) {
use warp_util::path::ShellFamily;
// TODO(CORE-2099): Figure out a more robust solution here. Fish users
// can redefine these functions via fish functions. Ideally this won't
// break if the user redefines the `source` or `.` built-in.
match shell_type {
ShellType::PowerShell => {
let path_str = String::from_utf8_lossy(&path_to_script);
let escaped = ShellFamily::PowerShell.escape(&path_str).into_owned();
self.write_bytes(b" . ", ctx);
self.write_bytes(escaped.into_bytes(), ctx);
}
_ => {
self.write_bytes(b" source '", ctx);
self.write_bytes(path_to_script, ctx);
self.write_bytes(b"'", ctx);
}
}
self.write_terminating_bootstrap_bytes(ctx);
}
#[cfg(not(feature = "local_fs"))]
fn write_bootstrap_script_to_shell(
&mut self,
_pending_session_info: &SessionInfo,
ctx: &mut ModelContext<PtyController<T>>,
_shell_type: ShellType,
bootstrap: Cow<'static, [u8]>,
) {
self.write_bytes(bootstrap, ctx);
}
/// Handles the shell having finished bootstrapping.
fn shell_bootstrapped(&mut self, is_subshell: bool) {
if is_subshell {
self.is_user_command_executing = false;
}
// Now that we have bootstrapped, we can be sure that the bootstrap
// file is no longer needed.
#[cfg(not(target_family = "wasm"))]
self.bootstrap_file.take();
}
/// Converts the given `command` into a byte array and writes its corresponding bytes to the PTY.
///
/// If the line editor is active, the command is written immediately. Otherwise, the command is
/// written when the line editor becomes active.
///
/// This also clears pending_writes, since the priority is to execute the user's command.
///
/// The exact sequence of corresponding bytes depends on the given `shell`. For example, if the
/// shell supports bracketed paste, the command's bytes may be wrapped in bracketed paste byte
/// sequences.
pub fn write_command(
&mut self,
command: &str,
shell_type: ShellType,
source: CommandExecutionSource,
ctx: &mut ModelContext<Self>,
) {
{
let mut model = self.terminal_model.lock();
// Explicitly start the block now that the command is executed.
match source {
CommandExecutionSource::AI { metadata } => {
model.start_command_execution_with_ai_metadata(metadata)
}
CommandExecutionSource::SharedSession {
participant_id,
ai_metadata,
..
} => model.start_command_execution_for_shared_session(participant_id, ai_metadata),
CommandExecutionSource::User => model.start_command_execution(),
CommandExecutionSource::EnvVarCollection { metadata } => {
model.start_command_execution_from_env_var_collection(metadata)
}
}
// Ensure that the `TerminalModel` doesn't interpret any of the PTY output from the
// following commands as in-band command output. If the in-band command output is not
// currently being received by the `TerminalModel`, this is a no-op.
model.end_in_band_command_output(false);
}
self.pending_writes.clear();
self.is_user_command_executing = true;
// Send the write to the PTY event loop.
let write = PtyWrite::Command {
command: command.to_owned(),
shell_type,
in_band_command_id: None,
before_write_fn: None,
};
if self.can_write_to_pty(ctx) {
// Cancel the async writer task and clear the async write queue.
// Check if line editor is active
self.send_write_to_event_loop(write, ctx);
} else {
self.pending_writes.push_back(write);
}
}
/// Synchronously writes the EOT (End-of-Transmission) char to the PTY.
pub fn write_end_of_transmission_char(&mut self, ctx: &mut ModelContext<Self>) {
self.write_bytes(&[escape_sequences::C0::EOT][..], ctx);
// Consider the active block to be "started" since a user performed an action that
// results in bytes being written to the pty. This makes the output from ctrl-d during ssh
// get written to the active block.
// TODO: reconsider this behavior since the output was not the result of a command, and given the function name
// is start_command_execution and no command was executed.
self.terminal_model.lock().start_command_execution();
}
/// Resizes the PTY's size (i.e. its notion of the number of columns and rows in the screen) via
/// ioctl system call and updates the terminal model as appropriate.
pub fn resize_pty(&self, size_update: SizeUpdate, ctx: &mut ModelContext<Self>) {
// Send a message to the PTY event loop to resize the PTY.
// We also need to resize when rows/cols changed without a pane size change
// (e.g. ViewerSizeReported on the sharer side).
if size_update.pane_size_changed()
|| size_update.is_refresh()
|| size_update.rows_or_columns_changed()
{
self.send_message_to_event_loop(Message::Resize(size_update.new_size), ctx);
}
}
/// Writes agent input to the PTY.
pub fn write_agent_bytes<B: Into<Cow<'static, [u8]>>>(
&mut self,
bytes: B,
mode: &AIAgentPtyWriteMode,
ctx: &mut ModelContext<Self>,
) {
self.send_write_to_event_loop(
PtyWrite::AgentInput {
bytes: bytes.into(),
mode: *mode,
},
ctx,
);
}
/// Writes user input to the PTY.
///
/// This should only be called for non-command input (e.g. input that should be passed through
/// in a long-running command or in the alt screen, rather than from the input editor).
pub fn write_bytes<B: Into<Cow<'static, [u8]>>>(
&mut self,
bytes: B,
ctx: &mut ModelContext<Self>,
) {
self.send_write_to_event_loop(
PtyWrite::Bytes {
bytes: bytes.into(),
},
ctx,
);
}
/// Shuts down the pty and event loop.
pub fn shutdown_pty(&mut self, ctx: &mut ModelContext<Self>) {
self.send_message_to_event_loop(Message::Shutdown, ctx);
}
/// Sends a message to the event loop thread requesting a PTY write for the given `bytes`.
///
/// If the write corresponds to a command, this also calls
/// [`LineEditorStatus::did_execute_command()`].
fn send_write_to_event_loop(&mut self, write: PtyWrite, ctx: &mut ModelContext<Self>) {
let (bytes_to_write, is_for_command, on_write_fn, raw_tmux_command) = match write {
PtyWrite::Command {
command,
shell_type,
before_write_fn: on_write_fn,
..
} => (
Cow::Owned(bytes_to_execute_command(
command.as_str(),
shell_type,
self.is_bracketed_paste_enabled,
)),
true,
on_write_fn,
false,
),
PtyWrite::AgentInput { bytes, mode } => {
let decorated_bytes =
mode.decorate_bytes(bytes.into_owned(), self.is_bracketed_paste_enabled);
(decorated_bytes.into(), false, None, false)
}
PtyWrite::Bytes { bytes } => (bytes, false, None, false),
PtyWrite::TmuxCommand(command) => {
let command = command.get_command_string();
debug_assert!(
command.ends_with('\n'),
"Tmux commands must end in a newlines so they are executed"
);
debug_assert!(
self.tmux_control_mode.is_some(),
"Received tmux command outside of control mode."
);
(command.into_bytes().into(), false, None, true)
}
PtyWrite::RunNativeShellCompletions(state) => {
self.in_flight_native_completions_state = Some(state);
// Send a ^Y control code to trigger the right bindkey. We
// then wait for an OSC-based signal from the shell before we
// send the text that needs to be completed.
let bytes = vec![0x19_u8];
(bytes.into(), false, None, false)
}
};
// The terminal hangs if we send 0 bytes through.
if bytes_to_write.is_empty() {
return;
}
let bytes_to_write = match &mut self.tmux_control_mode {
None => bytes_to_write,
Some(_) if raw_tmux_command => bytes_to_write,
Some(TmuxControlMode::Pending { buffer }) => {
buffer.extend_from_slice(&bytes_to_write);
return;
}
Some(TmuxControlMode::Active { primary_pane }) => {
crate::terminal::model::tmux::format_input(*primary_pane, &bytes_to_write)
.as_bytes()
.to_owned()
.into()
}
};
if is_for_command {
self.line_editor_status
.update(ctx, |line_editor_status, ctx| {
line_editor_status.did_execute_command(ctx)
});
}
if let Some(on_write_fn) = on_write_fn {
on_write_fn();
}
self.send_message_to_event_loop(Message::Input(bytes_to_write), ctx);
}
/// Sends a message to the event loop. If the send fails with `SendError::Disconnected`, emits
/// a `PtyDisconnected` event.
fn send_message_to_event_loop(&self, message: Message, ctx: &mut ModelContext<Self>) {
match self.event_loop_tx.send(message) {
Err(EventLoopSendError::Disconnected) => {
ctx.emit(PtyControllerEvent::PtyDisconnected);
}
Err(e) => {
log::warn!("Unable to send event loop msg {e:?}");
}
_ => (),
}
}
pub fn run_native_shell_completions(
&mut self,
buffer_text: String,
results_tx: async_channel::Sender<Vec<ShellCompletion>>,
ctx: &mut ModelContext<Self>,
) {
// Make sure we only have a single pending native shell completions
// request at a time by dropping any existing ones from the queue.
self.pending_writes
.retain(|write| !matches!(write, PtyWrite::RunNativeShellCompletions(_)));
self.pending_writes
.push_back(PtyWrite::RunNativeShellCompletions(
NativeShellCompletionsState::AwaitingPrompt {
buffer_text,
results_tx,
},
));
self.execute_next_queued_write(ctx);
}
}
pub enum PtyControllerEvent {
/// Emitted when the event loop thread has exited.
PtyDisconnected,
}
impl<T: EventLoopSender> Entity for PtyController<T> {
type Event = PtyControllerEvent;
}
/// Returns the shell-dependent array of bytes to be written to the PTY to execute `command`.
fn bytes_to_execute_command(
command: &str,
shell_type: ShellType,
is_bracketed_paste_enabled: bool,
) -> Vec<u8> {
let mut command_bytes = shell_type.kill_buffer_bytes().to_vec();
// Only execute the command via bracketed paste if the command is not empty. Some ZSH
// bracketed paste magic functions return errors if bracketed paste is used without text
// in-between the bracketed paste escape sequences.
if is_bracketed_paste_enabled && !command.is_empty() {
match shell_type {
ShellType::Fish => {
// Fish strips leading (and trailing) whitespace from pasted commands (entered via
// bracketed paste). To ensure that leading whitespace is preserved, first append
// leading whitespace bytes and then surround the remaining command string with the
// bracketed paste sequence. Conceptually, this would be like manually typing in
// the whitespace into the fish line editor, and then pasting in the command.
//
// The leading whitespace is particularly meaningful in fish because it causes the
// following command to be ommitted from history (like the HISTIGNORESPACE option
// in zsh).
//
// We don't care about preserving trailing whitespace; it would just take up
// unnecessary space in the blocklist.
let (leading_whitespace, rest_of_command) =
command.split_at(command.len() - command.trim_start().len());
command_bytes.extend(leading_whitespace.as_bytes());
command_bytes.extend(wrap_bytes_in_bracketed_paste(
rest_of_command
.replace(escape_sequences::C0::ESC as char, "")
.into_bytes(),
));
}
_ => command_bytes.extend(wrap_bytes_in_bracketed_paste(
command
.replace(escape_sequences::C0::ESC as char, "")
.into_bytes(),
)),
}
} else {
let command_without_escapes = command.replace(escape_sequences::C0::ESC as char, "");
// This is a fix for PLAT-770 to allow multi-line commands in Powershell.
// In general, shells without bracketed paste don't handle `\n` that well,
// and in the case of PowerShell, it is explicitly ignored.
let command_without_newlines = LINEFEED_REGEX.replace_all(&command_without_escapes, "\r");
command_bytes.extend(command_without_newlines.as_bytes());
}
command_bytes.extend(shell_type.execute_command_bytes().to_vec());
command_bytes
}
/// Returns a vector containing the given `bytes` wrapped in bracketed paste start and end
/// sequences.
fn wrap_bytes_in_bracketed_paste(bytes: impl IntoIterator<Item = u8>) -> impl Iterator<Item = u8> {
escape_sequences::BRACKETED_PASTE_START
.iter()
.copied()
.chain(bytes)
.chain(escape_sequences::BRACKETED_PASTE_END.iter().copied())
}
#[derive(Error, Debug)]
pub enum EventLoopSendError {
#[error("Unable to send message: receiver is disconnected")]
Disconnected,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub trait EventLoopSender: 'static {
fn send(&self, message: Message) -> Result<(), EventLoopSendError>;
}
@@ -0,0 +1,233 @@
use crate::terminal::color::List;
use crate::terminal::event::UserBlockCompleted;
use crate::terminal::event_listener::ChannelEventListener;
use crate::terminal::model::block::{BlockSize, SerializedBlock};
use crate::terminal::shell::ShellType;
use crate::terminal::BlockPadding;
use crate::theme::WarpTheme;
use super::*;
impl PtyController {
/// Flushes PTY writes by sending a "dummy" test write, and blocking until the test write is
/// handled.
///
/// When the "dummy write" is handled, an event is emitted through a channel. This method
/// blocks on receiving this event through the channel. Since async writes are handled in order
/// that they are queued, this ensures all queued writes have been handled/"flushed".
fn flush_pty_writes(&mut self) {
let (tx, rx) = futures::channel::oneshot::channel();
let _ = self.queue_async_write(AsyncPtyWrite {
bytes: vec![],
delay: None,
on_write_fn: Some(Box::new(move || {
let _ = tx.send(());
})),
});
let _ = warpui::r#async::block_on(rx);
}
}
fn terminal_model(background_executor: Arc<Background>) -> Arc<FairMutex<TerminalModel>> {
Arc::new(FairMutex::new(TerminalModel::new_for_test(
// This BlockSize contains arbitrary values.
BlockSize {
block_padding: BlockPadding {
padding_top: 0.5,
command_padding_top: 0.5,
middle: 0.5,
bottom: 0.5,
},
size: SizeInfo::new_for_test_with_width_and_height(7., 10.5),
max_block_scroll_limit: 1000,
prompt_height: 1.,
},
List::from(&WarpTheme::default().into()),
ChannelEventListener::new_for_test(),
background_executor,
false,
None,
false,
None,
)))
}
fn assert_input_matches(message: &Message, expected_bytes: Vec<u8>) {
assert!(matches!(message, Message::Input(bytes) if bytes.to_vec() == expected_bytes));
}
/// Returns a vector containing the bytes we expect to be written to the PTY to execute the given
/// `command` in the given `Shell`.
fn expected_command_bytes(command: &str, shell: &Shell) -> Vec<u8> {
let mut bytes = shell.shell_type().kill_buffer_bytes().to_vec();
bytes.extend(command.as_bytes().to_vec());
bytes.extend(shell.shell_type().execute_command_bytes());
bytes
}
#[test]
fn test_pty_controller_writes_user_command() {
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
let background_executor = Arc::new(Background::default());
let mut controller = PtyController::new(
event_loop_tx,
background_executor.clone(),
terminal_model(background_executor),
);
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
assert!(controller.write_user_command("echo foo", &shell).is_ok());
controller.flush_pty_writes();
let message = event_loop_rx
.try_recv()
.expect("PtyController should have sent write.");
assert!(
matches!(message, Message::Input(bytes) if bytes.to_vec() == expected_command_bytes("echo foo", &shell))
);
}
#[test]
fn test_pty_controller_writes_in_band_command() {
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
let background_executor = Arc::new(Background::default());
let mut controller = PtyController::new(
event_loop_tx,
background_executor.clone(),
terminal_model(background_executor),
);
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
assert!(controller.write_in_band_command("echo foo", &shell).is_ok());
controller.flush_pty_writes();
let message = event_loop_rx
.try_recv()
.expect("PtyController should have sent write.");
assert_input_matches(&message, expected_command_bytes("echo foo", &shell));
}
#[test]
fn test_pty_controller_updates_block_list_when_writing_in_band_command() {
let (event_loop_tx, _) = mio_extras::channel::channel();
let background_executor = Arc::new(Background::default());
let terminal_model = terminal_model(background_executor.clone());
let mut controller =
PtyController::new(event_loop_tx, background_executor, terminal_model.clone());
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
assert!(controller.write_in_band_command("echo foo", &shell).is_ok());
controller.flush_pty_writes();
assert!(terminal_model
.lock()
.block_list()
.is_writing_or_executing_in_band_command());
}
#[test]
fn test_pty_controller_writes_input_buffer_sequence_after_block_completed() {
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
let background_executor = Arc::new(Background::default());
let mut controller = PtyController::new(
event_loop_tx,
background_executor.clone(),
terminal_model(background_executor),
);
controller.set_state_after_block_completed(
&BlockType::User(UserBlockCompleted {
serialized_block: SerializedBlock::new_for_test("echo foo".as_bytes().to_vec(), vec![])
.into(),
command: "echo foo".to_owned(),
output_truncated: "".to_owned(),
started_at: None,
num_output_lines: 0,
num_output_lines_truncated: 0,
shell_type: None,
}),
true,
);
controller.flush_pty_writes();
let message = event_loop_rx
.try_recv()
.expect("PtyController should have sent write.");
assert_input_matches(&message, vec![escape_sequences::C0::ESC, b'i']);
}
#[test]
fn test_pty_controller_writes_in_band_command_after_input_buffer_sequence() {
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
let background_executor = Arc::new(Background::default());
let mut controller = PtyController::new(
event_loop_tx,
background_executor.clone(),
terminal_model(background_executor),
);
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
controller.set_state_after_block_completed(
&BlockType::User(UserBlockCompleted {
serialized_block: SerializedBlock::new_for_test("echo foo".as_bytes().to_vec(), vec![])
.into(),
command: "echo foo".to_owned(),
output_truncated: "".to_owned(),
started_at: None,
num_output_lines: 0,
num_output_lines_truncated: 0,
shell_type: None,
}),
true,
);
assert!(controller.write_in_band_command("echo foo", &shell).is_ok());
controller.flush_pty_writes();
let mut messages = vec![];
while let Ok(message) = event_loop_rx.try_recv() {
messages.push(message);
}
assert_eq!(messages.len(), 2);
assert_input_matches(&messages[0], vec![escape_sequences::C0::ESC, b'i']);
assert_input_matches(&messages[1], expected_command_bytes("echo foo", &shell));
}
#[test]
fn test_pty_controller_cancels_async_writes_upon_user_command() {
let (event_loop_tx, event_loop_rx) = mio_extras::channel::channel();
let background_executor = Arc::new(Background::default());
let mut controller = PtyController::new(
event_loop_tx,
background_executor.clone(),
terminal_model(background_executor),
);
controller.set_state_after_block_completed(
&BlockType::User(UserBlockCompleted {
serialized_block: SerializedBlock::new_for_test("echo foo".as_bytes().to_vec(), vec![])
.into(),
command: "echo foo".to_owned(),
output_truncated: "".to_owned(),
started_at: None,
num_output_lines: 0,
num_output_lines_truncated: 0,
shell_type: None,
}),
true,
);
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
// Writing this command should cancel writing the input buffer escape sequence, which is
// written after a 50ms delay. Since only ~25 ms has passed the write should not have
// occurred yet.
assert!(controller.write_user_command("echo foo", &shell).is_ok());
controller.flush_pty_writes();
let mut messages = vec![];
while let Ok(message) = event_loop_rx.try_recv() {
messages.push(message);
}
assert_eq!(messages.len(), 1);
assert_input_matches(&messages[0], expected_command_bytes("echo foo", &shell));
}
@@ -0,0 +1,422 @@
use instant::Instant;
use settings::Setting;
use warp_core::SessionId;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle};
use crate::terminal::warpify::settings::SshExtensionInstallMode;
use crate::remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
use crate::remote_server::ssh_transport::SshTransport;
use crate::terminal::model::session::{IsLegacySSHSession, SessionInfo};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::warpify::settings::WarpifySettings;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
use remote_server::setup::RemotePlatform;
use super::pty_controller::{EventLoopSender, PtyController};
/// Per-SSH-init state machine. Encoding the state as an enum makes invalid
/// transitions unrepresentable and ensures the `SessionInfo` stash cannot be
/// accessed after it has been consumed.
///
/// Every active state carries `setup_start` so that the total setup duration
/// can be measured when the flow reaches `SessionConnected`.
enum SshInitState {
Idle,
/// Stash held, `check_binary` in flight.
AwaitingCheck {
session_info: SessionInfo,
transport: SshTransport,
setup_start: Instant,
},
/// Stash held, choice block showing.
AwaitingUserChoice {
session_info: SessionInfo,
transport: SshTransport,
setup_start: Instant,
},
/// Stash held, `install_binary` in flight.
AwaitingInstall {
session_id: SessionId,
session_info: SessionInfo,
transport: SshTransport,
setup_start: Instant,
},
/// Stash held, `connect_session` in flight. Bootstrap is flushed only
/// once `SessionConnected` arrives (or on connection failure).
AwaitingConnect {
session_id: SessionId,
session_info: SessionInfo,
setup_start: Instant,
},
}
/// Per-pane orchestrator that defers the bootstrap script write for SSH sessions,
/// checks for the remote-server binary, and presents a two-option choice block when the binary is missing.
///
/// Uses a [`WeakModelHandle`] back to [`PtyController`] to avoid preventing
/// `PtyController` from being deallocated.
pub struct RemoteServerController<T: EventLoopSender> {
pty_controller: WeakModelHandle<PtyController<T>>,
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
state: SshInitState,
/// Whether the binary was installed during this setup flow.
did_install: bool,
/// Detected remote platform from the binary check phase, used for telemetry.
remote_platform: Option<RemotePlatform>,
}
impl<T: EventLoopSender> Entity for RemoteServerController<T> {
type Event = ();
}
impl<T: EventLoopSender> RemoteServerController<T> {
pub fn new(
pty_controller: WeakModelHandle<PtyController<T>>,
model_event_dispatcher: ModelHandle<ModelEventDispatcher>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&model_event_dispatcher, |me, event, ctx| {
if let ModelEvent::SshInitShell {
pending_session_info,
} = event
{
me.on_ssh_init_shell_requested(pending_session_info.as_ref().clone(), ctx);
}
});
let mgr = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&mgr, |me, event, ctx| match event {
RemoteServerManagerEvent::BinaryCheckComplete {
session_id,
result,
remote_platform,
} => {
me.remote_platform = remote_platform.clone();
me.on_binary_check_complete(*session_id, result.clone(), ctx);
}
RemoteServerManagerEvent::BinaryInstallComplete { session_id, result } => {
me.on_binary_install_complete(*session_id, result.clone(), ctx);
}
RemoteServerManagerEvent::SessionConnected { session_id, .. } => {
me.on_session_connected(*session_id, ctx);
}
RemoteServerManagerEvent::SessionConnectionFailed { session_id, .. } => {
me.on_session_connection_failed(*session_id, ctx);
}
_ => {}
});
Self {
pty_controller,
model_event_dispatcher,
state: SshInitState::Idle,
did_install: false,
remote_platform: None,
}
}
/// Extracts the `SessionInfo` from the stash and writes the bootstrap
/// script to the PTY via `PtyController::initialize_shell`.
fn flush_stashed_bootstrap(&mut self, session_info: SessionInfo, ctx: &mut ModelContext<Self>) {
if let Some(pty) = self.pty_controller.upgrade(ctx) {
pty.update(ctx, |pty, ctx| {
pty.initialize_shell(&session_info, ctx);
});
} else {
log::warn!("PtyController dropped before bootstrap could be flushed");
}
}
/// Idle -> AwaitingCheck
fn on_ssh_init_shell_requested(&mut self, info: SessionInfo, ctx: &mut ModelContext<Self>) {
let IsLegacySSHSession::Yes { socket_path } = &info.is_legacy_ssh_session else {
return;
};
let session_id = info.session_id;
let socket_path = socket_path.clone();
debug_assert!(matches!(self.state, SshInitState::Idle));
match std::mem::replace(&mut self.state, SshInitState::Idle) {
SshInitState::Idle => {}
SshInitState::AwaitingCheck {
session_info: old_info,
..
}
| SshInitState::AwaitingUserChoice {
session_info: old_info,
..
}
| SshInitState::AwaitingInstall {
session_info: old_info,
..
}
| SshInitState::AwaitingConnect {
session_info: old_info,
..
} => {
self.flush_stashed_bootstrap(old_info, ctx);
}
}
let transport = SshTransport::new(socket_path);
self.did_install = false;
self.remote_platform = None;
self.state = SshInitState::AwaitingCheck {
session_info: info,
transport: transport.clone(),
setup_start: Instant::now(),
};
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.check_binary(session_id, transport, ctx);
});
}
fn on_binary_check_complete(
&mut self,
session_id: SessionId,
result: Result<bool, String>,
ctx: &mut ModelContext<Self>,
) {
let SshInitState::AwaitingCheck {
ref session_info, ..
} = self.state
else {
return;
};
if session_info.session_id != session_id {
return;
}
let SshInitState::AwaitingCheck {
session_info,
transport,
setup_start,
} = std::mem::replace(&mut self.state, SshInitState::Idle)
else {
unreachable!("just matched AwaitingCheck above");
};
match result {
Ok(true) => {
self.state = SshInitState::AwaitingConnect {
session_id,
session_info,
setup_start,
};
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.connect_session(session_id, transport, ctx);
});
}
Ok(false) => {
let install_mode = *WarpifySettings::as_ref(ctx)
.ssh_extension_install_mode
.value();
match install_mode {
SshExtensionInstallMode::AlwaysAsk => {
self.state = SshInitState::AwaitingUserChoice {
session_info,
transport,
setup_start,
};
self.model_event_dispatcher.update(ctx, |d, ctx| {
d.request_remote_server_block(session_id, ctx);
});
}
SshExtensionInstallMode::AlwaysInstall => {
self.did_install = true;
self.state = SshInitState::AwaitingInstall {
session_id,
session_info,
transport: transport.clone(),
setup_start,
};
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.install_binary(session_id, transport, ctx);
});
}
SshExtensionInstallMode::NeverInstall => {
self.flush_stashed_bootstrap(session_info, ctx);
}
}
}
Err(err) => {
log::error!("Binary check failed for {session_id:?}: {err}");
self.flush_stashed_bootstrap(session_info, ctx);
}
}
}
pub fn handle_ssh_remote_server_install(
&mut self,
session_id: SessionId,
ctx: &mut ModelContext<Self>,
) {
let SshInitState::AwaitingUserChoice { .. } = self.state else {
log::warn!("Install clicked but state is not AwaitingUserChoice for {session_id:?}");
return;
};
let SshInitState::AwaitingUserChoice {
session_info,
transport,
setup_start,
} = std::mem::replace(&mut self.state, SshInitState::Idle)
else {
unreachable!("just matched AwaitingUserChoice above");
};
self.did_install = true;
self.state = SshInitState::AwaitingInstall {
session_id,
session_info,
transport: transport.clone(),
setup_start,
};
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.install_binary(session_id, transport, ctx);
});
}
/// Called when the remote server session is connected. Flushes the
/// stashed bootstrap (so the session initializes with a live client)
/// and emits the `RemoteServerSetupDuration` telemetry event.
fn on_session_connected(&mut self, session_id: SessionId, ctx: &mut ModelContext<Self>) {
let SshInitState::AwaitingConnect {
session_id: expected,
..
} = &self.state
else {
return;
};
if *expected != session_id {
return;
}
let SshInitState::AwaitingConnect {
session_info,
setup_start,
..
} = std::mem::replace(&mut self.state, SshInitState::Idle)
else {
unreachable!("just matched AwaitingConnect above");
};
// Flush the stashed bootstrap now that the server is connected.
// `client_for_session` will return `Some` when the session
// subsequently initializes, so it picks `RemoteServerCommandExecutor`.
self.flush_stashed_bootstrap(session_info, ctx);
let duration_ms = Instant::now()
.duration_since(setup_start)
.as_millis()
.min(u64::MAX as u128) as u64;
let (remote_os, remote_arch) = self
.remote_platform
.as_ref()
.map(|p| {
(
Some(p.os.as_str().to_owned()),
Some(p.arch.as_str().to_owned()),
)
})
.unwrap_or((None, None));
send_telemetry_from_ctx!(
TelemetryEvent::RemoteServerSetupDuration {
duration_ms,
installed_binary: self.did_install,
remote_os,
remote_arch,
},
ctx
);
}
/// Called when the remote server connection failed. Flushes the stashed
/// bootstrap so the SSH session is not permanently blocked.
fn on_session_connection_failed(
&mut self,
session_id: SessionId,
ctx: &mut ModelContext<Self>,
) {
let SshInitState::AwaitingConnect {
session_id: expected,
..
} = &self.state
else {
return;
};
if *expected != session_id {
return;
}
let SshInitState::AwaitingConnect { session_info, .. } =
std::mem::replace(&mut self.state, SshInitState::Idle)
else {
unreachable!("just matched AwaitingConnect above");
};
log::warn!(
"Remote server connection failed for session {session_id:?}; \
flushing bootstrap to unblock SSH session"
);
self.flush_stashed_bootstrap(session_info, ctx);
}
pub fn handle_ssh_remote_server_skip(
&mut self,
session_id: SessionId,
ctx: &mut ModelContext<Self>,
) {
let SshInitState::AwaitingUserChoice { session_info, .. } =
std::mem::replace(&mut self.state, SshInitState::Idle)
else {
log::warn!("Skip clicked but state is not AwaitingUserChoice for {session_id:?}");
return;
};
self.flush_stashed_bootstrap(session_info, ctx);
}
fn on_binary_install_complete(
&mut self,
session_id: SessionId,
result: Result<(), String>,
ctx: &mut ModelContext<Self>,
) {
let SshInitState::AwaitingInstall {
session_id: expected,
..
} = &self.state
else {
return;
};
if *expected != session_id {
return;
}
let SshInitState::AwaitingInstall {
session_info,
transport,
setup_start,
..
} = std::mem::replace(&mut self.state, SshInitState::Idle)
else {
unreachable!("just matched AwaitingInstall above");
};
match result {
Ok(()) => {
self.state = SshInitState::AwaitingConnect {
session_id,
session_info,
setup_start,
};
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.connect_session(session_id, transport, ctx);
});
}
Err(err) => {
log::error!("Binary install failed for {session_id:?}: {err}");
self.flush_stashed_bootstrap(session_info, ctx);
}
}
}
}
@@ -0,0 +1,196 @@
use crate::persistence::ModelEvent;
use crate::terminal::line_editor_status::LineEditorStatus;
use crate::terminal::model::session::{ExecutorCommandEvent, Sessions};
use crate::terminal::model::terminal_model::ExitReason;
use crate::terminal::view;
use crate::terminal::writeable_pty::command_history::update_command_history;
use crate::terminal::writeable_pty::pty_controller::EventLoopSender;
use crate::terminal::writeable_pty::{PtyController, PtyControllerEvent};
use crate::terminal::ModelEventDispatcher;
use crate::terminal::{TerminalModel, TerminalView};
use async_channel::Receiver;
use parking_lot::FairMutex;
use std::sync::mpsc::SyncSender;
use std::sync::Arc;
use warpui::{AppContext, ModelHandle, ViewHandle};
/// Wires up bi-directional communication between the PtyController and the TerminalView.
/// Note that this interaction can't live in the TerminalView because the view must be manager-agnostic.
///
/// NOTE: we cannot simply use the strong references (the handle arguments to this wire_up fn)
/// in the subscription callbacks because that will create a reference cycle. Instead,
/// we should use weak handles and upgrade them lazily.
pub fn wire_up_pty_controller_with_view<T: EventLoopSender>(
pty_controller: &ModelHandle<PtyController<T>>,
terminal_view: &ViewHandle<TerminalView>,
model: Arc<FairMutex<TerminalModel>>,
sessions: ModelHandle<Sessions>,
model_event_sender: Option<SyncSender<ModelEvent>>,
ctx: &mut AppContext,
) {
let controller_weak_handle = pty_controller.downgrade();
let view_weak_handle = terminal_view.downgrade();
let model_clone = model.clone();
ctx.subscribe_to_view(terminal_view, move |_view, event, ctx| {
// NOTE: we cannot simply use the strong reference (the model handle argument to this wire_up fn)
// because it'll create a reference cycle with the `subscribe_to_model` usage below. Instead,
// we should use a weak handle and upgrade it lazily.
let Some(controller) = controller_weak_handle.upgrade(ctx) else {
return;
};
match event {
view::Event::CtrlD => {
controller.update(ctx, |controller, ctx| {
controller.write_end_of_transmission_char(ctx);
});
}
view::Event::ShutdownPty => {
controller.update(ctx, |controller, ctx| {
controller.shutdown_pty(ctx);
});
}
view::Event::WriteBytesToPty { bytes } => {
controller.update(ctx, |controller, ctx| {
// TODO: the underlying bytes should be wrapped in an Arc and copied out only when they need to be written to the PTY.
controller.write_bytes(bytes.clone(), ctx);
});
}
view::Event::WriteAgentInputToPty { bytes, mode } => {
controller.update(ctx, |controller, ctx| {
controller.write_agent_bytes(bytes.clone(), mode, ctx);
});
}
view::Event::Resize { size_update } => {
controller.update(ctx, |controller, ctx| {
controller.resize_pty(*size_update, ctx);
});
}
view::Event::ExecuteCommand(event) => {
let Some(shell_type) = sessions
.as_ref(ctx)
.get(event.session_id)
.map(|s| s.shell().shell_type())
else {
log::warn!("Failed to get shell type for the session associated to the command execution event.");
return;
};
model_clone.lock().block_list_mut().active_block_mut().set_cloud_workflow_state(event.workflow_id);
controller.update(ctx, |controller, ctx| {
controller.write_command(&event.command, shell_type, event.source.clone(), ctx)
});
if event.should_add_command_to_history {
update_command_history(
event,
&model_clone,
model_event_sender.as_ref(),
&sessions,
ctx,
);
}
}
view::Event::RunNativeShellCompletions { buffer_text, results_tx } => {
controller.update(ctx, |controller, ctx| {
controller.run_native_shell_completions(buffer_text.clone(), results_tx.clone(), ctx);
});
}
_ => {}
}
});
ctx.subscribe_to_model(pty_controller, move |_pty_controller, event, ctx| {
let Some(view) = view_weak_handle.upgrade(ctx) else {
return;
};
match event {
PtyControllerEvent::PtyDisconnected => view.update(ctx, |_, _| {
model.lock().exit(ExitReason::PtyDisconnected);
}),
}
});
}
/// Wires up bi-directional communication between the RemoteServerController and the TerminalView.
/// This handles the flow to render the remote server block and forward the user's choice to the controller.
///
/// Note that this interaction can't live in the TerminalView because the view must be manager-agnostic.
///
/// NOTE: we cannot simply use the strong references (the handle arguments to this wire_up fn)
/// in the subscription callbacks because that will create a reference cycle. Instead,
/// we should use weak handles and upgrade them lazily.
#[cfg(not(target_family = "wasm"))]
pub fn wire_up_remote_server_controller_with_view<T: EventLoopSender>(
remote_server_controller: &ModelHandle<
super::remote_server_controller::RemoteServerController<T>,
>,
terminal_view: &ViewHandle<TerminalView>,
ctx: &mut AppContext,
) {
let controller_weak = remote_server_controller.downgrade();
ctx.subscribe_to_view(terminal_view, move |_view, event, ctx| {
let Some(controller) = controller_weak.upgrade(ctx) else {
return;
};
match event {
view::Event::RemoteServerInstallRequested { session_id } => {
controller.update(ctx, |ctrl, ctx| {
ctrl.handle_ssh_remote_server_install(*session_id, ctx);
});
}
view::Event::RemoteServerSkipRequested { session_id } => {
controller.update(ctx, |ctrl, ctx| {
ctrl.handle_ssh_remote_server_skip(*session_id, ctx);
});
}
_ => {}
}
});
}
/// Creates a PtyController to broker writes to the PTY and registers it as a model.
pub fn init_pty_controller_model<Sender: EventLoopSender>(
event_loop_tx: Sender,
executor_command_rx: Receiver<ExecutorCommandEvent>,
model_events: ModelHandle<ModelEventDispatcher>,
sessions: ModelHandle<Sessions>,
model: Arc<FairMutex<TerminalModel>>,
ctx: &mut AppContext,
) -> ModelHandle<PtyController<Sender>> {
let line_editor_status =
ctx.add_model(|ctx| LineEditorStatus::new(model_events.clone(), sessions.clone(), ctx));
ctx.add_model(|ctx| {
PtyController::new(
event_loop_tx,
model_events,
line_editor_status,
sessions,
executor_command_rx,
model,
ctx,
)
})
}
/// Creates a [`RemoteServerController`] that orchestrates the SSH init flow.
#[cfg(not(target_family = "wasm"))]
pub fn init_remote_server_controller<Sender: EventLoopSender>(
pty_controller: &ModelHandle<PtyController<Sender>>,
model_events: &ModelHandle<ModelEventDispatcher>,
ctx: &mut AppContext,
) -> ModelHandle<super::remote_server_controller::RemoteServerController<Sender>> {
let pty_weak = pty_controller.downgrade();
ctx.add_model(|ctx| {
super::remote_server_controller::RemoteServerController::new(
pty_weak,
model_events.clone(),
ctx,
)
})
}