509 lines
20 KiB
Rust
509 lines
20 KiB
Rust
use std::fmt;
|
|
use std::fmt::{Debug, Formatter};
|
|
use std::num::ParseIntError;
|
|
use std::string::FromUtf8Error;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use instant::Instant;
|
|
|
|
use crate::server::ids::SyncId;
|
|
use crate::server::telemetry::ImageProtocol;
|
|
use crate::terminal::model::block::BlockMetadata;
|
|
use crate::terminal::model::block::SerializedBlock;
|
|
use crate::terminal::model::completions::ShellCompletion;
|
|
use crate::terminal::model::terminal_model::HandlerEvent;
|
|
use crate::terminal::shell::ShellType;
|
|
use crate::terminal::ClipboardType;
|
|
use crate::util::AsciiDebug;
|
|
|
|
use super::history::HistoryEntry;
|
|
use super::model::ansi::{FinishUpdateValue, WarpificationUnavailableReason};
|
|
use super::model::block::BlockId;
|
|
use super::model::session::{SessionId, SessionInfo};
|
|
use super::model::terminal_model::{BlockIndex, ExitReason, TmuxInstallationState};
|
|
|
|
pub use remote_server::setup::RemoteServerSetupState;
|
|
|
|
#[derive(Clone)]
|
|
/// Events sent to the main thread by the terminal model & event loop.
|
|
pub enum Event {
|
|
CompletionsFinished(Vec<ShellCompletion>),
|
|
MouseCursorDirty,
|
|
Title(String),
|
|
VisibleBootstrapBlock,
|
|
/// Performs the minimal work necessary to show that a block has completed.
|
|
/// Treat this as a performance-sensitive path.
|
|
BlockCompleted(BlockCompletedEvent),
|
|
/// Meant for more expensive operations that can be delayed without negatively
|
|
/// affecting the UI.
|
|
AfterBlockCompleted(AfterBlockCompletedEvent),
|
|
/// Send on DProtoHook::Preexec, but only for blocks after bootstrapping
|
|
AfterBlockStarted {
|
|
block_id: BlockId,
|
|
command: String,
|
|
is_for_in_band_command: bool,
|
|
},
|
|
/// Sent when a new block is created.
|
|
BlockMetadataReceived(BlockMetadataReceivedEvent),
|
|
/// Sent after a background block is started and added to the block list.
|
|
BackgroundBlockStarted,
|
|
ClipboardStore(ClipboardType, String),
|
|
ClipboardLoad(
|
|
ClipboardType,
|
|
Arc<dyn Fn(&str) -> String + Sync + Send + 'static>,
|
|
),
|
|
CursorBlinkingChange(bool),
|
|
TerminalClear,
|
|
Bell,
|
|
Exit {
|
|
reason: ExitReason,
|
|
},
|
|
/// An indication that we are about to initiate an interactive SSH session
|
|
/// (which may or may not use the SSH wrapper).
|
|
PreInteractiveSSHSession,
|
|
/// An indication that a successful SSH connection was initiated via the
|
|
/// SSH wrapper. The argument is the name of the remote shell.
|
|
SSH(String),
|
|
/// Emitted when the remote shell for a session is about to exit, so
|
|
/// per-session resources (e.g. the `ssh … remote-server-proxy` child that
|
|
/// holds a multiplexed channel on the ControlMaster) can be torn down
|
|
/// before the outer ssh tunnel starts closing.
|
|
ExitShell {
|
|
session_id: SessionId,
|
|
},
|
|
/// Sent when the model detects an SSH ControlMaster error, which means that
|
|
/// completions reliant on command execution will not work.
|
|
SSHControlMasterError,
|
|
TerminalModeSwapped(TerminalMode),
|
|
ExecutedInBandCommand(ExecutedExecutorCommandEvent),
|
|
TmuxControlModeReady {
|
|
primary_pane: u32,
|
|
},
|
|
/// See comment above [crate::terminal::ModelEvent::DetectedEndOfSshLogin].
|
|
DetectedEndOfSshLogin(SshLoginStatus),
|
|
RemoteWarpificationIsUnavailable(WarpificationUnavailableReason),
|
|
SshTmuxInstaller(TmuxInstallationState),
|
|
TmuxInstallFailed {
|
|
line: String,
|
|
command: String,
|
|
},
|
|
InitSsh(InitSshEvent),
|
|
InitSubshell(InitSubshellEvent),
|
|
/// Emitted when the user's RC file has been executed in a subshell.
|
|
SourcedRcFileInSubshell(SourcedRcFileInSubshellEvent),
|
|
/// Emitted when the active block's prompt has been updated.
|
|
PromptUpdated,
|
|
/// Emitted when the honor_ps1 state of the shell is out-of-sync with Warp's settings.
|
|
/// This can happen in cases such as when the user changes between PS1 and Warp prompt inside
|
|
/// of an SSH session (the bindkeys are sent to the SSH session but not the local session, so
|
|
/// they are out-of-sync when the user exits SSH).
|
|
HonorPS1OutOfSync,
|
|
/// Emitted when the terminal model receives typeahead output from the PTY.
|
|
/// "Typeahead" are characters that were written to the PTY during long-running command execution
|
|
/// close to the end of the its execution, such that these characters were not actually read by
|
|
/// the running program. The shell stores these characters, inserts them into its internal line
|
|
/// buffer, and re-echoes them after Precmd.
|
|
Typeahead,
|
|
/// Emitted when the agent is tagged in or out of the active block.
|
|
/// Users "Tag an agent in" when they ask the agent to take over a long running command
|
|
/// that was started outside of a conversation (and they tag the agent out when they take control back).
|
|
AgentTaggedInChanged {
|
|
is_tagged_in: bool,
|
|
},
|
|
Handler(HandlerEvent),
|
|
/// Emitted by the async remote server setup task to report intermediate
|
|
/// state changes (e.g. Checking → Installing → Initializing) so the
|
|
/// prompt can show stage-specific messages.
|
|
RemoteServerSetupStateChanged {
|
|
session_id: SessionId,
|
|
state: RemoteServerSetupState,
|
|
},
|
|
/// Emitted when the remote server binary has been successfully checked or
|
|
/// installed and is ready. The session is initialized independently on
|
|
/// `Bootstrapped`; when the remote server later connects, the client is
|
|
/// attached to the existing session's `RemoteServerCommandExecutor` via
|
|
/// the `RemoteServerManagerEvent::SessionConnected` subscription in
|
|
/// `Sessions::new`.
|
|
RemoteServerReady {
|
|
session_id: SessionId,
|
|
},
|
|
/// Emitted when the remote server setup failed. The session falls back to
|
|
/// the ControlMaster-based `RemoteCommandExecutor`.
|
|
RemoteServerFailed {
|
|
session_id: SessionId,
|
|
error: String,
|
|
},
|
|
/// Emitted when the assisted auto-update has completed and we're ready to
|
|
/// relaunch the app.
|
|
FinishUpdate(FinishUpdateValue),
|
|
TextSelectionChanged,
|
|
ShellSpawned(ShellType),
|
|
SendCompletionsPrompt,
|
|
ImageReceived {
|
|
image_id: u32,
|
|
image_data: Vec<u8>,
|
|
image_protocol: ImageProtocol,
|
|
},
|
|
BootstrapPrecmdDone,
|
|
/// A pluggable notification triggered via OSC 9 or OSC 777 escape sequences.
|
|
/// External programs can use this to trigger notifications in Warp.
|
|
///
|
|
/// References:
|
|
/// - OSC 9: <https://conemu.github.io/en/AnsiEscapeCodes.html#OSC_Operating_system_commands>
|
|
/// - OSC 777: <https://codeberg.org/dnkl/foot/wiki/Notify>
|
|
PluggableNotification {
|
|
title: Option<String>,
|
|
body: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct InitSubshellEvent {
|
|
pub shell_type: ShellType,
|
|
pub uname: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SourcedRcFileInSubshellEvent {
|
|
pub shell_type: ShellType,
|
|
pub uname: Option<String>,
|
|
pub tmux: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct InitSshEvent {
|
|
pub shell_type: ShellType,
|
|
pub uname: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub enum TerminalMode {
|
|
AltScreen,
|
|
BlockList,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum SshLoginStatus {
|
|
/// We have some evidence login is complete but should check again.
|
|
RecheckBeforeWarpifying,
|
|
/// We have high confidence login is complete.
|
|
ReadyToWarpify,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct InitShellEvent {
|
|
pub pending_session_info: SessionInfo,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct BootstrappedEvent {
|
|
/// The command which spawned the shell.
|
|
pub spawning_command: String,
|
|
// This is wrapped in an `Box` to surpress clippy's large-enum-variant warning, not because it
|
|
// functionally needs to be wrapped in an `Box`.
|
|
pub session_info: Box<SessionInfo>,
|
|
pub restored_block_commands: Vec<HistoryEntry>,
|
|
/// The time we spent sourcing the user's rcfiles, in seconds. This may be
|
|
/// None if the information was not provided by the shell.
|
|
pub rcfiles_duration_seconds: Option<f64>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct BlockCompletedEvent {
|
|
/// This will be None when we don't want to collect telemetry
|
|
/// for this block's latency.
|
|
pub block_latency_data: Option<BlockLatencyData>,
|
|
pub block_type: BlockType,
|
|
pub num_secrets_obfuscated: usize,
|
|
pub block_index: BlockIndex,
|
|
pub block_id: BlockId,
|
|
pub session_id: Option<SessionId>,
|
|
pub restored_block_was_local: Option<bool>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AfterBlockCompletedEvent {
|
|
/// The delay from the CommandFinished ansi hook to the Precmd hook.
|
|
/// This value is only provided for the blocks that the user directly
|
|
/// executes (so it's not provided if this is a restored block from session
|
|
/// restoration or a bootstrapping block).
|
|
pub command_finished_to_precmd_delay: Option<Duration>,
|
|
pub block_type: BlockType,
|
|
pub num_secrets_obfuscated: usize,
|
|
|
|
/// If the completed block was a workflow, this is its id.
|
|
pub cloud_workflow_id: Option<SyncId>,
|
|
|
|
/// If the completed block had an env var object associated.
|
|
pub cloud_env_var_collection_id: Option<SyncId>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct BlockLatencyData {
|
|
pub command: &'static str,
|
|
/// When the block's command grid was started (i.e. when the user hit enter).
|
|
pub started_at: Instant,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
/// Different types of blocks. `User` is for normal execution.
|
|
/// Everything else is earlier in the bootstrapping sequence.
|
|
pub enum BlockType {
|
|
/// When there are blocks that finish in the bootstrap sequence,
|
|
/// we don't want to propagate the event around our app.
|
|
BootstrapHidden,
|
|
/// This is a special case where the user's rcfiles resulted in outputs. We
|
|
/// will want some of the view logic to execute, and not all of it.
|
|
BootstrapVisible(Arc<SerializedBlock>),
|
|
/// This was a block we restored through session restoration.
|
|
Restored,
|
|
/// This was a block created for execution of an in-band command.
|
|
InBandCommand,
|
|
/// This is a normal block that the user executed.
|
|
User(UserBlockCompleted),
|
|
|
|
/// This is a block containing background process output.
|
|
Background(Arc<SerializedBlock>),
|
|
|
|
/// This is a block containing static/hardcoded content (e.g. the subshell Warpification
|
|
/// welcome block).
|
|
Static,
|
|
}
|
|
|
|
impl BlockType {
|
|
pub fn is_bootstrap_block(&self) -> bool {
|
|
matches!(self, Self::BootstrapHidden | Self::BootstrapVisible(_))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
/// A notification that the metadata for the active block & prompt is now
|
|
/// available.
|
|
pub struct BlockMetadataReceivedEvent {
|
|
pub block_metadata: BlockMetadata,
|
|
pub block_index: BlockIndex,
|
|
/// Whether the previous block was an in-band command.
|
|
pub is_after_in_band_command: bool,
|
|
/// Whether the session has fully completed the bootstrapping process.
|
|
pub is_done_bootstrapping: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
/// Contents of a normal block that a user executed.
|
|
pub struct UserBlockCompleted {
|
|
pub index: BlockIndex,
|
|
|
|
pub serialized_block: Arc<SerializedBlock>,
|
|
|
|
/// The input lines for a block without any escape sequences.
|
|
pub command: String,
|
|
|
|
/// The command with secrets obfuscated.
|
|
pub command_with_obfuscated_secrets: String,
|
|
|
|
/// The output lines for a block without any escape sequences.
|
|
/// They are truncated to the number of lines specificed by the caller.
|
|
pub output_truncated: String,
|
|
|
|
/// The output lines for a block without any escape sequences.
|
|
/// They are truncated to the number of lines specificed by the caller.
|
|
/// Forced secrets to be obfuscated as well.
|
|
pub output_truncated_with_obfuscated_secrets: String,
|
|
|
|
/// `true` if the block was run as a requested command or was part of a CLI subagent interaction.
|
|
pub was_part_of_agent_interaction: bool,
|
|
|
|
/// Time that we started the command grid (i.e. immediately after the user
|
|
/// hit enter).
|
|
pub started_at: Option<Instant>,
|
|
|
|
/// The number of lines in the output grid when it was finished.
|
|
pub num_output_lines: u64,
|
|
|
|
/// The number of lines of output that were truncated while the block
|
|
/// was active and receiving output.
|
|
pub num_output_lines_truncated: u64,
|
|
}
|
|
|
|
/// Emitted upon completion of an executor command that goes through the pty, such as the
|
|
/// InBandCommandExecutor or the TmuxCommandExecutor.
|
|
#[derive(Clone)]
|
|
pub struct ExecutedExecutorCommandEvent {
|
|
pub command_id: String,
|
|
pub exit_code: usize,
|
|
pub output: Vec<u8>,
|
|
}
|
|
|
|
impl ExecutedExecutorCommandEvent {
|
|
/// Parses the given `payload` (expected to be the payload of a generator output OSC) into a
|
|
/// `ExecutedGeneratorCommandValue`.
|
|
///
|
|
/// The given `string` is expected to follow the following format:
|
|
/// <commmand_id>;<output>;<exit_code>
|
|
///
|
|
/// Returns a `ParseGeneratorCommandValueError` if payload cannot be successfully parsed.
|
|
///
|
|
pub fn parse_generator_payload(payload: Vec<u8>) -> Result<Self, ParseGeneratorOutputError> {
|
|
// Break the payload apart at the first and last semicolons.
|
|
let mut payload_initial_split = payload.splitn(2, |&byte| byte == b';');
|
|
|
|
let Some(before_first_semicolon) = payload_initial_split.next() else {
|
|
return Err(ParseGeneratorOutputError::Corrupted);
|
|
};
|
|
|
|
let Some(after_first_semicolon) = payload_initial_split.next() else {
|
|
return Err(ParseGeneratorOutputError::Corrupted);
|
|
};
|
|
|
|
let mut payload_final_split = after_first_semicolon.rsplitn(2, |&byte| byte == b';');
|
|
let Some(after_final_semicolon) = payload_final_split.next() else {
|
|
return Err(ParseGeneratorOutputError::Corrupted);
|
|
};
|
|
|
|
let Some(payload_middle) = payload_final_split.next() else {
|
|
return Err(ParseGeneratorOutputError::Corrupted);
|
|
};
|
|
|
|
let command_id = String::from_utf8(before_first_semicolon.to_vec())
|
|
.map_err(ParseGeneratorOutputError::Utf8DecodingFailure)?;
|
|
|
|
let exit_code = String::from_utf8(after_final_semicolon.to_vec())
|
|
.map_err(ParseGeneratorOutputError::Utf8DecodingFailure)?
|
|
.parse::<usize>()
|
|
.map_err(ParseGeneratorOutputError::ExitCodeParseFailure)?;
|
|
|
|
// The output of the command remains as bytes. This is so we can operate on the bytes higher in
|
|
// the stack if we need to, such as in the case of parsing out the zsh history file where we want to
|
|
// transform the byte array before converting to a string.
|
|
let output = payload_middle.to_vec();
|
|
|
|
Ok(Self {
|
|
command_id,
|
|
exit_code,
|
|
output,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Debug for ExecutedExecutorCommandEvent {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("ExecutedExecutorCommandEvent")
|
|
.field("command_id", &self.command_id)
|
|
.field("exit_code", &self.exit_code)
|
|
.field("output", &AsciiDebug(&self.output))
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum ParseGeneratorOutputError {
|
|
#[error("Failed to parse exit code: {0:?}")]
|
|
ExitCodeParseFailure(ParseIntError),
|
|
#[error("Corrupted DCS. Should be of the format <command_id>;<exit_code>;<output>. ")]
|
|
Corrupted,
|
|
#[error("Failed to convert to Utf8: {0:?}")]
|
|
Utf8DecodingFailure(FromUtf8Error),
|
|
}
|
|
|
|
impl Debug for Event {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Event::CompletionsFinished(_) => write!(f, "CompletionsFinished"),
|
|
Event::MouseCursorDirty => write!(f, "MouseCursorDirty"),
|
|
Event::BlockCompleted(_) => write!(f, "BlockCompleted"),
|
|
Event::AfterBlockCompleted(_) => write!(f, "AfterBlockCompleted"),
|
|
Event::BlockMetadataReceived(event) => write!(
|
|
f,
|
|
"BlockStarted({:?}, Done bootstrapping: {:?})",
|
|
event.block_metadata, event.is_done_bootstrapping
|
|
),
|
|
Event::AfterBlockStarted { .. } => write!(f, "BlockExecutionStarted"),
|
|
Event::BackgroundBlockStarted => write!(f, "BackgroundBlockStarted"),
|
|
Event::VisibleBootstrapBlock => write!(f, "VisibleBootstrapBlock"),
|
|
Event::Title(title) => write!(f, "Title({title})"),
|
|
Event::ClipboardStore(_, text) => write!(f, "ClipboardStore({text})"),
|
|
Event::ClipboardLoad(_, _) => write!(f, "ClipboardLoad()"),
|
|
Event::TerminalClear => write!(f, "TerminalClear"),
|
|
Event::Bell => write!(f, "Bell"),
|
|
Event::Exit { reason } => write!(f, "Exit({reason:?})"),
|
|
Event::CursorBlinkingChange(blinking) => write!(f, "CursorBlinking({blinking})"),
|
|
Event::PreInteractiveSSHSession => write!(f, "Pre-Interactive SSH Session"),
|
|
Event::SSH(remote_shell) => write!(f, "SSH(remote shell: {remote_shell}"),
|
|
Event::SSHControlMasterError => write!(f, "SSH ControlMaster error"),
|
|
Event::TerminalModeSwapped(_) => write!(f, "Terminal mode swapped"),
|
|
Event::TmuxControlModeReady { primary_pane } => {
|
|
write!(f, "TmuxControlModeReady(primary_pane: {primary_pane})")
|
|
}
|
|
Event::DetectedEndOfSshLogin(check_type) => {
|
|
write!(f, "DetectedEndOfSshLogin: {check_type:?}")
|
|
}
|
|
Event::RemoteWarpificationIsUnavailable(_) => {
|
|
write!(f, "RemoteWarpificationIsUnavailable")
|
|
}
|
|
Event::SshTmuxInstaller(installer) => {
|
|
write!(f, "SshTmuxInstaller({installer:?})")
|
|
}
|
|
Event::TmuxInstallFailed { line, command } => {
|
|
write!(f, "TmuxInstallFailed(line: {line}, command: {command})")
|
|
}
|
|
Event::ExecutedInBandCommand(event) => write!(
|
|
f,
|
|
"Executed in-band command with ID {} and exit code {}",
|
|
event.command_id, event.exit_code
|
|
),
|
|
Event::InitSubshell(event) => {
|
|
write!(f, "InitSubshell({event:?})")
|
|
}
|
|
Event::SourcedRcFileInSubshell(event) => {
|
|
write!(f, "SourcedRcFileInSubshell({event:?})")
|
|
}
|
|
Event::InitSsh(event) => {
|
|
write!(f, "InitSsh({event:?})")
|
|
}
|
|
Event::PromptUpdated => write!(f, "PromptUpdated"),
|
|
Event::HonorPS1OutOfSync => write!(f, "HonorPS1OutOfSync"),
|
|
Event::Typeahead => write!(f, "Typeahead"),
|
|
Event::AgentTaggedInChanged { is_tagged_in } => {
|
|
write!(f, "AgentTaggedInChanged(is_tagged_in: {is_tagged_in})")
|
|
}
|
|
Event::Handler(handler_event) => write!(f, "Handler({handler_event:?}))"),
|
|
Event::RemoteServerSetupStateChanged {
|
|
session_id,
|
|
ref state,
|
|
} => {
|
|
write!(
|
|
f,
|
|
"RemoteServerSetupStateChanged(session: {session_id:?}, state: {state:?})"
|
|
)
|
|
}
|
|
Event::RemoteServerReady { session_id } => {
|
|
write!(f, "RemoteServerReady(session: {session_id:?})")
|
|
}
|
|
Event::RemoteServerFailed {
|
|
session_id,
|
|
ref error,
|
|
} => {
|
|
write!(
|
|
f,
|
|
"RemoteServerFailed(session: {session_id:?}, error: {error})"
|
|
)
|
|
}
|
|
Event::FinishUpdate(data) => write!(f, "FinishUpdate({})", data.update_id),
|
|
Event::TextSelectionChanged => write!(f, "TextSelectionChanged"),
|
|
Event::ShellSpawned(shell_type) => write!(f, "ShellSpawned({shell_type:?})"),
|
|
Event::SendCompletionsPrompt => write!(f, "SendCompletionsPrompt"),
|
|
Event::ImageReceived { image_id, .. } => {
|
|
write!(f, "ImageReceived(image_id: {image_id})")
|
|
}
|
|
Event::BootstrapPrecmdDone => write!(f, "BootstrapPrecmdDone"),
|
|
Event::PluggableNotification { title, body } => {
|
|
write!(f, "PluggableNotification(title: {title:?}, body: {body})")
|
|
}
|
|
Event::ExitShell { session_id } => {
|
|
write!(f, "ExitShell(session: {session_id:?})")
|
|
}
|
|
}
|
|
}
|
|
}
|