first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user