Skip to content

Commit b6f7819

Browse files
feat: Add image paste support and key binding (#3088)
* feat: Add image paste support and key binding * fix: Run fmt * fix: multiple imports * fix: Add replace png with image crate * fix: simplify approach
1 parent dd12213 commit b6f7819

File tree

11 files changed

+858
-20
lines changed

11 files changed

+858
-20
lines changed

Cargo.lock

Lines changed: 427 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ amzn-consolas-client = { path = "crates/amzn-consolas-client" }
1818
amzn-qdeveloper-streaming-client = { path = "crates/amzn-qdeveloper-streaming-client" }
1919
amzn-toolkit-telemetry-client = { path = "crates/amzn-toolkit-telemetry-client" }
2020
anstream = "0.6.13"
21-
arboard = { version = "3.5.0", default-features = false }
21+
arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }
2222
assert_cmd = "2.0"
2323
async-trait = "0.1.87"
2424
aws-config = "1.0.3"
@@ -74,6 +74,7 @@ parking_lot = "0.12.3"
7474
paste = "1.0.11"
7575
pdf-extract = "0.10.0"
7676
percent-encoding = "2.2.0"
77+
image = "0.25"
7778
predicates = "3.0"
7879
prettyplease = "0.2.32"
7980
quote = "1.0.40"

crates/chat-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ owo-colors.workspace = true
7171
parking_lot.workspace = true
7272
paste.workspace = true
7373
percent-encoding.workspace = true
74+
image.workspace = true
7475
r2d2.workspace = true
7576
r2d2_sqlite.workspace = true
7677
rand.workspace = true

crates/chat-cli/src/cli/chat/cli/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod knowledge;
1111
pub mod logdump;
1212
pub mod mcp;
1313
pub mod model;
14+
pub mod paste;
1415
pub mod persist;
1516
pub mod profile;
1617
pub mod prompts;
@@ -33,6 +34,7 @@ use knowledge::KnowledgeSubcommand;
3334
use logdump::LogdumpArgs;
3435
use mcp::McpArgs;
3536
use model::ModelArgs;
37+
use paste::PasteArgs;
3638
use persist::PersistSubcommand;
3739
use profile::AgentSubcommand;
3840
use prompts::PromptsArgs;
@@ -123,6 +125,8 @@ pub enum SlashCommand {
123125
/// View, manage, and resume to-do lists
124126
#[command(subcommand)]
125127
Todos(TodoSubcommand),
128+
/// Paste an image from clipboard
129+
Paste(PasteArgs),
126130
}
127131

128132
impl SlashCommand {
@@ -191,6 +195,7 @@ impl SlashCommand {
191195
// },
192196
Self::Checkpoint(subcommand) => subcommand.execute(os, session).await,
193197
Self::Todos(subcommand) => subcommand.execute(os, session).await,
198+
Self::Paste(args) => args.execute(os, session).await,
194199
}
195200
}
196201

@@ -223,6 +228,7 @@ impl SlashCommand {
223228
},
224229
Self::Checkpoint(_) => "checkpoint",
225230
Self::Todos(_) => "todos",
231+
Self::Paste(_) => "paste",
226232
}
227233
}
228234

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
use clap::Args;
2+
use crossterm::execute;
3+
use crossterm::style::{
4+
self,
5+
Color,
6+
};
7+
8+
use crate::cli::chat::util::clipboard::paste_image_from_clipboard;
9+
use crate::cli::chat::{
10+
ChatError,
11+
ChatSession,
12+
ChatState,
13+
};
14+
use crate::os::Os;
15+
16+
#[derive(Debug, Args, PartialEq)]
17+
pub struct PasteArgs;
18+
19+
impl PasteArgs {
20+
pub async fn execute(self, _os: &mut Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
21+
match paste_image_from_clipboard() {
22+
Ok(path) => Ok(ChatState::HandleInput {
23+
input: path.display().to_string(),
24+
}),
25+
Err(e) => {
26+
execute!(
27+
session.stderr,
28+
style::SetForegroundColor(Color::Red),
29+
style::Print("❌ Failed to paste image: "),
30+
style::SetForegroundColor(Color::Reset),
31+
style::Print(format!("{}\n", e))
32+
)?;
33+
34+
Ok(ChatState::PromptUser {
35+
skip_printing_tools: false,
36+
})
37+
},
38+
}
39+
}
40+
}

crates/chat-cli/src/cli/chat/input_source.rs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use eyre::Result;
22
use rustyline::error::ReadlineError;
33

44
use super::prompt::{
5+
PasteState,
56
PromptQueryResponseReceiver,
67
PromptQuerySender,
78
rl,
@@ -11,7 +12,10 @@ use super::skim_integration::SkimCommandSelector;
1112
use crate::os::Os;
1213

1314
#[derive(Debug)]
14-
pub struct InputSource(inner::Inner);
15+
pub struct InputSource {
16+
inner: inner::Inner,
17+
paste_state: PasteState,
18+
}
1519

1620
mod inner {
1721
use rustyline::Editor;
@@ -38,12 +42,16 @@ impl Drop for InputSource {
3842
}
3943
impl InputSource {
4044
pub fn new(os: &Os, sender: PromptQuerySender, receiver: PromptQueryResponseReceiver) -> Result<Self> {
41-
Ok(Self(inner::Inner::Readline(rl(os, sender, receiver)?)))
45+
let paste_state = PasteState::new();
46+
Ok(Self {
47+
inner: inner::Inner::Readline(rl(os, sender, receiver, paste_state.clone())?),
48+
paste_state,
49+
})
4250
}
4351

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

@@ -72,7 +80,7 @@ impl InputSource {
7280

7381
use crate::database::settings::Setting;
7482

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

9199
#[allow(dead_code)]
92100
pub fn new_mock(lines: Vec<String>) -> Self {
93-
Self(inner::Inner::Mock { index: 0, lines })
101+
Self {
102+
inner: inner::Inner::Mock { index: 0, lines },
103+
paste_state: PasteState::new(),
104+
}
94105
}
95106

96107
pub fn read_line(&mut self, prompt: Option<&str>) -> Result<Option<String>, ReadlineError> {
97-
match &mut self.0 {
108+
match &mut self.inner {
98109
inner::Inner::Readline(rl) => {
99110
let prompt = prompt.unwrap_or_default();
100111
let curr_line = rl.readline(prompt);
@@ -131,11 +142,21 @@ impl InputSource {
131142
// We're keeping this method for potential future use
132143
#[allow(dead_code)]
133144
pub fn set_buffer(&mut self, content: &str) {
134-
if let inner::Inner::Readline(rl) = &mut self.0 {
145+
if let inner::Inner::Readline(rl) = &mut self.inner {
135146
// Add to history so user can access it with up arrow
136147
let _ = rl.add_history_entry(content);
137148
}
138149
}
150+
151+
/// Check if clipboard pastes were triggered and return all paths
152+
pub fn take_clipboard_pastes(&mut self) -> Vec<std::path::PathBuf> {
153+
self.paste_state.take_all()
154+
}
155+
156+
/// Reset the paste counter (called after submitting a message)
157+
pub fn reset_paste_count(&mut self) {
158+
self.paste_state.reset_count();
159+
}
139160
}
140161

141162
#[cfg(test)]

crates/chat-cli/src/cli/chat/mod.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,27 @@ impl ChatSession {
19131913
None => return Ok(ChatState::Exit),
19141914
};
19151915

1916+
// Check if there's a pending clipboard paste from Ctrl+V
1917+
let pasted_paths = self.input_source.take_clipboard_pastes();
1918+
if !pasted_paths.is_empty() {
1919+
// Check if the input contains image markers
1920+
let image_marker_regex = regex::Regex::new(r"\[Image #\d+\]").unwrap();
1921+
if image_marker_regex.is_match(&user_input) {
1922+
// Join all paths with spaces for processing
1923+
let paths_str = pasted_paths
1924+
.iter()
1925+
.map(|p| p.display().to_string())
1926+
.collect::<Vec<_>>()
1927+
.join(" ");
1928+
1929+
// Reset the counter for next message
1930+
self.input_source.reset_paste_count();
1931+
1932+
// Return HandleInput with all paths to automatically process the images
1933+
return Ok(ChatState::HandleInput { input: paths_str });
1934+
}
1935+
}
1936+
19161937
self.conversation.append_user_transcript(&user_input);
19171938
Ok(ChatState::HandleInput { input: user_input })
19181939
}

crates/chat-cli/src/cli/chat/prompt.rs

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
use std::borrow::Cow;
22
use std::cell::RefCell;
33
use std::path::PathBuf;
4+
use std::sync::{
5+
Arc,
6+
Mutex,
7+
};
48

59
use eyre::Result;
610
use rustyline::completion::{
@@ -46,6 +50,10 @@ use super::tool_manager::{
4650
PromptQuery,
4751
PromptQueryResult,
4852
};
53+
use super::util::clipboard::{
54+
ClipboardError,
55+
paste_image_from_clipboard,
56+
};
4957
use crate::cli::experiment::experiment_manager::{
5058
ExperimentManager,
5159
ExperimentName,
@@ -54,6 +62,41 @@ use crate::database::settings::Setting;
5462
use crate::os::Os;
5563
use crate::util::directories::chat_cli_bash_history_path;
5664

65+
/// Shared state for clipboard paste operations triggered by Ctrl+V
66+
#[derive(Clone, Debug)]
67+
pub struct PasteState {
68+
inner: Arc<Mutex<PasteStateInner>>,
69+
}
70+
71+
#[derive(Debug)]
72+
struct PasteStateInner {
73+
paths: Vec<PathBuf>,
74+
}
75+
76+
impl PasteState {
77+
pub fn new() -> Self {
78+
Self {
79+
inner: Arc::new(Mutex::new(PasteStateInner { paths: Vec::new() })),
80+
}
81+
}
82+
83+
pub fn add(&self, path: PathBuf) -> usize {
84+
let mut inner = self.inner.lock().unwrap();
85+
inner.paths.push(path);
86+
inner.paths.len()
87+
}
88+
89+
pub fn take_all(&self) -> Vec<PathBuf> {
90+
let mut inner = self.inner.lock().unwrap();
91+
std::mem::take(&mut inner.paths)
92+
}
93+
94+
pub fn reset_count(&self) {
95+
let mut inner = self.inner.lock().unwrap();
96+
inner.paths.clear();
97+
}
98+
}
99+
57100
pub const COMMANDS: &[&str] = &[
58101
"/clear",
59102
"/help",
@@ -100,6 +143,7 @@ pub const COMMANDS: &[&str] = &[
100143
"/changelog",
101144
"/save",
102145
"/load",
146+
"/paste",
103147
"/subscribe",
104148
];
105149

@@ -463,10 +507,54 @@ impl Highlighter for ChatHelper {
463507
}
464508
}
465509

510+
/// Handler for pasting images from clipboard via Ctrl+V
511+
///
512+
/// This stores the pasted image path in shared state and inserts a marker.
513+
/// The marker causes readline to return, and the chat loop handles the paste automatically.
514+
struct PasteImageHandler {
515+
paste_state: PasteState,
516+
}
517+
518+
impl PasteImageHandler {
519+
fn new(paste_state: PasteState) -> Self {
520+
Self { paste_state }
521+
}
522+
}
523+
524+
impl rustyline::ConditionalEventHandler for PasteImageHandler {
525+
fn handle(
526+
&self,
527+
_evt: &rustyline::Event,
528+
_n: rustyline::RepeatCount,
529+
_positive: bool,
530+
_ctx: &rustyline::EventContext<'_>,
531+
) -> Option<Cmd> {
532+
match paste_image_from_clipboard() {
533+
Ok(path) => {
534+
// Store the full path in shared state and get the count
535+
let count = self.paste_state.add(path);
536+
537+
// Insert [Image #N] marker so user sees what they're pasting
538+
// User presses Enter to submit
539+
Some(Cmd::Insert(1, format!("[Image #{}]", count)))
540+
},
541+
Err(ClipboardError::NoImage) => {
542+
// Silent fail - no image to paste
543+
Some(Cmd::Noop)
544+
},
545+
Err(_) => {
546+
// Could log error, but don't interrupt user
547+
Some(Cmd::Noop)
548+
},
549+
}
550+
}
551+
}
552+
466553
pub fn rl(
467554
os: &Os,
468555
sender: PromptQuerySender,
469556
receiver: PromptQueryResponseReceiver,
557+
paste_state: PasteState,
470558
) -> Result<Editor<ChatHelper, FileHistory>> {
471559
let edit_mode = match os.database.settings.get_string(Setting::ChatEditMode).as_deref() {
472560
Some("vi" | "vim") => EditMode::Vi,
@@ -549,6 +637,12 @@ pub fn rl(
549637
EventHandler::Simple(Cmd::Insert(1, "/tangent".to_string())),
550638
);
551639

640+
// Add custom keybinding for Ctrl+V to paste images from clipboard
641+
rl.bind_sequence(
642+
KeyEvent(KeyCode::Char('v'), Modifiers::CTRL),
643+
EventHandler::Conditional(Box::new(PasteImageHandler::new(paste_state))),
644+
);
645+
552646
Ok(rl)
553647
}
554648

@@ -891,7 +985,8 @@ mod tests {
891985

892986
// Create a mock Os for testing
893987
let mock_os = crate::os::Os::new().await.unwrap();
894-
let mut test_editor = rl(&mock_os, sender, receiver).unwrap();
988+
let paste_state = PasteState::new();
989+
let mut test_editor = rl(&mock_os, sender, receiver, paste_state).unwrap();
895990

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

0 commit comments

Comments
 (0)