first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+211 -97
View File
@@ -4,74 +4,67 @@
//! 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 base64::Engine;
use galaxyui::clipboard::{ClipboardContent, ImageData};
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 warpify_footer;
pub use crate::terminal::CLIAgent;
use warpify_footer::{WarpifyFooterView, WarpifyFooterViewEvent};
use std::path::Path;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use galaxyui::r#async::Timer;
use crate::code_review::diff_state::GitDeltaPreference;
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
use anyhow::anyhow;
use galaxy_core::{
features::FeatureFlag,
report_error, send_telemetry_from_ctx,
settings::Setting,
ui::{
appearance::Appearance,
color::contrast::{
high_enough_contrast, pick_best_foreground_color, MinimumAllowedContrast,
},
theme::{color::internal_colors, Fill as ThemeFill},
},
};
use parking_lot::FairMutex;
use pathfinder_color::ColorU;
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 warpify_footer::{WarpifyFooterView, WarpifyFooterViewEvent};
use galaxyui::elements::{
ChildView, Container, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize, ParentElement,
};
use galaxyui::keymap::Keystroke;
use galaxyui::r#async::Timer;
use galaxyui::{
elements::{
ChildView, Container, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize,
ParentElement,
},
keymap::Keystroke,
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use crate::{
ai::blocklist::{agent_view::agent_view_bg_fill, block::cli_controller::CLISubagentEvent},
cmd_or_ctrl_shift,
server::telemetry::{CLIAgentType, CLISubagentControlState, TelemetryEvent},
settings::{
AISettings, AISettingsChangedEvent, CompiledCommandsForCodingAgentToolbar,
InputModeSettings,
},
terminal::cli_agent_sessions::CLIAgentRichInputCloseReason,
terminal::{
model_events::{ModelEvent, ModelEventDispatcher},
TerminalModel,
},
ui_components::{blended_colors, icons::Icon},
view_components::action_button::{
ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, TooltipAlignment,
},
};
use galaxy_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START};
use super::{RichContentInsertionPosition, TerminalAction, TerminalView};
use crate::terminal::view::block_banner::WarpificationMode;
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`
@@ -94,6 +87,18 @@ const CLI_AGENT_IMAGE_PASTE_DELAY: Duration = Duration::from_millis(300);
#[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<u8> {
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 {
@@ -125,9 +130,14 @@ fn rich_input_submit_strategy(agent: CLIAgent) -> RichInputSubmitStrategy {
| CLIAgent::Gemini
| CLIAgent::Auggie
| CLIAgent::CursorCli => RichInputSubmitStrategy::DelayedEnter,
CLIAgent::Amp | CLIAgent::Droid | CLIAgent::Pi | CLIAgent::Unknown => {
RichInputSubmitStrategy::Inline
}
CLIAgent::Amp
| CLIAgent::Droid
| CLIAgent::Pi
| CLIAgent::Goose
| CLIAgent::Hermes
| CLIAgent::Vibe
| CLIAgent::Antigravity
| CLIAgent::Unknown => RichInputSubmitStrategy::Inline,
}
}
@@ -229,6 +239,24 @@ impl TerminalView {
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);
@@ -239,20 +267,11 @@ impl TerminalView {
UseAgentToolbarEvent::HideRichInput => {
self.close_cli_agent_rich_input_and_disable_auto_toggle(ctx);
}
UseAgentToolbarEvent::Warpify { mode } => {
UseAgentToolbarEvent::Warpify => {
self.hide_use_agent_footer_in_blocklist(ctx);
match mode {
WarpificationMode::Ssh { .. } => {
self.handle_action(&TerminalAction::WarpifySSHSession, ctx);
}
WarpificationMode::Subshell { .. } => {
self.handle_action(&TerminalAction::TriggerSubshellBootstrap, ctx);
}
}
self.handle_action(&TerminalAction::TriggerSubshellBootstrap, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::WarpifyFooterAcceptedWarpify {
is_ssh: mode.is_ssh()
},
TelemetryEvent::WarpifyFooterAcceptedWarpify { is_ssh: false },
ctx
);
}
@@ -276,13 +295,8 @@ impl TerminalView {
) -> bool {
let ai_settings = AISettings::as_ref(app);
// If a warpify mode is set, that means ssh or subshell is detected and we should show the footer.
if self
.use_agent_footer
.as_ref(app)
.warpify_mode(app)
.is_some()
{
// If the warpify footer is active, a subshell was detected and we should show the footer.
if self.use_agent_footer.as_ref(app).is_warpify_active(app) {
return true;
}
@@ -407,7 +421,7 @@ impl TerminalView {
if !self.model.lock().is_alt_screen_active() {
self.use_agent_footer.update(ctx, |footer, ctx| {
footer.clear_warpify_mode(ctx);
footer.clear_warpify(ctx);
});
self.hide_use_agent_footer_in_blocklist(ctx);
}
@@ -579,14 +593,14 @@ impl TerminalView {
}
/// Conditionally closes CLI agent rich input after a prompt submission.
/// When auto-toggle is active with a plugin listener, rich input stays
/// open (status-change events manage visibility instead).
/// Otherwise, respects the auto-dismiss-after-submit setting.
/// 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<Self>) {
let session = CLIAgentSessionsModel::as_ref(ctx).session(self.view_id);
let has_plugin = session
.as_ref()
.is_some_and(|s| s.listener.is_some() && s.should_auto_toggle_input);
.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 {
@@ -776,7 +790,7 @@ impl TerminalView {
}]),
..Default::default()
});
me.write_user_bytes_to_pty(vec![0x16], ctx);
me.write_user_bytes_to_pty(cli_agent_paste_keystroke_bytes(), ctx);
true
})
.await;
@@ -800,6 +814,110 @@ impl TerminalView {
);
}
/// 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<String>,
ctx: &mut ViewContext<Self>,
) {
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
@@ -1074,8 +1192,8 @@ impl UseAgentToolbar {
ctx: &mut ViewContext<Self>,
) {
match event {
WarpifyFooterViewEvent::Warpify { mode } => {
ctx.emit(UseAgentToolbarEvent::Warpify { mode: mode.clone() });
WarpifyFooterViewEvent::Warpify => {
ctx.emit(UseAgentToolbarEvent::Warpify);
}
WarpifyFooterViewEvent::UseAgent => {
ctx.emit(UseAgentToolbarEvent::UseAgent);
@@ -1109,30 +1227,26 @@ impl UseAgentToolbar {
.map(|session| session.agent)
}
/// Sets the current warpification mode. When set, the footer shows the
/// Activates the warpify footer. When active, the footer shows the
/// warpify view instead of the CLI agent or regular "Use agent" views.
pub(in crate::terminal) fn set_warpify_mode(
&mut self,
mode: WarpificationMode,
ctx: &mut ViewContext<Self>,
) {
pub(in crate::terminal) fn show_warpify(&mut self, ctx: &mut ViewContext<Self>) {
self.warpify_footer_view.update(ctx, |view, ctx| {
view.set_mode(mode, ctx);
view.show(ctx);
});
ctx.notify();
}
/// Clears the warpification mode so the footer reverts to its default behavior.
pub(in crate::terminal) fn clear_warpify_mode(&mut self, ctx: &mut ViewContext<Self>) {
/// Deactivates the warpify footer so it reverts to its default behavior.
pub(in crate::terminal) fn clear_warpify(&mut self, ctx: &mut ViewContext<Self>) {
self.warpify_footer_view.update(ctx, |view, ctx| {
view.clear_mode(ctx);
view.clear(ctx);
});
ctx.notify();
}
/// Returns the current warpification mode, if set.
pub(in crate::terminal) fn warpify_mode(&self, app: &AppContext) -> Option<WarpificationMode> {
self.warpify_footer_view.as_ref(app).mode().cloned()
/// Returns whether the warpify footer is currently active.
pub(in crate::terminal) fn is_warpify_active(&self, app: &AppContext) -> bool {
self.warpify_footer_view.as_ref(app).is_active()
}
/// Returns whether there's a current CLI agent (like Claude Code).
@@ -1158,8 +1272,8 @@ pub enum UseAgentToolbarEvent {
OpenRichInput,
/// Hide the rich input editor (same as Escape).
HideRichInput,
/// User chose to warpify the subshell/SSH session.
Warpify { mode: WarpificationMode },
/// User chose to warpify the subshell.
Warpify,
/// User chose to use the agent.
UseAgent,
}
@@ -1174,8 +1288,8 @@ impl View for UseAgentToolbar {
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
// If a warpify mode is set, delegate rendering to the warpify footer view.
if self.warpify_footer_view.as_ref(app).mode().is_some() {
// If the warpify footer is active, delegate rendering to the warpify footer view.
if self.warpify_footer_view.as_ref(app).is_active() {
return ChildView::new(&self.warpify_footer_view).finish();
}
@@ -1372,5 +1486,5 @@ impl ActionButtonTheme for AgentFooterButtonTheme {
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;