Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
425 changes: 422 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/forge_main/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ windows-sys = { version = "0.61", features = ["Win32_System_Console"] }

[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = "3.4"
image = "0.25"

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time", "test-util"] }
Expand Down
4 changes: 4 additions & 0 deletions crates/forge_main/src/built_in_commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
"command": "retry",
"description": "Retry the last command [alias: r]"
},
{
"command": "paste-image",
"description": "Paste image from clipboard [alias: pv]"
},
{
"command": "compact",
"description": "Compact the conversation context"
Expand Down
7 changes: 7 additions & 0 deletions crates/forge_main/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ pub enum TopLevelCommand {
#[command(subcommand)]
Vscode(VscodeCommand),

/// Paste image from clipboard and return the @[path] string.
PasteImage {
/// Optional path to save the image to.
#[arg(long, short = 'o')]
output: Option<PathBuf>,
},

/// Update forge to the latest version.
Update(UpdateArgs),

Expand Down
9 changes: 8 additions & 1 deletion crates/forge_main/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ impl ForgeCommandManager {
| "dump"
| "model"
| "tools"
| "paste-image"
| "login"
| "logout"
| "retry"
Expand Down Expand Up @@ -273,6 +274,7 @@ impl ForgeCommandManager {
"/provider" => Ok(SlashCommand::Provider),
"/tools" => Ok(SlashCommand::Tools),
"/agent" => Ok(SlashCommand::Agent),
"/pv" | "/paste-image" => Ok(SlashCommand::PasteImage),
"/login" => Ok(SlashCommand::Login),
"/logout" => Ok(SlashCommand::Logout),
"/retry" => Ok(SlashCommand::Retry),
Expand Down Expand Up @@ -418,11 +420,15 @@ pub enum SlashCommand {
#[strum(props(usage = "Allows you to configure provider"))]
Login,

/// Logs out from the configured provider
/// Logs out from the configured provider
#[strum(props(usage = "Logout from configured provider"))]
Logout,

/// Paste an image from the clipboard
#[strum(props(usage = "Paste an image from the clipboard"))]
PasteImage,
/// Retry without modifying model context

#[strum(props(usage = "Retry the last command"))]
Retry,
/// List all conversations for the active workspace
Expand Down Expand Up @@ -479,6 +485,7 @@ impl SlashCommand {
SlashCommand::Custom(event) => &event.name,
SlashCommand::Shell(_) => "!shell",
SlashCommand::Agent => "agent",
SlashCommand::PasteImage => "paste-image",
SlashCommand::Login => "login",
SlashCommand::Logout => "logout",
SlashCommand::Retry => "retry",
Expand Down
74 changes: 74 additions & 0 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,56 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
Ok(())
}

async fn on_paste_image_core(&mut self, output: Option<PathBuf>) -> Result<PathBuf> {
#[cfg(not(target_os = "android"))]
{
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| anyhow::anyhow!("Failed to initialize clipboard: {}", e))?;

match clipboard.get_image() {
Ok(image) => {
let path = if let Some(path) = output {
path
} else {
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let filename = format!("pasted_image_{}.png", timestamp);
self.api.environment().cwd.join(&filename)
};

// Use 'image' crate to save the PNG
image::save_buffer(
&path,
&image.bytes,
image.width as u32,
image.height as u32,
image::ColorType::Rgba8,
)
.map_err(|e| {
anyhow::anyhow!("Failed to save image to {}: {}", path.display(), e)
})?;
Ok(path)
}
Err(arboard::Error::ContentNotAvailable) => {
anyhow::bail!("No image found in clipboard.")
}
Err(e) => {
anyhow::bail!("Failed to read image from clipboard: {}", e)
}
}
}
#[cfg(target_os = "android")]
{
anyhow::bail!("Clipboard image access not supported on Android")
}
}

async fn handle_subcommands(&mut self, subcommand: TopLevelCommand) -> anyhow::Result<()> {
match subcommand {
TopLevelCommand::PasteImage { output } => {
let path = self.on_paste_image_core(output).await?;
println!("@[{}]", path.display());
return Ok(());
}
TopLevelCommand::Agent(agent_group) => {
match agent_group.command {
crate::cli::AgentCommand::List => {
Expand Down Expand Up @@ -1996,6 +2044,9 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
SlashCommand::Usage => {
self.on_usage().await?;
}
SlashCommand::PasteImage => {
self.handle_paste_image().await?;
}
SlashCommand::Message(ref content) => {
self.spinner.start(None)?;
self.on_message(Some(content.clone())).await?;
Expand Down Expand Up @@ -2185,6 +2236,29 @@ impl<A: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> A + Send + Sync> UI
Ok(())
}

async fn handle_paste_image(&mut self) -> anyhow::Result<()> {
match self.on_paste_image_core(None).await {
Ok(path) => {
let filename = path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("pasted_image.png");
let tag = format!("@[{}]", filename);
self.console.set_buffer(tag);
self.writeln(format!(
"{} Image saved to {} and added to prompt buffer.",
"✓".green(),
filename.yellow()
))?;
Ok(())
}
Err(e) => {
self.writeln_title(TitleFormat::error(e.to_string()))?;
Ok(())
}
}
}

/// Select a model from all configured providers using porcelain-style
/// tabular display matching the shell plugin's `:model` UI.
///
Expand Down
1 change: 1 addition & 0 deletions shell-plugin/forge.plugin.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ source "${0:A:h}/lib/actions/git.zsh"
source "${0:A:h}/lib/actions/auth.zsh"
source "${0:A:h}/lib/actions/editor.zsh"
source "${0:A:h}/lib/actions/provider.zsh"
source "${0:A:h}/lib/actions/image.zsh"
source "${0:A:h}/lib/actions/doctor.zsh"
source "${0:A:h}/lib/actions/keyboard.zsh"

Expand Down
30 changes: 30 additions & 0 deletions shell-plugin/lib/actions/image.zsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env zsh

# Image action handlers

# Action handler: Paste image from clipboard
function _forge_action_paste_image() {
# Call forge paste-image to save the image and get the @[path] string
local paste_output
paste_output=$(_forge_exec paste-image 2>/dev/null)

if [[ $? -eq 0 && -n "$paste_output" ]]; then
# Append to existing text or replace
local input_text="$1"
if [[ -n "$input_text" ]]; then
BUFFER="$input_text $paste_output"
elif [[ -n "$BUFFER" && "$WIDGET" != "forge-accept-line" ]]; then
# If called from a keybinding and buffer is not empty
BUFFER="$BUFFER $paste_output"
else
BUFFER="$paste_output"
fi
CURSOR=${#BUFFER}
# Only call zle if it exists
[[ -n "$WIDGET" ]] && zle reset-prompt
else
echo
_forge_log error "No image found in clipboard or failed to save."
_forge_reset
fi
}
7 changes: 7 additions & 0 deletions shell-plugin/lib/bindings.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# Register ZLE widgets
zle -N forge-accept-line
zle -N forge-completion
zle -N forge-paste-image

# Custom bracketed-paste handler to fix syntax highlighting after paste
# Addresses timing issues by ensuring buffer state stabilizes before prompt reset
Expand All @@ -29,3 +30,9 @@ bindkey '^M' forge-accept-line
bindkey '^J' forge-accept-line
# Update the Tab binding to use the new completion widget
bindkey '^I' forge-completion # Tab for both @ and :command completion

# Key handler: Paste image from clipboard
function forge-paste-image() {
_forge_action_paste_image
}
bindkey '^X^V' forge-paste-image
4 changes: 4 additions & 0 deletions shell-plugin/lib/dispatcher.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ function forge-accept-line() {
provider-login|login|provider)
_forge_action_login "$input_text"
;;
paste-image|pv)
_forge_action_paste_image "$input_text"
return
;;
logout)
_forge_action_logout "$input_text"
;;
Expand Down
Loading