//! Footer bar for "Use agent" functionality during long-running commands. //! //! This module provides a footer that appears at the bottom of active long running blocks, //! offering users the option to bring in the agent. For CLI agent commands (e.g., Claude Code, //! Gemini CLI, Codex), it displays a specialized footer with additional functionality. use base64::Engine; use warpui::clipboard::{ClipboardContent, ImageData}; use crate::ai::agent::ImageContext; use crate::ai::blocklist::agent_view::agent_input_footer::{ AgentInputFooter, AgentInputFooterEvent, }; use crate::terminal::cli_agent_sessions::{CLIAgentInputEntrypoint, CLIAgentSessionsModel}; use crate::terminal::shared_session::{ SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, }; use crate::util::image::{infer_mime_type, MAX_IMAGE_SIZE_BYTES_FOR_CLI_AGENT, MIME_SNIFF_BYTES}; mod wormhole_footer; use std::path::Path; use std::sync::{Arc, LazyLock}; use std::time::Duration; use anyhow::anyhow; use galaxy_core::features::FeatureFlag; use galaxy_core::settings::Setting; use galaxy_core::ui::appearance::Appearance; use galaxy_core::ui::color::contrast::{ high_enough_contrast, pick_best_foreground_color, MinimumAllowedContrast, }; use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::Fill as ThemeFill; use galaxy_core::{report_error, send_telemetry_from_ctx}; use galaxy_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START}; use galaxyui::elements::{ ChildView, Container, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize, ParentElement, }; use galaxyui::keymap::Keystroke; use galaxyui::r#async::Timer; use galaxyui::{ AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use parking_lot::FairMutex; use pathfinder_color::ColorU; use wormhole_footer::{WormholeFooterView, WormholeFooterViewEvent}; use super::{RichContentInsertionPosition, TerminalAction, TerminalView}; use crate::ai::blocklist::agent_view::agent_view_bg_fill; use crate::ai::blocklist::block::cli_controller::CLISubagentEvent; use crate::cmd_or_ctrl_shift; use crate::code_review::diff_state::GitDeltaPreference; use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; use crate::server::telemetry::{CLIAgentType, CLISubagentControlState, TelemetryEvent}; use crate::settings::{ AISettings, AISettingsChangedEvent, CompiledCommandsForCodingAgentToolbar, InputModeSettings, }; use crate::terminal::cli_agent_sessions::CLIAgentRichInputCloseReason; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; pub use crate::terminal::CLIAgent; use crate::terminal::TerminalModel; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, TooltipAlignment, }; /// Small delay inserted between separate PTY writes to CLI agents. /// (Used both for the mode-switch prefix split and for the `DelayedEnter` /// submit strategy so each write is delivered as a distinct stdin read.) const CLI_AGENT_PTY_WRITE_DELAY: Duration = Duration::from_millis(50); /// Longer delay for agents (like Copilot) that need extra time after a /// bracketed paste before they will accept a submit keystroke. const CLI_AGENT_BRACKETED_PASTE_ENTER_DELAY: Duration = Duration::from_millis(300); /// Longer delay between clipboard image pastes (Ctrl+V) to CLI agents. /// The CLI agent needs time to read from the system clipboard before /// we overwrite it with the next image. const CLI_AGENT_IMAGE_PASTE_DELAY: Duration = Duration::from_millis(300); /// ASCII prefixes that CLI agents use to switch input modes (e.g. `!` for bash /// mode in Claude Code). When the rich input starts with one of these, the /// prefix byte is written to the PTY separately so the agent can process it /// before the rest of the command arrives. #[allow(clippy::byte_char_slices)] const CLI_AGENT_MODE_SWITCH_PREFIXES: &[u8] = &[b'!', b'&']; /// Bytes that simulate a "paste image from clipboard" keystroke for the /// foreground CLI agent. `0x16` is `Ctrl+V` (SYN); on Windows Claude Code /// listens for `Alt+V` (`ESC` + `'v'`) instead. Mirrored from the equivalent /// branch in `TerminalView::paste`. fn cli_agent_paste_keystroke_bytes() -> Vec { if cfg!(windows) { vec![0x1b, b'v'] } else { vec![0x16] } } /// How rich input delivers text + Enter to the CLI agent's PTY. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RichInputSubmitStrategy { /// Send text bytes followed by `\r` in a single write. /// Works for agents whose input layer processes the carriage return as /// a submit even when it arrives in the same buffer as the preceding text. Inline, /// Wrap text in bracketed paste escape sequences, then send `\r` separately. /// Required for agents like Codex whose paste-burst heuristics would /// otherwise suppress a rapid Enter after a character stream. BracketedPaste, /// Send text first, then `\r` after a short delay. /// For agents that don't respond to `\r` when it arrives in the same /// buffer as the text and don't support bracketed paste reliably. DelayedEnter, /// Wrap text in bracketed paste (reliable buffer insertion), then send /// `\r` after a delay. For agents like Copilot that need bracketed paste /// for reliable text delivery but also need a separate delayed Enter. BracketedPasteDelayedEnter, } /// Returns the strategy for submitting rich input text to a CLI agent's PTY. fn rich_input_submit_strategy(agent: CLIAgent) -> RichInputSubmitStrategy { match agent { CLIAgent::Codex => RichInputSubmitStrategy::BracketedPaste, CLIAgent::Copilot => RichInputSubmitStrategy::BracketedPasteDelayedEnter, CLIAgent::Claude | CLIAgent::OpenCode | CLIAgent::Gemini | CLIAgent::Auggie | CLIAgent::CursorCli => RichInputSubmitStrategy::DelayedEnter, CLIAgent::Amp | CLIAgent::Droid | CLIAgent::Pi | CLIAgent::Goose | CLIAgent::Hermes | CLIAgent::Vibe | CLIAgent::Antigravity | CLIAgent::Unknown => RichInputSubmitStrategy::Inline, } } static USE_AGENT_KEYSTROKE: LazyLock = LazyLock::new(|| Keystroke::parse(cmd_or_ctrl_shift("enter")).expect("valid keystroke")); impl TerminalView { pub(super) fn register_subscriptions_for_use_agent_footer( &mut self, ctx: &mut ViewContext, ) { let ai_settings = AISettings::handle(ctx); ctx.subscribe_to_model(&ai_settings, |me, _, event, ctx| match event { AISettingsChangedEvent::IsAnyAIEnabled { .. } | AISettingsChangedEvent::ShouldRenderCLIAgentToolbar { .. } => { me.maybe_show_use_agent_footer_in_blocklist(ctx); } AISettingsChangedEvent::ShouldRenderUseAgentToolbarForUserCommands { .. } => { // When the setting is re-enabled (e.g. from the AI settings page), // reset the pane-scoped dismissal so the footer can reappear. if *AISettings::as_ref(ctx) .should_render_use_agent_footer_for_user_commands .value() { me.use_agent_footer.update(ctx, |footer, _| { footer.did_user_dismiss = false; }); } me.maybe_show_use_agent_footer_in_blocklist(ctx); } AISettingsChangedEvent::CLIAgentToolbarEnabledCommands { .. } => { me.maybe_show_use_agent_footer_in_blocklist(ctx); } _ => (), }); ctx.subscribe_to_view(&self.use_agent_footer, |me, _, event, ctx| { me.handle_use_agent_footer_event(event, ctx); }); let input_mode_settings = InputModeSettings::handle(ctx); let mut was_pinned_to_top = input_mode_settings .as_ref(ctx) .input_mode .is_pinned_to_top(); ctx.subscribe_to_model(&input_mode_settings, move |me, settings_handle, _, ctx| { let is_pinned_to_top = settings_handle.as_ref(ctx).is_pinned_to_top(); if was_pinned_to_top != is_pinned_to_top { was_pinned_to_top = is_pinned_to_top; me.maybe_show_use_agent_footer_in_blocklist(ctx); } }); ctx.subscribe_to_model( &self.cli_subagent_controller, |me, _, event, ctx| match event { CLISubagentEvent::SpawnedSubagent { .. } => { me.hide_use_agent_footer_in_blocklist(ctx); } CLISubagentEvent::UpdatedControl { .. } => { me.maybe_show_use_agent_footer_in_blocklist(ctx); } _ => (), }, ); } fn handle_use_agent_footer_event( &mut self, event: &UseAgentToolbarEvent, ctx: &mut ViewContext, ) { match event { UseAgentToolbarEvent::Dismiss => { self.hide_use_agent_footer_in_blocklist(ctx); send_telemetry_from_ctx!(TelemetryEvent::AgentToolbarDismissed, ctx); ctx.notify(); } UseAgentToolbarEvent::WriteToPty(text) => { // Route like user-typed terminal input so shared-session viewers // forward the write request to the sharer instead of only // emitting a local PTY write event. self.write_user_bytes_to_pty(text.as_bytes().to_vec(), ctx); } UseAgentToolbarEvent::InsertIntoRichInput(text) => { self.input.update(ctx, |input, ctx| { input.insert_into_cli_agent_rich_input(text, ctx); }); } UseAgentToolbarEvent::ToggleCodeReviewPane(cli_agent) => { self.toggle_code_review_pane( GitDeltaPreference::Always, CodeReviewPaneEntrypoint::CLIAgentView, Some(*cli_agent), true, // focus_new_pane ctx, ); } UseAgentToolbarEvent::ToggleFileExplorer(cli_agent) => { self.toggle_file_tree(Some((*cli_agent).into()), ctx); } UseAgentToolbarEvent::StartRemoteControl { scrollback_type } => { self.auto_stop_sharing_on_cli_end = *scrollback_type == SharedSessionScrollbackType::None; let source = SharedSessionSource::user( self.active_conversation_task_id(ctx).map(|t| t.to_string()), ); self.attempt_to_share_session( *scrollback_type, Some(SharedSessionActionSource::FooterChip), source, true, ctx, ); } UseAgentToolbarEvent::StopRemoteControl => { self.auto_stop_sharing_on_cli_end = false; self.stop_sharing_session(SharedSessionActionSource::FooterChip, ctx); } UseAgentToolbarEvent::OpenRichInput => { if self.has_active_cli_agent_input_session(ctx) { self.close_cli_agent_rich_input_and_disable_auto_toggle(ctx); } else { self.open_cli_agent_rich_input(CLIAgentInputEntrypoint::FooterButton, ctx); } } UseAgentToolbarEvent::HideRichInput => { self.close_cli_agent_rich_input_and_disable_auto_toggle(ctx); } UseAgentToolbarEvent::Wormhole => { self.hide_use_agent_footer_in_blocklist(ctx); self.handle_action(&TerminalAction::TriggerSubshellBootstrap, ctx); send_telemetry_from_ctx!( TelemetryEvent::WormholeFooterAcceptedWormhole { is_ssh: false }, ctx ); } UseAgentToolbarEvent::UseAgent => { self.hide_use_agent_footer_in_blocklist(ctx); self.handle_action(&TerminalAction::SetInputModeAgent, ctx); } } } pub(super) fn has_active_cli_agent_input_session(&self, app: &AppContext) -> bool { CLIAgentSessionsModel::as_ref(app).is_input_open(self.view_id) } /// Checks if the footer should be rendered. /// Reads the CLI agent from the sessions model (single source of truth). pub(super) fn should_render_use_agent_footer( &self, model: &TerminalModel, app: &AppContext, ) -> bool { let ai_settings = AISettings::as_ref(app); // If the wormhole footer is active, a subshell was detected and we should show the footer. if self.use_agent_footer.as_ref(app).is_wormhole_active(app) { return true; } let active_block = model.block_list().active_block(); let cli_agent = CLIAgentSessionsModel::as_ref(app) .session(self.view_id) .map(|s| s.agent); // Check the appropriate setting based on whether this is a CLI agent command if cli_agent.is_some() { // For CLI agent commands, only check the CLI agent footer setting. // This is independent of the global AI toggle so that users who // disable Warp AI still get the footer for third-party coding agents. if !*ai_settings.should_render_cli_agent_footer { return false; } // If a CLIAgent is active, we always want to show the agent footer. return true; } // All other footer variants require the global AI setting to be on. if !ai_settings.is_any_ai_enabled(app) { return false; } if !active_block.is_eligible_for_agent_handoff() { // For regular commands (not agent handoff), check the "Use Agent" footer setting. // Agent handoff blocks always show the footer regardless of this setting. let is_user_command = active_block.requested_command_action_id().is_none(); if is_user_command && (self.use_agent_footer.as_ref(app).did_user_dismiss() || !*ai_settings.should_render_use_agent_footer_for_user_commands) { return false; } } // Don't show the use agent footer during LRCs in setup phase of ambient agent sessions. let is_shared_ambient_session = model.is_shared_ambient_agent_session(); !self.is_input_box_visible(model, app) && ((active_block.is_eligible_to_tag_in_agent() && !is_shared_ambient_session) || active_block.is_eligible_for_agent_handoff()) } /// Returns the detected CLI agent for the active block's command, if any. /// /// This method resolves aliases before detecting the CLI agent. For example, /// if a user has aliased `foo` to `claude`, running `foo` will detect Claude. /// Falls back to user-configured toolbar command patterns, returning the /// assigned agent (or `CLIAgent::Unknown` for unassigned patterns). /// /// The second tuple element is the custom command prefix (the first word of /// the command), present only when the agent was resolved via a custom /// toolbar command pattern rather than native detection. pub(super) fn detect_cli_agent_from_model( &self, model: &TerminalModel, ctx: &AppContext, ) -> Option<(CLIAgent, Option)> { let active_block = model.block_list().active_block(); if !active_block.is_active_and_long_running() { return None; } let command = active_block.command_with_secrets_obfuscated(false); let detected = self.active_block_session_id().and_then(|session_id| { self.sessions.read(ctx, |sessions, _| { let session = sessions.get(session_id)?; CLIAgent::detect( &command, Some(session.shell_family().escape_char()), Some(session.aliases()), ctx, ) }) }); if let Some(agent) = detected { return Some((agent, None)); } CompiledCommandsForCodingAgentToolbar::matched_agent(ctx, &command).map(|agent| { let prefix = command.split_whitespace().next().map(str::to_owned); (agent, prefix) }) } /// Updates the UI during a long running command to agent "tagged-in state". /// /// An agent may be "tagged in" during a _user-executed_ long running command, where being /// 'tagged in' means the input is visible and locked in agent mode, presumably awaiting user /// submission of a prompt for the agent to interact with the command. pub(super) fn tag_in_agent_for_user_long_running_command( &mut self, ctx: &mut ViewContext, ) { if self .model .lock() .block_list() .active_block() .is_agent_tagged_in() || !self .model .lock() .block_list() .active_block() .is_eligible_to_tag_in_agent() { return; } self.model .lock() .block_list_mut() .active_block_mut() .set_is_agent_tagged_in(true); if !self.model.lock().is_alt_screen_active() { self.use_agent_footer.update(ctx, |footer, ctx| { footer.clear_wormhole(ctx); }); self.hide_use_agent_footer_in_blocklist(ctx); } self.input.update(ctx, |input, ctx| { input.set_input_mode_agent(true, ctx); input.clear_buffer_and_reset_undo_stack(ctx); }); ctx.notify(); let model = self.model.lock(); let active_block = model.block_list().active_block(); let conversation_id = active_block.ai_conversation_id(); let block_id = active_block.id().clone(); send_telemetry_from_ctx!( TelemetryEvent::CLISubagentControlStateChanged { conversation_id, block_id, control_state: CLISubagentControlState::AgentTaggedIn, }, ctx ); } /// Tags the agent "out". See docs on `tag_in_agent_for_user_long_running_command` for /// 'tagged-in' semantics. /// /// Hides the agent input and re-shows the 'Use agent' footer at the bottom of the block. pub(super) fn tag_out_agent_for_user_long_running_command( &mut self, ctx: &mut ViewContext, ) { if !self .model .lock() .block_list() .active_block() .is_agent_tagged_in() { return; } self.model .lock() .block_list_mut() .active_block_mut() .set_is_agent_tagged_in(false); if !self.model.lock().is_alt_screen_active() { self.maybe_show_use_agent_footer_in_blocklist(ctx); } self.input.update(ctx, |input, ctx| { input.set_input_mode_terminal(false, ctx); }); self.redetermine_terminal_focus(ctx); ctx.notify(); let model = self.model.lock(); let active_block = model.block_list().active_block(); let conversation_id = active_block.ai_conversation_id(); let block_id = active_block.id().clone(); send_telemetry_from_ctx!( TelemetryEvent::CLISubagentControlStateChanged { conversation_id, block_id, control_state: CLISubagentControlState::AgentTaggedOut, }, ctx ); } pub(super) fn maybe_show_use_agent_footer_in_blocklist(&mut self, ctx: &mut ViewContext) { // This is a bit of a hack- but it ensures we never show more than one footer in the // blocklist. self.hide_use_agent_footer_in_blocklist(ctx); let (should_render_footer, is_alt_screen_active) = { let model = self.model.lock(); ( self.should_render_use_agent_footer(&model, ctx), model.is_alt_screen_active(), ) }; if is_alt_screen_active || !should_render_footer { return; } let should_insert_after_block = !InputModeSettings::as_ref(ctx).is_pinned_to_top(); // Send telemetry when showing CLI agent footer if let Some(session) = CLIAgentSessionsModel::as_ref(ctx).session(self.view_id) { let cli_agent_type: CLIAgentType = session.agent.into(); send_telemetry_from_ctx!( TelemetryEvent::CLIAgentToolbarShown { cli_agent: cli_agent_type, }, ctx ); } self.insert_rich_content( None, self.use_agent_footer.clone(), None, RichContentInsertionPosition::Append { insert_below_long_running_block: should_insert_after_block, }, ctx, ); } pub(super) fn hide_use_agent_footer_in_blocklist(&mut self, ctx: &mut ViewContext) { let mut model = self.model.lock(); let block_list = model.block_list_mut(); block_list.remove_rich_content(self.use_agent_footer.id()); ctx.notify(); } /// Closes the CLI agent rich input session. Side effects (input config restore, /// buffer clear, hint text) are handled reactively by subscribers to /// `CLIAgentSessionsModelEvent::InputSessionChanged`. pub(in crate::terminal) fn close_cli_agent_rich_input( &mut self, reason: CLIAgentRichInputCloseReason, ctx: &mut ViewContext, ) { self.close_cli_agent_rich_input_impl(true, reason, ctx); } pub(in crate::terminal) fn close_cli_agent_rich_input_and_disable_auto_toggle( &mut self, ctx: &mut ViewContext, ) { self.close_cli_agent_rich_input_impl(false, CLIAgentRichInputCloseReason::Manual, ctx); } fn close_cli_agent_rich_input_impl( &mut self, should_auto_toggle_input: bool, reason: CLIAgentRichInputCloseReason, ctx: &mut ViewContext, ) { if !self.has_active_cli_agent_input_session(ctx) { return; } // Save the current buffer text as a draft before closing, so it can // be restored if the user reopens the composer. let draft = self.input.as_ref(ctx).buffer_text(ctx); let view_id = self.view_id; CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { sessions_model.set_draft(view_id, draft); sessions_model.close_input(view_id, should_auto_toggle_input, ctx); }); let cli_agent_type: Option = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .map(|s| s.agent.into()); if let Some(cli_agent) = cli_agent_type { send_telemetry_from_ctx!( TelemetryEvent::CLIAgentRichInputClosed { cli_agent, reason }, ctx ); } self.redetermine_terminal_focus(ctx); ctx.notify(); } /// Conditionally closes CLI agent rich input after a prompt submission. /// When auto-toggle is active with a plugin listener that emits rich /// status, rich input stays open (status-change events manage visibility /// instead). Otherwise, respects the auto-dismiss-after-submit setting. fn maybe_close_rich_input_after_submit(&mut self, ctx: &mut ViewContext) { let session = CLIAgentSessionsModel::as_ref(ctx).session(self.view_id); let has_plugin = session .as_ref() .is_some_and(|s| s.supports_rich_status() && s.should_auto_toggle_input); let ai_settings = AISettings::as_ref(ctx); let should_close = if has_plugin && *ai_settings.auto_toggle_rich_input { false } else { *ai_settings.auto_dismiss_rich_input_after_submit }; if should_close { self.close_cli_agent_rich_input(CLIAgentRichInputCloseReason::Submit, ctx); } else { self.input.update(ctx, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); }); } } pub(super) fn submit_cli_agent_rich_input( &mut self, text: String, ctx: &mut ViewContext, ) { if !self.has_active_cli_agent_input_session(ctx) { return; } if text.trim().is_empty() { return; } let prompt_length = text.chars().count(); let cli_agent: Option = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .map(|s| s.agent.into()); if let Some(cli_agent) = cli_agent { send_telemetry_from_ctx!( TelemetryEvent::CLIAgentRichInputSubmitted { cli_agent, prompt_length, }, ctx ); } // Clear any saved draft so submitted text isn't restored on the next open. let view_id = self.view_id; CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, _| { sessions_model.clear_draft(view_id); }); let strategy = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .map(|s| rich_input_submit_strategy(s.agent)) .unwrap_or(RichInputSubmitStrategy::Inline); let text_bytes = text.into_bytes(); // Clear the buffer eagerly so that any close path (auto-dismiss, // auto-toggle, or a deferred timer) sees an empty buffer and doesn't // re-save the submitted text as a draft. self.input.update(ctx, |input, ctx| { input.clear_buffer_and_reset_undo_stack(ctx); }); // Extract pending image attachments and clear them from the context // model before submission. let images: Vec<_> = self .ai_context_model .as_ref(ctx) .pending_images() .into_iter() .cloned() .collect(); if !images.is_empty() { self.ai_context_model.update(ctx, |model, ctx| { model.clear_pending_images(ctx); }); } // When the input starts with a known mode-switch prefix (e.g. `!` for // bash mode, `&` for background mode), write the prefix byte separately // with a small delay before the rest of the command. This gives CLI // agents like Claude Code time to recognise the prefix and switch modes // before the command text arrives. // // Only applied to known ASCII prefixes to avoid splitting multi-byte // UTF-8 characters. if text_bytes.len() > 1 && CLI_AGENT_MODE_SWITCH_PREFIXES.contains(&text_bytes[0]) { self.write_user_bytes_to_pty(vec![text_bytes[0]], ctx); let rest = text_bytes[1..].to_vec(); ctx.spawn( Timer::after(CLI_AGENT_PTY_WRITE_DELAY), move |me, _, ctx| { me.paste_images_then_submit_text(images, rest, strategy, ctx); }, ); } else { self.paste_images_then_submit_text(images, text_bytes, strategy, ctx); } } /// Submits `text` as a prompt to the active CLI agent on this terminal by /// writing it to the PTY using the agent-specific submission strategy /// (the same pipeline as the CLI agent rich input composer). /// /// Intended for callers that produce prompts outside the rich input /// editor (e.g. shared-session viewer follow-up prompts). Returns /// without writing if there is no active CLI agent session or the text /// is empty. #[cfg(feature = "local_tty")] pub(crate) fn submit_text_to_cli_agent_pty( &mut self, text: String, ctx: &mut ViewContext, ) { let Some(agent) = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .map(|s| s.agent) else { return; }; let text_bytes = text.into_bytes(); if text_bytes.is_empty() { return; } let strategy = rich_input_submit_strategy(agent); self.write_cli_agent_text_then_submit(text_bytes, strategy, ctx); } /// Simulates clipboard image paste for each pending image attachment by /// writing the image to the system clipboard and sending Ctrl+V to the PTY. /// After all images are pasted, the text prompt is sent via the normal /// submission strategy. /// /// Uses a single async task that hops back to the view context via /// [`ViewSpawner`] for each image, rather than chaining per-image timers. /// If the rich input session closes mid-paste, the loop exits early so we /// don't leak Ctrl+V bytes into an unrelated PTY context. fn paste_images_then_submit_text( &mut self, images: Vec, text_bytes: Vec, strategy: RichInputSubmitStrategy, ctx: &mut ViewContext, ) { // Bail if the rich input session was closed before we got here. if !self.has_active_cli_agent_input_session(ctx) { return; } if images.is_empty() { self.write_cli_agent_text_then_submit(text_bytes, strategy, ctx); return; } let spawner = ctx.spawner(); ctx.spawn( async move { for image in images { // Decode off the main thread; log and skip on failure. let raw_bytes = match base64::engine::general_purpose::STANDARD.decode(&image.data) { Ok(bytes) => bytes, Err(_) => { log::error!( "Failed to decode base64 image data for {}", image.file_name ); continue; } }; // Hop back to the view to write the clipboard + Ctrl+V. // Returns false if the input session has closed, in which // case we stop pasting and skip the final text submit. let should_continue = spawner .spawn(move |me, ctx| { if !me.has_active_cli_agent_input_session(ctx) { return false; } ctx.clipboard().write(ClipboardContent { images: Some(vec![ImageData { data: raw_bytes, mime_type: image.mime_type, filename: Some(image.file_name), }]), ..Default::default() }); me.write_user_bytes_to_pty(cli_agent_paste_keystroke_bytes(), ctx); true }) .await; if !matches!(should_continue, Ok(true)) { return false; } // Give the CLI agent time to read from the clipboard before // we overwrite it with the next image (or send the text). Timer::after(CLI_AGENT_IMAGE_PASTE_DELAY).await; } true }, move |me, ok, ctx| { if !ok || !me.has_active_cli_agent_input_session(ctx) { return; } me.write_cli_agent_text_then_submit(text_bytes, strategy, ctx); }, ); } /// Mirrors the CLI-agent Cmd+V image-paste path in `TerminalView::paste` /// for dropped image files: reads each file, writes its bytes to the /// system clipboard as image data, and sends the agent's paste keystroke /// to the PTY so the agent reads the image directly. This produces the /// same outcome as if the user had copied the image to their clipboard /// and pressed Cmd+V over the agent's TUI. pub(super) fn paste_dropped_images_to_cli_agent( &mut self, image_filepaths: Vec, ctx: &mut ViewContext, ) { if image_filepaths.is_empty() { return; } let spawner = ctx.spawner(); ctx.spawn( async move { for path_str in image_filepaths { // Stat first so a multi-GB drop doesn't load into memory // before we reject it. CLI agents handle their own // compression, so the cap only exists to bound memory use. match async_fs::metadata(&path_str).await { Ok(meta) if (meta.len() as usize) > MAX_IMAGE_SIZE_BYTES_FOR_CLI_AGENT => { let filename = Path::new(&path_str) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path_str.clone()); let limit_mb = MAX_IMAGE_SIZE_BYTES_FOR_CLI_AGENT / 1_000_000; let msg = format!( "{filename} is too large to send to the agent (limit {limit_mb}MB)." ); let _ = spawner .spawn(move |me, ctx| { me.show_error_toast(msg, ctx); }) .await; continue; } Ok(_) => {} Err(e) => { log::error!("Failed to stat dropped image {path_str}: {e}"); continue; } } let bytes = match async_fs::read(&path_str).await { Ok(b) => b, Err(e) => { log::error!("Failed to read dropped image {path_str}: {e}"); continue; } }; let path = Path::new(&path_str); let filename = path.file_name().map(|n| n.to_string_lossy().into_owned()); let sniff_len = bytes.len().min(MIME_SNIFF_BYTES); let mime_type = infer_mime_type(path, &bytes[..sniff_len]); // Hop back to the view to write the clipboard + paste // keystroke. Bail if the CLI agent session disappeared, // OR if the agent's long-running block exited while we // were reading off-thread — without that second check // the paste byte would leak into the shell after the // agent quit, since the session entry can outlive its // foreground block. let should_continue = spawner .spawn(move |me, ctx| { if !me.has_active_cli_agent_session(ctx) { return false; } let still_long_running = me .model .lock() .block_list() .active_block() .is_active_and_long_running(); if !still_long_running { return false; } ctx.clipboard().write(ClipboardContent { images: Some(vec![ImageData { data: bytes, mime_type, filename, }]), ..Default::default() }); me.write_user_bytes_to_pty(cli_agent_paste_keystroke_bytes(), ctx); true }) .await; if !matches!(should_continue, Ok(true)) { return; } // Give the CLI agent time to read from the clipboard // before we overwrite it with the next image. Timer::after(CLI_AGENT_IMAGE_PASTE_DELAY).await; } }, |_, _, _| {}, ); } /// Writes the input text to the PTY and then sends a carriage return to /// submit it, using the agent-specific strategy. After the submission is /// complete (synchronously for the inline strategies, after a timer for /// the delayed strategies), closes the rich input if the user's settings /// request auto-dismissal. fn write_cli_agent_text_then_submit( &mut self, text_bytes: Vec, strategy: RichInputSubmitStrategy, ctx: &mut ViewContext, ) { match strategy { RichInputSubmitStrategy::Inline => { let mut bytes = text_bytes; bytes.extend_from_slice(b"\r"); self.write_user_bytes_to_pty(bytes, ctx); self.maybe_close_rich_input_after_submit(ctx); } RichInputSubmitStrategy::BracketedPaste => { let mut bytes = Vec::with_capacity( BRACKETED_PASTE_START.len() + text_bytes.len() + BRACKETED_PASTE_END.len(), ); bytes.extend_from_slice(BRACKETED_PASTE_START); bytes.extend_from_slice(&text_bytes); bytes.extend_from_slice(BRACKETED_PASTE_END); self.write_user_bytes_to_pty(bytes, ctx); self.write_user_bytes_to_pty(b"\r".to_vec(), ctx); self.maybe_close_rich_input_after_submit(ctx); } RichInputSubmitStrategy::DelayedEnter => { self.write_user_bytes_to_pty(text_bytes, ctx); ctx.spawn( Timer::after(CLI_AGENT_PTY_WRITE_DELAY), move |me, _, ctx| { me.write_user_bytes_to_pty(b"\r".to_vec(), ctx); me.maybe_close_rich_input_after_submit(ctx); }, ); } RichInputSubmitStrategy::BracketedPasteDelayedEnter => { let mut bytes = Vec::with_capacity( BRACKETED_PASTE_START.len() + text_bytes.len() + BRACKETED_PASTE_END.len(), ); bytes.extend_from_slice(BRACKETED_PASTE_START); bytes.extend_from_slice(&text_bytes); bytes.extend_from_slice(BRACKETED_PASTE_END); self.write_user_bytes_to_pty(bytes, ctx); ctx.spawn( Timer::after(CLI_AGENT_BRACKETED_PASTE_ENTER_DELAY), move |me, _, ctx| { me.write_user_bytes_to_pty(b"\r".to_vec(), ctx); me.maybe_close_rich_input_after_submit(ctx); }, ); } } } pub(in crate::terminal) fn open_cli_agent_rich_input( &mut self, entrypoint: CLIAgentInputEntrypoint, ctx: &mut ViewContext, ) { if !FeatureFlag::CLIAgentRichInput.is_enabled() || self.has_active_cli_agent_input_session(ctx) { return; } // The Ctrl-G binding and footer button are both gated on an active CLI // agent session, so the session should always exist here. let Some(cli_agent) = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) .map(|session| session.agent) else { return; }; let ai_input_model = self.ai_input_model.as_ref(ctx); let previous_input_config = ai_input_model.input_config(); let previous_was_lock_set_with_empty_buffer = ai_input_model.was_lock_set_with_empty_buffer(); let view_id = self.view_id; CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { sessions_model.open_input( view_id, entrypoint, previous_input_config, previous_was_lock_set_with_empty_buffer, true, ctx, ); }); send_telemetry_from_ctx!( TelemetryEvent::CLIAgentRichInputOpened { cli_agent: cli_agent.into(), entrypoint, }, ctx ); // Input mode switch, buffer clear, draft restoration, and hint text // are handled reactively by Input's subscription to InputSessionChanged. self.redetermine_terminal_focus(ctx); ctx.notify(); } } /// Footer rendered at the bottom of the active long running block or alt screen element. /// /// For regular commands, displays a 'Use agent' keystroke button to enter agent mode. /// For CLI agent commands (e.g., Claude Code, Gemini CLI, Codex), displays a specialized /// footer with image attachment, voice input, file explorer, view changes, and share buttons. pub struct UseAgentToolbar { terminal_view_id: EntityId, terminal_model: Arc>, // Standard "Use agent" UI button: ViewHandle, give_control_back_button: ViewHandle, dismiss_button: ViewHandle, dont_show_again_button: ViewHandle, // Shared agent input footer (renders CLI agent mode when a CLI session is active). agent_input_footer: ViewHandle, // Wormhole footer UI (shown when a subshell/SSH command is detected). wormhole_footer_view: ViewHandle, // `true` if the user has dismissed the footer. // // Footer dismissal is terminal pane-scoped, e.g. dismissal hides the footer for this // specific terminal pane for the lifetime of the pane. did_user_dismiss: bool, } impl UseAgentToolbar { pub(crate) fn new( terminal_view_id: EntityId, terminal_model: Arc>, model_event_dispatcher: &ModelHandle, agent_input_footer: ViewHandle, ctx: &mut ViewContext, ) -> Self { let button_size = ButtonSize::XSmall; let button = ctx.add_typed_action_view(|ctx| { ActionButton::new( "Use agent", AgentFooterButtonTheme::new(Some(terminal_model.clone())), ) .with_icon(Icon::Oz) .with_keybinding(KeystrokeSource::Fixed(USE_AGENT_KEYSTROKE.clone()), ctx) .with_size(button_size) .with_tooltip("Ask the Galaxy agent to assist") .with_tooltip_alignment(TooltipAlignment::Left) .on_click(|ctx| { ctx.dispatch_typed_action(TerminalAction::SetInputModeAgent); }) }); let give_control_back_button = ctx.add_typed_action_view(|ctx| { ActionButton::new( "Give control back to agent", AgentFooterButtonTheme::new(Some(terminal_model.clone())), ) .with_icon(Icon::Oz) .with_keybinding(KeystrokeSource::Fixed(USE_AGENT_KEYSTROKE.clone()), ctx) .with_size(button_size) .with_tooltip("Ask the Galaxy agent to resume") .with_tooltip_alignment(TooltipAlignment::Left) .on_click(|ctx| { ctx.dispatch_typed_action(TerminalAction::SetInputModeAgent); }) }); let dismiss_button = ctx.add_typed_action_view(|_| { ActionButton::new( "Dismiss", AgentFooterButtonTheme::new(Some(terminal_model.clone())), ) .on_click(|ctx| { ctx.dispatch_typed_action(UseAgentToolbarAction::Dismiss { permanently: false }); }) .with_size(button_size) }); let dont_show_again_button = ctx.add_typed_action_view(|_| { ActionButton::new( "Don't show again", AgentFooterButtonTheme::new(Some(terminal_model.clone())), ) .on_click(|ctx| { ctx.dispatch_typed_action(UseAgentToolbarAction::Dismiss { permanently: true }); }) .with_size(button_size) }); // Subscribe to agent input footer events to forward CLI-relevant ones. ctx.subscribe_to_view(&agent_input_footer, |me, _, event, ctx| { me.handle_agent_input_footer_event(event, ctx); }); let wormhole_footer_view = ctx.add_typed_action_view(|ctx| WormholeFooterView::new(terminal_model.clone(), ctx)); ctx.subscribe_to_view(&wormhole_footer_view, |me, _, event, ctx| { me.handle_wormhole_footer_event(event, ctx); }); ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| { if let ModelEvent::TerminalModeSwapped(..) = event { me.notify_and_notify_children(ctx); } }); // Re-render when the CLI agent session state changes (e.g. status updates // from the plugin, session started/ended). let cli_agent_sessions = CLIAgentSessionsModel::handle(ctx); ctx.subscribe_to_model(&cli_agent_sessions, move |me, _, event, ctx| { if event.terminal_view_id() != terminal_view_id { return; } me.notify_and_notify_children(ctx); }); Self { terminal_view_id, button, give_control_back_button, dismiss_button, dont_show_again_button, agent_input_footer, wormhole_footer_view, terminal_model, did_user_dismiss: false, } } fn handle_agent_input_footer_event( &mut self, event: &AgentInputFooterEvent, ctx: &mut ViewContext, ) { // Forward CLI-relevant events from the shared agent input footer. match event { AgentInputFooterEvent::WriteToPty(text) => { ctx.emit(UseAgentToolbarEvent::WriteToPty(text.clone())); } AgentInputFooterEvent::InsertIntoCLIRichInput(text) => { ctx.emit(UseAgentToolbarEvent::InsertIntoRichInput(text.clone())); } AgentInputFooterEvent::ToggleCodeReviewPane(agent) => { ctx.emit(UseAgentToolbarEvent::ToggleCodeReviewPane(*agent)); } AgentInputFooterEvent::ToggleFileExplorer(agent) => { ctx.emit(UseAgentToolbarEvent::ToggleFileExplorer(*agent)); } AgentInputFooterEvent::OpenRichInput => { ctx.emit(UseAgentToolbarEvent::OpenRichInput); } AgentInputFooterEvent::HideRichInput => { ctx.emit(UseAgentToolbarEvent::HideRichInput); } // Non-CLI events are handled by Input's subscription, not here. _ => {} } } fn handle_wormhole_footer_event( &mut self, event: &WormholeFooterViewEvent, ctx: &mut ViewContext, ) { match event { WormholeFooterViewEvent::Wormhole => { ctx.emit(UseAgentToolbarEvent::Wormhole); } WormholeFooterViewEvent::UseAgent => { ctx.emit(UseAgentToolbarEvent::UseAgent); } WormholeFooterViewEvent::Dismiss => { ctx.emit(UseAgentToolbarEvent::Dismiss); } } } pub(in crate::terminal) fn notify_and_notify_children(&mut self, ctx: &mut ViewContext) { ctx.notify(); self.agent_input_footer.update(ctx, |_, ctx| ctx.notify()); self.wormhole_footer_view.update(ctx, |_, ctx| ctx.notify()); self.button.update(ctx, |_, ctx| ctx.notify()); self.give_control_back_button .update(ctx, |_, ctx| ctx.notify()); self.dismiss_button.update(ctx, |_, ctx| ctx.notify()); self.dont_show_again_button .update(ctx, |_, ctx| ctx.notify()); } /// Returns whether the user has dismissed this footer. pub fn did_user_dismiss(&self) -> bool { self.did_user_dismiss } fn cli_agent(&self, app: &AppContext) -> Option { CLIAgentSessionsModel::as_ref(app) .session(self.terminal_view_id) .map(|session| session.agent) } /// Activates the wormhole footer. When active, the footer shows the /// wormhole view instead of the CLI agent or regular "Use agent" views. pub(in crate::terminal) fn show_wormhole(&mut self, ctx: &mut ViewContext) { self.wormhole_footer_view.update(ctx, |view, ctx| { view.show(ctx); }); ctx.notify(); } /// Deactivates the wormhole footer so it reverts to its default behavior. pub(in crate::terminal) fn clear_wormhole(&mut self, ctx: &mut ViewContext) { self.wormhole_footer_view.update(ctx, |view, ctx| { view.clear(ctx); }); ctx.notify(); } /// Returns whether the wormhole footer is currently active. pub(in crate::terminal) fn is_wormhole_active(&self, app: &AppContext) -> bool { self.wormhole_footer_view.as_ref(app).is_active() } /// Returns whether there's a current CLI agent (like Claude Code). #[cfg(feature = "voice_input")] pub fn has_cli_agent(&self, app: &AppContext) -> bool { self.cli_agent(app).is_some() } } /// Events emitted by UseAgentToolbar. pub enum UseAgentToolbarEvent { /// The footer was dismissed. Dismiss, /// Write text to the PTY (from CLI agent view). WriteToPty(String), /// Insert text into CLI agent rich input. InsertIntoRichInput(String), /// Toggle the code review pane (from CLI agent view). ToggleCodeReviewPane(CLIAgent), /// Toggle the file explorer (from CLI agent view). ToggleFileExplorer(CLIAgent), /// Open the rich input editor for composing a prompt. OpenRichInput, /// Hide the rich input editor (same as Escape). HideRichInput, /// User chose to wormhole the subshell. Wormhole, /// User chose to use the agent. UseAgent, StartRemoteControl { scrollback_type: SharedSessionScrollbackType, }, StopRemoteControl, } impl Entity for UseAgentToolbar { type Event = UseAgentToolbarEvent; } impl View for UseAgentToolbar { fn ui_name() -> &'static str { "UseAgentToolbar" } fn render(&self, app: &AppContext) -> Box { // If the wormhole footer is active, delegate rendering to the wormhole footer view. if self.wormhole_footer_view.as_ref(app).is_active() { return ChildView::new(&self.wormhole_footer_view).finish(); } // Hide the toolbar entirely when CLI rich input is open, // since the Input view renders its own footer in that state. if CLIAgentSessionsModel::as_ref(app).is_input_open(self.terminal_view_id) { return Empty::new().finish(); } // If a CLI agent is detected, delegate rendering to the CLI agent footer view. // Wrap with horizontal padding matching the terminal view padding so the footer // aligns consistently with the input context (which inherits terminal padding). if self.cli_agent(app).is_some() { let mut container = Container::new(ChildView::new(&self.agent_input_footer).finish()) .with_horizontal_padding(*super::PADDING_LEFT); // Apply the alt screen background on this outer container so it covers // the horizontal padding area as well, preventing a visible color mismatch // between the padding and the footer content. let terminal_model = self.terminal_model.lock(); if terminal_model.is_alt_screen_active() { if let Some(bg_color) = terminal_model.alt_screen().inferred_bg_color() { container = container.with_background(bg_color); } } return container.finish(); } let terminal_model = self.terminal_model.lock(); let active_block = terminal_model.block_list().active_block(); let show_give_control_back_button = active_block.is_eligible_for_agent_handoff(); let show_dismiss_actions = active_block.requested_command_action_id().is_none(); let mut button_row = Flex::row() .with_spacing(4.) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( ChildView::new(if show_give_control_back_button { &self.give_control_back_button } else { &self.button }) .finish(), ); if show_dismiss_actions { button_row = button_row .with_child(Expanded::new(1., Empty::new().finish()).finish()) .with_child(ChildView::new(&self.dismiss_button).finish()); if !show_give_control_back_button { button_row = button_row.with_child(ChildView::new(&self.dont_show_again_button).finish()); } } let mut container = Container::new(button_row.finish()) .with_horizontal_padding(*super::PADDING_LEFT) .with_vertical_padding(4.); if terminal_model.is_alt_screen_active() { if let Some(bg_color) = terminal_model.alt_screen().inferred_bg_color() { container = container.with_background(bg_color); } } else if terminal_model.block_list().agent_view_state().is_inline() { container = container.with_background(agent_view_bg_fill(app)); } container.finish() } } #[derive(Debug, Clone)] pub enum UseAgentToolbarAction { Dismiss { permanently: bool }, } impl TypedActionView for UseAgentToolbar { type Action = UseAgentToolbarAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { let UseAgentToolbarAction::Dismiss { permanently } = action; self.did_user_dismiss = true; ctx.emit(UseAgentToolbarEvent::Dismiss); if *permanently { AISettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(e) = settings .should_render_use_agent_footer_for_user_commands .set_value(false, ctx) { report_error!(anyhow!("{e:?}") .context("Failed to set `ShouldRenderUseAgentToolbarForUserCommands`")); } }); } ctx.notify(); } } #[derive(Clone)] pub(super) struct AgentFooterButtonTheme { /// When set, enables alt-screen contrast adjustment for text and border. terminal_model: Option>>, } impl AgentFooterButtonTheme { pub fn new(terminal_model: Option>>) -> Self { Self { terminal_model } } /// Returns the inferred background colour of the alt screen, if active. fn inferred_alt_screen_bg(&self) -> Option { let terminal_model = self.terminal_model.as_ref()?; let terminal_model = terminal_model.lock(); terminal_model .is_alt_screen_active() .then(|| terminal_model.alt_screen().inferred_bg_color()) .flatten() } /// Picks a colour that contrasts well against `bg`, choosing between two /// neutral candidates. fn contrast_adjusted_color( bg: ColorU, default: ColorU, contrast: MinimumAllowedContrast, appearance: &Appearance, ) -> ColorU { if high_enough_contrast(default, bg, contrast) { default } else { pick_best_foreground_color( bg, blended_colors::neutral_2(appearance.theme()), blended_colors::neutral_6(appearance.theme()), contrast, ) } } } impl ActionButtonTheme for AgentFooterButtonTheme { fn background(&self, hovered: bool, appearance: &Appearance) -> Option { if hovered { Some(internal_colors::fg_overlay_2(appearance.theme())) } else { None } } fn border(&self, appearance: &Appearance) -> Option { let color = appearance.theme().outline().into_solid(); if let Some(bg) = self.inferred_alt_screen_bg() { return Some(Self::contrast_adjusted_color( bg, color, MinimumAllowedContrast::NonText, appearance, )); } Some(color) } fn text_color( &self, _hovered: bool, _background: Option, appearance: &Appearance, ) -> ColorU { let color = appearance .theme() .sub_text_color(appearance.theme().surface_1()) .into_solid(); // If rendered in the alt screen, the footer is rendered with the inferred background color // of the alt screen output grid (if there is one). In such cases, we have to ensure that // the text within the footer is high-contrast enough to be legible, since the background // color can essentially be anything. if let Some(bg) = self.inferred_alt_screen_bg() { return Self::contrast_adjusted_color( bg, color, MinimumAllowedContrast::Text, appearance, ); } color } } #[cfg(test)] #[path = "mod_tests.rs"] mod tests;