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) <noreply@anthropic.com>
---
 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: API + ConsoleWriter + 'static, F: Fn(ForgeConfig) -> 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) <noreply@anthropic.com>
---
 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) <noreply@anthropic.com>
---
 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) <noreply@anthropic.com>
---
 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 `@[<temp.png>]` 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) <noreply@anthropic.com>
---
 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 `@[<path>]` 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<Option<PathBuf>> {
+    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<u8> = 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<String> {
+    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 `@[<temp.png>]` 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<Event>.
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) <noreply@anthropic.com>
---
 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 `@[<path>]` attachment syntax.
+//! and written to a short-named file under `~/forge/clipboard/` so the
+//! resulting `@[<path>]` 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<Option<PathBuf>> {
     let mut clipboard = match arboard::Clipboard::new() {
         Ok(c) => c,
@@ -44,16 +44,37 @@ pub fn capture_clipboard_image_to_temp() -> anyhow::Result<Option<PathBuf>> {
     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<PathBuf> {
+    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 `@[<absolute-path>]` syntax at message-submit time,
so the existing `forge_domain::Attachment` pipeline sees no change.

Implementation:
- A process-level `LazyLock<Mutex<Vec<PathBuf>>>` 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 `@[<absolute-path>]`. Out-of-range or
  malformed chips (`[image 1]`, `[Image  1]`, `[Image 0]`) are left
  as literal text so users can type them organically.
- `From<Signal> 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) <noreply@anthropic.com>
---
 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 `@[<path>]` 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 `@[<absolute-path>]`
+//! 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<Mutex<Vec<PathBuf>>> = 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<Option<PathBuf>> {
+pub fn capture_clipboard_image() -> anyhow::Result<Option<CapturedImage>> {
     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<Option<PathBuf>> {
     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<Option<PathBuf>> {
     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<String> {
     arboard::Clipboard::new().ok()?.get_text().ok()
 }
 
+/// Rewrites every `[Image N]` token in `buffer` whose `N` corresponds to a
+/// registered slot into the canonical `@[<absolute-path>]` 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, &registry)
+}
+
+/// 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 <digits>]` 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<PathBuf> {
+        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", &registry);
+        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]", &registry);
+        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", &registry);
+        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", &registry);
+        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", &registry);
+        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", &registry);
+        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] 世界", &registry);
+        let expected = "こんにちは @[/Users/u/forge/clipboard/aaaaaaa1.png] 世界";
+        assert_eq!(actual, expected);
+    }
+
+    #[test]
+    fn test_expand_no_registry_no_match() {
+        let registry: Vec<PathBuf> = vec![];
+        let actual = expand_with_registry("[Image 1] hi", &registry);
+        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 `@[<temp.png>]` 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 `@[<path>]` 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<Signal> 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 `@[<path>]` 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)
+    }
+}
