Skip to content

feat(cli): image pasting support via Ctrl+V and /paste command#2823

Open
Stranmor wants to merge 12 commits intoantinomyhq:mainfrom
Stranmor:pr/image-pasting
Open

feat(cli): image pasting support via Ctrl+V and /paste command#2823
Stranmor wants to merge 12 commits intoantinomyhq:mainfrom
Stranmor:pr/image-pasting

Conversation

@Stranmor
Copy link
Copy Markdown

@Stranmor Stranmor commented Apr 3, 2026

Closes #2811

This PR adds support for inserting images directly into the chat prompt from the clipboard.

Features

  • Binds Ctrl+V in the prompt to read an image from the clipboard.
  • Adds /paste slash command as an alternative way to paste images.
  • Images are saved as PNG in ~/.local/share/forge/images and inserted as @[/path/to/image.png].
  • Implements a robust clipboard fetching fallback:
    1. Tries native Wayland/X11 tools (wl-paste, xclip) for accurate pixel extraction.
    2. Falls back to arboard if native tools aren't present.
    3. Checks if the clipboard contains file URIs (file://...) or absolute paths if the user copied image files directly from a file manager.

@github-actions github-actions bot added the type: feature Brand new functionality, features, pages, workflows, endpoints, etc. label Apr 3, 2026
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 3, 2026

CLA assistant check
All committers have signed the CLA.

Comment on lines +153 to +159
let line = if (line.starts_with('"') && line.ends_with('"'))
|| (line.starts_with('\'') && line.ends_with('\''))
{
&line[1..line.len() - 1]
} else {
line
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slice indexing panic when line is a single quote character

If the clipboard contains a single " or ' character, this code will panic at runtime with an index out of bounds error.

When line = "\"" (length 1):

  • line.starts_with('"') returns true
  • line.ends_with('"') returns true
  • &line[1..line.len() - 1] becomes &line[1..0], which is an invalid slice range

Fix:

let line = if line.len() >= 2 && ((line.starts_with('"') && line.ends_with('"'))
    || (line.starts_with('\'') && line.ends_with('\''))) 
{
    &line[1..line.len() - 1]
} else {
    line
};
Suggested change
let line = if (line.starts_with('"') && line.ends_with('"'))
|| (line.starts_with('\'') && line.ends_with('\''))
{
&line[1..line.len() - 1]
} else {
line
};
let line = if line.len() >= 2 && ((line.starts_with('"') && line.ends_with('"'))
|| (line.starts_with('\'') && line.ends_with('\'')))
{
&line[1..line.len() - 1]
} else {
line
};

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +117 to +124
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/forge_paste.log")
{
let _ = writeln!(&mut f, "Received !forge_internal_paste_image");
use std::io::Write;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug logging code was left in production. This will create and append to /tmp/forge_paste.log on every Ctrl+V press, causing unnecessary disk I/O and potential disk space issues over time.

// Remove this debug code block entirely:
if let Ok(mut f) = std::fs::OpenOptions::new()
    .create(true)
    .append(true)
    .open("/tmp/forge_paste.log")
{
    let _ = writeln!(&mut f, "Received !forge_internal_paste_image");
    use std::io::Write;
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

use url::Url;

fn get_images_dir() -> Option<PathBuf> {
let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-platform compatibility issue. Using HOME environment variable with /tmp fallback will fail on Windows where neither exists. The path /tmp doesn't exist on Windows and would cause directory creation to fail, breaking the paste functionality.

// Use platform-appropriate directories:
let home_dir = if cfg!(windows) {
    std::env::var("USERPROFILE").unwrap_or_else(|_| std::env::var("TEMP").unwrap_or_else(|_| "C:\\Temp".to_string()))
} else {
    std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())
};
Suggested change
let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let home_dir = if cfg!(windows) {
std::env::var("USERPROFILE").unwrap_or_else(|_| {
std::env::var("TEMP").unwrap_or_else(|_| "C:\\Temp".to_string())
})
} else {
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())
};

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +73 to 82
async fn get_custom_instructions(&self) -> Vec<String> {
let paths = self.cache.get_or_init(|| self.discover_agents_files()).await;

let mut custom_instructions = Vec::new();

for path in paths {
if let Ok(content) = self.infra.read_utf8(&path).await {
if let Ok(content) = self.infra.read_utf8(path).await {
custom_instructions.push(content);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache behavior has changed from caching file contents to only caching file paths. This means files are re-read from disk on every get_custom_instructions() call instead of being cached in memory. While this may be intentional to pick up file changes, it significantly reduces cache effectiveness and introduces repeated file I/O.

If the old caching behavior is desired, the cache type should remain Vec<String> and cache the actual content:

cache: tokio::sync::OnceCell<Vec<String>>,

async fn init(&self) -> Vec<String> {
    let paths = self.discover_agents_files().await;
    let mut custom_instructions = Vec::new();
    for path in paths {
        if let Ok(content) = self.infra.read_utf8(&path).await {
            custom_instructions.push(content);
        }
    }
    custom_instructions
}

async fn get_custom_instructions(&self) -> Vec<String> {
    self.cache.get_or_init(|| self.init()).await.clone()
}
Suggested change
async fn get_custom_instructions(&self) -> Vec<String> {
let paths = self.cache.get_or_init(|| self.discover_agents_files()).await;
let mut custom_instructions = Vec::new();
for path in paths {
if let Ok(content) = self.infra.read_utf8(&path).await {
if let Ok(content) = self.infra.read_utf8(path).await {
custom_instructions.push(content);
}
}
async fn get_custom_instructions(&self) -> Vec<String> {
self.cache.get_or_init(|| async {
let paths = self.discover_agents_files().await;
let mut custom_instructions = Vec::new();
for path in paths {
if let Ok(content) = self.infra.read_utf8(&path).await {
custom_instructions.push(content);
}
}
custom_instructions
}).await.clone()
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@Stranmor Stranmor force-pushed the pr/image-pasting branch 2 times, most recently from e6a47dd to 1a5ffa5 Compare April 7, 2026 23:19
…g, cross-platform paths

Co-Authored-By: ForgeCode <noreply@forgecode.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature Brand new functionality, features, pages, workflows, endpoints, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Passing images from clipboard to the agent

2 participants