From 3a43b7264cab1cb0d40495b7ddd1016ccd3b130a Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 01:51:38 +0800 Subject: [PATCH 1/7] chore(branding): swap anvil icon for gear, FORGE for GRAFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zsh-setup visual check and `graff zsh doctor` still rendered the legacy `󱙺 FORGE 33.0k` line; replace with ` GRAFF 33.0k` to match the rest of the rebrand. Cosmetic only — internal symbols, env vars, and crate names are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_main/src/ui.rs | 6 +++--- shell-plugin/doctor.zsh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 8bba15c1..522d253c 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1780,9 +1780,9 @@ impl A + Send + Sync> UI println!(); println!( "{} {} {}", - "󱙺".bold(), - "FORGE 33.0k".bold(), - " tonic-1.0".cyan() + "".bold(), + "GRAFF 33.0k".bold(), + " tonic-1.0".cyan() ); let can_see_nerd_fonts = diff --git a/shell-plugin/doctor.zsh b/shell-plugin/doctor.zsh index ca9ad782..cc9831ca 100644 --- a/shell-plugin/doctor.zsh +++ b/shell-plugin/doctor.zsh @@ -570,7 +570,7 @@ fi if [[ "$nerd_font_disabled" == "false" ]]; then echo "" echo "$(yellow "Visual Check [Manual Verification Required]")" -echo " $(bold "󱙺 GRAFF 33.0k") $(cyan " tonic-1.0")" +echo " $(bold " GRAFF 33.0k") $(cyan " tonic-1.0")" echo "" echo " Graff uses Nerd Fonts to enrich cli experience, can you see all the icons clearly without any overlap?" echo " If you see boxes (□) or question marks (?), install a Nerd Font from:" From a216c697b35928df42561e2a090464758e7c0087 Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 02:16:24 +0800 Subject: [PATCH 2/7] chore(rebrand): show GRAFF label with gear icon in zsh rprompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right-hand prompt was rendering `󱙺 FORGE` (anvil + UpperSnake of the agent ID) — the anvil glyph plus the visible brand. Swap the agent symbol from `\u{f167a}` (nf-md-anvil) to `\u{f013}` (nf-fa-cog) and special-case the `forge` agent ID to render as `GRAFF` instead of `FORGE`. The internal agent ID stays `forge` so `:forge` slash commands and config keep working. Test fixtures updated; all 20 rprompt tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_main/src/zsh/rprompt.rs | 51 +++++++++++++++------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/crates/forge_main/src/zsh/rprompt.rs b/crates/forge_main/src/zsh/rprompt.rs index c9c413d0..70d8e216 100644 --- a/crates/forge_main/src/zsh/rprompt.rs +++ b/crates/forge_main/src/zsh/rprompt.rs @@ -84,7 +84,7 @@ impl Default for ZshRPrompt { } } -const AGENT_SYMBOL: &str = "\u{f167a}"; +const AGENT_SYMBOL: &str = "\u{f013}"; const MODEL_SYMBOL: &str = "\u{ec19}"; /// Terminal width (in columns) at which the reasoning effort label switches @@ -102,15 +102,20 @@ impl Display for ZshRPrompt { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let active = *self.token_count.unwrap_or_default() > 0usize; - // Add agent + // Add agent. The internal agent ID stays `forge` for backwards + // compatibility with `:forge` slash commands and config; the brand- + // facing label is `GRAFF`. let agent_id = self.agent.clone().unwrap_or_default(); + let agent_str = agent_id.to_string(); + let label = if agent_str == "forge" { + "GRAFF".to_string() + } else { + agent_str.to_case(Case::UpperSnake) + }; let agent_id = if self.use_nerd_font { - format!( - "{AGENT_SYMBOL} {}", - agent_id.to_string().to_case(Case::UpperSnake) - ) + format!("{AGENT_SYMBOL} {}", label) } else { - agent_id.to_string().to_case(Case::UpperSnake) + label }; let styled = if active { agent_id.zsh().bold().fg(ZshColor::WHITE) @@ -218,7 +223,7 @@ mod tests { .model(Some(ModelId::new("gpt-4"))) .to_string(); - let expected = " %B%F{240}\u{f167a} FORGE%f%b %F{240}\u{ec19} gpt-4%f"; + let expected = " %B%F{240}\u{f013} GRAFF%f%b %F{240}\u{ec19} gpt-4%f"; assert_eq!(actual, expected); } @@ -231,7 +236,7 @@ mod tests { .token_count(Some(TokenCount::Actual(1500))) .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; assert_eq!(actual, expected); } @@ -246,7 +251,7 @@ mod tests { .currency_symbol("\u{f155}") .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}\u{f155}0.01%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %B%F{2}\u{f155}0.01%f%b %F{134}\u{ec19} gpt-4%f"; assert_eq!(actual, expected); } @@ -260,7 +265,7 @@ mod tests { .use_nerd_font(false) .to_string(); - let expected = " %B%F{15}FORGE%f%b %B%F{15}1.5k%f%b %F{134}gpt-4%f"; + let expected = " %B%F{15}GRAFF%f%b %B%F{15}1.5k%f%b %F{134}gpt-4%f"; assert_eq!(actual, expected); } @@ -276,7 +281,7 @@ mod tests { .conversion_ratio(83.5) .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}INR0.83%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %B%F{2}INR0.83%f%b %F{134}\u{ec19} gpt-4%f"; assert_eq!(actual, expected); } #[test] @@ -291,7 +296,7 @@ mod tests { .conversion_ratio(0.92) .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %B%F{2}€0.01%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %B%F{2}€0.01%f%b %F{134}\u{ec19} gpt-4%f"; assert_eq!(actual, expected); } @@ -307,7 +312,7 @@ mod tests { .to_string(); let expected = - " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}HIGH%f"; + " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}HIGH%f"; assert_eq!(actual, expected); } @@ -320,7 +325,7 @@ mod tests { .reasoning_effort(Some(Effort::Medium)) .to_string(); - let expected = " %B%F{240}\u{f167a} FORGE%f%b %F{240}\u{ec19} gpt-4%f %F{240}MEDIUM%f"; + let expected = " %B%F{240}\u{f013} GRAFF%f%b %F{240}\u{ec19} gpt-4%f %F{240}MEDIUM%f"; assert_eq!(actual, expected); } @@ -336,7 +341,7 @@ mod tests { .use_nerd_font(false) .to_string(); - let expected = " %B%F{15}FORGE%f%b %B%F{15}1.5k%f%b %F{134}gpt-4%f %F{3}LOW%f"; + let expected = " %B%F{15}GRAFF%f%b %B%F{15}1.5k%f%b %F{134}gpt-4%f %F{3}LOW%f"; assert_eq!(actual, expected); } @@ -351,7 +356,7 @@ mod tests { .reasoning_effort(Some(Effort::None)) .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; assert_eq!(actual, expected); } @@ -365,7 +370,7 @@ mod tests { .reasoning_effort(None) .to_string(); - let expected = " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; + let expected = " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f"; assert_eq!(actual, expected); } @@ -380,7 +385,7 @@ mod tests { .to_string(); let expected = - " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}XHIGH%f"; + " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}XHIGH%f"; assert_eq!(actual, expected); } @@ -397,7 +402,7 @@ mod tests { .to_string(); let expected = - " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MED%f"; + " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MED%f"; assert_eq!(actual, expected); } @@ -414,7 +419,7 @@ mod tests { .to_string(); let expected = - " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MEDIUM%f"; + " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MEDIUM%f"; assert_eq!(actual, expected); } @@ -431,7 +436,7 @@ mod tests { .to_string(); let expected = - " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}HIGH%f"; + " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}HIGH%f"; assert_eq!(actual, expected); } @@ -448,7 +453,7 @@ mod tests { .to_string(); let expected = - " %B%F{15}\u{f167a} FORGE%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MIN%f"; + " %B%F{15}\u{f013} GRAFF%f%b %B%F{15}1.5k%f%b %F{134}\u{ec19} gpt-4%f %F{3}MIN%f"; assert_eq!(actual, expected); } From 6929822f03c4f7d55edaf7503f98f555d447e46d Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 02:16:31 +0800 Subject: [PATCH 3/7] chore(versioning): inherit workspace version (0.1.2) across all crates Every forge_* crate hardcoded `version = "0.1.1"` in its Cargo.toml, so even though the v0.1.2 tag and `workspace.package.version` both said 0.1.2, the displayed `forge_tracker::VERSION` (which expands `CARGO_PKG_VERSION` of the per-crate manifest) stayed at 0.1.1. Switch all 25 crates to `version.workspace = true` so they inherit the workspace version. Future bumps now only need to edit the root Cargo.toml. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 50 +++++++++++----------- crates/forge_api/Cargo.toml | 2 +- crates/forge_app/Cargo.toml | 2 +- crates/forge_ci/Cargo.toml | 2 +- crates/forge_config/Cargo.toml | 2 +- crates/forge_display/Cargo.toml | 2 +- crates/forge_domain/Cargo.toml | 2 +- crates/forge_embed/Cargo.toml | 2 +- crates/forge_eventsource/Cargo.toml | 2 +- crates/forge_eventsource_stream/Cargo.toml | 2 +- crates/forge_fs/Cargo.toml | 2 +- crates/forge_infra/Cargo.toml | 2 +- crates/forge_json_repair/Cargo.toml | 2 +- crates/forge_main/Cargo.toml | 2 +- crates/forge_markdown_stream/Cargo.toml | 2 +- crates/forge_repo/Cargo.toml | 2 +- crates/forge_select/Cargo.toml | 2 +- crates/forge_services/Cargo.toml | 2 +- crates/forge_snaps/Cargo.toml | 2 +- crates/forge_spinner/Cargo.toml | 2 +- crates/forge_stream/Cargo.toml | 2 +- crates/forge_template/Cargo.toml | 2 +- crates/forge_test_kit/Cargo.toml | 2 +- crates/forge_tool_macros/Cargo.toml | 2 +- crates/forge_tracker/Cargo.toml | 2 +- crates/forge_walker/Cargo.toml | 2 +- 26 files changed, 50 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 307dcb85..519029a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2267,7 +2267,7 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "forge_api" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-trait", @@ -2286,7 +2286,7 @@ dependencies = [ [[package]] name = "forge_app" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-recursion", @@ -2338,7 +2338,7 @@ dependencies = [ [[package]] name = "forge_ci" -version = "0.1.1" +version = "0.1.2" dependencies = [ "derive_setters", "gh-workflow", @@ -2349,7 +2349,7 @@ dependencies = [ [[package]] name = "forge_config" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "config", @@ -2372,7 +2372,7 @@ dependencies = [ [[package]] name = "forge_display" -version = "0.1.1" +version = "0.1.2" dependencies = [ "console", "derive_setters", @@ -2389,7 +2389,7 @@ dependencies = [ [[package]] name = "forge_domain" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-trait", @@ -2431,7 +2431,7 @@ dependencies = [ [[package]] name = "forge_embed" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "handlebars", @@ -2440,7 +2440,7 @@ dependencies = [ [[package]] name = "forge_eventsource" -version = "0.1.1" +version = "0.1.2" dependencies = [ "forge_eventsource_stream", "futures", @@ -2459,7 +2459,7 @@ dependencies = [ [[package]] name = "forge_eventsource_stream" -version = "0.1.1" +version = "0.1.2" dependencies = [ "futures", "futures-core", @@ -2473,7 +2473,7 @@ dependencies = [ [[package]] name = "forge_fs" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "bstr", @@ -2489,7 +2489,7 @@ dependencies = [ [[package]] name = "forge_infra" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-trait", @@ -2540,7 +2540,7 @@ dependencies = [ [[package]] name = "forge_json_repair" -version = "0.1.1" +version = "0.1.2" dependencies = [ "pretty_assertions", "regex", @@ -2553,7 +2553,7 @@ dependencies = [ [[package]] name = "forge_main" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "arboard", @@ -2617,7 +2617,7 @@ dependencies = [ [[package]] name = "forge_markdown_stream" -version = "0.1.1" +version = "0.1.2" dependencies = [ "colored", "insta", @@ -2635,7 +2635,7 @@ dependencies = [ [[package]] name = "forge_repo" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-openai", @@ -2701,7 +2701,7 @@ dependencies = [ [[package]] name = "forge_select" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "colored", @@ -2714,7 +2714,7 @@ dependencies = [ [[package]] name = "forge_services" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-recursion", @@ -2773,7 +2773,7 @@ dependencies = [ [[package]] name = "forge_snaps" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "chrono", @@ -2788,7 +2788,7 @@ dependencies = [ [[package]] name = "forge_spinner" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "colored", @@ -2804,7 +2804,7 @@ dependencies = [ [[package]] name = "forge_stream" -version = "0.1.1" +version = "0.1.2" dependencies = [ "futures", "tokio", @@ -2812,7 +2812,7 @@ dependencies = [ [[package]] name = "forge_template" -version = "0.1.1" +version = "0.1.2" dependencies = [ "html-escape", "pretty_assertions", @@ -2820,7 +2820,7 @@ dependencies = [ [[package]] name = "forge_test_kit" -version = "0.1.1" +version = "0.1.2" dependencies = [ "serde", "serde_json", @@ -2829,7 +2829,7 @@ dependencies = [ [[package]] name = "forge_tool_macros" -version = "0.1.1" +version = "0.1.2" dependencies = [ "proc-macro2", "quote", @@ -2838,7 +2838,7 @@ dependencies = [ [[package]] name = "forge_tracker" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-trait", @@ -2869,7 +2869,7 @@ dependencies = [ [[package]] name = "forge_walker" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "derive_setters", diff --git a/crates/forge_api/Cargo.toml b/crates/forge_api/Cargo.toml index bc28fbc9..dded1f4f 100644 --- a/crates/forge_api/Cargo.toml +++ b/crates/forge_api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_api" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_app/Cargo.toml b/crates/forge_app/Cargo.toml index 0f51f3e8..7b0452c9 100644 --- a/crates/forge_app/Cargo.toml +++ b/crates/forge_app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_app" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_ci/Cargo.toml b/crates/forge_ci/Cargo.toml index 5ffa0439..37946156 100644 --- a/crates/forge_ci/Cargo.toml +++ b/crates/forge_ci/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_ci" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_config/Cargo.toml b/crates/forge_config/Cargo.toml index a2ea77e3..6b639d45 100644 --- a/crates/forge_config/Cargo.toml +++ b/crates/forge_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_config" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_display/Cargo.toml b/crates/forge_display/Cargo.toml index 85c45dc0..e3103e9c 100644 --- a/crates/forge_display/Cargo.toml +++ b/crates/forge_display/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_display" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_domain/Cargo.toml b/crates/forge_domain/Cargo.toml index 9dba82cd..e9bae3b6 100644 --- a/crates/forge_domain/Cargo.toml +++ b/crates/forge_domain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_domain" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_embed/Cargo.toml b/crates/forge_embed/Cargo.toml index 7cb3db42..469df164 100644 --- a/crates/forge_embed/Cargo.toml +++ b/crates/forge_embed/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_embed" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_eventsource/Cargo.toml b/crates/forge_eventsource/Cargo.toml index 3291d4d1..e4958707 100644 --- a/crates/forge_eventsource/Cargo.toml +++ b/crates/forge_eventsource/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_eventsource" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_eventsource_stream/Cargo.toml b/crates/forge_eventsource_stream/Cargo.toml index c2641840..de1c3253 100644 --- a/crates/forge_eventsource_stream/Cargo.toml +++ b/crates/forge_eventsource_stream/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_eventsource_stream" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_fs/Cargo.toml b/crates/forge_fs/Cargo.toml index dc398799..a10cfe08 100644 --- a/crates/forge_fs/Cargo.toml +++ b/crates/forge_fs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_fs" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_infra/Cargo.toml b/crates/forge_infra/Cargo.toml index f13e8b51..ced46a3f 100644 --- a/crates/forge_infra/Cargo.toml +++ b/crates/forge_infra/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_infra" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_json_repair/Cargo.toml b/crates/forge_json_repair/Cargo.toml index b2b372e4..fec2cea9 100644 --- a/crates/forge_json_repair/Cargo.toml +++ b/crates/forge_json_repair/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_json_repair" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index c4ba34b2..7c114cb1 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_main" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_markdown_stream/Cargo.toml b/crates/forge_markdown_stream/Cargo.toml index 45124d96..f3ee8ccd 100644 --- a/crates/forge_markdown_stream/Cargo.toml +++ b/crates/forge_markdown_stream/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_markdown_stream" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_repo/Cargo.toml b/crates/forge_repo/Cargo.toml index 7a5a1c48..7170a459 100644 --- a/crates/forge_repo/Cargo.toml +++ b/crates/forge_repo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_repo" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_select/Cargo.toml b/crates/forge_select/Cargo.toml index 46139ad5..75918b07 100644 --- a/crates/forge_select/Cargo.toml +++ b/crates/forge_select/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_select" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_services/Cargo.toml b/crates/forge_services/Cargo.toml index f79011ba..e8146e09 100644 --- a/crates/forge_services/Cargo.toml +++ b/crates/forge_services/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_services" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_snaps/Cargo.toml b/crates/forge_snaps/Cargo.toml index 9eb452d4..74497197 100644 --- a/crates/forge_snaps/Cargo.toml +++ b/crates/forge_snaps/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_snaps" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_spinner/Cargo.toml b/crates/forge_spinner/Cargo.toml index 9a1a0064..2227a67f 100644 --- a/crates/forge_spinner/Cargo.toml +++ b/crates/forge_spinner/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_spinner" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_stream/Cargo.toml b/crates/forge_stream/Cargo.toml index b3c980bb..4ca2bfd2 100644 --- a/crates/forge_stream/Cargo.toml +++ b/crates/forge_stream/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_stream" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_template/Cargo.toml b/crates/forge_template/Cargo.toml index 428fd419..be8710d1 100644 --- a/crates/forge_template/Cargo.toml +++ b/crates/forge_template/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_template" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_test_kit/Cargo.toml b/crates/forge_test_kit/Cargo.toml index 92adc98e..38303573 100644 --- a/crates/forge_test_kit/Cargo.toml +++ b/crates/forge_test_kit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_test_kit" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_tool_macros/Cargo.toml b/crates/forge_tool_macros/Cargo.toml index 3237f277..6d81ac0c 100644 --- a/crates/forge_tool_macros/Cargo.toml +++ b/crates/forge_tool_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_tool_macros" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_tracker/Cargo.toml b/crates/forge_tracker/Cargo.toml index e626dfbf..7b6e4a1c 100644 --- a/crates/forge_tracker/Cargo.toml +++ b/crates/forge_tracker/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_tracker" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/forge_walker/Cargo.toml b/crates/forge_walker/Cargo.toml index da4572cd..500b5388 100644 --- a/crates/forge_walker/Cargo.toml +++ b/crates/forge_walker/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge_walker" -version = "0.1.1" +version.workspace = true edition.workspace = true rust-version.workspace = true From 075856e4d53cf8705908ae4c13b4babac3c1f22c Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 02:26:17 +0800 Subject: [PATCH 4/7] chore(rebrand): apply gear + GRAFF brand to interactive REPL prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REPL prompt rendered by reedline (`forge_main::prompt::ForgePrompt`) had its own copy of the anvil `AGENT_SYMBOL` and UpperSnake-of-agent-id formatting. The earlier sweep only fixed the zsh rprompt path, so launching `graff` interactively still showed `󱙺 FORGE` on the right side of the REPL prompt. Apply the same swap here: gear glyph plus `forge` agent ID rendered as `GRAFF` (internal ID stays `forge`). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_main/src/prompt.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/forge_main/src/prompt.rs b/crates/forge_main/src/prompt.rs index a7995b62..72045e09 100644 --- a/crates/forge_main/src/prompt.rs +++ b/crates/forge_main/src/prompt.rs @@ -20,7 +20,7 @@ const BRANCH_SYMBOL: &str = "\u{f418}"; // branch icon const SUCCESS_SYMBOL: &str = "\u{f013e}"; // 󰄾 chevron // Nerd font symbols — right prompt (ZSH rprompt) -const AGENT_SYMBOL: &str = "\u{f167a}"; +const AGENT_SYMBOL: &str = "\u{f013}"; const MODEL_SYMBOL: &str = "\u{ec19}"; /// Terminal width at which the reasoning effort label switches from the @@ -138,11 +138,15 @@ impl Prompt for ForgePrompt { }; let mut result = String::with_capacity(64); - // Agent name with nerd font symbol - let agent_str = format!( - "{AGENT_SYMBOL} {}", + // Agent name with nerd font symbol. The internal agent ID stays + // `forge` for backwards compatibility with `:forge` slash commands; + // the brand-facing label is `GRAFF`. + let label = if self.agent_id.as_str() == "forge" { + "GRAFF".to_string() + } else { self.agent_id.as_str().to_case(Case::UpperSnake) - ); + }; + let agent_str = format!("{AGENT_SYMBOL} {}", label); write!( result, " {}", @@ -330,11 +334,10 @@ mod tests { // No tokens → dimmed agent + model, no token/cost segments let mut prompt = ForgePrompt::default(); let _ = prompt.model(ModelId::new("gpt-4")); - let actual = prompt.render_prompt_right(); // Agent symbol and name present assert!(actual.contains(AGENT_SYMBOL)); - assert!(actual.contains("FORGE")); + assert!(actual.contains("GRAFF")); // Model symbol and name present assert!(actual.contains(MODEL_SYMBOL)); assert!(actual.contains("gpt-4")); From aeed5920f933d215ec27d4e528fd5df4c0128359 Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 02:56:31 +0800 Subject: [PATCH 5/7] feat(repl): add Ctrl+V clipboard image paste support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #16. When the user presses Ctrl+V in the graff REPL, the system clipboard is inspected. If it holds an image, the RGBA pixel buffer is encoded as PNG to a unique file under the system temp directory and `@[]` is inserted into the input buffer. The existing `forge_domain::Attachment` pipeline resolves that reference into a multimodal content block at message-send time, so the model sees the image with no further plumbing. If the clipboard does not hold an image, Ctrl+V falls back to plain text paste (replicating the standard binding) so users do not lose normal paste behaviour. If clipboard access fails entirely, the binding is a no-op. Why Ctrl+V (not Cmd+V): on macOS, Cmd+V is intercepted by the terminal itself; the app receives whatever the terminal forwards (usually text or filename, never raw image bytes). Ctrl+V reaches the app directly. This is the only binding that gives reliable in-process clipboard access without per-terminal configuration. Implementation: - New module `crates/forge_main/src/clipboard.rs` wraps the arboard clipboard API and writes PNGs via the `image` crate. - `ForgeEditMode::parse_event` (`crates/forge_main/src/editor.rs`) intercepts the Ctrl+V key event before delegating to the inner Emacs edit mode — the same pattern already used for bracketed paste. - `image` added to `[workspace.dependencies]` with PNG-only feature to keep binary size impact minimal. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + Cargo.toml | 1 + crates/forge_main/Cargo.toml | 2 + crates/forge_main/src/clipboard.rs | 80 ++++++++++++++++++++++++++++++ crates/forge_main/src/editor.rs | 31 +++++++++++- crates/forge_main/src/lib.rs | 1 + 6 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 crates/forge_main/src/clipboard.rs diff --git a/Cargo.lock b/Cargo.lock index 519029a7..6bcf82c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2585,6 +2585,7 @@ dependencies = [ "futures", "gix", "humantime", + "image", "include_dir", "indexmap 2.14.0", "insta", @@ -2612,6 +2613,7 @@ dependencies = [ "tracing", "update-informer", "url", + "uuid", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 04a0141d..9081a3f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ enable-ansi-support = "0.3.1" derive_setters = "0.1.9" dirs = "6.0.0" dissimilar = "1.0.9" +image = { version = "0.25", default-features = false, features = ["png"] } dotenvy = "0.15.7" fzf-wrapped = "0.1.4" futures = "0.3.32" diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index 7c114cb1..ef28656d 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -70,6 +70,8 @@ terminal_size = "0.4" rustls.workspace = true tempfile.workspace = true tiny_http.workspace = true +image.workspace = true +uuid.workspace = true [target.'cfg(windows)'.dependencies] enable-ansi-support.workspace = true diff --git a/crates/forge_main/src/clipboard.rs b/crates/forge_main/src/clipboard.rs new file mode 100644 index 00000000..25db6cc8 --- /dev/null +++ b/crates/forge_main/src/clipboard.rs @@ -0,0 +1,80 @@ +//! System clipboard helpers for the REPL. +//! +//! Provides clipboard image and text capture used by the Ctrl+V keybinding +//! in [`crate::editor::ForgeEditMode`]. Clipboard images are captured as +//! RGBA pixel buffers via `arboard`, encoded as PNG via the `image` crate, +//! and written to a unique file under the system temp directory. The +//! caller receives the path so it can be inserted into the input buffer +//! using the existing `@[]` attachment syntax. + +use std::path::PathBuf; + +use anyhow::Context; + +/// Attempts to capture an image from the system clipboard and write it as a +/// PNG to a unique file in the system temp directory. +/// +/// Returns `Ok(Some(path))` if an image was captured and successfully +/// written, `Ok(None)` if the clipboard does not currently hold an image +/// (or contains an empty image), and `Err` only for unexpected failures +/// (PNG encoding error, temp-dir permission error, etc.). Callers should +/// treat `Ok(None)` as the normal "no image, fall back to text paste" +/// path. +pub fn capture_clipboard_image_to_temp() -> anyhow::Result> { + let mut clipboard = match arboard::Clipboard::new() { + Ok(c) => c, + Err(_) => return Ok(None), + }; + + let img_data = match clipboard.get_image() { + Ok(d) => d, + Err(_) => return Ok(None), + }; + + if img_data.width == 0 || img_data.height == 0 { + return Ok(None); + } + + let width = u32::try_from(img_data.width) + .context("clipboard image width does not fit in u32")?; + let height = u32::try_from(img_data.height) + .context("clipboard image height does not fit in u32")?; + let bytes: Vec = img_data.bytes.into_owned(); + + let img = image::RgbaImage::from_raw(width, height, bytes) + .context("clipboard image bytes did not match width*height*4")?; + + let path = std::env::temp_dir().join(format!( + "graff-clipboard-{}.png", + uuid::Uuid::new_v4() + )); + img.save_with_format(&path, image::ImageFormat::Png) + .context("failed to write clipboard PNG to temp file")?; + + Ok(Some(path)) +} + +/// Attempts to read text from the system clipboard. +/// +/// Returns `None` for any failure path — clipboard unavailable, no text +/// content, IO error. Used as the fallback when Ctrl+V finds no image. +pub fn capture_clipboard_text() -> Option { + arboard::Clipboard::new().ok()?.get_text().ok() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_capture_returns_ok_none_when_clipboard_empty() { + // We cannot reliably control the system clipboard in unit tests, + // so this only verifies that a missing image yields Ok(None) rather + // than panicking when arboard cannot be initialised (e.g. CI). + let actual = capture_clipboard_image_to_temp(); + let expected_is_err = false; + assert_eq!(actual.is_err(), expected_is_err); + } +} diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index 9718b8cc..0aa8dff6 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; -use crossterm::event::Event; +use crossterm::event::{Event, KeyEvent, KeyEventKind}; use forge_api::Environment; use nu_ansi_term::{Color, Style}; use reedline::{ @@ -152,6 +152,35 @@ impl EditMode for ForgeEditMode { return ReedlineEvent::Edit(vec![EditCommand::InsertString(wrapped)]); } + // Ctrl+V: capture clipboard image when present, otherwise fall back + // to plain-text paste. On macOS users typically use Cmd+V, which the + // terminal intercepts before the app sees it — Ctrl+V is the only + // reliable in-process binding for clipboard access. Image capture + // produces an `@[]` reference that the existing attachment + // pipeline (`forge_domain::Attachment`) resolves into a multimodal + // content block at message-send time. + if let Event::Key(KeyEvent { + code: KeyCode::Char('v'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + }) = raw + { + match crate::clipboard::capture_clipboard_image_to_temp() { + Ok(Some(path)) => { + let inserted = format!("@[{}]", path.display()); + return ReedlineEvent::Edit(vec![EditCommand::InsertString(inserted)]); + } + Ok(None) | Err(_) => { + // No image (or capture/encoding failed); fall back to text. + } + } + if let Some(text) = crate::clipboard::capture_clipboard_text() { + return ReedlineEvent::Edit(vec![EditCommand::InsertString(text)]); + } + return ReedlineEvent::None; + } + // For every other event, delegate to the inner Emacs mode. // We need to reconstruct a ReedlineRawEvent from the crossterm Event. // ReedlineRawEvent implements TryFrom. diff --git a/crates/forge_main/src/lib.rs b/crates/forge_main/src/lib.rs index 960f0f16..6b2622c6 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -1,5 +1,6 @@ pub mod banner; mod cli; +mod clipboard; mod completer; mod conversation_selector; mod display_constants; From 296ee6b0089bc2a5dedeaed33739fb73ddd6954a Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 03:05:42 +0800 Subject: [PATCH 6/7] feat(repl): shorten clipboard image paths for readability Save clipboard PNGs under `~/forge/clipboard/<8hex>.png` instead of the system temp directory. The resulting `@[...]` reference inserted into the input buffer drops from ~109 chars (`/var/folders/t3/...`) to ~50 chars, so the user's typed message is not visually swamped by the path. Falls back to the system temp dir when `~/forge/` cannot be reached (sandboxed builds, CI). Uses an 8-character UUID prefix so paths stay readable but unique within the directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_main/src/clipboard.rs | 45 ++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/forge_main/src/clipboard.rs b/crates/forge_main/src/clipboard.rs index 25db6cc8..bde0aafa 100644 --- a/crates/forge_main/src/clipboard.rs +++ b/crates/forge_main/src/clipboard.rs @@ -3,23 +3,23 @@ //! Provides clipboard image and text capture used by the Ctrl+V keybinding //! in [`crate::editor::ForgeEditMode`]. Clipboard images are captured as //! RGBA pixel buffers via `arboard`, encoded as PNG via the `image` crate, -//! and written to a unique file under the system temp directory. The -//! caller receives the path so it can be inserted into the input buffer -//! using the existing `@[]` attachment syntax. +//! and written to a short-named file under `~/forge/clipboard/` so the +//! resulting `@[]` reference stays readable in the input buffer. +//! The caller receives the path so it can be inserted into the input +//! using the existing attachment syntax. use std::path::PathBuf; use anyhow::Context; /// Attempts to capture an image from the system clipboard and write it as a -/// PNG to a unique file in the system temp directory. +/// PNG file under `~/forge/clipboard/<8hex>.png`. /// /// Returns `Ok(Some(path))` if an image was captured and successfully /// written, `Ok(None)` if the clipboard does not currently hold an image /// (or contains an empty image), and `Err` only for unexpected failures -/// (PNG encoding error, temp-dir permission error, etc.). Callers should -/// treat `Ok(None)` as the normal "no image, fall back to text paste" -/// path. +/// (PNG encoding error, IO error). Callers should treat `Ok(None)` as the +/// normal "no image, fall back to text paste" path. pub fn capture_clipboard_image_to_temp() -> anyhow::Result> { let mut clipboard = match arboard::Clipboard::new() { Ok(c) => c, @@ -44,16 +44,37 @@ pub fn capture_clipboard_image_to_temp() -> anyhow::Result> { let img = image::RgbaImage::from_raw(width, height, bytes) .context("clipboard image bytes did not match width*height*4")?; - let path = std::env::temp_dir().join(format!( - "graff-clipboard-{}.png", - uuid::Uuid::new_v4() - )); + // Save to ~/forge/clipboard/<8-hex>.png so the resulting `@[...]` + // reference stays short and human-readable rather than the ~100-char + // `/var/folders/...` system temp path. Falls back to system temp if + // the home directory is unavailable (sandboxed environments, CI, etc.). + let dir = clipboard_dir().unwrap_or_else(std::env::temp_dir); + if let Err(err) = std::fs::create_dir_all(&dir) { + // Permission or IO error creating the directory; fall back rather + // than fail Ctrl+V entirely. + tracing::debug!( + error = %err, + path = %dir.display(), + "could not create clipboard dir; falling back to system temp" + ); + } + let id = uuid::Uuid::new_v4().simple().to_string(); + let short = id.get(..8).unwrap_or(&id); + let path = dir.join(format!("{short}.png")); img.save_with_format(&path, image::ImageFormat::Png) - .context("failed to write clipboard PNG to temp file")?; + .context("failed to write clipboard PNG to disk")?; Ok(Some(path)) } +/// Returns `~/forge/clipboard/`. Matches the existing `~/forge/` config +/// directory used elsewhere in graff (see +/// `forge_config::ConfigReader::base_path`). Returns `None` when no home +/// directory can be resolved. +fn clipboard_dir() -> Option { + Some(dirs::home_dir()?.join("forge").join("clipboard")) +} + /// Attempts to read text from the system clipboard. /// /// Returns `None` for any failure path — clipboard unavailable, no text From cf5af3c3e7604fd872431e48c7bd70d0f6b33e5a Mon Sep 17 00:00:00 2001 From: justrach <54503978+justrach@users.noreply.github.com> Date: Tue, 5 May 2026 03:17:28 +0800 Subject: [PATCH 7/7] feat(repl): show [Image N] chip instead of raw paste path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the long `@[/Users/.../forge/clipboard/<8hex>.png]` insertion with a tidy `[Image 1]`, `[Image 2]`, ... chip. The chip is rewritten to the canonical `@[]` syntax at message-submit time, so the existing `forge_domain::Attachment` pipeline sees no change. Implementation: - A process-level `LazyLock>>` registry maps slot numbers to on-disk PNG paths. Lifetime is the process — counters do not reset on `:new` for v1, simplest correct behavior. - `capture_clipboard_image()` returns `CapturedImage { slot }`; the Ctrl+V handler in `ForgeEditMode::parse_event` inserts `[Image {slot}]` instead of the path. - `expand_image_chips(buffer)` rewrites every `[Image N]` whose `N` is a registered slot into `@[]`. Out-of-range or malformed chips (`[image 1]`, `[Image 1]`, `[Image 0]`) are left as literal text so users can type them organically. - `From for ReadResult` calls the expander on the trimmed buffer before handing it to the agent pipeline. - 8 unit tests cover single chip, multiple chips, out-of-range, zero index, lowercase, extra-space, empty registry, unicode. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_main/src/clipboard.rs | 226 ++++++++++++++++++++++++++--- crates/forge_main/src/editor.rs | 47 +++--- 2 files changed, 227 insertions(+), 46 deletions(-) diff --git a/crates/forge_main/src/clipboard.rs b/crates/forge_main/src/clipboard.rs index bde0aafa..109062ec 100644 --- a/crates/forge_main/src/clipboard.rs +++ b/crates/forge_main/src/clipboard.rs @@ -1,26 +1,45 @@ -//! System clipboard helpers for the REPL. +//! System clipboard helpers and image-attachment registry for the REPL. //! -//! Provides clipboard image and text capture used by the Ctrl+V keybinding -//! in [`crate::editor::ForgeEditMode`]. Clipboard images are captured as -//! RGBA pixel buffers via `arboard`, encoded as PNG via the `image` crate, -//! and written to a short-named file under `~/forge/clipboard/` so the -//! resulting `@[]` reference stays readable in the input buffer. -//! The caller receives the path so it can be inserted into the input -//! using the existing attachment syntax. +//! Clipboard images captured via Ctrl+V (see [`crate::editor::ForgeEditMode`]) +//! are stored as PNG files under `~/forge/clipboard/` and registered in a +//! per-process [`ImageRegistry`]. Each registered image gets a small +//! 1-indexed slot — 1, 2, 3 — and the user sees a tidy `[Image N]` chip +//! in the input buffer instead of the long file path. +//! +//! At message-submit time, [`expand_image_chips`] rewrites every recognised +//! `[Image N]` token in the buffer to the canonical `@[]` +//! attachment syntax, so the existing `forge_domain::Attachment` pipeline +//! handles it without any further plumbing. use std::path::PathBuf; +use std::sync::{LazyLock, Mutex}; use anyhow::Context; -/// Attempts to capture an image from the system clipboard and write it as a -/// PNG file under `~/forge/clipboard/<8hex>.png`. +/// In-process registry of clipboard images, indexed by the 1-based slot the +/// user sees as `[Image N]`. Lifetime is the process — counters do not reset +/// on `:new`, which matches the simplicity of this v1 chip approach. A future +/// iteration may scope the registry per-conversation. +static REGISTRY: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +/// Result of a successful clipboard image capture. +#[derive(Debug, Clone)] +pub struct CapturedImage { + /// 1-indexed slot in [`REGISTRY`]; rendered as `[Image {slot}]` in the + /// input buffer. + pub slot: usize, +} + +/// Attempts to capture an image from the system clipboard, write it as a +/// PNG file under `~/forge/clipboard/<8hex>.png`, and register it for the +/// `[Image N]` chip syntax. /// -/// Returns `Ok(Some(path))` if an image was captured and successfully +/// Returns `Ok(Some(captured))` if an image was captured and successfully /// written, `Ok(None)` if the clipboard does not currently hold an image /// (or contains an empty image), and `Err` only for unexpected failures /// (PNG encoding error, IO error). Callers should treat `Ok(None)` as the /// normal "no image, fall back to text paste" path. -pub fn capture_clipboard_image_to_temp() -> anyhow::Result> { +pub fn capture_clipboard_image() -> anyhow::Result> { let mut clipboard = match arboard::Clipboard::new() { Ok(c) => c, Err(_) => return Ok(None), @@ -44,14 +63,12 @@ pub fn capture_clipboard_image_to_temp() -> anyhow::Result> { let img = image::RgbaImage::from_raw(width, height, bytes) .context("clipboard image bytes did not match width*height*4")?; - // Save to ~/forge/clipboard/<8-hex>.png so the resulting `@[...]` - // reference stays short and human-readable rather than the ~100-char - // `/var/folders/...` system temp path. Falls back to system temp if - // the home directory is unavailable (sandboxed environments, CI, etc.). + // Save under `~/forge/clipboard/<8hex>.png`. Falls back to the system + // temp dir when the home directory is unavailable (sandboxed builds, + // CI). The user-facing chip uses the registry slot, not the filename, + // so the on-disk name only matters for debugging. let dir = clipboard_dir().unwrap_or_else(std::env::temp_dir); if let Err(err) = std::fs::create_dir_all(&dir) { - // Permission or IO error creating the directory; fall back rather - // than fail Ctrl+V entirely. tracing::debug!( error = %err, path = %dir.display(), @@ -64,7 +81,10 @@ pub fn capture_clipboard_image_to_temp() -> anyhow::Result> { img.save_with_format(&path, image::ImageFormat::Png) .context("failed to write clipboard PNG to disk")?; - Ok(Some(path)) + let mut reg = REGISTRY.lock().expect("clipboard registry mutex poisoned"); + reg.push(path); + let slot = reg.len(); + Ok(Some(CapturedImage { slot })) } /// Returns `~/forge/clipboard/`. Matches the existing `~/forge/` config @@ -83,19 +103,179 @@ pub fn capture_clipboard_text() -> Option { arboard::Clipboard::new().ok()?.get_text().ok() } +/// Rewrites every `[Image N]` token in `buffer` whose `N` corresponds to a +/// registered slot into the canonical `@[]` attachment +/// syntax. Tokens whose index is out of range are left untouched, so a +/// user typing `[Image 99]` literally is not silently rewritten. +/// +/// Called by the REPL at message-submit time, after reedline returns the +/// buffer but before the line is dispatched into the agent pipeline. +pub fn expand_image_chips(buffer: &str) -> String { + let registry = REGISTRY.lock().expect("clipboard registry mutex poisoned"); + expand_with_registry(buffer, ®istry) +} + +/// Internal helper extracted so the chip-expansion logic is unit-testable +/// without touching the global registry state. +fn expand_with_registry(buffer: &str, registry: &[PathBuf]) -> String { + let mut out = String::with_capacity(buffer.len()); + let bytes = buffer.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if let Some((replacement, consumed)) = try_chip(bytes, i, registry) { + out.push_str(&replacement); + i += consumed; + } else { + // Safe: bytes[i] is a UTF-8 boundary because we only advance + // by `consumed` on full chip matches (which are pure ASCII) + // or by 1 when bytes[i] is a non-chip byte. For non-ASCII + // text we copy the full multi-byte char rather than a single + // byte to preserve UTF-8. + let ch_start = i; + let ch_len = utf8_char_len(bytes[i]); + let ch_end = (ch_start + ch_len).min(bytes.len()); + out.push_str(&buffer[ch_start..ch_end]); + i = ch_end; + } + } + out +} + +/// Returns the length of a UTF-8 character starting at `lead_byte`. +fn utf8_char_len(lead_byte: u8) -> usize { + if lead_byte < 0x80 { + 1 + } else if lead_byte < 0xc0 { + // Continuation byte in the middle of a char — defensive default. + 1 + } else if lead_byte < 0xe0 { + 2 + } else if lead_byte < 0xf0 { + 3 + } else { + 4 + } +} + +/// Tries to match a `[Image N]` token starting at `start`. Returns +/// `Some((replacement, consumed))` if the token is well-formed AND the +/// index is in range of `registry`. The token must be exactly +/// `[Image ]` with a single space — a literal user typing +/// `[Image 1]` (two spaces) or `[image 1]` (lowercase) is left alone. +fn try_chip(bytes: &[u8], start: usize, registry: &[PathBuf]) -> Option<(String, usize)> { + const PREFIX: &[u8] = b"[Image "; + if bytes.get(start..start + PREFIX.len())? != PREFIX { + return None; + } + let mut cursor = start + PREFIX.len(); + let digits_start = cursor; + while bytes.get(cursor).copied().map(|b| b.is_ascii_digit()).unwrap_or(false) { + cursor += 1; + } + if cursor == digits_start { + return None; + } + if bytes.get(cursor)? != &b']' { + return None; + } + let n: usize = std::str::from_utf8(&bytes[digits_start..cursor]) + .ok()? + .parse() + .ok()?; + if n == 0 { + return None; + } + let path = registry.get(n - 1)?; + let replacement = format!("@[{}]", path.display()); + Some((replacement, cursor + 1 - start)) +} + #[cfg(test)] mod tests { + use std::path::PathBuf; + use pretty_assertions::assert_eq; use super::*; + fn fixture_registry() -> Vec { + vec![ + PathBuf::from("/Users/u/forge/clipboard/aaaaaaa1.png"), + PathBuf::from("/Users/u/forge/clipboard/bbbbbbb2.png"), + ] + } + #[test] fn test_capture_returns_ok_none_when_clipboard_empty() { - // We cannot reliably control the system clipboard in unit tests, - // so this only verifies that a missing image yields Ok(None) rather - // than panicking when arboard cannot be initialised (e.g. CI). - let actual = capture_clipboard_image_to_temp(); + // We cannot control the system clipboard in unit tests; this just + // confirms the function returns Ok rather than panicking when + // arboard is unavailable (e.g. CI). + let actual = capture_clipboard_image(); let expected_is_err = false; assert_eq!(actual.is_err(), expected_is_err); } + + #[test] + fn test_expand_single_chip() { + let registry = fixture_registry(); + let actual = expand_with_registry("[Image 1] what is this", ®istry); + let expected = "@[/Users/u/forge/clipboard/aaaaaaa1.png] what is this"; + assert_eq!(actual, expected); + } + + #[test] + fn test_expand_multiple_chips() { + let registry = fixture_registry(); + let actual = expand_with_registry("compare [Image 1] and [Image 2]", ®istry); + let expected = "compare @[/Users/u/forge/clipboard/aaaaaaa1.png] and @[/Users/u/forge/clipboard/bbbbbbb2.png]"; + assert_eq!(actual, expected); + } + + #[test] + fn test_expand_leaves_out_of_range_chips_alone() { + let registry = fixture_registry(); + let actual = expand_with_registry("see [Image 99] please", ®istry); + let expected = "see [Image 99] please"; + assert_eq!(actual, expected); + } + + #[test] + fn test_expand_leaves_zero_alone() { + let registry = fixture_registry(); + let actual = expand_with_registry("[Image 0] is bad", ®istry); + let expected = "[Image 0] is bad"; + assert_eq!(actual, expected); + } + + #[test] + fn test_expand_leaves_lowercase_alone() { + let registry = fixture_registry(); + let actual = expand_with_registry("[image 1] no", ®istry); + let expected = "[image 1] no"; + assert_eq!(actual, expected); + } + + #[test] + fn test_expand_leaves_extra_space_alone() { + let registry = fixture_registry(); + let actual = expand_with_registry("[Image 1] no", ®istry); + let expected = "[Image 1] no"; + assert_eq!(actual, expected); + } + + #[test] + fn test_expand_preserves_unicode() { + let registry = fixture_registry(); + let actual = expand_with_registry("こんにちは [Image 1] 世界", ®istry); + let expected = "こんにちは @[/Users/u/forge/clipboard/aaaaaaa1.png] 世界"; + assert_eq!(actual, expected); + } + + #[test] + fn test_expand_no_registry_no_match() { + let registry: Vec = vec![]; + let actual = expand_with_registry("[Image 1] hi", ®istry); + let expected = "[Image 1] hi"; + assert_eq!(actual, expected); + } } diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index 0aa8dff6..935346a1 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -156,9 +156,10 @@ impl EditMode for ForgeEditMode { // to plain-text paste. On macOS users typically use Cmd+V, which the // terminal intercepts before the app sees it — Ctrl+V is the only // reliable in-process binding for clipboard access. Image capture - // produces an `@[]` reference that the existing attachment - // pipeline (`forge_domain::Attachment`) resolves into a multimodal - // content block at message-send time. + // saves the PNG to `~/forge/clipboard/`, registers a 1-indexed slot, + // and inserts a `[Image N]` chip into the buffer. The chip is + // rewritten to the canonical `@[]` attachment syntax by + // [`crate::clipboard::expand_image_chips`] at message-submit time. if let Event::Key(KeyEvent { code: KeyCode::Char('v'), modifiers: KeyModifiers::CONTROL, @@ -166,10 +167,10 @@ impl EditMode for ForgeEditMode { .. }) = raw { - match crate::clipboard::capture_clipboard_image_to_temp() { - Ok(Some(path)) => { - let inserted = format!("@[{}]", path.display()); - return ReedlineEvent::Edit(vec![EditCommand::InsertString(inserted)]); + match crate::clipboard::capture_clipboard_image() { + Ok(Some(captured)) => { + let chip = format!("[Image {}]", captured.slot); + return ReedlineEvent::Edit(vec![EditCommand::InsertString(chip)]); } Ok(None) | Err(_) => { // No image (or capture/encoding failed); fall back to text. @@ -198,25 +199,25 @@ impl EditMode for ForgeEditMode { impl From for ReadResult { fn from(signal: Signal) -> Self { match signal { - Signal::Success(buffer) => { - let trimmed = buffer.trim(); - if trimmed.is_empty() { - ReadResult::Empty - } else { - ReadResult::Success(trimmed.to_string()) - } - } - Signal::ExternalBreak(buffer) => { - let trimmed = buffer.trim(); - if trimmed.is_empty() { - ReadResult::Empty - } else { - ReadResult::Success(trimmed.to_string()) - } - } + Signal::Success(buffer) => buffer_to_result(&buffer), + Signal::ExternalBreak(buffer) => buffer_to_result(&buffer), Signal::CtrlC => ReadResult::Continue, Signal::CtrlD => ReadResult::Exit, _ => ReadResult::Continue, } } } + +/// Trims the user's submitted buffer and rewrites any `[Image N]` chips +/// captured via Ctrl+V into the canonical `@[]` attachment syntax +/// before the line reaches the agent pipeline. Returns `Empty` for a +/// blank submission. +fn buffer_to_result(buffer: &str) -> ReadResult { + let trimmed = buffer.trim(); + if trimmed.is_empty() { + ReadResult::Empty + } else { + let expanded = crate::clipboard::expand_image_chips(trimmed); + ReadResult::Success(expanded) + } +}