Skip to content

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

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

feat(cli): image pasting support via Ctrl+V and /paste command#2823
Stranmor wants to merge 2 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 3 times, most recently from 1a5ffa5 to 67d9323 Compare April 7, 2026 23:19
Add clipboard image pasting support for the terminal prompt:
- Ctrl+V keybinding reads images from system clipboard
- /paste slash command as alternative entry point
- Saves pasted images as PNG to data directory
- Fallback chain: wl-paste → xclip → arboard → text URI parsing
- Cross-platform path handling via dirs crate
- Magic byte validation for image format detection

Closes antinomyhq#2811

Co-Authored-By: ForgeCode <noreply@forgecode.dev>
Add conversation title to ForgePrompt left side in yellow brackets.
Set terminal window title via crossterm::SetTitle for tab/tmux visibility.
Truncate long titles to 30 chars with Unicode-safe ellipsis.

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