Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
468 changes: 457 additions & 11 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ amzn-consolas-client = { path = "crates/amzn-consolas-client" }
amzn-qdeveloper-streaming-client = { path = "crates/amzn-qdeveloper-streaming-client" }
amzn-toolkit-telemetry-client = { path = "crates/amzn-toolkit-telemetry-client" }
anstream = "0.6.13"
arboard = { version = "3.5.0", default-features = false }
arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }
assert_cmd = "2.0"
async-trait = "0.1.87"
aws-config = "1.0.3"
Expand Down Expand Up @@ -73,6 +73,7 @@ owo-colors = "4.2.0"
parking_lot = "0.12.3"
paste = "1.0.11"
percent-encoding = "2.2.0"
image = "0.25"
predicates = "3.0"
prettyplease = "0.2.32"
quote = "1.0.40"
Expand Down
1 change: 1 addition & 0 deletions crates/chat-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ owo-colors.workspace = true
parking_lot.workspace = true
paste.workspace = true
percent-encoding.workspace = true
image.workspace = true
r2d2.workspace = true
r2d2_sqlite.workspace = true
rand.workspace = true
Expand Down
6 changes: 6 additions & 0 deletions crates/chat-cli/src/cli/chat/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod knowledge;
pub mod logdump;
pub mod mcp;
pub mod model;
pub mod paste;
pub mod persist;
pub mod profile;
pub mod prompts;
Expand All @@ -32,6 +33,7 @@ use knowledge::KnowledgeSubcommand;
use logdump::LogdumpArgs;
use mcp::McpArgs;
use model::ModelArgs;
use paste::PasteArgs;
use persist::PersistSubcommand;
use profile::AgentSubcommand;
use prompts::PromptsArgs;
Expand Down Expand Up @@ -122,6 +124,8 @@ pub enum SlashCommand {
/// View, manage, and resume to-do lists
#[command(subcommand)]
Todos(TodoSubcommand),
/// Paste an image from clipboard
Paste(PasteArgs),
}

impl SlashCommand {
Expand Down Expand Up @@ -190,6 +194,7 @@ impl SlashCommand {
// },
Self::Checkpoint(subcommand) => subcommand.execute(os, session).await,
Self::Todos(subcommand) => subcommand.execute(os, session).await,
Self::Paste(args) => args.execute(os, session).await,
}
}

Expand Down Expand Up @@ -222,6 +227,7 @@ impl SlashCommand {
},
Self::Checkpoint(_) => "checkpoint",
Self::Todos(_) => "todos",
Self::Paste(_) => "paste",
}
}

Expand Down
40 changes: 40 additions & 0 deletions crates/chat-cli/src/cli/chat/cli/paste.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use clap::Args;
use crossterm::execute;
use crossterm::style::{
self,
Color,
};

use crate::cli::chat::util::clipboard::paste_image_from_clipboard;
use crate::cli::chat::{
ChatError,
ChatSession,
ChatState,
};
use crate::os::Os;

#[derive(Debug, Args, PartialEq)]
pub struct PasteArgs;

impl PasteArgs {
pub async fn execute(self, _os: &mut Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
match paste_image_from_clipboard() {
Ok(path) => Ok(ChatState::HandleInput {
input: path.display().to_string(),
}),
Err(e) => {
execute!(
session.stderr,
style::SetForegroundColor(Color::Red),
style::Print("❌ Failed to paste image: "),
style::SetForegroundColor(Color::Reset),
style::Print(format!("{}\n", e))
)?;

Ok(ChatState::PromptUser {
skip_printing_tools: false,
})
},
}
}
}
35 changes: 28 additions & 7 deletions crates/chat-cli/src/cli/chat/input_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use eyre::Result;
use rustyline::error::ReadlineError;

use super::prompt::{
PasteState,
PromptQueryResponseReceiver,
PromptQuerySender,
rl,
Expand All @@ -11,7 +12,10 @@ use super::skim_integration::SkimCommandSelector;
use crate::os::Os;

#[derive(Debug)]
pub struct InputSource(inner::Inner);
pub struct InputSource {
inner: inner::Inner,
paste_state: PasteState,
}

mod inner {
use rustyline::Editor;
Expand All @@ -38,12 +42,16 @@ impl Drop for InputSource {
}
impl InputSource {
pub fn new(os: &Os, sender: PromptQuerySender, receiver: PromptQueryResponseReceiver) -> Result<Self> {
Ok(Self(inner::Inner::Readline(rl(os, sender, receiver)?)))
let paste_state = PasteState::new();
Ok(Self {
inner: inner::Inner::Readline(rl(os, sender, receiver, paste_state.clone())?),
paste_state,
})
}

/// Save history to file
pub fn save_history(&mut self) -> Result<()> {
if let inner::Inner::Readline(rl) = &mut self.0 {
if let inner::Inner::Readline(rl) = &mut self.inner {
if let Some(helper) = rl.helper() {
let history_path = helper.get_history_path();

Expand Down Expand Up @@ -72,7 +80,7 @@ impl InputSource {

use crate::database::settings::Setting;

if let inner::Inner::Readline(rl) = &mut self.0 {
if let inner::Inner::Readline(rl) = &mut self.inner {
let key_char = match os.database.settings.get_string(Setting::SkimCommandKey) {
Some(key) if key.len() == 1 => key.chars().next().unwrap_or('s'),
_ => 's', // Default to 's' if setting is missing or invalid
Expand All @@ -90,11 +98,14 @@ impl InputSource {

#[allow(dead_code)]
pub fn new_mock(lines: Vec<String>) -> Self {
Self(inner::Inner::Mock { index: 0, lines })
Self {
inner: inner::Inner::Mock { index: 0, lines },
paste_state: PasteState::new(),
}
}

pub fn read_line(&mut self, prompt: Option<&str>) -> Result<Option<String>, ReadlineError> {
match &mut self.0 {
match &mut self.inner {
inner::Inner::Readline(rl) => {
let prompt = prompt.unwrap_or_default();
let curr_line = rl.readline(prompt);
Expand Down Expand Up @@ -131,11 +142,21 @@ impl InputSource {
// We're keeping this method for potential future use
#[allow(dead_code)]
pub fn set_buffer(&mut self, content: &str) {
if let inner::Inner::Readline(rl) = &mut self.0 {
if let inner::Inner::Readline(rl) = &mut self.inner {
// Add to history so user can access it with up arrow
let _ = rl.add_history_entry(content);
}
}

/// Check if clipboard pastes were triggered and return all paths
pub fn take_clipboard_pastes(&mut self) -> Vec<std::path::PathBuf> {
self.paste_state.take_all()
}

/// Reset the paste counter (called after submitting a message)
pub fn reset_paste_count(&mut self) {
self.paste_state.reset_count();
}
}

#[cfg(test)]
Expand Down
21 changes: 21 additions & 0 deletions crates/chat-cli/src/cli/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1907,6 +1907,27 @@ impl ChatSession {
None => return Ok(ChatState::Exit),
};

// Check if there's a pending clipboard paste from Ctrl+V
let pasted_paths = self.input_source.take_clipboard_pastes();
if !pasted_paths.is_empty() {
// Check if the input contains image markers
let image_marker_regex = regex::Regex::new(r"\[Image #\d+\]").unwrap();
if image_marker_regex.is_match(&user_input) {
// Join all paths with spaces for processing
let paths_str = pasted_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(" ");

// Reset the counter for next message
self.input_source.reset_paste_count();

// Return HandleInput with all paths to automatically process the images
return Ok(ChatState::HandleInput { input: paths_str });
}
}

self.conversation.append_user_transcript(&user_input);
Ok(ChatState::HandleInput { input: user_input })
}
Expand Down
97 changes: 96 additions & 1 deletion crates/chat-cli/src/cli/chat/prompt.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::path::PathBuf;
use std::sync::{
Arc,
Mutex,
};

use eyre::Result;
use rustyline::completion::{
Expand Down Expand Up @@ -46,6 +50,10 @@ use super::tool_manager::{
PromptQuery,
PromptQueryResult,
};
use super::util::clipboard::{
ClipboardError,
paste_image_from_clipboard,
};
use crate::cli::experiment::experiment_manager::{
ExperimentManager,
ExperimentName,
Expand All @@ -54,6 +62,41 @@ use crate::database::settings::Setting;
use crate::os::Os;
use crate::util::directories::chat_cli_bash_history_path;

/// Shared state for clipboard paste operations triggered by Ctrl+V
#[derive(Clone, Debug)]
pub struct PasteState {
inner: Arc<Mutex<PasteStateInner>>,
}

#[derive(Debug)]
struct PasteStateInner {
paths: Vec<PathBuf>,
}

impl PasteState {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(PasteStateInner { paths: Vec::new() })),
}
}

pub fn add(&self, path: PathBuf) -> usize {
let mut inner = self.inner.lock().unwrap();
inner.paths.push(path);
inner.paths.len()
}

pub fn take_all(&self) -> Vec<PathBuf> {
let mut inner = self.inner.lock().unwrap();
std::mem::take(&mut inner.paths)
}

pub fn reset_count(&self) {
let mut inner = self.inner.lock().unwrap();
inner.paths.clear();
}
}

pub const COMMANDS: &[&str] = &[
"/clear",
"/help",
Expand Down Expand Up @@ -100,6 +143,7 @@ pub const COMMANDS: &[&str] = &[
"/changelog",
"/save",
"/load",
"/paste",
"/subscribe",
];

Expand Down Expand Up @@ -463,10 +507,54 @@ impl Highlighter for ChatHelper {
}
}

/// Handler for pasting images from clipboard via Ctrl+V
///
/// This stores the pasted image path in shared state and inserts a marker.
/// The marker causes readline to return, and the chat loop handles the paste automatically.
struct PasteImageHandler {
paste_state: PasteState,
}

impl PasteImageHandler {
fn new(paste_state: PasteState) -> Self {
Self { paste_state }
}
}

impl rustyline::ConditionalEventHandler for PasteImageHandler {
fn handle(
&self,
_evt: &rustyline::Event,
_n: rustyline::RepeatCount,
_positive: bool,
_ctx: &rustyline::EventContext<'_>,
) -> Option<Cmd> {
match paste_image_from_clipboard() {
Ok(path) => {
// Store the full path in shared state and get the count
let count = self.paste_state.add(path);

// Insert [Image #N] marker so user sees what they're pasting
// User presses Enter to submit
Some(Cmd::Insert(1, format!("[Image #{}]", count)))
},
Err(ClipboardError::NoImage) => {
// Silent fail - no image to paste
Some(Cmd::Noop)
},
Err(_) => {
// Could log error, but don't interrupt user
Some(Cmd::Noop)
},
}
}
}

pub fn rl(
os: &Os,
sender: PromptQuerySender,
receiver: PromptQueryResponseReceiver,
paste_state: PasteState,
) -> Result<Editor<ChatHelper, FileHistory>> {
let edit_mode = match os.database.settings.get_string(Setting::ChatEditMode).as_deref() {
Some("vi" | "vim") => EditMode::Vi,
Expand Down Expand Up @@ -549,6 +637,12 @@ pub fn rl(
EventHandler::Simple(Cmd::Insert(1, "/tangent".to_string())),
);

// Add custom keybinding for Ctrl+V to paste images from clipboard
rl.bind_sequence(
KeyEvent(KeyCode::Char('v'), Modifiers::CTRL),
EventHandler::Conditional(Box::new(PasteImageHandler::new(paste_state))),
);

Ok(rl)
}

Expand Down Expand Up @@ -864,7 +958,8 @@ mod tests {

// Create a mock Os for testing
let mock_os = crate::os::Os::new().await.unwrap();
let mut test_editor = rl(&mock_os, sender, receiver).unwrap();
let paste_state = PasteState::new();
let mut test_editor = rl(&mock_os, sender, receiver, paste_state).unwrap();

// Reserved Emacs keybindings that should not be overridden
let reserved_keys = ['a', 'e', 'f', 'b', 'k'];
Expand Down
Loading