diff --git a/InterlinedList/Services/AiAvailabilityService.cs b/InterlinedList/Services/AiAvailabilityService.cs
new file mode 100644
index 0000000..06aa36b
--- /dev/null
+++ b/InterlinedList/Services/AiAvailabilityService.cs
@@ -0,0 +1,278 @@
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// The single source of truth for "may this account be shown AI controls at
+/// all, and how much of today's quota is left". Fetches
+/// GET /api/ai/status once per session, caches it, and exposes
+/// — the one gate every AI affordance in the app
+/// binds its visibility to.
+///
+/// Why this exists as its own singleton rather than a field on
+/// : every AI panel needs the same answer and must
+/// not each spend a round-trip on it, and adding a member to
+/// would conflict with several other open branches
+/// that also touch it. is created lazily off
+/// , so nothing else in the app changes.
+///
+/// The gate deliberately excludes quota.
+/// also requires unspent quota, which is the wrong rule for visibility:
+/// a subscriber who has used all 50 of today's generations should still see the
+/// AI controls with a "you're out until tomorrow" line in place, not watch them
+/// vanish. So visibility = Subscriber && providers non-empty, and
+/// quota exhaustion is reported through /
+/// instead.
+///
+/// Verified live 2026-09-16 on the test account:
+/// {"subscriber":true,"providers":["anthropic"],"defaultModels":{…},
+/// "quota":{"usedToday":5,"dailyLimit":50,"remaining":45}}
+///
+/// Threading: this is an bound directly by
+/// views, so mutate it from the UI thread. Every public method here is awaited
+/// from a ViewModel command (WPF resumes continuations on the dispatcher), and
+/// / are synchronous callbacks
+/// made from the same place.
+///
+public sealed partial class AiAvailabilityService : ObservableObject
+{
+ private static readonly Lazy LazyShared =
+ new(() => new AiAvailabilityService(AppServices.Session));
+
+ /// The process-wide instance. Panels use this rather than newing one up.
+ public static AiAvailabilityService Shared => LazyShared.Value;
+
+ private readonly SessionService _session;
+
+ ///
+ /// Single-flight guard: several AI panels can be constructed in the same
+ /// frame (switching to Lists then Documents), and they must share one
+ /// in-flight GET rather than each issuing their own.
+ ///
+ private Task? _inFlight;
+
+ ///
+ /// Bumped by and . A response
+ /// that comes back stamped with an older generation is discarded: signing
+ /// out (or into another account) while a status GET is in flight must not
+ /// let the previous account's subscriber flag land in the cache and open
+ /// the gate for someone who isn't entitled to it.
+ ///
+ private int _generation;
+
+ public AiAvailabilityService(SessionService session)
+ {
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+
+ // A different account has a different subscriber state and a different
+ // quota, so the cache is per-session, not per-process.
+ _session.PropertyChanged += OnSessionChanged;
+ }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsLoaded))]
+ [NotifyPropertyChangedFor(nameof(IsAiAvailable))]
+ [NotifyPropertyChangedFor(nameof(Quota))]
+ [NotifyPropertyChangedFor(nameof(RemainingToday))]
+ [NotifyPropertyChangedFor(nameof(DailyLimit))]
+ [NotifyPropertyChangedFor(nameof(QuotaLabel))]
+ [NotifyPropertyChangedFor(nameof(IsQuotaExhausted))]
+ [NotifyPropertyChangedFor(nameof(DefaultModel))]
+ [NotifyPropertyChangedFor(nameof(UnavailableReason))]
+ private AiStatus? status;
+
+ /// True while the one-per-session GET is running.
+ [ObservableProperty]
+ private bool isLoading;
+
+ ///
+ /// Set when the status fetch itself failed (offline, 401, 5xx). AI controls
+ /// stay hidden in that case — the app can't prove the account is entitled,
+ /// and guessing "yes" would show a subscriber-only control to a free
+ /// account, which #10 forbids outright.
+ ///
+ [ObservableProperty]
+ private string? loadError;
+
+ private void OnSessionChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(SessionService.CurrentUser))
+ Reset();
+ }
+
+ // ── The gate ────────────────────────────────────────────────────────────────
+
+ public bool IsLoaded => Status is not null;
+
+ ///
+ /// The gate. Bind every AI button, tab, panel and menu entry's
+ /// visibility to this and nothing else.
+ ///
+ /// providers: [] means the server has no ANTHROPIC_API_KEY, so
+ /// a call would come back 409 no_provider_configured — there is
+ /// nothing a user could do about it, so the control is hidden rather than
+ /// shown-and-failing. subscriber: false is a free account, and per
+ /// #10 those see no AI controls at all (not a disabled one, not an upsell).
+ /// Unknown (not loaded yet, or the fetch failed) is also false: hidden is
+ /// the safe default in both directions.
+ ///
+ public bool IsAiAvailable => Status is { Subscriber: true } status && status.HasProviderConfigured;
+
+ /// The model /suggest will run, for the "powered by" byline. Anthropic-only app-wide.
+ public string? DefaultModel => Status?.DefaultModel;
+
+ /// Why AI isn't offered, when it isn't. Diagnostic only — never shown to a free account.
+ public string? UnavailableReason => Status is null
+ ? LoadError ?? "AI status hasn't loaded yet."
+ : Status.UnavailableReason;
+
+ // ── Quota surfacing ─────────────────────────────────────────────────────────
+
+ public AiQuota? Quota => Status?.Quota;
+
+ public int RemainingToday => Status?.Quota.RemainingOrComputed ?? 0;
+
+ public int DailyLimit => Status?.Quota.DailyLimit ?? AiFeatureLimits.DailyGenerationLimit;
+
+ /// "45 of 50 left today" — the line #10 asks to appear wherever an AI action is offered.
+ public string QuotaLabel => Status is null
+ ? "Checking AI quota…"
+ : $"{RemainingToday} of {DailyLimit} left today";
+
+ public bool IsQuotaExhausted => Status?.Quota.IsExhausted ?? false;
+
+ // ── Loading ─────────────────────────────────────────────────────────────────
+
+ ///
+ /// Fetch the status if it hasn't been fetched for this session yet.
+ /// Idempotent and safe to call from every panel's constructor: concurrent
+ /// callers await the same request, and a completed one is a no-op.
+ ///
+ public Task EnsureLoadedAsync(CancellationToken ct = default)
+ {
+ if (Status is not null) return Task.CompletedTask;
+ return _inFlight ??= LoadAsync(_generation, ct);
+ }
+
+ ///
+ /// Re-fetch on demand — after a subscription change, or to reconcile the
+ /// quota against the server (a /suggest echoes usedToday but omits
+ /// remaining, so a long session's computed figure can drift if the
+ /// same account is used elsewhere).
+ ///
+ public Task RefreshAsync(CancellationToken ct = default)
+ => _inFlight = LoadAsync(++_generation, ct);
+
+ private async Task LoadAsync(int generation, CancellationToken ct)
+ {
+ IsLoading = true;
+ LoadError = null;
+ try
+ {
+ var status = await _session.Api.GetAiStatusAsync(ct);
+
+ // Superseded by a Reset (sign-out / account switch) or a newer
+ // Refresh while we were waiting — drop it on the floor.
+ if (generation != _generation) return null;
+
+ Status = status;
+ return status;
+ }
+ catch (AiApiException ex)
+ {
+ // 401 here means the token is gone; anything else means the server
+ // couldn't answer. Either way AI stays hidden.
+ if (generation == _generation) LoadError = ex.UserMessage;
+ AppLog.Warn($"GET /api/ai/status failed ({ex.StatusCode} {ex.RawCode}): {ex.Message}");
+ return null;
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (Exception ex)
+ {
+ if (generation == _generation) LoadError = "Couldn't check whether AI is available.";
+ AppLog.Error("GET /api/ai/status failed.", ex);
+ return null;
+ }
+ finally
+ {
+ if (generation == _generation)
+ {
+ IsLoading = false;
+ // Let the next EnsureLoadedAsync retry a failed fetch instead
+ // of caching the failure for the rest of the session.
+ if (Status is null) _inFlight = null;
+ }
+ }
+ }
+
+ // ── Quota bookkeeping ───────────────────────────────────────────────────────
+
+ ///
+ /// Fold the quota a /suggest or /generate echoed back into the cached
+ /// status, so the chip counts down without another GET.
+ ///
+ /// Shape note: the echoed object has usedToday and
+ /// dailyLimit but no remaining (verified live, twice),
+ /// so this stores into
+ /// — keeping the cached status shaped like a
+ /// /status response and letting the UI keep binding one property.
+ ///
+ public void ApplyQuota(AiQuota? quota)
+ {
+ if (quota is null || Status is not { } status) return;
+
+ var limit = quota.DailyLimit > 0 ? quota.DailyLimit : status.Quota.DailyLimit;
+
+ Status = new AiStatus
+ {
+ Subscriber = status.Subscriber,
+ Providers = status.Providers,
+ DefaultModels = status.DefaultModels,
+ Quota = new AiQuota
+ {
+ UsedToday = quota.UsedToday,
+ DailyLimit = limit,
+ Remaining = Math.Max(0, limit - quota.UsedToday)
+ }
+ };
+ }
+
+ ///
+ /// A 429 quota_exceeded arrived, so today's allowance is gone
+ /// whatever the cached figure said — reflect that immediately instead of
+ /// leaving a stale "3 left today" next to a "you're out" message.
+ ///
+ public void MarkQuotaExhausted()
+ {
+ if (Status is not { } status) return;
+
+ var limit = status.Quota.DailyLimit > 0 ? status.Quota.DailyLimit : AiFeatureLimits.DailyGenerationLimit;
+
+ Status = new AiStatus
+ {
+ Subscriber = status.Subscriber,
+ Providers = status.Providers,
+ DefaultModels = status.DefaultModels,
+ Quota = new AiQuota { UsedToday = limit, DailyLimit = limit, Remaining = 0 }
+ };
+ }
+
+ ///
+ /// Drop the cache (sign-out, or a switch to another account). Bumping the
+ /// generation makes any in-flight GET discard its own result, so a response
+ /// for the previous account can't repopulate the cache afterwards.
+ ///
+ public void Reset()
+ {
+ _generation++;
+ _inFlight = null;
+ IsLoading = false;
+ LoadError = null;
+ Status = null;
+ }
+}
diff --git a/InterlinedList/ViewModels/AiNotice.cs b/InterlinedList/ViewModels/AiNotice.cs
new file mode 100644
index 0000000..ff4e016
--- /dev/null
+++ b/InterlinedList/ViewModels/AiNotice.cs
@@ -0,0 +1,111 @@
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// How an should read and look. Kept distinct from
+/// because several codes collapse to the same
+/// treatment while two of them — and
+/// — must stay visibly different from each other and
+/// from a generic failure. That's the whole point of #10's last two acceptance
+/// criteria.
+///
+public enum AiNoticeKind
+{
+ /// Neutral progress/result copy ("Draft ready — review it below").
+ Info,
+
+ /// The user's input needs changing before it's worth spending a unit. Actionable, their side.
+ Input,
+
+ /// 429 quota_exceeded — today's 50 are gone. Comes back tomorrow, not sooner.
+ QuotaExceeded,
+
+ /// 429 rate_limited — the 15/60s window tripped. Comes back in seconds.
+ RateLimited,
+
+ /// 422 invalid_ai_output / refused — the model answered unusably. A unit was still spent.
+ ModelDeclined,
+
+ /// Everything else: provider_error, 401, an unknown code, a transport failure.
+ Error
+}
+
+///
+/// One non-blocking, in-place message from an AI call. Rendered as a line of
+/// text inside the panel that raised it — never a MessageBox, never
+/// anything that blocks the dispatcher (#10).
+///
+/// exists because this API's quota accounting is
+/// asymmetric: an input-validation rejection costs nothing (verified live —
+/// usedToday was unchanged across four of them), but any call that
+/// reaches the model costs one of the 50 even when it fails. A user who just
+/// lost a unit to invalid_ai_output deserves to be told so, and told to
+/// re-word rather than mash the button.
+///
+public sealed record AiNotice(AiNoticeKind Kind, string Text, bool Spent = false)
+{
+ /// True for the states worth an amber (live/pending) treatment rather than red.
+ public bool IsTransient => Kind is AiNoticeKind.RateLimited or AiNoticeKind.QuotaExceeded or AiNoticeKind.ModelDeclined;
+
+ public bool IsQuotaExceeded => Kind == AiNoticeKind.QuotaExceeded;
+ public bool IsRateLimited => Kind == AiNoticeKind.RateLimited;
+
+ public static AiNotice Info(string text) => new(AiNoticeKind.Info, text);
+
+ /// A client-side pre-flight rejection. No unit spent — that's the reason pre-flight exists.
+ public static AiNotice Input(string text) => new(AiNoticeKind.Input, text, Spent: false);
+
+ ///
+ /// Map a failed AI call onto its message. Branches on
+ /// only — never on the server's prose —
+ /// except for invalid_input, where the server's wording is specific
+ /// and better than anything generic ("List not found.", "A list must be
+ /// selected.", "Only http(s) URLs are supported." — all verified live).
+ ///
+ public static AiNotice From(AiApiException ex)
+ {
+ ArgumentNullException.ThrowIfNull(ex);
+
+ return ex.Code switch
+ {
+ AiErrorCode.QuotaExceeded => new(
+ AiNoticeKind.QuotaExceeded,
+ $"Today's AI allowance is used up ({AiFeatureLimits.DailyGenerationLimit} per rolling 24 hours). " +
+ "It frees up again as the oldest of today's requests ages out — try again tomorrow.",
+ Spent: false),
+
+ AiErrorCode.RateLimited => new(
+ AiNoticeKind.RateLimited,
+ ex.RetryAfter is { } wait
+ ? $"Too many AI requests in the last minute. Try again in {Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds))} seconds."
+ : $"Too many AI requests in the last minute (the limit is {AiFeatureLimits.RateLimitRequestsPerMinute}). Wait a moment and try again.",
+ Spent: false),
+
+ // Both of these got as far as the model, so a unit is gone. Say so:
+ // it's the difference between "try again" and "try again, but
+ // change something first".
+ AiErrorCode.InvalidAiOutput => new(
+ AiNoticeKind.ModelDeclined,
+ "The AI's answer came back in a shape this app couldn't use. That attempt still counted against today's allowance — re-word the request before trying again.",
+ Spent: true),
+
+ AiErrorCode.Refused => new(
+ AiNoticeKind.ModelDeclined,
+ "The AI declined to answer that. That attempt still counted against today's allowance — try different wording.",
+ Spent: true),
+
+ // The server's own message is the useful one here, and a local
+ // pre-flight rejection reuses the same code with nothing spent.
+ AiErrorCode.InvalidInput => new(AiNoticeKind.Input, ex.UserMessage, Spent: false),
+
+ AiErrorCode.ProviderError => new(
+ AiNoticeKind.Error,
+ "The AI provider didn't answer. That attempt may still have counted against today's allowance — give it a minute before trying again.",
+ Spent: true),
+
+ _ => new(AiNoticeKind.Error, ex.UserMessage, Spent: !ex.IsLocal)
+ };
+ }
+}
diff --git a/InterlinedList/ViewModels/AiPanelViewModelBase.cs b/InterlinedList/ViewModels/AiPanelViewModelBase.cs
new file mode 100644
index 0000000..c999d5c
--- /dev/null
+++ b/InterlinedList/ViewModels/AiPanelViewModelBase.cs
@@ -0,0 +1,242 @@
+using System.ComponentModel;
+using System.Net.Http;
+using CommunityToolkit.Mvvm.ComponentModel;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// What every AI panel in this app shares: the one availability gate, the
+/// remaining-quota line, a single in-place instead of any
+/// dialog, and one funnel that all /api/ai/* traffic goes through.
+///
+/// The funnel is the point. is the only place
+/// AI calls are made from, which is what makes three otherwise easy-to-forget
+/// rules structural rather than aspirational:
+///
+///
+/// - It never retries. A failed call that reached the model already
+/// spent one of the 50 daily generations, so an automatic retry silently
+/// doubles the bill. The user re-asks, or nobody does.
+/// - It refuses to start at all when is false,
+/// so a stale bound button can't fire a call that would answer
+/// 403 subscription_required or 409 no_provider_configured.
+/// - It folds the echoed quota back into
+/// on success and marks the
+/// allowance gone on quota_exceeded, so the chip never disagrees with
+/// the message next to it.
+///
+///
+/// Subclasses do the feature-specific pre-flight (word caps, required source
+/// references) before calling in — every rejection caught there is a quota unit
+/// that was never spent, which is the entire reason the caps are mirrored
+/// client-side.
+///
+public abstract partial class AiPanelViewModelBase : ObservableObject
+{
+ protected SessionService Session { get; }
+
+ /// The shared gate. Views bind visibility to Availability.IsAiAvailable.
+ public AiAvailabilityService Availability { get; }
+
+ protected AiPanelViewModelBase(SessionService session, AiAvailabilityService? availability = null)
+ {
+ Session = session ?? throw new ArgumentNullException(nameof(session));
+ Availability = availability ?? AiAvailabilityService.Shared;
+
+ // Re-raise the gate and the quota line as our own properties so a panel's
+ // XAML can bind them without reaching through two DataContext levels.
+ Availability.PropertyChanged += OnAvailabilityChanged;
+ }
+
+ private void OnAvailabilityChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ switch (e.PropertyName)
+ {
+ case nameof(AiAvailabilityService.Status):
+ case nameof(AiAvailabilityService.LoadError):
+ OnPropertyChanged(nameof(IsAiAvailable));
+ OnPropertyChanged(nameof(QuotaLabel));
+ OnPropertyChanged(nameof(IsQuotaExhausted));
+ OnPropertyChanged(nameof(CanRunAi));
+ NotifyAiCommandsChanged();
+ break;
+ }
+ }
+
+ /// The gate — see .
+ public bool IsAiAvailable => Availability.IsAiAvailable;
+
+ /// "45 of 50 left today".
+ public string QuotaLabel => Availability.QuotaLabel;
+
+ public bool IsQuotaExhausted => Availability.IsQuotaExhausted;
+
+ ///
+ /// True when a call is worth attempting: available, nothing already in
+ /// flight, and today's allowance isn't spent. Subclasses AND this with their
+ /// own input checks in their commands' CanExecute.
+ ///
+ public bool CanRunAi => IsAiAvailable && !IsBusy && !IsQuotaExhausted;
+
+ /// A /suggest or /generate is in flight. Drives the amber pending state.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanRunAi))]
+ private bool isBusy;
+
+ /// What the panel is doing right now, for the pending line ("Drafting your list…").
+ [ObservableProperty]
+ private string? busyText;
+
+ ///
+ /// The single in-place message slot. Non-blocking by construction: it's a
+ /// bound string, not a dialog.
+ ///
+ [ObservableProperty]
+ private AiNotice? notice;
+
+ partial void OnIsBusyChanged(bool value) => NotifyAiCommandsChanged();
+
+ ///
+ /// Raise CanExecuteChanged for whichever commands depend on
+ /// . Subclasses override because the generated
+ /// …Command properties only exist on them.
+ ///
+ protected virtual void NotifyAiCommandsChanged() { }
+
+ /// Called from a view's constructor; safe to call repeatedly.
+ public Task EnsureStatusLoadedAsync() => Availability.EnsureLoadedAsync();
+
+ protected void ClearNotice() => Notice = null;
+
+ ///
+ /// Fail a request locally, with the same code the server would have used,
+ /// without spending a unit. This is what a pre-flight rejection looks like.
+ ///
+ protected void RejectLocally(string message) => Notice = AiNotice.Input(message);
+
+ ///
+ /// Run one AI call. Returns null on any failure, having already written the
+ /// right in-place . Does not retry, ever.
+ ///
+ /// Pending copy while it runs.
+ /// The single /api/ai/* call to make.
+ ///
+ /// Pulls the echoed quota out of the result so the chip can count down.
+ /// Note the echoed object has no remaining field — that's handled in
+ /// .
+ ///
+ protected async Task RunAiAsync(
+ string busyText,
+ Func> call,
+ Func? quotaOf = null,
+ CancellationToken ct = default)
+ where T : class
+ {
+ ArgumentNullException.ThrowIfNull(call);
+
+ // Rule 2: a stale binding must not be able to spend anything.
+ if (!IsAiAvailable)
+ {
+ Notice = new AiNotice(AiNoticeKind.Error,
+ Availability.UnavailableReason ?? "AI isn't available on this account.");
+ return null;
+ }
+
+ if (IsBusy) return null;
+
+ IsBusy = true;
+ BusyText = busyText;
+ Notice = null;
+
+ try
+ {
+ var result = await call(ct);
+
+ if (quotaOf is not null)
+ Availability.ApplyQuota(quotaOf(result));
+
+ return result;
+ }
+ catch (AiApiException ex)
+ {
+ Notice = AiNotice.From(ex);
+
+ // Keep the chip honest: a 429 quota_exceeded is the server saying
+ // the allowance is gone regardless of what we had cached.
+ if (ex.Code == AiErrorCode.QuotaExceeded)
+ Availability.MarkQuotaExhausted();
+
+ // A 401 means the token died mid-session; the gate should stop
+ // claiming AI is available until it's re-checked.
+ if (ex.Code == AiErrorCode.Unauthorized)
+ Availability.Reset();
+
+ AppLog.Warn($"AI call failed ({ex.StatusCode} {ex.RawCode}): {ex.Message}");
+ return null;
+ }
+ catch (OperationCanceledException)
+ {
+ // A cancelled call may or may not have reached the model — say
+ // nothing rather than guess.
+ return null;
+ }
+ catch (HttpRequestException ex)
+ {
+ Notice = new AiNotice(AiNoticeKind.Error, "Couldn't reach InterlinedList. Check your connection and try again.");
+ AppLog.Warn($"AI call transport failure: {ex.Message}");
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Notice = new AiNotice(AiNoticeKind.Error, "The AI request failed unexpectedly.");
+ AppLog.Error("Unexpected failure during an AI call.", ex);
+ return null;
+ }
+ finally
+ {
+ IsBusy = false;
+ BusyText = null;
+ }
+ }
+
+ ///
+ /// Run a plain (non-AI) follow-up call — the read-after-write GET that
+ /// confirms what /generate actually created. Separate from
+ /// because it spends no quota and throws the
+ /// ordinary exception type.
+ ///
+ protected async Task RunApiAsync(string busyText, Func> call, CancellationToken ct = default)
+ where T : class
+ {
+ ArgumentNullException.ThrowIfNull(call);
+
+ IsBusy = true;
+ BusyText = busyText;
+ try
+ {
+ return await call(ct);
+ }
+ catch (InterlinedApiException ex)
+ {
+ Notice = new AiNotice(AiNoticeKind.Error, ex.Message);
+ return null;
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Notice = new AiNotice(AiNoticeKind.Error, "The request failed unexpectedly.");
+ AppLog.Error("Unexpected failure during an AI follow-up call.", ex);
+ return null;
+ }
+ finally
+ {
+ IsBusy = false;
+ BusyText = null;
+ }
+ }
+}
diff --git a/InterlinedList/Views/AiConverters.cs b/InterlinedList/Views/AiConverters.cs
new file mode 100644
index 0000000..b97dfdd
--- /dev/null
+++ b/InterlinedList/Views/AiConverters.cs
@@ -0,0 +1,81 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+using System.Windows.Media;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// Visible when the bound object is non-null. The AI panels use one nullable
+/// / artifact slot each, so "is something there" is the
+/// visibility question over and over.
+///
+public sealed class NotNullToVisibilityConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is null ? Visibility.Collapsed : Visibility.Visible;
+
+ public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+/// Collapsed when the bound bool is true (the inverse of BooleanToVisibilityConverter).
+public sealed class InverseBoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is true ? Visibility.Collapsed : Visibility.Visible;
+
+ public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+///
+/// An to its Strata colour. Amber #F0A830 — the
+/// brand's live/pending accent — covers the three "come back and try again"
+/// states, because they are states, not errors: the request was well-formed and
+/// the app is working correctly. Only a genuine failure gets the error red, and
+/// input problems get plain body text since they're just instructions.
+///
+/// Returns a resource key rather than a literal so both themes' brushes apply;
+/// the two fixed hexes are the pair already used for error/amber text
+/// throughout the app (see the ErrorBanner styles in Lists/Documents).
+///
+public sealed class AiNoticeBrushConverter : IValueConverter
+{
+ private static readonly SolidColorBrush ErrorBrush = new(Color.FromRgb(0xE8, 0x11, 0x23));
+ private static readonly SolidColorBrush AmberBrush = new(Color.FromRgb(0xF0, 0xA8, 0x30));
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch
+ {
+ AiNoticeKind.QuotaExceeded or AiNoticeKind.RateLimited or AiNoticeKind.ModelDeclined => AmberBrush,
+ AiNoticeKind.Error => ErrorBrush,
+ // Input and Info read as guidance; let them inherit the body colour via
+ // the app's muted text brush, resolved by the caller's DynamicResource.
+ _ => Application.Current?.TryFindResource("TextBodyBrush") as Brush ?? (Brush)AmberBrush
+ };
+
+ public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+///
+/// An to the short label that prefixes the message,
+/// so quota_exceeded and rate_limited are told apart at a glance
+/// rather than reading as the same red failure (#10).
+///
+public sealed class AiNoticeLabelConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch
+ {
+ AiNoticeKind.QuotaExceeded => "DAILY LIMIT",
+ AiNoticeKind.RateLimited => "SLOW DOWN",
+ AiNoticeKind.ModelDeclined => "TRY DIFFERENT WORDING",
+ AiNoticeKind.Input => "CHECK THIS",
+ AiNoticeKind.Error => "FAILED",
+ _ => "AI"
+ };
+
+ public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
diff --git a/InterlinedList/Views/AiNoticeBar.xaml b/InterlinedList/Views/AiNoticeBar.xaml
new file mode 100644
index 0000000..3c18dd6
--- /dev/null
+++ b/InterlinedList/Views/AiNoticeBar.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/AiNoticeBar.xaml.cs b/InterlinedList/Views/AiNoticeBar.xaml.cs
new file mode 100644
index 0000000..a65c12d
--- /dev/null
+++ b/InterlinedList/Views/AiNoticeBar.xaml.cs
@@ -0,0 +1,32 @@
+using System.Windows;
+using System.Windows.Controls;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// Renders one in place. Hosts pass the notice in and
+/// need nothing else:
+///
+///
+/// <local:AiNoticeBar Notice="{Binding Notice}"/>
+///
+///
+/// A null notice collapses the control, so a host never needs its own
+/// visibility rule. Nothing here blocks the dispatcher — that's the whole
+/// reason AI messages are a control rather than a dialog (#10).
+///
+public partial class AiNoticeBar : UserControl
+{
+ public static readonly DependencyProperty NoticeProperty =
+ DependencyProperty.Register(nameof(Notice), typeof(AiNotice), typeof(AiNoticeBar), new PropertyMetadata(null));
+
+ public AiNoticeBar() => InitializeComponent();
+
+ /// The message to show, or null to show nothing.
+ public AiNotice? Notice
+ {
+ get => (AiNotice?)GetValue(NoticeProperty);
+ set => SetValue(NoticeProperty, value);
+ }
+}
diff --git a/InterlinedList/Views/AiQuotaChip.xaml b/InterlinedList/Views/AiQuotaChip.xaml
new file mode 100644
index 0000000..11f0825
--- /dev/null
+++ b/InterlinedList/Views/AiQuotaChip.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/AiQuotaChip.xaml.cs b/InterlinedList/Views/AiQuotaChip.xaml.cs
new file mode 100644
index 0000000..728eed1
--- /dev/null
+++ b/InterlinedList/Views/AiQuotaChip.xaml.cs
@@ -0,0 +1,37 @@
+using System.Windows.Controls;
+using InterlinedList.Services;
+
+namespace InterlinedList.Views;
+
+///
+/// The remaining-daily-quota chip #10 asks for wherever an AI action is
+/// offered. Drop it in with no attributes and no code-behind in the host:
+///
+///
+/// <local:AiQuotaChip/>
+///
+///
+/// It reads directly rather than
+/// taking a dependency property, because there is exactly one answer per
+/// session and every instance wants the same one. It also hides itself when the
+/// gate is closed, so a host never needs its own visibility binding for it.
+///
+/// It binds QuotaLabel, which is derived from
+/// — not from
+/// AiQuota.Remaining. That matters: /api/ai/status sends
+/// remaining but /api/ai/suggest does not (verified live), so
+/// after a suggestion the raw field is null and only the computed one is right.
+///
+public partial class AiQuotaChip : UserControl
+{
+ public AiQuotaChip()
+ {
+ InitializeComponent();
+ DataContext = AiAvailabilityService.Shared;
+
+ // Cheap safety net: if this chip is the first AI surface a user reaches,
+ // make sure the once-per-session status fetch has been kicked off.
+ // EnsureLoadedAsync is idempotent and single-flighted.
+ _ = AiAvailabilityService.Shared.EnsureLoadedAsync();
+ }
+}