diff --git a/Cargo.lock b/Cargo.lock index 03b429efab4..04680b01445 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15933,6 +15933,7 @@ dependencies = [ "async-channel", "async-fs", "base64 0.22.1", + "blocking", "channel_versions", "clap", "command", @@ -15950,6 +15951,7 @@ dependencies = [ "pathfinder_geometry", "rangemap", "serde_json", + "shell-words", "similar", "string-offset", "strum 0.27.1", diff --git a/app/src/ai/blocklist/context_model.rs b/app/src/ai/blocklist/context_model.rs index 6a1c1fa4bf3..ba0cb4c3d27 100644 --- a/app/src/ai/blocklist/context_model.rs +++ b/app/src/ai/blocklist/context_model.rs @@ -50,6 +50,14 @@ pub enum AttachmentType { File, } +/// Lightweight metadata for rendering a pending attachment without cloning its payload. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingAttachmentSummary { + pub index: usize, + pub attachment_type: AttachmentType, + pub file_name: String, +} + /// A pending attachment — either an image (base64 in memory) or a file (path reference). #[derive(Clone, Debug)] pub enum PendingAttachment { @@ -278,6 +286,19 @@ impl BlocklistAIContextModel { &self.pending_attachments } + /// Returns lightweight metadata for all pending attachments. + pub fn pending_attachment_summaries(&self) -> Vec { + self.pending_attachments + .iter() + .enumerate() + .map(|(index, attachment)| PendingAttachmentSummary { + index, + attachment_type: attachment.attachment_type(), + file_name: attachment.file_name().to_owned(), + }) + .collect() + } + /// Returns only the pending images for the next query. pub fn pending_images(&self) -> Vec<&ImageContext> { self.pending_attachments diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 88641c44fae..afbcc1c7644 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -82,10 +82,10 @@ pub use child_agent_launch::{ pub(crate) use context_model::block_context_from_terminal_model; #[cfg(feature = "tui")] pub use context_model::block_context_from_terminal_model; -pub use context_model::BlocklistAIContextModel; -pub(crate) use context_model::{ - AttachmentType, BlocklistAIContextEvent, PendingAttachment, PendingFile, -}; +#[cfg(feature = "tui")] +pub use context_model::PendingAttachmentSummary; +pub use context_model::{AttachmentType, BlocklistAIContextEvent, BlocklistAIContextModel}; +pub(crate) use context_model::{PendingAttachment, PendingFile}; pub use controller::input_context::{ BLOCK_CONTEXT_ATTACHMENT_REGEX, DIFF_HUNK_ATTACHMENT_REGEX, DRIVE_OBJECT_ATTACHMENT_REGEX, }; diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 51d6dcfaa6e..5071075d5ec 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -27,8 +27,8 @@ pub use crate::ai::agent::{ AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentPtyWriteMode, AIAgentText, AIAgentTextSection, AIAgentTodo, AIAgentTodoId, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AgentOutputTable, AskUserQuestionResult, CancellationReason, - FileGlobV2Result, GrepResult, MessageId, ReceivedMessageDisplay, RenderableAIError, - RequestCommandOutputResult, RunAgentsAgentOutcomeKind, RunAgentsResult, + FileGlobV2Result, GrepResult, ImageContext, MessageId, ReceivedMessageDisplay, + RenderableAIError, RequestCommandOutputResult, RunAgentsAgentOutcomeKind, RunAgentsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, ShellCommandDelay, StartAgentExecutionMode, SuggestNewConversationResult, SummarizationType, TodoOperation, UserQueryMode, @@ -78,12 +78,13 @@ pub use crate::ai::blocklist::{ }; pub use crate::ai::blocklist::{ block_context_from_terminal_model, inherit_child_agent_settings, AIActionStatus, - AskUserQuestionExecutor, BlocklistAIActionEvent, BlocklistAIActionModel, - BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, InputConfig, - InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, - PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, - RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, - StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, StartAgentRequestId, + AskUserQuestionExecutor, AttachmentType, BlocklistAIActionEvent, BlocklistAIActionModel, + BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, + InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, + PendingAttachmentSummary, PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, + RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, + ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, + StartAgentRequest, StartAgentRequestId, }; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, @@ -185,6 +186,10 @@ pub use crate::tui::{ }; #[cfg(any(test, feature = "test-util"))] pub use crate::tui_test_support::register_tui_session_view_test_singletons; +pub use crate::util::image::{ + infer_mime_type, is_supported_image_mime_type, process_image_for_agent, ProcessImageResult, + MAX_IMAGE_COUNT_FOR_QUERY, MAX_IMAGE_SIZE_BYTES, MIME_SNIFF_BYTES, +}; pub use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use crate::util::time_format::format_elapsed_seconds; diff --git a/crates/warp_tui/Cargo.toml b/crates/warp_tui/Cargo.toml index 14c6ea9be8b..0afeb49023e 100644 --- a/crates/warp_tui/Cargo.toml +++ b/crates/warp_tui/Cargo.toml @@ -38,6 +38,7 @@ anyhow.workspace = true async-channel.workspace = true async-fs.workspace = true base64.workspace = true +blocking.workspace = true channel_versions.workspace = true clap.workspace = true command.workspace = true @@ -55,6 +56,7 @@ pathfinder_color.workspace = true pathfinder_geometry.workspace = true rangemap.workspace = true serde_json.workspace = true +shell-words = "1.1.0" similar.workspace = true string-offset.workspace = true strum.workspace = true @@ -62,7 +64,7 @@ strum_macros.workspace = true sum_tree.workspace = true unicode-width.workspace = true vec1.workspace = true -warp = { workspace = true, features = ["tui"] } +warp = { workspace = true, features = ["image_as_context", "tui"] } warp_channel_config.workspace = true warp_core.workspace = true warp_errors.workspace = true diff --git a/crates/warp_tui/src/attachment_bar/mod.rs b/crates/warp_tui/src/attachment_bar/mod.rs new file mode 100644 index 00000000000..ee41c6a61ff --- /dev/null +++ b/crates/warp_tui/src/attachment_bar/mod.rs @@ -0,0 +1,7 @@ +mod model; +mod view; + +pub(crate) use model::{TuiAttachmentModel, TuiAttachmentPasteDisposition}; +pub(crate) use view::{ + init, TuiAttachmentBar, TuiAttachmentBarEvent, FOCUS_ATTACHMENTS_BINDING_NAME, +}; diff --git a/crates/warp_tui/src/attachment_bar/model.rs b/crates/warp_tui/src/attachment_bar/model.rs new file mode 100644 index 00000000000..5d3ac94184e --- /dev/null +++ b/crates/warp_tui/src/attachment_bar/model.rs @@ -0,0 +1,514 @@ +use std::path::{Path, PathBuf}; + +use base64::engine::general_purpose; +use base64::Engine as _; +use warp::editor::CodeEditorModel; +use warp::tui_export::{ + infer_mime_type, is_supported_image_mime_type, process_image_for_agent, ActiveSession, + BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIInputModel, ImageContext, + InputType, InputTypeAutoDetectionSource, LLMPreferences, PendingAttachmentSummary, + ProcessImageResult, MAX_IMAGE_COUNT_FOR_QUERY, MAX_IMAGE_SIZE_BYTES, MIME_SNIFF_BYTES, +}; +use warp_core::features::FeatureFlag; +use warp_editor::model::CoreEditorModel; +use warpui_core::clipboard::{ClipboardContent, ImageData}; +use warpui_core::clipboard_utils::CLIPBOARD_IMAGE_MIME_TYPES; +use warpui_core::r#async::SpawnedFutureHandle; +use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _}; + +use crate::input_mode_policy::AI_LOCKED_CONFIG; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TuiAttachmentSnapshot { + pub(crate) selected: Option, + pub(crate) position: Option, + pub(crate) count: usize, + pub(crate) is_processing: bool, + pub(crate) selected_is_processing: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiAttachmentModelEvent { + Updated, + AbortInputDetection, + RequestInputDetection, + RestorePastedText(String), + ShowHint(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiAttachmentPasteDisposition { + Started, + Handled, + NotHandled, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AttachmentModeTransition { + None, + LockAgent, + RestoreAgent { request_detection: bool }, +} + +pub(crate) struct TuiAttachmentModel { + context_model: ModelHandle, + input_mode: ModelHandle, + input_editor: ModelHandle, + active_session: ModelHandle, + terminal_surface_id: EntityId, + selected_index: Option, + /// Last observed shared-context count. Growth selects the newest item; + /// shrinkage preserves and clamps the current selection. + last_attachment_count: usize, + had_locking_attachment: bool, + processing_file_name: Option, + processing_count: usize, + in_flight: Option, +} + +impl TuiAttachmentModel { + pub(crate) fn new( + context_model: ModelHandle, + input_mode: ModelHandle, + input_editor: ModelHandle, + active_session: ModelHandle, + terminal_surface_id: EntityId, + ctx: &mut ModelContext, + ) -> Self { + let initial_attachment_count = context_model.as_ref(ctx).pending_attachments().len(); + let had_locking_attachment = context_model.as_ref(ctx).has_locking_attachment(); + ctx.subscribe_to_model(&context_model, |model, _, event, ctx| { + if matches!(event, BlocklistAIContextEvent::UpdatedPendingContext { .. }) { + model.sync_from_context(ctx); + } + }); + Self { + context_model, + input_mode, + input_editor, + active_session, + terminal_surface_id, + selected_index: initial_attachment_count.checked_sub(1), + last_attachment_count: initial_attachment_count, + had_locking_attachment, + processing_file_name: None, + processing_count: 0, + in_flight: None, + } + } + + pub(crate) fn snapshot(&self, ctx: &AppContext) -> TuiAttachmentSnapshot { + let summaries = self + .context_model + .as_ref(ctx) + .pending_attachment_summaries(); + let selected_index = self + .selected_index + .filter(|index| *index < summaries.len()) + .or_else(|| summaries.len().checked_sub(1)); + if let Some(file_name) = &self.processing_file_name { + let count = summaries.len() + self.processing_count; + TuiAttachmentSnapshot { + selected: Some(PendingAttachmentSummary { + index: count.saturating_sub(1), + attachment_type: warp::tui_export::AttachmentType::Image, + file_name: file_name.clone(), + }), + position: Some(count), + count, + is_processing: true, + selected_is_processing: true, + } + } else { + TuiAttachmentSnapshot { + selected: selected_index.and_then(|index| summaries.get(index).cloned()), + position: selected_index.map(|index| index + 1), + count: summaries.len(), + is_processing: self.is_processing(), + selected_is_processing: false, + } + } + } + + pub(crate) fn should_render(&self, ctx: &AppContext) -> bool { + self.is_processing() || self.has_attachments(ctx) + } + + pub(crate) fn has_attachments(&self, ctx: &AppContext) -> bool { + !self + .context_model + .as_ref(ctx) + .pending_attachments() + .is_empty() + } + + pub(crate) fn select_next(&mut self, ctx: &mut ModelContext) { + let count = self.context_model.as_ref(ctx).pending_attachments().len(); + if count < 2 { + return; + } + self.selected_index = Some(self.selected_index.unwrap_or_default().wrapping_add(1) % count); + ctx.emit(TuiAttachmentModelEvent::Updated); + } + + pub(crate) fn select_previous(&mut self, ctx: &mut ModelContext) { + let count = self.context_model.as_ref(ctx).pending_attachments().len(); + if count < 2 { + return; + } + let selected = self.selected_index.unwrap_or_default(); + self.selected_index = Some(if selected == 0 { + count - 1 + } else { + selected - 1 + }); + ctx.emit(TuiAttachmentModelEvent::Updated); + } + + pub(crate) fn remove_selected(&mut self, ctx: &mut ModelContext) { + if self.processing_file_name.is_some() { + if let Some(in_flight) = self.in_flight.take() { + in_flight.abort(); + } + self.finish_processing(); + ctx.emit(TuiAttachmentModelEvent::Updated); + return; + } + let Some(index) = self.selected_index else { + return; + }; + self.context_model.update(ctx, |context_model, ctx| { + context_model.remove_pending_attachment(index, ctx); + }); + } + + pub(crate) fn try_attach_paste( + &mut self, + text: String, + ctx: &mut ModelContext, + ) -> TuiAttachmentPasteDisposition { + if !FeatureFlag::ImageAsContext.is_enabled() { + return TuiAttachmentPasteDisposition::NotHandled; + } + let Some(paths) = parse_image_paths(&text, &self.current_working_directory(ctx)) else { + return TuiAttachmentPasteDisposition::NotHandled; + }; + if let Err(error) = self.validate_new_images(paths.len(), ctx) { + ctx.emit(TuiAttachmentModelEvent::RestorePastedText(text)); + ctx.emit(TuiAttachmentModelEvent::ShowHint(error)); + return TuiAttachmentPasteDisposition::Handled; + } + + let processing_file_name = paths + .last() + .and_then(|path| path.file_name()) + .map(|file_name| file_name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "image".to_owned()); + self.start_processing(processing_file_name, paths.len(), ctx); + let original_text = text; + self.in_flight = Some(ctx.spawn_abortable( + process_paths(paths), + move |model, result, ctx| { + model.in_flight = None; + model.finish_processing(); + match result { + Ok(images) => { + model.context_model.update(ctx, |context_model, ctx| { + context_model.append_pending_images(images, ctx); + }); + } + Err(error) => { + ctx.emit(TuiAttachmentModelEvent::RestorePastedText( + original_text.clone(), + )); + ctx.emit(TuiAttachmentModelEvent::ShowHint(error)); + } + } + ctx.emit(TuiAttachmentModelEvent::Updated); + }, + |_, _| {}, + )); + TuiAttachmentPasteDisposition::Started + } + + pub(crate) fn paste_image_from_clipboard(&mut self, ctx: &mut ModelContext) -> bool { + if let Err(error) = self.validate_new_images(1, ctx) { + ctx.emit(TuiAttachmentModelEvent::ShowHint(error)); + return false; + } + self.start_processing("clipboard-image.png".to_owned(), 1, ctx); + self.in_flight = Some(ctx.spawn_abortable( + read_and_process_clipboard_image(), + |model, result, ctx| { + model.in_flight = None; + model.finish_processing(); + match result { + Ok(image) => { + model.context_model.update(ctx, |context_model, ctx| { + context_model.append_pending_images(vec![image], ctx); + }); + } + Err(error) => ctx.emit(TuiAttachmentModelEvent::ShowHint(error)), + } + ctx.emit(TuiAttachmentModelEvent::Updated); + }, + |_, _| {}, + )); + true + } + + fn start_processing(&mut self, file_name: String, count: usize, ctx: &mut ModelContext) { + self.processing_file_name = Some(file_name); + self.processing_count = count; + ctx.emit(TuiAttachmentModelEvent::Updated); + } + + fn finish_processing(&mut self) { + self.processing_file_name = None; + self.processing_count = 0; + } + + fn is_processing(&self) -> bool { + self.processing_count > 0 + } + + fn validate_new_images(&self, count: usize, ctx: &AppContext) -> Result<(), String> { + if !FeatureFlag::ImageAsContext.is_enabled() { + return Err("Image attachments are unavailable.".to_owned()); + } + if self.is_processing() { + return Err("Wait for the current image attachment to finish.".to_owned()); + } + let attached_images = self.context_model.as_ref(ctx).pending_images().len(); + if attached_images + count > MAX_IMAGE_COUNT_FOR_QUERY { + return Err(format!( + "Image attachment limit is {MAX_IMAGE_COUNT_FOR_QUERY} per query." + )); + } + if !LLMPreferences::as_ref(ctx).vision_supported(ctx, Some(self.terminal_surface_id)) { + return Err("The selected model does not support image attachments.".to_owned()); + } + Ok(()) + } + + fn current_working_directory(&self, ctx: &AppContext) -> PathBuf { + self.active_session + .as_ref(ctx) + .current_working_directory() + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_default() + } + + fn input_is_empty(&self, ctx: &AppContext) -> bool { + self.input_editor + .as_ref(ctx) + .content() + .as_ref(ctx) + .is_empty() + } + + fn sync_from_context(&mut self, ctx: &mut ModelContext) { + let attachment_count = self.context_model.as_ref(ctx).pending_attachments().len(); + self.selected_index = reconciled_selected_index( + self.last_attachment_count, + attachment_count, + self.selected_index, + ); + self.last_attachment_count = attachment_count; + + let has_locking_attachment = self.context_model.as_ref(ctx).has_locking_attachment(); + let input_is_empty = self.input_is_empty(ctx); + let autodetection_enabled = self + .input_mode + .as_ref(ctx) + .is_autodetection_enabled_for_current_context(ctx); + match attachment_mode_transition( + self.had_locking_attachment, + has_locking_attachment, + autodetection_enabled, + input_is_empty, + ) { + AttachmentModeTransition::LockAgent => { + self.input_mode.update(ctx, |input_mode, ctx| { + input_mode.set_input_config( + AI_LOCKED_CONFIG, + input_is_empty, + Some(InputTypeAutoDetectionSource::AttachmentForcedAi), + ctx, + ); + }); + ctx.emit(TuiAttachmentModelEvent::AbortInputDetection); + } + AttachmentModeTransition::RestoreAgent { request_detection } => { + self.input_mode.update(ctx, |input_mode, ctx| { + if autodetection_enabled { + input_mode.enable_autodetection(InputType::AI, ctx); + } else { + input_mode.set_input_config(AI_LOCKED_CONFIG, input_is_empty, None, ctx); + } + }); + if request_detection { + ctx.emit(TuiAttachmentModelEvent::RequestInputDetection); + } + } + AttachmentModeTransition::None => {} + } + self.had_locking_attachment = has_locking_attachment; + ctx.emit(TuiAttachmentModelEvent::Updated); + } +} + +impl Entity for TuiAttachmentModel { + type Event = TuiAttachmentModelEvent; +} + +fn reconciled_selected_index( + previous_count: usize, + count: usize, + selected_index: Option, +) -> Option { + if count == 0 { + None + } else if count > previous_count { + Some(count - 1) + } else { + Some(selected_index.unwrap_or(count - 1).min(count - 1)) + } +} + +fn attachment_mode_transition( + had_locking_attachment: bool, + has_locking_attachment: bool, + autodetection_enabled: bool, + input_is_empty: bool, +) -> AttachmentModeTransition { + match (had_locking_attachment, has_locking_attachment) { + (false, true) => AttachmentModeTransition::LockAgent, + (true, false) => AttachmentModeTransition::RestoreAgent { + request_detection: autodetection_enabled && !input_is_empty, + }, + (false, false) | (true, true) => AttachmentModeTransition::None, + } +} + +fn parse_image_paths(text: &str, cwd: &Path) -> Option> { + let tokens = shell_words::split(text.trim()).ok()?; + if tokens.is_empty() { + return None; + } + tokens + .into_iter() + .map(|token| resolve_image_path(&token, cwd)) + .collect() +} + +fn resolve_image_path(token: &str, cwd: &Path) -> Option { + let token = token.strip_prefix("file://").unwrap_or(token); + let path = if token == "~" { + dirs::home_dir()? + } else if let Some(rest) = token.strip_prefix("~/") { + dirs::home_dir()?.join(rest) + } else { + PathBuf::from(token) + }; + let path = if path.is_absolute() { + path + } else { + cwd.join(path) + }; + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp").then_some(path) +} + +async fn process_paths(paths: Vec) -> Result, String> { + let mut images = Vec::with_capacity(paths.len()); + for path in paths { + let metadata = async_fs::metadata(&path) + .await + .map_err(|_| format!("Could not read image {}.", path.display()))?; + if !metadata.is_file() { + return Err(format!("Image path is not a file: {}.", path.display())); + } + if metadata.len() > u64::try_from(MAX_IMAGE_SIZE_BYTES).unwrap_or(u64::MAX) { + return Err(format!("Image is too large: {}.", path.display())); + } + let bytes = async_fs::read(&path) + .await + .map_err(|_| format!("Could not read image {}.", path.display()))?; + let mime_type = infer_mime_type(&path, &bytes[..bytes.len().min(MIME_SNIFF_BYTES)]); + if !is_supported_image_mime_type(&mime_type) { + return Err(format!( + "Unsupported image type for {}. Use PNG, JPG, GIF, or WebP.", + path.display() + )); + } + let data = match process_image_for_agent(&bytes) { + ProcessImageResult::Success { data } => data, + ProcessImageResult::TooLarge => { + return Err(format!("Image is too large: {}.", path.display())); + } + ProcessImageResult::Error(_) => { + return Err(format!("Could not process image {}.", path.display())); + } + }; + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return Err(format!("Image has no valid filename: {}.", path.display())); + }; + images.push(ImageContext { + data: general_purpose::STANDARD.encode(data), + mime_type, + file_name: file_name.to_owned(), + is_figma: false, + }); + } + Ok(images) +} + +async fn read_and_process_clipboard_image() -> Result { + let content = blocking::unblock(|| -> Result { + let mut clipboard = warpui::platform::create_system_clipboard() + .map_err(|_| "The system clipboard is unavailable.".to_owned())?; + Ok(clipboard.read()) + }) + .await?; + process_clipboard_content(content) +} + +fn process_clipboard_content(content: ClipboardContent) -> Result { + let images = content + .images + .ok_or_else(|| "The clipboard does not contain an image.".to_owned())?; + let image = CLIPBOARD_IMAGE_MIME_TYPES + .iter() + .find_map(|mime_type| { + images + .iter() + .find(|image| image.mime_type == *mime_type) + .cloned() + }) + .ok_or_else(|| "The clipboard does not contain a supported image.".to_owned())?; + process_clipboard_image_data(image) +} + +fn process_clipboard_image_data(image: ImageData) -> Result { + let data = match process_image_for_agent(&image.data) { + ProcessImageResult::Success { data } => data, + ProcessImageResult::TooLarge => return Err("The clipboard image is too large.".to_owned()), + ProcessImageResult::Error(_) => { + return Err("The clipboard image could not be processed.".to_owned()); + } + }; + Ok(ImageContext { + data: general_purpose::STANDARD.encode(data), + mime_type: image.mime_type, + file_name: image + .filename + .unwrap_or_else(|| "clipboard-image.png".to_owned()), + is_figma: false, + }) +} + +#[cfg(test)] +#[path = "model_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/attachment_bar/model_tests.rs b/crates/warp_tui/src/attachment_bar/model_tests.rs new file mode 100644 index 00000000000..90024d9a493 --- /dev/null +++ b/crates/warp_tui/src/attachment_bar/model_tests.rs @@ -0,0 +1,154 @@ +use std::path::Path; + +use base64::engine::general_purpose; +use base64::Engine as _; +use futures_lite::future::block_on; +use warpui_core::clipboard::{ClipboardContent, ImageData}; + +use super::{ + attachment_mode_transition, parse_image_paths, process_clipboard_content, process_paths, + reconciled_selected_index, AttachmentModeTransition, MAX_IMAGE_SIZE_BYTES, +}; + +const ONE_PIXEL_PNG: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +#[test] +fn parses_single_and_quoted_image_paths() { + let cwd = Path::new("/workspace"); + assert_eq!( + parse_image_paths("image.png", cwd).unwrap(), + vec![cwd.join("image.png")] + ); + assert_eq!( + parse_image_paths("'screenshots/image one.webp'", cwd).unwrap(), + vec![cwd.join("screenshots/image one.webp")] + ); +} + +#[test] +fn parses_multiple_image_paths_in_order() { + let cwd = Path::new("/workspace"); + assert_eq!( + parse_image_paths("one.png two.jpg", cwd).unwrap(), + vec![cwd.join("one.png"), cwd.join("two.jpg")] + ); +} + +#[test] +fn rejects_mixed_or_non_image_pastes() { + let cwd = Path::new("/workspace"); + assert!(parse_image_paths("one.png notes.txt", cwd).is_none()); + assert!(parse_image_paths("ordinary prompt text", cwd).is_none()); +} + +#[test] +fn selection_tracks_newest_and_clamps_after_removal() { + assert_eq!(reconciled_selected_index(0, 2, None), Some(1)); + assert_eq!(reconciled_selected_index(2, 1, Some(1)), Some(0)); + assert_eq!(reconciled_selected_index(1, 0, Some(0)), None); +} + +#[test] +fn attachment_transitions_lock_and_restore_nld() { + assert_eq!( + attachment_mode_transition(false, true, true, false), + AttachmentModeTransition::LockAgent + ); + assert_eq!( + attachment_mode_transition(true, true, true, false), + AttachmentModeTransition::None + ); + assert_eq!( + attachment_mode_transition(true, false, true, false), + AttachmentModeTransition::RestoreAgent { + request_detection: true + } + ); + assert_eq!( + attachment_mode_transition(true, false, true, true), + AttachmentModeTransition::RestoreAgent { + request_detection: false + } + ); + assert_eq!( + attachment_mode_transition(true, false, false, false), + AttachmentModeTransition::RestoreAgent { + request_detection: false + } + ); +} + +#[test] +fn processes_valid_images_in_paste_order() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first.png"); + let second = directory.path().join("second.png"); + let png = general_purpose::STANDARD.decode(ONE_PIXEL_PNG).unwrap(); + std::fs::write(&first, &png).unwrap(); + std::fs::write(&second, &png).unwrap(); + + let images = block_on(process_paths(vec![first, second])).unwrap(); + + assert_eq!( + images + .iter() + .map(|image| image.file_name.as_str()) + .collect::>(), + ["first.png", "second.png"] + ); + assert!(images.iter().all(|image| !image.data.is_empty())); +} + +#[test] +fn processing_is_all_or_nothing() { + let directory = tempfile::tempdir().unwrap(); + let valid = directory.path().join("valid.png"); + let invalid = directory.path().join("invalid.png"); + let png = general_purpose::STANDARD.decode(ONE_PIXEL_PNG).unwrap(); + std::fs::write(&valid, png).unwrap(); + std::fs::write(&invalid, b"not an image").unwrap(); + + assert!(block_on(process_paths(vec![valid, invalid])).is_err()); +} + +#[test] +fn rejects_oversized_image_before_reading_it() { + let directory = tempfile::tempdir().unwrap(); + let oversized = directory.path().join("oversized.png"); + let file = std::fs::File::create(&oversized).unwrap(); + file.set_len(u64::try_from(MAX_IMAGE_SIZE_BYTES).unwrap() + 1) + .unwrap(); + + assert_eq!( + block_on(process_paths(vec![oversized.clone()])).unwrap_err(), + format!("Image is too large: {}.", oversized.display()) + ); +} + +#[test] +fn processes_clipboard_image_content() { + let png = general_purpose::STANDARD.decode(ONE_PIXEL_PNG).unwrap(); + let content = ClipboardContent { + images: Some(vec![ImageData { + data: png, + mime_type: "image/png".to_owned(), + filename: Some("clipboard.png".to_owned()), + }]), + ..Default::default() + }; + + let context = process_clipboard_content(content).unwrap(); + + assert_eq!(context.mime_type, "image/png"); + assert_eq!(context.file_name, "clipboard.png"); + assert!(!context.data.is_empty()); +} + +#[test] +fn rejects_clipboard_content_without_an_image() { + assert_eq!( + process_clipboard_content(ClipboardContent::plain_text("text".to_owned())).unwrap_err(), + "The clipboard does not contain an image." + ); +} diff --git a/crates/warp_tui/src/attachment_bar/view.rs b/crates/warp_tui/src/attachment_bar/view.rs new file mode 100644 index 00000000000..6f6dd3ce193 --- /dev/null +++ b/crates/warp_tui/src/attachment_bar/view.rs @@ -0,0 +1,366 @@ +use warp::tui_export::AttachmentType; +use warpui_core::elements::tui::{TuiElement, TuiFlex, TuiHoverable, TuiText}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::EditableBinding; +use warpui_core::{ + AppContext, BlurContext, Entity, FocusContext, ModelHandle, TuiView, TypedActionView, + ViewContext, +}; + +use super::model::{TuiAttachmentModel, TuiAttachmentModelEvent, TuiAttachmentPasteDisposition}; +use crate::keybindings::TUI_BINDING_GROUP; +use crate::tui_builder::TuiUiBuilder; + +pub(crate) const FOCUS_ATTACHMENTS_BINDING_NAME: &str = "tui:session:focus_attachments"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiAttachmentBarEvent { + AbortInputDetection, + RequestInputDetection, + RestorePastedText(String), + ShowHint(String), + ReturnFocus, +} + +fn attachment_control( + text: &'static str, + mouse: MouseStateHandle, + action: TuiAttachmentBarAction, + ctx: &AppContext, +) -> Box { + let builder = TuiUiBuilder::from_app(ctx); + let style = if mouse.lock().is_ok_and(|state| state.is_hovered()) { + builder.primary_text_style() + } else { + builder.muted_text_style() + }; + TuiHoverable::new( + mouse, + TuiText::new(text).with_style(style).truncate().finish(), + ) + .on_click(move |event_ctx, _| { + event_ctx.dispatch_typed_action(action.clone()); + }) + .finish() +} + +#[derive(Clone, Debug)] +pub(crate) enum TuiAttachmentBarAction { + Next, + Previous, + RemoveSelected, + ReturnFocus, +} + +pub(crate) struct TuiAttachmentBar { + model: ModelHandle, + focused: bool, + previous_mouse: MouseStateHandle, + next_mouse: MouseStateHandle, + remove_mouse: MouseStateHandle, +} + +pub(crate) fn init(app: &mut AppContext) { + let context = id!(TuiAttachmentBar::ui_name()); + app.register_editable_bindings([ + EditableBinding::new( + "tui:attachments:next", + "Select the next attachment", + TuiAttachmentBarAction::Next, + ) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("tab"), + EditableBinding::new( + "tui:attachments:next", + "Select the next attachment", + TuiAttachmentBarAction::Next, + ) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("right"), + EditableBinding::new( + "tui:attachments:previous", + "Select the previous attachment", + TuiAttachmentBarAction::Previous, + ) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-tab"), + EditableBinding::new( + "tui:attachments:previous", + "Select the previous attachment", + TuiAttachmentBarAction::Previous, + ) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("left"), + EditableBinding::new( + "tui:attachments:remove", + "Remove the selected attachment", + TuiAttachmentBarAction::RemoveSelected, + ) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("backspace"), + EditableBinding::new( + "tui:attachments:remove", + "Remove the selected attachment", + TuiAttachmentBarAction::RemoveSelected, + ) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("delete"), + EditableBinding::new( + "tui:attachments:return_focus", + "Return focus to the input", + TuiAttachmentBarAction::ReturnFocus, + ) + .with_context_predicate(context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("escape"), + EditableBinding::new( + "tui:attachments:return_focus", + "Return focus to the input", + TuiAttachmentBarAction::ReturnFocus, + ) + .with_context_predicate(context) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("enter"), + ]); +} + +impl TuiAttachmentBar { + pub(crate) fn new(model: ModelHandle, ctx: &mut ViewContext) -> Self { + let model_for_subscription = model.clone(); + ctx.subscribe_to_model(&model, move |view, _, event, ctx| match event { + TuiAttachmentModelEvent::Updated => { + // TUI notifications invalidate the whole window, including the + // parent that conditionally renders this attachment bar. + if view.focused && !model_for_subscription.as_ref(ctx).should_render(ctx) { + ctx.emit(TuiAttachmentBarEvent::ReturnFocus); + } + ctx.notify(); + } + TuiAttachmentModelEvent::AbortInputDetection => { + ctx.emit(TuiAttachmentBarEvent::AbortInputDetection); + } + TuiAttachmentModelEvent::RequestInputDetection => { + ctx.emit(TuiAttachmentBarEvent::RequestInputDetection); + } + TuiAttachmentModelEvent::RestorePastedText(text) => { + ctx.emit(TuiAttachmentBarEvent::RestorePastedText(text.clone())); + } + TuiAttachmentModelEvent::ShowHint(text) => { + ctx.emit(TuiAttachmentBarEvent::ShowHint(text.clone())); + } + }); + Self { + model, + focused: false, + previous_mouse: MouseStateHandle::default(), + next_mouse: MouseStateHandle::default(), + remove_mouse: MouseStateHandle::default(), + } + } + + pub(crate) fn should_render(&self, ctx: &AppContext) -> bool { + self.model.as_ref(ctx).should_render(ctx) + } + + pub(crate) fn try_attach_paste( + &mut self, + text: String, + ctx: &mut ViewContext, + ) -> TuiAttachmentPasteDisposition { + self.model + .update(ctx, |model, ctx| model.try_attach_paste(text, ctx)) + } + + pub(crate) fn paste_image_from_clipboard(&mut self, ctx: &mut ViewContext) { + self.model + .update(ctx, |model, ctx| model.paste_image_from_clipboard(ctx)); + } + + pub(crate) fn remove_selected(&mut self, ctx: &mut ViewContext) { + self.model + .update(ctx, |model, ctx| model.remove_selected(ctx)); + } +} + +fn render_attachment_snapshot( + snapshot: super::model::TuiAttachmentSnapshot, + focused: bool, + previous_mouse: MouseStateHandle, + next_mouse: MouseStateHandle, + remove_mouse: MouseStateHandle, + ctx: &AppContext, +) -> Box { + let builder = TuiUiBuilder::from_app(ctx); + let Some(selected) = snapshot.selected else { + return TuiText::new("loading image…") + .with_style(builder.muted_text_style()) + .truncate() + .finish(); + }; + let kind = match selected.attachment_type { + AttachmentType::Image => "[image]", + AttachmentType::File => "[file]", + }; + if snapshot.selected_is_processing { + return TuiFlex::row() + .child( + TuiText::new(format!("{kind} ")) + .with_style(builder.accent_text_style()) + .truncate() + .finish(), + ) + .flex_child( + TuiText::new(selected.file_name) + .with_style(builder.muted_text_style()) + .truncate() + .finish(), + ) + .child( + TuiText::new(" · loading…") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ) + .finish(); + } + let mut row = TuiFlex::row(); + if snapshot.count > 1 { + row = row + .child(attachment_control( + "‹ ", + previous_mouse, + TuiAttachmentBarAction::Previous, + ctx, + )) + .child( + TuiText::new(" ") + .with_style(builder.muted_text_style()) + .finish(), + ); + } + row = row + .child( + TuiText::new(format!("{kind} ")) + .with_style(builder.accent_text_style()) + .truncate() + .finish(), + ) + .flex_child( + TuiText::new(selected.file_name) + .with_style(if focused { + builder.primary_text_style() + } else { + builder.muted_text_style() + }) + .truncate() + .finish(), + ) + .child( + TuiText::new(format!( + " {}/{} ", + snapshot.position.unwrap_or(1), + snapshot.count + )) + .with_style(builder.muted_text_style()) + .truncate() + .finish(), + ) + .child(attachment_control( + "×", + remove_mouse, + TuiAttachmentBarAction::RemoveSelected, + ctx, + )); + if snapshot.count > 1 { + row = row + .child( + TuiText::new(" ") + .with_style(builder.muted_text_style()) + .finish(), + ) + .child(attachment_control( + " ›", + next_mouse, + TuiAttachmentBarAction::Next, + ctx, + )); + } + if snapshot.is_processing { + row = row.child( + TuiText::new(" · loading…") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + row.finish() +} + +impl Entity for TuiAttachmentBar { + type Event = TuiAttachmentBarEvent; +} + +impl TuiView for TuiAttachmentBar { + fn ui_name() -> &'static str { + "TuiAttachmentBar" + } + + fn render(&self, ctx: &AppContext) -> Box { + render_attachment_snapshot( + self.model.as_ref(ctx).snapshot(ctx), + self.focused, + self.previous_mouse.clone(), + self.next_mouse.clone(), + self.remove_mouse.clone(), + ctx, + ) + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + if focus_ctx.is_self_focused() { + self.focused = true; + ctx.notify(); + } + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.focused = false; + ctx.notify(); + } + } +} + +impl TypedActionView for TuiAttachmentBar { + type Action = TuiAttachmentBarAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + TuiAttachmentBarAction::Next => { + self.model.update(ctx, |model, ctx| model.select_next(ctx)); + } + TuiAttachmentBarAction::Previous => { + self.model + .update(ctx, |model, ctx| model.select_previous(ctx)); + } + TuiAttachmentBarAction::RemoveSelected => { + self.model + .update(ctx, |model, ctx| model.remove_selected(ctx)); + } + TuiAttachmentBarAction::ReturnFocus => { + ctx.emit(TuiAttachmentBarEvent::ReturnFocus); + } + } + } +} + +#[cfg(test)] +#[path = "view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/attachment_bar/view_tests.rs b/crates/warp_tui/src/attachment_bar/view_tests.rs new file mode 100644 index 00000000000..93636614b17 --- /dev/null +++ b/crates/warp_tui/src/attachment_bar/view_tests.rs @@ -0,0 +1,118 @@ +use warp::appearance::Appearance; +use warp::tui_export::{AttachmentType, PendingAttachmentSummary}; +use warpui::EntityIdMap; +use warpui_core::elements::tui::{ + TuiBuffer, TuiBufferExt, TuiConstraint, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, + TuiRect, TuiScreenPosition, TuiSize, +}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{App, AppContext}; + +use super::render_attachment_snapshot; +use crate::attachment_bar::model::TuiAttachmentSnapshot; + +fn render_lines(ctx: &AppContext, snapshot: TuiAttachmentSnapshot, width: u16) -> Vec { + let mut element = render_attachment_snapshot( + snapshot, + false, + MouseStateHandle::default(), + MouseStateHandle::default(), + MouseStateHandle::default(), + ctx, + ); + let mut rendered_views = EntityIdMap::default(); + let mut layout_ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = element.layout( + TuiConstraint::loose(TuiSize::new(width, 1)), + &mut layout_ctx, + ctx, + ); + let area = TuiRect::new(0, 0, size.width, size.height); + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render( + TuiScreenPosition::new(i32::from(area.x), i32::from(area.y)), + &mut surface, + &mut paint_ctx, + ); + buffer.to_lines() +} + +fn snapshot(file_name: &str, position: usize, count: usize) -> TuiAttachmentSnapshot { + TuiAttachmentSnapshot { + selected: Some(PendingAttachmentSummary { + index: position - 1, + attachment_type: AttachmentType::Image, + file_name: file_name.to_owned(), + }), + position: Some(position), + count, + is_processing: false, + selected_is_processing: false, + } +} + +#[test] +fn renders_single_attachment_without_carousel_arrows() { + App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + let line = render_lines(ctx, snapshot("screenshot.png", 1, 1), 60).remove(0); + assert!(line.contains("[image]")); + assert!(line.contains("screenshot.png")); + assert!(line.contains("1/1")); + assert!(line.contains('×')); + assert!(!line.contains('‹')); + assert!(!line.contains('›')); + }); + }); +} + +#[test] +fn renders_carousel_position_and_truncates_at_narrow_width() { + App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + let line = + render_lines(ctx, snapshot("a-very-long-screenshot-name.png", 2, 3), 28).remove(0); + assert!(line.contains("[image]")); + assert!(line.contains("2/3")); + assert!(line.contains('‹')); + assert!(line.contains('›')); + assert!(line.contains('×')); + assert!(line.chars().count() <= 28); + }); + }); +} + +#[test] +fn renders_provisional_filename_while_image_is_loading() { + App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + let lines = render_lines( + ctx, + TuiAttachmentSnapshot { + selected: Some(PendingAttachmentSummary { + index: 0, + attachment_type: AttachmentType::Image, + file_name: "clipboard-image.png".to_owned(), + }), + position: Some(1), + count: 1, + is_processing: true, + selected_is_processing: true, + }, + 40, + ); + let line = &lines[0]; + assert!(line.contains("[image]")); + assert!(line.contains("clipboard-image.png")); + assert!(line.contains("loading…")); + assert!(!line.contains('×')); + }); + }); +} diff --git a/crates/warp_tui/src/editor_element.rs b/crates/warp_tui/src/editor_element.rs index f4f8b170d77..eee3418764f 100644 --- a/crates/warp_tui/src/editor_element.rs +++ b/crates/warp_tui/src/editor_element.rs @@ -51,7 +51,7 @@ pub enum TuiEditorAction { InsertChar(char), /// Insert one complete paste payload (only emitted when the element is /// [`editable`](TuiEditorElement::editable)). - InsertText(String), + PasteText(String), /// Place the cursor / begin a character selection at `offset` (single click). SelectionStartAt { offset: CharOffset }, /// Extend the active selection's head to `offset` (shift-click). @@ -796,7 +796,7 @@ impl TuiElement for TuiEditorElement { } } TuiEvent::Paste { text } => { - handler(TuiEditorAction::InsertText(text.clone()), event_ctx); + handler(TuiEditorAction::PasteText(text.clone()), event_ctx); return true; } TuiEvent::ScrollWheel { .. } diff --git a/crates/warp_tui/src/editor_element_tests.rs b/crates/warp_tui/src/editor_element_tests.rs index 84671cd8ee2..9793e2738a5 100644 --- a/crates/warp_tui/src/editor_element_tests.rs +++ b/crates/warp_tui/src/editor_element_tests.rs @@ -172,8 +172,8 @@ fn editable_paste_emits_one_complete_text_action() { )); let actions = actions.borrow(); assert_eq!(actions.len(), 1); - let TuiEditorAction::InsertText(text) = &actions[0] else { - panic!("expected InsertText"); + let TuiEditorAction::PasteText(text) = &actions[0] else { + panic!("expected PasteText"); }; assert_eq!(text, payload); }); diff --git a/crates/warp_tui/src/editor_interaction.rs b/crates/warp_tui/src/editor_interaction.rs index 6aad5b1e26c..cbd2c4a7748 100644 --- a/crates/warp_tui/src/editor_interaction.rs +++ b/crates/warp_tui/src/editor_interaction.rs @@ -478,7 +478,7 @@ pub(crate) fn apply_editor_action( TuiEditorAction::InsertChar(c) => { model.update(ctx, |model, ctx| model.user_insert(&c.to_string(), ctx)); } - TuiEditorAction::InsertText(text) => { + TuiEditorAction::PasteText(text) => { let text = behavior.normalize_text(text); model.update(ctx, |model, ctx| model.user_insert(text, ctx)); } diff --git a/crates/warp_tui/src/editor_view_tests.rs b/crates/warp_tui/src/editor_view_tests.rs index 0a7bbf4bb6b..829beae81e7 100644 --- a/crates/warp_tui/src/editor_view_tests.rs +++ b/crates/warp_tui/src/editor_view_tests.rs @@ -38,7 +38,7 @@ fn layout_clamps_stale_scroll_after_resize_and_text_replacement() { render_lines_at_width(&app, &editor, 3); editor.update(&mut app, |editor, ctx| { editor.handle_action( - &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("abcdef".to_string())), + &TuiEditorViewAction::Editor(TuiEditorAction::PasteText("abcdef".to_string())), ctx, ); }); @@ -123,7 +123,7 @@ fn single_line_paste_discards_later_lines() { editor.update(&mut app, |editor, ctx| { editor.handle_action( - &TuiEditorViewAction::Editor(TuiEditorAction::InsertText( + &TuiEditorViewAction::Editor(TuiEditorAction::PasteText( "first\nsecond".to_string(), )), ctx, @@ -198,7 +198,7 @@ fn editor_follows_cursor_within_its_one_row_viewport() { editor.update(&mut app, |editor, ctx| { editor.handle_action( - &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("abcd".to_string())), + &TuiEditorViewAction::Editor(TuiEditorAction::PasteText("abcd".to_string())), ctx, ); }); @@ -325,7 +325,7 @@ fn actions_edit_the_single_line_buffer() { }); editor.update(&mut app, |editor, ctx| { editor.handle_action( - &TuiEditorViewAction::Editor(TuiEditorAction::InsertText("gen".to_string())), + &TuiEditorViewAction::Editor(TuiEditorAction::PasteText("gen".to_string())), ctx, ); editor.handle_action( diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index f9973dc0f9f..bc679b18127 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -107,6 +107,10 @@ pub fn init(app: &mut AppContext) { pub enum TuiInputViewEvent { /// The user pressed Enter to submit the current input. Contains the final text. Submitted(String), + /// The terminal delivered one complete bracketed-paste payload. + Pasted(String), + /// Backspace was pressed with an empty normal input. + BackspaceAtEmptyInput, /// The user selected a slash command menu item. AcceptedSlashCommand(AcceptSlashCommandOrSavedPrompt), /// The user selected a conversation menu item. @@ -351,6 +355,19 @@ impl TuiInputView { ctx.notify(); } + /// Inserts a paste payload after the parent declines to consume it as + /// structured input. + pub(crate) fn insert_pasted_text(&mut self, text: &str, ctx: &mut ViewContext) { + apply_editor_action( + &self.model, + &TuiEditorAction::PasteText(text.to_owned()), + self.editor_behavior, + ctx, + ); + self.follow_cursor(ctx); + ctx.notify(); + } + /// Composes the shell-mode input row: the accent-styled `!` affordance in a /// two-column gutter (glyph plus one column of right padding), then the /// editor filling the remaining width. The gutter is outside the editable @@ -438,6 +455,10 @@ impl TypedActionView for TuiInputView { } let outcome = match action { TuiInputAction::Editor(editor_action) => { + if let TuiEditorAction::PasteText(text) = editor_action { + ctx.emit(TuiInputViewEvent::Pasted(text.clone())); + return; + } // A `!` typed at the very start of the input enters shell mode // instead of inserting (matching the GUI's typed-only trigger). if matches!(editor_action, TuiEditorAction::InsertChar('!')) @@ -488,6 +509,12 @@ impl TypedActionView for TuiInputView { { self.exit_shell_mode(ctx); TuiEditorInteractionOutcome::FollowCursor + } else if matches!(*command, TuiEditorCommand::Backspace) + && self.plain_text(ctx).is_empty() + && self.is_cursor_at_start(ctx) + { + ctx.emit(TuiInputViewEvent::BackspaceAtEmptyInput); + TuiEditorInteractionOutcome::FollowCursor } else { self.editor_state.apply_command( &self.model, diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index f66230dda13..e5d35ec0fc3 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -577,18 +577,28 @@ fn escape_dismisses_menu_and_closed_menu_submit_falls_through() { } #[test] -fn multiline_paste_inserts_without_submitting_until_enter() { +fn multiline_paste_emits_once_and_fallback_inserts_without_submitting() { App::test((), |mut app| async move { - let (view, submitted) = app.update(|ctx| { + let (view, pasted, submitted) = app.update(|ctx| { let view = build_view(ctx); + let pasted = Rc::new(RefCell::new(Vec::new())); + let pasted_for_subscription = pasted.clone(); let submitted = Rc::new(RefCell::new(Vec::new())); let submitted_for_subscription = submitted.clone(); - ctx.subscribe_to_view(&view, move |_, event, _| { - if let TuiInputViewEvent::Submitted(text) = event { + ctx.subscribe_to_view(&view, move |_, event, _| match event { + TuiInputViewEvent::Pasted(text) => { + pasted_for_subscription.borrow_mut().push(text.clone()); + } + TuiInputViewEvent::Submitted(text) => { submitted_for_subscription.borrow_mut().push(text.clone()); } + TuiInputViewEvent::AcceptedSlashCommand(_) + | TuiInputViewEvent::AcceptedConversation(_) + | TuiInputViewEvent::AcceptedModel(_) + | TuiInputViewEvent::AcceptedMcp(_) + | TuiInputViewEvent::BackspaceAtEmptyInput => {} }); - (view, submitted) + (view, pasted, submitted) }); let payload = "USER:\nhello\n\nAGENT:\nHi!\n"; @@ -596,13 +606,14 @@ fn multiline_paste_inserts_without_submitting_until_enter() { dispatch( &view, ctx, - &[TuiInputAction::Editor(TuiEditorAction::InsertText( + &[TuiInputAction::Editor(TuiEditorAction::PasteText( payload.to_owned(), ))], ); }); app.read(|ctx| { - assert_eq!(text(&view, ctx), payload); + assert_eq!(pasted.borrow().as_slice(), &[payload]); + assert_eq!(text(&view, ctx), ""); assert!( submitted.borrow().is_empty(), "paste must not emit a submission" @@ -610,12 +621,49 @@ fn multiline_paste_inserts_without_submitting_until_enter() { }); app.update(|ctx| { + view.update(ctx, |view, ctx| view.insert_pasted_text(payload, ctx)); dispatch(&view, ctx, &[TuiInputAction::Submit]); }); assert_eq!(submitted.borrow().as_slice(), &[payload]); }); } +#[test] +fn backspace_at_empty_input_emits_attachment_removal_event() { + App::test((), |mut app| async move { + let (view, events) = app.update(|ctx| { + let view = build_view(ctx); + let events = Rc::new(RefCell::new(0)); + let events_for_subscription = events.clone(); + ctx.subscribe_to_view(&view, move |_, event, _| { + if matches!(event, TuiInputViewEvent::BackspaceAtEmptyInput) { + *events_for_subscription.borrow_mut() += 1; + } + }); + (view, events) + }); + + app.update(|ctx| { + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::Backspace)], + ); + }); + assert_eq!(*events.borrow(), 1); + + app.update(|ctx| { + type_str(&view, ctx, "x"); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::Backspace)], + ); + }); + assert_eq!(*events.borrow(), 1); + }); +} + fn dispatch(view: &ViewHandle, ctx: &mut AppContext, actions: &[TuiInputAction]) { view.update(ctx, |v, vctx| { for action in actions { diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index a832e6a379f..d65d21bff24 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -25,6 +25,7 @@ use warpui_core::keymap::{ }; use warpui_core::{Action, AppContext, TuiView}; +use crate::attachment_bar::TuiAttachmentBar; use crate::editor_interaction::{editor_binding_specs, TuiEditorBindingTarget, TuiEditorCommand}; use crate::editor_view::{TuiEditorView, TuiEditorViewAction}; use crate::input::view::TuiInputAction; @@ -38,6 +39,7 @@ use crate::transcript_view::TuiTranscriptView; /// Group tag set on every TUI-registered binding. The validators treat it (or /// a `tui:` name prefix) as proof of TUI ownership. pub(crate) const TUI_BINDING_GROUP: &str = "tui"; +pub(crate) const ATTACHMENTS_AVAILABLE_FLAG: &str = "TuiAttachmentsAvailable"; pub(crate) const PLAN_TOGGLE_AVAILABLE_FLAG: &str = "TuiPlanToggleAvailable"; pub(crate) const KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG: &str = "TuiKeyboardEnhancementAvailable"; pub(crate) const PLAN_TOGGLE_BINDING_NAME: &str = "tui:session:toggle_plan"; @@ -67,6 +69,7 @@ pub(crate) fn plan_toggle_hint(ctx: &AppContext) -> Option { pub(crate) fn init(app: &mut AppContext) { crate::root_view::init(app); crate::terminal_session_view::init(app); + crate::attachment_bar::init(app); crate::input::init(app); register_editor_bindings( app, @@ -122,6 +125,7 @@ fn register_editor_bindings( fn register_binding_validators(app: &mut AppContext) { app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); + app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); app.register_tui_binding_validator::(is_tui_owned_binding); diff --git a/crates/warp_tui/src/keybindings_tests.rs b/crates/warp_tui/src/keybindings_tests.rs index 13c631316ce..93a1a86aec9 100644 --- a/crates/warp_tui/src/keybindings_tests.rs +++ b/crates/warp_tui/src/keybindings_tests.rs @@ -1,6 +1,12 @@ -use warpui_core::App; +use warpui_core::keymap::Context; +use warpui_core::{App, TuiView}; -use super::{is_tui_owned, TUI_BINDING_GROUP}; +use super::{is_tui_owned, ATTACHMENTS_AVAILABLE_FLAG, TUI_BINDING_GROUP}; +use crate::attachment_bar::{TuiAttachmentBar, FOCUS_ATTACHMENTS_BINDING_NAME}; +use crate::input::TuiInputView; +use crate::terminal_session_view::{ + TuiTerminalSessionView, PASTE_IMAGE_BINDING_NAME, SESSION_COMPOSER_OWNS_INPUT_FLAG, +}; #[test] fn tui_ownership_is_by_name_prefix_or_group() { @@ -26,3 +32,61 @@ fn tui_binding_registration_passes_the_cross_surface_validators() { app.update(super::init); }); } +#[test] +fn attachment_bindings_are_scoped_to_available_and_focused_contexts() { + App::test((), |mut app| async move { + app.update(|ctx| { + crate::terminal_session_view::init(ctx); + crate::attachment_bar::init(ctx); + + let mut input_with_attachments = Context::default(); + input_with_attachments.set.insert(TuiInputView::ui_name()); + input_with_attachments + .set + .insert(TuiTerminalSessionView::ui_name()); + input_with_attachments + .set + .insert(ATTACHMENTS_AVAILABLE_FLAG); + let mut plain_input = Context::default(); + plain_input.set.insert(TuiInputView::ui_name()); + plain_input.set.insert(TuiTerminalSessionView::ui_name()); + let focus_bindings = ctx + .editable_bindings() + .filter(|binding| binding.name == FOCUS_ATTACHMENTS_BINDING_NAME) + .collect::>(); + assert_eq!(focus_bindings.len(), 1); + assert!(focus_bindings + .iter() + .all(|binding| binding.in_context(&input_with_attachments))); + assert!(focus_bindings + .iter() + .all(|binding| !binding.in_context(&plain_input))); + + let mut bar_context = Context::default(); + bar_context.set.insert(TuiAttachmentBar::ui_name()); + let local_bindings = ctx + .editable_bindings() + .filter(|binding| binding.name.starts_with("tui:attachments:")) + .collect::>(); + assert!(!local_bindings.is_empty()); + assert!(local_bindings + .iter() + .all(|binding| binding.in_context(&bar_context))); + assert!(local_bindings + .iter() + .all(|binding| !binding.in_context(&plain_input))); + + let paste_image = ctx + .editable_bindings() + .find(|binding| binding.name == PASTE_IMAGE_BINDING_NAME) + .expect("clipboard image binding"); + let mut composer_context = plain_input.clone(); + composer_context + .set + .insert(SESSION_COMPOSER_OWNS_INPUT_FLAG); + assert!(paste_image.in_context(&composer_context)); + assert!(!paste_image.in_context(&plain_input)); + assert!(!paste_image.in_context(&bar_context)); + }); + }); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 45eae6f7cfa..0c032bb6add 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -11,6 +11,7 @@ mod agent_block; mod agent_block_sections; mod agent_message; mod alt_screen_view; +mod attachment_bar; mod autoupdate; mod clipboard; pub mod input; diff --git a/crates/warp_tui/src/option_selector_tests.rs b/crates/warp_tui/src/option_selector_tests.rs index d99646cfe51..f5f3e2c9504 100644 --- a/crates/warp_tui/src/option_selector_tests.rs +++ b/crates/warp_tui/src/option_selector_tests.rs @@ -228,7 +228,7 @@ fn focused_search_filters_including_digits_and_enter_confirms_top_match() { edit_search( &mut app, &selector, - TuiEditorAction::InsertText("gpt-5".to_string()), + TuiEditorAction::PasteText("gpt-5".to_string()), ); let lines = render_lines(&app, &selector, 60); @@ -297,7 +297,7 @@ fn search_no_matches_and_escape_clear_are_rendered_without_moving_the_field() { edit_search( &mut app, &selector, - TuiEditorAction::InsertText("zzz".to_string()), + TuiEditorAction::PasteText("zzz".to_string()), ); let lines = render_lines(&app, &selector, 60); assert!(lines.iter().any(|line| line.contains("Search:"))); diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 00fa81cc2bb..17886c32589 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -55,6 +55,10 @@ use warpui_core::{ }; use crate::alt_screen_view::AltScreenElement; +use crate::attachment_bar::{ + TuiAttachmentBar, TuiAttachmentBarEvent, TuiAttachmentModel, TuiAttachmentPasteDisposition, + FOCUS_ATTACHMENTS_BINDING_NAME, +}; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; use crate::conversation_menu::{TuiConversationMenuEvent, TuiConversationMenuModel}; @@ -65,8 +69,9 @@ use crate::input::{TuiInputView, TuiInputViewEvent}; use crate::input_mode_policy::{self, TuiInputModePolicy}; use crate::input_suggestions_mode::TuiInputSuggestionsModeModel; use crate::keybindings::{ - CONTEXTUAL_PLAN_TOGGLE_BINDING_NAME, KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG, - PLAN_TOGGLE_AVAILABLE_FLAG, PLAN_TOGGLE_BINDING_NAME, TUI_BINDING_GROUP, + ATTACHMENTS_AVAILABLE_FLAG, CONTEXTUAL_PLAN_TOGGLE_BINDING_NAME, + KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG, PLAN_TOGGLE_AVAILABLE_FLAG, PLAN_TOGGLE_BINDING_NAME, + TUI_BINDING_GROUP, }; use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; @@ -102,6 +107,8 @@ const MAX_INPUT_TEXT_ROWS: u16 = 6; const CTRL_C_EXIT_HINT: &str = "ctrl-c again to exit"; const SESSION_CAN_CANCEL_RESTORE_FLAG: &str = "TuiSessionCanCancelRestore"; const SESSION_CAN_HAND_BACK_CONTROL_FLAG: &str = "TuiSessionCanHandBackControl"; +pub(crate) const SESSION_COMPOSER_OWNS_INPUT_FLAG: &str = "TuiSessionComposerOwnsInput"; +pub(crate) const PASTE_IMAGE_BINDING_NAME: &str = "tui:session:paste_image"; /// Events emitted by the TUI terminal session surface. pub(crate) enum TuiTerminalSessionEvent { @@ -240,12 +247,17 @@ pub(crate) enum TuiTerminalSessionAction { ForwardUserPtyBytes(Vec), /// Toggle the latest exposed inline plan. TogglePlan, + /// Move focus from the prompt input into the attachment bar. + FocusAttachments, + /// Read a raw image from the host clipboard and attach it to the next query. + PasteImageFromClipboard, } /// The authenticated terminal/session surface rendered inside [`RootTuiView`]. pub(crate) struct TuiTerminalSessionView { transcript: ViewHandle, input_view: ViewHandle, + attachment_bar: ViewHandle, inline_menus: Vec, suggestions_mode: ModelHandle, conversation_menu: ModelHandle, @@ -343,6 +355,40 @@ pub(crate) fn init(app: &mut AppContext) { ) .with_group(TUI_BINDING_GROUP) .with_key_binding("ctrl-p"), + EditableBinding::new( + FOCUS_ATTACHMENTS_BINDING_NAME, + "Focus image attachments", + TuiTerminalSessionAction::FocusAttachments, + ) + .with_context_predicate( + (id!(TuiInputView::ui_name()) | id!(TuiTerminalSessionView::ui_name())) + & id!(ATTACHMENTS_AVAILABLE_FLAG), + ) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("tab"), + EditableBinding::new( + PASTE_IMAGE_BINDING_NAME, + "Paste an image from the clipboard", + TuiTerminalSessionAction::PasteImageFromClipboard, + ) + .with_context_predicate( + (id!(TuiInputView::ui_name()) | id!(TuiTerminalSessionView::ui_name())) + & id!(SESSION_COMPOSER_OWNS_INPUT_FLAG), + ) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("ctrl-v"), + #[cfg(windows)] + EditableBinding::new( + PASTE_IMAGE_BINDING_NAME, + "Paste an image from the clipboard", + TuiTerminalSessionAction::PasteImageFromClipboard, + ) + .with_context_predicate( + (id!(TuiInputView::ui_name()) | id!(TuiTerminalSessionView::ui_name())) + & id!(SESSION_COMPOSER_OWNS_INPUT_FLAG), + ) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("alt-v"), ]); } @@ -820,9 +866,10 @@ impl TuiTerminalSessionView { let inline_menus_for_input = inline_menus.clone(); let suggestions_mode_for_input = suggestions_mode.clone(); let transcript_for_input = transcript.clone(); + let input_editor_for_input = input_editor_model.clone(); let input_view = ctx.add_typed_action_tui_view(move |ctx| { TuiInputView::new( - input_editor_model, + input_editor_for_input, input_mode_for_input_view, suggestions_mode_for_input, inline_menus_for_input, @@ -831,6 +878,21 @@ impl TuiTerminalSessionView { ) .with_keyboard_enhancement_supported(keyboard_enhancement_supported) }); + let attachment_model = ctx.add_model(|ctx| { + TuiAttachmentModel::new( + context_model.clone(), + ai_input_model.clone(), + input_editor_model, + active_session.clone(), + terminal_surface_id, + ctx, + ) + }); + let attachment_bar = + ctx.add_typed_action_tui_view(|ctx| TuiAttachmentBar::new(attachment_model, ctx)); + ctx.subscribe_to_view(&attachment_bar, |view, _, event, ctx| { + view.handle_attachment_bar_event(event, ctx); + }); ctx.subscribe_to_view(&transcript, |view, _, event, ctx| match event { TuiTranscriptViewEvent::SelectionStarted => { @@ -851,6 +913,11 @@ impl TuiTerminalSessionView { ctx.subscribe_to_view(&input_view, |view, _, event, ctx| match event { TuiInputViewEvent::Submitted(text) => view.handle_submitted(text.clone(), ctx), + TuiInputViewEvent::Pasted(text) => view.handle_pasted(text.clone(), ctx), + TuiInputViewEvent::BackspaceAtEmptyInput => { + view.attachment_bar + .update(ctx, |bar, ctx| bar.remove_selected(ctx)); + } TuiInputViewEvent::AcceptedSlashCommand(action) => { view.handle_accepted_slash_command(action, ctx); } @@ -1069,6 +1136,7 @@ impl TuiTerminalSessionView { Self { transcript, input_view, + attachment_bar, inline_menus, suggestions_mode, conversation_menu, @@ -1470,6 +1538,36 @@ impl TuiTerminalSessionView { } } + fn handle_pasted(&mut self, text: String, ctx: &mut ViewContext) { + let disposition = self + .attachment_bar + .update(ctx, |bar, ctx| bar.try_attach_paste(text.clone(), ctx)); + if disposition == TuiAttachmentPasteDisposition::NotHandled { + self.input_view + .update(ctx, |input, ctx| input.insert_pasted_text(&text, ctx)); + } + } + + fn handle_attachment_bar_event( + &mut self, + event: &TuiAttachmentBarEvent, + ctx: &mut ViewContext, + ) { + match event { + TuiAttachmentBarEvent::AbortInputDetection => self.abort_input_detection(ctx), + TuiAttachmentBarEvent::RequestInputDetection => self.schedule_input_detection(ctx), + TuiAttachmentBarEvent::RestorePastedText(text) => { + self.input_view + .update(ctx, |input, ctx| input.insert_pasted_text(text, ctx)); + } + TuiAttachmentBarEvent::ShowHint(text) => { + self.show_transient_hint(text.clone(), ctx); + } + TuiAttachmentBarEvent::ReturnFocus => ctx.focus(&self.input_view), + } + ctx.notify(); + } + /// Displays `text` in the footer's hint slot for the transient-hint /// duration, then reverts to the persistent content. fn show_transient_hint(&mut self, text: String, ctx: &mut ViewContext) { @@ -2292,7 +2390,11 @@ impl TuiView for TuiTerminalSessionView { } fn child_view_ids(&self, _ctx: &AppContext) -> Vec { - vec![self.transcript.id(), self.input_view.id()] + vec![ + self.transcript.id(), + self.input_view.id(), + self.attachment_bar.id(), + ] } fn keymap_context(&self, ctx: &AppContext) -> keymap::Context { @@ -2309,6 +2411,12 @@ impl TuiView for TuiTerminalSessionView { if self.keyboard_enhancement_supported { context.set.insert(KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG); } + if !self.process_owns_input() && !self.suggestions_mode.as_ref(ctx).mode().is_visible() { + context.set.insert(SESSION_COMPOSER_OWNS_INPUT_FLAG); + if self.attachment_bar.as_ref(ctx).should_render(ctx) { + context.set.insert(ATTACHMENTS_AVAILABLE_FLAG); + } + } context } @@ -2452,6 +2560,17 @@ impl TuiView for TuiTerminalSessionView { } else { builder.accent_border_style() }; + if self.attachment_bar.as_ref(ctx).should_render(ctx) { + content = content.child( + TuiConstrainedBox::new( + TuiContainer::new(TuiChildView::new(&self.attachment_bar).finish()) + .with_padding_x(1) + .finish(), + ) + .with_max_rows(1) + .finish(), + ); + } content = content.child( TuiConstrainedBox::new( TuiContainer::new(TuiChildView::new(&self.input_view).finish()) @@ -2513,6 +2632,15 @@ impl TypedActionView for TuiTerminalSessionView { self.transcript .update(ctx, |transcript, ctx| transcript.toggle_latest_plan(ctx)); } + TuiTerminalSessionAction::FocusAttachments => { + if self.attachment_bar.as_ref(ctx).should_render(ctx) { + ctx.focus(&self.attachment_bar); + } + } + TuiTerminalSessionAction::PasteImageFromClipboard => { + self.attachment_bar + .update(ctx, |bar, ctx| bar.paste_image_from_clipboard(ctx)); + } } } } diff --git a/crates/warp_tui/src/terminal_session_view/input_detection.rs b/crates/warp_tui/src/terminal_session_view/input_detection.rs index b0b3babfea5..dff86ebcbc1 100644 --- a/crates/warp_tui/src/terminal_session_view/input_detection.rs +++ b/crates/warp_tui/src/terminal_session_view/input_detection.rs @@ -104,7 +104,7 @@ impl TuiTerminalSessionView { } } - fn abort_input_detection(&mut self, ctx: &mut ViewContext) { + pub(super) fn abort_input_detection(&mut self, ctx: &mut ViewContext) { if let Some(future) = self.input_detection.future.take() { future.abort(); } diff --git a/crates/warpui/src/platform/mod.rs b/crates/warpui/src/platform/mod.rs index 05c6d37c70f..c53af66d836 100644 --- a/crates/warpui/src/platform/mod.rs +++ b/crates/warpui/src/platform/mod.rs @@ -29,6 +29,23 @@ pub mod current { pub use app::AppBuilder; pub use warpui_core::platform::*; +/// Creates the native system clipboard implementation used by the GUI +/// platform delegate without requiring a graphical event loop. +#[cfg(not(target_family = "wasm"))] +pub fn create_system_clipboard() -> anyhow::Result> { + cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + Ok(Box::new(mac::clipboard::Clipboard::new()?)) + } else if #[cfg(any(target_os = "linux", target_os = "freebsd"))] { + Ok(Box::new(crate::windowing::winit::linux::LinuxClipboard::new()?)) + } else if #[cfg(target_os = "windows")] { + Ok(Box::new(crate::windowing::winit::windows::WindowsClipboard::new()?)) + } else { + anyhow::bail!("System clipboard is unavailable on this platform") + } + } +} + /// Returns whether the current device is a mobile device with touch input. /// /// This is a cross-platform wrapper around the platform-specific implementation.