From 6d94e9ca56144e1b92bee36b05a325bde0947f83 Mon Sep 17 00:00:00 2001 From: nocmk2 Date: Tue, 7 Apr 2026 17:34:05 +0800 Subject: [PATCH 1/2] fix(config): update in-memory session config when setting default provider and model set_default_provider_and_model() only persisted the new provider/model to disk via update_environment() but did not update the in-memory self.config. This caused subsequent get_default_model() and get_default_provider() calls within the same process to return stale values. After switching models via /model (which crosses providers), the TUI and zsh rprompt would still show the old model until the process was restarted. Now the in-memory config is updated immediately after the disk write, matching the behavior of set_default_model() and set_default_provider(). Co-Authored-By: ForgeCode --- crates/forge_services/src/app_config.rs | 57 ++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index eaefedd83f..c78b133cd0 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -114,8 +114,16 @@ impl AppConfigService provider_id: ProviderId, model: ModelId, ) -> anyhow::Result<()> { - self.update(ConfigOperation::SetModel(provider_id, model)) - .await + self.update(ConfigOperation::SetModel( + provider_id.clone(), + model.clone(), + )) + .await?; + let mut config = self.config.lock().unwrap(); + let session = config.session.get_or_insert_with(Default::default); + session.provider_id = Some(provider_id.as_ref().to_string()); + session.model_id = Some(model.to_string()); + Ok(()) } async fn get_commit_config(&self) -> anyhow::Result> { @@ -551,4 +559,49 @@ mod tests { assert_eq!(actual_model, ModelId::new("claude-3")); Ok(()) } + + #[tokio::test] + async fn test_set_default_provider_and_model_updates_in_memory() -> anyhow::Result<()> { + let fixture = MockInfra::new(); + let service = + ForgeAppConfigService::new(Arc::new(fixture.clone()), ForgeConfig::default()); + + // Set both provider and model atomically + service + .set_default_provider_and_model(ProviderId::OPENAI, ModelId::new("gpt-4")) + .await?; + + // Verify both provider and model are immediately available in-memory + let actual_provider = service.get_default_provider().await?; + let actual_model = service.get_provider_model(None).await?; + + assert_eq!(actual_provider, ProviderId::OPENAI); + assert_eq!(actual_model, ModelId::new("gpt-4")); + Ok(()) + } + + #[tokio::test] + async fn test_set_default_provider_and_model_then_change_model() -> anyhow::Result<()> { + let fixture = MockInfra::new(); + let service = + ForgeAppConfigService::new(Arc::new(fixture.clone()), ForgeConfig::default()); + + // Set both provider and model atomically (first-time setup flow) + service + .set_default_provider_and_model(ProviderId::OPENAI, ModelId::new("gpt-4")) + .await?; + + // Then change just the model (user running /model) + service + .set_default_model("gpt-4o".to_string().into()) + .await?; + + // Provider should remain OpenAI, model should be updated + let actual_provider = service.get_default_provider().await?; + let actual_model = service.get_provider_model(None).await?; + + assert_eq!(actual_provider, ProviderId::OPENAI); + assert_eq!(actual_model, ModelId::new("gpt-4o")); + Ok(()) + } } From f780315dc26ea1a44e342778496fba868ebe1909 Mon Sep 17 00:00:00 2001 From: nocmk2 Date: Wed, 8 Apr 2026 14:02:55 +0800 Subject: [PATCH 2/2] feat: implement paste image from clipboard - Added 'forge paste-image' subcommand to save clipboard images and output @[path]. - Added /paste-image (/pv) REPL command to save image and add to prompt buffer. - Added Ctrl-X Ctrl-V shortcut to ZSH plugin for easy image pasting. - Integrated with arboard and image crates for cross-platform support. Co-Authored-By: ForgeCode --- Cargo.lock | 425 ++++++++++++++++++- crates/forge_main/Cargo.toml | 1 + crates/forge_main/src/built_in_commands.json | 4 + crates/forge_main/src/cli.rs | 7 + crates/forge_main/src/model.rs | 9 +- crates/forge_main/src/ui.rs | 74 ++++ shell-plugin/forge.plugin.zsh | 1 + shell-plugin/lib/actions/image.zsh | 30 ++ shell-plugin/lib/bindings.zsh | 7 + shell-plugin/lib/dispatcher.zsh | 4 + 10 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 shell-plugin/lib/actions/image.zsh diff --git a/Cargo.lock b/Cargo.lock index e9b0b0b366..4cba8baea4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -82,6 +100,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arboard" version = "3.6.1" @@ -102,6 +126,17 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -114,6 +149,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ascii" version = "1.1.0" @@ -221,6 +265,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey 0.1.1", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-credential-types" version = "1.2.14" @@ -580,6 +667,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -595,6 +688,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -624,6 +726,12 @@ dependencies = [ "serde", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.19.1" @@ -831,6 +939,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -1022,6 +1136,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1719,6 +1842,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1798,6 +1941,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fake" version = "5.1.0" @@ -2179,6 +2337,7 @@ dependencies = [ "forge_walker", "futures", "humantime", + "image", "include_dir", "indexmap 2.13.0", "insta", @@ -2681,6 +2840,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "glob" version = "0.3.3" @@ -3421,12 +3590,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "include_dir" version = "0.7.4" @@ -3504,6 +3699,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -3704,12 +3910,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libredox" version = "0.1.12" @@ -3787,6 +4009,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3860,6 +4091,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -4111,6 +4352,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "ntapi" version = "0.4.1" @@ -4150,12 +4397,33 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-format" version = "0.4.4" @@ -4190,6 +4458,17 @@ dependencies = [ "num-modular", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4408,6 +4687,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pastey" version = "0.2.1" @@ -4701,6 +4986,25 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "prost" version = "0.14.3" @@ -4784,6 +5088,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -4967,6 +5280,56 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "rayon" version = "1.11.0" @@ -5263,6 +5626,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -5289,7 +5658,7 @@ dependencies = [ "futures", "http 1.4.0", "oauth2", - "pastey", + "pastey 0.2.1", "pin-project-lite", "process-wrap", "reqwest 0.12.28", @@ -5944,6 +6313,15 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "similar" version = "2.7.0" @@ -6490,7 +6868,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -7136,6 +7514,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -8171,6 +8560,12 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yaml-rust" version = "0.4.5" @@ -8312,11 +8707,35 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", ] diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index 9357f0da16..894e397d71 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -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"] } diff --git a/crates/forge_main/src/built_in_commands.json b/crates/forge_main/src/built_in_commands.json index 2d6b45ef30..9921612514 100644 --- a/crates/forge_main/src/built_in_commands.json +++ b/crates/forge_main/src/built_in_commands.json @@ -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" diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index de3285fdaa..adcddf1346 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -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, + }, + /// Update forge to the latest version. Update(UpdateArgs), diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index 83dd9f88cd..cee8941981 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -92,6 +92,7 @@ impl ForgeCommandManager { | "dump" | "model" | "tools" + | "paste-image" | "login" | "logout" | "retry" @@ -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), @@ -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 @@ -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", diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 08aa510f53..37f0942377 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -387,8 +387,56 @@ impl A + Send + Sync> UI Ok(()) } + async fn on_paste_image_core(&mut self, output: Option) -> Result { + #[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 => { @@ -1996,6 +2044,9 @@ impl 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?; @@ -2185,6 +2236,29 @@ impl 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. /// diff --git a/shell-plugin/forge.plugin.zsh b/shell-plugin/forge.plugin.zsh index e877afdff7..d6f09714bb 100755 --- a/shell-plugin/forge.plugin.zsh +++ b/shell-plugin/forge.plugin.zsh @@ -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" diff --git a/shell-plugin/lib/actions/image.zsh b/shell-plugin/lib/actions/image.zsh new file mode 100644 index 0000000000..ecfcf28fb6 --- /dev/null +++ b/shell-plugin/lib/actions/image.zsh @@ -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 +} diff --git a/shell-plugin/lib/bindings.zsh b/shell-plugin/lib/bindings.zsh index 5100f3fb5b..b9c0f811af 100644 --- a/shell-plugin/lib/bindings.zsh +++ b/shell-plugin/lib/bindings.zsh @@ -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 @@ -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 diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index 67f46340f9..a5830b2541 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -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" ;;