Files
galaxy/app/src/terminal/model_events.rs
T
rkw6086 7c106eecd5 feat: expand Galaxy agent and remote tooling
Add Wormhole remote helpers, provider and agent improvements, filesystem diagnostics, model metadata support, and schema-aware settings IntelliSense.
2026-08-23 13:55:47 -05:00

472 lines
20 KiB
Rust

use std::sync::Arc;
use async_channel::Receiver;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use super::event::{BootstrappedEvent, SshLoginStatus};
use super::model::ansi;
use super::model::ansi::FinishUpdateValue;
use super::model::block::BlockId;
use super::model::completions::ShellCompletion;
use super::model::session::{IsSSHWrapperSession, SessionId, SessionInfo};
use super::model::terminal_model::{CommandType, ExitReason, HandlerEvent};
use crate::features::FeatureFlag;
use crate::remote_server::manager::RemoteServerManager;
use crate::server::telemetry::ImageProtocol;
use crate::terminal::event::{
AfterBlockCompletedEvent, BlockCompletedEvent, BlockMetadataReceivedEvent,
BlockWorkingDirectoryUpdatedEvent, Event, ExecutedExecutorCommandEvent, InitSubshellEvent,
SourcedRcFileInSubshellEvent, TerminalMode,
};
use crate::terminal::model::session::Sessions;
use crate::terminal::shell::ShellType;
use crate::terminal::ClipboardType;
/// Model that dispatches events that have been emitted by the [`crate::terminal::TerminalModel`],
/// allowing other models/views to subscribe to `TerminalModel` events like it would any other
/// entity within the UI framework.
pub struct ModelEventDispatcher {
last_start_prompt_marker: Option<PromptKind>,
active_session_id: Option<SessionId>,
sessions: ModelHandle<Sessions>,
}
impl ModelEventDispatcher {
pub fn new(
model_events_rx: Receiver<Event>,
sessions: ModelHandle<Sessions>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.spawn_stream_local(
model_events_rx,
Self::handle_terminal_model_event,
|_, _| (),
);
Self {
active_session_id: None,
last_start_prompt_marker: None,
sessions,
}
}
/// Returns the active session to which the PTY is currently attached.
///
/// The active session is the session corresponding to the session ID included in the most
/// recent Precmd payload.
pub fn active_session_id(&self) -> Option<SessionId> {
self.active_session_id
}
/// Sets the active session ID directly, for use in unit tests where there's no `Precmd` event.
#[cfg(test)]
pub fn set_active_session_id(&mut self, session_id: SessionId) {
self.active_session_id = Some(session_id);
}
/// Emits the corresponding `ModelEvent` for the received `HandlerEvent` emitted by
/// `TerminalModel` when some `ansi::Handler` method is called.
fn handle_terminal_model_event(&mut self, event: Event, ctx: &mut ModelContext<Self>) {
let event_to_emit = match event {
Event::Handler(HandlerEvent::InitShell {
pending_session_info,
}) => {
self.sessions.update(ctx, |sessions, ctx| {
sessions.register_pending_session(pending_session_info.as_ref(), ctx);
});
let is_ssh_wrapper_session = matches!(
pending_session_info.is_ssh_wrapper_session,
IsSSHWrapperSession::Yes { .. }
);
if FeatureFlag::SshRemoteServer.is_enabled() && is_ssh_wrapper_session {
ModelEvent::SshInitShell {
pending_session_info,
}
} else {
ModelEvent::Handler(AnsiHandlerEvent::InitShell {
pending_session_info,
})
}
}
Event::Handler(HandlerEvent::Bootstrapped(bootstrapped_event)) => {
let session_id = bootstrapped_event.session_info.session_id;
let is_subshell = bootstrapped_event.session_info.subshell_info.is_some();
// Always initialize the session synchronously. When the
// `SshRemoteServer` flag is enabled, the remote-server client
// is wired up independently: `Sessions::new` subscribes to
// `RemoteServerManagerEvent::SessionConnected` and attaches the
// client to the session's `RemoteServerCommandExecutor` when
// the connection lands, so it's safe to initialize the session
// before the remote server finishes connecting.
self.complete_bootstrapped_session(bootstrapped_event, ctx);
ModelEvent::Handler(AnsiHandlerEvent::Bootstrapped {
session_id,
is_subshell,
})
}
Event::RemoteServerReady { session_id } => {
log::info!("Remote server ready for session {session_id:?}");
return;
}
Event::RemoteServerFailed { session_id, error } => {
log::warn!(
"Remote server setup failed for session {session_id:?}, falling back to \
ControlMaster: {error}"
);
return;
}
Event::Handler(HandlerEvent::PromptStart) => {
self.last_start_prompt_marker = Some(PromptKind::Left);
ModelEvent::Handler(AnsiHandlerEvent::StartPrompt)
}
Event::Handler(HandlerEvent::RPromptStart) => {
self.last_start_prompt_marker = Some(PromptKind::Right);
ModelEvent::Handler(AnsiHandlerEvent::StartRPrompt)
}
Event::Handler(HandlerEvent::PromptEnd) => match self.last_start_prompt_marker.take() {
None | Some(PromptKind::Left) => ModelEvent::Handler(AnsiHandlerEvent::EndPrompt),
Some(PromptKind::Right) => ModelEvent::Handler(AnsiHandlerEvent::EndRPrompt),
},
Event::Handler(HandlerEvent::Precmd {
session_id,
handled_after_inband,
env_vars,
}) => {
// Update the active session to the one that corresponds to the received SessionId.
self.active_session_id = session_id;
// Update the active session's environment variables
if let Some(session_id) = session_id {
// We set the environment variables here, which triggers the prompt to refresh
// as certain chips depend on environment variables. We specifically want to
// avoid triggering prompt refreshes for in-band commands because otherwise we'll
// create a loop if updating the prompt involves running an in-band command.
// This is similar to how we handle ModelEvent::AfterBlockCompleted in terminal_view.rs
if !handled_after_inband {
self.sessions.update(ctx, |sessions, ctx| {
sessions.set_env_vars_for_session(session_id, env_vars, ctx)
});
}
}
ModelEvent::Handler(AnsiHandlerEvent::Precmd)
}
Event::Handler(HandlerEvent::Preexec) => ModelEvent::Handler(AnsiHandlerEvent::Preexec),
Event::Handler(HandlerEvent::CommandFinished { command_type }) => match command_type {
CommandType::InBandCommand => {
ModelEvent::Handler(AnsiHandlerEvent::InBandCommandFinished)
}
CommandType::User => ModelEvent::Handler(AnsiHandlerEvent::UserCommandFinished),
_ => return,
},
Event::Handler(HandlerEvent::SetMode {
mode: ansi::Mode::BracketedPaste,
}) => ModelEvent::Handler(AnsiHandlerEvent::SetBracketedPaste),
Event::Handler(HandlerEvent::UnsetMode {
mode: ansi::Mode::BracketedPaste,
}) => ModelEvent::Handler(AnsiHandlerEvent::UnsetBracketedPaste),
Event::CompletionsFinished(res) => ModelEvent::CompletionsFinished(res),
Event::MouseCursorDirty => ModelEvent::MouseCursorDirty,
Event::Title(title) => ModelEvent::Title(title),
Event::VisibleBootstrapBlock => ModelEvent::VisibleBootstrapBlock,
Event::BlockCompleted(block_completed_event) => {
ModelEvent::BlockCompleted(block_completed_event)
}
Event::AfterBlockCompleted(after_block_completed_event) => {
ModelEvent::AfterBlockCompleted(after_block_completed_event)
}
Event::AfterBlockStarted {
block_id,
command,
is_for_in_band_command,
} => ModelEvent::AfterBlockStarted {
block_id,
command,
is_for_in_band_command,
},
Event::BlockMetadataReceived(block_metadata_received_event) => {
ModelEvent::BlockMetadataReceived(block_metadata_received_event)
}
Event::BlockWorkingDirectoryUpdated(block_working_directory_updated_event) => {
ModelEvent::BlockWorkingDirectoryUpdated(block_working_directory_updated_event)
}
Event::BackgroundBlockStarted => ModelEvent::BackgroundBlockStarted,
Event::ClipboardStore(clipboard_type, text) => {
ModelEvent::ClipboardStore(clipboard_type, text)
}
Event::ClipboardLoad(clipboard_type, clipboard_load) => {
ModelEvent::ClipboardLoad(clipboard_type, clipboard_load)
}
Event::CursorBlinkingChange(is_blinking) => {
ModelEvent::CursorBlinkingChange(is_blinking)
}
Event::TerminalClear => ModelEvent::TerminalClear,
Event::DetectedEndOfSshLogin(check_type) => {
ModelEvent::DetectedEndOfSshLogin(check_type)
}
Event::Bell => ModelEvent::Bell,
Event::Exit { reason } => ModelEvent::Exit { reason },
Event::PreInteractiveSSHSession => ModelEvent::PreInteractiveSSHSession,
Event::SSH(ssh) => ModelEvent::SSH(ssh),
Event::SSHControlMasterError => ModelEvent::SSHControlMasterError,
Event::TerminalModeSwapped(terminal_mode) => {
ModelEvent::TerminalModeSwapped(terminal_mode)
}
Event::ExecutedInBandCommand(executed_in_band_command_event) => {
ModelEvent::ExecutedInBandCommand(executed_in_band_command_event)
}
Event::InitSubshell(init_subshell_event) => {
ModelEvent::InitSubshell(init_subshell_event)
}
Event::SourcedRcFileInSubshell(sourced_rc_file_in_subshell_event) => {
ModelEvent::SourcedRcFileInSubshell(sourced_rc_file_in_subshell_event)
}
Event::PromptUpdated => ModelEvent::PromptUpdated,
Event::HonorPS1OutOfSync => ModelEvent::HonorPS1OutOfSync,
Event::Typeahead => ModelEvent::Typeahead,
Event::FinishUpdate(data) => ModelEvent::FinishUpdate(data),
Event::TextSelectionChanged => ModelEvent::SelectedTextChanged,
Event::ShellSpawned(shell_type) => ModelEvent::ShellSpawned(shell_type),
Event::SendCompletionsPrompt => ModelEvent::SendCompletionsPrompt,
Event::ImageReceived {
image_id,
image_data,
image_protocol,
} => ModelEvent::ImageReceived {
image_id,
image_data,
image_protocol,
},
Event::BootstrapPrecmdDone => ModelEvent::BootstrapPrecmdDone,
Event::AgentTaggedInChanged {
block_id,
is_tagged_in,
} => ModelEvent::AgentTaggedInChanged {
block_id,
is_tagged_in,
},
Event::PluggableNotification { title, body } => {
ModelEvent::PluggableNotification { title, body }
}
Event::ExitShell { session_id } => ModelEvent::ExitShell { session_id },
_ => return,
};
ctx.emit(event_to_emit);
}
/// Finalizes session initialization by calling `Sessions::initialize_bootstrapped_session`.
///
/// For SSH wrapper sessions with the `SshRemoteServer` flag, this also
/// sends the `SessionBootstrapped` notification to the remote server via
/// the manager.
///
/// The `SessionBootstrapped` notification is sent **before** initializing
/// the session so the daemon creates the `LocalCommandExecutor` before any
/// subscriber (e.g. `TerminalView::handle_session_bootstrapped`) can queue
/// a `RunCommand` request. Without this ordering, a race exists where the
/// daemon receives `RunCommand` before the executor is ready, producing
/// "No executor for RunCommand, session was never initialized" errors.
fn complete_bootstrapped_session(
&mut self,
event: BootstrappedEvent,
ctx: &mut ModelContext<Self>,
) {
let BootstrappedEvent {
session_info,
spawning_command,
restored_block_commands,
rcfiles_duration_seconds,
} = event;
let (is_ssh_wrapper_session, session_id, shell_type_name, shell_path) = (
matches!(
session_info.is_ssh_wrapper_session,
IsSSHWrapperSession::Yes { .. }
),
session_info.session_id,
session_info.shell.shell_type().name().to_owned(),
session_info.shell.shell_path().clone(),
);
// Send the SessionBootstrapped notification to the daemon BEFORE
// initializing the session. `initialize_bootstrapped_session` emits
// `SessionsEvent::SessionBootstrapped`, which causes subscribers to
// immediately queue `RunCommand` requests (e.g. `load_external_commands`).
// The daemon must have the executor ready before those requests arrive.
if FeatureFlag::SshRemoteServer.is_enabled() && is_ssh_wrapper_session {
RemoteServerManager::handle(ctx).update(ctx, |mgr, _ctx| {
mgr.notify_session_bootstrapped(
session_id,
&shell_type_name,
shell_path.as_deref(),
);
});
}
self.sessions.update(ctx, |sessions, ctx| {
sessions.initialize_bootstrapped_session(
*session_info,
spawning_command,
restored_block_commands,
rcfiles_duration_seconds,
ctx,
);
});
}
/// Emits an event so `TerminalView` can render the remote server block.
pub fn request_remote_server_block(
&mut self,
session_id: SessionId,
ctx: &mut ModelContext<Self>,
) {
ctx.emit(ModelEvent::RemoteServerBlockRequested { session_id });
}
}
/// The type of prompt for which a `PromptStart` event has been received.
enum PromptKind {
Left,
Right,
}
/// Set of events that were dispatched from the [`crate::terminal::TerminalModel`] while parsing
/// PTY output.
pub enum ModelEvent {
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 when an existing block's working directory has been updated
/// outside of the precmd path (e.g. via an OSC 7 escape sequence).
BlockWorkingDirectoryUpdated(BlockWorkingDirectoryUpdatedEvent),
/// 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),
/// 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),
/// Sent when a line of output from an interactive ssh session indicates login is complete.
/// A line such as "Last login: Wed Oct 30" for example indicates login is complete. This is
/// useful for detecting when an ssh session becomes ready for wormholing.
DetectedEndOfSshLogin(SshLoginStatus),
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,
/// Events that correspond to a specific ansi handler hook while parsing PTY output.
///
/// These events make it possible for other models/views to subscribe to PTY output events that
/// are otherwise solely handled on the event loop thread by `TerminalModel`; since PTY output
/// handling logic is mostly executed on that event loop thread, they would otherwise be
/// inaccessible to views/models.
Handler(AnsiHandlerEvent),
FinishUpdate(FinishUpdateValue),
SelectedTextChanged,
ShellSpawned(ShellType),
CompletionsFinished(Vec<ShellCompletion>),
SendCompletionsPrompt,
ImageReceived {
image_id: u32,
image_data: Vec<u8>,
image_protocol: ImageProtocol,
},
BootstrapPrecmdDone,
AgentTaggedInChanged {
block_id: BlockId,
is_tagged_in: bool,
},
/// A pluggable notification triggered via OSC 9 or OSC 777 escape sequences.
PluggableNotification {
title: Option<String>,
body: String,
},
/// Emitted when an SSH session's `InitShell` is intercepted by the
/// `SshRemoteServer` feature flag. `RemoteServerController` subscribes to
/// this instead of `Handler(InitShell)` so `PtyController` never sees it.
SshInitShell {
pending_session_info: Box<SessionInfo>,
},
/// Emitted by `ModelEventDispatcher::request_remote_server_block`
/// when the remote-server binary is missing and the user must choose.
RemoteServerBlockRequested {
session_id: SessionId,
},
/// Emitted right before the remote shell for a session exits. Used to
/// tear down per-session resources (e.g. the remote-server-proxy ssh
/// child) before the outer ssh tunnel starts closing.
ExitShell {
session_id: SessionId,
},
}
#[derive(Clone, Debug)]
pub enum AnsiHandlerEvent {
InitShell {
pending_session_info: Box<SessionInfo>,
},
Bootstrapped {
session_id: SessionId,
is_subshell: bool,
},
Precmd,
Preexec,
UserCommandFinished,
InBandCommandFinished,
StartPrompt,
StartRPrompt,
EndPrompt,
EndRPrompt,
SetBracketedPaste,
UnsetBracketedPaste,
}
impl Entity for ModelEventDispatcher {
type Event = ModelEvent;
}