diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8d06621..aa6fe2d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Summary documentation at `docs/` (linked in README).
- `ModelManager.SetDefault(provider, model, caps, exclusive)` helper to manage per-capability defaults.
- New `AIRuntimeMessage` model to handle information, warning and error messages on AI Call.
+- New component badges to visually identify verified and deprecated models.
### Changed
diff --git a/docs/Components/ComponentBase/AIProviderComponentBase.md b/docs/Components/ComponentBase/AIProviderComponentBase.md
index fc5c83df..36d569fb 100644
--- a/docs/Components/ComponentBase/AIProviderComponentBase.md
+++ b/docs/Components/ComponentBase/AIProviderComponentBase.md
@@ -21,5 +21,5 @@ Expose provider selection via context menu and store the selection so derived co
## Related
-- [AIComponentAttributes](../Helpers/AIComponentAttributes.md) – draws a provider logo/badge on the component.
+- [AIProviderComponentAttributes](../Helpers/AIProviderComponentAttributes.md) – draws a provider logo/badge on the component.
- [AIStatefulAsyncComponentBase](./StatefulAsyncComponentBase.md) – combines this base with the async state machine.
diff --git a/docs/Components/Helpers/AIComponentAttributes.md b/docs/Components/Helpers/AIComponentAttributes.md
index bcf2db6c..6e1ef819 100644
--- a/docs/Components/Helpers/AIComponentAttributes.md
+++ b/docs/Components/Helpers/AIComponentAttributes.md
@@ -1,4 +1,4 @@
-# AIComponentAttributes
+# AIProviderComponentAttributes
Custom Grasshopper attributes that add a provider badge to AI components.
diff --git a/docs/Suggestions.md b/docs/Suggestions.md
index 17d24b40..fe0960d3 100644
--- a/docs/Suggestions.md
+++ b/docs/Suggestions.md
@@ -138,9 +138,10 @@ Breaking changes are acceptable; this is a forward-looking plan.
- [ ] AI Provider streaming of responses
- [ ] AI Provider-side prompt caching
-- [ ] AI Call cancellation token
+- [ ] AI Call cancellation token, callable by the user in components (cancelled state), webchat (with new ui button to cancel current call) and automatically called on closing webchat dialog or rhino
- [ ] AI Call local caching of responses to avoid recomputation
- [ ] AI Call compatibility with parallel tool calling
- [ ] AI tool to generate Grasshopper definitions in GHJSON format
-- [ ] New way to save Grasshopper files in GHJSON format
+- [ ] New way to save Grasshopper files in GHJSON format to disk
- [ ] Improve WebChat UI with full html environment, including dynamic loading messages, supporting streaming, different interaction type (image, audio, text, toolcall, toolresult...)
+- [ ] Add compatibility in persistant data storage and GhJson to more Grasshopper native data types (colors, points, vectors, lines, plane, circle...)
diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs
new file mode 100644
index 00000000..9aee1640
--- /dev/null
+++ b/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs
@@ -0,0 +1,157 @@
+/*
+ * SmartHopper - AI-powered Grasshopper Plugin
+ * Copyright (C) 2025 Marc Roca Musach
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ */
+
+/*
+ * TestBadgesOneComponent: Grasshopper test component to visually verify the
+ * inline badge rendering. Displays a single sample badge above the component
+ * using the shared ComponentBadgesAttributes with an override that contributes
+ * one additional badge and hover label.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Threading;
+using System.Threading.Tasks;
+using Grasshopper.Kernel;
+using SmartHopper.Core.ComponentBase;
+
+namespace SmartHopper.Components.Test.Badges
+{
+ ///
+ /// Test component that renders exactly one badge via custom attributes.
+ ///
+ public class TestBadgesOneComponent : AIStatefulAsyncComponentBase
+ {
+ ///
+ public override Guid ComponentGuid => new Guid("2B5E5F5A-6F4D-4C0F-8D99-0E9A04BB33A1");
+
+ ///
+ protected override Bitmap Icon => null;
+
+ ///
+ public override GH_Exposure Exposure => GH_Exposure.quinary;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TestBadgesOneComponent()
+ : base("Test Badges: One", "TBadges1",
+ "Renders a single sample badge above the component for visual verification.",
+ "SmartHopper", "Testing")
+ {
+ }
+
+ ///
+ protected override void RegisterOutputParams(GH_OutputParamManager pManager)
+ {
+ // No custom outputs; base adds Metrics
+ base.RegisterOutputParams(pManager);
+ }
+
+ ///
+ public override void CreateAttributes()
+ {
+ this.m_attributes = new OneBadgeAttributes(this);
+ }
+
+ ///
+ /// No-op: badge test component defines no additional inputs.
+ ///
+ /// Input param manager.
+ protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager)
+ {
+ // Intentionally empty
+ }
+
+ ///
+ /// No-op: badge test component defines no additional outputs.
+ ///
+ /// Output param manager.
+ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager)
+ {
+ // Intentionally empty
+ }
+
+ ///
+ /// Creates a minimal no-op worker since the component exists only to test inline badge rendering.
+ ///
+ /// Unused progress reporter.
+ /// A worker that performs no computation.
+ protected override AsyncWorkerBase CreateWorker(Action progressReporter)
+ {
+ return new NoopWorker(this, AddRuntimeMessage);
+ }
+
+ ///
+ /// Minimal worker for badge test components. It gathers no input, performs no work,
+ /// and sets only a lightweight status message so the async pipeline completes.
+ ///
+ private sealed class NoopWorker : AsyncWorkerBase
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Component instance.
+ /// Delegate to add runtime messages.
+ public NoopWorker(GH_Component parent, Action addRuntimeMessage)
+ : base(parent, addRuntimeMessage)
+ {
+ }
+
+ ///
+ public override void GatherInput(IGH_DataAccess DA, out int dataCount)
+ {
+ dataCount = 0;
+ }
+
+ ///
+ public override Task DoWorkAsync(CancellationToken token)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ public override void SetOutput(IGH_DataAccess DA, out string message)
+ {
+ message = "Ready";
+ }
+ }
+
+ ///
+ /// Custom attributes contributing one additional badge.
+ ///
+ private class OneBadgeAttributes : ComponentBadgesAttributes
+ {
+ private static void DrawSampleBadge(Graphics g, float x, float y)
+ {
+ var size = 16f;
+ using (var bg = new SolidBrush(Color.FromArgb(52, 152, 219))) // blue
+ using (var pen = new Pen(Color.White, 1.5f))
+ {
+ var rect = new RectangleF(x, y, size, size);
+ g.FillEllipse(bg, rect);
+ g.DrawEllipse(pen, rect);
+
+ // white dot
+ g.FillEllipse(Brushes.White, rect.X + size * 0.45f, rect.Y + size * 0.45f, size * 0.1f, size * 0.1f);
+ }
+ }
+
+ public OneBadgeAttributes(AIProviderComponentBase owner) : base(owner) { }
+
+ ///
+ protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges()
+ {
+ yield return (DrawSampleBadge, "Sample badge 1");
+ }
+ }
+ }
+}
diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs
new file mode 100644
index 00000000..d1e289b2
--- /dev/null
+++ b/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs
@@ -0,0 +1,155 @@
+/*
+ * SmartHopper - AI-powered Grasshopper Plugin
+ * Copyright (C) 2025 Marc Roca Musach
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ */
+
+/*
+ * TestBadgesThreeComponent: Verifies three inline badges rendering and hover labels.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Threading;
+using System.Threading.Tasks;
+using Grasshopper.Kernel;
+using SmartHopper.Core.ComponentBase;
+
+namespace SmartHopper.Components.Test.Badges
+{
+ ///
+ /// Test component that renders three sample badges via custom attributes.
+ ///
+ public class TestBadgesThreeComponent : AIStatefulAsyncComponentBase
+ {
+ ///
+ public override Guid ComponentGuid => new Guid("5D9A39A7-9F1B-4F2E-9D6A-9B9D3F93A0B7");
+
+ ///
+ protected override Bitmap Icon => null;
+
+ ///
+ public override GH_Exposure Exposure => GH_Exposure.quinary;
+
+ public TestBadgesThreeComponent()
+ : base("Test Badges: Three", "TBadges3",
+ "Renders three sample badges above the component for visual verification.",
+ "SmartHopper", "Testing")
+ {
+ }
+
+ public override void CreateAttributes()
+ {
+ this.m_attributes = new ThreeBadgesAttributes(this);
+ }
+
+ ///
+ /// No-op: badge test component defines no additional inputs.
+ ///
+ /// Input param manager.
+ protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager)
+ {
+ // Intentionally empty
+ }
+
+ ///
+ /// No-op: badge test component defines no additional outputs.
+ ///
+ /// Output param manager.
+ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager)
+ {
+ // Intentionally empty
+ }
+
+ ///
+ /// Creates a minimal no-op worker since the component exists only to test inline badge rendering.
+ ///
+ /// Unused progress reporter.
+ /// A worker that performs no computation.
+ protected override AsyncWorkerBase CreateWorker(Action progressReporter)
+ {
+ return new NoopWorker(this, AddRuntimeMessage);
+ }
+
+ ///
+ /// Minimal worker for badge test components. It gathers no input, performs no work,
+ /// and sets only a lightweight status message so the async pipeline completes.
+ ///
+ private sealed class NoopWorker : AsyncWorkerBase
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Component instance.
+ /// Delegate to add runtime messages.
+ public NoopWorker(GH_Component parent, Action addRuntimeMessage)
+ : base(parent, addRuntimeMessage)
+ {
+ }
+
+ ///
+ public override void GatherInput(IGH_DataAccess DA, out int dataCount)
+ {
+ dataCount = 0;
+ }
+
+ ///
+ public override Task DoWorkAsync(CancellationToken token)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ public override void SetOutput(IGH_DataAccess DA, out string message)
+ {
+ message = "Ready";
+ }
+ }
+
+ private class ThreeBadgesAttributes : ComponentBadgesAttributes
+ {
+ private const float S = 16f;
+
+ private static void DrawBlue(Graphics g, float x, float y)
+ {
+ using var bg = new SolidBrush(Color.FromArgb(52, 152, 219));
+ using var pen = new Pen(Color.White, 1.5f);
+ var rect = new RectangleF(x, y, S, S);
+ g.FillEllipse(bg, rect);
+ g.DrawEllipse(pen, rect);
+ }
+
+ private static void DrawPurple(Graphics g, float x, float y)
+ {
+ using var bg = new SolidBrush(Color.FromArgb(155, 89, 182));
+ using var pen = new Pen(Color.White, 1.5f);
+ var rect = new RectangleF(x, y, S, S);
+ g.FillEllipse(bg, rect);
+ g.DrawEllipse(pen, rect);
+ }
+
+ private static void DrawTeal(Graphics g, float x, float y)
+ {
+ using var bg = new SolidBrush(Color.FromArgb(26, 188, 156));
+ using var pen = new Pen(Color.White, 1.5f);
+ var rect = new RectangleF(x, y, S, S);
+ g.FillEllipse(bg, rect);
+ g.DrawEllipse(pen, rect);
+ }
+
+ public ThreeBadgesAttributes(AIProviderComponentBase owner) : base(owner) { }
+
+ protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges()
+ {
+ yield return (DrawBlue, "Sample badge 1");
+ yield return (DrawPurple, "Sample badge 2");
+ yield return (DrawTeal, "Sample badge 3");
+ }
+ }
+ }
+}
diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs
new file mode 100644
index 00000000..04bdcee2
--- /dev/null
+++ b/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs
@@ -0,0 +1,145 @@
+/*
+ * SmartHopper - AI-powered Grasshopper Plugin
+ * Copyright (C) 2025 Marc Roca Musach
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ */
+
+/*
+ * TestBadgesTwoComponent: Verifies two inline badges rendering and hover labels.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Threading;
+using System.Threading.Tasks;
+using Grasshopper.Kernel;
+using SmartHopper.Core.ComponentBase;
+
+namespace SmartHopper.Components.Test.Badges
+{
+ ///
+ /// Test component that renders two sample badges via custom attributes.
+ ///
+ public class TestBadgesTwoComponent : AIStatefulAsyncComponentBase
+ {
+ ///
+ public override Guid ComponentGuid => new Guid("A1A8E7D1-5F40-4F8C-8D8C-2E7DE55F6B22");
+
+ ///
+ protected override Bitmap Icon => null;
+
+ ///
+ public override GH_Exposure Exposure => GH_Exposure.quinary;
+
+ public TestBadgesTwoComponent()
+ : base("Test Badges: Two", "TBadges2",
+ "Renders two sample badges above the component for visual verification.",
+ "SmartHopper", "Testing")
+ {
+ }
+
+ public override void CreateAttributes()
+ {
+ this.m_attributes = new TwoBadgesAttributes(this);
+ }
+
+ ///
+ /// No-op: badge test component defines no additional inputs.
+ ///
+ /// Input param manager.
+ protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager)
+ {
+ // Intentionally empty
+ }
+
+ ///
+ /// No-op: badge test component defines no additional outputs.
+ ///
+ /// Output param manager.
+ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager)
+ {
+ // Intentionally empty
+ }
+
+ ///
+ /// Creates a minimal no-op worker since the component exists only to test inline badge rendering.
+ ///
+ /// Unused progress reporter.
+ /// A worker that performs no computation.
+ protected override AsyncWorkerBase CreateWorker(Action progressReporter)
+ {
+ return new NoopWorker(this, AddRuntimeMessage);
+ }
+
+ ///
+ /// Minimal worker for badge test components. It gathers no input, performs no work,
+ /// and sets only a lightweight status message so the async pipeline completes.
+ ///
+ private sealed class NoopWorker : AsyncWorkerBase
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Component instance.
+ /// Delegate to add runtime messages.
+ public NoopWorker(GH_Component parent, Action addRuntimeMessage)
+ : base(parent, addRuntimeMessage)
+ {
+ }
+
+ ///
+ public override void GatherInput(IGH_DataAccess DA, out int dataCount)
+ {
+ dataCount = 0;
+ }
+
+ ///
+ public override Task DoWorkAsync(CancellationToken token)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ public override void SetOutput(IGH_DataAccess DA, out string message)
+ {
+ message = "Ready";
+ }
+ }
+
+ private class TwoBadgesAttributes : ComponentBadgesAttributes
+ {
+ private const float S = 16f;
+
+ private static void DrawBlueBadge(Graphics g, float x, float y)
+ {
+ using var bg = new SolidBrush(Color.FromArgb(52, 152, 219));
+ using var pen = new Pen(Color.White, 1.5f);
+ var rect = new RectangleF(x, y, S, S);
+ g.FillEllipse(bg, rect);
+ g.DrawEllipse(pen, rect);
+ }
+
+ private static void DrawPurpleBadge(Graphics g, float x, float y)
+ {
+ using var bg = new SolidBrush(Color.FromArgb(155, 89, 182));
+ using var pen = new Pen(Color.White, 1.5f);
+ var rect = new RectangleF(x, y, S, S);
+ g.FillEllipse(bg, rect);
+ g.DrawEllipse(pen, rect);
+ }
+
+ public TwoBadgesAttributes(AIProviderComponentBase owner) : base(owner) { }
+
+ protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges()
+ {
+ yield return (DrawBlueBadge, "Sample badge 1");
+ yield return (DrawPurpleBadge, "Sample badge 2");
+ }
+ }
+ }
+}
diff --git a/src/SmartHopper.Core/ComponentBase/AIComponentAttributes.cs b/src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs
similarity index 52%
rename from src/SmartHopper.Core/ComponentBase/AIComponentAttributes.cs
rename to src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs
index b4466013..7562a0b7 100644
--- a/src/SmartHopper.Core/ComponentBase/AIComponentAttributes.cs
+++ b/src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs
@@ -8,12 +8,14 @@
* version 3 of the License, or (at your option) any later version.
*/
+using System;
using System.Drawing;
using Grasshopper.GUI;
using Grasshopper.GUI.Canvas;
using Grasshopper.Kernel.Attributes;
using SmartHopper.Infrastructure.AIProviders;
using SmartHopper.Infrastructure.Settings;
+using Timer = System.Timers.Timer;
namespace SmartHopper.Core.ComponentBase
{
@@ -21,19 +23,28 @@ namespace SmartHopper.Core.ComponentBase
/// Custom attributes for AI components that displays the provider logo
/// as a badge on the component.
///
- public class AIComponentAttributes : GH_ComponentAttributes
+ public class AIProviderComponentAttributes : GH_ComponentAttributes
{
private readonly AIProviderComponentBase owner;
private const int BADGESIZE = 16; // Size of the provider logo badge
private const float MINZOOMTHRESHOLD = 0.5f; // Minimum zoom level to show the badge
private const int PROVIDERSTRIPHEIGHT = 20; // Height of the provider strip
+ // Hover state for inline provider label
+ private RectangleF providerIconRect = RectangleF.Empty;
+ private bool hoverProviderIcon = false;
+
+ // Timer-based auto-hide for inline label (disappears after 5s even if still hovered)
+ // Purpose: avoid sticky labels when the cursor remains stationary.
+ private Timer? providerLabelTimer;
+ private bool providerLabelAutoHidden = false;
+
///
- /// Initializes a new instance of the class.
- /// Creates a new instance of AIComponentAttributes.
+ /// Initializes a new instance of the class.
+ /// Creates a new instance of AIProviderComponentAttributes.
///
/// The AI component that owns these attributes.
- public AIComponentAttributes(AIProviderComponentBase owner)
+ public AIProviderComponentAttributes(AIProviderComponentBase owner)
: base(owner)
{
this.owner = owner;
@@ -53,6 +64,10 @@ protected override void Layout()
bounds.Height += PROVIDERSTRIPHEIGHT;
this.Bounds = bounds;
}
+
+ // Reset hover state on layout
+ this.providerIconRect = RectangleF.Empty;
+ this.hoverProviderIcon = false;
}
///
@@ -106,12 +121,87 @@ protected override void Render(GH_Canvas canvas, Graphics graphics, GH_CanvasCha
bounds.Bottom - PROVIDERSTRIPHEIGHT + ((PROVIDERSTRIPHEIGHT - BADGESIZE) / 2),
BADGESIZE,
BADGESIZE);
+ this.providerIconRect = iconRect;
// Draw the provider icon using GH methods
if (providerIcon != null)
{
GH_GraphicsUtil.RenderIcon(graphics, iconRect, providerIcon);
}
+
+ // Draw inline label for provider when hovered and not auto-hidden (rendered after icon)
+ if (this.hoverProviderIcon && !this.providerLabelAutoHidden && this.providerIconRect.Width > 0 && canvas.Viewport.Zoom >= MINZOOMTHRESHOLD)
+ {
+ var label = $"Using {actualProviderName} provider";
+ InlineLabelRenderer.DrawInlineLabel(graphics, this.providerIconRect, label);
+ }
+ }
+ }
+
+ ///
+ /// Track mouse hover over the provider icon to trigger inline label rendering.
+ ///
+ public override GH_ObjectResponse RespondToMouseMove(GH_Canvas sender, GH_CanvasMouseEvent e)
+ {
+ bool prev = this.hoverProviderIcon;
+
+ if (sender?.Viewport.Zoom < MINZOOMTHRESHOLD)
+ {
+ this.hoverProviderIcon = false;
+ }
+ else
+ {
+ var pt = e.CanvasLocation;
+ this.hoverProviderIcon = !this.providerIconRect.IsEmpty && this.providerIconRect.Contains(pt);
+ }
+
+ if (prev != this.hoverProviderIcon)
+ {
+ // Start/stop 5s auto-hide timer based on hover transitions
+ if (this.hoverProviderIcon)
+ {
+ this.providerLabelAutoHidden = false;
+ StartProviderLabelTimer();
+ }
+ else
+ {
+ StopProviderLabelTimer();
+ this.providerLabelAutoHidden = false; // reset for next hover
+ }
+
+ this.owner.OnDisplayExpired(false);
+ }
+
+ return base.RespondToMouseMove(sender, e);
+ }
+
+ ///
+ /// Starts a one-shot 5s timer to auto-hide the inline label and request a repaint.
+ ///
+ private void StartProviderLabelTimer()
+ {
+ StopProviderLabelTimer();
+ this.providerLabelTimer = new Timer(5000) { AutoReset = false };
+ this.providerLabelTimer.Elapsed += (_, __) =>
+ {
+ // Mark as auto-hidden and request display refresh
+ this.providerLabelAutoHidden = true;
+ try { this.owner?.OnDisplayExpired(false); } catch { /* ignore */ }
+ StopProviderLabelTimer();
+ };
+ this.providerLabelTimer.Start();
+ }
+
+ ///
+ /// Stops and disposes the provider label timer if active.
+ ///
+ private void StopProviderLabelTimer()
+ {
+ if (this.providerLabelTimer != null)
+ {
+ try { this.providerLabelTimer.Stop(); } catch { /* ignore */ }
+ try { this.providerLabelTimer.Dispose(); } catch { /* ignore */ }
+ this.providerLabelTimer = null;
}
}
}
diff --git a/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs
index e643bdfc..0f43b055 100644
--- a/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs
+++ b/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs
@@ -165,7 +165,7 @@ public string GetActualAIProviderName()
///
public override void CreateAttributes()
{
- this.m_attributes = new AIComponentAttributes(this);
+ this.m_attributes = new AIProviderComponentAttributes(this);
}
///
diff --git a/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs
index 398ca6a4..53b6f2bb 100644
--- a/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs
+++ b/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs
@@ -27,6 +27,7 @@
using SmartHopper.Infrastructure.AICall;
using SmartHopper.Infrastructure.AIModels;
using SmartHopper.Infrastructure.AITools;
+using SmartHopper.Infrastructure.Settings;
namespace SmartHopper.Core.ComponentBase
{
@@ -47,6 +48,13 @@ public abstract class AIStatefulAsyncComponentBase : AIProviderComponentBase
///
private AIMetrics responseMetrics;
+ ///
+ /// Cached badge flags (to prevent recomputation during Render/panning).
+ ///
+ private bool badgeVerified;
+ private bool badgeDeprecated;
+ private bool badgeCacheValid;
+
///
/// Initializes a new instance of the class.
/// Creates a new instance of the AI-powered stateful asynchronous component.
@@ -112,6 +120,9 @@ protected override void SolveInstance(IGH_DataAccess DA)
DA.GetData("Model", ref model);
this.SetModel(model);
+ // Update badge cache using current inputs before executing the solution
+ this.UpdateBadgeCache();
+
base.SolveInstance(DA);
}
@@ -335,6 +346,107 @@ protected override void BeforeSolveInstance()
protected override void OnSolveInstancePostSolve(IGH_DataAccess DA)
{
this.SetMetricsOutput(DA);
+
+ // Update badge cache again after solving, so last metrics model is considered
+ this.UpdateBadgeCache();
+ }
+
+ #endregion
+
+ #region UI
+
+ ///
+ /// Creates the custom attributes for this component, enabling provider and model badges.
+ /// Uses to render provider icon (via base) and model state badges.
+ ///
+ public override void CreateAttributes()
+ {
+ this.m_attributes = new ComponentBadgesAttributes(this);
+ }
+
+ ///
+ /// Updates the cached badge flags based on the most relevant model and provider.
+ /// Priority for model: last metrics model, then configured/default model.
+ ///
+ internal void UpdateBadgeCache()
+ {
+ try
+ {
+ // Resolve provider
+ string providerName = this.GetActualAIProviderName();
+ if (providerName == AIProviderComponentBase.DEFAULT_PROVIDER)
+ {
+ providerName = SmartHopperSettings.Instance.DefaultAIProvider;
+ }
+
+ // Resolve model to badge
+ string modelForBadges = this.GetLastMetricsModel();
+ if (string.IsNullOrWhiteSpace(modelForBadges))
+ {
+ modelForBadges = this.GetModelToDisplay();
+ }
+
+ if (string.IsNullOrWhiteSpace(providerName) || string.IsNullOrWhiteSpace(modelForBadges))
+ {
+ this.badgeVerified = false;
+ this.badgeDeprecated = false;
+ this.badgeCacheValid = false;
+ return;
+ }
+
+ var caps = ModelManager.Instance.GetCapabilities(providerName, modelForBadges);
+ if (caps == null)
+ {
+ this.badgeVerified = false;
+ this.badgeDeprecated = false;
+ this.badgeCacheValid = false;
+ return;
+ }
+
+ this.badgeVerified = caps.Verified;
+ this.badgeDeprecated = caps.Deprecated;
+ this.badgeCacheValid = true;
+ }
+ catch
+ {
+ // On any failure, mark cache invalid to avoid rendering
+ this.badgeVerified = false;
+ this.badgeDeprecated = false;
+ this.badgeCacheValid = false;
+ }
+ }
+
+ ///
+ /// Tries to get the cached badge flags without recomputation.
+ ///
+ /// True if model is verified.
+ /// True if model is deprecated.
+ /// True if cache is valid; otherwise false.
+ internal bool TryGetCachedBadgeFlags(out bool verified, out bool deprecated)
+ {
+ verified = this.badgeVerified;
+ deprecated = this.badgeDeprecated;
+ return this.badgeCacheValid;
+ }
+
+ ///
+ /// Returns the last used model recorded in the component's persisted metrics, if any.
+ /// Used by UI attributes to reflect the actual model previously executed.
+ ///
+ /// Last used model name, or empty if not available.
+ internal string GetLastMetricsModel()
+ {
+ return this.responseMetrics != null ? (this.responseMetrics.Model ?? string.Empty) : string.Empty;
+ }
+
+ ///
+ /// Returns the configured/input model or the provider's default model for display
+ /// when the component has never run.
+ ///
+ /// Configured/default model name, or empty.
+ internal string GetModelToDisplay()
+ {
+ return this.GetModel() ?? string.Empty;
}
#endregion
diff --git a/src/SmartHopper.Core/ComponentBase/ComponentBadgesAttributes.cs b/src/SmartHopper.Core/ComponentBase/ComponentBadgesAttributes.cs
new file mode 100644
index 00000000..00ad903a
--- /dev/null
+++ b/src/SmartHopper.Core/ComponentBase/ComponentBadgesAttributes.cs
@@ -0,0 +1,304 @@
+/*
+ * SmartHopper - AI-powered Grasshopper Plugin
+ * Copyright (C) 2025 Marc Roca Musach
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ */
+
+/*
+ * ComponentBadgesAttributes: Custom Grasshopper component attributes that render
+ * AI model status badges (Verified/Deprecated) as floating circles above the component.
+ *
+ * Purpose: Extend component UI to show model state directly on the component.
+ * - Uses last used model from metrics when available; otherwise falls back to the
+ * configured (input/default) model.
+ * - Queries ModelManager for AIModelCapabilities to determine Verified/Deprecated flags.
+ * - Designed to be extensible for future badges (e.g., automatic model replacement).
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using Grasshopper.GUI;
+using Grasshopper.GUI.Canvas;
+using Timer = System.Timers.Timer;
+
+namespace SmartHopper.Core.ComponentBase
+{
+ ///
+ /// Custom attributes for AI components that display model badges (verified/deprecated)
+ /// as floating circles centered above the component (not overlapping the body).
+ ///
+ public class ComponentBadgesAttributes : AIProviderComponentAttributes
+ {
+ private readonly AIProviderComponentBase owner;
+
+ // Layout constants
+ private const int BADGE_SIZE = 16; // Size of badges
+ private const float MIN_ZOOM_THRESHOLD = 0.5f; // Minimum zoom to render badges
+ private const int BADGE_GAP = 6; // Gap between badges
+ private const int FLOAT_OFFSET = -10; // Vertical offset above component
+
+ // Hover/interaction state for inline labels (generalized)
+ private readonly List badgeRects = new List();
+ private readonly List badgeLabels = new List();
+ private int hoverBadgeIndex = -1;
+
+ // Timer-based auto-hide for inline badge labels (disappears after 5s even if still hovered)
+ // Purpose: avoid sticky labels when the cursor remains stationary over a badge.
+ private Timer? badgeLabelTimer;
+ private bool badgeLabelAutoHidden = false;
+
+ ///
+ /// Creates a new instance of .
+ ///
+ /// The AI component that owns these attributes.
+ public ComponentBadgesAttributes(AIProviderComponentBase owner)
+ : base(owner)
+ {
+ this.owner = owner;
+ }
+
+ ///
+ /// Keep default component bounds; badges are drawn floating above.
+ ///
+ protected override void Layout()
+ {
+ base.Layout();
+
+ // Ensure the attribute bounds include the floating badges region above the component
+ // so that Grasshopper dispatches mouse events when hovering the badges.
+ var bounds = this.Bounds;
+ float extendTop = FLOAT_OFFSET + BADGE_SIZE;
+ bounds.Y -= extendTop;
+ bounds.Height += extendTop;
+ this.Bounds = bounds;
+
+ // Reset hover state when layout changes
+ this.badgeRects.Clear();
+ this.badgeLabels.Clear();
+ this.hoverBadgeIndex = -1;
+ }
+
+ ///
+ /// Renders model state badges as floating circles above the component.
+ /// Uses cached flags from the owner to avoid heavy lookups during panning.
+ ///
+ protected override void Render(GH_Canvas canvas, Graphics graphics, GH_CanvasChannel channel)
+ {
+ // First render base visuals (including provider icon at the bottom strip)
+ base.Render(canvas, graphics, channel);
+
+ if (channel != GH_CanvasChannel.Objects)
+ {
+ return;
+ }
+
+ if (canvas.Viewport.Zoom < MIN_ZOOM_THRESHOLD)
+ {
+ return;
+ }
+
+ if (this.owner is not AIStatefulAsyncComponentBase stateful)
+ {
+ return;
+ }
+
+ if (!stateful.TryGetCachedBadgeFlags(out bool showVerified, out bool showDeprecated))
+ {
+ return;
+ }
+
+ // Collect badges (built-in + extension point)
+ var items = new List<(System.Action draw, string label)>();
+ if (showVerified)
+ {
+ items.Add((DrawVerifiedBadge, "Using a verified model"));
+ }
+ if (showDeprecated)
+ {
+ items.Add((DrawDeprecatedBadge, "Using a deprecated model"));
+ }
+ foreach (var extra in this.GetAdditionalBadges())
+ {
+ items.Add(extra);
+ }
+
+ if (items.Count == 0)
+ {
+ this.badgeRects.Clear();
+ this.badgeLabels.Clear();
+ this.hoverBadgeIndex = -1;
+ return;
+ }
+
+ var bounds = this.Bounds;
+
+ int count = items.Count;
+ float totalWidth = count * BADGE_SIZE + (count - 1) * BADGE_GAP;
+ float originX = bounds.Left + (bounds.Width - totalWidth) / 2f;
+ float y = bounds.Top - FLOAT_OFFSET - BADGE_SIZE;
+
+ // Reset rects before drawing
+ this.badgeRects.Clear();
+ this.badgeLabels.Clear();
+
+ float x = originX;
+ for (int i = 0; i < items.Count; i++)
+ {
+ var item = items[i];
+ item.draw(graphics, x, y);
+ this.badgeRects.Add(new RectangleF(x, y, BADGE_SIZE, BADGE_SIZE));
+ this.badgeLabels.Add(item.label);
+ x += BADGE_SIZE + BADGE_GAP;
+ }
+
+ // Draw inline labels last so they appear on top
+ if (this.hoverBadgeIndex >= 0 && this.hoverBadgeIndex < this.badgeRects.Count && !this.badgeLabelAutoHidden)
+ {
+ var rect = this.badgeRects[this.hoverBadgeIndex];
+ var text = this.badgeLabels[this.hoverBadgeIndex];
+ InlineLabelRenderer.DrawInlineLabel(graphics, rect, text);
+ }
+ }
+
+ // Model resolution moved to owner cache to avoid work during Render.
+
+ ///
+ /// Draw a simple green check-circle for Verified.
+ ///
+ private static void DrawVerifiedBadge(Graphics g, float x, float y)
+ {
+ using (var bg = new SolidBrush(Color.FromArgb(32, 152, 72))) // green
+ using (var pen = new Pen(Color.White, 1.5f))
+ {
+ var rect = new RectangleF(x, y, BADGE_SIZE, BADGE_SIZE);
+ g.FillEllipse(bg, rect);
+
+ // check mark
+ var p1 = new PointF(x + BADGE_SIZE * 0.28f, y + BADGE_SIZE * 0.55f);
+ var p2 = new PointF(x + BADGE_SIZE * 0.45f, y + BADGE_SIZE * 0.72f);
+ var p3 = new PointF(x + BADGE_SIZE * 0.75f, y + BADGE_SIZE * 0.30f);
+ g.DrawLines(pen, new[] { p1, p2, p3 });
+ }
+ }
+
+ ///
+ /// Draw a simple orange warning triangle for Deprecated.
+ ///
+ private static void DrawDeprecatedBadge(Graphics g, float x, float y)
+ {
+ using (var bg = new SolidBrush(Color.FromArgb(230, 126, 34))) // orange
+ using (var pen = new Pen(Color.White, 1.5f))
+ {
+ var cx = x + BADGE_SIZE / 2f;
+ var cy = y + BADGE_SIZE / 2f;
+ var r = BADGE_SIZE * 0.45f;
+
+ // Triangle points
+ var p1 = new PointF(cx, cy - r);
+ var p2 = new PointF(cx - r * 0.866f, cy + r * 0.5f);
+ var p3 = new PointF(cx + r * 0.866f, cy + r * 0.5f);
+
+ var path = new System.Drawing.Drawing2D.GraphicsPath();
+ path.AddPolygon(new[] { p1, p2, p3 });
+ g.FillPath(bg, path);
+
+ // Exclamation mark
+ var lineTop = new PointF(cx, cy - r * 0.2f);
+ var lineBottom = new PointF(cx, cy + r * 0.3f);
+ g.DrawLine(pen, lineTop, lineBottom);
+ g.DrawEllipse(pen, cx - 1.5f, cy + r * 0.4f, 3f, 3f);
+ }
+ }
+
+ ///
+ /// Extension point to allow derived attributes to contribute additional badges.
+ /// Each badge provides a draw function and the hover label text.
+ ///
+ /// Sequence of additional badge descriptors.
+ protected virtual IEnumerable<(System.Action draw, string label)> GetAdditionalBadges()
+ {
+ yield break;
+ }
+
+ ///
+ /// Track mouse hover over badges to trigger inline label rendering.
+ ///
+ public override Grasshopper.GUI.Canvas.GH_ObjectResponse RespondToMouseMove(GH_Canvas sender, GH_CanvasMouseEvent e)
+ {
+ int prevIndex = this.hoverBadgeIndex;
+
+ if (sender?.Viewport.Zoom < MIN_ZOOM_THRESHOLD)
+ {
+ this.hoverBadgeIndex = -1;
+ }
+ else
+ {
+ var pt = e.CanvasLocation;
+ int newIndex = -1;
+ for (int i = 0; i < this.badgeRects.Count; i++)
+ {
+ if (this.badgeRects[i].Contains(pt))
+ {
+ newIndex = i;
+ break;
+ }
+ }
+
+ this.hoverBadgeIndex = newIndex;
+ }
+
+ if (prevIndex != this.hoverBadgeIndex)
+ {
+ // Start/stop 5s auto-hide timer based on hover transitions
+ if (this.hoverBadgeIndex >= 0)
+ {
+ this.badgeLabelAutoHidden = false;
+ StartBadgeLabelTimer();
+ }
+ else
+ {
+ StopBadgeLabelTimer();
+ this.badgeLabelAutoHidden = false; // reset for next hover
+ }
+
+ this.owner.OnDisplayExpired(false);
+ }
+
+ return base.RespondToMouseMove(sender, e);
+ }
+
+ ///
+ /// Starts a one-shot 5s timer to auto-hide the inline badge label and request a repaint.
+ ///
+ private void StartBadgeLabelTimer()
+ {
+ StopBadgeLabelTimer();
+ this.badgeLabelTimer = new Timer(5000) { AutoReset = false };
+ this.badgeLabelTimer.Elapsed += (_, __) =>
+ {
+ this.badgeLabelAutoHidden = true;
+ try { this.owner?.OnDisplayExpired(false); } catch { /* ignore */ }
+ StopBadgeLabelTimer();
+ };
+ this.badgeLabelTimer.Start();
+ }
+
+ ///
+ /// Stops and disposes the badge label timer if active.
+ ///
+ private void StopBadgeLabelTimer()
+ {
+ if (this.badgeLabelTimer != null)
+ {
+ try { this.badgeLabelTimer.Stop(); } catch { /* ignore */ }
+ try { this.badgeLabelTimer.Dispose(); } catch { /* ignore */ }
+ this.badgeLabelTimer = null;
+ }
+ }
+ }
+}
diff --git a/src/SmartHopper.Core/ComponentBase/InlineLabelRenderer.cs b/src/SmartHopper.Core/ComponentBase/InlineLabelRenderer.cs
new file mode 100644
index 00000000..2b9fb213
--- /dev/null
+++ b/src/SmartHopper.Core/ComponentBase/InlineLabelRenderer.cs
@@ -0,0 +1,58 @@
+/*
+ * SmartHopper - AI-powered Grasshopper Plugin
+ * Copyright (C) 2025 Marc Roca Musach
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ */
+
+/*
+ * InlineLabelRenderer: Utility to render small inline tooltip-like labels anchored
+ * to UI rectangles on the Grasshopper canvas.
+ * Purpose: Centralize inline label drawing for provider icons and badges to avoid
+ * code duplication and ensure consistent styling.
+ */
+
+using System.Drawing;
+using Grasshopper.Kernel;
+
+namespace SmartHopper.Core.ComponentBase
+{
+ ///
+ /// Provides helper methods to draw compact inline labels anchored to a rectangle.
+ ///
+ internal static class InlineLabelRenderer
+ {
+ ///
+ /// Draws an inline tooltip-like label above the given anchor rectangle.
+ /// The label uses GH small font, dark background and light border/text.
+ ///
+ /// Target graphics.
+ /// Anchor rectangle the label should relate to.
+ /// Text to display inside the label.
+ public static void DrawInlineLabel(Graphics g, RectangleF anchor, string text)
+ {
+ var font = GH_FontServer.Small;
+ var size = g.MeasureString(text, font);
+ var padding = 4f;
+ var width = size.Width + padding * 2f;
+ var height = size.Height + padding * 1.5f;
+ var x = anchor.Left + (anchor.Width - width) / 2f;
+ var y = anchor.Top - height - 4f; // small gap below
+
+ var rect = new RectangleF(x, y, width, height);
+ using (var bg = new SolidBrush(Color.FromArgb(240, 255, 255, 255)))
+ using (var pen = new Pen(Color.FromArgb(255, 255, 255, 255), 1f))
+ using (var fg = new SolidBrush(Color.Black))
+ {
+ g.FillRectangle(bg, rect);
+ g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
+ var tx = rect.X + padding;
+ var ty = rect.Y + (rect.Height - size.Height) / 2f;
+ g.DrawString(text, font, fg, new PointF(tx, ty));
+ }
+ }
+ }
+}
diff --git a/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs b/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs
index 7f507cda..26656fe3 100644
--- a/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs
+++ b/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs
@@ -12,6 +12,7 @@
* SelectingComponentBase - base class for components with a "Select Components" button.
*/
+using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
@@ -21,6 +22,7 @@
using Grasshopper.GUI.Canvas;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Attributes;
+using Timer = System.Timers.Timer;
namespace SmartHopper.Core.ComponentBase
{
@@ -108,6 +110,11 @@ public class SelectingComponentAttributes : GH_ComponentAttributes
private bool isHovering;
private bool isClicking;
+ // Timer-based auto-hide of the visual highlight for selected objects.
+ // Purpose: ensure the dashed highlight disappears after 5s even if the cursor stays hovered.
+ private Timer? selectDisplayTimer;
+ private bool selectAutoHidden = false;
+
public SelectingComponentAttributes(SelectingComponentBase owner)
: base(owner)
{
@@ -145,7 +152,7 @@ protected override void Render(GH_Canvas canvas, Graphics graphics, GH_CanvasCha
var ty = this.buttonBounds.Y + ((this.buttonBounds.Height - size.Height) / 2);
graphics.DrawString(text, font, (this.isHovering || this.isClicking) ? Brushes.Black : Brushes.White, new PointF(tx, ty));
- if (this.isHovering && this.owner.SelectedObjects.Count > 0)
+ if (this.isHovering && !this.selectAutoHidden && this.owner.SelectedObjects.Count > 0)
{
using (var pen = new Pen(Color.DodgerBlue, 2f))
{
@@ -181,7 +188,20 @@ public override GH_ObjectResponse RespondToMouseMove(GH_Canvas sender, GH_Canvas
this.isHovering = this.buttonBounds.Contains((int)e.CanvasLocation.X, (int)e.CanvasLocation.Y);
if (was != this.isHovering)
{
- this.owner.ExpireSolution(true);
+ // Start/stop 5s auto-hide timer based on hover transitions
+ if (this.isHovering)
+ {
+ this.selectAutoHidden = false;
+ StartSelectDisplayTimer();
+ }
+ else
+ {
+ StopSelectDisplayTimer();
+ this.selectAutoHidden = false; // reset for next hover
+ }
+
+ // Use display invalidation for hover-only visual changes
+ this.owner.OnDisplayExpired(false);
}
return base.RespondToMouseMove(sender, e);
@@ -197,5 +217,34 @@ public override GH_ObjectResponse RespondToMouseUp(GH_Canvas sender, GH_CanvasMo
return base.RespondToMouseUp(sender, e);
}
+
+ ///
+ /// Starts a one-shot 5s timer to auto-hide the selection highlight and request a repaint.
+ ///
+ private void StartSelectDisplayTimer()
+ {
+ StopSelectDisplayTimer();
+ this.selectDisplayTimer = new Timer(5000) { AutoReset = false };
+ this.selectDisplayTimer.Elapsed += (_, __) =>
+ {
+ this.selectAutoHidden = true;
+ try { this.owner?.OnDisplayExpired(false); } catch { /* ignore */ }
+ StopSelectDisplayTimer();
+ };
+ this.selectDisplayTimer.Start();
+ }
+
+ ///
+ /// Stops and disposes the selection display timer if active.
+ ///
+ private void StopSelectDisplayTimer()
+ {
+ if (this.selectDisplayTimer != null)
+ {
+ try { this.selectDisplayTimer.Stop(); } catch { /* ignore */ }
+ try { this.selectDisplayTimer.Dispose(); } catch { /* ignore */ }
+ this.selectDisplayTimer = null;
+ }
+ }
}
}