use std::collections::{HashMap, HashSet}; use std::hash::{Hash as _, Hasher as _}; use std::sync::Arc; use std::time::Duration; use futures::{pin_mut, FutureExt as _}; use itertools::Itertools; use galaxy_completer::completer::CommandExitStatus; use galaxy_core::r#async::debounce; use galaxy_core::user_preferences::GetUserPreferences; use galaxyui::r#async::{SpawnedFutureHandle, Timer}; use galaxyui::{ AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, ViewHandle, WeakModelHandle, }; use super::context_chip::{ ChipAvailability, ChipFingerprintInput, ChipRuntimeCapabilities, ContextChip, Environment, ExternalCommandsAvailability, GeneratorContext, PromptGenerator, RefreshConfig, ShellCommandGenerator, }; use super::logging::{ChipCommandLogEntry, PromptChipExecutionPhase, PromptChipLogger}; use super::prompt::Prompt; use super::{chips_to_string, ChipResult, ChipValue, ContextChipKind}; use crate::code_review::git_repo_model::{GitRepoStatusEvent, GitRepoStatusModel}; use crate::code_review::github_repo_model::{GitHubRepoEvent, GitHubRepoModel}; use crate::context_chips::display_chip::GitLineChanges; use crate::editor::EditorView; use crate::features::FeatureFlag; use crate::menu::{MenuItem, MenuItemFields}; use crate::settings::{InputSettings, WarpPromptSeparator}; use crate::terminal::event::{BlockType, UserBlockCompleted}; use crate::terminal::model::block::{Block, BlockMetadata}; use crate::terminal::model::session::{ExecuteCommandOptions, Session, Sessions, SessionsEvent}; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::session_settings::{ SessionSettings, SessionSettingsChangedEvent, ToolbarChipSelection, }; use crate::terminal::view::{ContextMenuAction, PromptPart, PromptPosition, TerminalAction}; #[cfg(test)] #[path = "current_prompt_tests.rs"] mod tests; const PROMPT_DEBOUNCE_PERIOD: Duration = Duration::from_millis(50); const PROMPT_DEBOUNCE_PERIOD_KEY: &str = "PromptDebouncePeriod"; type ChipFingerprint = u64; /// The lifecycle state of a chip's value computation within a [`CurrentPrompt`]. #[derive(Clone, Debug, Default, PartialEq, Eq)] enum ChipUpdateStatus { #[default] Idle, Loading, Ready, Cached, Disabled, TimedOut, Error, } /// ChipState stores the state and point-in-time information related to a specific chip. /// For example, it's last computed value or a refresh handle. #[derive(Clone, Debug, Default)] pub struct ChipState { last_computed_value: Option, last_on_click_values: Option>, last_fingerprint: Option, /// The fingerprint from the last fetch that failed. When the current fingerprint matches, /// chips with `suppress_on_failure` skip re-execution. last_failure_fingerprint: Option, availability: ChipAvailability, update_status: ChipUpdateStatus, /// Future handle for periodically-refreshing chips. refresh_handle: Option, /// Future handle for asynchronous generators. generator_handle: Option, /// Future handle for asynchronous on-click generators. on_click_generator_handle: Option, /// Whether the chip should render or not. should_render: bool, /// Monotonic counter incremented when a user command matching this chip's /// `invalidate_on_commands` completes. Hashed via `ChipFingerprintInput::InvalidatingCommandCount`. invalidating_command_count: u64, } impl Drop for ChipState { fn drop(&mut self) { if let Some(refresh_handle) = self.refresh_handle.take() { refresh_handle.abort(); } if let Some(generator_handle) = self.generator_handle.take() { generator_handle.abort(); } if let Some(generator_handle) = self.on_click_generator_handle.take() { generator_handle.abort(); } } } impl ChipState { fn new(kind: &ContextChipKind) -> Self { Self { last_computed_value: None, last_on_click_values: None, last_fingerprint: None, last_failure_fingerprint: None, availability: ChipAvailability::Enabled, update_status: ChipUpdateStatus::Idle, refresh_handle: None, generator_handle: None, on_click_generator_handle: None, should_render: kind.should_render("", &Default::default()), invalidating_command_count: 0, } } fn clear_abort_handlers(&mut self) { if let Some(refresh_handle) = self.refresh_handle.take() { refresh_handle.abort(); } if let Some(generator_handle) = self.generator_handle.take() { generator_handle.abort(); } if let Some(generator_handle) = self.on_click_generator_handle.take() { generator_handle.abort(); } } fn clear_cache(&mut self) { self.last_computed_value = None; self.last_on_click_values = None; self.last_fingerprint = None; self.last_failure_fingerprint = None; self.availability = ChipAvailability::Enabled; self.update_status = ChipUpdateStatus::Idle; } } /// CurrentPrompt is a model initialized per session that represents the actual prompt for a given /// session. It subscribes to the singleton prompt model to get the current settings, and then /// stores the states for each chip and manages the refreshing logic. #[derive(Clone)] pub struct CurrentPrompt { states: HashMap, renderable_chips: HashSet, same_line_prompt_enabled: bool, /// The separator to use as a trailing character at the end of Warp prompt, if any. separator: WarpPromptSeparator, latest_context: Option, sessions: ModelHandle, prompt_chip_logger: PromptChipLogger, update_tx: async_channel::Sender<()>, /// When set, branch, branch status, and diff stats are populated from /// `GitRepoStatusModel` filesystem events. git_repo_status: Option>, /// When set, the `GithubPullRequest` chip value is populated from /// `GitHubRepoModel` for the current repository. github_repo_model: Option>, } /// Context about the current terminal session, needed to update the prompt. #[derive(Clone, Debug)] struct PromptContext { active_block_metadata: BlockMetadata, environment: Environment, } #[derive(Clone)] struct ShellCommandExecutionContext { session: Arc, command: String, current_dir_path: Option, environment_variables: Option>, shell_type: crate::terminal::shell::ShellType, } impl CurrentPrompt { pub fn new(sessions: ModelHandle, ctx: &mut ModelContext) -> Self { Self::new_with_model_events(sessions, None, ctx) } pub fn new_with_model_events( sessions: ModelHandle, model_events: Option<&ModelHandle>, ctx: &mut ModelContext, ) -> Self { let prompt = Prompt::handle(ctx); ctx.subscribe_to_model(&prompt, Self::handle_prompt_changed); ctx.subscribe_to_model( &SessionSettings::handle(ctx), Self::handle_session_settings_changed, ); ctx.subscribe_to_model(&sessions, |me, _, event, ctx| { if let SessionsEvent::EnvironmentVariablesUpdated { .. } = event { me.update_states_with_new_context(ctx); } }); if let Some(model_events) = model_events { ctx.subscribe_to_model(model_events, Self::handle_model_event); } let (update_tx, update_rx) = async_channel::unbounded(); let debounce_period = ctx .private_user_preferences() .read_value(PROMPT_DEBOUNCE_PERIOD_KEY) .ok() .flatten() .and_then(|s| s.parse().ok()) .map(Duration::from_millis) .unwrap_or(PROMPT_DEBOUNCE_PERIOD); // Debounce rendering updates to the prompt ctx.spawn_stream_local( debounce(debounce_period, update_rx), |_, _, ctx| ctx.notify(), |_, _| {}, ); Self { states: Default::default(), renderable_chips: Default::default(), sessions, latest_context: None, prompt_chip_logger: PromptChipLogger::default(), update_tx, same_line_prompt_enabled: prompt.as_ref(ctx).same_line_prompt_enabled(), separator: prompt.as_ref(ctx).separator(), git_repo_status: None, github_repo_model: None, } } /// This is used to subscribe to an editor view (i.e. in the input) whose buffer /// we'd like to use to update chip state. pub fn subscribe_to_input_editor( &self, editor: ViewHandle, ctx: &mut ModelContext, ) { // A WeakViewHandle is used here to avoid leaking the terminal model let weak_editor_handle = editor.downgrade(); ctx.subscribe_to_view(&editor, move |me, _, _, ctx| { // CurrentPrompt exists and this fn is called even if we're not using warp prompt. // We don't need to do anything if we're honoring PS1 unless universal developer input // or AgentView is enabled (agent view needs chips regardless of PS1 setting). if *SessionSettings::as_ref(ctx).honor_ps1 && !InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx) && !FeatureFlag::AgentView.is_enabled() { return; } let Some(editor) = weak_editor_handle.upgrade(ctx) else { return; }; let latest_context = me.latest_context.clone(); if let Some(context) = latest_context { if let Some(session_id) = context.active_block_metadata.session_id() { let session = me .sessions .update(ctx, |sessions, _| sessions.get(session_id)); if let Some(session) = session { let buffer_text = editor.as_ref(ctx).buffer_text(ctx); for (kind, state) in me.states.iter_mut() { state.should_render = kind.should_render(&buffer_text, session.aliases()); } ctx.notify(); } } } }); } pub fn snapshot(&self) -> HashMap> { let cur = self .states .iter() .filter_map(|(kind, state)| { if state.should_render && !matches!(state.availability, ChipAvailability::Hidden) { Some((kind.clone(), state.last_computed_value.clone())) } else { None } }) .collect(); cur } pub fn on_click_snapshot(&self) -> HashMap> { self.states .iter() .filter_map(|(kind, state)| { if matches!(state.availability, ChipAvailability::Hidden) { return None; } state .last_on_click_values .clone() .map(|values| (kind.clone(), values)) }) .collect() } /// Whether same line prompt is enabled for the Warp prompt. pub fn same_line_prompt_enabled(&self) -> bool { self.same_line_prompt_enabled } /// The separator for the current Warp prompt. pub fn separator(&self) -> WarpPromptSeparator { self.separator } fn update_chip_value(&mut self, chip_kind: &ContextChipKind, value: Option) { log::debug!("Updating prompt value of {chip_kind:?} to {value:?}"); if let Some(state) = self.states.get_mut(chip_kind) { if state.last_computed_value != value { state.last_computed_value = value; state.update_status = ChipUpdateStatus::Ready; let _ = self.update_tx.try_send(()); } } } fn update_on_click_value(&mut self, chip_kind: &ContextChipKind, value: Option>) { log::debug!("Updating prompt on_click value of {chip_kind:?} to {value:?}"); let filter_values = match chip_kind { ContextChipKind::ShellGitBranch => self.filter_git_branch_on_click_values(value), _ => value, }; if let Some(state) = self.states.get_mut(chip_kind) { state.last_on_click_values = filter_values; let _ = self.update_tx.try_send(()); } } fn set_chip_availability( &mut self, chip_kind: &ContextChipKind, availability: ChipAvailability, ) { if let Some(state) = self.states.get_mut(chip_kind) { if state.availability != availability { state.availability = availability; let _ = self.update_tx.try_send(()); } } } fn set_chip_update_status(&mut self, chip_kind: &ContextChipKind, status: ChipUpdateStatus) { if let Some(state) = self.states.get_mut(chip_kind) { state.update_status = status; } } fn set_chip_fingerprint( &mut self, chip_kind: &ContextChipKind, fingerprint: Option, ) { if let Some(state) = self.states.get_mut(chip_kind) { state.last_fingerprint = fingerprint; } } fn chip_runtime_capabilities_for_session( &self, session: Option<&Session>, required_executables: &[String], include_external_command_count: bool, ) -> ChipRuntimeCapabilities { session .map(|session| { ChipRuntimeCapabilities::from_session_with_external_command_queries( session, required_executables.iter().map(String::as_str), include_external_command_count, ) }) .unwrap_or_default() } fn build_chip_fingerprint( &self, chip_kind: &ContextChipKind, chip: &ContextChip, required_executables: &[String], context: &GeneratorContext, capabilities: &ChipRuntimeCapabilities, ) -> Option { let inputs = chip.runtime_policy().fingerprint_inputs(); if inputs.is_empty() { return None; } let mut hasher = std::collections::hash_map::DefaultHasher::new(); for input in inputs { input.hash(&mut hasher); match input { ChipFingerprintInput::SessionId => { context.active_block_metadata.session_id().hash(&mut hasher); } ChipFingerprintInput::SessionIsLocal => { context .active_session .map(Session::is_local) .hash(&mut hasher); } ChipFingerprintInput::WorkingDirectory => { context .active_block_metadata .current_working_directory() .hash(&mut hasher); } ChipFingerprintInput::GitBranch => { context.current_environment.git_branch().hash(&mut hasher); } ChipFingerprintInput::PythonVirtualenv => { context .current_environment .python_virtualenv() .hash(&mut hasher); } ChipFingerprintInput::CondaEnvironment => { context .current_environment .conda_environment() .hash(&mut hasher); } ChipFingerprintInput::NodeVersion => { context.current_environment.node_version().hash(&mut hasher); } ChipFingerprintInput::SessionUser => { context.active_session.map(Session::user).hash(&mut hasher); } ChipFingerprintInput::SessionHostname => { context .active_session .map(Session::hostname) .hash(&mut hasher); } ChipFingerprintInput::ExternalCommandsState => { match &capabilities.external_commands { ExternalCommandsAvailability::Unknown => { 0u8.hash(&mut hasher); } ExternalCommandsAvailability::Known { command_count, .. } => { 1u8.hash(&mut hasher); command_count.hash(&mut hasher); } } } ChipFingerprintInput::RequiredExecutablesPresence => { let mut cmds = required_executables .iter() .map(String::as_str) .collect_vec(); cmds.sort_unstable(); for cmd in cmds { cmd.hash(&mut hasher); capabilities .external_commands .contains(cmd) .hash(&mut hasher); } } ChipFingerprintInput::InvalidatingCommandCount => { if let Some(state) = self.states.get(chip_kind) { state.invalidating_command_count.hash(&mut hasher); } } } } Some(hasher.finish()) } fn maybe_skip_fetch_due_to_matching_fingerprint( &mut self, chip_kind: &ContextChipKind, new_fingerprint: Option, allow_fingerprint_skip: bool, ) -> bool { if !allow_fingerprint_skip { return false; } let Some(new_fingerprint) = new_fingerprint else { return false; }; // A retryable failure (`Error`, `TimedOut`) is not a usable cached // result: `last_fingerprint` is recorded before the command runs, and // such failures intentionally do not populate `last_failure_fingerprint`. // Without this guard, the next periodic tick would treat the failed // attempt as a cache hit and never retry. Deterministic failures // continue to be suppressed via `last_failure_fingerprint`. let should_skip = self .states .get(chip_kind) .filter(|state| { !matches!( state.update_status, ChipUpdateStatus::Error | ChipUpdateStatus::TimedOut ) }) .and_then(|state| state.last_fingerprint.as_ref()) .is_some_and(|existing| existing == &new_fingerprint); if should_skip { self.set_chip_update_status(chip_kind, ChipUpdateStatus::Cached); return true; } self.set_chip_fingerprint(chip_kind, Some(new_fingerprint)); false } fn with_current_generator_context( &self, ctx: &AppContext, func: impl FnOnce(&GeneratorContext) -> R, ) -> Option { self.with_generator_context(ctx, |generator_context| Some(func(generator_context))) } fn prepare_shell_command_context( &self, cmd: &ShellCommandGenerator, ctx: &AppContext, ) -> Option { let latest_context = self.latest_context.as_ref()?; let session_id = latest_context.active_block_metadata.session_id()?; let (session, mut environment_variables) = self.sessions.read(ctx, |sessions, _| { ( sessions.get(session_id), sessions.get_env_vars_for_session(session_id), ) }); let session = session?; let shell_type = session.shell().shell_type(); let command = cmd.command().for_shell(shell_type).map(str::to_owned)?; let current_dir_path = latest_context .active_block_metadata .current_working_directory() .map(ToOwned::to_owned); let path_env_var = session.path().as_deref().map(str::to_owned); if let (Some(path_var), Some(env_vars)) = (path_env_var, environment_variables.as_mut()) { env_vars.insert("PATH".to_string(), path_var); } Some(ShellCommandExecutionContext { session, command, current_dir_path, environment_variables, shell_type, }) } /// Races command execution against a timeout. /// /// On timeout we drop the in-flight `execute_command` future, which is the only per-command /// cancellation mechanism exposed here today. That drop path triggers actual cancellation for /// local and in-band executors (for example `kill_on_drop` / `on_cancel`), but we intentionally /// do not call `session.cancel_active_commands()` because it is session-global and would cancel /// unrelated generator commands as well. async fn execute_session_command_with_timeout( session: Arc, command: String, current_dir_path: Option, environment_variables: Option>, timeout: Option, ) -> (Option, bool) { let command_future = session .execute_command( &command, current_dir_path.as_deref(), environment_variables, ExecuteCommandOptions::default(), ) .fuse(); let timeout_future = match timeout { Some(duration) => Timer::after(duration), None => Timer::never(), } .fuse(); pin_mut!(command_future); pin_mut!(timeout_future); futures::select! { result = command_future => (result.ok(), false), _ = timeout_future => (None, true), } } fn filter_git_branch_on_click_values( &self, values_opt: Option>, ) -> Option> { super::git_branch_on_click::filter_git_branch_on_click_values(values_opt) } /// Perform a single update of the given chip. /// /// If the chip's generator runs asynchronously, this will update its generator future handle. fn fetch_chip_value_once( &mut self, chip_kind: &ContextChipKind, generator: &PromptGenerator, on_click_generator: Option, allow_fingerprint_skip: bool, ctx: &mut ModelContext, ) { let Some(chip) = chip_kind.to_chip() else { log::error!("Undefined chip: {chip_kind:?}"); return; }; let required_executables = chip.runtime_policy().required_executables(); let include_external_command_count = chip .runtime_policy() .fingerprint_inputs() .contains(&ChipFingerprintInput::ExternalCommandsState); let (availability, fingerprint) = self .with_current_generator_context(ctx, |generator_context| { let capabilities = self.chip_runtime_capabilities_for_session( generator_context.active_session, required_executables, include_external_command_count, ); ( chip.availability(&capabilities), self.build_chip_fingerprint( chip_kind, &chip, required_executables, generator_context, &capabilities, ), ) }) .unwrap_or((ChipAvailability::Enabled, None)); self.set_chip_availability(chip_kind, availability.clone()); if !availability.is_enabled() { if let Some(state) = self.states.get_mut(chip_kind) { if let Some(handle) = state.generator_handle.take() { handle.abort(); } if let Some(handle) = state.on_click_generator_handle.take() { handle.abort(); } } self.update_chip_value(chip_kind, None); self.update_on_click_value(chip_kind, None); self.set_chip_update_status(chip_kind, ChipUpdateStatus::Disabled); return; } if self.maybe_skip_fetch_due_to_matching_fingerprint( chip_kind, fingerprint, allow_fingerprint_skip, ) { return; } if chip.runtime_policy().suppress_on_failure() { if let Some(state) = self.states.get(chip_kind) { if let Some(current_fp) = &fingerprint { if state.last_failure_fingerprint.as_ref() == Some(current_fp) { self.update_chip_value(chip_kind, None); self.update_on_click_value(chip_kind, None); self.set_chip_update_status(chip_kind, ChipUpdateStatus::Cached); return; } } } } match generator { PromptGenerator::ShellCommand(cmd) => { let Some(exec_ctx) = self.prepare_shell_command_context(cmd, ctx) else { log::warn!("Generator for {chip_kind:?}: could not prepare execution context"); self.update_chip_value(chip_kind, None); self.update_on_click_value(chip_kind, None); self.set_chip_update_status(chip_kind, ChipUpdateStatus::Error); return; }; let chip_kind = chip_kind.clone(); let Some(state) = self.states.get_mut(&chip_kind) else { log::warn!("Tried to run generator for {chip_kind:?}, but state was missing"); return; }; if let Some(handle) = state.generator_handle.take() { handle.abort(); } state.update_status = ChipUpdateStatus::Loading; let timeout = chip.runtime_policy().shell_command_timeout(); let suppress_on_failure = chip.runtime_policy().suppress_on_failure(); let allow_empty_value = chip.allow_empty_value(); let chip_title = chip.title().to_owned(); let current_fingerprint = fingerprint; let logger = self.prompt_chip_logger.clone(); let handle = ctx.spawn( async move { let (value, timed_out) = Self::execute_session_command_with_timeout( exec_ctx.session.clone(), exec_ctx.command.clone(), exec_ctx.current_dir_path.clone(), exec_ctx.environment_variables.clone(), timeout, ) .await; (value, timed_out, chip_kind, exec_ctx, chip_title) }, move |me, (value, timed_out, chip_kind, exec_ctx, chip_title), _| { logger.log_shell_command(&ChipCommandLogEntry { chip_kind: &chip_kind, chip_title: &chip_title, phase: PromptChipExecutionPhase::Value, shell_type: exec_ctx.shell_type, working_directory: exec_ctx.current_dir_path.as_deref(), command: &exec_ctx.command, output: value.as_ref(), timed_out, }); // GitDiffStats has two value sources that can race when entering a repo: // this shell fallback (`git diff --shortstat HEAD`, tracked changes only) // and a repo-status watcher that also counts untracked files. If the // watcher attached while this fallback was in flight, drop the fallback's // result if matches!(chip_kind, ContextChipKind::GitDiffStats) && me.is_updated_externally(&chip_kind) { return; } if timed_out { if suppress_on_failure { if let Some(state) = me.states.get_mut(&chip_kind) { state.last_failure_fingerprint = current_fingerprint; } } me.update_chip_value(&chip_kind, None); me.set_chip_update_status(&chip_kind, ChipUpdateStatus::TimedOut); return; } let (output, status, failed) = match &value { Some(command_output) if command_output.status == CommandExitStatus::Success => { let output = command_output.to_string().ok().and_then(|mut s| { s.truncate(s.trim_end().len()); if allow_empty_value || !s.is_empty() { Some(s) } else { None } }); (output, ChipUpdateStatus::Ready, false) } _ => (None, ChipUpdateStatus::Error, true), }; if suppress_on_failure && failed { if let Some(state) = me.states.get_mut(&chip_kind) { state.last_failure_fingerprint = current_fingerprint; } } else if suppress_on_failure { if let Some(state) = me.states.get_mut(&chip_kind) { if state.last_failure_fingerprint == current_fingerprint { state.last_failure_fingerprint = None; } } } let chip_value = output.map(ChipValue::Text); me.update_chip_value(&chip_kind, chip_value); me.set_chip_update_status(&chip_kind, status); }, ); state.generator_handle = Some(handle); } PromptGenerator::Contextual { from_context_fn } => { self.set_chip_update_status(chip_kind, ChipUpdateStatus::Loading); let value = self.with_generator_context(ctx, from_context_fn); self.update_chip_value(chip_kind, value); self.set_chip_update_status(chip_kind, ChipUpdateStatus::Ready); } } if let Some(on_click_gen) = on_click_generator { self.refresh_on_click_values(chip_kind, on_click_gen, ctx); } } /// Run only the on-click generator for the given chip, updating the /// `last_on_click_values` in state when the command completes. fn refresh_on_click_values( &mut self, chip_kind: &ContextChipKind, on_click_generator: PromptGenerator, ctx: &mut ModelContext, ) { let PromptGenerator::ShellCommand(on_click_cmd) = on_click_generator else { return; }; if !self .states .get(chip_kind) .is_some_and(|state| state.availability.is_enabled()) { return; } let chip_kind = chip_kind.clone(); let Some(exec_ctx) = self.prepare_shell_command_context(&on_click_cmd, ctx) else { return; }; let Some(chip) = chip_kind.to_chip() else { return; }; let Some(state) = self.states.get_mut(&chip_kind) else { log::warn!("Tried to run on-click generator for {chip_kind:?}, but state was missing"); return; }; if let Some(handle) = state.on_click_generator_handle.take() { handle.abort(); } let timeout = chip.runtime_policy().shell_command_timeout(); let chip_title = chip.title().to_owned(); let logger = self.prompt_chip_logger.clone(); let handle = ctx.spawn( async move { let (value, timed_out) = Self::execute_session_command_with_timeout( exec_ctx.session.clone(), exec_ctx.command.clone(), exec_ctx.current_dir_path.clone(), exec_ctx.environment_variables.clone(), timeout, ) .await; (value, timed_out, chip_kind, exec_ctx, chip_title) }, move |me, (on_click_value, timed_out, chip_kind, exec_ctx, chip_title), _ctx| { logger.log_shell_command(&ChipCommandLogEntry { chip_kind: &chip_kind, chip_title: &chip_title, phase: PromptChipExecutionPhase::OnClick, shell_type: exec_ctx.shell_type, working_directory: exec_ctx.current_dir_path.as_deref(), command: &exec_ctx.command, output: on_click_value.as_ref(), timed_out, }); if timed_out { me.update_on_click_value(&chip_kind, None); return; } let on_click_output = match on_click_value { Some(command_output) if command_output.status == CommandExitStatus::Success => { match command_output.to_string() { Ok(string) => string .split('\n') .map(|s| s.trim().to_string()) .collect_vec(), Err(_) => Vec::new(), } } _ => Vec::new(), }; me.update_on_click_value(&chip_kind, Some(on_click_output)); }, ); state.on_click_generator_handle = Some(handle); } fn fetch_chip_value_at_interval( &mut self, chip_kind: &ContextChipKind, initial_value_generator: Option, on_click_generator: Option, allow_fingerprint_skip: bool, ctx: &mut ModelContext, ) { // For periodically-updated chips, we have to check if context chips were disabled while // waiting on the timer. This protects against race conditions between aborting the // previous refresh handle and starting the next one. if !self.active(ctx) { return; } let Some(chip) = chip_kind.to_chip() else { log::error!("Undefined chip: {chip_kind:?}"); return; }; if let RefreshConfig::Periodically { interval } = chip.refresh_config() { let initial_value_generator = initial_value_generator.as_ref().unwrap_or(chip.generator()); self.fetch_chip_value_once( chip_kind, initial_value_generator, on_click_generator.clone(), allow_fingerprint_skip, ctx, ); let interval = *interval; let chip_kind_clone = chip_kind.clone(); let future = ctx.spawn( async move { Timer::after(interval).await; chip_kind_clone }, |me, chip_kind, ctx| { me.fetch_chip_value_at_interval(&chip_kind, None, None, true, ctx); }, ); match self.states.get_mut(chip_kind) { Some(state) => state.refresh_handle = Some(future), None => log::warn!("Missing state for {chip_kind:?}"), } } } fn run_chips(&mut self, chips: Vec, ctx: &mut ModelContext) { if !self.active(ctx) { log::debug!("Context chips are not in use, won't run"); return; } chips.iter().for_each(|chip_kind| { let Some(chip) = chip_kind.to_chip() else { log::error!("Undefined chip: {chip_kind:?}"); return; }; // Add states of new chips if !self.states.contains_key(chip_kind) { let state = ChipState::new(chip_kind); self.states.insert(chip_kind.clone(), state); } if self.is_updated_externally(chip_kind) { // For chips updated externally (e.g. by the per-repo git status // filesystem watcher), avoid running the shell-based fallback // generator. Doing so can briefly overwrite the structured // watcher value with one that uses different semantics (for // example, the `GitDiffStats` shell fallback runs `git diff // --shortstat HEAD`, which excludes untracked files, whereas // the watcher counts untracked files as changes), causing the // chip to flicker between the tracked-only count and the // all-files count when untracked files are present. // // If a chip provides an `initial_value_generator` that sources // from the prompt context (rather than running a shell // command), use it for a fast initial value until the watcher // emits a metadata-changed event. if let Some(initial_gen) = chip_kind.initial_value_generator() { self.fetch_chip_value_once( chip_kind, &initial_gen, chip.on_click_generator().cloned(), true, ctx, ); } else { // Externally-updated chips without an `initial_value_generator` // are left blank after a state rebuild (`states.clear()` in // `handle_prompt_changed`, or `clear_cache()` on a session // change) until their backing model emits a change event. // `GithubPullRequest` only emits when cached PR info actually // changes, so after a rebuild there may be no event to restore // the already-cached value until the next periodic refresh. if matches!(chip_kind, ContextChipKind::GithubPullRequest) { self.sync_pr_chip_from_model(ctx); } } return; } match chip.refresh_config() { RefreshConfig::OnDemandOnly => { self.fetch_chip_value_once( chip_kind, chip.generator(), chip.on_click_generator().cloned(), true, ctx, ); } RefreshConfig::Periodically { .. } => { self.fetch_chip_value_at_interval( chip_kind, chip_kind.initial_value_generator(), chip.on_click_generator().cloned(), true, ctx, ); } RefreshConfig::OnFileChanges { filepath } => { log::debug!("Unimplemented: would've watched changes to filepath: {filepath}"); // fall back to OnDemandOnly behavior instead self.fetch_chip_value_once( chip_kind, chip.generator(), chip.on_click_generator().cloned(), true, ctx, ); } }; }); } /// Reads the currently-configured chips from the [`Prompt`] model and filters out any that /// are missing their definition. fn configured_chips(&self, ctx: &AppContext) -> Vec { let prompt = Prompt::as_ref(ctx); prompt .chip_kinds() .into_iter() .filter(|chip_kind| chip_kind.to_chip().is_some()) .collect() } /// Chips whose values we should actively maintain in state. /// /// When Agent View is enabled, the footer chips should not depend on prompt chip /// customization/ordering/visibility, so we keep their backing values up to date even if they /// are not present in the prompt configuration. fn chips_to_run(&self, ctx: &AppContext) -> Vec { let mut chips = self.configured_chips(ctx); if FeatureFlag::AgentView.is_enabled() { let footer_chips = SessionSettings::as_ref(ctx) .agent_footer_chip_selection .all_chips(); for chip_kind in footer_chips { if !chips.contains(&chip_kind) { chips.push(chip_kind); } } // Also include chips configured for the CLI agent footer. let cli_footer_chips = SessionSettings::as_ref(ctx) .cli_agent_footer_chip_selection .all_chips(); for chip_kind in cli_footer_chips { if !chips.contains(&chip_kind) { chips.push(chip_kind); } } } chips } /// Resets states (including terminating any in progress spawned operations), and updates the /// existing states map with new information. /// This is called when the context gets updated (ie. a new block metadata is received). fn update_states_with_new_context(&mut self, ctx: &mut ModelContext) { // 1. Terminating existing spawned operations. self.clear_chips(); // 2. Running chips with new context self.run_chips(self.chips_to_run(ctx), ctx); } /// Resets states (including terminating any in progress spawned operations), and updates the /// existing states map with new information. /// This is called when the context gets updated (ie. a new block metadata is received). fn update_states_with_new_context_and_session(&mut self, ctx: &mut ModelContext) { // 1. Terminating existing spawned operations. self.clear_chips_and_cache(); // 2. Running chips with new context self.run_chips(self.chips_to_run(ctx), ctx); } /// Handles prompt updates (ie. configuration changes). /// Removes states for chips that are no longer in use, and removes them; and for new chips - /// runs them. Note that existing chips don't need to run, because they're already in a good /// spot, and changing Prompt configuration most likely doesn't mean updating the context. fn handle_prompt_changed( &mut self, _: ModelHandle, _prompt_event: &::Event, ctx: &mut ModelContext, ) { self.states.clear(); self.update_states_with_new_context(ctx); let prompt = Prompt::as_ref(ctx); self.separator = prompt.separator(); // Always notify, so that if the prompt layout changed (reordering chips, for example), // we'll re-render the prompt, even if no individual chip contents changed. ctx.notify(); } fn handle_session_settings_changed( &mut self, _: ModelHandle, event: &SessionSettingsChangedEvent, ctx: &mut ModelContext, ) { if let SessionSettingsChangedEvent::HonorPS1 { .. } = event { if self.active(ctx) { // If switching from PS1 to context chips, we'll need to restart the chip-updating // loops. Any previous async updates will have been cancelled. log::debug!("Re-enabling context chips"); self.update_states_with_new_context(ctx) } else { // If switching from context chips to PS1, stop any in-flight chip updates. log::debug!("Using PS1, disabling context chips"); self.clear_chips_and_cache(); } } if let SessionSettingsChangedEvent::SavedPrompt { .. } = event { let session_settings = SessionSettings::as_ref(ctx); self.same_line_prompt_enabled = session_settings.saved_prompt.same_line_prompt_enabled(); self.separator = session_settings.saved_prompt.separator(); } if let SessionSettingsChangedEvent::AgentToolbarChipSelectionSetting { .. } = event { // Recompute which chips to run when the agent footer config changes. self.update_states_with_new_context(ctx); } if let SessionSettingsChangedEvent::GithubPrChipDefaultValidation { .. } = event { // Re-resolve the default prompt's chip list (which gates the // PR chip on `is_suppressed()`) and re-run chips with the new // suppression state. self.update_states_with_new_context(ctx); } if let SessionSettingsChangedEvent::CLIAgentToolbarChipSelectionSetting { .. } = event { self.update_states_with_new_context(ctx); } } fn clear_chips(&mut self) { self.states .iter_mut() .for_each(|(_, state)| state.clear_abort_handlers()); self.renderable_chips.clear(); } /// Clear all context chip state and stop any in-progress updates. fn clear_chips_and_cache(&mut self) { self.clear_chips(); self.states .iter_mut() .for_each(|(_, state)| state.clear_cache()); } /// Waits for any in-progress asynchronous generators to finish. #[cfg(test)] pub fn await_generators( &self, ctx: &mut galaxyui::AppContext, ) -> futures_util::future::BoxFuture<'static, ()> { use futures_util::FutureExt; // This structure prevents the returned Future from referencing self. let chip_futures = self .states .values() .flat_map(|state| { [ state.generator_handle.as_ref(), state.on_click_generator_handle.as_ref(), ] }) .flatten() .map(|handle| ctx.await_spawned_future(handle.future_id())) .collect_vec(); async move { for future in chip_futures { future.await; } } .boxed() } /// Whether or not any asynchronous generators are currently refreshing. #[cfg(test)] pub fn are_any_generators_running(&self) -> bool { self.states .values() .flat_map(|state| { [ state.generator_handle.as_ref(), state.on_click_generator_handle.as_ref(), ] }) .flatten() .any(|handle| !handle.abort_handle().is_aborted()) } fn handle_model_event( &mut self, _: ModelHandle, event: &ModelEvent, ctx: &mut ModelContext, ) { if let ModelEvent::AfterBlockCompleted(after_block_completed) = event { if let BlockType::User(UserBlockCompleted { command, .. }) = &after_block_completed.block_type { if let Some(cmd) = command.split_whitespace().next() { // Resolve aliases so that e.g. `alias g=git` followed by `g push` // still triggers invalidation for chips watching "git". let resolved = self .latest_context .as_ref() .and_then(|context| context.active_block_metadata.session_id()) .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)) .and_then(|session| session.alias_value(cmd).map(String::from)); let effective_cmd = resolved.as_deref().unwrap_or(cmd); for (chip_kind, state) in &mut self.states { if let Some(chip) = chip_kind.to_chip() { if chip .runtime_policy() .invalidate_on_commands() .iter() .any(|c| c == effective_cmd) { state.invalidating_command_count += 1; } } } } } } } /// Update the prompt context to reflect a new active block. This should be called from the /// parent terminal whenever a new set of block metadata is received. pub fn update_context(&mut self, active_block: &Block, ctx: &mut ModelContext) { let session_has_changed = match &self.latest_context { Some(ctx) => ctx.active_block_metadata.session_id() != active_block.session_id(), None => true, }; self.latest_context = Some(PromptContext { active_block_metadata: active_block.metadata(), environment: Environment::from_block(active_block), }); if session_has_changed { self.update_states_with_new_context_and_session(ctx); } else { self.update_states_with_new_context(ctx); } } /// Run a callback with the latest generator context. fn with_generator_context(&self, ctx: &C, func: F) -> Option where C: ModelAsRef, F: FnOnce(&GeneratorContext) -> Option, { let current_context = self.latest_context.as_ref()?; let active_session = current_context .active_block_metadata .session_id() .and_then(|session_id| self.sessions.as_ref(ctx).get(session_id)); let context = GeneratorContext { active_block_metadata: ¤t_context.active_block_metadata, active_session: active_session.as_deref(), current_environment: ¤t_context.environment, }; func(&context) } /// Builds context menu items for copying individual context chips pub fn copy_menu_items( &self, position: PromptPosition, ctx: &AppContext, ) -> Vec> { Prompt::as_ref(ctx) .chip_kinds() .into_iter() .filter_map(|chip_kind| { let has_value = self .states .get(&chip_kind) .is_some_and(|state| state.last_computed_value.is_some()); if has_value && chip_kind.is_copyable() { if let Some(chip) = chip_kind.to_chip() { Some( MenuItemFields::new(format!("Copy {}", chip.title())) .with_on_select_action(TerminalAction::ContextMenu( ContextMenuAction::CopyPrompt { position, part: PromptPart::ContextChip(chip_kind), }, )) .into_item(), ) } else { log::error!("Missing definition for chip: {chip_kind:?}"); None } } else { None } }) .collect() } /// Gets the latest value of the given chip. pub fn latest_chip_value(&self, chip_kind: &ContextChipKind) -> Option<&ChipValue> { self.states .get(chip_kind) .and_then(|state| state.last_computed_value.as_ref()) } /// Gets the latest chip data for the given chip kind, independent of prompt configuration. pub fn latest_chip_result(&self, chip_kind: &ContextChipKind) -> Option { let state = self.states.get(chip_kind)?; if !state.should_render || matches!(state.availability, ChipAvailability::Hidden) { return None; } Some(ChipResult { kind: chip_kind.clone(), value: state.last_computed_value.clone(), on_click_values: state.last_on_click_values.clone().unwrap_or_default(), }) } /// Serializes the current prompt as an unstyled string. pub fn prompt_as_string(&self, ctx: &AppContext) -> String { chips_to_string( Prompt::as_ref(ctx) .chip_kinds() .into_iter() .filter_map(|chip_kind| { let value = &self.states.get(&chip_kind)?.last_computed_value; let on_click_value = self.states.get(&chip_kind)?.last_on_click_values.clone(); let chip_result = ChipResult { kind: chip_kind, value: value.clone(), on_click_values: on_click_value.unwrap_or_default(), }; Some(chip_result) }), ) } /// Set the per-repo git status model handle. When `Some`, subscribes to /// metadata events so git-backed prompt chips are updated from the /// per-repo status model. PR info is handled separately by /// [`Self::set_github_repo_model`]. pub fn set_git_repo_status( &mut self, handle: Option>, ctx: &mut ModelContext, ) { // Unsubscribe from the previous model, if any. if let Some(old_weak) = self.git_repo_status.take() { if let Some(old_strong) = old_weak.upgrade(ctx) { ctx.unsubscribe_from_model(&old_strong); } } // Repo detached, clear git chips that require repository metadata. if handle.is_none() { for chip_kind in [ ContextChipKind::GitDiffStats, ContextChipKind::GitBranchStatus, ] { if let Some(state) = self.states.get_mut(&chip_kind) { state.clear_abort_handlers(); state.clear_cache(); } } let _ = self.update_tx.try_send(()); return; } if let Some(weak) = handle { if let Some(strong) = weak.upgrade(ctx) { self.git_repo_status = Some(weak); ctx.subscribe_to_model(&strong, |me, _, event, ctx| match event { GitRepoStatusEvent::MetadataChanged => { me.apply_git_repo_metadata(ctx); } }); // Eagerly populate chips if metadata is already available (the // initial `refresh_metadata` in `GitRepoStatusModel::new` may // have completed before we subscribed). If it hasn't finished // yet, the subscription above will catch the `MetadataChanged` // event when it does. if strong.as_ref(ctx).metadata(ctx).is_some() { self.apply_git_repo_metadata(ctx); } } } } /// Set the per-repo GitHub-info model handle. When `Some`, subscribes to /// its events so the `GithubPullRequest` chip value is updated. pub fn set_github_repo_model( &mut self, handle: Option>, ctx: &mut ModelContext, ) { // Unsubscribe from the previous model, if any. if let Some(old_weak) = self.github_repo_model.take() { if let Some(old_strong) = old_weak.upgrade(ctx) { ctx.unsubscribe_from_model(&old_strong); } } if handle.is_none() { // GitHub-info handle detached: clear any stale PR chip state. if let Some(state) = self.states.get_mut(&ContextChipKind::GithubPullRequest) { state.clear_abort_handlers(); state.clear_cache(); } let _ = self.update_tx.try_send(()); return; } if let Some(weak) = handle { if let Some(strong) = weak.upgrade(ctx) { self.github_repo_model = Some(weak); // Only PR info drives the chip value; repository name/owner // changes don't affect it. ctx.subscribe_to_model(&strong, |me, _, event, ctx| match event { GitHubRepoEvent::PrInfoChanged => { me.sync_pr_chip_from_model(ctx); } GitHubRepoEvent::RepositoryInfoChanged => {} }); // Eagerly populate the PR chip if PR info has already landed. self.sync_pr_chip_from_model(ctx); } } } /// Read the current `GitRepoStatusModel` metadata and push it into the /// git-backed chip states. fn apply_git_repo_metadata(&mut self, ctx: &mut ModelContext) { let metadata = self .git_repo_status .as_ref() .and_then(|w| w.upgrade(ctx)) .and_then(|h| h.as_ref(ctx).metadata(ctx).cloned()); let Some(metadata) = metadata else { return; }; // Update ShellGitBranch. let new_branch = ChipValue::Text(metadata.current_branch_name.clone()); let current_branch = self .latest_chip_value(&ContextChipKind::ShellGitBranch) .cloned(); if current_branch.as_ref() != Some(&new_branch) { self.update_chip_value(&ContextChipKind::ShellGitBranch, Some(new_branch)); // Refresh the branch dropdown so it stays in sync. let chip_kind = ContextChipKind::ShellGitBranch; if let Some(chip) = chip_kind.to_chip() { if let Some(on_click_gen) = chip.on_click_generator().cloned() { self.refresh_on_click_values(&chip_kind, on_click_gen, ctx); } } } let new_branch_status = ChipValue::GitBranchStatus(metadata.branch_tracking_status.clone()); let current_branch_status = self .latest_chip_value(&ContextChipKind::GitBranchStatus) .cloned(); if current_branch_status.as_ref() != Some(&new_branch_status) { self.update_chip_value(&ContextChipKind::GitBranchStatus, Some(new_branch_status)); } // Update GitDiffStats with structured data directly. let new_diff_stats = ChipValue::GitDiffStats(GitLineChanges::from_diff_stats( &metadata.stats_against_head, )); let current_diff_stats = self .latest_chip_value(&ContextChipKind::GitDiffStats) .cloned(); if current_diff_stats.as_ref() != Some(&new_diff_stats) { self.update_chip_value(&ContextChipKind::GitDiffStats, Some(new_diff_stats)); } } /// Reads PR info from the per-repo `GitHubRepoModel` and updates the /// `GithubPullRequest` chip value if it differs from the current one. fn sync_pr_chip_from_model(&mut self, ctx: &AppContext) { let new_pr_value = self .github_repo_model .as_ref() .and_then(|w| w.upgrade(ctx)) .and_then(|h| { h.as_ref(ctx) .pr_info(ctx) .map(|info| ChipValue::Text(info.url.clone())) }); let current_pr = self .latest_chip_value(&ContextChipKind::GithubPullRequest) .cloned(); if current_pr != new_pr_value { self.update_chip_value(&ContextChipKind::GithubPullRequest, new_pr_value); } } /// Returns `true` when the given chip's value is updated externally /// (e.g. by a filesystem watcher) and the periodic timer should be skipped. fn is_updated_externally(&self, chip_kind: &ContextChipKind) -> bool { match chip_kind { ContextChipKind::ShellGitBranch | ContextChipKind::GitBranchStatus | ContextChipKind::GitDiffStats => self.git_repo_status.is_some(), ContextChipKind::GithubPullRequest => self.github_repo_model.is_some(), _ => false, } } /// Whether or not context chips are active. If this is false, we can skip running them. fn active(&self, ctx: &AppContext) -> bool { // Context chips are active when: // 1. PS1 is not honored (normal case), OR // 2. Universal developer input is enabled (overrides PS1 behavior), OR // 3. AgentView feature is enabled (agent view needs chips regardless of PS1) !*SessionSettings::as_ref(ctx).honor_ps1 || InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx) || FeatureFlag::AgentView.is_enabled() } } impl Entity for CurrentPrompt { type Event = (); }